grc_plugin.py 28 KB

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