tls_plugin.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. from datetime import datetime
  7. from typing import List, Dict, Optional, Any
  8. from urllib.parse import urljoin, urlparse
  9. from curl_cffi import requests, const
  10. from bs4 import BeautifulSoup
  11. from vs_plg import IVSPlg
  12. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  13. from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN
  14. from toolkit.vs_cloud_api import VSCloudApi
  15. class TlsPlugin(IVSPlg):
  16. """
  17. TLSContact 签证预约插件
  18. 适配法国签证 (FR) 流程
  19. """
  20. def __init__(self, group_id: str):
  21. self.group_id = group_id
  22. self.config: Optional[VSPlgConfig] = None
  23. self.free_config: Dict[str, Any] = {}
  24. self.is_healthy = True
  25. # 会话相关
  26. self.session: Optional[requests.Session] = None
  27. self.travel_group: Optional[Dict] = None
  28. self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
  29. def get_group_id(self) -> str:
  30. return self.group_id
  31. def set_config(self, config: VSPlgConfig):
  32. self.config = config
  33. try:
  34. self.free_config = json.loads(config.free_config) if config.free_config else {}
  35. except:
  36. self.free_config = {}
  37. def health_check(self) -> bool:
  38. return self.is_healthy
  39. def _save_debug_html(self, content: str, prefix: str = "debug"):
  40. save_dir = "debug_pages"
  41. if not os.path.exists(save_dir):
  42. os.makedirs(save_dir)
  43. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  44. filename = f"{save_dir}/{prefix}_{self.group_id}_{timestamp}.html"
  45. with open(filename, "w", encoding="utf-8") as f:
  46. f.write(content)
  47. VSC_INFO("tls_plg", "[%s] HTML saved to: %s", self.group_id, filename)
  48. def _get_proxy_url(self):
  49. # 构造代理
  50. proxy_url = ""
  51. if self.config.proxy.ip:
  52. s = self.config.proxy
  53. if s.username:
  54. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  55. else:
  56. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  57. return proxy_url
  58. def create_session(self):
  59. """
  60. 创建会话:处理 Cloudflare -> 登录 -> 获取 Travel Group
  61. """
  62. # 1. 初始化 Session
  63. curlopt = {
  64. const.CurlOpt.MAXAGE_CONN: 1800,
  65. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  66. const.CurlOpt.VERBOSE: False,
  67. }
  68. self.session = requests.Session(
  69. proxy=self._get_proxy_url(),
  70. impersonate="chrome124",
  71. curl_options=curlopt,
  72. use_thread_local_curl=False,
  73. http_version=const.CurlHttpVersion.V2TLS
  74. )
  75. embassy = self.free_config.get('center', {})
  76. if not embassy:
  77. raise NotFoundError(message="center not found in free config")
  78. # 2. 解决 Cloudflare 5s 盾
  79. self._solve_cloudflare5S_challenge()
  80. # 3. 获取登录页面参数 (OIDC)
  81. login_page = "https://visas-fr.tlscontact.com/en-us/login"
  82. params = {
  83. "issuerId": embassy["code"],
  84. "country": embassy["country"],
  85. "vac": embassy["code"],
  86. "redirect": f"/en-us/country/{embassy['country']}/vac/{embassy['code']}"
  87. }
  88. headers = {
  89. 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  90. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
  91. 'Referer': f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}/vac/{embassy["code"]}',
  92. 'User-Agent': self.user_agent,
  93. }
  94. resp = self._perform_request("GET", login_page, headers=headers, params=params)
  95. self._save_debug_html(resp.text, 'Login_Page')
  96. # 解析 Keycloak 登录地址
  97. soup = BeautifulSoup(resp.text, 'html.parser')
  98. form = soup.find('form')
  99. if not form:
  100. raise NotFoundError(message="Login form not found")
  101. action = form.get('action')
  102. authenticate_url = action if action.startswith('http') else urljoin(resp.url, action)
  103. # 4. 解决 ReCaptcha V2 (登录验证码)
  104. api_token = self.free_config.get("capsolver_key", "")
  105. if not api_token:
  106. raise NotFoundError(message="Missing 'capsolver_key' in free_config, captcha might fail.")
  107. rc_params = {
  108. "type": "ReCaptchaV2TaskProxyLess",
  109. "page": authenticate_url,
  110. "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
  111. "apiToken": api_token,
  112. "proxy": self._get_proxy_url()
  113. }
  114. g_token = self._solve_recaptcha(rc_params)
  115. # 5. 提交登录
  116. payload = {
  117. 'username': self.config.account.username,
  118. 'password': self.config.account.password,
  119. 'g-recaptcha-response': g_token
  120. }
  121. headers['Content-Type'] = 'application/x-www-form-urlencoded'
  122. resp = self._perform_request("POST", authenticate_url, headers=headers, data=payload)
  123. self._save_debug_html(resp.text, 'Travel_Groups_Page')
  124. # 6. 解析 Travel Groups
  125. groups = self._parse_travel_groups(resp.text)
  126. # 选择匹配城市的 Group
  127. target_city = embassy['city'].lower()
  128. for g in groups:
  129. if g['location'].lower() == target_city:
  130. self.travel_group = g
  131. break
  132. if not self.travel_group:
  133. raise NotFoundError(message=f"No group found for city {target_city}")
  134. VSC_INFO("tls_plg", "[%s] Session created. Group: %s", self.group_id, self.travel_group['group_number'])
  135. def query(self) -> VSQueryResult:
  136. res = VSQueryResult()
  137. res.success = False
  138. embassy = self.free_config.get('center', {})
  139. group_num = self.travel_group['group_number']
  140. interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
  141. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  142. params = {
  143. 'location': embassy["code"],
  144. 'month': interest_month,
  145. }
  146. headers = {
  147. 'accept': '*/*',
  148. 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
  149. 'referer': f'{url}?location={embassy["code"]}',
  150. 'user-agent': self.user_agent,
  151. }
  152. resp = self._perform_request("GET", url, headers=headers, params=params)
  153. self._save_debug_html(resp.text, 'Query_Slot_Page')
  154. self._check_session_expired_page(resp.text)
  155. # 检测关键词
  156. if not "availableAppointments" in resp.text:
  157. raise NotFoundError(message='Query result not found availableAppointments')
  158. # 3. 解析 Slots
  159. all_slots = self._parse_appointment_slots(resp.text)
  160. target_labels = self.free_config.get("target_labels", ["", "pta"])
  161. available = [s for s in all_slots if s.get("label") in target_labels]
  162. res.city = self.free_config.get('city', '')
  163. res.country = self.free_config.get('country', '')
  164. res.visa_type = self.free_config.get('visa_type', '')
  165. res.routing_key = self.free_config.get('routing_key', '')
  166. if available:
  167. res.success = True
  168. res.availability_status = AvailabilityStatus.Available
  169. res.earliest_date = available[0]['date']
  170. date_map = {}
  171. for s in available:
  172. d = s['date']
  173. date_map.setdefault(d, [])
  174. ts = VSQueryResult.DateAvailability.TimeSlot()
  175. ts.time = s['time']
  176. ts.label = f"{s['type']}"
  177. date_map[d].append(ts)
  178. for d, slots in date_map.items():
  179. da = VSQueryResult.DateAvailability()
  180. da.date = d
  181. da.times = slots
  182. res.availability.append(da)
  183. else:
  184. res.success = False
  185. res.availability_status = AvailabilityStatus.NoneAvailable
  186. return res
  187. def book(self, slot_info: VSQueryResult, user_input: Dict = None) -> VSBookResult:
  188. res = VSBookResult()
  189. res.success = False
  190. target_date = slot_info.availability[0].date
  191. target_time = slot_info.availability[0].times[0].time
  192. target_label = ""
  193. embassy = self.free_config.get('center', {})
  194. group_num = self.travel_group['group_number']
  195. interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
  196. # 1. 解决 ReCaptcha V3
  197. page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={embassy["code"]}&month={interest_month}'
  198. api_token = self.free_config.get("capsolver_key", "")
  199. rc_params = {
  200. "type": "ReCaptchaV3Task",
  201. "page": page_url,
  202. "action": "book",
  203. "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  204. "apiToken": api_token,
  205. "proxy": self._get_proxy_url()
  206. }
  207. g_token = self._solve_recaptcha(rc_params)
  208. # 2. 构造请求
  209. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  210. next_action = '601f284bf7ee33b6578ad0fad426fae18c232707f2'
  211. next_state = '%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22$GROUPID$%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
  212. headers = {
  213. 'Next-Action': next_action,
  214. 'Referer': page_url,
  215. 'Next-Router-State-Tree': next_state.replace("$GROUPID$", group_num),
  216. 'Accept': 'text/x-component',
  217. 'User-Agent': self.user_agent,
  218. }
  219. params = {
  220. 'location': embassy["code"],
  221. 'month': interest_month,
  222. }
  223. boundary = "----WebKitFormBoundary" + "".join(
  224. random.choices("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=16)
  225. )
  226. headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
  227. form_fields = {
  228. '1_formGroupId': str(group_num),
  229. '1_lang': 'en-us',
  230. '1_process': 'APPOINTMENT',
  231. '1_location': embassy["code"],
  232. '1_date': target_date,
  233. '1_time': target_time,
  234. '1_appointmentLabel': target_label,
  235. '1_captcha_token': g_token,
  236. '0': '[{"status":"IDLE"},"$K1"]'
  237. }
  238. body_parts = []
  239. for name, value in form_fields.items():
  240. body_parts.append(f"--{boundary}\r\n")
  241. body_parts.append(f'Content-Disposition: form-data; name="{name}"\r\n')
  242. body_parts.append("\r\n")
  243. body_parts.append(f"{value}\r\n")
  244. body_parts.append(f"--{boundary}--\r\n")
  245. body = "".join(body_parts).encode("utf-8")
  246. resp = self.session.post(url, params=params, headers=headers, data=body, allow_redirects=False)
  247. self._save_debug_html(resp.text, 'Book_Appointment_Page')
  248. if resp.status_code == 303:
  249. res.success = True
  250. res.book_date = target_date
  251. res.book_time = target_time
  252. return res
  253. else:
  254. VSC_WARN('tls_plg', 'Expected Status is 303, but got {resp.status_code}')
  255. res.success = False
  256. return res
  257. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  258. """
  259. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  260. 1. 发送 OPTIONS 请求
  261. 2. 发送实际请求
  262. """
  263. print(f'[perform request] {method} {url} {data} {json_data} {params}')
  264. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  265. VSC_INFO('tls_plg', resp.text)
  266. if resp.status_code == 200:
  267. return resp
  268. elif resp.status_code == 401:
  269. self.is_healthy = False
  270. raise SessionExpiredOrInvalidError()
  271. elif resp.status_code == 403:
  272. raise PermissionDeniedError()
  273. elif resp.status_code == 429:
  274. self.is_healthy = False
  275. raise RateLimiteddError()
  276. else:
  277. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  278. def _solve_cloudflare5S_challenge(self):
  279. """
  280. 解决 Cloudflare 5s 盾
  281. """
  282. VSC_INFO("tls_plg", f"[{self.group_id}] Solving Cloudflare 5s...")
  283. embassy = self.free_config.get('center', {})
  284. website_url = f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}'
  285. # 1. 格式化代理字符串, 这里的接口要求格式通常是: host:port:user:pass (根据你的脚本示例)
  286. p = self.config.proxy
  287. if p.username:
  288. proxy_str = f"{p.ip}:{p.port}:{p.username}:{p.password}"
  289. else:
  290. proxy_str = f"{p.ip}:{p.port}"
  291. # 2. 提交任务
  292. task = VSCloudApi.Instance().submit_anticloudflare_task(proxy_str, website_url)
  293. # 3. 等待结果
  294. task_id = str(task['id'])
  295. result = VSCloudApi.Instance().get_anticloudflare_result(task_id)
  296. parsed = json.loads(result.get('result', '{}'))
  297. cookies_list = parsed.get('cookies', [])
  298. for cookie in cookies_list:
  299. if cookie['name'] in ['__cf_bm', 'cf_clearance']:
  300. self.session.cookies.set(
  301. cookie['name'],
  302. cookie['value'],
  303. domain=cookie['domain'],
  304. path='/'
  305. )
  306. ua = parsed.get('userAgent')
  307. if ua:
  308. self.user_agent = ua
  309. self.session.headers['User-Agent'] = ua
  310. VSC_INFO("tls_plg", "[%s] Cloudflare 5s challenge solved.", self.group_id)
  311. def _solve_recaptcha(self, params) -> str:
  312. """
  313. 调用 Capsolver
  314. """
  315. key = params.get("apiToken")
  316. if not key:
  317. raise NotFoundError(message="Api-token is required for recaptcha solver")
  318. submit_url = "https://api.capsolver.com/createTask"
  319. task = {
  320. "type": params.get("type"),
  321. "websiteURL": params.get("page"),
  322. "websiteKey": params.get("siteKey"),
  323. }
  324. if params.get("action"):
  325. task["pageAction"] = params.get("action")
  326. if params.get("proxy"):
  327. p = urlparse(params.get("proxy"))
  328. task["proxyType"] = p.scheme
  329. task["proxyAddress"] = p.hostname
  330. task["proxyPort"] = p.port
  331. if p.username:
  332. task["proxyLogin"] = p.username
  333. task["proxyPassword"] = p.password
  334. payload = {"clientKey": key, "task": task}
  335. r = requests.post(submit_url, json=payload, timeout=20)
  336. if r.status_code != 200:
  337. raise BizLogicError(message="Failed to submit capsolver task")
  338. task_id = r.json().get("taskId")
  339. for _ in range(20):
  340. r = requests.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": task_id}, timeout=20)
  341. if r.status_code == 200:
  342. d = r.json()
  343. if d.get("status") == "ready":
  344. return d["solution"]["gRecaptchaResponse"]
  345. time.sleep(3)
  346. raise BizLogicError(message="Capsolver task timeout")
  347. def _parse_travel_groups(self, html: str) -> List[Dict]:
  348. groups = []
  349. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  350. js_match = re.search(js_pattern, html, re.DOTALL)
  351. if js_match:
  352. json_str = js_match.group(1).replace(r'\"', '"')
  353. data = json.loads(json_str)
  354. for g in data:
  355. groups.append({
  356. 'group_name': g.get('groupName'),
  357. 'group_number': g.get('formGroupId'),
  358. 'location': g.get('vacName')
  359. })
  360. else:
  361. VSC_WARN('tls_plg', 'Parsed travel group page, but not found travelGroups')
  362. return groups
  363. def _parse_appointment_slots(self, html: str) -> List[Dict]:
  364. slots = []
  365. pattern = r'availableAppointments\\?":\s*(\[.*?\])(?:,\\?"|\},)'
  366. match = re.search(pattern, html, re.DOTALL)
  367. if match:
  368. json_str = match.group(1).replace(r'\"', '"')
  369. data = json.loads(json_str)
  370. for day in data:
  371. d_str = day.get('day')
  372. for s in day.get('slots', []):
  373. labels = s.get('labels', [])
  374. lbl = ""
  375. stype = ""
  376. cost = ""
  377. if 'pta' in labels:
  378. lbl = 'pta'
  379. stype = "Prime"
  380. elif 'ptaw' in labels:
  381. lbl = 'ptaw'
  382. stype = "Prime Weekend"
  383. elif '' in labels:
  384. lbl = ''
  385. stype = "Standard"
  386. if lbl or not labels:
  387. slots.append({
  388. 'date': d_str,
  389. 'time': s.get('time'),
  390. 'label': lbl,
  391. 'type': stype,
  392. 'cost': cost
  393. })
  394. else:
  395. VSC_WARN("tls_plg", 'Parsed appointment slots page, but not found availableAppointments')
  396. return slots
  397. def _check_session_expired_page(self, html: str) -> bool:
  398. if not html:
  399. self.is_healthy = False
  400. raise SessionExpiredOrInvalidError()
  401. if 'availableAppointments' not in html:
  402. if 'redirected automatically' in html.lower():
  403. self.is_healthy = False
  404. raise SessionExpiredOrInvalidError()
  405. if 'login' in html.lower() and 'password' in html.lower():
  406. self.is_healthy = False
  407. raise SessionExpiredOrInvalidError()
  408. if 'session expired!' in html.lower() and 'for security reasons, your session has expired. please log in again to continue.' in html.lower() and 'you will be redirected automatically in 10 seconds.' in html.lower():
  409. self.is_healthy = False
  410. raise SessionExpiredOrInvalidError()