grc_plugin.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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
  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.session_create_time: float = 0
  26. def get_group_id(self) -> str:
  27. return self.group_id
  28. def set_log(self, logger: Callable[[str], None]) -> None:
  29. self.logger = logger
  30. def set_config(self, config: VSPlgConfig):
  31. self.config = config
  32. self.free_config = config.free_config or {}
  33. def health_check(self) -> bool:
  34. if not self.is_healthy:
  35. return False
  36. if self.session is None:
  37. return False
  38. if self.config.session_max_life > 0:
  39. current_time = time.time()
  40. elapsed_time = current_time - self.session_create_time
  41. if elapsed_time > self.config.session_max_life * 60:
  42. 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")
  43. return False
  44. return True
  45. def create_session(self):
  46. # 1. 初始化 Session
  47. curlopt = {
  48. const.CurlOpt.MAXAGE_CONN: 1800,
  49. const.CurlOpt.MAXLIFETIME_CONN: 1800,
  50. const.CurlOpt.VERBOSE: self.config.debug,
  51. }
  52. self.session = requests.Session(
  53. proxy=self._get_proxy_url(),
  54. impersonate="chrome124",
  55. curl_options=curlopt,
  56. use_thread_local_curl=False,
  57. http_version=const.CurlHttpVersion.V2TLS
  58. )
  59. login_url = "https://www.supersaas.com/schedule/login/GreekEmbassyInDublin/Visas"
  60. self._perform_request("GET", login_url)
  61. headers = {
  62. "Referer": login_url,
  63. "Origin": "https://www.supersaas.com",
  64. "Content-Type": "application/x-www-form-urlencoded"
  65. }
  66. data = {
  67. "name": self.config.account.username,
  68. "password": self.config.account.password,
  69. "remember": "K",
  70. "cookie_fix": "1",
  71. "button": ""
  72. }
  73. resp = self._perform_request('POST', login_url, headers=headers, data=data)
  74. if "Sign out" in resp.text or "Signed in as" in resp.text:
  75. self.session_create_time = time.time()
  76. self._log(f"Session created successfully. (User: {self.config.account.username})")
  77. # 如果登录失败,SuperSaaS 通常会留在当前页面并显示错误信息
  78. elif "Invalid email or password" in resp.text:
  79. self._save_debug_html(resp.text, prefix='login_auth_fail')
  80. raise BizLogicError(message='Login failed: Invalid email or password')
  81. else:
  82. # 其他未知错误
  83. self._save_debug_html(resp.text, prefix='login_unknown_fail')
  84. # 打印 URL 辅助调试,看是否跳转了
  85. self._log(f"Login check failed. Current URL: {resp.url}")
  86. raise BizLogicError(message='Login failed: Unknown response')
  87. def _get_daily_schedule(self, open_times, date_obj):
  88. """根据 open_times 获取当天的开始和结束分钟数"""
  89. if not open_times:
  90. return None, None
  91. weekday_py = date_obj.weekday()
  92. if weekday_py >= 4:
  93. return None, None
  94. js_day_index = (date_obj.weekday() + 1) % 7
  95. start_min = open_times[js_day_index]
  96. end_min = open_times[js_day_index + 7]
  97. return start_min, end_min
  98. def _is_blocked_by_ecache(self, ecache_blocked, timestamp):
  99. """检查某个时间点是否在临时关闭范围内 (如节假日)"""
  100. for block in ecache_blocked:
  101. if block[0] <= timestamp < block[1]:
  102. return True
  103. return False
  104. def query(self, apt_type: AppointmentType) -> VSQueryResult:
  105. res = VSQueryResult()
  106. res.success = False
  107. url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
  108. headers = {
  109. "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
  110. "Origin": "https://www.supersaas.com",
  111. }
  112. resp = self._perform_request("GET", url, headers=headers)
  113. if self.config.debug:
  114. self._save_debug_html(resp.text, prefix='Grc_Query_Slot_Page')
  115. if 'Log into Visas schedule' in resp.text:
  116. self.is_healthy = False
  117. raise SessionExpiredOrInvalidError(message='Session expired.')
  118. res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
  119. if res_id_match:
  120. self.resource_id = res_id_match.group(1)
  121. default_length = None
  122. len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
  123. if len_match:
  124. default_length = int(len_match.group(1))
  125. # 提取每日营业时间 (open_times)
  126. open_times = None
  127. ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
  128. if ot_match:
  129. open_times = [int(x) for x in ot_match.group(1).split(',')]
  130. # 提取临时关闭/休息日 (ecache)
  131. ecache_blocked = None
  132. ec_match = re.search(r'var ecache\s*=\s*(\{.*?\})', resp.text)
  133. if ec_match:
  134. ec_data_str = ec_match.group(1)
  135. data_match = re.search(r'data:\s*(\[\[.*?\]\])', ec_data_str)
  136. if data_match:
  137. ecache_blocked = json.loads(data_match.group(1))
  138. # 提取已预约数据 (app)
  139. booked_timestamps = None
  140. app_match = re.search(r'var app\s*=\s*(\[\[.*?\]\])', resp.text)
  141. if app_match:
  142. app_data = json.loads(app_match.group(1))
  143. booked_timestamps = set(item[0] for item in app_data)
  144. # 提取 season (非常重要:学期/季度限制)
  145. season_range = None
  146. season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
  147. if season_match:
  148. season_range = [int(season_match.group(1)), int(season_match.group(2))]
  149. print(f"[*] 限制范围 (Season): 截止到 {datetime.fromtimestamp(season_range[1], timezone.utc)}")
  150. # 确定扫描起点
  151. cursor_match = re.search(r'Date\.UTC\((\d+),(\d+),(\d+),(\d+)\)', resp.text)
  152. if cursor_match:
  153. y, m, d, h = map(int, cursor_match.groups())
  154. start_date = datetime(y, m + 1, d, h, tzinfo=timezone.utc)
  155. else:
  156. start_date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
  157. print(f"[*] 分析配置: 默认时长 {default_length/60} 分钟")
  158. days_to_scan = 5 * 7
  159. valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
  160. for day_offset in range(days_to_scan):
  161. current_day = start_date + timedelta(days=day_offset)
  162. start_min, end_min = self._get_daily_schedule(open_times, current_day)
  163. # 如果开始=结束 (比如都是 600),或者没有定义,说明当天不营业
  164. if start_min is None or start_min >= end_min:
  165. continue
  166. # 生成当天的所有 Slot, 将分钟转换为当天的具体时间 start_min 630 -> 10:30
  167. current_slot_min = start_min
  168. while current_slot_min + (default_length / 60) <= end_min:
  169. # 计算 Slot 的具体时间对象
  170. slot_hour = current_slot_min // 60
  171. slot_minute = current_slot_min % 60
  172. slot_dt = current_day.replace(hour=int(slot_hour), minute=int(slot_minute), second=0, microsecond=0)
  173. slot_ts = int(slot_dt.timestamp())
  174. # 下一个 slot 开始时间
  175. current_slot_min += (default_length / 60)
  176. # 检查是否过期
  177. if slot_ts < time.time():
  178. continue
  179. # Slot 时间 必须在 Season 范围内
  180. if slot_ts >= season_range[1]:
  181. # print(f" [Skip] {slot_dt} 超出 Season 范围")
  182. continue
  183. # 检查是否在临时关闭列表 (ecache) 中
  184. if self._is_blocked_by_ecache(ecache_blocked, slot_ts + 1):
  185. # print(f" [Skip] {slot_dt} 被 ecache (节假日/关闭) 屏蔽")
  186. continue
  187. # 检查是否已被预约 (在 app 数组中)
  188. if slot_ts in booked_timestamps:
  189. # print(f" [Skip] {slot_dt} 已被预约")
  190. continue
  191. booking_payload = {
  192. "timestamp": slot_ts,
  193. "datetime": slot_dt.strftime("%Y-%m-%d %H:%M:%S")
  194. }
  195. time_slot = TimeSlot(
  196. time=slot_dt.strftime("%H:%M"),
  197. label=json.dumps(booking_payload) # 序列化存入 label
  198. )
  199. date_key = slot_dt.date()
  200. if date_key not in valid_slots_map:
  201. valid_slots_map[date_key] = []
  202. valid_slots_map[date_key].append(time_slot)
  203. if valid_slots_map:
  204. res.success = True
  205. res.availability_status = AvailabilityStatus.Available
  206. # 按日期排序
  207. sorted_dates = sorted(valid_slots_map.keys())
  208. res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
  209. res.availability = []
  210. for d in sorted_dates:
  211. res.availability.append(DateAvailability(
  212. date=datetime.combine(d, datetime.min.time()),
  213. times=valid_slots_map[d]
  214. ))
  215. self._log(f"Found availability on {len(sorted_dates)} days.")
  216. else:
  217. self._log("No available slots found.")
  218. return res
  219. def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
  220. res = VSBookResult()
  221. res.success = False
  222. # 1. 准备日期筛选参数
  223. exp_start = user_inputs.get('expected_start_date', '')
  224. exp_end = user_inputs.get('expected_end_date', '')
  225. # 将 Availability 转换为 { "YYYY-MM-DD": DateAvailabilityObj } 的映射,方便查找
  226. date_map = {}
  227. available_date_strs = []
  228. for date_avail in slot_info.availability:
  229. # datetime 转 string (YYYY-MM-DD)
  230. d_str = date_avail.date.strftime("%Y-%m-%d")
  231. date_map[d_str] = date_avail
  232. available_date_strs.append(d_str)
  233. # 2. 筛选符合要求的日期
  234. # _filter_dates 内部已经进行了 random.shuffle,所以返回列表的第一个即为随机选中的有效日期
  235. valid_dates = self._filter_dates(available_date_strs, exp_start, exp_end)
  236. if not valid_dates:
  237. self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).")
  238. return res
  239. # 3. 选择具体的 Slot
  240. target_date_str = valid_dates[0]
  241. target_day_data = date_map[target_date_str]
  242. if not target_day_data.times:
  243. res.message = f"Date {target_date_str} has no time slots."
  244. return res
  245. # 这里简单策略:选择该日期的第一个可用时间点
  246. # 如果需要随机时间,可以使用 random.choice(target_day_data.times)
  247. target_slot = target_day_data.times[0]
  248. # 4. 解析 Slot Label 数据
  249. # label 中存储了 {"timestamp": 123456, "datetime": "2026-02-06 10:00:00", ...}
  250. try:
  251. slot_data = json.loads(target_slot.label)
  252. start_time_str = slot_data.get('datetime')
  253. except Exception as e:
  254. self._log(f"Failed to parse slot label: {e}")
  255. return res
  256. # 5. 准备预定请求数据
  257. url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
  258. # 计算 finish_time (SuperSaaS通常需要 finish_time,默认时长1小时)
  259. start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
  260. finish_dt = start_dt + timedelta(hours=1)
  261. finish_time_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S")
  262. # 映射用户信息 (假设 self.config.profile 包含这些字段)
  263. # 根据之前的 HTML 分析:
  264. # field_1_r -> Passport number
  265. # field_2_r -> Reason for Visa
  266. first_name = user_inputs.get('first_name', '')
  267. last_name = user_inputs.get('last_name', '')
  268. phone_country_code = user_inputs.get('phone_country_code', '353')
  269. phone_no = user_inputs.get('phone_no', '088888888')
  270. address = user_inputs.get('address', "Dublin, Ireland")
  271. passport_no = user_inputs.get('passport_no', "")
  272. payload = {
  273. "reservation[start_time]": start_time_str,
  274. "reservation[finish_time]": finish_time_str,
  275. "reservation[full_name]": f"{first_name} {last_name}",
  276. "reservation[mobile]": f'+{phone_country_code}{phone_no}',
  277. "reservation[address]": address,
  278. "reservation[description]": "",
  279. # 自定义必填字段
  280. "reservation[field_1_r]": passport_no,
  281. "reservation[field_2_r]": "Tourism",
  282. # 系统字段
  283. "reservation[resource_id]": self.resource_id,
  284. "reservation[xpos]": "",
  285. "reservation[ypos]": "",
  286. "button": ""
  287. }
  288. headers = {
  289. "Referer": url,
  290. "Origin": "https://www.supersaas.com",
  291. "Content-Type": "application/x-www-form-urlencoded",
  292. }
  293. self._log(f"Attempting to book slot: {start_time_str}")
  294. resp = self._perform_request('POST', url, data=payload, headers=headers)
  295. res.success = True
  296. res.book_date = start_dt.strftime("%Y-%m-%d") # 格式: YYYY-mm-dd
  297. res.book_time = start_dt.strftime("%H:%M") # 格式: hh:mm
  298. self._log(f"Booking successful for {res.book_date} at {res.book_time}")
  299. return res
  300. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  301. """
  302. 根据用户的期望范围筛选可用日期
  303. :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
  304. :param start_str: 用户期望开始日期 (YYYY-MM-DD)
  305. :param end_str: 用户期望结束日期 (YYYY-MM-DD)
  306. :return: 符合要求的日期列表
  307. """
  308. # 如果没有设置范围,则不过滤,返回所有日期
  309. if not start_str or not end_str:
  310. # 也要打乱一下,保证随机性
  311. shuffled_dates = list(dates)
  312. random.shuffle(shuffled_dates)
  313. return shuffled_dates
  314. valid_dates = []
  315. try:
  316. # 截取前10位以防带有时分秒
  317. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  318. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  319. for date_str in dates:
  320. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  321. # 比较范围 (闭区间)
  322. if s_date <= curr_date <= e_date:
  323. valid_dates.append(date_str)
  324. except ValueError:
  325. self._log("Date format error in expected_start_date or expected_end_date. Ignoring filter.")
  326. shuffled_dates = list(dates)
  327. random.shuffle(shuffled_dates)
  328. return shuffled_dates
  329. random.shuffle(valid_dates)
  330. return valid_dates
  331. def _log(self, message):
  332. if self.logger:
  333. self.logger(f'[GrcPlugin] [{self.group_id}] {message}')
  334. def _save_debug_html(self, content: str, prefix: str = "debug"):
  335. save_dir = "debug_pages"
  336. if not os.path.exists(save_dir):
  337. os.makedirs(save_dir)
  338. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  339. filename = f"{save_dir}/{prefix}_{timestamp}.html"
  340. with open(filename, "w", encoding="utf-8") as f:
  341. f.write(content)
  342. self._log(f"HTML saved to: {filename}")
  343. def _get_proxy_url(self):
  344. # 构造代理
  345. proxy_url = ""
  346. if self.config.proxy.ip:
  347. s = self.config.proxy
  348. if s.username:
  349. proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
  350. else:
  351. proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
  352. return proxy_url
  353. def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
  354. """
  355. 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
  356. 1. 发送 OPTIONS 请求
  357. 2. 发送实际请求
  358. """
  359. resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
  360. if self.config.debug:
  361. self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
  362. if resp.status_code == 200:
  363. return resp
  364. elif resp.status_code == 401:
  365. self.is_healthy = False
  366. raise SessionExpiredOrInvalidError()
  367. elif resp.status_code == 403:
  368. raise PermissionDeniedError()
  369. elif resp.status_code == 429:
  370. self.is_healthy = False
  371. raise RateLimiteddError()
  372. else:
  373. raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
  374. def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
  375. """
  376. 根据用户的期望范围筛选可用日期
  377. :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
  378. :param start_str: 用户期望开始日期 (YYYY-MM-DD)
  379. :param end_str: 用户期望结束日期 (YYYY-MM-DD)
  380. :return: 符合要求的日期列表
  381. """
  382. # 如果没有设置范围,则不过滤,返回所有日期
  383. if not start_str or not end_str:
  384. return dates
  385. valid_dates = []
  386. # 截取前10位以防带有时分秒
  387. s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
  388. e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
  389. for date_str in dates:
  390. curr_date = datetime.strptime(date_str, "%Y-%m-%d")
  391. # 比较范围 (闭区间)
  392. if s_date <= curr_date <= e_date:
  393. valid_dates.append(date_str)
  394. random.shuffle(valid_dates)
  395. return valid_dates