ita_plugin.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  1. import time
  2. import json
  3. import random
  4. import socket
  5. import uuid
  6. import shutil
  7. import re
  8. import os
  9. import base64
  10. from datetime import datetime
  11. from typing import List, Dict, Optional, Any, Callable
  12. from urllib.parse import urlencode, urlparse
  13. # DrissionPage 核心
  14. from DrissionPage import ChromiumPage, ChromiumOptions
  15. from vs_plg import IVSPlg
  16. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  17. from toolkit.proxy_tunnel import ProxyTunnel
  18. from toolkit.vs_cloud_api import VSCloudApi
  19. from utils.mouse import HumanMouse
  20. from utils.keyboard import HumanKeyboard
  21. from utils.fingerprint_utils import FingerprintGenerator
  22. class BrowserResponse:
  23. def __init__(self, result_dict):
  24. result_dict = result_dict or {}
  25. self.status_code = result_dict.get('status', 0)
  26. self.text = result_dict.get('body', '')
  27. self.headers = result_dict.get('headers', {})
  28. self.url = result_dict.get('url', '')
  29. self._json = None
  30. def json(self):
  31. if self._json is None:
  32. if not self.text: return {}
  33. try: self._json = json.loads(self.text)
  34. except: self._json = {}
  35. return self._json
  36. # ==========================================
  37. # 2. ItaPlugin 核心逻辑
  38. # ==========================================
  39. class ItaPlugin(IVSPlg):
  40. def __init__(self, group_id: str):
  41. self.group_id = group_id
  42. self.config: Optional[VSPlgConfig] = None
  43. self.free_config: Dict[str, Any] = {}
  44. self.is_healthy = True
  45. self.logger = None
  46. self.page: Optional[ChromiumPage] = None
  47. # Prenotami 特有配置
  48. self._service_id = 0
  49. self._host = 'https://prenotami.esteri.it'
  50. # --- [核心修改] 并发隔离与资源管理 ---
  51. # 生成唯一实例 ID
  52. self.instance_id = uuid.uuid4().hex[:8]
  53. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  54. # 定义子目录:代理插件目录 & 浏览器用户数据目录
  55. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  56. # 确保根目录存在 (子目录由具体逻辑创建)
  57. if not os.path.exists(self.root_workspace):
  58. os.makedirs(self.root_workspace)
  59. # 持有隧道实例
  60. self.tunnel = None
  61. self.session_create_time: float = 0
  62. def get_group_id(self) -> str:
  63. return self.group_id
  64. def set_log(self, logger: Callable[[str], None]) -> None:
  65. self.logger = logger
  66. def _log(self, message):
  67. if self.logger:
  68. self.logger(f'[ItaPlugin] [{self.group_id}] {message}')
  69. else:
  70. print(f'[ItaPlugin] [{self.group_id}] {message}')
  71. def set_config(self, config: VSPlgConfig):
  72. self.config = config
  73. self.free_config = config.free_config or {}
  74. # Service ID (e.g., 1321 for Ireland, 5059 for Guangzhou)
  75. self._service_id = self.free_config.get('service_id', 0)
  76. def keep_alive(self):
  77. pass
  78. def health_check(self) -> bool:
  79. if not self.is_healthy or not self.page:
  80. return False
  81. try:
  82. if not self.page.run_js("return 1;"):
  83. return False
  84. except:
  85. return False
  86. if self.config.session_max_life > 0:
  87. if time.time() - self.session_create_time > self.config.session_max_life:
  88. self._log("Session expired.")
  89. return False
  90. return True
  91. # -------------------------------------------------------------
  92. # 1. Create Session (Login)
  93. # -------------------------------------------------------------
  94. def create_session(self):
  95. """
  96. 全浏览器登录流程:
  97. 1. 启动浏览器
  98. 2. 解决 ReCaptcha
  99. 3. 登录并维持 Session
  100. """
  101. self._log(f"Initializing Session (ID: {self.instance_id})...")
  102. co = ChromiumOptions()
  103. def get_free_port():
  104. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  105. s.bind(('', 0))
  106. return s.getsockname()[1]
  107. debug_port = get_free_port()
  108. self._log(f"Assigned Debug Port: {debug_port}")
  109. co.set_user_data_path(self.user_data_path)
  110. chrome_path = os.getenv("CHROME_BIN")
  111. if chrome_path and os.path.exists(chrome_path):
  112. co.set_paths(browser_path=chrome_path)
  113. if self.config.proxy and self.config.proxy.ip:
  114. p = self.config.proxy
  115. if p.username and p.password:
  116. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  117. self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
  118. local_proxy = self.tunnel.start()
  119. self._log(f"Tunnel started at {local_proxy}")
  120. co.set_argument(f'--proxy-server={local_proxy}')
  121. else:
  122. proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
  123. co.set_argument(f'--proxy-server={proxy_str}')
  124. else:
  125. self._log("[WARN] No proxy configured!")
  126. fingerprint_gen = FingerprintGenerator()
  127. specific_fp = fingerprint_gen.generate(self.config.account.username)
  128. self._log(f'browser fingerprint={specific_fp}')
  129. co.headless(False)
  130. co.set_argument('--no-sandbox')
  131. # co.set_argument('--disable-gpu')
  132. co.set_argument('--disable-dev-shm-usage')
  133. co.set_argument('--window-size=1920,1080')
  134. co.set_argument('--disable-blink-features=AutomationControlled')
  135. co.set_argument('--ignore-gpu-blocklist') # 忽略无显卡黑名单
  136. co.set_argument('--enable-webgl') # 强制开启 WebGL
  137. co.set_argument('--use-gl=angle') # 使用 ANGLE 渲染后端
  138. co.set_argument('--use-angle=swiftshader')# 强制使用 CPU 进行 3D 渲染 (这步最关键!)
  139. co.set_argument(f"--fingerprint={specific_fp.get('seed')}")
  140. co.set_argument(f"--fingerprint-platform={specific_fp.get('platform')}")
  141. co.set_argument(f"--fingerprint-brand={specific_fp.get('brand')}")
  142. try:
  143. self.page = ChromiumPage(co)
  144. if self.config.debug:
  145. self.page.get('https://example.com')
  146. js_script = """
  147. function getFingerprint() {
  148. let webglVendor = 'Unknown';
  149. let webglRenderer = 'Unknown';
  150. try {
  151. let canvas = document.createElement('canvas');
  152. let gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  153. if (gl) {
  154. let debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
  155. if (debugInfo) {
  156. webglVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
  157. webglRenderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
  158. }
  159. }
  160. } catch(e) {}
  161. return {
  162. "User-Agent": navigator.userAgent,
  163. "Platform": navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform,
  164. "Brands": navigator.userAgentData ? navigator.userAgentData.brands.map(b => b.brand).join(', ') : 'Not Supported',
  165. "CPU Cores": navigator.hardwareConcurrency,
  166. "Language": navigator.language,
  167. "Timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
  168. "WebGL Vendor": webglVendor,
  169. "WebGL Renderer": webglRenderer
  170. };
  171. }
  172. return getFingerprint();
  173. """
  174. fp_data = self.page.run_js(js_script)
  175. self._log("================ 预检浏览器指纹数据 ================")
  176. self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
  177. self._log("====================================================")
  178. login_url = f"{self._host}/Home"
  179. self._log(f"Navigating to {login_url}")
  180. self.page.get(login_url)
  181. self._log("Init humanize tools...")
  182. self.mouse = HumanMouse(self.page, debug=True)
  183. self.keyboard = HumanKeyboard(self.page)
  184. self._log("Random mouse start position...")
  185. viewport_width = self.page.rect.viewport_size[0]
  186. viewport_height = self.page.rect.viewport_size[1]
  187. init_x = random.randint(10, viewport_width - 10)
  188. init_y = random.randint(10, viewport_height - 10)
  189. self.mouse.move(init_x, init_y)
  190. # 等待登录框
  191. if not self.page.wait.ele_displayed('#login-email', timeout=20):
  192. raise BizLogicError("Login page not loaded")
  193. # 填充用户名密码
  194. self.mouse.human_click_ele(self.page.ele('#login-email'))
  195. self.keyboard.type_text(self.config.account.username)
  196. self.mouse.human_click_ele(self.page.ele('#login-password'))
  197. self.keyboard.type_text(self.config.account.password)
  198. # 先定位
  199. self._log("Locating Login button...")
  200. login_btn = self.page.ele('#captcha-trigger')
  201. self.mouse.human_click_ele(login_btn)
  202. self._log("Login button clicked.")
  203. # 等待 URL 变化或特定元素出现
  204. # 成功通常跳转到 /UserArea, 失败则留在 /Home
  205. end_time = time.time() + 45
  206. login_success = False
  207. while time.time() < end_time:
  208. time.sleep(1)
  209. curr_url = self.page.url
  210. # 成功特征
  211. if "/UserArea" in curr_url or "/Services" in curr_url:
  212. login_success = True
  213. break
  214. # 失败特征
  215. if self.page.ele('.validation-summary-errors') or self.page.ele('.field-validation-error'):
  216. err_text = self.page.ele('.validation-summary-errors').text if self.page.ele('.validation-summary-errors') else "Unknown validation error"
  217. raise PermissionDeniedError(f"Login Failed: {err_text}")
  218. # 检查是否有弹窗错误
  219. if "Home" in curr_url and self.page.ele('#logoutForm'):
  220. # 有时候虽然在 Home 但出现了 Logout 按钮,也算成功
  221. login_success = True
  222. break
  223. if not login_success:
  224. # 截图保留现场
  225. # self.page.get_screenshot(path="login_fail.jpg")
  226. raise BizLogicError("Login Failed: Timeout waiting for redirect (Captcha score too low?)")
  227. self._log("Login Successful.")
  228. self.session_create_time = time.time()
  229. except Exception as e:
  230. self._log(f"Create Session Failed: {e}")
  231. self.cleanup()
  232. raise e
  233. # -------------------------------------------------------------
  234. # 2. Query Availability
  235. # -------------------------------------------------------------
  236. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  237. res = VSQueryResult()
  238. res.success = False
  239. res.availability_status = AvailabilityStatus.NoneAvailable
  240. if not self._service_id:
  241. raise BizLogicError("Service ID not configured")
  242. # 1. 检查 Slot 是否可用 (Check Availability Endpoint)
  243. check_url = f"{self._host}/Services/Booking/{self._service_id}"
  244. # 使用 Fetch 发起检查请求
  245. resp = self._perform_request("GET", check_url, headers={
  246. "Referer": f"{self._host}/Services",
  247. "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
  248. })
  249. # 302 跳转处理逻辑
  250. if resp.status_code == 200:
  251. # 200 表示进入了预约页,有号
  252. self._log("Slot Check: 200 OK (Availability Detected)")
  253. pass
  254. elif "BookingCalendar" in resp.url: # 或者是被重定向到了 Calendar
  255. self._log("Slot Check: Redirected to Calendar (Availability Detected)")
  256. pass
  257. else:
  258. # 被重定向回 Home 或 Service,说明没号或 Session 过期
  259. if "Home" in resp.url or "Login" in resp.url:
  260. self.is_healthy = False
  261. raise SessionExpiredOrInvalidError("Session expired during query")
  262. self._log("Slot Check: No availability (Redirected back)")
  263. return res
  264. # 2. 查询月份 (Query Month)
  265. # 默认查询当月,或者配置的月份
  266. tar_dates = self.free_config.get("target_dates", [])
  267. if not tar_dates:
  268. # 默认查下个月
  269. next_month = datetime.now().replace(day=28) + datetime.timedelta(days=4)
  270. tar_dates = [next_month.strftime("%Y-%m-%d")]
  271. all_slots = []
  272. # Prenotami 需要先 retrieve server info
  273. self._perform_request("GET", f"{self._host}/BookingCalendar/RetrieveServerInfo")
  274. for date_str in tar_dates:
  275. # 构造月份格式 2026-01-05 -> 2026-01-01 (API 需要)
  276. try:
  277. dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
  278. except:
  279. try:
  280. dt = datetime.strptime(date_str, "%Y-%m-%d")
  281. except:
  282. dt = datetime.now()
  283. # API 需要格式: 2025-11-05T... 格式的字符串作为 selectedDay
  284. # 实际上 RetrieveCalendarAvailability 只需要由前端日历控件触发的格式
  285. # 查询日历 API
  286. cal_url = f"{self._host}/BookingCalendar/RetrieveCalendarAvailability"
  287. cal_payload = {
  288. "_Servizio": str(self._service_id),
  289. "selectedDay": date_str # 原样传配置里的 ISO 串
  290. }
  291. resp_cal = self._perform_request("POST", cal_url, json_data=cal_payload)
  292. if resp_cal.status_code != 200: continue
  293. # 解析有效日期
  294. valid_days = self._parse_valid_days(resp_cal.text)
  295. self._log(f"Valid days for {date_str}: {valid_days}")
  296. if valid_dates:
  297. res.success = True
  298. res.availability_status = AvailabilityStatus.Available
  299. earliest_date = valid_dates[0]
  300. earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  301. res.earliest_date = earliest_dt
  302. for day in valid_days:
  303. # 查询具体 Slot
  304. slot_url = f"{self._host}/BookingCalendar/RetrieveTimeSlots"
  305. slot_payload = {
  306. "selectedDay": day, # YYYY-MM-DD
  307. "idService": str(self._service_id)
  308. }
  309. resp_slot = self._perform_request("POST", slot_url, json_data=slot_payload)
  310. time_slots = self._parse_time_slots(resp_slot.text)
  311. ts_list = []
  312. if time_slots:
  313. # 转换结构
  314. for ts in time_slots:
  315. # ts: {'id': 123, 'start': '10:00', 'end': '10:30', 'remain': 1}
  316. ts_list.append(TimeSlot(
  317. time=f"{ts['start']} - {ts['end']}",
  318. label=str(ts['id']) # 将 ID 存入 label 以便 book 使用
  319. ))
  320. res.availability.append(DateAvailability(date=datetime.strptime(day, "%d-%m-%Y"), times=ts_list))
  321. return res
  322. # -------------------------------------------------------------
  323. # 3. Book
  324. # -------------------------------------------------------------
  325. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  326. res = VSBookResult()
  327. res.success = False
  328. if not slot_info.availability:
  329. raise NotFoundError("No slots to book")
  330. target_dt = slot_info.availability[0].date
  331. target_date = target_dt.strftime("%Y-%m-%d")
  332. # 取第一个时间段
  333. target_slot = slot_info.availability[0].times[0]
  334. slot_id = target_slot.label # 我们在 query 里把 ID 存在了 label
  335. slot_text = target_slot.time # "10:00 - 10:30"
  336. # 1. 获取 OTP (GenerateOTP)
  337. self._log("Requesting OTP...")
  338. otp_url = f"{self._host}/BookingCalendar/GenerateOTP?ServiceID={self._service_id}"
  339. self._perform_request("POST", otp_url)
  340. # 2. 等待并读取邮件
  341. self._log("Waiting for email code...")
  342. time.sleep(10) # 稍微等一下发信
  343. email_account = self.config.account.email
  344. # 使用 CloudAPI 读取 (假设已配置)
  345. otp_code = VSCloudApi.Instance().get_email_verify_code(email_account)
  346. if not otp_code:
  347. raise BizLogicError("Failed to retrieve OTP code")
  348. self._log(f"Got OTP: {otp_code}")
  349. # 3. 提交详细信息 (Fill User Info)
  350. # 这是最复杂的一步,涉及文件上传 (Multipart)
  351. self._log("Submitting User Details & Files...")
  352. # 准备文件 (转 Base64 传给 JS)
  353. passport_pdf_path = user_inputs.get('passport_pdf_path')
  354. irp_pdf_path = user_inputs.get('irp_pdf_path')
  355. def file_to_b64(path):
  356. if not path or not os.path.exists(path): return ""
  357. with open(path, "rb") as f:
  358. return base64.b64encode(f.read()).decode('utf-8')
  359. ppt_b64 = file_to_b64(passport_pdf_path)
  360. irp_b64 = file_to_b64(irp_pdf_path)
  361. # 构造 JS FormData 提交脚本
  362. # 注意:这里需要根据 Service ID (Dublin/Canton) 动态调整字段 ID
  363. # 下面以 Dublin (1321) 的字段为例,如果是 Canton 需要修改 _Id 和 _TipoDatoAddizionale
  364. # 为了通用性,这里演示 Dublin 的结构,请根据实际 Service ID 调整 mapping
  365. # 假设是 Dublin (根据提供的源码分析)
  366. boundary = '----WebKitFormBoundaryRandomString'
  367. submit_url = f"{self._host}/Services/Booking/{self._service_id}"
  368. # 注入 JS 执行
  369. js_submit = f"""
  370. const url = "{submit_url}";
  371. const fd = new FormData();
  372. // 基础字段
  373. fd.append('ServizioDescrizione', 'D Visa Application');
  374. fd.append('MessaggioRassicuranteWaitingList', 'True');
  375. fd.append('isWaitingListEnabled', 'False');
  376. fd.append('IDServizioConsolare', '35');
  377. fd.append('IDServizioErogato', '{self._service_id}');
  378. fd.append('IdTipoPrenotazione', '1'); // Single
  379. fd.append('NumMaxAccompagnatori', '3');
  380. fd.append('NumAccompagnatoriSelected', '0');
  381. // 动态字段 (Dublin 示例)
  382. // [0] Other citizenship -> User Input
  383. fd.append('DatiAddizionaliPrenotante[0]._Descrizione', 'Other citizenship/s');
  384. fd.append('DatiAddizionaliPrenotante[0]._testo', '{user_inputs.get("citizen", "China")}');
  385. fd.append('DatiAddizionaliPrenotante[0]._Obbligatorio', 'False');
  386. fd.append('DatiAddizionaliPrenotante[0]._Id', '61738');
  387. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoDatoAddizionale', '26');
  388. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoControllo', '2');
  389. // [1] Full address -> User Input
  390. fd.append('DatiAddizionaliPrenotante[1]._Descrizione', 'Full residence address');
  391. fd.append('DatiAddizionaliPrenotante[1]._testo', '{user_inputs.get("address", "")}');
  392. fd.append('DatiAddizionaliPrenotante[1]._Obbligatorio', 'True');
  393. fd.append('DatiAddizionaliPrenotante[1]._Id', '61739');
  394. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoDatoAddizionale', '25');
  395. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoControllo', '2');
  396. // [2] Passport Num
  397. fd.append('DatiAddizionaliPrenotante[2]._Descrizione', 'Passport number');
  398. fd.append('DatiAddizionaliPrenotante[2]._testo', '{user_inputs.get("passport", "")}');
  399. fd.append('DatiAddizionaliPrenotante[2]._Obbligatorio', 'True');
  400. fd.append('DatiAddizionaliPrenotante[2]._Id', '61740');
  401. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoDatoAddizionale', '2');
  402. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoControllo', '2');
  403. // [3] Reason (Select)
  404. fd.append('DatiAddizionaliPrenotante[3]._Descrizione', 'Reason for visit');
  405. fd.append('DatiAddizionaliPrenotante[3]._Obbligatorio', 'True');
  406. fd.append('DatiAddizionaliPrenotante[3]._Id', '61741');
  407. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoDatoAddizionale', '34');
  408. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoControllo', '3');
  409. fd.append('DatiAddizionaliPrenotante[3]._idSelezionato', '42'); // 42 = Tourism? Need verify
  410. // OTP
  411. fd.append('otp-input', '{otp_code}');
  412. fd.append('PrivacyCheck', 'true');
  413. // 文件处理 (Base64 -> Blob -> FormData)
  414. // 注意:这里假设页面上有文件上传的对应 ID,或者我们直接硬编码 FormData
  415. // 原始抓包并未显示文件字段名,通常是 File_0, File_1
  416. // 我们需要将 base64 转 blob
  417. async function addFile(b64, name, filename) {{
  418. if(!b64) return;
  419. const res = await fetch(`data:application/pdf;base64,${{b64}}`);
  420. const blob = await res.blob();
  421. fd.append(name, blob, filename);
  422. }}
  423. // 并行处理文件
  424. await Promise.all([
  425. addFile('{ppt_b64}', 'File_0', 'passport.pdf'), // 假设 File_0 是护照
  426. addFile('{irp_b64}', 'File_1', 'irp.pdf') // 假设 File_1 是 IRP
  427. ]);
  428. // 发送 POST
  429. return fetch(url, {{
  430. method: 'POST',
  431. body: fd
  432. }}).then(async r => {{
  433. return {{ status: r.status, url: r.url, text: await r.text() }};
  434. }}).catch(e => {{ return {{ status: 0, text: e.toString() }}; }});
  435. """
  436. result_dict = self.page.run_js(js_submit)
  437. resp = BrowserResponse(result_dict)
  438. if resp.status_code == 302 or "BookingCalendar" in resp.url:
  439. self._log("User Info Submitted Successfully.")
  440. else:
  441. self._log(f"User Info Submit Failed: {resp.text[:100]}")
  442. # 如果 OTP 错误,页面会返回特定错误信息
  443. if "Codice errato" in resp.text:
  444. raise BizLogicError("Invalid OTP Code")
  445. return res # Fail
  446. # 4. 最终确认预约 (InsertNewBooking)
  447. self._log("Finalizing Booking...")
  448. final_url = f"{self._host}/BookingCalendar/InsertNewBooking"
  449. final_payload = {
  450. "idCalendarioGiornaliero": slot_id,
  451. "selectedDay": target_date,
  452. "selectedHour": slot_text # "10:00 - 10:30(2)"
  453. }
  454. # 这里用 Form-UrlEncoded
  455. resp_final = self._perform_request("POST", final_url, data=final_payload)
  456. if resp_final.status_code == 200:
  457. self._log("Booking Confirmed!")
  458. res.success = True
  459. res.book_date = target_date
  460. res.book_time = slot_text
  461. else:
  462. self._log(f"Final Booking Failed: {resp_final.status_code}")
  463. return res
  464. # -------------------------------------------------------------
  465. # 4. Helpers
  466. # -------------------------------------------------------------
  467. def _get_proxy_url(self):
  468. # 构造代理
  469. proxy_url = ""
  470. if self.config.proxy.ip:
  471. s = self.config.proxy
  472. if s.username:
  473. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  474. else:
  475. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  476. return proxy_url
  477. def _solve_and_inject_prenotami_captcha(self):
  478. """
  479. 专门处理 Prenotami 的 ReCaptcha Enterprise
  480. """
  481. self._log("Solving ReCaptcha Enterprise (Action: LOGIN)...")
  482. api_token = self.free_config.get("capsolver_key", "")
  483. if not api_token:
  484. raise BizLogicError("Capsolver Key is required for Prenotami")
  485. # 从 HTML 源码中提取的信息
  486. site_key = "6LdkwrIqAAAAAC4NX-g_j7lEx9vh1rg94ZL2cFfY"
  487. page_url = self.page.url
  488. # 注意:Prenotami 的这个 Key 其实是混合模式,
  489. # 虽然它是 V3 (Enterprise),但很多打码平台用 V2 接口也能解,或者必须用 V3 Enterprise 接口
  490. # 建议先尝试 ReCaptchaV3EnterpriseTaskProxyLess
  491. # 修正为最标准的 V3 Enterprise 配置
  492. rc_params = {
  493. "type": "ReCaptchaV3EnterpriseTaskProxyless",
  494. "page": page_url,
  495. "siteKey": site_key,
  496. "action": "LOGIN", # 关键参数
  497. "minScore": 0.7, # 要求高分
  498. "apiToken": api_token,
  499. # "proxy": self._get_proxy_url()
  500. }
  501. g_token = self._solve_recaptcha(rc_params)
  502. self._log(f"Captcha Solved. Token length: {len(g_token)}")
  503. hook_js = f"""
  504. // 1. 填充隐藏域 (双重保险)
  505. var input = document.getElementById('g-recaptcha-response');
  506. if(input) {{
  507. input.value = "{g_token}";
  508. }}
  509. // 2. 劫持 grecaptcha.execute 和 grecaptcha.enterprise.execute
  510. // 无论网页用哪个版本,都拦截下来
  511. var mockExecute = function() {{
  512. console.log("Recaptcha execution intercepted!");
  513. return Promise.resolve("{g_token}");
  514. }};
  515. if (window.grecaptcha) {{
  516. window.grecaptcha.execute = mockExecute;
  517. if (window.grecaptcha.enterprise) {{
  518. window.grecaptcha.enterprise.execute = mockExecute;
  519. }}
  520. }}
  521. """
  522. self._log("Injecting ReCaptcha Hook...")
  523. self.page.run_js(hook_js)
  524. def _perform_request(self, method, url, headers=None, data=None, json_data=None):
  525. """JS Fetch Wrapper"""
  526. if not self.page: raise BizLogicError("Browser not init")
  527. fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
  528. if json_data:
  529. fetch_opts['body'] = json.dumps(json_data)
  530. fetch_opts['headers']['Content-Type'] = 'application/json; charset=UTF-8'
  531. elif data:
  532. if isinstance(data, dict):
  533. from urllib.parse import urlencode
  534. fetch_opts['body'] = urlencode(data)
  535. fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  536. else:
  537. fetch_opts['body'] = data
  538. js = f"""
  539. return fetch("{url}", {json.dumps(fetch_opts)})
  540. .then(async r => {{
  541. const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
  542. return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
  543. }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  544. """
  545. return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
  546. def _solve_recaptcha(self, params) -> str:
  547. """
  548. 调用 YesCaptcha API 识别
  549. """
  550. client_key = params.get("apiToken")
  551. # 1. 选择任务类型
  552. # 根据文档:RecaptchaV3TaskProxylessM1S7 强制 0.7 分,适合登录
  553. task_type = "RecaptchaV3TaskProxyless" # 默认
  554. if params.get("minScore") == 0.7:
  555. task_type = "RecaptchaV3TaskProxylessM1S7"
  556. elif params.get("minScore") == 0.9:
  557. task_type = "RecaptchaV3TaskProxylessM1S9"
  558. # 2. 构造创建任务请求
  559. create_url = "https://api.yescaptcha.com/createTask"
  560. create_data = {
  561. "clientKey": client_key,
  562. "task": {
  563. "type": task_type,
  564. "websiteURL": params.get("page"),
  565. "websiteKey": params.get("siteKey"),
  566. "pageAction": params.get("action") # YesCaptcha 要求的字段名是 pageAction
  567. }
  568. }
  569. import requests as req
  570. try:
  571. # 发送创建任务请求
  572. r = req.post(create_url, json=create_data, timeout=20)
  573. if r.status_code != 200:
  574. raise BizLogicError(f"YesCaptcha Create Failed: {r.text}")
  575. res_json = r.json()
  576. if res_json.get("errorId") != 0:
  577. raise BizLogicError(f"YesCaptcha Error: {res_json.get('errorDescription')}")
  578. task_id = res_json.get("taskId")
  579. if not task_id:
  580. raise BizLogicError("YesCaptcha returned no taskId")
  581. # 3. 轮询获取结果
  582. result_url = "https://api.yescaptcha.com/getTaskResult"
  583. for _ in range(30): # 最多等 60-90秒
  584. time.sleep(3)
  585. r = req.post(result_url, json={"clientKey": client_key, "taskId": task_id}, timeout=20)
  586. d = r.json()
  587. # 识别中
  588. if d.get("status") == "processing":
  589. continue
  590. # 识别成功
  591. if d.get("status") == "ready":
  592. solution = d.get("solution", {})
  593. token = solution.get("gRecaptchaResponse")
  594. if token:
  595. return token
  596. else:
  597. raise BizLogicError("YesCaptcha ready but no token found")
  598. # 识别失败
  599. if d.get("errorId") != 0:
  600. raise BizLogicError(f"YesCaptcha Task Failed: {d.get('errorDescription')}")
  601. except Exception as e:
  602. raise BizLogicError(f"Captcha Solver Exception: {e}")
  603. raise BizLogicError("YesCaptcha timeout")
  604. def _parse_valid_days(self, text):
  605. # 提取 DateLibere (YYYY-MM-DD)
  606. # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}
  607. # 原始正则: r'{"DateLibere":"(.*?)","SlotLiberi":\d+,"SlotRimanenti":(-?\d+)}'
  608. days = []
  609. try:
  610. matches = re.findall(r'{"DateLibere":"(.*?)".*?"SlotRimanenti":(-?\d+)}', text)
  611. for d_str, rem in matches:
  612. if int(rem) != -1:
  613. # 22/10/2024 -> 2024-10-22
  614. dt = datetime.strptime(d_str[:10], "%d/%m/%Y")
  615. days.append(dt.strftime("%Y-%m-%d"))
  616. except: pass
  617. return days
  618. def _parse_time_slots(self, text):
  619. # 提取 IDCalendarioServizioGiornaliero, StartTime, EndTime, Remain
  620. slots = []
  621. try:
  622. # 原始逻辑比较复杂,这里简化正则
  623. # 查找 SlotRimanenti > 0 的记录
  624. # 关键是 IDCalendarioServizioGiornaliero
  625. raw_list = json.loads(text)
  626. # Prenotami 返回的是一个 JSON 列表字符串
  627. for item in raw_list:
  628. remain = item.get('SlotRimanenti', -1)
  629. if remain > 0:
  630. start = item['OrarioInizioFascia']
  631. end = item['OrarioFineFascia']
  632. s_time = f"{start['Hours']:02d}:{start['Minutes']:02d}"
  633. e_time = f"{end['Hours']:02d}:{end['Minutes']:02d}"
  634. slots.append({
  635. 'id': item['IDCalendarioServizioGiornaliero'],
  636. 'start': s_time,
  637. 'end': e_time,
  638. 'remain': remain
  639. })
  640. except: pass
  641. return slots
  642. # --- 资源清理核心方法 ---
  643. def cleanup(self):
  644. """
  645. 销毁浏览器并彻底删除临时文件
  646. """
  647. # 1. 关闭浏览器
  648. if self.page:
  649. try:
  650. self.page.quit() # 这会关闭 Chrome 进程
  651. except Exception:
  652. pass # 忽略已关闭的错误
  653. self.page = None
  654. # 2. 删除文件
  655. # 注意:Chrome 关闭后可能需要几百毫秒释放文件锁,稍微等待
  656. if os.path.exists(self.root_workspace):
  657. for _ in range(3):
  658. try:
  659. time.sleep(0.2)
  660. shutil.rmtree(self.root_workspace, ignore_errors=True)
  661. break
  662. except Exception as e:
  663. # 如果删除失败(通常是Windows文件占用),重试
  664. self._log(f"Cleanup retry: {e}")
  665. time.sleep(0.5)
  666. # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
  667. if os.path.exists(self.root_workspace):
  668. self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
  669. # 3. [新增] 关闭代理隧道
  670. if self.tunnel:
  671. try: self.tunnel.stop()
  672. except: pass
  673. self.tunnel = None
  674. def __del__(self):
  675. """
  676. 析构函数:当对象被垃圾回收时自动调用
  677. """
  678. self.cleanup()