tls_plugin.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. from datetime import datetime
  7. from typing import List, Dict, Optional, Any
  8. from urllib.parse import urljoin, urlparse
  9. # 第三方库
  10. try:
  11. from curl_cffi import requests, const
  12. from bs4 import BeautifulSoup
  13. except ImportError:
  14. raise ImportError("Missing dependencies. Run: pip install curl-cffi beautifulsoup4")
  15. # 框架依赖
  16. from vs_plg import IVSPlg, VSError # type: ignore
  17. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
  18. from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
  19. from toolkit.vs_cloud_api import VSCloudApi # type: ignore
  20. class TlsPlugin(IVSPlg):
  21. """
  22. TLS 签证预约插件
  23. 适配法国签证 (FR) 流程
  24. """
  25. def __init__(self, group_id: str):
  26. self.group_id = group_id
  27. self.config: Optional[VSPlgConfig] = None
  28. self.free_config: Dict[str, Any] = {}
  29. # 会话相关
  30. self.session: Optional[requests.Session] = None
  31. self.travel_group: Optional[Dict] = None
  32. self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
  33. # 状态
  34. self.last_error = VSError(0, "OK")
  35. self.is_healthy = True
  36. def _save_debug_html(self, content: str, prefix: str = "debug"):
  37. """
  38. 辅助方法:将页面 HTML 保存到本地 debug_pages 目录
  39. """
  40. try:
  41. # 确保目录存在
  42. save_dir = "debug_pages"
  43. if not os.path.exists(save_dir):
  44. os.makedirs(save_dir)
  45. # 生成文件名: prefix_GroupID_时间戳.html
  46. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  47. filename = f"{save_dir}/{prefix}_{self.group_id}_{timestamp}.html"
  48. with open(filename, "w", encoding="utf-8") as f:
  49. f.write(content)
  50. VSC_INFO("tls_plg", "[%s] HTML saved to: %s", self.group_id, filename)
  51. except Exception as e:
  52. VSC_WARN("tls_plg", "[%s] Failed to save debug HTML: %s", self.group_id, str(e))
  53. def get_group_id(self) -> str:
  54. return self.group_id
  55. def set_config(self, config: VSPlgConfig):
  56. self.config = config
  57. try:
  58. self.free_config = json.loads(config.free_config) if config.free_config else {}
  59. except:
  60. self.free_config = {}
  61. def health_check(self) -> bool:
  62. return self.is_healthy
  63. def get_last_error(self) -> VSError:
  64. return self.last_error
  65. def _set_error(self, code: int, message: str):
  66. self.last_error = VSError(code, message)
  67. VSC_ERROR("tls_plg", "[%s] Error %d: %s", self.group_id, code, message)
  68. if code in [2003, 2000, 2001]: # 会话无效或登录失败
  69. self.is_healthy = False
  70. # ---------------------------------------------------------
  71. # 核心接口实现
  72. # ---------------------------------------------------------
  73. def create_session(self) -> bool:
  74. """
  75. 创建会话:处理 Cloudflare -> 登录 -> 获取 Travel Group
  76. """
  77. VSC_INFO("tls_plg", "[%s] Creating session...", self.group_id)
  78. self.is_healthy = True
  79. # 1. 初始化 Session
  80. curlopt = {
  81. const.CurlOpt.MAXAGE_CONN: 1800,
  82. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  83. const.CurlOpt.VERBOSE: False, # 生产环境建议关闭
  84. }
  85. # 构造代理
  86. proxy_url = ""
  87. if self.config.proxy.ip:
  88. s = self.config.proxy
  89. if s.username:
  90. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  91. else:
  92. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  93. self.session = requests.Session(
  94. proxy=proxy_url,
  95. impersonate="chrome124",
  96. curl_options=curlopt,
  97. use_thread_local_curl=False,
  98. http_version=const.CurlHttpVersion.V2TLS
  99. )
  100. embassy = self._get_embassy_config()
  101. if not embassy:
  102. return False
  103. # 2. 解决 Cloudflare 5s 盾
  104. if not self._solve_cloudflare5S_challenge(embassy, proxy_url):
  105. self._set_error(1001, "Cloudflare challenge failed")
  106. return False
  107. # 3. 获取登录页面参数 (OIDC)
  108. login_page = "https://visas-fr.tlscontact.com/en-us/login"
  109. params = {
  110. "issuerId": embassy["code"],
  111. "country": embassy["country"],
  112. "vac": embassy["code"],
  113. "redirect": f"/en-us/country/{embassy['country']}/vac/{embassy['code']}"
  114. }
  115. headers = {
  116. 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  117. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
  118. 'Referer': f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}/vac/{embassy["code"]}',
  119. 'User-Agent': self.user_agent,
  120. }
  121. try:
  122. resp = self.session.get(login_page, headers=headers, params=params)
  123. if resp.status_code != 200:
  124. self._set_error(resp.status_code, f"Get Login Page Failed: {resp.status_code}")
  125. return False
  126. # 解析 Keycloak 登录地址
  127. soup = BeautifulSoup(resp.text, 'html.parser')
  128. form = soup.find('form')
  129. if not form:
  130. self._set_error(2005, "Login form not found")
  131. return False
  132. action = form.get('action')
  133. authenticate_url = action if action.startswith('http') else urljoin(resp.url, action)
  134. except Exception as e:
  135. self._set_error(1099, f"Network error during login init: {e}")
  136. return False
  137. # 4. 解决 ReCaptcha V2 (登录验证码)
  138. # 注意:这里需要 API Token,从配置获取
  139. api_token = self.free_config.get("capsolver_key", "")
  140. if not api_token:
  141. VSC_WARN("tls_plg", "Missing 'capsolver_key' in free_config, captcha might fail.")
  142. rc_params = {
  143. "type": "ReCaptchaV2TaskProxyLess", # 或 ReCaptchaV2Task 配合 proxy
  144. "page": resp.url,
  145. "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
  146. "apiToken": api_token,
  147. "proxy": proxy_url
  148. }
  149. g_token = self._solve_recaptcha(rc_params)
  150. if not g_token:
  151. self._set_error(1001, "Failed to solve Login Recaptcha")
  152. return False
  153. # 5. 提交登录
  154. payload = {
  155. 'username': self.config.account.username,
  156. 'password': self.config.account.password,
  157. 'g-recaptcha-response': g_token
  158. }
  159. headers['Content-Type'] = 'application/x-www-form-urlencoded'
  160. try:
  161. resp = self.session.post(authenticate_url, headers=headers, data=payload)
  162. if resp.status_code != 200:
  163. self._set_error(resp.status_code, f"Login Submit Failed: {resp.status_code}")
  164. return False
  165. # 6. 解析 Travel Groups
  166. groups = self._parse_travel_groups(resp.text)
  167. if not groups:
  168. # 检查是否包含错误信息
  169. if "Invalid username or password" in resp.text:
  170. self._set_error(2000, "Invalid username or password")
  171. else:
  172. self._set_error(2005, "No Travel Groups found after login")
  173. return False
  174. # 选择匹配城市的 Group
  175. target_city = embassy['city'].lower()
  176. for g in groups:
  177. if g['location'].lower() == target_city:
  178. self.travel_group = g
  179. break
  180. if not self.travel_group:
  181. self._set_error(2005, f"No group found for city {target_city}")
  182. return False
  183. VSC_INFO("tls_plg", "[%s] Session created. Group: %s", self.group_id, self.travel_group['group_number'])
  184. return True
  185. except Exception as e:
  186. self._set_error(1099, f"Login exception: {e}")
  187. return False
  188. def query(self) -> VSQueryResult:
  189. res = VSQueryResult()
  190. if not self.session or not self.travel_group:
  191. self._set_error(2003, "Session invalid, please login first")
  192. return res
  193. embassy = self._get_embassy_config()
  194. group_num = self.travel_group['group_number']
  195. interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
  196. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  197. params = {
  198. 'location': embassy["code"],
  199. 'month': interest_month,
  200. }
  201. headers = {
  202. 'accept': '*/*',
  203. 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
  204. 'referer': f'{url}?location={embassy["code"]}',
  205. 'user-agent': self.user_agent,
  206. }
  207. try:
  208. resp = self.session.get(url, params=params, headers=headers)
  209. # 1. 检查 Cloudflare 403 (硬性拦截)
  210. if resp.status_code == 403:
  211. VSC_WARN("tls_plg", "[%s] Query 403 Forbidden. Solving Cloudflare...", self.group_id)
  212. if self._solve_cloudflare5S_challenge(embassy):
  213. resp = self.session.get(url, params=params, headers=headers)
  214. else:
  215. self._set_error(2006, "Cloudflare re-challenge failed")
  216. return res
  217. # 2. 智能检查 Session Expired
  218. # 逻辑修正:即使是 401,如果内容包含 valid data,也视为成功 (绕过某些WAF误报)
  219. is_valid_content = "availableAppointments" in resp.text
  220. if not is_valid_content:
  221. if resp.status_code == 401 or self._is_session_expired_page(resp.text):
  222. VSC_WARN("tls_plg", "[%s] Session expired. URL: %s", self.group_id, resp.url)
  223. self._save_debug_html(resp.text, "query_session_expired")
  224. self._set_error(2003, "Session expired")
  225. self.is_healthy = False
  226. return res
  227. # 其他非 200 且无内容的错误
  228. if resp.status_code != 200:
  229. self._set_error(resp.status_code, f"Query failed status: {resp.status_code}")
  230. return res
  231. # 3. 解析 Slots
  232. all_slots = self._parse_appointment_slots(resp.text)
  233. # 过滤 Label
  234. target_labels = self.free_config.get("target_labels", ["", "pta"])
  235. available = []
  236. for slot in all_slots:
  237. if slot.get('label') in target_labels:
  238. available.append(slot)
  239. res.success = True
  240. res.city = embassy['city']
  241. res.visa_type = "Short Stay"
  242. res.availability_status = AvailabilityStatus.NoneAvailable
  243. if available:
  244. res.availability_status = AvailabilityStatus.Available
  245. res.earliest_date = available[0]['date']
  246. date_map = {}
  247. for s in available:
  248. d = s['date']
  249. if d not in date_map: date_map[d] = []
  250. ts = VSQueryResult.DateAvailability.TimeSlot()
  251. ts.time = s['time']
  252. ts.label = f"{s['type']}"
  253. date_map[d].append(ts)
  254. for d, slots in date_map.items():
  255. da = VSQueryResult.DateAvailability()
  256. da.date = d
  257. da.times = slots
  258. res.availability.append(da)
  259. VSC_INFO("tls_plg", "[%s] Found %d slots", self.group_id, len(available))
  260. else:
  261. VSC_DEBUG("tls_plg", "[%s] Query OK, but no matching slots.", self.group_id)
  262. except Exception as e:
  263. self._set_error(1099, f"Query exception: {e}")
  264. return res
  265. def book(self, slot_info: VSQueryResult) -> VSBookResult:
  266. """
  267. 预约 (实现 Multipart Form 提交)
  268. 注意:传入的 slot_info 是 query 的结果,我们需要从中选一个具体的 slot。
  269. 这里假设 slot_info.availability[0].times[0] 是我们要订的。
  270. """
  271. res = VSBookResult()
  272. if not self.session or not self.travel_group:
  273. self._set_error(2003, "Session invalid")
  274. return res
  275. # 简单策略:选第一个可用时间
  276. if not slot_info.availability or not slot_info.availability[0].times:
  277. self._set_error(3002, "No slots in slot_info to book")
  278. return res
  279. target_date = slot_info.availability[0].date
  280. target_time = slot_info.availability[0].times[0].time
  281. # 从 label 解析回原始 label string 比较困难,这里简化处理,
  282. # 实际应在 QueryResult 中携带原始数据,或重新匹配
  283. # 这里为了演示,假设 label 为空 (Standard)
  284. target_label = ""
  285. embassy = self._get_embassy_config()
  286. group_num = self.travel_group['group_number']
  287. interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
  288. # 1. 解决 ReCaptcha V3
  289. page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={embassy["code"]}&month={interest_month}'
  290. proxy_url = self.session.proxies.get("http") if self.session.proxies else ""
  291. api_token = self.free_config.get("capsolver_key", "")
  292. rc_params = {
  293. "type": "ReCaptchaV3Task",
  294. "page": page_url,
  295. "action": "book",
  296. "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
  297. "apiToken": api_token,
  298. "proxy": proxy_url
  299. }
  300. g_token = self._solve_recaptcha(rc_params)
  301. if not g_token:
  302. self._set_error(1001, "Failed to solve Booking Recaptcha")
  303. return res
  304. # 2. 构造请求
  305. url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
  306. # 复杂的 Header
  307. next_action = '601f284bf7ee33b6578ad0fad426fae18c232707f2' # 此值可能会变,需关注
  308. next_state = '%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22$GROUPID$%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
  309. headers = {
  310. 'Next-Action': next_action,
  311. 'Referer': page_url,
  312. 'Next-Router-State-Tree': next_state.replace("$GROUPID$", group_num),
  313. 'Accept': 'text/x-component',
  314. 'User-Agent': self.user_agent,
  315. }
  316. params = {
  317. 'location': embassy["code"],
  318. 'month': interest_month,
  319. }
  320. # 3. 构造 Multipart Form Data
  321. boundary = "----WebKitFormBoundary" + "".join(
  322. random.choices("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=16)
  323. )
  324. headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
  325. form_fields = {
  326. '1_formGroupId': str(group_num),
  327. '1_lang': 'en-us',
  328. '1_process': 'APPOINTMENT',
  329. '1_location': embassy["code"],
  330. '1_date': target_date,
  331. '1_time': target_time,
  332. '1_appointmentLabel': target_label,
  333. '1_captcha_token': g_token,
  334. '0': '[{"status":"IDLE"},"$K1"]'
  335. }
  336. body_parts = []
  337. for name, value in form_fields.items():
  338. body_parts.append(f"--{boundary}\r\n")
  339. body_parts.append(f'Content-Disposition: form-data; name="{name}"\r\n')
  340. body_parts.append("\r\n")
  341. body_parts.append(f"{value}\r\n")
  342. body_parts.append(f"--{boundary}--\r\n")
  343. body = "".join(body_parts).encode("utf-8")
  344. try:
  345. resp = self.session.post(url, params=params, headers=headers, data=body)
  346. if resp.status_code == 303: # TLS 成功通常重定向
  347. res.success = True
  348. res.order_id = f"TLS-{int(time.time())}"
  349. res.book_date = target_date
  350. res.book_time = target_time
  351. VSC_INFO("tls_plg", "[%s] Book Success (303 Redirect)!", self.group_id)
  352. return res
  353. else:
  354. self._set_error(resp.status_code, f"Book Failed: {resp.status_code} {resp.text[:100]}")
  355. except Exception as e:
  356. self._set_error(1099, f"Book exception: {e}")
  357. return res
  358. # ---------------------------------------------------------
  359. # 辅助功能
  360. # ---------------------------------------------------------
  361. def _get_embassy_config(self) -> Dict:
  362. # 从 free_config 提取 embassy 信息,格式需与 TLS_EMBASSY 结构一致
  363. # 示例 JSON: { "embassy": { "code": "gbLON2fr", "country": "gb", "mission": "fr", "city": "london" } }
  364. # 或者平铺在 free_config
  365. if "embassy_code" in self.free_config:
  366. return {
  367. "code": self.free_config.get("embassy_code"),
  368. "country": self.free_config.get("country_code"),
  369. "mission": self.free_config.get("mission_code", "fr"),
  370. "city": self.free_config.get("city")
  371. }
  372. return {} # 失败
  373. def _solve_cloudflare5S_challenge(self, embassy, proxy_url) -> bool:
  374. """
  375. 解决 Cloudflare 5s 盾
  376. 使用 VSCloudApi 的 submit_anticloudflare_task
  377. """
  378. VSC_INFO("tls_plg", "[%s] Solving Cloudflare 5s...", self.group_id)
  379. website_url = f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}'
  380. # 1. 格式化代理字符串
  381. # 这里的接口要求格式通常是: host:port:user:pass (根据你的脚本示例)
  382. # self.config.proxy 结构体里的数据
  383. p = self.config.proxy
  384. if not p.ip:
  385. VSC_ERROR("tls_plg", "Proxy is required for Cloudflare challenge")
  386. return False
  387. # 构造 user:pass@ip:port 用于 urlparse (方便解析) 或者直接拼接
  388. # 你的独立脚本中是: f'{parsed_proxy.hostname}:{parsed_proxy.port}:{parsed_proxy.username}:{parsed_proxy.password}'
  389. # VSPlgConfig 中的 proxy 对象字段: ip, port, username, password
  390. if p.username:
  391. proxy_str = f"{p.ip}:{p.port}:{p.username}:{p.password}"
  392. else:
  393. proxy_str = f"{p.ip}:{p.port}"
  394. # 2. 提交任务
  395. task = VSCloudApi.Instance().submit_anticloudflare_task(proxy_str, website_url)
  396. if not task or not task.get('id'):
  397. VSC_ERROR("tls_plg", "[%s] Failed to submit AntiCloudflareTask", self.group_id)
  398. return False
  399. # 3. 等待结果 (VSCloudApi.get_anticloudflare_result 内部已包含轮询)
  400. task_id = str(task['id'])
  401. result = VSCloudApi.Instance().get_anticloudflare_result(task_id)
  402. if result:
  403. try:
  404. # 4. 解析结果并设置 Session
  405. # result['result'] 是一个 JSON 字符串,包含 cookies 和 userAgent
  406. parsed_result = json.loads(result.get('result', '{}'))
  407. cookies_list = parsed_result.get('cookies', [])
  408. name_list = ['__cf_bm', 'cf_clearance']
  409. for cookie in cookies_list:
  410. if cookie['name'] in name_list:
  411. self.session.cookies.set(
  412. cookie['name'],
  413. cookie['value'],
  414. domain=cookie['domain'],
  415. path='/'
  416. )
  417. ua = parsed_result.get('userAgent')
  418. if ua:
  419. self.user_agent = ua
  420. self.session.headers['User-Agent'] = ua
  421. VSC_INFO("tls_plg", "[%s] Cloudflare 5s challenge solved.", self.group_id)
  422. return True
  423. except Exception as e:
  424. VSC_ERROR("tls_plg", f"Failed to parse Cloudflare result: {e}")
  425. return False
  426. def _solve_recaptcha(self, params) -> Optional[str]:
  427. """
  428. 调用 Capsolver (保留原脚本逻辑)
  429. """
  430. try:
  431. key = params.get("apiToken")
  432. if not key: return None
  433. submit_url = "https://api.capsolver.com/createTask"
  434. task = {
  435. "type": params.get("type"),
  436. "websiteURL": params.get("page"),
  437. "websiteKey": params.get("siteKey"),
  438. }
  439. if params.get("action"):
  440. task["pageAction"] = params.get("action")
  441. if params.get("proxy"):
  442. p = urlparse(params.get("proxy"))
  443. task["proxyType"] = p.scheme
  444. task["proxyAddress"] = p.hostname
  445. task["proxyPort"] = p.port
  446. if p.username:
  447. task["proxyLogin"] = p.username
  448. task["proxyPassword"] = p.password
  449. payload = {"clientKey": key, "task": task}
  450. r = requests.post(submit_url, json=payload, timeout=20)
  451. if r.status_code != 200: return None
  452. task_id = r.json().get("taskId")
  453. if not task_id: return None
  454. # Query
  455. for _ in range(20):
  456. r = requests.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": task_id}, timeout=20)
  457. if r.status_code == 200:
  458. d = r.json()
  459. if d.get("status") == "ready":
  460. return d["solution"]["gRecaptchaResponse"]
  461. time.sleep(3)
  462. except Exception as e:
  463. VSC_ERROR("tls_plg", f"Capsolver error: {e}")
  464. return None
  465. def _parse_travel_groups(self, html: str) -> List[Dict]:
  466. groups = []
  467. try:
  468. js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
  469. js_match = re.search(js_pattern, html, re.DOTALL)
  470. if js_match:
  471. json_str = js_match.group(1).replace(r'\"', '"')
  472. data = json.loads(json_str)
  473. for g in data:
  474. groups.append({
  475. 'group_name': g.get('groupName'),
  476. 'group_number': g.get('formGroupId'),
  477. 'location': g.get('vacName')
  478. })
  479. except:
  480. pass
  481. return groups
  482. def _parse_appointment_slots(self, html: str) -> List[Dict]:
  483. slots = []
  484. try:
  485. # 增强正则:匹配 "availableAppointments": 或 \"availableAppointments\":
  486. # 并且兼容末尾是 ,"showFlexi... 或 ,\"showFlexi...
  487. # DOTALL 模式确保匹配跨行
  488. pattern = r'availableAppointments\\?":\s*(\[.*?\])(?:,\\?"|\},)'
  489. match = re.search(pattern, html, re.DOTALL)
  490. if match:
  491. json_str = match.group(1)
  492. # 清理转义字符:将 \" 替换为 "
  493. json_str = json_str.replace(r'\"', '"')
  494. data = json.loads(json_str)
  495. for day in data:
  496. d_str = day.get('day')
  497. for s in day.get('slots', []):
  498. labels = s.get('labels', [])
  499. lbl = ""
  500. stype = ""
  501. cost = ""
  502. if 'pta' in labels:
  503. lbl = 'pta'
  504. stype = "Prime"
  505. elif 'ptaw' in labels:
  506. lbl = 'ptaw'
  507. stype = "Prime Weekend"
  508. elif '' in labels:
  509. lbl = ''
  510. stype = "Standard"
  511. if lbl or not labels:
  512. slots.append({
  513. 'date': d_str,
  514. 'time': s.get('time'),
  515. 'label': lbl,
  516. 'type': stype,
  517. 'cost': cost
  518. })
  519. except Exception as e:
  520. VSC_DEBUG("tls_plg", f"Slot parse error: {e}")
  521. pass
  522. return slots
  523. def _is_session_expired_page(self, html: str) -> bool:
  524. if not html: return False
  525. if 'availableAppointments' not in html: return True
  526. # 简化判断:如果包含 redirecting automatically 通常是过期
  527. if 'redirected automatically' in html.lower(): return True
  528. return False