ita_plugin.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  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 concurrent.futures import ThreadPoolExecutor
  11. from datetime import datetime
  12. from typing import List, Dict, Optional, Any, Callable
  13. from urllib.parse import urlencode, urlparse
  14. from DrissionPage import ChromiumPage, ChromiumOptions
  15. import configure
  16. from vs_plg import IVSPlg
  17. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  18. from toolkit.mihomo_tunnel import MihomoTunnel
  19. from toolkit.vs_cloud_api import VSCloudApi
  20. from utils.mouse import HumanMouse
  21. from utils.keyboard import HumanKeyboard
  22. from utils.fingerprint_utils import FingerprintGenerator
  23. class BrowserResponse:
  24. def __init__(self, result_dict):
  25. result_dict = result_dict or {}
  26. self.status_code = result_dict.get('status', 0)
  27. self.text = result_dict.get('body', '')
  28. self.headers = result_dict.get('headers', {})
  29. self.url = result_dict.get('url', '')
  30. self._json = None
  31. def json(self):
  32. if self._json is None:
  33. if not self.text: return {}
  34. try: self._json = json.loads(self.text)
  35. except: self._json = {}
  36. return self._json
  37. # ==========================================
  38. # 2. ItaPlugin 核心逻辑
  39. # ==========================================
  40. class ItaPlugin(IVSPlg):
  41. def __init__(self, group_id: str):
  42. self.group_id = group_id
  43. self.config: Optional[VSPlgConfig] = None
  44. self.free_config: Dict[str, Any] = {}
  45. self.is_healthy = True
  46. self.logger = None
  47. self.page: Optional[ChromiumPage] = None
  48. # Prenotami 特有配置
  49. self._service_id = 0
  50. self.ita_url = 'https://prenotami.esteri.it'
  51. self.instance_id = uuid.uuid4().hex[:8]
  52. self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
  53. self.user_data_path = os.path.join(self.root_workspace, "user_data")
  54. if not os.path.exists(self.root_workspace):
  55. os.makedirs(self.root_workspace)
  56. self.tunnel = None
  57. self.session_create_time: float = 0
  58. def set_log(self, logger: Callable[[str], None]) -> None:
  59. self.logger = logger
  60. def _log(self, message):
  61. if self.logger:
  62. self.logger(f'[ItaPlugin] [{self.group_id}] {message}')
  63. else:
  64. print(f'[ItaPlugin] [{self.group_id}] {message}')
  65. def set_config(self, config: VSPlgConfig):
  66. self.config = config
  67. self.free_config = config.free_config or {}
  68. # Service ID (e.g., 1321 for Ireland, 5059 for Guangzhou)
  69. self._service_id = self.free_config.get('service_id', 0)
  70. def keep_alive(self):
  71. pass
  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:
  82. self._log("Session expired.")
  83. return False
  84. return True
  85. def create_session(self):
  86. """
  87. 全浏览器会话创建:过盾 -> JS注入登录 -> 状态机自动路由导航 -> 到达目标页
  88. """
  89. self._log(f"Initializing Session (ID: {self.instance_id})...")
  90. co = ChromiumOptions()
  91. def get_free_port():
  92. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  93. s.bind(('', 0))
  94. return s.getsockname()[1]
  95. debug_port = get_free_port()
  96. self._log(f"Assigned Debug Port: {debug_port}")
  97. self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
  98. co.set_local_port(debug_port)
  99. co.set_user_data_path(self.user_data_path)
  100. chrome_path = configure.CHROME_PATH
  101. if not chrome_path:
  102. chrome_path = os.getenv("CHROME_BIN")
  103. if chrome_path and os.path.exists(chrome_path):
  104. co.set_paths(browser_path=chrome_path)
  105. if self.config.proxy and self.config.proxy.ip:
  106. p = self.config.proxy
  107. if p.username and p.password:
  108. self._log(f"Starting Proxy Tunnel for {p.ip}...")
  109. exit_node = {
  110. "name": "ExitNode",
  111. "type": p.proto,
  112. "server": p.ip,
  113. "port": p.port,
  114. "username": p.username,
  115. "password": p.password
  116. }
  117. relay_node = None
  118. if configure.MIHOMO_RELAY_NODES:
  119. relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
  120. mihomo_path = configure.MIHOMO_BIN_PATH
  121. if not mihomo_path:
  122. mihomo_path = os.getenv("MIHOMO_BIN")
  123. if not mihomo_path:
  124. raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
  125. self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
  126. local_proxy = self.tunnel.start()
  127. self._log(f"Tunnel started at {local_proxy}")
  128. co.set_argument(f'--proxy-server={local_proxy}')
  129. else:
  130. proxy_str = f"{p.proto}://{p.ip}:{p.port}"
  131. co.set_argument(f'--proxy-server={proxy_str}')
  132. else:
  133. self._log("[WARN] No proxy configured!")
  134. specific_fp = FingerprintGenerator().generate(self.config.account.username)
  135. fp_seed = specific_fp.get("seed")
  136. fp_platform = specific_fp.get("platform")
  137. fp_brand = specific_fp.get("brand")
  138. self._log(f'browser fingerprint seed={fp_seed}')
  139. co.headless(False)
  140. co.set_argument('--no-sandbox')
  141. co.set_argument('--disable-dev-shm-usage')
  142. co.set_argument('--window-size=1920,1080')
  143. co.set_argument('--disable-blink-features=AutomationControlled')
  144. co.set_argument(f"--fingerprint={fp_seed}")
  145. co.set_argument(f"--fingerprint-platform={fp_platform}")
  146. co.set_argument(f"--fingerprint-brand={fp_brand}")
  147. self.page = ChromiumPage(co)
  148. if self.config.debug:
  149. self.page.get('https://example.com')
  150. js_script = """
  151. function getFingerprint() {
  152. let webglVendor = 'Unknown';
  153. let webglRenderer = 'Unknown';
  154. try {
  155. let canvas = document.createElement('canvas');
  156. let gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  157. if (gl) {
  158. let debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
  159. if (debugInfo) {
  160. webglVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
  161. webglRenderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
  162. }
  163. }
  164. } catch(e) {}
  165. return {
  166. "User-Agent": navigator.userAgent,
  167. "Platform": navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform,
  168. "Brands": navigator.userAgentData ? navigator.userAgentData.brands.map(b => b.brand).join(', ') : 'Not Supported',
  169. "CPU Cores": navigator.hardwareConcurrency,
  170. "Language": navigator.language,
  171. "Timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
  172. "WebGL Vendor": webglVendor,
  173. "WebGL Renderer": webglRenderer
  174. };
  175. }
  176. return getFingerprint();
  177. """
  178. fp_data = self.page.run_js(js_script)
  179. self._log("================ 预检浏览器指纹数据 ================")
  180. self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
  181. self._log("====================================================")
  182. self.page = ChromiumPage(co)
  183. ita_url = self.ita_url
  184. self._log(f"Navigating to {ita_url}")
  185. self.page.get(ita_url)
  186. self._log("Init humanize tools...")
  187. self.mouse = HumanMouse(self.page, debug=True)
  188. self.keyboard = HumanKeyboard(self.page)
  189. self._log("Random mouse start position...")
  190. viewport_width = self.page.rect.viewport_size[0]
  191. viewport_height = self.page.rect.viewport_size[1]
  192. init_x = random.randint(10, viewport_width - 10)
  193. init_y = random.randint(10, viewport_height - 10)
  194. self.mouse.move(init_x, init_y)
  195. switch_en_btn = self.page.ele('tag:a@@href=/Language/ChangeLanguage?lang=2')
  196. self.mouse.human_click_ele(switch_en_btn)
  197. self.page.wait.load_start()
  198. time.sleep(5)
  199. max_steps = 15
  200. stuck_counter = 0
  201. last_url = ""
  202. session_created = False
  203. has_submitted_login = False
  204. username = self.config.account.username
  205. password = self.config.account.password
  206. for step in range(max_steps):
  207. self.page.wait.doc_loaded()
  208. time.sleep(1)
  209. current_url = self.page.url
  210. current_title = self.page.title.lower()
  211. current_html_content = self.page.html
  212. self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
  213. # --- [异常处理与反爬对抗层] ---
  214. if current_url == last_url:
  215. stuck_counter += 1
  216. else:
  217. last_url = current_url
  218. stuck_counter = 0
  219. if stuck_counter >= 3:
  220. self._log("[WARN] Page stucked, try to refresh...")
  221. self.page.refresh()
  222. self._random_sleep(5, 8)
  223. stuck_counter = 0
  224. continue
  225. server_error_indicators = ["502 Bad Gateway", "503 Service Temporarily Unavailable", "error 1020"]
  226. if any(err in current_html_content for err in server_error_indicators):
  227. self._log(f"[WARN] Server network error, try to refresh (Step: {step})...")
  228. self.page.refresh()
  229. self._random_sleep(10, 15)
  230. continue
  231. # Cloudflare 拦截
  232. cloudflare_blocked_indicators = [
  233. "Sorry, you have been blocked",
  234. "You are being rate limited",
  235. "Cloudflare Ray ID"
  236. ]
  237. if any(indicator in current_html_content for indicator in cloudflare_blocked_indicators):
  238. raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
  239. # 遇到五秒盾先绕盾 (保留你原有的逻辑)
  240. if "just a moment" in current_title or "cloudflare" in current_title:
  241. # 假设你有 cf_bypasser 实例
  242. # if not cf_bypasser.bypass(max_retry=3): continue
  243. self._log("[State] CF 5-second shield detected. Waiting...")
  244. time.sleep(5)
  245. continue
  246. # 状态 0: 成功到达目标预约页面 (Target Page)
  247. if "/Services" in current_url and self.page.ele('.app-menu', timeout=1):
  248. self._log("🎉 Successfully reached the Booking Services page! Session created successfully!")
  249. self.session_create_time = time.time()
  250. session_created = True
  251. break
  252. # 状态 1: 登录前的初始首页
  253. elif self.page.ele('#pingid-button', timeout=1):
  254. self._log("[State] Initial Landing Page detected. Clicking login button...")
  255. login_btn = self.page.ele('#pingid-button')
  256. self.mouse.human_click_ele(login_btn)
  257. self._log("Redirecting to PingID...")
  258. self._random_sleep(3, 5) # 替代 time.sleep(5)
  259. continue
  260. # 状态 2: 真正的表单登录页 (PingID)
  261. elif self.page.ele('@name=callback_1', timeout=1):
  262. self._log("[State] PingID Login Form detected. Submitting credentials...")
  263. print("正在输入账号...")
  264. user_input = self.page.ele('@name=callback_1')
  265. self.mouse.human_click_ele(user_input)
  266. user_input.input(username, clear=True)
  267. time.sleep(1) # 模拟人手停顿
  268. print("正在输入密码...")
  269. pwd_input = self.page.ele('@type=password')
  270. self.mouse.human_click_ele(pwd_input)
  271. pwd_input.input(password, clear=True)
  272. time.sleep(1)
  273. print("正在点击登录按钮...")
  274. submit_btn = self.page.ele('tag:button@type=submit')
  275. self.mouse.human_click_ele(submit_btn)
  276. has_submitted_login = True
  277. self._log("Login form submitted. Waiting for dashboard to load...")
  278. self._random_sleep(5, 7)
  279. continue
  280. # 状态 3: 登录成功后的主控制台 (导航栏页面)
  281. elif self.page.ele('.app-menu', timeout=1):
  282. self._log("[State] Dashboard Menu detected.")
  283. switch_en_btn = self.page.ele('tag:a@@href=/Language/ChangeLanguage?lang=2', timeout=0.5)
  284. if switch_en_btn:
  285. self._log("Found 'English' language switch button. Clicking to change language...")
  286. self.mouse.human_click_ele(switch_en_btn)
  287. self._random_sleep(4, 6)
  288. continue
  289. # 动作 B: 如果没有英文切换按钮(说明已经是英文或不存在),则查找并点击 Book 按钮
  290. book_btn = self.page.ele('@href=/Services', timeout=0.5)
  291. if book_btn:
  292. self._log("Found 'Book' menu element. Clicking to proceed to reservation...")
  293. self.mouse.human_click_ele(book_btn)
  294. self._random_sleep(4, 6)
  295. continue
  296. self._log("[WARN] On Dashboard, but neither 'English' nor 'Book' button was found. Waiting...")
  297. time.sleep(2)
  298. continue
  299. else:
  300. self._log("[State] In unknown or transitional state. Waiting for next polling cycle...")
  301. time.sleep(2)
  302. if not session_created:
  303. raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
  304. # -------------------------------------------------------------
  305. # 2. Query Availability
  306. # -------------------------------------------------------------
  307. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  308. res = VSQueryResult()
  309. res.success = False
  310. res.availability_status = AvailabilityStatus.NoneAvailable
  311. # 假设要预约的服务和到访原因
  312. TARGET_SERVICE = "National and Schengen Visas"
  313. REASON_FOR_VISIT = "Tourism" # 对应 value 42 的选项文本
  314. # ==========================================
  315. # 步骤 1:在服务列表中寻找并点击目标服务的 Book
  316. # ==========================================
  317. print("等待服务列表加载...")
  318. self.page.wait.eles_loaded('@aria-controls=dataTableServices', timeout=10)
  319. print(f"正在查找 [{TARGET_SERVICE}] 的 Book 按钮...")
  320. # 使用 XPath:找包含 TARGET_SERVICE 文本的行(tr),再找该行里包含 Book 的超链接(a)
  321. target_book_btn_xpath = f'xpath://tr[contains(., "{TARGET_SERVICE}")]//a[contains(text(), "Book")]'
  322. book_btn = self.page.ele(target_book_btn_xpath)
  323. if not book_btn:
  324. print(f"未找到 {TARGET_SERVICE} 的 Book 按钮,可能当前无号,程序退出。")
  325. # 这里可以根据你的逻辑 return 或者 raise Exception
  326. else:
  327. self.mouse.human_click_ele(book_btn)
  328. self.page.wait.load_start()
  329. time.sleep(3)
  330. # ==========================================
  331. # 步骤 2:填写预约表单
  332. # ==========================================
  333. print("等待预约表单加载...")
  334. self.page.wait.eles_loaded('#bookingForm', timeout=10)
  335. print("1. 选择预约类型...")
  336. typeofbooking_ddl = self.page.ele('#typeofbookingddl')
  337. # DrissionPage select 方法直接按文本选中,会自动触发网页的 JS 联动
  338. typeofbooking_ddl.select('Individual booking')
  339. time.sleep(1)
  340. print("2. 选择到访原因...")
  341. reason_ddl = self.page.ele('#ddls_0')
  342. reason_ddl.select(REASON_FOR_VISIT)
  343. time.sleep(1)
  344. print("3. 填写备注...")
  345. notes_input = self.page.ele('#BookingNotes')
  346. notes_input.clear()
  347. notes_input.input('N/A') # 选填,填入 N/A 或者留空
  348. time.sleep(1)
  349. # ==========================================
  350. # 步骤 3:处理 OTP 验证码
  351. # ==========================================
  352. print("4. 点击发送 OTP 验证码...")
  353. otp_send_btn = self.page.ele('#otp-send')
  354. self.mouse.human_click_ele(otp_send_btn)
  355. # 这是一个 AJAX 请求,会转圈圈。我们需要等待成功提示出现
  356. print("等待验证码发送成功的提示...")
  357. self.page.wait.ele_displayed('#IdOtpSent', timeout=15) # 等待绿字 "New code sent!" 显示
  358. print("验证码已发送!")
  359. print("5. 填写默认验证码 123456 ...")
  360. otp_input = self.page.ele('#otp-input')
  361. self.mouse.human_click_ele(otp_input)
  362. otp_input.input('123456')
  363. time.sleep(1)
  364. # ==========================================
  365. # 步骤 4:勾选隐私政策并提交
  366. # ==========================================
  367. print("6. 勾选隐私政策...")
  368. privacy_checkbox = self.page.ele('#PrivacyCheck')
  369. # 判断一下如果没勾上才去点,防止重复点击取消了
  370. if not privacy_checkbox.states.is_checked:
  371. self.mouse.human_click_ele(privacy_checkbox)
  372. time.sleep(1)
  373. print("7. 点击 Forward 提交表单...")
  374. forward_btn = self.page.ele('#btnAvanti')
  375. self.mouse.human_click_ele(forward_btn)
  376. # 提交后页面会跳转,等待加载开始
  377. self.page.wait.load_start()
  378. print("表单已提交!当前页面:", self.page.url)
  379. time.sleep(5)
  380. valid_dates = []
  381. if valid_dates:
  382. res.success = True
  383. res.availability_status = AvailabilityStatus.Available
  384. earliest_date = valid_dates[0]
  385. earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
  386. res.earliest_date = earliest_dt
  387. for day in valid_days:
  388. # 查询具体 Slot
  389. slot_url = f"{self._host}/BookingCalendar/RetrieveTimeSlots"
  390. slot_payload = {
  391. "selectedDay": day, # YYYY-MM-DD
  392. "idService": str(self._service_id)
  393. }
  394. resp_slot = self._perform_request("POST", slot_url, json_data=slot_payload)
  395. time_slots = self._parse_time_slots(resp_slot.text)
  396. ts_list = []
  397. if time_slots:
  398. # 转换结构
  399. for ts in time_slots:
  400. # ts: {'id': 123, 'start': '10:00', 'end': '10:30', 'remain': 1}
  401. ts_list.append(TimeSlot(
  402. time=f"{ts['start']} - {ts['end']}",
  403. label=str(ts['id']) # 将 ID 存入 label 以便 book 使用
  404. ))
  405. res.availability.append(DateAvailability(date=datetime.strptime(day, "%d-%m-%Y"), times=ts_list))
  406. return res
  407. # -------------------------------------------------------------
  408. # 3. Book
  409. # -------------------------------------------------------------
  410. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  411. res = VSBookResult()
  412. res.success = False
  413. if not slot_info.availability:
  414. raise NotFoundError("No slots to book")
  415. target_dt = slot_info.availability[0].date
  416. target_date = target_dt.strftime("%Y-%m-%d")
  417. # 取第一个时间段
  418. target_slot = slot_info.availability[0].times[0]
  419. slot_id = target_slot.label # 我们在 query 里把 ID 存在了 label
  420. slot_text = target_slot.time # "10:00 - 10:30"
  421. # 1. 获取 OTP (GenerateOTP)
  422. self._log("Requesting OTP...")
  423. otp_url = f"{self._host}/BookingCalendar/GenerateOTP?ServiceID={self._service_id}"
  424. self._perform_request("POST", otp_url)
  425. # 2. 等待并读取邮件
  426. self._log("Waiting for email code...")
  427. time.sleep(10) # 稍微等一下发信
  428. email_account = self.config.account.email
  429. # 使用 CloudAPI 读取 (假设已配置)
  430. otp_code = VSCloudApi.Instance().get_email_verify_code(email_account)
  431. if not otp_code:
  432. raise BizLogicError("Failed to retrieve OTP code")
  433. self._log(f"Got OTP: {otp_code}")
  434. # 3. 提交详细信息 (Fill User Info)
  435. # 这是最复杂的一步,涉及文件上传 (Multipart)
  436. self._log("Submitting User Details & Files...")
  437. # 准备文件 (转 Base64 传给 JS)
  438. passport_pdf_path = user_inputs.get('passport_pdf_path')
  439. irp_pdf_path = user_inputs.get('irp_pdf_path')
  440. def file_to_b64(path):
  441. if not path or not os.path.exists(path): return ""
  442. with open(path, "rb") as f:
  443. return base64.b64encode(f.read()).decode('utf-8')
  444. ppt_b64 = file_to_b64(passport_pdf_path)
  445. irp_b64 = file_to_b64(irp_pdf_path)
  446. # 构造 JS FormData 提交脚本
  447. # 注意:这里需要根据 Service ID (Dublin/Canton) 动态调整字段 ID
  448. # 下面以 Dublin (1321) 的字段为例,如果是 Canton 需要修改 _Id 和 _TipoDatoAddizionale
  449. # 为了通用性,这里演示 Dublin 的结构,请根据实际 Service ID 调整 mapping
  450. # 假设是 Dublin (根据提供的源码分析)
  451. boundary = '----WebKitFormBoundaryRandomString'
  452. submit_url = f"{self._host}/Services/Booking/{self._service_id}"
  453. # 注入 JS 执行
  454. js_submit = f"""
  455. const url = "{submit_url}";
  456. const fd = new FormData();
  457. // 基础字段
  458. fd.append('ServizioDescrizione', 'D Visa Application');
  459. fd.append('MessaggioRassicuranteWaitingList', 'True');
  460. fd.append('isWaitingListEnabled', 'False');
  461. fd.append('IDServizioConsolare', '35');
  462. fd.append('IDServizioErogato', '{self._service_id}');
  463. fd.append('IdTipoPrenotazione', '1'); // Single
  464. fd.append('NumMaxAccompagnatori', '3');
  465. fd.append('NumAccompagnatoriSelected', '0');
  466. // 动态字段 (Dublin 示例)
  467. // [0] Other citizenship -> User Input
  468. fd.append('DatiAddizionaliPrenotante[0]._Descrizione', 'Other citizenship/s');
  469. fd.append('DatiAddizionaliPrenotante[0]._testo', '{user_inputs.get("citizen", "China")}');
  470. fd.append('DatiAddizionaliPrenotante[0]._Obbligatorio', 'False');
  471. fd.append('DatiAddizionaliPrenotante[0]._Id', '61738');
  472. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoDatoAddizionale', '26');
  473. fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoControllo', '2');
  474. // [1] Full address -> User Input
  475. fd.append('DatiAddizionaliPrenotante[1]._Descrizione', 'Full residence address');
  476. fd.append('DatiAddizionaliPrenotante[1]._testo', '{user_inputs.get("address", "")}');
  477. fd.append('DatiAddizionaliPrenotante[1]._Obbligatorio', 'True');
  478. fd.append('DatiAddizionaliPrenotante[1]._Id', '61739');
  479. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoDatoAddizionale', '25');
  480. fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoControllo', '2');
  481. // [2] Passport Num
  482. fd.append('DatiAddizionaliPrenotante[2]._Descrizione', 'Passport number');
  483. fd.append('DatiAddizionaliPrenotante[2]._testo', '{user_inputs.get("passport", "")}');
  484. fd.append('DatiAddizionaliPrenotante[2]._Obbligatorio', 'True');
  485. fd.append('DatiAddizionaliPrenotante[2]._Id', '61740');
  486. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoDatoAddizionale', '2');
  487. fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoControllo', '2');
  488. // [3] Reason (Select)
  489. fd.append('DatiAddizionaliPrenotante[3]._Descrizione', 'Reason for visit');
  490. fd.append('DatiAddizionaliPrenotante[3]._Obbligatorio', 'True');
  491. fd.append('DatiAddizionaliPrenotante[3]._Id', '61741');
  492. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoDatoAddizionale', '34');
  493. fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoControllo', '3');
  494. fd.append('DatiAddizionaliPrenotante[3]._idSelezionato', '42'); // 42 = Tourism? Need verify
  495. // OTP
  496. fd.append('otp-input', '{otp_code}');
  497. fd.append('PrivacyCheck', 'true');
  498. // 文件处理 (Base64 -> Blob -> FormData)
  499. // 注意:这里假设页面上有文件上传的对应 ID,或者我们直接硬编码 FormData
  500. // 原始抓包并未显示文件字段名,通常是 File_0, File_1
  501. // 我们需要将 base64 转 blob
  502. async function addFile(b64, name, filename) {{
  503. if(!b64) return;
  504. const res = await fetch(`data:application/pdf;base64,${{b64}}`);
  505. const blob = await res.blob();
  506. fd.append(name, blob, filename);
  507. }}
  508. // 并行处理文件
  509. await Promise.all([
  510. addFile('{ppt_b64}', 'File_0', 'passport.pdf'), // 假设 File_0 是护照
  511. addFile('{irp_b64}', 'File_1', 'irp.pdf') // 假设 File_1 是 IRP
  512. ]);
  513. // 发送 POST
  514. return fetch(url, {{
  515. method: 'POST',
  516. body: fd
  517. }}).then(async r => {{
  518. return {{ status: r.status, url: r.url, text: await r.text() }};
  519. }}).catch(e => {{ return {{ status: 0, text: e.toString() }}; }});
  520. """
  521. result_dict = self.page.run_js(js_submit)
  522. resp = BrowserResponse(result_dict)
  523. if resp.status_code == 302 or "BookingCalendar" in resp.url:
  524. self._log("User Info Submitted Successfully.")
  525. else:
  526. self._log(f"User Info Submit Failed: {resp.text[:100]}")
  527. # 如果 OTP 错误,页面会返回特定错误信息
  528. if "Codice errato" in resp.text:
  529. raise BizLogicError("Invalid OTP Code")
  530. return res # Fail
  531. # 4. 最终确认预约 (InsertNewBooking)
  532. self._log("Finalizing Booking...")
  533. final_url = f"{self._host}/BookingCalendar/InsertNewBooking"
  534. final_payload = {
  535. "idCalendarioGiornaliero": slot_id,
  536. "selectedDay": target_date,
  537. "selectedHour": slot_text # "10:00 - 10:30(2)"
  538. }
  539. # 这里用 Form-UrlEncoded
  540. resp_final = self._perform_request("POST", final_url, data=final_payload)
  541. if resp_final.status_code == 200:
  542. self._log("Booking Confirmed!")
  543. res.success = True
  544. res.book_date = target_date
  545. res.book_time = slot_text
  546. else:
  547. self._log(f"Final Booking Failed: {resp_final.status_code}")
  548. return res
  549. def _perform_request(self, method, url, headers=None, data=None, json_data=None):
  550. """JS Fetch Wrapper"""
  551. if not self.page: raise BizLogicError("Browser not init")
  552. fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
  553. if json_data:
  554. fetch_opts['body'] = json.dumps(json_data)
  555. fetch_opts['headers']['Content-Type'] = 'application/json; charset=UTF-8'
  556. elif data:
  557. if isinstance(data, dict):
  558. from urllib.parse import urlencode
  559. fetch_opts['body'] = urlencode(data)
  560. fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
  561. else:
  562. fetch_opts['body'] = data
  563. js = f"""
  564. return fetch("{url}", {json.dumps(fetch_opts)})
  565. .then(async r => {{
  566. const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
  567. return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
  568. }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
  569. """
  570. return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
  571. def _parse_valid_days(self, text):
  572. # 提取 DateLibere (YYYY-MM-DD)
  573. # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}
  574. # 原始正则: r'{"DateLibere":"(.*?)","SlotLiberi":\d+,"SlotRimanenti":(-?\d+)}'
  575. days = []
  576. try:
  577. matches = re.findall(r'{"DateLibere":"(.*?)".*?"SlotRimanenti":(-?\d+)}', text)
  578. for d_str, rem in matches:
  579. if int(rem) != -1:
  580. # 22/10/2024 -> 2024-10-22
  581. dt = datetime.strptime(d_str[:10], "%d/%m/%Y")
  582. days.append(dt.strftime("%Y-%m-%d"))
  583. except: pass
  584. return days
  585. def _parse_time_slots(self, text):
  586. # 提取 IDCalendarioServizioGiornaliero, StartTime, EndTime, Remain
  587. slots = []
  588. try:
  589. # 原始逻辑比较复杂,这里简化正则
  590. # 查找 SlotRimanenti > 0 的记录
  591. # 关键是 IDCalendarioServizioGiornaliero
  592. raw_list = json.loads(text)
  593. # Prenotami 返回的是一个 JSON 列表字符串
  594. for item in raw_list:
  595. remain = item.get('SlotRimanenti', -1)
  596. if remain > 0:
  597. start = item['OrarioInizioFascia']
  598. end = item['OrarioFineFascia']
  599. s_time = f"{start['Hours']:02d}:{start['Minutes']:02d}"
  600. e_time = f"{end['Hours']:02d}:{end['Minutes']:02d}"
  601. slots.append({
  602. 'id': item['IDCalendarioServizioGiornaliero'],
  603. 'start': s_time,
  604. 'end': e_time,
  605. 'remain': remain
  606. })
  607. except: pass
  608. return slots
  609. # --- 资源清理核心方法 ---
  610. def cleanup(self):
  611. """
  612. 销毁浏览器并彻底删除临时文件
  613. """
  614. # 1. 关闭浏览器
  615. if self.page:
  616. try:
  617. self.page.quit() # 这会关闭 Chrome 进程
  618. except Exception:
  619. pass # 忽略已关闭的错误
  620. self.page = None
  621. # 2. 删除文件
  622. # 注意:Chrome 关闭后可能需要几百毫秒释放文件锁,稍微等待
  623. if os.path.exists(self.root_workspace):
  624. for _ in range(3):
  625. try:
  626. time.sleep(0.2)
  627. shutil.rmtree(self.root_workspace, ignore_errors=True)
  628. break
  629. except Exception as e:
  630. # 如果删除失败(通常是Windows文件占用),重试
  631. self._log(f"Cleanup retry: {e}")
  632. time.sleep(0.5)
  633. # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
  634. if os.path.exists(self.root_workspace):
  635. self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
  636. # 3. [新增] 关闭代理隧道
  637. if self.tunnel:
  638. try: self.tunnel.stop()
  639. except: pass
  640. self.tunnel = None
  641. def __del__(self):
  642. """
  643. 析构函数:当对象被垃圾回收时自动调用
  644. """
  645. self.cleanup()