tls_standalone.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. import uuid
  7. import socket
  8. import shutil
  9. import requests
  10. import threading
  11. import select
  12. import base64
  13. from datetime import datetime
  14. from urllib.parse import urlencode
  15. # DrissionPage 核心
  16. from DrissionPage import ChromiumPage, ChromiumOptions
  17. class BrowserResponse:
  18. """模拟 requests.Response 的轻量级对象"""
  19. def __init__(self, result_dict):
  20. result_dict = result_dict or {}
  21. self.status_code = result_dict.get('status', 0)
  22. self.text = result_dict.get('body', '')
  23. self.headers = result_dict.get('headers', {})
  24. self.url = result_dict.get('url', '')
  25. class TlsAutoBot:
  26. def __init__(self, config: dict):
  27. """
  28. config 包含: proxy, account, capsolver_key, apt_config (code, country, city), target_dates 等
  29. """
  30. self.config = config
  31. self.instance_id = uuid.uuid4().hex[:8]
  32. self.workspace = os.path.abspath(os.path.join("data", f"tls_session_{self.instance_id}"))
  33. self.page = None
  34. self.travel_group = None
  35. def _log(self, msg):
  36. print(f"[TLS-Bot-{self.instance_id}] {msg}")
  37. def _get_free_port(self):
  38. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  39. s.bind(('', 0))
  40. return s.getsockname()[1]
  41. def init_browser(self):
  42. self._log("Initializing browser...")
  43. co = ChromiumOptions()
  44. # 1. 端口与路径隔离
  45. port = self._get_free_port()
  46. co.set_local_port(port)
  47. co.set_user_data_path(self.workspace)
  48. # 2. 代理配置
  49. proxy_cfg = self.config.get('proxy', {})
  50. proxy_str = f"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
  51. print(f'set proxy={proxy_str}')
  52. co.set_argument(f'--proxy-server={proxy_str}')
  53. # 3. 反爬配置
  54. co.headless(False)
  55. co.set_argument('--no-sandbox')
  56. co.set_argument('--disable-gpu')
  57. co.set_argument('--disable-dev-shm-usage')
  58. co.set_argument('--window-size=1920,1080')
  59. co.set_argument('--disable-blink-features=AutomationControlled')
  60. self.page = ChromiumPage(co)
  61. def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None, api_domain: str = None) -> str:
  62. """通用解决验证码 (同步 User-Agent 防止被盾识别为高风险)"""
  63. capsolver_key = self.config.get('capsolver_key')
  64. if not capsolver_key:
  65. raise ValueError("Capsolver API key missing")
  66. task = {
  67. "type": task_type,
  68. "websiteURL": page_url,
  69. "websiteKey": site_key,
  70. }
  71. if api_domain:
  72. task["apiDomain"] = api_domain
  73. if use_proxy:
  74. proxy = self.config['proxy']
  75. task["proxyType"] = proxy.get('scheme', 'http')
  76. task["proxyAddress"] = proxy.get('ip')
  77. task["proxyPort"] = int(proxy.get('port'))
  78. if proxy.get('username'):
  79. task["proxyLogin"] = proxy.get('username')
  80. task["proxyPassword"] = proxy.get('password')
  81. if action:
  82. task["pageAction"] = action
  83. payload = {"clientKey": capsolver_key, "task": task}
  84. res = requests.post("https://api.capsolver.com/createTask", json=payload, timeout=20)
  85. if res.status_code != 200 or res.json().get("errorId") != 0:
  86. raise Exception(f"Failed to create capsolver task: {res.text}")
  87. task_id = res.json().get("taskId")
  88. self._log(f"Task created: {task_id}. Waiting for solution...")
  89. for _ in range(30):
  90. r = requests.post(
  91. "https://api.capsolver.com/getTaskResult",
  92. json={"clientKey": capsolver_key, "taskId": task_id},
  93. timeout=20
  94. )
  95. data = r.json()
  96. if data.get("status") == "ready":
  97. self._log("Captcha solved successfully!")
  98. return data["solution"].get("gRecaptchaResponse") or data["solution"].get("token")
  99. time.sleep(3)
  100. raise Exception("Capsolver task timeout")
  101. def login(self):
  102. """执行自动登录流程并提取 Group ID"""
  103. self.init_browser()
  104. apt_config = self.config['apt_config']
  105. login_url = "https://visas-fr.tlscontact.com/en-us/login"
  106. params = {
  107. "issuerId": apt_config["code"],
  108. "country": apt_config["country"],
  109. "vac": apt_config["code"],
  110. "redirect": f"/en-us/country/{apt_config['country']}/vac/{apt_config['code']}"
  111. }
  112. full_login_url = f"{login_url}?{urlencode(params)}"
  113. self._log(f"Navigating to login: {full_login_url}")
  114. self.page.get(full_login_url)
  115. time.sleep(3)
  116. wait_start = time.time()
  117. while True:
  118. # 获取页面 HTML,转小写
  119. # 注意:如果此处报错 "页面被刷新",是 DrissionPage 的机制问题,
  120. # 但你要求先不处理复杂错误,所以这里保持最简单的写法。
  121. html = self.page.html.lower()
  122. # 检查是否在排队室 (法语或英语)
  123. if "file d'attente" in html or "waiting room" in html:
  124. # 如果等太久(比如1小时),就强制停止
  125. if time.time() - wait_start > 6 * 60:
  126. self._log("Waiting room timeout (1h).")
  127. break
  128. self._log("In Waiting Room... Waiting for auto-refresh.")
  129. time.sleep(30) # 截图说页面会自动刷新,所以这里只sleep,不动浏览器
  130. else:
  131. # 页面里没有“等候室”的字了,说明出来了
  132. break
  133. if not self.page.ele('#email-input-field'):
  134. self._log("Form not found, reloading...")
  135. self.page.get(full_login_url)
  136. self.page.wait.ele_displayed('#email-input-field', timeout=15)
  137. self._log("Waiting 3 seconds for Captcha scripts to load...")
  138. time.sleep(3)
  139. g_token = ""
  140. # 判断登录页是否有 ReCaptcha
  141. if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
  142. self._log("Login ReCaptcha detected, solving...")
  143. # 登录页通常是 V2
  144. g_token = self.solve_captcha(
  145. page_url=self.page.url,
  146. task_type="ReCaptchaV2TaskProxyLess",
  147. site_key="6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0"
  148. )
  149. account = self.config['account']
  150. js_login = f"""
  151. var u = document.getElementById('email-input-field');
  152. if(u) {{ u.value = "{account['username']}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
  153. var p = document.getElementById('password-input-field');
  154. if(p) {{ p.value = "{account['password']}"; p.dispatchEvent(new Event('input', {{bubbles:true}})); }}
  155. var g = document.getElementById('g-recaptcha-response');
  156. if(g) {{ g.value = "{g_token}"; }}
  157. var btn = document.getElementById('btn-login');
  158. if(btn) {{ btn.click(); return true; }} else {{ return false; }}
  159. """
  160. self._log("Submitting Login via JS...")
  161. self.page.run_js(js_login)
  162. self._log("Waiting for dashboard redirect...")
  163. self.page.wait.url_change('login-actions', exclude=True, timeout=45)
  164. time.sleep(4)
  165. if "login-actions" in self.page.url or "auth" in self.page.url:
  166. raise Exception("Login Failed! Invalid credentials or Captcha rejected.")
  167. self._log("Waiting for dashboard...")
  168. self.page.wait.load_start()
  169. time.sleep(5)
  170. # 解析 Dashboard 提取 Group ID
  171. self._log("Parsing Dashboard for Travel Group...")
  172. html = self.page.html
  173. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  174. js_match = re.search(js_pattern, html, re.DOTALL)
  175. groups = []
  176. if js_match:
  177. json_str = js_match.group(1).replace(r'\"', '"')
  178. groups = json.loads(json_str)
  179. target_city = apt_config['city'].lower()
  180. for g in groups:
  181. if g.get('vacName', '').lower() == target_city:
  182. self.travel_group = g
  183. break
  184. if not self.travel_group:
  185. raise Exception(f"Travel Group not found for city: {target_city}")
  186. formgroup_id = self.travel_group.get('formGroupId')
  187. self._log(f"Waiting for group button to render: {formgroup_id}")
  188. # 隐患修复:确保按钮渲染出来后再点,防止 JS 找不到元素
  189. btn_selector = f'tag:button@@name=formGroupId@@value={formgroup_id}'
  190. self.page.wait.ele_displayed(btn_selector, timeout=15)
  191. self._log(f"Select group_id={formgroup_id} via JS...")
  192. # 替代繁琐的 run_js,直接用内置的 by_js=True 触发
  193. self.page.ele(btn_selector).click(by_js=True)
  194. self._log("Waiting for service-level redirect...")
  195. self.page.wait.url_change('travel-groups', exclude=True, timeout=45)
  196. time.sleep(2) # 页面跳转后给个短缓冲
  197. if "travel-groups" in self.page.url or "auth" in self.page.url:
  198. raise Exception("Redirect to service-level Failed!")
  199. # ==========================================
  200. # 2. 点击进入 Appointment Booking
  201. # ==========================================
  202. self._log("Waiting for book-appointment button to render...")
  203. # 隐患修复:同样必须等待 Continue 按钮渲染完成
  204. self.page.wait.ele_displayed('#book-appointment-btn', timeout=15)
  205. self._log("Clicking 'Continue' to appointment booking via JS...")
  206. self.page.ele('#book-appointment-btn').click(by_js=True)
  207. self._log("Waiting for appointment-booking redirect...")
  208. self.page.wait.url_change('service-level', exclude=True, timeout=45)
  209. time.sleep(2)
  210. if "service-level" in self.page.url or "auth" in self.page.url:
  211. raise Exception("Redirect to appointment-booking Failed!")
  212. self._log("Waiting for appointment-booking page to fully load...")
  213. self.page.wait.load_start()
  214. time.sleep(3)
  215. self._log(f"✅ Login & Navigation Success! Target Group ID: {formgroup_id}")
  216. def query_slots(self) -> list:
  217. """根据当前 UI 状态自动判断路由,并使用高鲁棒性特征提取 Slot"""
  218. group_num = self.travel_group['formGroupId']
  219. apt_config = self.config['apt_config']
  220. interest_month = self.config.get("interest_month", time.strftime("%m-%Y"))
  221. # 转换工具
  222. target_date_obj = datetime.strptime(interest_month, "%m-%Y")
  223. target_month_text = target_date_obj.strftime("%B %Y")
  224. target_year = target_date_obj.year
  225. target_month_num = target_date_obj.month
  226. slots = []
  227. # 获取选中的月份:通过 data-testid 找到当前月份按钮并提取文本
  228. # 注意:这里改用 data-testid 寻找当前月份,更稳健
  229. current_selected_ele = self.page.ele('@data-testid=btn-current-month-available')
  230. current_month_text = current_selected_ele.text.strip() if current_selected_ele else ""
  231. is_on_target_month = (current_month_text.lower() == target_month_text.lower())
  232. if not is_on_target_month:
  233. # ==========================================
  234. # 模式 A: 不在目标月份 (UI 路由 + HTML 强鲁棒性解析)
  235. # ==========================================
  236. self._log(f"Current is '{current_month_text}', navigating to '{target_month_text}'...")
  237. for _ in range(12):
  238. target_btn_xpath = f'xpath://a[contains(@href, "month={interest_month}")]'
  239. target_btn = self.page.ele(target_btn_xpath)
  240. if target_btn:
  241. target_btn.click(by_js=True)
  242. time.sleep(3)
  243. break
  244. next_btn = self.page.ele('@data-testid=btn-next-month-available')
  245. if next_btn:
  246. next_btn.click(by_js=True)
  247. time.sleep(2)
  248. else:
  249. self._log("Warning: Cannot find target month or 'Next Month' button.")
  250. break
  251. self._log("Extracting slots from DOM using robust data-testid features...")
  252. # 【高鲁棒性提取】
  253. # 特征定义:寻找所有的“日期块”。
  254. # 什么是一个日期块?它是一个 div,里面直接包含一个 p 标签(放日期),
  255. # 且同时包含另一个 div,其内部有带有 'slot' 字样的按钮。
  256. day_blocks_xpath = '//div[p and div//button[contains(@data-testid, "slot")]]'
  257. day_blocks = self.page.eles(f'xpath:{day_blocks_xpath}')
  258. for block in day_blocks:
  259. # 1. 提取日期:只要是这个 block 下的 p 标签,必定是 "Mon 01" 这种
  260. p_ele = block.ele('tag:p')
  261. if not p_ele: continue
  262. # 直接从 p 标签的纯文本里抽取出数字,忽略前面的字母
  263. day_match = re.search(r'\d+', p_ele.text)
  264. if not day_match: continue
  265. day_str = day_match.group()
  266. full_date = f"{target_year}-{target_month_num:02d}-{int(day_str):02d}"
  267. # 2. 提取可用按钮:利用 data-testid 前缀匹配
  268. # 完美过滤掉 btn-unavailable-slot (灰色的不可用按钮)
  269. available_btns = block.eles('xpath:.//button[starts-with(@data-testid, "btn-available-slot")]')
  270. for btn in available_btns:
  271. # 提取时间:无视内部各种 span 的变动,只要 html 里有 00:00 这种格式就被截取
  272. time_match = re.search(r'\d{2}:\d{2}', btn.html)
  273. if not time_match: continue
  274. time_str = time_match.group()
  275. # 提取 Label:完全依赖测试工程师留下的 testid
  276. test_id = btn.attr('data-testid') or ""
  277. if 'prime' in test_id and 'weekend' in test_id:
  278. lbl = 'ptaw'
  279. elif 'prime' in test_id:
  280. lbl = 'pta'
  281. else:
  282. lbl = ''
  283. slots.append({
  284. 'date': full_date,
  285. 'time': time_str,
  286. 'label': lbl
  287. })
  288. else:
  289. # ==========================================
  290. # 模式 B: 已经在目标月份 (JS Fetch + 正则 JSON 解析)
  291. # ==========================================
  292. self._log(f"Already on '{target_month_text}'. Executing silent JS fetch...")
  293. base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  294. params = {'location': apt_config["code"], 'month': interest_month}
  295. query_url = f"{base_url}?{urlencode(params)}"
  296. js_script = f"""
  297. return fetch("{query_url}", {{ credentials: "include" }})
  298. .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
  299. .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  300. """
  301. res_dict = self.page.run_js(js_script)
  302. resp = BrowserResponse(res_dict)
  303. if resp.status_code != 200:
  304. raise Exception(f"Silent Query Failed: {resp.status_code}")
  305. self._log("Extracting slots from JSON response...")
  306. pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
  307. match = re.search(pattern, resp.text, re.DOTALL)
  308. if match:
  309. json_str = match.group(1).replace(r'\"', '"')
  310. data = json.loads(json_str)
  311. for day in data:
  312. d_str = day.get('day')
  313. for s in day.get('slots', []):
  314. labels = s.get('labels', [])
  315. if not labels: continue
  316. lbl = ""
  317. if 'pta' in labels: lbl = 'pta'
  318. elif 'ptaw' in labels: lbl = 'ptaw'
  319. elif '' in labels: lbl = ''
  320. slots.append({
  321. 'date': d_str,
  322. 'time': s.get('time'),
  323. 'label': lbl
  324. })
  325. self._log(f"Found {len(slots)} valid slots.")
  326. return slots
  327. def _filter_dates(self, available_dates: list, start_str: str, end_str: str) -> list:
  328. if not start_str or not end_str:
  329. return available_dates
  330. valid = []
  331. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  332. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  333. for d in available_dates:
  334. curr = datetime.strptime(d, "%Y-%m-%d")
  335. if s_date <= curr <= e_date:
  336. valid.append(d)
  337. return valid
  338. def book(self, all_slots: list) -> bool:
  339. """执行预定流程"""
  340. if not all_slots:
  341. self._log("No slots provided to book.")
  342. return False
  343. # 1. 过滤日期 & 筛选标签
  344. target_labels = self.config.get('target_labels', [''])
  345. exp_start = self.config.get('expected_start_date', '')
  346. exp_end = self.config.get('expected_end_date', '')
  347. unique_dates = list(set([s['date'] for s in all_slots]))
  348. valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
  349. possible_slots =[
  350. s for s in all_slots
  351. if s['date'] in valid_dates and s['label'] in target_labels
  352. ]
  353. if not possible_slots:
  354. self._log("No slots match target dates and labels.")
  355. return False
  356. # 2. 随机选择一个 Slot
  357. selected = random.choice(possible_slots)
  358. sel_date = selected['date']
  359. sel_time = selected['time']
  360. sel_label = selected['label']
  361. self._log(f"Selected Slot -> Date: {sel_date}, Time: {sel_time}, Label: {sel_label or 'standard'}")
  362. group_num = self.travel_group['formGroupId']
  363. apt_config = self.config['apt_config']
  364. current_url = self.page.url
  365. router_state = f'%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{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'
  366. # 3. 获取金额 (Basket Cost)
  367. # self._log("Fetching basket cost...")
  368. # cost_payload =[{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
  369. # cost_body_json = json.dumps(cost_payload)
  370. # js_cost = f"""
  371. # return fetch("{current_url}", {{
  372. # method: 'POST',
  373. # headers: {{
  374. # 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
  375. # 'Next-Router-State-Tree': '{router_state}',
  376. # 'Accept': 'text/x-component',
  377. # 'Content-Type': 'text/plain;charset=UTF-8'
  378. # }},
  379. # body: `{cost_body_json}`
  380. # }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
  381. # .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  382. # """
  383. # cost_res = BrowserResponse(self.page.run_js(js_cost))
  384. # if cost_res.status_code != 200:
  385. # self._log(f"Basket cost check failed: {cost_res.status_code}")
  386. # return False
  387. # 4. 解决 ReCaptcha V3
  388. # self._log("Solving Booking ReCaptcha V3...")
  389. # g_token = self.solve_captcha(
  390. # page_url=current_url,
  391. # task_type="ReCaptchaV3TaskProxyLess",
  392. # site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  393. # use_proxy=False,
  394. # action="book",
  395. # api_domain="recaptcha.net"
  396. # )
  397. # 5. 注入 Hook 并点击表单
  398. # self._log("Injecting reCAPTCHA hook and modifying form...")
  399. # # 5.1 注入你提供的劫持 JS
  400. # hook_js = f"""
  401. # // 1. 填充可能存在的标准隐藏域
  402. # var input = document.getElementById('g-recaptcha-response');
  403. # if(input) {{
  404. # input.value = "{g_token}";
  405. # // 派发React识别的事件
  406. # input.dispatchEvent(new Event('input', {{ bubbles: true }}));
  407. # input.dispatchEvent(new Event('change', {{ bubbles: true }}));
  408. # }}
  409. # // 2. 劫持 grecaptcha.execute
  410. # var mockExecute = function() {{
  411. # console.log("[Hook] Recaptcha execution intercepted!");
  412. # return Promise.resolve("{g_token}");
  413. # }};
  414. # // 无论网页是否已加载完毕,保证对象存在并被劫持
  415. # if (!window.grecaptcha) {{
  416. # window.grecaptcha = {{}};
  417. # }}
  418. # window.grecaptcha.execute = mockExecute;
  419. # if (!window.grecaptcha.enterprise) {{
  420. # window.grecaptcha.enterprise = {{}};
  421. # }}
  422. # window.grecaptcha.enterprise.execute = mockExecute;
  423. # """
  424. # self.page.run_js(hook_js)
  425. # 5.2 注入表单数据并原样点击
  426. js_inject_and_click = f"""
  427. try {{
  428. const form = document.querySelector('form');
  429. if (!form) return 'Form not found';
  430. function setReactValue(input, value) {{
  431. if (!input) return;
  432. input.value = value;
  433. input.dispatchEvent(new Event('input', {{ bubbles: true }}));
  434. input.dispatchEvent(new Event('change', {{ bubbles: true }}));
  435. }}
  436. // 填入抢单日期、时间、标签
  437. setReactValue(form.querySelector('input[name="date"]'), '2026-06-01');
  438. setReactValue(form.querySelector('input[name="time"]'), '12:00');
  439. setReactValue(form.querySelector('input[name="appointmentLabel"]'), '{sel_label}');
  440. // 解禁并点击 Submit 按钮
  441. const submitBtn = form.querySelector('button[type="submit"]');
  442. if (submitBtn) {{
  443. submitBtn.removeAttribute('disabled');
  444. submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
  445. submitBtn.click();
  446. return 'clicked';
  447. }} else {{
  448. return 'Submit button not found';
  449. }}
  450. }} catch (e) {{
  451. return e.toString();
  452. }}
  453. """
  454. inject_res = self.page.run_js(js_inject_and_click)
  455. self._log(f"Form submission triggered: {inject_res}")
  456. if inject_res != 'clicked':
  457. self._log("❌ Failed to inject form or click the submit button.")
  458. return False
  459. # 6. 验证是否抢单成功 (轮询页面跳转)
  460. self._log("Waiting for Next.js to process the form submission...")
  461. for _ in range(15):
  462. time.sleep(1.0)
  463. current_page_url = self.page.url
  464. # Next.js 跳转进入确认页
  465. if "appointment-confirmation" in current_page_url:
  466. self._log(f"✅ BOOKING SUCCESS! Redirected to: {current_page_url}")
  467. return True
  468. try:
  469. body_text = str(self.page.run_js("return document.body.innerText || '';"))
  470. if "APPOINTMENT_LIMIT_REACHED" in body_text or "appointment limit" in body_text.lower():
  471. self._log("❌ BOOKING FAILED! Reason: Appointment Limit Reached")
  472. return False
  473. except Exception:
  474. pass
  475. self._log("❌ BOOKING FAILED! Timeout waiting for redirect confirmation.")
  476. return False
  477. # def book(self, all_slots: list) -> bool:
  478. # """执行预定流程"""
  479. # if not all_slots:
  480. # self._log("No slots provided to book.")
  481. # return False
  482. # # 1. 过滤日期 & 筛选标签
  483. # target_labels = self.config.get('target_labels', [''])
  484. # exp_start = self.config.get('expected_start_date', '')
  485. # exp_end = self.config.get('expected_end_date', '')
  486. # # 提取唯一的可用日期列表
  487. # unique_dates = list(set([s['date'] for s in all_slots]))
  488. # valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
  489. # possible_slots = [
  490. # s for s in all_slots
  491. # if s['date'] in valid_dates and s['label'] in target_labels
  492. # ]
  493. # if not possible_slots:
  494. # self._log("No slots match target dates and labels.")
  495. # return False
  496. # # 2. 随机选择一个 Slot
  497. # selected = random.choice(possible_slots)
  498. # sel_date = selected['date']
  499. # sel_time = selected['time']
  500. # sel_label = selected['label']
  501. # self._log(f"Selected Slot -> Date: {sel_date}, Time: {sel_time}, Label: {sel_label or 'standard'}")
  502. # group_num = self.travel_group['formGroupId']
  503. # apt_config = self.config['apt_config']
  504. # current_url = self.page.url
  505. # router_state = f'%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{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'
  506. # # 3. 获取金额 (Basket Cost)
  507. # self._log("Fetching basket cost...")
  508. # cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
  509. # cost_body_json = json.dumps(cost_payload)
  510. # js_cost = f"""
  511. # return fetch("{current_url}", {{
  512. # method: 'POST',
  513. # headers: {{
  514. # 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
  515. # 'Next-Router-State-Tree': '{router_state}',
  516. # 'Accept': 'text/x-component',
  517. # 'Content-Type': 'text/plain;charset=UTF-8'
  518. # }},
  519. # body: `{cost_body_json}`
  520. # }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
  521. # .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  522. # """
  523. # cost_res = BrowserResponse(self.page.run_js(js_cost))
  524. # if cost_res.status_code != 200:
  525. # self._log(f"Basket cost check failed: {cost_res.status_code}")
  526. # return False
  527. # # 4. 解决 ReCaptcha V3
  528. # self._log("Solving Booking ReCaptcha V3...")
  529. # g_token = self.solve_captcha(
  530. # page_url=current_url,
  531. # task_type="ReCaptchaV3M1TaskProxyLess",
  532. # site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  533. # use_proxy=False,
  534. # action="book",
  535. # api_domain="recaptcha.net"
  536. # )
  537. # # 5. 提交 Booking
  538. # self._log("Submitting final booking request...")
  539. # js_book = f"""
  540. # const formData = new FormData();
  541. # formData.append('1_formGroupId', '{group_num}');
  542. # formData.append('1_lang', 'en-us');
  543. # formData.append('1_process', 'APPOINTMENT');
  544. # formData.append('1_location', '{apt_config["code"]}');
  545. # formData.append('1_date', '{sel_date}');
  546. # formData.append('1_time', '{sel_time}');
  547. # formData.append('1_appointmentLabel', '{sel_label}');
  548. # formData.append('1_captchaToken', '{g_token}');
  549. # formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
  550. # return fetch("{current_url}", {{
  551. # method: 'POST',
  552. # headers: {{
  553. # 'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
  554. # 'Next-Router-State-Tree': '{router_state}',
  555. # 'Accept': 'text/x-component'
  556. # }},
  557. # body: formData
  558. # }}).then(async r => {{
  559. # const hdrs = {{}};
  560. # r.headers.forEach((v, k) => hdrs[k] = v);
  561. # return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
  562. # }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  563. # """
  564. # book_res_dict = self.page.run_js(js_book)
  565. # book_resp = BrowserResponse(book_res_dict)
  566. # # 6. 解析结果 (判定 Next.js 跳转)
  567. # headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
  568. # action_redirect = headers_lower.get('x-action-redirect', '')
  569. # is_success = (
  570. # book_resp.status_code == 303 or
  571. # (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
  572. # )
  573. # if is_success:
  574. # self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
  575. # return True
  576. # else:
  577. # self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
  578. # if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
  579. # self._log("-> Reason: Appointment Limit Reached")
  580. # else:
  581. # self._log(f"-> Response Body: {book_resp.text[:300]}")
  582. # return False
  583. def cleanup(self):
  584. self._log("Cleaning up resources...")
  585. if self.page:
  586. try: self.page.quit()
  587. except: pass
  588. if os.path.exists(self.workspace):
  589. time.sleep(1)
  590. shutil.rmtree(self.workspace, ignore_errors=True)
  591. # =====================================================================
  592. # 运行主逻辑
  593. # =====================================================================
  594. if __name__ == "__main__":
  595. # 填写你的账号配置
  596. MY_CONFIG = {
  597. # 账号信息
  598. "account": {
  599. "username": "mayun06@gmail-app.com",
  600. "password": "Visafly@111"
  601. },
  602. # 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
  603. "apt_config": {
  604. "country": "cn",
  605. "city": "Chengdu",
  606. "code": "cnCNG2fr"
  607. },
  608. # 代理配置
  609. "proxy": {
  610. "schema": "http",
  611. "ip": "127.0.0.1",
  612. "port": "7890",
  613. "username": "",
  614. "password": ""
  615. },
  616. # Capsolver API Key
  617. "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
  618. # 查询的月份 (格式: MM-YYYY)
  619. "interest_month": "06-2026",
  620. # 期望的日期范围
  621. "expected_start_date": "2026-06-01",
  622. "expected_end_date": "2026-06-30",
  623. # 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
  624. "target_labels": [""]
  625. }
  626. bot = TlsAutoBot(config=MY_CONFIG)
  627. try:
  628. # 1. 登录
  629. bot.login()
  630. # 2. 检查是否有号
  631. slots = bot.query_slots()
  632. if slots:
  633. bot._log(f"Found {len(slots)} total available slots in this month.")
  634. # 3. 尝试预订
  635. success = bot.book(slots)
  636. if success:
  637. print("\n🎉 Congratulations! Slot booked successfully!")
  638. else:
  639. print("\n⚠️ Failed to book the slot.")
  640. else:
  641. bot._log("No available slots found for the requested criteria.")
  642. time.sleep(3600)
  643. except Exception as e:
  644. bot._log(f"Error occurred during execution: {str(e)}")
  645. finally:
  646. bot.cleanup()