grc_plugin.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import time
  2. import json
  3. import random
  4. import re
  5. import os
  6. from datetime import datetime, timezone, timedelta
  7. from typing import List, Dict, Any, Callable, Optional, Set, Tuple
  8. from curl_cffi import requests, const
  9. from vs_plg import IVSPlg
  10. from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
  11. MODERN_BROWSERS: List[str] = [
  12. "chrome",
  13. "edge",
  14. "safari",
  15. "safari_ios",
  16. "chrome_android",
  17. "firefox",
  18. ]
  19. class GrcPlugin(IVSPlg):
  20. """
  21. https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas
  22. 签证预约插件
  23. 适配爱尔兰希腊签证 (GR) 流程
  24. """
  25. def __init__(self, group_id: str):
  26. self.group_id = group_id
  27. self.config: Optional[VSPlgConfig] = None
  28. self.free_config: Dict[str, Any] = {}
  29. self.is_healthy = True
  30. self.logger = None
  31. self.session: Optional[requests.Session] = None
  32. self.resource_id = '1123832'
  33. self.rp_id = None
  34. self.token = None
  35. self.session_create_time: float = 0
  36. def get_group_id(self) -> str:
  37. return self.group_id
  38. def set_log(self, logger: Callable[[str], None]) -> None:
  39. self.logger = logger
  40. def set_config(self, config: VSPlgConfig):
  41. self.config = config
  42. self.free_config = config.free_config or {}
  43. def keep_alive(self):
  44. pass
  45. def health_check(self) -> bool:
  46. if not self.is_healthy:
  47. return False
  48. if self.session is None:
  49. return False
  50. if self.config.session_max_life > 0:
  51. current_time = time.time()
  52. elapsed_time = current_time - self.session_create_time
  53. if elapsed_time > self.config.session_max_life:
  54. self._log(f"Session Life ({int(elapsed_time)}s) out of max life limit ({self.config.session_max_life * 60}s), mark as unhealth session")
  55. return False
  56. return True
  57. def create_session(self):
  58. curlopt = {
  59. const.CurlOpt.MAXAGE_CONN: 1800,
  60. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  61. const.CurlOpt.VERBOSE: self.config.debug,
  62. }
  63. chosen_browser = random.choice(MODERN_BROWSERS)
  64. self._log(f"Using browser fingerprint: {chosen_browser}")
  65. self.session = requests.Session(
  66. proxy=self._get_proxy_url(),
  67. impersonate=chosen_browser,
  68. curl_options=curlopt,
  69. use_thread_local_curl=False,
  70. http_version=const.CurlHttpVersion.V2TLS
  71. )
  72. login_url = "https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas"
  73. self._perform_request("GET", login_url)
  74. headers = {
  75. "Referer": login_url,
  76. "Origin": "https://www.supersaas.com",
  77. "Content-Type": "application/x-www-form-urlencoded"
  78. }
  79. data = {
  80. "name": self.config.account.username,
  81. "password": self.config.account.password,
  82. "remember": "K",
  83. "cookie_fix": "1",
  84. "button": ""
  85. }
  86. resp = self._perform_request('POST', login_url, headers=headers, data=data)
  87. if "Sign out" in resp.text or "Signed in as" in resp.text:
  88. if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
  89. self.is_healthy = False
  90. self._save_debug_html(resp.text, prefix='login_quota_exceeded')
  91. self._log(f"Login failed: Account '{self.config.account.username}' has reached the maximum number of reservations.")
  92. raise BizLogicError(message='Login failed: Account has reached the maximum number of reservations.')
  93. self.session_create_time = time.time()
  94. self._log(f"Session created successfully. (User: {self.config.account.username})")
  95. elif "Invalid email or password" in resp.text:
  96. self._save_debug_html(resp.text, prefix='login_auth_fail')
  97. raise BizLogicError(message='Login failed: Invalid email or password')
  98. else:
  99. self._save_debug_html(resp.text, prefix='login_unknown_fail')
  100. self._log(f"Login check failed. Current URL: {resp.url}")
  101. raise BizLogicError(message='Login failed: Unknown response')
  102. def _extract_int(self, pattern: str, text: str, default: int = 0) -> int:
  103. """辅助正则提取数字的函数"""
  104. match = re.search(pattern, text)
  105. if match:
  106. return int(match.group(1))
  107. return default
  108. def _get_daily_bounds_and_breaks(self, open_times: list, bit_prefs: int, date_ts: int, js_day_index: int) -> Tuple[Optional[int], Optional[int], Optional[Tuple[int, int]]]:
  109. """完美还原JS逻辑,解析每天的营业起始时间,并提取隐藏的午休时间"""
  110. if not open_times:
  111. return None, None, None
  112. # =========================================================
  113. # 【终极拦截器】:解析 bit_prefs 位掩码!
  114. # 最低的 7 位控制着周日(0)到周六(6)的物理营业开关。
  115. # 如果为 0,这天就是雷打不动的休息日,直接返回空!
  116. # =========================================================
  117. if bit_prefs > 0:
  118. if not (bit_prefs & (1 << js_day_index)):
  119. return None, None, None
  120. def safe_get(idx):
  121. if idx < len(open_times):
  122. return open_times[idx]
  123. return None
  124. # [0-6] 是周日到周六的每天开始时间(分), [7-13] 是结束时间(分)
  125. start_min = safe_get(js_day_index)
  126. end_min = safe_get(js_day_index + 7)
  127. # Null 意味着当天不营业
  128. if start_min is None or end_min is None or start_min >= end_min:
  129. return None, None, None
  130. # JS 中的 day_base_ts = 严格的当天 00:00:00 (根据绝对时间戳截断)
  131. day_base_ts = date_ts - (date_ts % 86400)
  132. start_ts = day_base_ts + start_min * 60
  133. end_ts = day_base_ts + end_min * 60
  134. # [14-20] 是午休开始时间, [21-27] 是午休结束时间
  135. break_block = None
  136. break_start_min = safe_get(js_day_index + 14)
  137. break_end_min = safe_get(js_day_index + 21)
  138. if break_start_min is not None and break_end_min is not None:
  139. b_start = day_base_ts + break_start_min * 60
  140. b_end = day_base_ts + break_end_min * 60
  141. break_block = (b_start, b_end)
  142. return start_ts, end_ts, break_block
  143. def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
  144. """发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
  145. afrom_str = start_dt.strftime("%Y-%m-%d")
  146. ato_dt = start_dt + timedelta(days=days)
  147. ato_str = ato_dt.strftime("%Y-%m-%d 00:00")
  148. url = f"https://www.supersaas.com/ajax/resource/{self.rp_id}"
  149. params = {
  150. "token": self.token,
  151. "afrom": afrom_str,
  152. "ato": ato_str,
  153. "ad": "r",
  154. "efrom": afrom_str,
  155. "eto": ato_dt.strftime("%Y-%m-%d"),
  156. }
  157. headers = {
  158. "Accept": "*/*",
  159. "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
  160. "X-Requested-With": "XMLHttpRequest"
  161. }
  162. self._log(f"Fetching data from {afrom_str} to {ato_dt.strftime('%Y-%m-%d')}...")
  163. resp = self._perform_request("GET", url, params=params, headers=headers)
  164. return resp.json()
  165. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  166. res = VSQueryResult()
  167. res.success = False
  168. url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
  169. headers = {
  170. "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
  171. "Origin": "https://www.supersaas.com",
  172. }
  173. resp = self._perform_request("GET", url, headers=headers)
  174. if 'Log into Visas schedule' in resp.text:
  175. self.is_healthy = False
  176. raise SessionExpiredOrInvalidError(message='Session expired.')
  177. # 1. 提取核心会话参数
  178. self.resource_id = str(self._extract_int(r'resource\[(\d+)\]\s*=', resp.text))
  179. self.rp_id = str(self._extract_int(r'rp_id=(\d+)', resp.text))
  180. self.token = str(self._extract_int(r'token=(\d+)', resp.text))
  181. if not self.rp_id or not self.token or self.resource_id == '0':
  182. self._log("Failed to extract rp_id or token from HTML")
  183. raise NotFoundError(message='rp_id or token not found')
  184. # 2. 提取并还原所有的 JS 限制变量
  185. default_length = self._extract_int(r'default_length\s*=\s*(\d+)', resp.text, 3600)
  186. rounding = self._extract_int(r'rounding\s*=\s*(\d+)', resp.text, 3600)
  187. buffer_time = self._extract_int(r'buffer\s*=\s*(\d+)', resp.text, 0)
  188. add_limit = self._extract_int(r'add_limit\s*=\s*(\d+)', resp.text, 0)
  189. early_limit = self._extract_int(r'early_limit\s*=\s*(\d+)', resp.text, 0)
  190. early_snap = self._extract_int(r'early_snap\s*=\s*(\d+)', resp.text, 0)
  191. bit_prefs = self._extract_int(r'bit_prefs\s*=\s*(\d+)', resp.text, 0)
  192. # 3. 提取 open_times
  193. open_times = []
  194. ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
  195. if ot_match:
  196. # 巧妙利用 json.loads 处理包含 null 的 JS 数组字符串
  197. open_times_str = f"[{ot_match.group(1)}]"
  198. open_times = json.loads(open_times_str)
  199. # 4. 提取放号排期 (Season)
  200. season_start_ts = 0
  201. season_end_ts = float('inf')
  202. season_match = re.search(r'season\s*=\s*\[(\d+),\s*(\d+)\]', resp.text)
  203. if season_match:
  204. season_start_ts = int(season_match.group(1))
  205. season_end_ts = int(season_match.group(2))
  206. # 5. 提取全局页面屏蔽的假日例外期 (ecache)
  207. ecache_blocks = []
  208. ecache_match = re.search(r'ecache\s*=\s*\{data:\s*\[(.*?)\]\}', resp.text)
  209. if ecache_match:
  210. triplets = re.findall(r'\[(\d+),\s*(\d+),\s*\d+\]', ecache_match.group(1))
  211. for t0, t1 in triplets:
  212. ecache_blocks.append((int(t0), int(t1)))
  213. scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
  214. chunk_start_ts_initial = int(scan_start_dt.timestamp())
  215. days_total_scan = 60
  216. chunk_size = 30
  217. valid_slots_map: Dict[datetime.date, List[TimeSlot]] = {}
  218. # Chunk 循环
  219. for i in range(0, days_total_scan, chunk_size):
  220. chunk_start_dt = scan_start_dt + timedelta(days=i)
  221. json_data = self._fetch_schedule_data(chunk_start_dt, chunk_size)
  222. # 把已经预定的订单、节假日、以及Gcal双向同步块统一收集为“阻挡物实体墙”
  223. all_blocks: List[Tuple[int, int]] = ecache_blocks.copy()
  224. for key in ['app', 'exc', 'gcal']:
  225. if key in json_data:
  226. for item in json_data[key]:
  227. all_blocks.append((int(item[0]), int(item[1])))
  228. for day_offset in range(chunk_size):
  229. # 这里必须使用纯净的绝对 UNIX 时间进行加法,避免夏令时导致时间漂移
  230. current_day_ts = int(chunk_start_dt.timestamp()) + day_offset * 86400
  231. # 计算出当前天对应的 JS Weekday (0=Sun, 1=Mon ... 6=Sat)
  232. js_day_index = int((4 + (current_day_ts // 86400)) % 7)
  233. # 这里传入了 bit_prefs,彻底过滤掉伪装成开门的休息日!
  234. start_ts, end_ts, break_block = self._get_daily_bounds_and_breaks(open_times, bit_prefs, current_day_ts, js_day_index)
  235. if start_ts is None:
  236. continue
  237. # 每天专属的围墙:加入当天的午休时间
  238. daily_blocks = all_blocks.copy()
  239. if break_block:
  240. daily_blocks.append(break_block)
  241. curr_ts = start_ts
  242. current_time_ts = int(time.time())
  243. # --- 完全还原 JS 的 limits 以及 bit_prefs (early_snap) 机制 ---
  244. min_bookable_ts = current_time_ts + add_limit + 90
  245. max_bookable_ts = float('inf')
  246. if early_limit > 0:
  247. limit_ts = current_time_ts + early_limit
  248. if early_snap:
  249. k = limit_ts % 86400
  250. y = int((4 + (limit_ts // 86400)) % 7)
  251. # 这里正是 bit_prefs 的用武之地:检测位掩码
  252. if (bit_prefs & (1 << y)) and open_times[y] is not None and open_times[y] <= (k / 60):
  253. limit_ts += (86400 - k)
  254. max_bookable_ts = limit_ts + default_length
  255. # ================= 核心碰撞与吸附算法 =================
  256. while curr_ts + default_length <= end_ts:
  257. # 1. 不能早于排期放号的绝对起始时间
  258. if curr_ts < season_start_ts:
  259. curr_ts = season_start_ts
  260. continue
  261. # 2. 如果撞到了排期尽头,或超过了后台设置的最远可预约天数,当天循环直接结束
  262. if curr_ts >= season_end_ts or curr_ts >= max_bookable_ts:
  263. break
  264. # 3. 拦截提前量 (比如不允许预约1小时内的票)
  265. if curr_ts < min_bookable_ts:
  266. curr_ts = min_bookable_ts
  267. continue
  268. # 4. 【核心机制 1】:完美网格吸附 (Rounding Snapping)
  269. # 动态适配 10分、15分、30分、60分的网格,废弃单纯整点逻辑
  270. rem = curr_ts % rounding
  271. if rem > 0:
  272. curr_ts += (rounding - rem)
  273. continue
  274. slot_end = curr_ts + default_length
  275. # 5. 【核心机制 2】:带 Buffer 的贪婪碰撞检测
  276. overlapping_end = 0
  277. for b_start, b_end in daily_blocks:
  278. # 障碍物向前后各膨胀 buffer 的大小
  279. blocked_start = b_start - buffer_time
  280. blocked_end = b_end + buffer_time
  281. # 区间交集判断逻辑
  282. if curr_ts < blocked_end and slot_end > blocked_start:
  283. if blocked_end > overlapping_end:
  284. overlapping_end = blocked_end
  285. if overlapping_end > 0:
  286. # 碰撞触发:指针直接跳跃到带缓冲区的“障碍物最晚结束时间点”
  287. curr_ts = overlapping_end
  288. # 进入下个 while 循环时,机制 1 会将其吸附回最近的合法网格上!
  289. else:
  290. # ========================================================
  291. # 完美过检:记录合法 Slot (在此处增加 Finish Time 的提取)
  292. # ========================================================
  293. dt_start = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
  294. # 计算结束时间戳与时间对象
  295. finish_ts = curr_ts + default_length
  296. dt_finish = datetime.fromtimestamp(finish_ts, tz=timezone.utc)
  297. payload = {
  298. "resource_id": self.resource_id,
  299. "timestamp": curr_ts,
  300. "datetime": dt_start.strftime("%Y-%m-%d %H:%M:%S"),
  301. "finish_timestamp": finish_ts,
  302. "finish_datetime": dt_finish.strftime("%Y-%m-%d %H:%M:%S")
  303. }
  304. time_slot = TimeSlot(
  305. time=dt_start.strftime("%H:%M"),
  306. label=json.dumps(payload)
  307. )
  308. date_key = dt_start.date()
  309. if date_key not in valid_slots_map:
  310. valid_slots_map[date_key] = []
  311. valid_slots_map[date_key].append(time_slot)
  312. # 推进指针,寻找下一个槽位
  313. curr_ts += default_length
  314. # === 结果聚合与整理 ===
  315. if valid_slots_map:
  316. res.success = True
  317. res.availability_status = AvailabilityStatus.Available
  318. sorted_dates = sorted(valid_slots_map.keys())
  319. res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
  320. for d in sorted_dates:
  321. da = DateAvailability(
  322. date=datetime.combine(d, datetime.min.time()),
  323. times=valid_slots_map[d]
  324. )
  325. res.availability.append(da)
  326. self._log(f"Found slots on {len(sorted_dates)} days.")
  327. else:
  328. self._log("No slots found.")
  329. return res
  330. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  331. if user_inputs is None:
  332. user_inputs = {}
  333. res = VSBookResult()
  334. res.success = False
  335. # 重新登录一次,避免session expired
  336. self.create_session()
  337. exp_start = user_inputs.get('expected_start_date', '')
  338. exp_end = user_inputs.get('expected_end_date', '')
  339. date_map = {d.date.strftime("%Y-%m-%d"): d for d in slot_info.availability}
  340. all_dates = list(date_map.keys())
  341. valid_dates = self._filter_dates(all_dates, exp_start, exp_end)
  342. if not valid_dates:
  343. self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).")
  344. return res
  345. candidate_slots =[]
  346. for date_str in valid_dates:
  347. if date_str in date_map:
  348. candidate_slots.extend(date_map[date_str].times)
  349. random.shuffle(candidate_slots)
  350. if not candidate_slots:
  351. self._log("No slots found after filtering.")
  352. return res
  353. self._log(f"Found {len(candidate_slots)} candidate slots. Starting booking attempts...")
  354. # [修复点 1]:严谨处理输入参数里的空字符串,防止 dict.get() 返回空字符串触发后端空白报错
  355. first_name = (user_inputs.get('first_name') or '').strip()
  356. last_name = (user_inputs.get('last_name') or '').strip()
  357. full_name = f"{first_name} {last_name}".strip() or "Traveler"
  358. phone_code = (user_inputs.get('phone_country_code') or '353').strip()
  359. phone_no = (user_inputs.get('phone') or '088888888').strip()
  360. mobile = f"{phone_code}{phone_no}"
  361. address = (user_inputs.get('address') or "Dublin, Ireland").strip()
  362. passport_no = (user_inputs.get('passport_no') or "P0000000").strip()
  363. nationality = (user_inputs.get('nationality') or 'Chinese').strip()
  364. visa_reason = (user_inputs.get('visa_reason') or 'tourism').strip()
  365. for target_slot in candidate_slots[0:2]:
  366. try:
  367. slot_data = json.loads(target_slot.label)
  368. start_time_str = slot_data.get('datetime')
  369. finish_time_str = slot_data.get('finish_datetime')
  370. bk_res_id = slot_data.get('resource_id', self.resource_id)
  371. start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
  372. if finish_time_str:
  373. finish_dt = datetime.strptime(finish_time_str, "%Y-%m-%d %H:%M:%S")
  374. else:
  375. finish_dt = start_dt + timedelta(hours=1)
  376. # 处理抵离日期确保不为空
  377. arrival_date = (user_inputs.get('arrival_date') or '').strip()
  378. if not arrival_date:
  379. arrival_date = (start_dt + timedelta(days=15)).strftime("%Y-%m-%d")
  380. request_url = f"https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas?view=day&day={start_dt.day}&month={start_dt.month}"
  381. fmt_start = f"{start_dt.day}/{start_dt.month}/{start_dt.year} {start_dt.strftime('%H:%M')}"
  382. fmt_finish = f"{finish_dt.day}/{finish_dt.month}/{finish_dt.year} {finish_dt.strftime('%H:%M')}"
  383. headers = {
  384. "Referer": request_url,
  385. "Origin": "https://www.supersaas.com",
  386. "Content-Type": "application/x-www-form-urlencoded",
  387. }
  388. # ==========================
  389. # Request step 1: 预检查/打开弹窗
  390. # ==========================
  391. payload_stp_1 = {
  392. "reservation[start_time]": fmt_start,
  393. "reservation[finish_time]": fmt_finish,
  394. "reservation[full_name]": full_name,
  395. "reservation[mobile]": mobile,
  396. "reservation[address]": address,
  397. "reservation[resource_id]": bk_res_id,
  398. "button": "",
  399. "reservation[xpos]": "100",
  400. "reservation[ypos]": "200"
  401. }
  402. self._log(f"[Attempt] Try slot {start_time_str} - Req step 1")
  403. resp_stp_1 = self._perform_request('POST', request_url, data=payload_stp_1, headers=headers)
  404. if "This spot has already been taken" in resp_stp_1.text:
  405. self._log(f"Slot {start_time_str} is TAKEN. Trying next...")
  406. continue
  407. if 'id="reservation_error"' in resp_stp_1.text or 'class="dbox-error"' in resp_stp_1.text:
  408. err_match = re.search(r'<li>(.*?)</li>', resp_stp_1.text)
  409. err_reason = err_match.group(1) if err_match else "Unknown error"
  410. self._log(f"Slot {start_time_str} unavailable ({err_reason}). Trying next...")
  411. continue
  412. time.sleep(random.uniform(10, 30))
  413. # ==========================
  414. # Request step 2: 提交表单
  415. # ==========================
  416. payload_stp_2 = {
  417. "form[5]": passport_no,
  418. "form[8]": nationality,
  419. "form[6]": visa_reason,
  420. "form[7]": arrival_date,
  421. "form[9]": "",
  422. "form_commit": "Submit",
  423. "reservation[start_time]": fmt_start,
  424. "reservation[finish_time]": fmt_finish,
  425. "reservation[full_name]": full_name,
  426. "reservation[mobile]": mobile,
  427. "reservation[address]": address,
  428. "reservation[resource_id]": bk_res_id,
  429. "reservation[xpos]": "100",
  430. "reservation[ypos]": "200"
  431. }
  432. self._log(f"[Attempt] Submitting form for {start_time_str} - Req step 2")
  433. resp_book_stp_2 = self._perform_request('POST', request_url, data=payload_stp_2, headers=headers)
  434. # [修复点 2]:正确判断表单是否提交成功(不能只靠200判断,因为即使有误也会返回200状态码呈现红框)
  435. if "Reservation successfully created" in resp_book_stp_2.text:
  436. res.success = True
  437. res.book_date = start_dt.strftime("%Y-%m-%d")
  438. res.book_time = start_dt.strftime("%H:%M")
  439. self._log(f"Booking SUCCESS for {res.book_date} at {res.book_time}")
  440. return res
  441. else:
  442. # 获取表单验证的具体失败原因用于日志记录
  443. if 'id="errorExplanation"' in resp_book_stp_2.text or 'class="errorExplanation"' in resp_book_stp_2.text:
  444. err_match = re.search(r'<li>(.*?)</li>', resp_book_stp_2.text)
  445. err_reason = err_match.group(1) if err_match else "Form validation error"
  446. self._log(f"Submission failed for {start_time_str} ({err_reason}). Trying next...")
  447. else:
  448. self._log(f"Submission failed for {start_time_str}: Unknown error. Trying next...")
  449. if self.config.debug:
  450. self._save_debug_html(resp_book_stp_2.text, prefix='book_step2_fail')
  451. continue
  452. except Exception as e:
  453. self._log(f"Exception trying slot {start_time_str}: {e}")
  454. continue
  455. self._log("All candidate slots failed.")
  456. return res
  457. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  458. if not start_str or not end_str:
  459. shuffled_dates = list(dates)
  460. random.shuffle(shuffled_dates)
  461. return shuffled_dates
  462. valid_dates = []
  463. try:
  464. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  465. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  466. for date_str in dates:
  467. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  468. if s_date <= curr_date <= e_date:
  469. valid_dates.append(date_str)
  470. except ValueError:
  471. self._log("Date format error in expected_start_date or expected_end_date. Ignoring filter.")
  472. shuffled_dates = list(dates)
  473. random.shuffle(shuffled_dates)
  474. return shuffled_dates
  475. random.shuffle(valid_dates)
  476. return valid_dates
  477. def _log(self, message):
  478. if self.logger:
  479. self.logger(f'[GrcPlugin] [{self.group_id}] {message}')
  480. def _save_debug_html(self, content: str, prefix: str = "debug"):
  481. save_dir = "data/debug_pages"
  482. if not os.path.exists(save_dir):
  483. os.makedirs(save_dir)
  484. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  485. filename = f"{save_dir}/{prefix}_{timestamp}.html"
  486. with open(filename, "w", encoding="utf-8") as f:
  487. f.write(content)
  488. self._log(f"HTML saved to: {filename}")
  489. def _get_proxy_url(self):
  490. proxy_url = ""
  491. if self.config.proxy.ip:
  492. s = self.config.proxy
  493. if s.username:
  494. proxy_url = f"{s.proto}://{s.username}:{s.password}@{s.ip}:{s.port}"
  495. else:
  496. proxy_url = f"{s.proto}://{s.ip}:{s.port}"
  497. return proxy_url
  498. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  499. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  500. if self.config.debug:
  501. self._log(f'[perform request] Response={resp.text[:200]}...\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  502. if resp.status_code == 200:
  503. return resp
  504. elif resp.status_code == 401:
  505. self.is_healthy = False
  506. raise SessionExpiredOrInvalidError()
  507. elif resp.status_code == 403:
  508. raise PermissionDeniedError()
  509. elif resp.status_code == 429:
  510. self.is_healthy = False
  511. raise RateLimiteddError()
  512. else:
  513. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")