ita_plugin.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import time
  2. import json
  3. import random
  4. import uuid
  5. import shutil
  6. import re
  7. import os
  8. import base64
  9. from datetime import datetime
  10. from typing import List, Dict, Optional, Any, Callable
  11. from urllib.parse import urlencode, urlparse
  12. # DrissionPage 核心
  13. from DrissionPage import ChromiumPage, ChromiumOptions
  14. from vs_plg import IVSPlg
  15. from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  16. from toolkit.proxy_tunnel import ProxyTunnel
  17. from toolkit.vs_cloud_api import VSCloudApi
  18. class BrowserResponse:
  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. self._json = None
  26. def json(self):
  27. if self._json is None:
  28. if not self.text: return {}
  29. try: self._json = json.loads(self.text)
  30. except: self._json = {}
  31. return self._json
  32. # ==========================================
  33. # 2. ItaPlugin 核心逻辑
  34. # ==========================================
  35. class ItaPlugin(IVSPlg):
  36. def __init__(self, group_id: str):
  37. self.group_id = group_id
  38. self.config: Optional[VSPlgConfig] = None
  39. self.free_config: Dict[str, Any] = {}
  40. self.is_healthy = True
  41. self.logger = None
  42. self.page: Optional[ChromiumPage] = None
  43. # Prenotami 特有配置
  44. self._service_id = 0
  45. self._host = 'https://prenotami.esteri.it'
  46. # --- [核心修改] 并发隔离与资源管理 ---
  47. # 生成唯一实例 ID
  48. self.instance_id = uuid.uuid4().hex[:8]
  49. self.root_workspace = os.path.abspath(os.path.join("temp_browser_data", f"{self.group_id}_{self.instance_id}"))
  50. # 定义子目录:代理插件目录 & 浏览器用户数据目录
  51. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  52. # 确保根目录存在 (子目录由具体逻辑创建)
  53. if not os.path.exists(self.root_workspace):
  54. os.makedirs(self.root_workspace)
  55. # 持有隧道实例
  56. self.tunnel = None
  57. self.session_create_time: float = 0
  58. def get_group_id(self) -> str:
  59. return self.group_id
  60. def set_log(self, logger: Callable[[str], None]) -> None:
  61. self.logger = logger
  62. def _log(self, message):
  63. if self.logger:
  64. self.logger(f'[ItaPlugin] [{self.group_id}] {message}')
  65. else:
  66. print(f'[ItaPlugin] [{self.group_id}] {message}')
  67. def set_config(self, config: VSPlgConfig):
  68. self.config = config
  69. self.free_config = config.free_config or {}
  70. # Service ID (e.g., 1321 for Ireland, 5059 for Guangzhou)
  71. self._service_id = self.free_config.get('service_id', 0)
  72. def health_check(self) -> bool:
  73. if not self.is_healthy or not self.page:
  74. return False
  75. try:
  76. if not self.page.run_js("return 1;"):
  77. return False
  78. except:
  79. return False
  80. if self.config.session_max_life > 0:
  81. if time.time() - self.session_create_time > self.config.session_max_life * 60:
  82. self._log("Session expired.")
  83. return False
  84. return True
  85. # -------------------------------------------------------------
  86. # 1. Create Session (Login)
  87. # -------------------------------------------------------------
  88. def create_session(self):
  89. """
  90. 全浏览器登录流程:
  91. 1. 启动浏览器
  92. 2. 解决 ReCaptcha
  93. 3. 登录并维持 Session
  94. """
  95. self._log(f"Initializing Session (ID: {self.instance_id})...")
  96. co = ChromiumOptions()
  97. # -------------------------------------------------------------
  98. # [核心修复] 解决 'not enough values to unpack'
  99. # -------------------------------------------------------------
  100. # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
  101. # 2. 我们手动随机生成一个端口
  102. import random
  103. import socket
  104. def get_free_port():
  105. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  106. s.bind(('', 0))
  107. return s.getsockname()[1]
  108. debug_port = get_free_port()
  109. self._log(f"Assigned Debug Port: {debug_port}")
  110. # --- [关键配置] 设置独立的用户数据目录 ---
  111. # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
  112. # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
  113. co.set_user_data_path(self.user_data_path)
  114. # --- 1. 指定浏览器路径 (适配 Docker) ---
  115. chrome_path = os.getenv("CHROME_BIN")
  116. if chrome_path and os.path.exists(chrome_path):
  117. co.set_paths(browser_path=chrome_path)
  118. # --- [核心修改] 代理配置 ---
  119. if self.config.proxy and self.config.proxy.ip:
  120. p = self.config.proxy
  121. if p.username and p.password:
  122. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  123. # 1. 启动本地隧道
  124. self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
  125. local_proxy = self.tunnel.start()
  126. self._log(f"Tunnel started at {local_proxy}")
  127. # 2. Chrome 连接本地免密端口
  128. # 必须使用 --proxy-server 强制指定,绝对稳健
  129. co.set_argument(f'--proxy-server={local_proxy}')
  130. else:
  131. # 无密码代理,直接用
  132. proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
  133. co.set_argument(f'--proxy-server={proxy_str}')
  134. else:
  135. self._log("[WARN] No proxy configured!")
  136. co.headless(False)
  137. co.set_argument('--no-sandbox')
  138. co.set_argument('--disable-gpu')
  139. co.set_argument('--disable-dev-shm-usage')
  140. co.set_argument('--window-size=1920,1080')
  141. co.set_argument('--disable-blink-features=AutomationControlled')
  142. try:
  143. self.page = ChromiumPage(co)
  144. login_url = f"{self._host}/Home"
  145. self._log(f"Navigating to {login_url}")
  146. self.page.get(login_url)
  147. # 等待登录框
  148. if not self.page.wait.ele_displayed('#login-email', timeout=20):
  149. raise BizLogicError("Login page not loaded")
  150. # 填充用户名密码
  151. self.page.ele('#login-email').input(self.config.account.username)
  152. self.page.ele('#login-password').input(self.config.account.password)
  153. # 解决 ReCaptcha V2
  154. self._handle_login_captcha()
  155. # 提交登录
  156. self._log("Submitting login...")
  157. self.page.ele('xpath://*[@id="login-form"]/button').click()
  158. # 等待登录成功 (通常会跳转到 /UserArea 或 /Services)
  159. time.sleep(3)
  160. if "Home" in self.page.url and not self.page.ele('#logoutForm'):
  161. # 检查是否有错误提示
  162. if self.page.ele('.alert-danger'):
  163. err = self.page.ele('.alert-danger').text
  164. raise PermissionDeniedError(f"Login Failed: {err}")
  165. raise BizLogicError("Login Failed: Unknown reason")
  166. self._log("Login Successful.")
  167. # 访问服务列表页以保活
  168. self.page.get(f"{self._host}/Services")
  169. self.session_create_time = time.time()
  170. except Exception as e:
  171. self._log(f"Create Session Failed: {e}")
  172. self.cleanup()
  173. raise e
  174. def _handle_login_captcha(self):
  175. """处理登录页面的 ReCaptcha"""
  176. if self.page.ele('#recaptcha-anchor') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
  177. self._log("Solving ReCaptcha...")
  178. api_token = self.free_config.get("capsolver_key", "")
  179. if not api_token:
  180. self._log("WARN: No capsolver_key, manual solve required.")
  181. time.sleep(5)
  182. return
  183. site_key = "6LdkwrIqAAAAAC4NX-g_j7lEx9vh1rg94ZL2cFfY" # Prenotami Site Key
  184. rc_params = {
  185. "type": "ReCaptchaV2TaskProxyLess",
  186. "page": self.page.url,
  187. "siteKey": site_key,
  188. "apiToken": api_token
  189. }
  190. g_token = self._solve_recaptcha(rc_params)
  191. # 注入 Token
  192. js = f"""
  193. var el = document.getElementById('g-recaptcha-response');
  194. if(el) {{ el.value = "{g_token}"; }}
  195. """
  196. self.page.run_js(js)
  197. self._log("Captcha solved & injected.")
  198. # -------------------------------------------------------------
  199. # 2. Query Availability
  200. # -------------------------------------------------------------
  201. def query(self) -> VSQueryResult:
  202. res = VSQueryResult()
  203. res.success = False
  204. res.availability_status = AvailabilityStatus.NoneAvailable
  205. if not self._service_id:
  206. raise BizLogicError("Service ID not configured")
  207. # 1. 检查 Slot 是否可用 (Check Availability Endpoint)
  208. check_url = f"{self._host}/Services/Booking/{self._service_id}"
  209. # 使用 Fetch 发起检查请求
  210. resp = self._perform_request("GET", check_url, headers={
  211. "Referer": f"{self._host}/Services",
  212. "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
  213. })
  214. # 302 跳转处理逻辑
  215. if resp.status_code == 200:
  216. # 200 表示进入了预约页,有号
  217. self._log("Slot Check: 200 OK (Availability Detected)")
  218. pass
  219. elif "BookingCalendar" in resp.url: # 或者是被重定向到了 Calendar
  220. self._log("Slot Check: Redirected to Calendar (Availability Detected)")
  221. pass
  222. else:
  223. # 被重定向回 Home 或 Service,说明没号或 Session 过期
  224. if "Home" in resp.url or "Login" in resp.url:
  225. self.is_healthy = False
  226. raise SessionExpiredOrInvalidError("Session expired during query")
  227. self._log("Slot Check: No availability (Redirected back)")
  228. return res
  229. # 2. 查询月份 (Query Month)
  230. # 默认查询当月,或者配置的月份
  231. tar_dates = self.free_config.get("target_dates", [])
  232. if not tar_dates:
  233. # 默认查下个月
  234. next_month = datetime.now().replace(day=28) + datetime.timedelta(days=4)
  235. tar_dates = [next_month.strftime("%Y-%m-%d")]
  236. all_slots = []
  237. # Prenotami 需要先 retrieve server info
  238. self._perform_request("GET", f"{self._host}/BookingCalendar/RetrieveServerInfo")
  239. for date_str in tar_dates:
  240. # 构造月份格式 2026-01-05 -> 2026-01-01 (API 需要)
  241. try:
  242. dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
  243. except:
  244. try:
  245. dt = datetime.strptime(date_str, "%Y-%m-%d")
  246. except:
  247. dt = datetime.now()
  248. # API 需要格式: 2025-11-05T... 格式的字符串作为 selectedDay
  249. # 实际上 RetrieveCalendarAvailability 只需要由前端日历控件触发的格式
  250. # 查询日历 API
  251. cal_url = f"{self._host}/BookingCalendar/RetrieveCalendarAvailability"
  252. cal_payload = {
  253. "_Servizio": str(self._service_id),
  254. "selectedDay": date_str # 原样传配置里的 ISO 串
  255. }
  256. resp_cal = self._perform_request("POST", cal_url, json_data=cal_payload)
  257. if resp_cal.status_code != 200: continue
  258. # 解析有效日期
  259. valid_days = self._parse_valid_days(resp_cal.text)
  260. self._log(f"Valid days for {date_str}: {valid_days}")
  261. for day in valid_days:
  262. # 查询具体 Slot
  263. slot_url = f"{self._host}/BookingCalendar/RetrieveTimeSlots"
  264. slot_payload = {
  265. "selectedDay": day, # YYYY-MM-DD
  266. "idService": str(self._service_id)
  267. }
  268. resp_slot = self._perform_request("POST", slot_url, json_data=slot_payload)
  269. time_slots = self._parse_time_slots(resp_slot.text)
  270. if time_slots:
  271. res.success = True
  272. res.availability_status = AvailabilityStatus.Available
  273. res.earliest_date = day
  274. # 转换结构
  275. ts_list = []
  276. for ts in time_slots:
  277. # ts: {'id': 123, 'start': '10:00', 'end': '10:30', 'remain': 1}
  278. ts_list.append(TimeSlot(
  279. time=f"{ts['start']} - {ts['end']}",
  280. label=str(ts['id']) # 将 ID 存入 label 以便 book 使用
  281. ))
  282. res.availability.append(DateAvailability(date=day, times=ts_list))
  283. return res
  284. # -------------------------------------------------------------
  285. # 3. Book
  286. # -------------------------------------------------------------
  287. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  288. res = VSBookResult()
  289. res.success = False
  290. if not slot_info.availability:
  291. raise NotFoundError("No slots to book")
  292. target_date = slot_info.availability[0].date
  293. # 取第一个时间段
  294. target_slot = slot_info.availability[0].times[0]
  295. slot_id = target_slot.label # 我们在 query 里把 ID 存在了 label
  296. slot_text = target_slot.time # "10:00 - 10:30"
  297. # 1. 获取 OTP (GenerateOTP)
  298. self._log("Requesting OTP...")
  299. otp_url = f"{self._host}/BookingCalendar/GenerateOTP?ServiceID={self._service_id}"
  300. self._perform_request("POST", otp_url)
  301. # 2. 等待并读取邮件
  302. self._log("Waiting for email code...")
  303. time.sleep(10) # 稍微等一下发信
  304. email_account = self.config.account.email
  305. # 使用 CloudAPI 读取 (假设已配置)
  306. otp_code = VSCloudApi.Instance().get_email_verify_code(email_account)
  307. if not otp_code:
  308. raise BizLogicError("Failed to retrieve OTP code")
  309. self._log(f"Got OTP: {otp_code}")
  310. # 3. 提交详细信息 (Fill User Info)
  311. # 这是最复杂的一步,涉及文件上传 (Multipart)
  312. self._log("Submitting User Details & Files...")
  313. # 准备文件 (转 Base64 传给 JS)
  314. passport_pdf_path = user_inputs.get('passport_pdf_path')
  315. irp_pdf_path = user_inputs.get('irp_pdf_path')
  316. def file_to_b64(path):
  317. if not path or not os.path.exists(path): return ""
  318. with open(path, "rb") as f:
  319. return base64.b64encode(f.read()).decode('utf-8')
  320. ppt_b64 = file_to_b64(passport_pdf_path)
  321. irp_b64 = file_to_b64(irp_pdf_path)
  322. # 构造 JS FormData 提交脚本
  323. # 注意:这里需要根据 Service ID (Dublin/Canton) 动态调整字段 ID
  324. # 下面以 Dublin (1321) 的字段为例,如果是 Canton 需要修改 _Id 和 _TipoDatoAddizionale
  325. # 为了通用性,这里演示 Dublin 的结构,请根据实际 Service ID 调整 mapping
  326. # 假设是 Dublin (根据提供的源码分析)
  327. boundary = '----WebKitFormBoundaryRandomString'
  328. submit_url = f"{self._host}/Services/Booking/{self._service_id}"
  329. # 注入 JS 执行
  330. js_submit = f"""
  331. const url = "{submit_url}";
  332. const fd = new FormData();
  333. // 基础字段
  334. fd.append('ServizioDescrizione', 'D Visa Application');
  335. fd.append('MessaggioRassicuranteWaitingList', 'True');
  336. fd.append('isWaitingListEnabled', 'False');
  337. fd.append('IDServizioConsolare', '35');
  338. fd.append('IDServizioErogato', '{self._service_id}');
  339. fd.append('IdTipoPrenotazione', '1'); // Single
  340. fd.append('NumMaxAccompagnatori', '3');
  341. fd.append('NumAccompagnatoriSelected', '0');
  342. // 动态字段 (Dublin 示例)
  343. // [0] Other citizenship -> User Input
  344. fd.append('DatiAddizionaliPrenotante[0]._Descrizione', 'Other citizenship/s');
  345. fd.append('DatiAddizionaliPrenotante[0]._testo', '{user_inputs.get("citizen", "China")}');
  346. fd.append('DatiAddizionaliPrenotante[0]._Obbligatorio', 'False');
  347. fd.append('DatiAddizionaliPrenotante[0]._Id', '61738');
  348. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoDatoAddizionale', '26');
  349. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoControllo', '2');
  350. // [1] Full address -> User Input
  351. fd.append('DatiAddizionaliPrenotante[1]._Descrizione', 'Full residence address');
  352. fd.append('DatiAddizionaliPrenotante[1]._testo', '{user_inputs.get("address", "")}');
  353. fd.append('DatiAddizionaliPrenotante[1]._Obbligatorio', 'True');
  354. fd.append('DatiAddizionaliPrenotante[1]._Id', '61739');
  355. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoDatoAddizionale', '25');
  356. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoControllo', '2');
  357. // [2] Passport Num
  358. fd.append('DatiAddizionaliPrenotante[2]._Descrizione', 'Passport number');
  359. fd.append('DatiAddizionaliPrenotante[2]._testo', '{user_inputs.get("passport", "")}');
  360. fd.append('DatiAddizionaliPrenotante[2]._Obbligatorio', 'True');
  361. fd.append('DatiAddizionaliPrenotante[2]._Id', '61740');
  362. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoDatoAddizionale', '2');
  363. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoControllo', '2');
  364. // [3] Reason (Select)
  365. fd.append('DatiAddizionaliPrenotante[3]._Descrizione', 'Reason for visit');
  366. fd.append('DatiAddizionaliPrenotante[3]._Obbligatorio', 'True');
  367. fd.append('DatiAddizionaliPrenotante[3]._Id', '61741');
  368. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoDatoAddizionale', '34');
  369. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoControllo', '3');
  370. fd.append('DatiAddizionaliPrenotante[3]._idSelezionato', '42'); // 42 = Tourism? Need verify
  371. // OTP
  372. fd.append('otp-input', '{otp_code}');
  373. fd.append('PrivacyCheck', 'true');
  374. // 文件处理 (Base64 -> Blob -> FormData)
  375. // 注意:这里假设页面上有文件上传的对应 ID,或者我们直接硬编码 FormData
  376. // 原始抓包并未显示文件字段名,通常是 File_0, File_1
  377. // 我们需要将 base64 转 blob
  378. async function addFile(b64, name, filename) {{
  379. if(!b64) return;
  380. const res = await fetch(`data:application/pdf;base64,${{b64}}`);
  381. const blob = await res.blob();
  382. fd.append(name, blob, filename);
  383. }}
  384. // 并行处理文件
  385. await Promise.all([
  386. addFile('{ppt_b64}', 'File_0', 'passport.pdf'), // 假设 File_0 是护照
  387. addFile('{irp_b64}', 'File_1', 'irp.pdf') // 假设 File_1 是 IRP
  388. ]);
  389. // 发送 POST
  390. return fetch(url, {{
  391. method: 'POST',
  392. body: fd
  393. }}).then(async r => {{
  394. return {{ status: r.status, url: r.url, text: await r.text() }};
  395. }}).catch(e => {{ return {{ status: 0, text: e.toString() }}; }});
  396. """
  397. result_dict = self.page.run_js(js_submit)
  398. resp = BrowserResponse(result_dict)
  399. if resp.status_code == 302 or "BookingCalendar" in resp.url:
  400. self._log("User Info Submitted Successfully.")
  401. else:
  402. self._log(f"User Info Submit Failed: {resp.text[:100]}")
  403. # 如果 OTP 错误,页面会返回特定错误信息
  404. if "Codice errato" in resp.text:
  405. raise BizLogicError("Invalid OTP Code")
  406. return res # Fail
  407. # 4. 最终确认预约 (InsertNewBooking)
  408. self._log("Finalizing Booking...")
  409. final_url = f"{self._host}/BookingCalendar/InsertNewBooking"
  410. final_payload = {
  411. "idCalendarioGiornaliero": slot_id,
  412. "selectedDay": target_date,
  413. "selectedHour": slot_text # "10:00 - 10:30(2)"
  414. }
  415. # 这里用 Form-UrlEncoded
  416. resp_final = self._perform_request("POST", final_url, data=final_payload)
  417. if resp_final.status_code == 200:
  418. self._log("Booking Confirmed!")
  419. res.success = True
  420. res.book_date = target_date
  421. res.book_time = slot_text
  422. else:
  423. self._log(f"Final Booking Failed: {resp_final.status_code}")
  424. return res
  425. # -------------------------------------------------------------
  426. # 4. Helpers
  427. # -------------------------------------------------------------
  428. def _perform_request(self, method, url, headers=None, data=None, json_data=None):
  429. """JS Fetch Wrapper"""
  430. if not self.page: raise BizLogicError("Browser not init")
  431. fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
  432. if json_data:
  433. fetch_opts['body'] = json.dumps(json_data)
  434. fetch_opts['headers']['Content-Type'] = 'application/json; charset=UTF-8'
  435. elif data:
  436. if isinstance(data, dict):
  437. from urllib.parse import urlencode
  438. fetch_opts['body'] = urlencode(data)
  439. fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  440. else:
  441. fetch_opts['body'] = data
  442. js = f"""
  443. return fetch("{url}", {json.dumps(fetch_opts)})
  444. .then(async r => {{
  445. const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
  446. return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
  447. }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  448. """
  449. return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
  450. def _solve_recaptcha(self, params) -> str:
  451. # 复用通用的 Capsolver 逻辑
  452. key = params.get("apiToken")
  453. import requests as req
  454. task = { "type": params.get("type"), "websiteURL": params.get("page"), "websiteKey": params.get("siteKey") }
  455. r = req.post("https://api.capsolver.com/createTask", json={"clientKey": key, "task": task}, timeout=20)
  456. if r.status_code != 200: raise BizLogicError("Capsolver submit failed")
  457. tid = r.json().get("taskId")
  458. for _ in range(20):
  459. r = req.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": tid}, timeout=20)
  460. if r.status_code == 200 and r.json().get("status") == "ready":
  461. return r.json()["solution"]["gRecaptchaResponse"]
  462. time.sleep(3)
  463. raise BizLogicError("Capsolver timeout")
  464. def _parse_valid_days(self, text):
  465. # 提取 DateLibere (YYYY-MM-DD)
  466. # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}
  467. # 原始正则: r'{"DateLibere":"(.*?)","SlotLiberi":\d+,"SlotRimanenti":(-?\d+)}'
  468. days = []
  469. try:
  470. matches = re.findall(r'{"DateLibere":"(.*?)".*?"SlotRimanenti":(-?\d+)}', text)
  471. for d_str, rem in matches:
  472. if int(rem) != -1:
  473. # 22/10/2024 -> 2024-10-22
  474. dt = datetime.strptime(d_str[:10], "%d/%m/%Y")
  475. days.append(dt.strftime("%Y-%m-%d"))
  476. except: pass
  477. return days
  478. def _parse_time_slots(self, text):
  479. # 提取 IDCalendarioServizioGiornaliero, StartTime, EndTime, Remain
  480. slots = []
  481. try:
  482. # 原始逻辑比较复杂,这里简化正则
  483. # 查找 SlotRimanenti > 0 的记录
  484. # 关键是 IDCalendarioServizioGiornaliero
  485. raw_list = json.loads(text)
  486. # Prenotami 返回的是一个 JSON 列表字符串
  487. for item in raw_list:
  488. remain = item.get('SlotRimanenti', -1)
  489. if remain > 0:
  490. start = item['OrarioInizioFascia']
  491. end = item['OrarioFineFascia']
  492. s_time = f"{start['Hours']:02d}:{start['Minutes']:02d}"
  493. e_time = f"{end['Hours']:02d}:{end['Minutes']:02d}"
  494. slots.append({
  495. 'id': item['IDCalendarioServizioGiornaliero'],
  496. 'start': s_time,
  497. 'end': e_time,
  498. 'remain': remain
  499. })
  500. except: pass
  501. return slots
  502. # --- 资源清理核心方法 ---
  503. def cleanup(self):
  504. """
  505. 销毁浏览器并彻底删除临时文件
  506. """
  507. # 1. 关闭浏览器
  508. if self.page:
  509. try:
  510. self.page.quit() # 这会关闭 Chrome 进程
  511. except Exception:
  512. pass # 忽略已关闭的错误
  513. self.page = None
  514. # 2. 删除文件
  515. # 注意:Chrome 关闭后可能需要几百毫秒释放文件锁,稍微等待
  516. if os.path.exists(self.root_workspace):
  517. for _ in range(3):
  518. try:
  519. time.sleep(0.2)
  520. shutil.rmtree(self.root_workspace, ignore_errors=True)
  521. break
  522. except Exception as e:
  523. # 如果删除失败(通常是Windows文件占用),重试
  524. self._log(f"Cleanup retry: {e}")
  525. time.sleep(0.5)
  526. # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
  527. if os.path.exists(self.root_workspace):
  528. self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
  529. # 3. [新增] 关闭代理隧道
  530. if self.tunnel:
  531. try: self.tunnel.stop()
  532. except: pass
  533. self.tunnel = None
  534. def __del__(self):
  535. """
  536. 析构函数:当对象被垃圾回收时自动调用
  537. """
  538. self.cleanup()