grc_plugin.py 22 KB

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