tls_plugin.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. import uuid
  7. import shutil
  8. import socket
  9. from datetime import datetime
  10. from typing import List, Dict, Optional, Any, Callable
  11. from urllib.parse import urljoin, urlparse, urlencode, parse_qs
  12. from concurrent.futures import ThreadPoolExecutor
  13. from DrissionPage import ChromiumPage, ChromiumOptions
  14. import configure
  15. from vs_plg import IVSPlg
  16. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  17. from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
  18. from toolkit.mihomo_tunnel import MihomoTunnel
  19. from utils.mouse import HumanMouse, MOUSE_PROFILES
  20. from utils.keyboard import HumanKeyboard
  21. from utils.fingerprint_utils import FingerprintGenerator
  22. class BrowserResponse:
  23. """模拟 requests.Response"""
  24. def __init__(self, result_dict):
  25. result_dict = result_dict or {}
  26. self.status_code = result_dict.get('status', 0)
  27. self.text = result_dict.get('body', '')
  28. self.headers = result_dict.get('headers', {})
  29. self.url = result_dict.get('url', '')
  30. self._json = None
  31. def json(self):
  32. if self._json is None:
  33. if not self.text:
  34. return {}
  35. try:
  36. self._json = json.loads(self.text)
  37. except:
  38. self._json = {}
  39. return self._json
  40. class TlsPlugin(IVSPlg):
  41. """
  42. TLSContact 签证预约插件 (DrissionPage 版)
  43. """
  44. def __init__(self, group_id: str):
  45. self.group_id = group_id
  46. self.config: Optional[VSPlgConfig] = None
  47. self.free_config: Dict[str, Any] = {}
  48. self.is_healthy = True
  49. self.logger = None
  50. self.mouse = None
  51. self.keyboard = None
  52. self.page: Optional[ChromiumPage] = None
  53. self.travel_group: Optional[Dict] = None
  54. self.instance_id = uuid.uuid4().hex[:8]
  55. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  56. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  57. if not os.path.exists(self.root_workspace):
  58. os.makedirs(self.root_workspace)
  59. self.is_busy = False
  60. self.tunnel = None
  61. self.session_create_time: float = 0
  62. def get_group_id(self) -> str:
  63. return self.group_id
  64. def set_log(self, logger: Callable[[str], None]):
  65. self.logger = logger
  66. def _log(self, message):
  67. if self.logger:
  68. self.logger(f'[TlsPlugin] [{self.group_id}] {message}')
  69. else:
  70. print(f'[TlsPlugin] [{self.group_id}] {message}')
  71. def set_config(self, config: VSPlgConfig):
  72. self.config = config
  73. self.free_config = config.free_config or {}
  74. def keep_alive(self):
  75. try:
  76. self.page.refresh()
  77. self.page.wait.load_start(timeout=2)
  78. self.page.wait.doc_loaded()
  79. time.sleep(random.uniform(1, 3))
  80. self._check_page_is_session_expired_or_invalid('Book your appointment', html = self.page.html)
  81. self.simulate_random_human_mouse_move()
  82. except SessionExpiredOrInvalidError as e:
  83. self.is_healthy = False
  84. except Exception as e:
  85. self._log(f"Unexpected error in keep_alive: {e}")
  86. def simulate_random_human_mouse_move(self, min_x=100, max_x=800, min_y=100, max_y=800, min_points=1, max_points=2):
  87. """
  88. 在指定区域内模拟人类随机移动鼠标并点击数次。
  89. :param min_x: X坐标最小范围
  90. :param max_x: X坐标最大范围
  91. :param min_y: Y坐标最小范围
  92. :param max_y: Y坐标最大范围
  93. :param min_point: 随便移动的最少次数
  94. :param max_point: 随便移动的最多次数
  95. """
  96. move_cnt = random.randint(min_points, max_points)
  97. self._log(f"Starting random human simulation: will move {move_cnt} times in the area.")
  98. for i in range(move_cnt):
  99. rand_x = random.randint(min_x, max_x)
  100. rand_y = random.randint(min_y, max_y)
  101. self._log(f"[{i+1}/{move_cnt}] Moving mouse to ({rand_x}, {rand_y})")
  102. self.mouse.move(rand_x, rand_y, humanize=True)
  103. self._log("Random human move simulation completed.")
  104. def health_check(self) -> bool:
  105. if self.is_busy:
  106. return True
  107. if not self.is_healthy:
  108. return False
  109. if self.page is None:
  110. return False
  111. try:
  112. if not self.page.run_js("return 1;"):
  113. return False
  114. except:
  115. return False
  116. if self.config.session_max_life > 0:
  117. current_time = time.time()
  118. elapsed_time = current_time - self.session_create_time
  119. if elapsed_time > self.config.session_max_life:
  120. self._log(f"Session expired.")
  121. return False
  122. return True
  123. def _save_screenshot(self, name_prefix):
  124. try:
  125. timestamp = int(time.time())
  126. filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
  127. save_path = os.path.join("data", filename)
  128. os.makedirs("data", exist_ok=True)
  129. self.page.get_screenshot(path=save_path, full_page=False)
  130. self._log(f"Screenshot saved to {save_path}")
  131. except Exception as e:
  132. self._log(f"Failed to save screenshot: {e}")
  133. def create_session(self):
  134. """
  135. 全浏览器会话创建:过盾 -> JS注入登录 -> 状态机自动路由导航 -> 到达目标页
  136. """
  137. self._log(f"Initializing Session (ID: {self.instance_id})...")
  138. captcha_future = None
  139. captcha_executor = ThreadPoolExecutor(max_workers=1)
  140. login_captcha_cfg = self.free_config.get("login_captcha", {})
  141. if login_captcha_cfg.get('solve_advance'):
  142. login_page = login_captcha_cfg.get("page_url")
  143. site_key = login_captcha_cfg.get("site_key")
  144. task_type = login_captcha_cfg.get("task")
  145. self._log(f"🚀 Early starting background Captcha solve for sitekey={site_key}")
  146. rc_params = {
  147. "type": task_type,
  148. "page": login_page,
  149. "siteKey": site_key,
  150. "apiToken": self.free_config.get("capsolver_key", "")
  151. }
  152. captcha_future = captcha_executor.submit(self._solve_recaptcha, rc_params)
  153. co = ChromiumOptions()
  154. def get_free_port():
  155. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  156. s.bind(('', 0))
  157. return s.getsockname()[1]
  158. debug_port = get_free_port()
  159. self._log(f"Assigned Debug Port: {debug_port}")
  160. co.set_local_port(debug_port)
  161. co.set_user_data_path(self.user_data_path)
  162. chrome_path = configure.CHROME_PATH
  163. if not chrome_path:
  164. chrome_path = os.getenv("CHROME_BIN")
  165. if chrome_path and os.path.exists(chrome_path):
  166. co.set_paths(browser_path=chrome_path)
  167. if self.config.proxy and self.config.proxy.ip:
  168. p = self.config.proxy
  169. self._log(f'Current proxy id={p.id}')
  170. if p.username and p.password:
  171. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  172. exit_node = {
  173. "name": "ExitNode",
  174. "type": p.proto,
  175. "server": p.ip,
  176. "port": p.port,
  177. "username": p.username,
  178. "password": p.password
  179. }
  180. relay_node = None
  181. if configure.MIHOMO_RELAY_NODES:
  182. relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
  183. mihomo_path = configure.MIHOMO_BIN_PATH
  184. if not mihomo_path:
  185. mihomo_path = os.getenv("MIHOMO_BIN")
  186. if not mihomo_path:
  187. raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
  188. self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
  189. local_proxy = self.tunnel.start()
  190. self._log(f"Tunnel started at {local_proxy}")
  191. co.set_argument(f'--proxy-server={local_proxy}')
  192. else:
  193. proxy_str = f"{p.proto}://{p.ip}:{p.port}"
  194. co.set_argument(f'--proxy-server={proxy_str}')
  195. else:
  196. self._log("[WARN] No proxy configured!")
  197. specific_fp = FingerprintGenerator().generate(self.config.account.username)
  198. fp_seed = specific_fp.get("seed")
  199. fp_platform = specific_fp.get("platform")
  200. fp_brand = specific_fp.get("brand")
  201. self._log(f'browser fingerprint seed={fp_seed}')
  202. co.headless(False)
  203. co.set_argument('--no-sandbox')
  204. co.set_argument('--disable-dev-shm-usage')
  205. co.set_argument('--window-size=1920,1080')
  206. co.set_argument('--disable-blink-features=AutomationControlled')
  207. # co.set_argument('--ignore-gpu-blocklist')
  208. # co.set_argument('--enable-webgl')
  209. # co.set_argument('--use-gl=angle')
  210. # co.set_argument('--use-angle=swiftshader')
  211. co.set_argument(f"--fingerprint={fp_seed}")
  212. co.set_argument(f"--fingerprint-platform={fp_platform}")
  213. co.set_argument(f"--fingerprint-brand={fp_brand}")
  214. try:
  215. self.page = ChromiumPage(co)
  216. # --- 预检指纹信息 ---
  217. if self.config.debug:
  218. self.page.get('https://example.com')
  219. js_script = """
  220. function getFingerprint() {
  221. let webglVendor = 'Unknown';
  222. let webglRenderer = 'Unknown';
  223. try {
  224. let canvas = document.createElement('canvas');
  225. let gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  226. if (gl) {
  227. let debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
  228. if (debugInfo) {
  229. webglVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
  230. webglRenderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
  231. }
  232. }
  233. } catch(e) {}
  234. return {
  235. "User-Agent": navigator.userAgent,
  236. "Platform": navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform,
  237. "Brands": navigator.userAgentData ? navigator.userAgentData.brands.map(b => b.brand).join(', ') : 'Not Supported',
  238. "CPU Cores": navigator.hardwareConcurrency,
  239. "Language": navigator.language,
  240. "Timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
  241. "WebGL Vendor": webglVendor,
  242. "WebGL Renderer": webglRenderer
  243. };
  244. }
  245. return getFingerprint();
  246. """
  247. fp_data = self.page.run_js(js_script)
  248. self._log("================ 预检浏览器指纹数据 ================")
  249. self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
  250. self._log("====================================================")
  251. # --- 初始化访问与过盾 ---
  252. tls_url = self.free_config.get('tls_url', '')
  253. self._log(f"Navigating: {tls_url}")
  254. self.page.get(tls_url)
  255. time.sleep(5)
  256. if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
  257. self._log(f'Block by cloudflare, try refresh...')
  258. self.page.refresh()
  259. self.page.wait.load_start(timeout=2)
  260. self.page.wait.doc_loaded()
  261. cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
  262. if not cf_bypasser.bypass(max_retry=6):
  263. raise BizLogicError("Cloudflare bypass timeout")
  264. time.sleep(3)
  265. cf_bypasser.handle_waiting_room()
  266. self._log("Init humanize tools...")
  267. profile_name = random.choice(list(MOUSE_PROFILES.keys()))
  268. self._log(f"[HumanMouse] current mouse profiles: {profile_name}")
  269. self.mouse = HumanMouse(self.page, timing=MOUSE_PROFILES[profile_name], debug=self.config.debug)
  270. self.keyboard = HumanKeyboard(self.page)
  271. viewport_width = self.page.rect.viewport_size[0]
  272. viewport_height = self.page.rect.viewport_size[1]
  273. init_x = random.randint(10, viewport_width - 10)
  274. init_y = random.randint(10, viewport_height - 10)
  275. self.mouse.move(init_x, init_y)
  276. max_steps = 10
  277. session_created = False
  278. has_submitted_login = False
  279. other_langs = ['fr-fr', 'ar-ar', 'cb-ph', 'id-id', 'km-kh', 'mk-mk', 'ru-ru', 'sq-al', 'th-th', 'tl-ph', 'uz-uz', 'vi-vn', 'zh-cn', 'hy-am', 'zh-hk']
  280. for step in range(max_steps):
  281. self.page.wait.doc_loaded()
  282. time.sleep(0.5)
  283. current_url = self.page.url
  284. self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
  285. cloudflare_blocked_indicators = [
  286. "Sorry, you have been blocked" in self.page.html,
  287. "You are being rate limited" in self.page.html,
  288. "Cloudflare Ray ID" in self.page.html
  289. ]
  290. if any(cloudflare_blocked_indicators):
  291. raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
  292. # 如果语言不匹配, 切换语言到英语
  293. matched_lang = next((lang for lang in other_langs if lang in current_url), None)
  294. if matched_lang:
  295. current_url = current_url.replace(matched_lang, 'en-us')
  296. self.page.get(current_url)
  297. self.page.wait.load_start(timeout=3)
  298. continue
  299. # 状态 1:到达终极目标页面 (成功退出条件)
  300. if "appointment-booking" in current_url or self.page.ele('tag:button@text():Book your appointment', timeout=1):
  301. btn_selector = 'tag:button@text():Book your appointment'
  302. if self.page.wait.ele_displayed(btn_selector, timeout=10):
  303. self.session_create_time = time.time()
  304. self._log("✅ Login & Navigation Success! Reached appointment-booking.")
  305. session_created = True
  306. break
  307. # 状态 2:遇到没有申请人的拦截页 (致命错误退出条件)
  308. no_applicant_indicators = [
  309. "Add a new applicant" in self.page.html,
  310. "You have not yet added an applicant" in self.page.html,
  311. "applicants-information" in current_url
  312. ]
  313. if any(no_applicant_indicators):
  314. raise BizLogicError(message="No applicant added. Cannot proceed to booking.")
  315. if current_url == tls_url:
  316. # 状态 3:首页/登录入口页 -> 需要点击进入登录
  317. if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
  318. self._log("State: Login Portal. Clicking login link...")
  319. login_link = self.page.ele("tag:a@@href:login")
  320. self.mouse.human_click_ele(login_link)
  321. self.page.wait.load_start(timeout=3)
  322. continue
  323. if self.page.ele("tag:svg@@data-testid=user-button", timeout=1):
  324. self._log("State: Already login, logout now...")
  325. user_btn = self.page.ele("tag:svg@@data-testid=user-button")
  326. self.mouse.human_click_ele(user_btn)
  327. time.sleep(1.5)
  328. logout_btn = self.page.ele("#logout")
  329. self.mouse.human_click_ele(logout_btn)
  330. self.page.wait.load_start(timeout=3)
  331. self.page.get(tls_url)
  332. self.page.wait.load_start(timeout=3)
  333. continue
  334. # 状态 4:真正的登录表单页
  335. if self.page.ele('tag:label@@text():Email', timeout=1) and not has_submitted_login:
  336. self._log("State: Login Form. Processing credentials and Captcha...")
  337. recaptchav2_token = ""
  338. if not captcha_future and (self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]')):
  339. rec_iframe = self.page.ele('xpath://iframe[contains(@src, "recaptcha")]')
  340. rec_iframe_src = rec_iframe.attr('src')
  341. rec_parsed = urlparse(rec_iframe_src)
  342. rec_params = parse_qs(rec_parsed.query)
  343. rec_sitekey = rec_params.get("k", [None])[0]
  344. rec_size = rec_params.get("size", [None])[0]
  345. if 'normal' == rec_size:
  346. self._log(f"Found dynamic sitekey={rec_sitekey}. Starting async Captcha solver...")
  347. rc_params = {
  348. "type": "ReCaptchaV2TaskProxyLess",
  349. "page": current_url,
  350. "siteKey": rec_sitekey,
  351. "apiToken": self.free_config.get("capsolver_key", "")
  352. }
  353. captcha_future = captcha_executor.submit(self._solve_recaptcha, rc_params)
  354. username = self.config.account.username
  355. password = self.config.account.password
  356. input_ele = self.page.ele('tag:label@@text():Email').next()
  357. self.mouse.human_click_ele(input_ele)
  358. time.sleep(random.uniform(0.2, 0.6))
  359. self.keyboard.type_text(username, humanize=True)
  360. time.sleep(random.uniform(0.5, 1.2))
  361. input_ele = self.page.ele('tag:label@@text():Password').next()
  362. self.mouse.human_click_ele(input_ele)
  363. time.sleep(random.uniform(0.2, 0.6))
  364. self.keyboard.type_text(password, humanize=True)
  365. # 注入 Token
  366. if captcha_future:
  367. self._log("Waiting for background Captcha result...")
  368. try:
  369. # 设一个合理的超时,防止死锁
  370. recaptchav2_token = captcha_future.result(timeout=120)
  371. self._log("Background Captcha solved successfully!")
  372. except Exception as e:
  373. raise BizLogicError(f"Captcha solving failed or timed out: {e}")
  374. # 注入 Token
  375. if recaptchav2_token:
  376. inject_js = f"var g = document.getElementById('g-recaptcha-response'); if(g) {{ g.value = '{recaptchav2_token}'; }}"
  377. self.page.run_js(inject_js)
  378. time.sleep(random.uniform(0.5, 1.0))
  379. self._log("Submitting Login...")
  380. login_btn = self.page.ele('tag:button@@text():Login')
  381. self.mouse.human_click_ele(login_btn)
  382. has_submitted_login = True
  383. self.page.wait.load_start(timeout=5)
  384. continue
  385. # 状态 5:Travel Groups 页面
  386. if "travel-groups" in current_url:
  387. self._log("State: Travel Groups. Selecting targeted group...")
  388. groups = self._parse_travel_groups(self.page.html)
  389. location = self.free_config.get('location')
  390. self.travel_group = next((g for g in groups if location in g['location']), None)
  391. if not self.travel_group or not self.travel_group.get("submitted"):
  392. self._save_screenshot("group_not_found")
  393. raise NotFoundError(f"Group not found for {location}")
  394. formgroup_id = self.travel_group.get('group_number')
  395. btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
  396. if self.page.wait.eles_loaded(btn_selector, timeout=10):
  397. buttons = self.page.eles(btn_selector)
  398. select_btn = next((btn for btn in reversed(buttons) if btn.rect.size[0] > 0 and btn.rect.size[1] > 0), None)
  399. if select_btn:
  400. time.sleep(random.uniform(0.5, 1.2))
  401. self.mouse.human_click_ele(select_btn)
  402. self.page.wait.load_start(timeout=3)
  403. continue
  404. else:
  405. self._log("[WARN] Select button found but not visible.")
  406. else:
  407. self._log(f"[WARN] Wait timeout for group button {formgroup_id}")
  408. # 状态 6:中间过渡页,需点击 "Book Appointment" 继续往下走
  409. if self.page.ele('#book-appointment-btn', timeout=1):
  410. self._log("State: Intermediate Dashboard. Clicking Book Appointment button...")
  411. self.mouse.human_click_ele(self.page.ele('#book-appointment-btn'))
  412. self.page.wait.load_start(timeout=3)
  413. continue
  414. # 状态 7:登录失败校验 或 未知加载状态
  415. if "login-actions" in current_url and has_submitted_login:
  416. self._log("Waiting on login-actions... (Might be authenticating or invalid credentials)")
  417. time.sleep(2)
  418. if self.page.ele('text:Invalid username or password', timeout=1): # 假设网页上有错误提示
  419. raise BizLogicError(message="Login Failed! Invalid credentials or Captcha rejected.")
  420. continue
  421. self._log("State: Transitioning or Unknown. Waiting 2 seconds...")
  422. time.sleep(2)
  423. if not session_created:
  424. raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
  425. except Exception as e:
  426. self._log(f"Session Create Error: {e}")
  427. if self.config.debug:
  428. self._save_screenshot("create_session_except")
  429. self.cleanup()
  430. raise e
  431. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  432. res = VSQueryResult()
  433. res.success = False
  434. self.is_busy = True
  435. try:
  436. slots = []
  437. self._log(f"Executing silent JS fetch...")
  438. resp = self._perform_request("GET", self.page.url, retry_count=0)
  439. self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
  440. slots = self._parse_appointment_slots(resp.text)
  441. if slots:
  442. res.success = True
  443. earliest_date = slots[0]["date"]
  444. earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  445. res.availability_status = AvailabilityStatus.Available
  446. res.earliest_date = earliest_dt
  447. date_map: dict[datetime, list[TimeSlot]] = {}
  448. for s in slots:
  449. date_str = s["date"]
  450. dt = datetime.strptime(date_str, "%Y-%m-%d")
  451. date_map.setdefault(dt, []).append(
  452. TimeSlot(time=s["time"], label=str(s.get("label", "")))
  453. )
  454. res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
  455. else:
  456. res.success = False
  457. res.availability_status = AvailabilityStatus.NoneAvailable
  458. # TODO(TEST): 临时测试预约提交
  459. if configure.TLS_TEST_BOOK_AFTER_QUERY:
  460. test_date = "2026-06-10"
  461. test_time = "09:00"
  462. test_label = ""
  463. test_dt = datetime.strptime(test_date, "%Y-%m-%d")
  464. query_res = VSQueryResult()
  465. query_res.success = True
  466. query_res.availability_status = AvailabilityStatus.Available
  467. query_res.earliest_date = test_dt
  468. query_res.availability = [
  469. DateAvailability(
  470. date=test_dt,
  471. times=[TimeSlot(time=test_time, label=test_label)]
  472. )
  473. ]
  474. self._log(f"[TEST] using fixed June slot: {test_date} {test_time} {test_label}")
  475. test_userinput = {
  476. "support_pta": False,
  477. "expected_end_date": "2100-01-01",
  478. "expected_start_date": "2000-01-01"
  479. }
  480. try:
  481. self.book(query_res, test_userinput)
  482. except Exception as e:
  483. self._log(f"[TEST] book() after query failed: {e}")
  484. self.is_healthy = False
  485. finally:
  486. self.is_busy = False
  487. return res
  488. def book_bak(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  489. res = VSBookResult()
  490. res.success = False
  491. exp_start = user_inputs.get('expected_start_date', '')
  492. exp_end = user_inputs.get('expected_end_date', '')
  493. support_pta = user_inputs.get('support_pta', True)
  494. target_labels = ['']
  495. if support_pta:
  496. target_labels.append('pta')
  497. available_dates_str =[
  498. da.date.strftime("%Y-%m-%d")
  499. for da in slot_info.availability if da.date
  500. ]
  501. valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
  502. if not valid_dates_list:
  503. raise NotFoundError(message="No dates match user constraints")
  504. all_possible_slots =[]
  505. for da in slot_info.availability:
  506. if not da.date:
  507. continue
  508. date_str = da.date.strftime("%Y-%m-%d")
  509. if date_str in valid_dates_list:
  510. for t in da.times:
  511. if t.label in target_labels:
  512. all_possible_slots.append({
  513. "date": date_str,
  514. "time_obj": t,
  515. "label": t.label
  516. })
  517. if not all_possible_slots:
  518. raise NotFoundError(message="No suitable slot found (after label filtering)")
  519. selected_slot = random.choice(all_possible_slots)
  520. selected_date = selected_slot["date"]
  521. selected_time = selected_slot["time_obj"]
  522. selected_label = selected_slot["label"]
  523. self._log(f"Found {len(all_possible_slots)} valid slots. selected slot: {selected_date} {selected_time.time} {selected_label}")
  524. self.page.listen.start('/workflow/appointment-booking', method='POST')
  525. page_url = self.page.url
  526. location = self.travel_group.get('location')
  527. mapper = {
  528. "London": "gbLON2fr",
  529. "Dublin": "ieDUB2fr"
  530. }
  531. location_id = mapper.get(location)
  532. form_group_id =self.travel_group.get('group_number')
  533. api_token = self.free_config.get("capsolver_key", "")
  534. rc_params = {
  535. "type": "ReCaptchaV3Task",
  536. "page": page_url,
  537. "action": "book",
  538. "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  539. "apiToken": api_token,
  540. "proxy": True
  541. }
  542. g_token = self._solve_recaptcha(rc_params)
  543. ACTION_ID = "6033ac5e6e4ac04f59a4b74a9c5dd312876dd46bd9"
  544. js_script = f"""
  545. (async function() {{
  546. const url = "{page_url}";
  547. const groupId = "{form_group_id}";
  548. const locationId = "{location_id}";
  549. const selectedDate = "{selected_date}";
  550. const selectedTime = "{selected_time.time}";
  551. const selectedLabel = "{selected_label}";
  552. const captchaToken = "{g_token}";
  553. const actionId = "{ACTION_ID}";
  554. let castleToken = '';
  555. try {{
  556. const castleModule = window.__webpack_require__(93773);
  557. if (castleModule && castleModule.createRequestToken) {{
  558. castleToken = await castleModule.createRequestToken();
  559. console.log('Castle token obtained:', castleToken);
  560. }}
  561. }} catch(e) {{
  562. console.error('Failed to get Castle token:', e);
  563. }}
  564. let routerStateTree = '';
  565. try {{
  566. const routerModule = window.__webpack_require__(11807);
  567. const routerState = routerModule.getCurrentAppRouterState();
  568. if (routerState && routerState.tree) {{
  569. const prepareModule = window.__webpack_require__(16378);
  570. routerStateTree = prepareModule.prepareFlightRouterStateForRequest(routerState.tree);
  571. console.log('Router state tree obtained');
  572. }}
  573. }} catch(e) {{
  574. console.error('Failed to get router state:', e);
  575. }}
  576. const formData = new FormData();
  577. formData.append('1_formGroupId', groupId);
  578. formData.append('1_lang', 'en-us');
  579. formData.append('1_process', 'APPOINTMENT');
  580. formData.append('1_location', locationId);
  581. formData.append('1_date', selectedDate);
  582. formData.append('1_time', selectedTime);
  583. formData.append('1_appointmentLabel', selectedLabel);
  584. formData.append('1_castleRequestToken', castleToken);
  585. formData.append('1_captchaToken', captchaToken);
  586. formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
  587. const headers = {{
  588. 'Accept': 'text/x-component',
  589. 'Next-Action': actionId,
  590. 'Next-Router-State-Tree': routerStateTree,
  591. }};
  592. const response = await fetch(url, {{
  593. method: 'POST',
  594. headers: headers,
  595. body: formData,
  596. credentials: 'include'
  597. }});
  598. const text = await response.text();
  599. const responseHeaders = {{}};
  600. response.headers.forEach((value, key) => {{
  601. responseHeaders[key] = value;
  602. }});
  603. const result = {{
  604. status: response.status,
  605. body: text,
  606. headers: responseHeaders,
  607. url: response.url,
  608. redirected: response.redirected,
  609. ok: response.ok
  610. }};
  611. return result;
  612. }})();
  613. """
  614. self._log("Submitting booking request via JS Fetch...")
  615. self.page.run_js(js_script)
  616. packet = self.page.listen.wait(timeout=15)
  617. if not packet:
  618. raise BizLogicError(message='Listening data failed')
  619. self.page.listen.stop()
  620. self._log(f"URL: {packet.url}")
  621. self._log(f"POST Body: {packet.request.postData}")
  622. self._log(f"POST Stat: {packet.response.status}")
  623. self._log(f"POST head: {packet.response.headers}")
  624. self._log(f"POST Resp: {packet.response.raw_body}")
  625. redirect_location = packet.response.headers.get('location', '') or ''
  626. appointment_confirmation_indicators = [
  627. "order-summary" in redirect_location,
  628. "partner-services" in redirect_location,
  629. "appointment-confirmation" in redirect_location,
  630. ]
  631. if any(appointment_confirmation_indicators):
  632. self._log(f"Booking Success!")
  633. res.success = True
  634. res.book_date = selected_date
  635. res.book_time = selected_time.time
  636. return res
  637. return res
  638. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  639. res = VSBookResult()
  640. res.success = False
  641. exp_start = user_inputs.get('expected_start_date', '')
  642. exp_end = user_inputs.get('expected_end_date', '')
  643. support_pta = user_inputs.get('support_pta', True)
  644. target_labels = ['']
  645. if support_pta:
  646. target_labels.append('pta')
  647. available_dates_str =[
  648. da.date.strftime("%Y-%m-%d")
  649. for da in slot_info.availability if da.date
  650. ]
  651. valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
  652. if not valid_dates_list:
  653. raise NotFoundError(message="No dates match user constraints")
  654. all_possible_slots =[]
  655. for da in slot_info.availability:
  656. if not da.date:
  657. continue
  658. date_str = da.date.strftime("%Y-%m-%d")
  659. if date_str in valid_dates_list:
  660. for t in da.times:
  661. if t.label in target_labels:
  662. all_possible_slots.append({
  663. "date": date_str,
  664. "time_obj": t,
  665. "label": t.label
  666. })
  667. if not all_possible_slots:
  668. raise NotFoundError(message="No suitable slot found (after label filtering)")
  669. selected_slot = random.choice(all_possible_slots)
  670. selected_date = selected_slot["date"]
  671. selected_time = selected_slot["time_obj"]
  672. selected_label = selected_slot["label"]
  673. self._log(f"Found {len(all_possible_slots)} valid slots. selected slot: {selected_date} {selected_time.time} {selected_label}")
  674. self.page.listen.start('/workflow/appointment-booking', method='POST')
  675. js_update_form = f"""
  676. try {{
  677. const buttons = Array.from(document.querySelectorAll('button[type="submit"]'));
  678. const submitBtn = buttons.find(btn => {{
  679. return btn.textContent.trim().toLowerCase().includes('book your appointment');
  680. }});
  681. if (!submitBtn) return 'Submit button not found';
  682. const form = submitBtn.closest('form');
  683. if (!form) return 'Correct form not found';
  684. function setReactValue(input, value) {{
  685. if (!input) return;
  686. input.value = value;
  687. }}
  688. setReactValue(form.querySelector('input[name="date"]'), '{selected_date}');
  689. setReactValue(form.querySelector('input[name="time"]'), '{selected_time.time}');
  690. setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{selected_label}');
  691. submitBtn.removeAttribute('disabled');
  692. submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
  693. return 'form_updated';
  694. }} catch (e) {{
  695. return e.toString();
  696. }}
  697. """
  698. update_res = self.page.run_js(js_update_form)
  699. self._log(f"Form update triggered: {update_res}")
  700. if update_res != 'form_updated':
  701. raise BizLogicError(message=f"Failed to update form: {update_res}")
  702. submit_btn = self.page.ele('tag:button@@type=submit@@text():Book your appointment')
  703. if not submit_btn:
  704. raise BizLogicError(message="Submit button not found for mouse click")
  705. self._log("Moving mouse to submit button and clicking")
  706. self.mouse.human_click_ele(submit_btn)
  707. packet = self.page.listen.wait(timeout=10)
  708. if not packet:
  709. raise BizLogicError(message='Listening data failed')
  710. self.page.listen.stop()
  711. self._log(f"URL: {packet.url}")
  712. self._log(f"POST Body: {packet.request.postData}")
  713. self._log(f"POST Resp: {packet.response.body}")
  714. self._log("Waiting for Next.js to process the form submission...")
  715. for _ in range(10):
  716. try:
  717. current_page_url = self.page.url
  718. current_page_html = self.page.html
  719. appointment_confirmation_indicators = [
  720. "order-summary" in current_page_url,
  721. "partner-services" in current_page_url,
  722. "appointment-confirmation" in current_page_url,
  723. "Change my appointment" in current_page_html,
  724. "Book a new appointment" in current_page_html,
  725. ]
  726. if any(appointment_confirmation_indicators):
  727. self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
  728. res.success = True
  729. res.label = selected_label
  730. res.book_date = selected_date
  731. res.book_time = selected_time.time
  732. self._save_screenshot("book_slot_success")
  733. break
  734. toast_selector = 'tag:div@role=alert'
  735. toast_ele = self.page.ele(toast_selector, timeout=0.5)
  736. if toast_ele:
  737. error_msg = toast_ele.text
  738. self._log(f"❌ BOOKING FAILED! Detected popup: {error_msg}")
  739. break
  740. time.sleep(0.5)
  741. except Exception:
  742. pass
  743. return res
  744. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
  745. """
  746. 在浏览器上下文中注入 JS 执行 Fetch
  747. """
  748. if not self.page:
  749. raise BizLogicError("Browser not initialized")
  750. if params:
  751. from urllib.parse import urlencode
  752. if '?' in url:
  753. url += '&' + urlencode(params)
  754. else:
  755. url += '?' + urlencode(params)
  756. fetch_options = {
  757. "method": method.upper(),
  758. "headers": headers or {},
  759. "credentials": "include"
  760. }
  761. # Body 处理
  762. if json_data:
  763. fetch_options['body'] = json.dumps(json_data)
  764. fetch_options['headers']['Content-Type'] = 'application/json'
  765. elif data:
  766. if isinstance(data, dict):
  767. from urllib.parse import urlencode
  768. fetch_options['body'] = urlencode(data)
  769. fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
  770. else:
  771. fetch_options['body'] = data
  772. js_script = f"""
  773. const url = "{url}";
  774. const options = {json.dumps(fetch_options)};
  775. return fetch(url, options)
  776. .then(async response => {{
  777. const text = await response.text();
  778. const headers = {{}};
  779. response.headers.forEach((value, key) => headers[key] = value);
  780. return {{
  781. status: response.status,
  782. body: text,
  783. headers: headers,
  784. url: response.url
  785. }};
  786. }})
  787. .catch(error => {{
  788. return {{
  789. status: 0,
  790. body: error.toString(),
  791. headers: {{}},
  792. url: url
  793. }};
  794. }});
  795. """
  796. res_dict = self.page.run_js(js_script, timeout=30)
  797. resp = BrowserResponse(res_dict)
  798. if resp.status_code == 200:
  799. return resp
  800. elif resp.status_code == 401:
  801. self.is_healthy = False
  802. raise SessionExpiredOrInvalidError()
  803. elif resp.status_code == 403:
  804. if retry_count < 2:
  805. self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
  806. if self._refresh_firewall_session():
  807. self._log("Firewall session refreshed. Retrying request...")
  808. return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
  809. else:
  810. self._log("Failed to refresh firewall session.")
  811. raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
  812. elif resp.status_code == 429:
  813. self.is_healthy = False
  814. raise RateLimiteddError()
  815. else:
  816. if resp.status_code == 0:
  817. raise BizLogicError(f"Network Error: {resp.text}")
  818. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  819. def _refresh_firewall_session(self) -> bool:
  820. """
  821. 主动刷新页面以触发 Cloudflare 挑战并尝试通过
  822. """
  823. try:
  824. self._log("Refreshing page to trigger Cloudflare...")
  825. self.page.refresh()
  826. time.sleep(5)
  827. cf = CloudflareBypasser(self.page, log=self.config.debug)
  828. success = cf.bypass(max_retry=6)
  829. if success:
  830. title = self.page.title.lower()
  831. if "access denied" in title:
  832. return False
  833. time.sleep(2)
  834. return True
  835. return False
  836. except Exception as e:
  837. self._log(f"Error during firewall refresh: {e}")
  838. return False
  839. def _solve_recaptcha(self, params) -> str:
  840. """调用 VSCloudApi 解决 ReCaptcha"""
  841. key = params.get("apiToken")
  842. if not key:
  843. raise NotFoundError("Api-token required")
  844. submit_url = "https://api.capsolver.com/createTask"
  845. task = {
  846. "type": params.get("type"),
  847. "websiteURL": params.get("page"),
  848. "websiteKey": params.get("siteKey"),
  849. }
  850. if params.get("action"):
  851. task["pageAction"] = params.get("action")
  852. if params.get("proxy"):
  853. p = self.config.proxy
  854. task["proxyType"] = p.proto
  855. task["proxyAddress"] = p.ip
  856. task["proxyPort"] = p.port
  857. if p.username:
  858. task["proxyLogin"] = p.username
  859. task["proxyPassword"] = p.password
  860. payload = {"clientKey": key, "task": task}
  861. import requests as req
  862. r = req.post(submit_url, json=payload, timeout=20)
  863. if r.status_code != 200:
  864. raise BizLogicError(message="Failed to submit capsolver task")
  865. task_id = r.json().get("taskId")
  866. for _ in range(20):
  867. r = req.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": task_id}, timeout=20)
  868. if r.status_code == 200:
  869. d = r.json()
  870. if d.get("status") == "ready":
  871. return d["solution"]["gRecaptchaResponse"]
  872. time.sleep(3)
  873. raise BizLogicError(message="Capsolver task timeout")
  874. def _parse_travel_groups(self, html_content) -> List[Dict]:
  875. groups = []
  876. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  877. js_match = re.search(js_pattern, html_content, re.DOTALL)
  878. if js_match:
  879. json_str = js_match.group(1).replace(r'\"', '"')
  880. data = json.loads(json_str)
  881. for g in data:
  882. groups.append({
  883. 'group_name': g.get('groupName'),
  884. 'group_number': g.get('formGroupId'),
  885. 'location': g.get('vacName'),
  886. 'submitted': g.get('submitted')
  887. })
  888. else:
  889. self._log('Parsed travel group page, but not found travelGroups')
  890. return groups
  891. def _parse_appointment_slots(self, html_content) -> List[Dict]:
  892. slots = []
  893. pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
  894. match = re.search(pattern, html_content, re.DOTALL)
  895. if match:
  896. json_str = match.group(1).replace(r'\"', '"')
  897. data = json.loads(json_str)
  898. for day in data:
  899. d_str = day.get('day')
  900. for s in day.get('slots', []):
  901. labels = s.get('labels', [])
  902. lbl = None
  903. if 'pta' in labels: lbl = 'pta'
  904. elif 'ptaw' in labels: lbl = 'ptaw'
  905. elif '' in labels: lbl = ''
  906. if lbl is not None:
  907. slots.append({
  908. 'date': d_str,
  909. 'time': s.get('time'),
  910. 'label': lbl
  911. })
  912. return slots
  913. def _check_page_is_session_expired_or_invalid(self, keyword, html: str) -> bool:
  914. if not html:
  915. self.is_healthy = False
  916. raise SessionExpiredOrInvalidError()
  917. html_lower = html.lower()
  918. if keyword.lower() not in html_lower:
  919. session_expire_or_invalid_indicators = [
  920. 'redirected automatically' in html_lower,
  921. 'login' in html_lower and 'password' in html_lower,
  922. 'session expired' in html_lower
  923. ]
  924. if any(session_expire_or_invalid_indicators):
  925. self.is_healthy = False
  926. raise SessionExpiredOrInvalidError()
  927. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  928. if not start_str or not end_str:
  929. return dates
  930. valid_dates = []
  931. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  932. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  933. for date_str in dates:
  934. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  935. if s_date <= curr_date <= e_date:
  936. valid_dates.append(date_str)
  937. random.shuffle(valid_dates)
  938. return valid_dates
  939. # --- 资源清理核心方法 ---
  940. def cleanup(self):
  941. """
  942. 销毁浏览器并彻底删除临时文件
  943. """
  944. if self.page:
  945. try:
  946. self.page.quit(force=True)
  947. except Exception:
  948. pass
  949. self.page = None
  950. if os.path.exists(self.root_workspace):
  951. for _ in range(3):
  952. try:
  953. time.sleep(0.2)
  954. shutil.rmtree(self.root_workspace, ignore_errors=True)
  955. break
  956. except Exception as e:
  957. self._log(f"Cleanup retry: {e}")
  958. time.sleep(0.5)
  959. if os.path.exists(self.root_workspace):
  960. self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
  961. if self.tunnel:
  962. try: self.tunnel.stop()
  963. except: pass
  964. self.tunnel = None
  965. def __del__(self):
  966. """
  967. 析构函数:当对象被垃圾回收时自动调用
  968. """
  969. self.cleanup()