ita_plugin.py 24 KB

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