grc_plugin.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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. class GrcPlugin(IVSPlg):
  12. """
  13. https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas
  14. 签证预约插件
  15. 适配爱尔兰希腊签证 (GR) 流程
  16. """
  17. def __init__(self, group_id: str):
  18. self.group_id = group_id
  19. self.config: Optional[VSPlgConfig] = None
  20. self.free_config: Dict[str, Any] = {}
  21. self.is_healthy = True
  22. self.logger = None
  23. self.session: Optional[requests.Session] = None
  24. self.resource_id = '1123832'
  25. self.rp_id = None # 用于 AJAX 查询 (例如 778129)
  26. self.token = None # 用于 AJAX 查询
  27. self.session_create_time: float = 0
  28. def get_group_id(self) -> str:
  29. return self.group_id
  30. def set_log(self, logger: Callable[[str], None]) -> None:
  31. self.logger = logger
  32. def set_config(self, config: VSPlgConfig):
  33. self.config = config
  34. self.free_config = config.free_config or {}
  35. def keep_alive(self):
  36. home_page = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
  37. resp = self._perform_request("GET", home_page)
  38. if f'Signed in as {self.config.account.username.lower()}' not in resp.text:
  39. self.is_healthy = False
  40. def health_check(self) -> bool:
  41. if not self.is_healthy:
  42. return False
  43. if self.session is None:
  44. return False
  45. if self.config.session_max_life > 0:
  46. current_time = time.time()
  47. elapsed_time = current_time - self.session_create_time
  48. if elapsed_time > self.config.session_max_life * 60:
  49. 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")
  50. return False
  51. return True
  52. def create_session(self):
  53. # 1. 初始化 Session
  54. curlopt = {
  55. const.CurlOpt.MAXAGE_CONN: 1800,
  56. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  57. const.CurlOpt.VERBOSE: self.config.debug,
  58. }
  59. self.session = requests.Session(
  60. proxy=self._get_proxy_url(),
  61. impersonate="chrome124",
  62. curl_options=curlopt,
  63. use_thread_local_curl=False,
  64. http_version=const.CurlHttpVersion.V2TLS
  65. )
  66. login_url = "https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas"
  67. self._perform_request("GET", login_url)
  68. headers = {
  69. "Referer": login_url,
  70. "Origin": "https://www.supersaas.com",
  71. "Content-Type": "application/x-www-form-urlencoded"
  72. }
  73. data = {
  74. "name": self.config.account.username,
  75. "password": self.config.account.password,
  76. "remember": "K",
  77. "cookie_fix": "1",
  78. "button": ""
  79. }
  80. resp = self._perform_request('POST', login_url, headers=headers, data=data)
  81. # 判断是否登录成功
  82. if "Sign out" in resp.text or "Signed in as" in resp.text:
  83. # [新增修复点]: 检查账号是否已达最大预约数限制
  84. if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
  85. self.is_healthy = False
  86. self._save_debug_html(resp.text, prefix='login_quota_exceeded')
  87. self._log(f"Login failed: Account '{self.config.account.username}' has reached the maximum number of reservations.")
  88. raise BizLogicError(message='Login failed: Account has reached the maximum number of reservations.')
  89. self.session_create_time = time.time()
  90. self._log(f"Session created successfully. (User: {self.config.account.username})")
  91. # 如果登录失败,SuperSaaS 通常会留在当前页面并显示错误信息
  92. elif "Invalid email or password" in resp.text:
  93. self._save_debug_html(resp.text, prefix='login_auth_fail')
  94. raise BizLogicError(message='Login failed: Invalid email or password')
  95. else:
  96. # 其他未知错误
  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 _get_daily_schedule(self, open_times, date_obj):
  101. """根据 open_times 获取当天的开始和结束分钟数"""
  102. if not open_times:
  103. return None, None
  104. weekday_py = date_obj.weekday()
  105. if weekday_py >= 4:
  106. return None, None
  107. js_day_index = (date_obj.weekday() + 1) % 7
  108. start_min = open_times[js_day_index]
  109. end_min = open_times[js_day_index + 7]
  110. return start_min, end_min
  111. def _is_blocked_by_ecache(self, ecache_blocked, timestamp):
  112. """检查某个时间点是否在临时关闭范围内 (如节假日)"""
  113. for block in ecache_blocked:
  114. if block[0] <= timestamp < block[1]:
  115. return True
  116. return False
  117. def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
  118. """发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
  119. afrom_str = start_dt.strftime("%Y-%m-%d")
  120. ato_dt = start_dt + timedelta(days=days)
  121. ato_str = ato_dt.strftime("%Y-%m-%d 00:00")
  122. url = f"https://www.supersaas.com/ajax/resource/{self.rp_id}"
  123. params = {
  124. "token": self.token,
  125. "afrom": afrom_str,
  126. "ato": ato_str,
  127. "ad": "r",
  128. "efrom": afrom_str,
  129. "eto": ato_dt.strftime("%Y-%m-%d"),
  130. }
  131. headers = {
  132. "Accept": "*/*",
  133. "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
  134. "X-Requested-With": "XMLHttpRequest"
  135. }
  136. self._log(f"Fetching data from {afrom_str} to {ato_dt.strftime('%Y-%m-%d')}...")
  137. resp = self._perform_request("GET", url, params=params, headers=headers)
  138. return resp.json()
  139. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  140. res = VSQueryResult()
  141. res.success = False
  142. url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
  143. headers = {
  144. "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
  145. "Origin": "https://www.supersaas.com",
  146. }
  147. resp = self._perform_request("GET", url, headers=headers)
  148. if 'Log into Visas schedule' in resp.text:
  149. self.is_healthy = False
  150. raise SessionExpiredOrInvalidError(message='Session expired.')
  151. # 提取核心参数
  152. res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
  153. if res_id_match:
  154. self.resource_id = res_id_match.group(1)
  155. rp_match = re.search(r'rp_id=(\d+)', resp.text)
  156. if rp_match:
  157. self.rp_id = rp_match.group(1)
  158. tok_match = re.search(r'token=(\d+)', resp.text)
  159. if tok_match:
  160. self.token = tok_match.group(1)
  161. if not getattr(self, 'rp_id', None) or not getattr(self, 'token', None):
  162. self._log("Failed to extract rp_id or token from HTML")
  163. raise NotFoundError(message='rp_id or token not found')
  164. # 默认一小时长度 (3600秒)
  165. default_length = 3600
  166. len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
  167. if len_match:
  168. default_length = int(len_match.group(1))
  169. # 开放时间规则
  170. open_times = None
  171. ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
  172. if ot_match:
  173. open_times =[int(x) for x in ot_match.group(1).split(',')]
  174. # 排期结束时间
  175. season_end_ts = 9999999999
  176. season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
  177. if season_match:
  178. season_end_ts = int(season_match.group(2))
  179. # 提取全局页面屏蔽的假日例外期 (ecache)
  180. ecache_blocks =[]
  181. ecache_match = re.search(r'ecache\s*=\s*\{data:\s*\[(.*?)\]\}', resp.text)
  182. if ecache_match:
  183. # 匹配形如[1775433600,1775519970,0] 的数据
  184. triplets = re.findall(r'\[(\d+),\s*(\d+),\s*\d+\]', ecache_match.group(1))
  185. for t0, t1 in triplets:
  186. ecache_blocks.append((int(t0), int(t1)))
  187. scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
  188. current_time_ts = int(time.time())
  189. days_total_scan = 60
  190. chunk_size = 30
  191. valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
  192. for i in range(0, days_total_scan, chunk_size):
  193. chunk_start = scan_start_dt + timedelta(days=i)
  194. json_data = self._fetch_schedule_data(chunk_start, chunk_size)
  195. # 把已经预定的订单和节假日统一收集为“阻挡物实体墙”
  196. all_blocks: List[Tuple[int, int]] = ecache_blocks.copy()
  197. if 'app' in json_data:
  198. for item in json_data['app']:
  199. all_blocks.append((int(item[0]), int(item[1])))
  200. if 'exc' in json_data:
  201. for item in json_data['exc']:
  202. all_blocks.append((int(item[0]), int(item[1])))
  203. for day_offset in range(chunk_size):
  204. current_day = chunk_start + timedelta(days=day_offset)
  205. start_min, end_min = self._get_daily_schedule(open_times, current_day)
  206. if start_min is None or start_min >= end_min: continue
  207. start_ts = int(current_day.timestamp()) + start_min * 60
  208. end_ts = int(current_day.timestamp()) + end_min * 60
  209. curr_ts = start_ts
  210. # 开始执行官方引擎 1:1 的“碰撞与吸附算法”
  211. while curr_ts + default_length <= end_ts:
  212. if curr_ts < current_time_ts or curr_ts >= season_end_ts:
  213. curr_ts += 1800 # 安全步进
  214. continue
  215. # 【核心机制 1】:强制网格吸附 (Snap to grid)
  216. # 官方代码 start=precalc_constraints('0') 意味着仅允许在整点 (0分) 建立预约
  217. dt = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
  218. if dt.minute != 0:
  219. # 发现不在整点 (如11:30),立刻强制向前吸附到下一个整点 (加上剩余的分钟数)
  220. minutes_to_add = 60 - dt.minute
  221. curr_ts += minutes_to_add * 60
  222. continue # 时间已改变,重新循环执行检查
  223. slot_end = curr_ts + default_length
  224. # 【核心机制 2】:贪婪碰撞检测 (Collision detection)
  225. overlapping_end = 0
  226. for b_start, b_end in all_blocks:
  227. # 区间交集判断:起点早于障碍物终点 且 终点晚于障碍物起点 -> 发生碰撞
  228. if curr_ts < b_end and slot_end > b_start:
  229. if b_end > overlapping_end:
  230. overlapping_end = b_end
  231. if overlapping_end > 0:
  232. # 碰撞触发:指针直接抛弃当前区间,跳跃到“阻挡物最晚结束的时间点”
  233. curr_ts = overlapping_end
  234. # 下一轮循环时,【机制1】会自动将其吸附回合理的网格位!
  235. else:
  236. # 完美过检:没有碰撞且处于正确网格,记录合法 Slot
  237. payload = {
  238. "resource_id": self.resource_id,
  239. "timestamp": curr_ts,
  240. "datetime": dt.strftime("%Y-%m-%d %H:%M:%S")
  241. }
  242. time_slot = TimeSlot(
  243. time=dt.strftime("%H:%M"),
  244. label=json.dumps(payload)
  245. )
  246. date_key = dt.date()
  247. if date_key not in valid_slots_map:
  248. valid_slots_map[date_key] =[]
  249. valid_slots_map[date_key].append(time_slot)
  250. # 已放入一个格后,指针按订单长度向后推移
  251. curr_ts += default_length
  252. if valid_slots_map:
  253. res.success = True
  254. res.availability_status = AvailabilityStatus.Available
  255. sorted_dates = sorted(valid_slots_map.keys())
  256. res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
  257. res.availability =[DateAvailability(date=datetime.combine(d, datetime.min.time()), times=valid_slots_map[d]) for d in sorted_dates]
  258. self._log(f"Found slots on {len(sorted_dates)} days.")
  259. else:
  260. self._log("No slots found.")
  261. return res
  262. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  263. if user_inputs is None:
  264. user_inputs = {}
  265. res = VSBookResult()
  266. res.success = False
  267. # --- 1. 筛选并收集所有可用 Slot ---
  268. exp_start = user_inputs.get('expected_start_date', '')
  269. exp_end = user_inputs.get('expected_end_date', '')
  270. date_map = {d.date.strftime("%Y-%m-%d"): d for d in slot_info.availability}
  271. all_dates = list(date_map.keys())
  272. valid_dates = self._filter_dates(all_dates, exp_start, exp_end)
  273. if not valid_dates:
  274. self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).")
  275. return res
  276. candidate_slots =[]
  277. for date_str in valid_dates:
  278. if date_str in date_map:
  279. candidate_slots.extend(date_map[date_str].times)
  280. random.shuffle(candidate_slots)
  281. if not candidate_slots:
  282. self._log("No slots found after filtering.")
  283. return res
  284. self._log(f"Found {len(candidate_slots)} candidate slots. Starting booking attempts...")
  285. # [修复点 1]:严谨处理输入参数里的空字符串,防止 dict.get() 返回空字符串触发后端空白报错
  286. first_name = (user_inputs.get('first_name') or '').strip()
  287. last_name = (user_inputs.get('last_name') or '').strip()
  288. full_name = f"{first_name} {last_name}".strip() or "Traveler"
  289. phone_code = (user_inputs.get('phone_country_code') or '353').strip()
  290. phone_no = (user_inputs.get('phone') or '088888888').strip()
  291. mobile = f"{phone_code}{phone_no}"
  292. address = (user_inputs.get('address') or "Dublin, Ireland").strip()
  293. passport_no = (user_inputs.get('passport_no') or "P0000000").strip()
  294. nationality = (user_inputs.get('nationality') or 'Chinese').strip()
  295. visa_reason = (user_inputs.get('visa_reason') or 'tourism').strip()
  296. for target_slot in candidate_slots[0:2]:
  297. try:
  298. slot_data = json.loads(target_slot.label)
  299. start_time_str = slot_data.get('datetime')
  300. bk_res_id = slot_data.get('resource_id', self.resource_id)
  301. start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
  302. finish_dt = start_dt + timedelta(hours=1)
  303. # 处理抵离日期确保不为空
  304. arrival_date = (user_inputs.get('arrival_date') or '').strip()
  305. if not arrival_date:
  306. arrival_date = (start_dt + timedelta(days=15)).strftime("%Y-%m-%d")
  307. request_url = f"https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas?view=day&day={start_dt.day}&month={start_dt.month}"
  308. fmt_start = f"{start_dt.day}/{start_dt.month}/{start_dt.year} {start_dt.strftime('%H:%M')}"
  309. fmt_finish = f"{finish_dt.day}/{finish_dt.month}/{finish_dt.year} {finish_dt.strftime('%H:%M')}"
  310. headers = {
  311. "Referer": request_url,
  312. "Origin": "https://www.supersaas.com",
  313. "Content-Type": "application/x-www-form-urlencoded",
  314. }
  315. # ==========================
  316. # Request step 1: 预检查/打开弹窗
  317. # ==========================
  318. payload_stp_1 = {
  319. "reservation[start_time]": fmt_start,
  320. "reservation[finish_time]": fmt_finish,
  321. "reservation[full_name]": full_name,
  322. "reservation[mobile]": mobile,
  323. "reservation[address]": address,
  324. "reservation[resource_id]": bk_res_id,
  325. "button": "",
  326. "reservation[xpos]": "100",
  327. "reservation[ypos]": "200"
  328. }
  329. self._log(f"[Attempt] Try slot {start_time_str} - Req step 1")
  330. resp_stp_1 = self._perform_request('POST', request_url, data=payload_stp_1, headers=headers)
  331. if "This spot has already been taken" in resp_stp_1.text:
  332. self._log(f"Slot {start_time_str} is TAKEN. Trying next...")
  333. continue
  334. if 'id="reservation_error"' in resp_stp_1.text or 'class="dbox-error"' in resp_stp_1.text:
  335. err_match = re.search(r'<li>(.*?)</li>', resp_stp_1.text)
  336. err_reason = err_match.group(1) if err_match else "Unknown error"
  337. self._log(f"Slot {start_time_str} unavailable ({err_reason}). Trying next...")
  338. continue
  339. # ==========================
  340. # Request step 2: 提交表单
  341. # ==========================
  342. payload_stp_2 = {
  343. "form[5]": passport_no,
  344. "form[8]": nationality,
  345. "form[6]": visa_reason,
  346. "form[7]": arrival_date,
  347. "form[9]": "",
  348. "form_commit": "Submit",
  349. "reservation[start_time]": fmt_start,
  350. "reservation[finish_time]": fmt_finish,
  351. "reservation[full_name]": full_name,
  352. "reservation[mobile]": mobile,
  353. "reservation[address]": address,
  354. "reservation[resource_id]": bk_res_id,
  355. "reservation[xpos]": "100",
  356. "reservation[ypos]": "200"
  357. }
  358. self._log(f"[Attempt] Submitting form for {start_time_str} - Req step 2")
  359. resp_book_stp_2 = self._perform_request('POST', request_url, data=payload_stp_2, headers=headers)
  360. # [修复点 2]:正确判断表单是否提交成功(不能只靠200判断,因为即使有误也会返回200状态码呈现红框)
  361. if "Reservation successfully created" in resp_book_stp_2.text:
  362. res.success = True
  363. res.book_date = start_dt.strftime("%Y-%m-%d")
  364. res.book_time = start_dt.strftime("%H:%M")
  365. self._log(f"Booking SUCCESS for {res.book_date} at {res.book_time}")
  366. return res
  367. else:
  368. # 获取表单验证的具体失败原因用于日志记录
  369. if 'id="errorExplanation"' in resp_book_stp_2.text or 'class="errorExplanation"' in resp_book_stp_2.text:
  370. err_match = re.search(r'<li>(.*?)</li>', resp_book_stp_2.text)
  371. err_reason = err_match.group(1) if err_match else "Form validation error"
  372. self._log(f"Submission failed for {start_time_str} ({err_reason}). Trying next...")
  373. else:
  374. self._log(f"Submission failed for {start_time_str}: Unknown error. Trying next...")
  375. if self.config.debug:
  376. self._save_debug_html(resp_book_stp_2.text, prefix='book_step2_fail')
  377. continue
  378. except Exception as e:
  379. self._log(f"Exception trying slot {start_time_str}: {e}")
  380. continue
  381. self._log("All candidate slots failed.")
  382. return res
  383. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  384. if not start_str or not end_str:
  385. shuffled_dates = list(dates)
  386. random.shuffle(shuffled_dates)
  387. return shuffled_dates
  388. valid_dates = []
  389. try:
  390. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  391. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  392. for date_str in dates:
  393. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  394. if s_date <= curr_date <= e_date:
  395. valid_dates.append(date_str)
  396. except ValueError:
  397. self._log("Date format error in expected_start_date or expected_end_date. Ignoring filter.")
  398. shuffled_dates = list(dates)
  399. random.shuffle(shuffled_dates)
  400. return shuffled_dates
  401. random.shuffle(valid_dates)
  402. return valid_dates
  403. def _log(self, message):
  404. if self.logger:
  405. self.logger(f'[GrcPlugin] [{self.group_id}] {message}')
  406. def _save_debug_html(self, content: str, prefix: str = "debug"):
  407. save_dir = "data/debug_pages"
  408. if not os.path.exists(save_dir):
  409. os.makedirs(save_dir)
  410. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  411. filename = f"{save_dir}/{prefix}_{timestamp}.html"
  412. with open(filename, "w", encoding="utf-8") as f:
  413. f.write(content)
  414. self._log(f"HTML saved to: {filename}")
  415. def _get_proxy_url(self):
  416. proxy_url = ""
  417. if self.config.proxy.ip:
  418. s = self.config.proxy
  419. if s.username:
  420. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  421. else:
  422. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  423. return proxy_url
  424. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  425. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  426. if self.config.debug:
  427. self._log(f'[perform request] Response={resp.text[:200]}...\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  428. if resp.status_code == 200:
  429. return resp
  430. elif resp.status_code == 401:
  431. self.is_healthy = False
  432. raise SessionExpiredOrInvalidError()
  433. elif resp.status_code == 403:
  434. raise PermissionDeniedError()
  435. elif resp.status_code == 429:
  436. self.is_healthy = False
  437. raise RateLimiteddError()
  438. else:
  439. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  440. #[修复点 3]:删除了原有代码最下方重复的 def _filter_dates 方法