tls_standalone.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  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 ProxyTunnel:
  18. """
  19. 【修复优化版】管理本地代理隧道
  20. 1. 启用 TCP_NODELAY 消除握手延迟 (关键修复)
  21. 2. 开启 KeepAlive 防止链路中断
  22. 3. 修复非阻塞模式下 sendall 导致的数据丢失问题
  23. """
  24. def __init__(self, upstream_ip, upstream_port, username, password):
  25. self.upstream_ip = upstream_ip
  26. self.upstream_port = int(upstream_port)
  27. self.username = username
  28. self.password = password
  29. # 预先计算 Proxy-Authorization 头
  30. auth_str = f"{username}:{password}"
  31. b64_auth = base64.b64encode(auth_str.encode()).decode()
  32. self.auth_header = f"Proxy-Authorization: Basic {b64_auth}\r\n"
  33. self.server_socket = None
  34. self.local_port = 0
  35. self.running = False
  36. self.listen_thread = None
  37. def start(self):
  38. try:
  39. self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  40. self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  41. self.server_socket.bind(('127.0.0.1', 0))
  42. self.local_port = self.server_socket.getsockname()[1]
  43. self.server_socket.listen(128) # 增加连接队列长度
  44. self.running = True
  45. self.listen_thread = threading.Thread(target=self._accept_loop, daemon=True)
  46. self.listen_thread.start()
  47. return f"127.0.0.1:{self.local_port}"
  48. except Exception as e:
  49. self.stop()
  50. raise RuntimeError(f"Failed to start tunnel: {e}")
  51. def stop(self):
  52. self.running = False
  53. if self.server_socket:
  54. try:
  55. self.server_socket.close()
  56. except Exception:
  57. pass
  58. self.server_socket = None
  59. def _accept_loop(self):
  60. while self.running:
  61. try:
  62. if self.server_socket:
  63. # 使用 select 替代 settimeout,减少 CPU 空转
  64. r, _, _ = select.select([self.server_socket], [], [], 1.0)
  65. if r:
  66. try:
  67. client_sock, _ = self.server_socket.accept()
  68. t = threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True)
  69. t.start()
  70. except OSError:
  71. break
  72. except Exception:
  73. continue
  74. def _optimize_socket(self, sock):
  75. """核心优化:设置 Socket 选项"""
  76. try:
  77. # 1. 禁用 Nagle 算法:数据包立即发送,不等待填满缓冲区
  78. # 这是解决 "HttpClient Timeout" 的关键
  79. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  80. # 2. 开启 KeepAlive:防止防火墙切断空闲连接
  81. sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
  82. # 3. 增大缓冲区 (Linux/Mac 可选,Windows 一般自动管理)
  83. sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 32*1024)
  84. sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 32*1024)
  85. except Exception:
  86. pass
  87. def _handle_client(self, client_sock):
  88. upstream_sock = None
  89. try:
  90. self._optimize_socket(client_sock)
  91. client_sock.settimeout(30)
  92. # 1. 读取首包 (32KB 缓冲区)
  93. try:
  94. first_packet = client_sock.recv(32768)
  95. except socket.timeout:
  96. return # 客户端连上但不发数据
  97. if not first_packet:
  98. return
  99. # 2. 连接上游
  100. upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  101. self._optimize_socket(upstream_sock) # 同样优化上游 Socket
  102. upstream_sock.settimeout(10) # 连接超时 10s
  103. upstream_sock.connect((self.upstream_ip, self.upstream_port))
  104. # 连接建立后,将超时设为 None (阻塞模式),交由 select 控制
  105. upstream_sock.settimeout(None)
  106. client_sock.settimeout(None)
  107. # 3. 注入 Header
  108. sep = b'\r\n'
  109. idx = first_packet.find(sep)
  110. if idx != -1:
  111. new_packet = first_packet[:idx+2] + self.auth_header.encode() + first_packet[idx+2:]
  112. else:
  113. new_packet = first_packet
  114. # 4. 发送首包 (阻塞式发送,保证数据完整)
  115. upstream_sock.sendall(new_packet)
  116. # 5. 双向转发
  117. self._pipe_sockets(client_sock, upstream_sock)
  118. except Exception:
  119. pass
  120. finally:
  121. self._close_socket(client_sock)
  122. self._close_socket(upstream_sock)
  123. def _pipe_sockets(self, sock1, sock2):
  124. """
  125. 修复后的转发逻辑:
  126. 保持 Socket 为阻塞模式,利用 select 监听可读状态。
  127. """
  128. sockets = [sock1, sock2]
  129. last_activity = time.time()
  130. IDLE_TIMEOUT = 120 # 延长空闲超时
  131. while self.running:
  132. try:
  133. # 监听可读事件
  134. r, _, x = select.select(sockets, [], sockets, 1.0)
  135. if x: break # Socket 异常
  136. if not r:
  137. if time.time() - last_activity > IDLE_TIMEOUT:
  138. break
  139. continue
  140. for s in r:
  141. try:
  142. # 尝试读取
  143. data = s.recv(32768)
  144. except ConnectionResetError:
  145. data = None
  146. if not data:
  147. return # 连接关闭
  148. # 确定发送目标
  149. target = sock2 if s is sock1 else sock1
  150. # 关键修改:使用阻塞式 sendall
  151. # 如果网络卡顿,线程会在这里暂停等待,而不是抛出错误或丢包
  152. try:
  153. target.sendall(data)
  154. except BrokenPipeError:
  155. return
  156. last_activity = time.time()
  157. except Exception:
  158. break
  159. def _close_socket(self, sock):
  160. """优雅关闭 Socket"""
  161. if sock:
  162. try:
  163. # 发送 FIN 包,通知对端数据发送完毕
  164. sock.shutdown(socket.SHUT_RDWR)
  165. except Exception:
  166. pass
  167. try:
  168. sock.close()
  169. except Exception:
  170. pass
  171. def __del__(self):
  172. self.stop()
  173. class BrowserResponse:
  174. """模拟 requests.Response 的轻量级对象"""
  175. def __init__(self, result_dict):
  176. result_dict = result_dict or {}
  177. self.status_code = result_dict.get('status', 0)
  178. self.text = result_dict.get('body', '')
  179. self.headers = result_dict.get('headers', {})
  180. self.url = result_dict.get('url', '')
  181. class TlsAutoBot:
  182. def __init__(self, config: dict):
  183. """
  184. config 包含: proxy, account, capsolver_key, apt_config (code, country, city), target_dates 等
  185. """
  186. self.config = config
  187. self.instance_id = uuid.uuid4().hex[:8]
  188. self.workspace = os.path.abspath(os.path.join("data", f"tls_session_{self.instance_id}"))
  189. self.page = None
  190. self.travel_group = None
  191. self.tunnel = None
  192. def _log(self, msg):
  193. print(f"[TLS-Bot-{self.instance_id}] {msg}")
  194. def _get_free_port(self):
  195. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  196. s.bind(('', 0))
  197. return s.getsockname()[1]
  198. def init_browser(self):
  199. self._log("Initializing browser...")
  200. co = ChromiumOptions()
  201. # 1. 端口与路径隔离
  202. port = self._get_free_port()
  203. co.set_local_port(port)
  204. co.set_user_data_path(self.workspace)
  205. # 2. 代理配置
  206. proxy_cfg = self.config.get('proxy', {})
  207. if proxy_cfg.get("username") and proxy_cfg.get("password"):
  208. self._log(f"Starting Proxy Tunnel for {proxy_cfg.get('ip')}...")
  209. # 1. 启动本地隧道
  210. self.tunnel = ProxyTunnel(proxy_cfg.get('ip'), proxy_cfg.get('port'), proxy_cfg.get('username'), proxy_cfg.get('password'))
  211. local_proxy = self.tunnel.start()
  212. self._log(f"Tunnel started at {local_proxy}")
  213. # 2. Chrome 连接本地免密端口
  214. # 必须使用 --proxy-server 强制指定,绝对稳健
  215. co.set_argument(f'--proxy-server={local_proxy}')
  216. else:
  217. # 无密码代理,直接用
  218. proxy_str = f"{proxy_cfg.get('schema')}://{proxy_cfg.get('ip')}:{proxy_cfg.get('port')}"
  219. co.set_argument(f'--proxy-server={proxy_str}')
  220. # 3. 反爬配置
  221. co.headless(False)
  222. co.set_argument('--no-sandbox')
  223. co.set_argument('--disable-gpu')
  224. co.set_argument('--disable-dev-shm-usage')
  225. co.set_argument('--window-size=1920,1080')
  226. co.set_argument('--disable-blink-features=AutomationControlled')
  227. self.page = ChromiumPage(co)
  228. def solve_captcha(self, page_url: str, task_type: str, site_key: str, use_proxy = False, action: str = None) -> str:
  229. """通用解决验证码 (同步 User-Agent 防止被盾识别为高风险)"""
  230. capsolver_key = self.config.get('capsolver_key')
  231. if not capsolver_key:
  232. raise ValueError("Capsolver API key missing")
  233. task = {
  234. "type": task_type,
  235. "websiteURL": page_url,
  236. "websiteKey": site_key,
  237. }
  238. if use_proxy:
  239. proxy = self.config['proxy']
  240. task["proxyType"] = proxy.get('scheme', 'http')
  241. task["proxyAddress"] = proxy.get('ip')
  242. task["proxyPort"] = int(proxy.get('port'))
  243. if proxy.get('username'):
  244. task["proxyLogin"] = proxy.get('username')
  245. task["proxyPassword"] = proxy.get('password')
  246. if action:
  247. task["pageAction"] = action
  248. payload = {"clientKey": capsolver_key, "task": task}
  249. res = requests.post("https://api.capsolver.com/createTask", json=payload, timeout=20)
  250. if res.status_code != 200 or res.json().get("errorId") != 0:
  251. raise Exception(f"Failed to create capsolver task: {res.text}")
  252. task_id = res.json().get("taskId")
  253. self._log(f"Task created: {task_id}. Waiting for solution...")
  254. for _ in range(30):
  255. r = requests.post(
  256. "https://api.capsolver.com/getTaskResult",
  257. json={"clientKey": capsolver_key, "taskId": task_id},
  258. timeout=20
  259. )
  260. data = r.json()
  261. if data.get("status") == "ready":
  262. self._log("Captcha solved successfully!")
  263. return data["solution"].get("gRecaptchaResponse") or data["solution"].get("token")
  264. time.sleep(3)
  265. raise Exception("Capsolver task timeout")
  266. def login(self):
  267. """执行自动登录流程并提取 Group ID"""
  268. self.init_browser()
  269. apt_config = self.config['apt_config']
  270. login_url = "https://visas-fr.tlscontact.com/en-us/login"
  271. params = {
  272. "issuerId": apt_config["code"],
  273. "country": apt_config["country"],
  274. "vac": apt_config["code"],
  275. "redirect": f"/en-us/country/{apt_config['country']}/vac/{apt_config['code']}"
  276. }
  277. full_login_url = f"{login_url}?{urlencode(params)}"
  278. self._log(f"Navigating to login: {full_login_url}")
  279. self.page.get(full_login_url)
  280. time.sleep(3)
  281. wait_start = time.time()
  282. while True:
  283. # 获取页面 HTML,转小写
  284. # 注意:如果此处报错 "页面被刷新",是 DrissionPage 的机制问题,
  285. # 但你要求先不处理复杂错误,所以这里保持最简单的写法。
  286. html = self.page.html.lower()
  287. # 检查是否在排队室 (法语或英语)
  288. if "file d'attente" in html or "waiting room" in html:
  289. # 如果等太久(比如1小时),就强制停止
  290. if time.time() - wait_start > 6 * 60:
  291. self._log("Waiting room timeout (1h).")
  292. break
  293. self._log("In Waiting Room... Waiting for auto-refresh.")
  294. time.sleep(30) # 截图说页面会自动刷新,所以这里只sleep,不动浏览器
  295. else:
  296. # 页面里没有“等候室”的字了,说明出来了
  297. break
  298. if not self.page.ele('#email-input-field'):
  299. self._log("Form not found, reloading...")
  300. self.page.get(full_login_url)
  301. self.page.wait.ele_displayed('#email-input-field', timeout=15)
  302. self._log("Waiting 3 seconds for Captcha scripts to load...")
  303. time.sleep(3)
  304. g_token = ""
  305. # 判断登录页是否有 ReCaptcha
  306. if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
  307. self._log("Login ReCaptcha detected, solving...")
  308. # 登录页通常是 V2
  309. g_token = self.solve_captcha(
  310. page_url=self.page.url,
  311. task_type="ReCaptchaV2TaskProxyLess",
  312. site_key="6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0"
  313. )
  314. account = self.config['account']
  315. js_login = f"""
  316. var u = document.getElementById('email-input-field');
  317. if(u) {{ u.value = "{account['username']}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
  318. var p = document.getElementById('password-input-field');
  319. if(p) {{ p.value = "{account['password']}"; p.dispatchEvent(new Event('input', {{bubbles:true}})); }}
  320. var g = document.getElementById('g-recaptcha-response');
  321. if(g) {{ g.value = "{g_token}"; }}
  322. var btn = document.getElementById('btn-login');
  323. if(btn) {{ btn.click(); return true; }} else {{ return false; }}
  324. """
  325. self._log("Submitting Login via JS...")
  326. self.page.run_js(js_login)
  327. self._log("Waiting for dashboard redirect...")
  328. self.page.wait.url_change('login-actions', exclude=True, timeout=45)
  329. time.sleep(4)
  330. if "login-actions" in self.page.url or "auth" in self.page.url:
  331. raise Exception("Login Failed! Invalid credentials or Captcha rejected.")
  332. self._log("Waiting for dashboard...")
  333. self.page.wait.load_start()
  334. time.sleep(5)
  335. # 解析 Dashboard 提取 Group ID
  336. self._log("Parsing Dashboard for Travel Group...")
  337. html = self.page.html
  338. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  339. js_match = re.search(js_pattern, html, re.DOTALL)
  340. groups = []
  341. if js_match:
  342. json_str = js_match.group(1).replace(r'\"', '"')
  343. groups = json.loads(json_str)
  344. target_city = apt_config['city'].lower()
  345. for g in groups:
  346. if g.get('vacName', '').lower() == target_city:
  347. self.travel_group = g
  348. break
  349. if not self.travel_group:
  350. raise Exception(f"Travel Group not found for city: {target_city}")
  351. self._log(f"Login Success! Target Group ID: {self.travel_group['formGroupId']}")
  352. def query_slots(self) -> list:
  353. """通过 JS Fetch 获取可用日期并解析"""
  354. self._log("Querying available slots...")
  355. group_num = self.travel_group['formGroupId']
  356. apt_config = self.config['apt_config']
  357. interest_month = self.config.get("interest_month", time.strftime("%m-%Y"))
  358. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  359. params = {'location': apt_config["code"], 'month': interest_month}
  360. # 组装完整的 query url
  361. query_url = f"{url}?{urlencode(params)}"
  362. js_script = f"""
  363. return fetch("{query_url}", {{ credentials: "include" }})
  364. .then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
  365. .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  366. """
  367. res_dict = self.page.run_js(js_script)
  368. resp = BrowserResponse(res_dict)
  369. if resp.status_code != 200:
  370. raise Exception(f"Query Failed: {resp.status_code} - {resp.text[:100]}")
  371. # 解析正则 (保持原样)
  372. slots = []
  373. pattern = r'"availableAppointments\\":\s*(\[.*?\]),\\"showFlexiAppointment'
  374. match = re.search(pattern, resp.text, re.DOTALL)
  375. if match:
  376. json_str = match.group(1).replace(r'\"', '"')
  377. data = json.loads(json_str)
  378. for day in data:
  379. d_str = day.get('day')
  380. for s in day.get('slots', []):
  381. labels = s.get('labels', [])
  382. if not labels:
  383. continue # 空数组说明 unavailable
  384. lbl = ""
  385. if 'pta' in labels: lbl = 'pta'
  386. elif 'ptaw' in labels: lbl = 'ptaw'
  387. elif '' in labels: lbl = ''
  388. slots.append({
  389. 'date': d_str,
  390. 'time': s.get('time'),
  391. 'label': lbl
  392. })
  393. return slots
  394. def _filter_dates(self, available_dates: list, start_str: str, end_str: str) -> list:
  395. if not start_str or not end_str:
  396. return available_dates
  397. valid = []
  398. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  399. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  400. for d in available_dates:
  401. curr = datetime.strptime(d, "%Y-%m-%d")
  402. if s_date <= curr <= e_date:
  403. valid.append(d)
  404. return valid
  405. def book(self, all_slots: list) -> bool:
  406. """执行预定流程"""
  407. if not all_slots:
  408. self._log("No slots provided to book.")
  409. return False
  410. # 1. 过滤日期 & 筛选标签
  411. target_labels = self.config.get('target_labels', [''])
  412. exp_start = self.config.get('expected_start_date', '')
  413. exp_end = self.config.get('expected_end_date', '')
  414. # 提取唯一的可用日期列表
  415. unique_dates = list(set([s['date'] for s in all_slots]))
  416. valid_dates = self._filter_dates(unique_dates, exp_start, exp_end)
  417. possible_slots = [
  418. s for s in all_slots
  419. if s['date'] in valid_dates and s['label'] in target_labels
  420. ]
  421. if not possible_slots:
  422. self._log("No slots match target dates and labels.")
  423. return False
  424. # 2. 随机选择一个 Slot
  425. selected = random.choice(possible_slots)
  426. sel_date = selected['date']
  427. sel_time = selected['time']
  428. sel_label = selected['label']
  429. self._log(f"Selected Slot -> Date: {sel_date}, Time: {sel_time}, Label: {sel_label or 'standard'}")
  430. group_num = self.travel_group['formGroupId']
  431. apt_config = self.config['apt_config']
  432. base_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  433. # [关键修复] Next.js Action 必须带上正确的 Query 参数
  434. year, month, day = sel_date.split('-')
  435. formatted_month = f"{month}-{year}"
  436. full_url = f'{base_url}?location={apt_config["code"]}&month={formatted_month}'
  437. 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'
  438. # 3. 获取金额 (Basket Cost)
  439. self._log("Fetching basket cost...")
  440. cost_payload = [{"groupId": str(group_num), "lang": "en-us", "labels": [sel_label]}]
  441. cost_body_json = json.dumps(cost_payload)
  442. js_cost = f"""
  443. return fetch("{full_url}", {{
  444. method: 'POST',
  445. headers: {{
  446. 'Next-Action': '40124cc90acef520d4fd2daf60ad3c8e21fc2c11d8',
  447. 'Next-Router-State-Tree': '{router_state}',
  448. 'Accept': 'text/x-component',
  449. 'Content-Type': 'text/plain;charset=UTF-8'
  450. }},
  451. body: `{cost_body_json}`
  452. }}).then(async r => {{ return {{ status: r.status, body: await r.text() }}; }})
  453. .catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  454. """
  455. cost_res = BrowserResponse(self.page.run_js(js_cost))
  456. if cost_res.status_code != 200:
  457. self._log(f"Basket cost check failed: {cost_res.status_code}")
  458. return False
  459. # 4. 解决 ReCaptcha V3
  460. self._log("Solving Booking ReCaptcha V3...")
  461. g_token = self.solve_captcha(
  462. page_url=full_url,
  463. task_type="ReCaptchaV3Task",
  464. site_key="6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  465. use_proxy=True,
  466. action="book"
  467. )
  468. # 5. 提交 Booking
  469. self._log("Submitting final booking request...")
  470. js_book = f"""
  471. const formData = new FormData();
  472. formData.append('1_formGroupId', '{group_num}');
  473. formData.append('1_lang', 'en-us');
  474. formData.append('1_process', 'APPOINTMENT');
  475. formData.append('1_location', '{apt_config["code"]}');
  476. formData.append('1_date', '{sel_date}');
  477. formData.append('1_time', '{sel_time}');
  478. formData.append('1_appointmentLabel', '{sel_label}');
  479. formData.append('1_captchaToken', '{g_token}');
  480. formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
  481. return fetch("{full_url}", {{
  482. method: 'POST',
  483. headers: {{
  484. 'Next-Action': '6043cfd107081bc817cbb11a8c0db17d3a063401be',
  485. 'Next-Router-State-Tree': '{router_state}',
  486. 'Accept': 'text/x-component'
  487. }},
  488. body: formData
  489. }}).then(async r => {{
  490. const hdrs = {{}};
  491. r.headers.forEach((v, k) => hdrs[k] = v);
  492. return {{ status: r.status, body: await r.text(), headers: hdrs, url: r.url }};
  493. }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  494. """
  495. book_res_dict = self.page.run_js(js_book)
  496. book_resp = BrowserResponse(book_res_dict)
  497. # 6. 解析结果 (判定 Next.js 跳转)
  498. headers_lower = {str(k).lower(): v for k, v in book_resp.headers.items()}
  499. action_redirect = headers_lower.get('x-action-redirect', '')
  500. is_success = (
  501. book_resp.status_code == 303 or
  502. (book_resp.status_code == 200 and ("appointment-confirmation" in action_redirect or "appointment-confirmation" in book_resp.url))
  503. )
  504. if is_success:
  505. self._log(f"✅ BOOKING SUCCESS! Redirected to: {action_redirect or book_resp.url}")
  506. return True
  507. else:
  508. self._log(f"❌ BOOKING FAILED! Status: {book_resp.status_code}")
  509. if "APPOINTMENT_LIMIT_REACHED" in book_resp.text:
  510. self._log("-> Reason: Appointment Limit Reached")
  511. else:
  512. self._log(f"-> Response Body: {book_resp.text[:300]}")
  513. return False
  514. def cleanup(self):
  515. self._log("Cleaning up resources...")
  516. if self.page:
  517. try: self.page.quit()
  518. except: pass
  519. if os.path.exists(self.workspace):
  520. time.sleep(1)
  521. shutil.rmtree(self.workspace, ignore_errors=True)
  522. # =====================================================================
  523. # 运行主逻辑
  524. # =====================================================================
  525. if __name__ == "__main__":
  526. # 填写你的账号配置
  527. MY_CONFIG = {
  528. # 账号信息
  529. "account": {
  530. "username": "zhangsan06@gmail-app.com",
  531. "password": "Visafly@111"
  532. },
  533. # 目标签证中心信息 (例如广州 TLS: cnCNG2fr)
  534. "apt_config": {
  535. "country": "cn",
  536. "city": "Chengdu",
  537. "code": "cnCNG2fr"
  538. },
  539. # 代理配置
  540. "proxy": {
  541. "scheme": "http",
  542. "ip": "95.135.130.10",
  543. "port": "46107",
  544. "username": "Iz1WuKKwt1KUzEe",
  545. "password": "G7syngmdyGURblY"
  546. },
  547. # Capsolver API Key
  548. "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
  549. # 查询的月份 (格式: MM-YYYY)
  550. "interest_month": "06-2026",
  551. # 期望的日期范围
  552. "expected_start_date": "2026-06-01",
  553. "expected_end_date": "2026-06-30",
  554. # 目标标签: '' 是普通号, 'pta' 是 Prime 黄金时间号
  555. "target_labels": ["", "pta"]
  556. }
  557. bot = TlsAutoBot(config=MY_CONFIG)
  558. try:
  559. # 1. 登录
  560. bot.login()
  561. # 2. 检查是否有号
  562. slots = bot.query_slots()
  563. if slots:
  564. bot._log(f"Found {len(slots)} total available slots in this month.")
  565. # 3. 尝试预订
  566. success = bot.book(slots)
  567. if success:
  568. print("\n🎉 Congratulations! Slot booked successfully!")
  569. else:
  570. print("\n⚠️ Failed to book the slot.")
  571. else:
  572. bot._log("No available slots found for the requested criteria.")
  573. time.sleep(3600)
  574. except Exception as e:
  575. bot._log(f"Error occurred during execution: {str(e)}")
  576. finally:
  577. bot.cleanup()