tls_plugin.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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, Callable
  8. from urllib.parse import urljoin, urlparse
  9. from requests_toolbelt import MultipartEncoder
  10. from curl_cffi import requests, const
  11. from bs4 import BeautifulSoup
  12. from vs_plg import IVSPlg
  13. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  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. self.logger = None
  26. # 会话相关
  27. self.session: Optional[requests.Session] = None
  28. self.travel_group: Optional[Dict] = None
  29. self.user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
  30. self.session_create_time: float = 0
  31. def get_group_id(self) -> str:
  32. return self.group_id
  33. def set_log(self, logger: Callable[[str], None]) -> None:
  34. self.logger = logger
  35. def set_config(self, config: VSPlgConfig):
  36. self.config = config
  37. self.free_config = config.free_config or {}
  38. def health_check(self) -> bool:
  39. if not self.is_healthy:
  40. return False
  41. if self.session is None:
  42. return False
  43. if self.config.session_max_life > 0:
  44. current_time = time.time()
  45. elapsed_time = current_time - self.session_create_time
  46. if elapsed_time > self.config.session_max_life * 60:
  47. self._log(f"Session Life ({int(elapsed_time)}s) out of max life limit ({self.config.session_max_life * 60}s), mark as unhealth session")
  48. return False
  49. return True
  50. def create_session(self):
  51. """
  52. 创建会话:处理 Cloudflare -> 登录 -> 获取 Travel Group
  53. """
  54. # 1. 初始化 Session
  55. curlopt = {
  56. const.CurlOpt.MAXAGE_CONN: 1800,
  57. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  58. const.CurlOpt.VERBOSE: self.config.debug,
  59. }
  60. self.session = requests.Session(
  61. proxy=self._get_proxy_url(),
  62. impersonate="chrome124",
  63. curl_options=curlopt,
  64. use_thread_local_curl=False,
  65. http_version=const.CurlHttpVersion.V2TLS
  66. )
  67. embassy = self.free_config.get('center', {})
  68. if not embassy:
  69. raise NotFoundError(message="center not found in free config")
  70. # 2. 解决 Cloudflare 5s 盾
  71. self._solve_cloudflare5S_challenge()
  72. # 3. 获取登录页面参数 (OIDC)
  73. login_page = "https://visas-fr.tlscontact.com/en-us/login"
  74. params = {
  75. "issuerId": embassy["code"],
  76. "country": embassy["country"],
  77. "vac": embassy["code"],
  78. "redirect": f"/en-us/country/{embassy['country']}/vac/{embassy['code']}"
  79. }
  80. headers = {
  81. 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  82. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
  83. 'Referer': f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}/vac/{embassy["code"]}',
  84. 'User-Agent': self.user_agent,
  85. }
  86. resp = self._perform_request("GET", login_page, headers=headers, params=params)
  87. if self.config.debug:
  88. self._save_debug_html(resp.text, prefix='Tls_Login_Page')
  89. # 解析 Keycloak 登录地址
  90. soup = BeautifulSoup(resp.text, 'html.parser')
  91. form = soup.find('form')
  92. if not form:
  93. raise NotFoundError(message="Login form not found")
  94. action = form.get('action')
  95. authenticate_url = action if action.startswith('http') else urljoin(resp.url, action)
  96. # 4. 解决 ReCaptcha V2 (登录验证码)
  97. api_token = self.free_config.get("capsolver_key", "")
  98. if not api_token:
  99. raise NotFoundError(message="Missing 'capsolver_key' in free_config, captcha might fail.")
  100. rc_params = {
  101. "type": "ReCaptchaV2TaskProxyLess",
  102. "page": authenticate_url,
  103. "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
  104. "apiToken": api_token,
  105. "proxy": self._get_proxy_url()
  106. }
  107. g_token = self._solve_recaptcha(rc_params)
  108. # 5. 提交登录
  109. payload = {
  110. 'username': self.config.account.username,
  111. 'password': self.config.account.password,
  112. 'g-recaptcha-response': g_token
  113. }
  114. headers['Content-Type'] = 'application/x-www-form-urlencoded'
  115. resp = self._perform_request("POST", authenticate_url, headers=headers, data=payload)
  116. if self.config.debug:
  117. self._save_debug_html(resp.text, prefix='Tls_Travel_Groups_Page')
  118. # 6. 解析 Travel Groups
  119. self._check_page_is_session_expired_or_invalid("My travel group", resp.text)
  120. groups = self._parse_travel_groups(resp.text)
  121. # 选择匹配城市的 Group
  122. target_city = embassy['city'].lower()
  123. for g in groups:
  124. if g['location'].lower() == target_city:
  125. self.travel_group = g
  126. break
  127. if not self.travel_group:
  128. raise NotFoundError(message=f"No matched group found for city {target_city}")
  129. self.session_create_time = time.time()
  130. self._log(f"Session created successfully. Group: {self.travel_group['group_number']}")
  131. def query(self) -> VSQueryResult:
  132. res = VSQueryResult()
  133. res.success = False
  134. embassy = self.free_config.get('center', {})
  135. group_num = self.travel_group['group_number']
  136. interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
  137. max_retries = self.free_config.get("max_retries", 2)
  138. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  139. params = {
  140. 'location': embassy["code"],
  141. 'month': interest_month,
  142. }
  143. headers = {
  144. 'accept': '*/*',
  145. 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
  146. 'referer': f'{url}?location={embassy["code"]}',
  147. 'user-agent': self.user_agent,
  148. }
  149. for attempt in range(1, max_retries + 1):
  150. try:
  151. resp = self._perform_request("GET", url, headers=headers, params=params)
  152. if self.config.debug:
  153. self._save_debug_html(resp.text, prefix='Tls_Query_Slot_Page')
  154. break # ✅ 请求成功,跳出重试循环
  155. except PermissionDeniedError:
  156. self._log(f"Query Appointment-booking blocked (403), attempt {attempt}/{max_retries}")
  157. # 最后一次就不再绕盾了
  158. if attempt >= max_retries:
  159. raise PermissionDeniedError()
  160. self._solve_cloudflare5S_challenge()
  161. self._log("Cloudflare bypass success, retrying...")
  162. continue
  163. self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
  164. # 3. 解析 Slots
  165. all_slots = self._parse_appointment_slots(resp.text)
  166. target_labels = self.free_config.get("target_labels", ["", "pta"])
  167. available = [s for s in all_slots if s.get("label") in target_labels]
  168. res.city = self.free_config.get('city', '')
  169. res.country = self.free_config.get('country', '')
  170. res.visa_type = self.free_config.get('visa_type', '')
  171. res.routing_key = self.free_config.get('routing_key', '')
  172. if available:
  173. res.success = True
  174. res.availability_status = AvailabilityStatus.Available
  175. res.earliest_date = available[0]["date"]
  176. date_map: dict[str, list[TimeSlot]] = {}
  177. for s in available:
  178. d = s["date"]
  179. date_map.setdefault(d, []).append(
  180. TimeSlot(
  181. time=s["time"],
  182. label=str(s.get("label", "")),
  183. )
  184. )
  185. res.availability = [
  186. DateAvailability(date=d, times=slots)
  187. for d, slots in date_map.items()
  188. ]
  189. else:
  190. res.success = False
  191. res.availability_status = AvailabilityStatus.NoneAvailable
  192. return res
  193. def book(self, slot_info: VSQueryResult, user_input: Dict = None) -> VSBookResult:
  194. res = VSBookResult()
  195. res.success = False
  196. # 1. 基础信息提取
  197. embassy = self.free_config.get('center', {})
  198. group_num = self.travel_group['group_number']
  199. target_slot = slot_info.availability[0]
  200. target_date = target_slot.date
  201. target_time = target_slot.times[0].time
  202. # [关键修正] Label 处理
  203. # 根据你的 dump,如果是 Prime Time,这里是 "pta"。
  204. # 如果是普通号,通常是 "regular" 或者空字符串。
  205. # 我们优先取 slot_info 里的 label,如果没有则默认为空
  206. raw_labels = getattr(target_slot.times[0], 'labels', [])
  207. if isinstance(raw_labels, list) and len(raw_labels) > 0:
  208. # 取第一个标签,例如 "pta" 或 "regular"
  209. target_label = raw_labels[0]
  210. else:
  211. target_label = "" # 或者试试 "regular"
  212. # 2. 解决 ReCaptcha V3
  213. # 动作必须是 "book"
  214. page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={embassy["code"]}&month={target_date[:7]}'
  215. api_token = self.free_config.get("capsolver_key", "")
  216. rc_params = {
  217. "type": "ReCaptchaV3Task",
  218. "page": page_url,
  219. "action": "book",
  220. "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  221. "apiToken": api_token,
  222. "proxy": self._get_proxy_url()
  223. }
  224. g_token = self._solve_recaptcha(rc_params)
  225. # 3. 构造 Payload (严格对齐你的 Curl Dump)
  226. # Next.js Server Action ID (从你的 header 确认)
  227. ACTION_ID = "60d0616946df1fc4e7c094ca6a7a04f134d0be3d53"
  228. fields = {
  229. '1_formGroupId': str(group_num), # 修正:加了 form 前缀
  230. '1_lang': 'en-us',
  231. '1_process': 'APPOINTMENT',
  232. '1_location': embassy["code"], # 例如 gbLON2fr
  233. '1_date': target_date,
  234. '1_time': target_time,
  235. '1_appointmentLabel': target_label, # 修正:单数 Label,值为字符串 "pta" 或 "regular"
  236. '1_captcha_token': g_token, # 修正:下划线格式
  237. '0': '[{"status":"IDLE"},"$K1"]' # 对应 Next.js Action 的状态位
  238. }
  239. m = MultipartEncoder(fields=fields)
  240. # 4. 发送请求
  241. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  242. headers = {
  243. 'Next-Action': ACTION_ID,
  244. 'Referer': page_url,
  245. 'Origin': 'https://visas-fr.tlscontact.com',
  246. 'Accept': 'text/x-component',
  247. 'User-Agent': self.user_agent, # 确保和 curl_cffi 的 impersonate 一致
  248. 'Content-Type': m.content_type,
  249. # 使用你 dump 里的 State Tree,虽然长,但最稳妥
  250. 'Next-Router-State-Tree': '%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'+str(group_num)+'%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'
  251. }
  252. # 必须使用 curl_cffi 模拟浏览器指纹
  253. resp = self.session.post(url, data=m.to_string(), headers=headers, allow_redirects=False)
  254. if self.config.debug:
  255. self._save_debug_html(resp.text, prefix='Tls_Book_Result')
  256. # 5. 结果判定
  257. if resp.status_code == 303:
  258. location = resp.headers.get('Location', '')
  259. self._log(f"Booking Success! Redirecting to: {location}")
  260. res.success = True
  261. res.book_date = target_date
  262. res.book_time = target_time
  263. return res
  264. elif resp.status_code == 200:
  265. # Next.js 有时会在 200 中返回业务错误
  266. if "APPOINTMENT_LIMIT_REACHED" in resp.text:
  267. self._log("Failed: 限制/无号")
  268. elif "Invalid captcha" in resp.text:
  269. self._log("Failed: 验证码错误")
  270. else:
  271. self._log(f"Booking Failed (200 OK but error content): {resp.text[:200]}")
  272. else:
  273. self._log(f'Booking Failed. Status: {resp.status_code}')
  274. return res
  275. def _log(self, message):
  276. if self.logger:
  277. self.logger(f'[TlsPlugin] [{self.group_id}] {message}')
  278. def _save_debug_html(self, content: str, prefix: str = "debug"):
  279. save_dir = "debug_pages"
  280. if not os.path.exists(save_dir):
  281. os.makedirs(save_dir)
  282. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  283. filename = f"{save_dir}/{prefix}_{timestamp}.html"
  284. with open(filename, "w", encoding="utf-8") as f:
  285. f.write(content)
  286. self._log(f"HTML saved to: {filename}")
  287. def _get_proxy_url(self):
  288. # 构造代理
  289. proxy_url = ""
  290. if self.config.proxy.ip:
  291. s = self.config.proxy
  292. if s.username:
  293. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  294. else:
  295. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  296. return proxy_url
  297. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  298. """
  299. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  300. 1. 发送 OPTIONS 请求
  301. 2. 发送实际请求
  302. """
  303. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  304. if self.config.debug:
  305. self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  306. if resp.status_code == 200:
  307. return resp
  308. elif resp.status_code == 401:
  309. self.is_healthy = False
  310. raise SessionExpiredOrInvalidError()
  311. elif resp.status_code == 403:
  312. raise PermissionDeniedError()
  313. elif resp.status_code == 429:
  314. self.is_healthy = False
  315. raise RateLimiteddError()
  316. else:
  317. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  318. def _solve_cloudflare5S_challenge(self):
  319. """
  320. 解决 Cloudflare 5s 盾
  321. """
  322. self._log(f"Solving Cloudflare 5s...")
  323. embassy = self.free_config.get('center', {})
  324. website_url = f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}'
  325. # 1. 格式化代理字符串, 这里的接口要求格式通常是: host:port:user:pass (根据你的脚本示例)
  326. p = self.config.proxy
  327. if p.username:
  328. proxy_str = f"{p.ip}:{p.port}:{p.username}:{p.password}"
  329. else:
  330. proxy_str = f"{p.ip}:{p.port}"
  331. # 2. 提交任务
  332. task = VSCloudApi.Instance().submit_anticloudflare_task(proxy_str, website_url)
  333. # 3. 等待结果
  334. task_id = str(task['id'])
  335. result = VSCloudApi.Instance().get_anticloudflare_result(task_id)
  336. parsed = json.loads(result.get('result', '{}'))
  337. cookies_list = parsed.get('cookies', [])
  338. for cookie in cookies_list:
  339. if cookie['name'] in ['__cf_bm', 'cf_clearance']:
  340. self.session.cookies.set(
  341. cookie['name'],
  342. cookie['value'],
  343. domain=cookie['domain'],
  344. path='/'
  345. )
  346. ua = parsed.get('userAgent')
  347. if ua:
  348. self.user_agent = ua
  349. self.session.headers['User-Agent'] = ua
  350. self._log("Cloudflare 5s challenge solved.")
  351. def _solve_recaptcha(self, params) -> str:
  352. """
  353. 调用 Capsolver
  354. """
  355. key = params.get("apiToken")
  356. if not key:
  357. raise NotFoundError(message="Api-token is required for recaptcha solver")
  358. submit_url = "https://api.capsolver.com/createTask"
  359. task = {
  360. "type": params.get("type"),
  361. "websiteURL": params.get("page"),
  362. "websiteKey": params.get("siteKey"),
  363. }
  364. if params.get("action"):
  365. task["pageAction"] = params.get("action")
  366. if params.get("proxy"):
  367. p = urlparse(params.get("proxy"))
  368. task["proxyType"] = p.scheme
  369. task["proxyAddress"] = p.hostname
  370. task["proxyPort"] = p.port
  371. if p.username:
  372. task["proxyLogin"] = p.username
  373. task["proxyPassword"] = p.password
  374. payload = {"clientKey": key, "task": task}
  375. r = requests.post(submit_url, json=payload, timeout=20)
  376. if r.status_code != 200:
  377. raise BizLogicError(message="Failed to submit capsolver task")
  378. task_id = r.json().get("taskId")
  379. for _ in range(20):
  380. r = requests.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": task_id}, timeout=20)
  381. if r.status_code == 200:
  382. d = r.json()
  383. if d.get("status") == "ready":
  384. return d["solution"]["gRecaptchaResponse"]
  385. time.sleep(3)
  386. raise BizLogicError(message="Capsolver task timeout")
  387. def _parse_travel_groups(self, html: str) -> List[Dict]:
  388. groups = []
  389. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  390. js_match = re.search(js_pattern, html, re.DOTALL)
  391. if js_match:
  392. json_str = js_match.group(1).replace(r'\"', '"')
  393. data = json.loads(json_str)
  394. for g in data:
  395. groups.append({
  396. 'group_name': g.get('groupName'),
  397. 'group_number': g.get('formGroupId'),
  398. 'location': g.get('vacName')
  399. })
  400. else:
  401. self._log('Parsed travel group page, but not found travelGroups')
  402. return groups
  403. def _parse_appointment_slots(self, html: str) -> List[Dict]:
  404. slots = []
  405. pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
  406. match = re.search(pattern, html, re.DOTALL)
  407. if match:
  408. json_str = match.group(1).replace(r'\"', '"')
  409. data = json.loads(json_str)
  410. for day in data:
  411. d_str = day.get('day')
  412. for s in day.get('slots', []):
  413. labels = s.get('labels', [])
  414. lbl = ""
  415. stype = ""
  416. cost = ""
  417. if 'pta' in labels:
  418. lbl = 'pta'
  419. stype = "Prime"
  420. elif 'ptaw' in labels:
  421. lbl = 'ptaw'
  422. stype = "Prime Weekend"
  423. elif '' in labels:
  424. lbl = ''
  425. stype = "Standard"
  426. if lbl or not labels:
  427. slots.append({
  428. 'date': d_str,
  429. 'time': s.get('time'),
  430. 'label': lbl,
  431. 'type': stype,
  432. 'cost': cost
  433. })
  434. return slots
  435. else:
  436. self._log('Parsed appointment slot page, but not found availableAppointments')
  437. return slots
  438. def _check_page_is_session_expired_or_invalid(self, keyword, html: str) -> bool:
  439. if not html:
  440. self.is_healthy = False
  441. raise SessionExpiredOrInvalidError()
  442. if keyword not in html:
  443. if 'redirected automatically' in html.lower():
  444. self.is_healthy = False
  445. raise SessionExpiredOrInvalidError()
  446. if 'login' in html.lower() and 'password' in html.lower():
  447. self.is_healthy = False
  448. raise SessionExpiredOrInvalidError()
  449. 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():
  450. self.is_healthy = False
  451. raise SessionExpiredOrInvalidError()
  452. if 'temporarily blocked!' in html.lower() and 'Your session has been temporarily suspended due to the high number of your access to this page.' in html.lower() and 'You can try to access your account again in 2 hours.' in html.lower():
  453. self.is_healthy = False
  454. raise SessionExpiredOrInvalidError()