grc_plugin.py 24 KB

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