|
@@ -4,7 +4,7 @@ import random
|
|
|
import re
|
|
import re
|
|
|
import os
|
|
import os
|
|
|
from datetime import datetime, timezone, timedelta
|
|
from datetime import datetime, timezone, timedelta
|
|
|
-from typing import List, Dict, Any, Callable, Optional
|
|
|
|
|
|
|
+from typing import List, Dict, Any, Callable, Optional, Set, Tuple
|
|
|
|
|
|
|
|
from curl_cffi import requests, const
|
|
from curl_cffi import requests, const
|
|
|
|
|
|
|
@@ -27,6 +27,8 @@ class GrcPlugin(IVSPlg):
|
|
|
self.logger = None
|
|
self.logger = None
|
|
|
self.session: Optional[requests.Session] = None
|
|
self.session: Optional[requests.Session] = None
|
|
|
self.resource_id = '1123832'
|
|
self.resource_id = '1123832'
|
|
|
|
|
+ self.rp_id = None # 用于 AJAX 查询 (例如 778129)
|
|
|
|
|
+ self.token = None # 用于 AJAX 查询
|
|
|
self.session_create_time: float = 0
|
|
self.session_create_time: float = 0
|
|
|
|
|
|
|
|
def get_group_id(self) -> str:
|
|
def get_group_id(self) -> str:
|
|
@@ -85,7 +87,17 @@ class GrcPlugin(IVSPlg):
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
resp = self._perform_request('POST', login_url, headers=headers, data=data)
|
|
resp = self._perform_request('POST', login_url, headers=headers, data=data)
|
|
|
|
|
+
|
|
|
|
|
+ # 判断是否登录成功
|
|
|
if "Sign out" in resp.text or "Signed in as" in resp.text:
|
|
if "Sign out" in resp.text or "Signed in as" in resp.text:
|
|
|
|
|
+
|
|
|
|
|
+ # [新增修复点]: 检查账号是否已达最大预约数限制
|
|
|
|
|
+ if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
|
|
|
|
|
+ self.is_healthy = False
|
|
|
|
|
+ self._save_debug_html(resp.text, prefix='login_quota_exceeded')
|
|
|
|
|
+ self._log(f"Login failed: Account '{self.config.account.username}' has reached the maximum number of reservations.")
|
|
|
|
|
+ raise BizLogicError(message='Login failed: Account has reached the maximum number of reservations.')
|
|
|
|
|
+
|
|
|
self.session_create_time = time.time()
|
|
self.session_create_time = time.time()
|
|
|
self._log(f"Session created successfully. (User: {self.config.account.username})")
|
|
self._log(f"Session created successfully. (User: {self.config.account.username})")
|
|
|
|
|
|
|
@@ -97,7 +109,6 @@ class GrcPlugin(IVSPlg):
|
|
|
else:
|
|
else:
|
|
|
# 其他未知错误
|
|
# 其他未知错误
|
|
|
self._save_debug_html(resp.text, prefix='login_unknown_fail')
|
|
self._save_debug_html(resp.text, prefix='login_unknown_fail')
|
|
|
- # 打印 URL 辅助调试,看是否跳转了
|
|
|
|
|
self._log(f"Login check failed. Current URL: {resp.url}")
|
|
self._log(f"Login check failed. Current URL: {resp.url}")
|
|
|
raise BizLogicError(message='Login failed: Unknown response')
|
|
raise BizLogicError(message='Login failed: Unknown response')
|
|
|
|
|
|
|
@@ -119,6 +130,33 @@ class GrcPlugin(IVSPlg):
|
|
|
if block[0] <= timestamp < block[1]:
|
|
if block[0] <= timestamp < block[1]:
|
|
|
return True
|
|
return True
|
|
|
return False
|
|
return False
|
|
|
|
|
+
|
|
|
|
|
+ def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
|
|
|
|
|
+ """发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
|
|
|
|
|
+ afrom_str = start_dt.strftime("%Y-%m-%d")
|
|
|
|
|
+ ato_dt = start_dt + timedelta(days=days)
|
|
|
|
|
+ ato_str = ato_dt.strftime("%Y-%m-%d 00:00")
|
|
|
|
|
+
|
|
|
|
|
+ url = f"https://www.supersaas.com/ajax/resource/{self.rp_id}"
|
|
|
|
|
+
|
|
|
|
|
+ params = {
|
|
|
|
|
+ "token": self.token,
|
|
|
|
|
+ "afrom": afrom_str,
|
|
|
|
|
+ "ato": ato_str,
|
|
|
|
|
+ "ad": "r",
|
|
|
|
|
+ "efrom": afrom_str,
|
|
|
|
|
+ "eto": ato_dt.strftime("%Y-%m-%d"),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ headers = {
|
|
|
|
|
+ "Accept": "*/*",
|
|
|
|
|
+ "Referer": "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas",
|
|
|
|
|
+ "X-Requested-With": "XMLHttpRequest"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self._log(f"Fetching data from {afrom_str} to {ato_dt.strftime('%Y-%m-%d')}...")
|
|
|
|
|
+ resp = self._perform_request("GET", url, params=params, headers=headers)
|
|
|
|
|
+ return resp.json()
|
|
|
|
|
|
|
|
def query(self, apt_type: AppointmentType) -> VSQueryResult:
|
|
def query(self, apt_type: AppointmentType) -> VSQueryResult:
|
|
|
res = VSQueryResult()
|
|
res = VSQueryResult()
|
|
@@ -131,8 +169,6 @@ class GrcPlugin(IVSPlg):
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
resp = self._perform_request("GET", url, headers=headers)
|
|
resp = self._perform_request("GET", url, headers=headers)
|
|
|
- if self.config.debug:
|
|
|
|
|
- self._save_debug_html(resp.text, prefix='Grc_Query_Slot_Page')
|
|
|
|
|
|
|
|
|
|
if 'Log into Visas schedule' in resp.text:
|
|
if 'Log into Visas schedule' in resp.text:
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
@@ -141,255 +177,273 @@ class GrcPlugin(IVSPlg):
|
|
|
res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
|
|
res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
|
|
|
if res_id_match:
|
|
if res_id_match:
|
|
|
self.resource_id = res_id_match.group(1)
|
|
self.resource_id = res_id_match.group(1)
|
|
|
|
|
+
|
|
|
|
|
+ rp_match = re.search(r'rp_id=(\d+)', resp.text)
|
|
|
|
|
+ if rp_match:
|
|
|
|
|
+ self.rp_id = rp_match.group(1)
|
|
|
|
|
+
|
|
|
|
|
+ tok_match = re.search(r'token=(\d+)', resp.text)
|
|
|
|
|
+ if tok_match:
|
|
|
|
|
+ self.token = tok_match.group(1)
|
|
|
|
|
+
|
|
|
|
|
+ if not self.rp_id or not self.token:
|
|
|
|
|
+ self._log("Failed to extract rp_id or token from HTML")
|
|
|
|
|
+ raise NotFoundError(message='rp_id or token not found')
|
|
|
|
|
|
|
|
default_length = None
|
|
default_length = None
|
|
|
len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
|
|
len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
|
|
|
if len_match:
|
|
if len_match:
|
|
|
default_length = int(len_match.group(1))
|
|
default_length = int(len_match.group(1))
|
|
|
|
|
|
|
|
- # 提取每日营业时间 (open_times)
|
|
|
|
|
open_times = None
|
|
open_times = None
|
|
|
ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
|
|
ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
|
|
|
if ot_match:
|
|
if ot_match:
|
|
|
open_times = [int(x) for x in ot_match.group(1).split(',')]
|
|
open_times = [int(x) for x in ot_match.group(1).split(',')]
|
|
|
|
|
|
|
|
- # 提取临时关闭/休息日 (ecache)
|
|
|
|
|
- ecache_blocked = None
|
|
|
|
|
- ec_match = re.search(r'var ecache\s*=\s*(\{.*?\})', resp.text)
|
|
|
|
|
- if ec_match:
|
|
|
|
|
- ec_data_str = ec_match.group(1)
|
|
|
|
|
- data_match = re.search(r'data:\s*(\[\[.*?\]\])', ec_data_str)
|
|
|
|
|
- if data_match:
|
|
|
|
|
- ecache_blocked = json.loads(data_match.group(1))
|
|
|
|
|
-
|
|
|
|
|
- # 提取已预约数据 (app)
|
|
|
|
|
- booked_timestamps = None
|
|
|
|
|
- app_match = re.search(r'var app\s*=\s*(\[\[.*?\]\])', resp.text)
|
|
|
|
|
- if app_match:
|
|
|
|
|
- app_data = json.loads(app_match.group(1))
|
|
|
|
|
- booked_timestamps = set(item[0] for item in app_data)
|
|
|
|
|
-
|
|
|
|
|
- # 提取 season (非常重要:学期/季度限制)
|
|
|
|
|
- season_range = None
|
|
|
|
|
|
|
+ season_end_ts = 9999999999
|
|
|
season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
|
|
season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
|
|
|
if season_match:
|
|
if season_match:
|
|
|
- season_range = [int(season_match.group(1)), int(season_match.group(2))]
|
|
|
|
|
- print(f"[*] 限制范围 (Season): 截止到 {datetime.fromtimestamp(season_range[1], timezone.utc)}")
|
|
|
|
|
|
|
+ season_end_ts = int(season_match.group(2))
|
|
|
|
|
|
|
|
- # 确定扫描起点
|
|
|
|
|
cursor_match = re.search(r'Date\.UTC\((\d+),(\d+),(\d+),(\d+)\)', resp.text)
|
|
cursor_match = re.search(r'Date\.UTC\((\d+),(\d+),(\d+),(\d+)\)', resp.text)
|
|
|
if cursor_match:
|
|
if cursor_match:
|
|
|
y, m, d, h = map(int, cursor_match.groups())
|
|
y, m, d, h = map(int, cursor_match.groups())
|
|
|
start_date = datetime(y, m + 1, d, h, tzinfo=timezone.utc)
|
|
start_date = datetime(y, m + 1, d, h, tzinfo=timezone.utc)
|
|
|
else:
|
|
else:
|
|
|
start_date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
|
|
start_date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
|
|
|
- print(f"[*] 分析配置: 默认时长 {default_length/60} 分钟")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- days_to_scan = 5 * 7
|
|
|
|
|
|
|
+ scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
|
|
|
|
|
+ days_total_scan = 60
|
|
|
|
|
+ chunk_size = 30
|
|
|
valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
|
|
valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
|
|
|
- for day_offset in range(days_to_scan):
|
|
|
|
|
- current_day = start_date + timedelta(days=day_offset)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ for i in range(0, days_total_scan, chunk_size):
|
|
|
|
|
+ chunk_start = scan_start_dt + timedelta(days=i)
|
|
|
|
|
+ json_data = self._fetch_schedule_data(chunk_start, chunk_size)
|
|
|
|
|
|
|
|
- start_min, end_min = self._get_daily_schedule(open_times, current_day)
|
|
|
|
|
|
|
+ booked_ts: Set[int] = set()
|
|
|
|
|
+ blocked_ranges: List[Tuple[int, int]] =[]
|
|
|
|
|
|
|
|
- # 如果开始=结束 (比如都是 600),或者没有定义,说明当天不营业
|
|
|
|
|
- if start_min is None or start_min >= end_min:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 生成当天的所有 Slot, 将分钟转换为当天的具体时间 start_min 630 -> 10:30
|
|
|
|
|
- current_slot_min = start_min
|
|
|
|
|
- while current_slot_min + (default_length / 60) <= end_min:
|
|
|
|
|
- # 计算 Slot 的具体时间对象
|
|
|
|
|
- slot_hour = current_slot_min // 60
|
|
|
|
|
- slot_minute = current_slot_min % 60
|
|
|
|
|
-
|
|
|
|
|
- slot_dt = current_day.replace(hour=int(slot_hour), minute=int(slot_minute), second=0, microsecond=0)
|
|
|
|
|
- slot_ts = int(slot_dt.timestamp())
|
|
|
|
|
-
|
|
|
|
|
- # 下一个 slot 开始时间
|
|
|
|
|
- current_slot_min += (default_length / 60)
|
|
|
|
|
-
|
|
|
|
|
- # 检查是否过期
|
|
|
|
|
- if slot_ts < time.time():
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # Slot 时间 必须在 Season 范围内
|
|
|
|
|
- if slot_ts >= season_range[1]:
|
|
|
|
|
- # print(f" [Skip] {slot_dt} 超出 Season 范围")
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 检查是否在临时关闭列表 (ecache) 中
|
|
|
|
|
- if self._is_blocked_by_ecache(ecache_blocked, slot_ts + 1):
|
|
|
|
|
- # print(f" [Skip] {slot_dt} 被 ecache (节假日/关闭) 屏蔽")
|
|
|
|
|
- continue
|
|
|
|
|
|
|
+ if 'app' in json_data:
|
|
|
|
|
+ for item in json_data['app']:
|
|
|
|
|
+ booked_ts.add(int(item[0]))
|
|
|
|
|
+ if 'exc' in json_data:
|
|
|
|
|
+ for item in json_data['exc']:
|
|
|
|
|
+ blocked_ranges.append((int(item[0]), int(item[1])))
|
|
|
|
|
+
|
|
|
|
|
+ for day_offset in range(chunk_size):
|
|
|
|
|
+ current_day = chunk_start + timedelta(days=day_offset)
|
|
|
|
|
+ start_min, end_min = self._get_daily_schedule(open_times, current_day)
|
|
|
|
|
+ if start_min is None or start_min >= end_min: continue
|
|
|
|
|
+
|
|
|
|
|
+ curr_min = start_min
|
|
|
|
|
+ while curr_min + (default_length / 60) <= end_min:
|
|
|
|
|
+ slot_hour = int(curr_min // 60)
|
|
|
|
|
+ slot_minute = int(curr_min % 60)
|
|
|
|
|
+ slot_dt = current_day.replace(hour=slot_hour, minute=slot_minute, second=0, microsecond=0)
|
|
|
|
|
+ slot_ts = int(slot_dt.timestamp())
|
|
|
|
|
+ curr_min += (default_length / 60)
|
|
|
|
|
+
|
|
|
|
|
+ if slot_ts < time.time(): continue
|
|
|
|
|
+ if slot_ts >= season_end_ts: continue
|
|
|
|
|
+ if slot_ts in booked_ts: continue
|
|
|
|
|
+
|
|
|
|
|
+ is_blocked = False
|
|
|
|
|
+ check_ts = slot_ts + 1
|
|
|
|
|
+ for b_start, b_end in blocked_ranges:
|
|
|
|
|
+ if b_start <= check_ts < b_end:
|
|
|
|
|
+ is_blocked = True
|
|
|
|
|
+ break
|
|
|
|
|
+ if is_blocked: continue
|
|
|
|
|
+
|
|
|
|
|
+ payload = {
|
|
|
|
|
+ "resource_id": self.resource_id,
|
|
|
|
|
+ "timestamp": slot_ts,
|
|
|
|
|
+ "datetime": slot_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ }
|
|
|
|
|
+ time_slot = TimeSlot(
|
|
|
|
|
+ time=slot_dt.strftime("%H:%M"),
|
|
|
|
|
+ label=json.dumps(payload)
|
|
|
|
|
+ )
|
|
|
|
|
+ date_key = slot_dt.date()
|
|
|
|
|
+ if date_key not in valid_slots_map:
|
|
|
|
|
+ valid_slots_map[date_key] = []
|
|
|
|
|
+ valid_slots_map[date_key].append(time_slot)
|
|
|
|
|
|
|
|
- # 检查是否已被预约 (在 app 数组中)
|
|
|
|
|
- if slot_ts in booked_timestamps:
|
|
|
|
|
- # print(f" [Skip] {slot_dt} 已被预约")
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- booking_payload = {
|
|
|
|
|
- "timestamp": slot_ts,
|
|
|
|
|
- "datetime": slot_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- time_slot = TimeSlot(
|
|
|
|
|
- time=slot_dt.strftime("%H:%M"),
|
|
|
|
|
- label=json.dumps(booking_payload) # 序列化存入 label
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- date_key = slot_dt.date()
|
|
|
|
|
- if date_key not in valid_slots_map:
|
|
|
|
|
- valid_slots_map[date_key] = []
|
|
|
|
|
- valid_slots_map[date_key].append(time_slot)
|
|
|
|
|
-
|
|
|
|
|
if valid_slots_map:
|
|
if valid_slots_map:
|
|
|
res.success = True
|
|
res.success = True
|
|
|
res.availability_status = AvailabilityStatus.Available
|
|
res.availability_status = AvailabilityStatus.Available
|
|
|
-
|
|
|
|
|
- # 按日期排序
|
|
|
|
|
sorted_dates = sorted(valid_slots_map.keys())
|
|
sorted_dates = sorted(valid_slots_map.keys())
|
|
|
res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
|
|
res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
|
|
|
-
|
|
|
|
|
- res.availability = []
|
|
|
|
|
- for d in sorted_dates:
|
|
|
|
|
- res.availability.append(DateAvailability(
|
|
|
|
|
- date=datetime.combine(d, datetime.min.time()),
|
|
|
|
|
- times=valid_slots_map[d]
|
|
|
|
|
- ))
|
|
|
|
|
-
|
|
|
|
|
- self._log(f"Found availability on {len(sorted_dates)} days.")
|
|
|
|
|
|
|
+ res.availability =[DateAvailability(date=datetime.combine(d, datetime.min.time()), times=valid_slots_map[d]) for d in sorted_dates]
|
|
|
|
|
+ self._log(f"Found slots on {len(sorted_dates)} days.")
|
|
|
else:
|
|
else:
|
|
|
- self._log("No available slots found.")
|
|
|
|
|
|
|
+ self._log("No slots found.")
|
|
|
|
|
+
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
|
|
def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
|
|
|
|
|
+ if user_inputs is None:
|
|
|
|
|
+ user_inputs = {}
|
|
|
|
|
+
|
|
|
res = VSBookResult()
|
|
res = VSBookResult()
|
|
|
res.success = False
|
|
res.success = False
|
|
|
|
|
|
|
|
- # 1. 准备日期筛选参数
|
|
|
|
|
|
|
+ # --- 1. 筛选并收集所有可用 Slot ---
|
|
|
exp_start = user_inputs.get('expected_start_date', '')
|
|
exp_start = user_inputs.get('expected_start_date', '')
|
|
|
exp_end = user_inputs.get('expected_end_date', '')
|
|
exp_end = user_inputs.get('expected_end_date', '')
|
|
|
|
|
|
|
|
- # 将 Availability 转换为 { "YYYY-MM-DD": DateAvailabilityObj } 的映射,方便查找
|
|
|
|
|
- date_map = {}
|
|
|
|
|
- available_date_strs = []
|
|
|
|
|
- for date_avail in slot_info.availability:
|
|
|
|
|
- # datetime 转 string (YYYY-MM-DD)
|
|
|
|
|
- d_str = date_avail.date.strftime("%Y-%m-%d")
|
|
|
|
|
- date_map[d_str] = date_avail
|
|
|
|
|
- available_date_strs.append(d_str)
|
|
|
|
|
-
|
|
|
|
|
- # 2. 筛选符合要求的日期
|
|
|
|
|
- # _filter_dates 内部已经进行了 random.shuffle,所以返回列表的第一个即为随机选中的有效日期
|
|
|
|
|
- valid_dates = self._filter_dates(available_date_strs, exp_start, exp_end)
|
|
|
|
|
|
|
+ date_map = {d.date.strftime("%Y-%m-%d"): d for d in slot_info.availability}
|
|
|
|
|
+ all_dates = list(date_map.keys())
|
|
|
|
|
|
|
|
|
|
+ valid_dates = self._filter_dates(all_dates, exp_start, exp_end)
|
|
|
if not valid_dates:
|
|
if not valid_dates:
|
|
|
- self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).")
|
|
|
|
|
|
|
+ self._log(f"No available slots within the expected range ({exp_start} to {exp_end}).")
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
- # 3. 选择具体的 Slot
|
|
|
|
|
- target_date_str = valid_dates[0]
|
|
|
|
|
- target_day_data = date_map[target_date_str]
|
|
|
|
|
|
|
+ candidate_slots =[]
|
|
|
|
|
+ for date_str in valid_dates:
|
|
|
|
|
+ if date_str in date_map:
|
|
|
|
|
+ candidate_slots.extend(date_map[date_str].times)
|
|
|
|
|
|
|
|
- if not target_day_data.times:
|
|
|
|
|
- res.message = f"Date {target_date_str} has no time slots."
|
|
|
|
|
- return res
|
|
|
|
|
-
|
|
|
|
|
- # 这里简单策略:选择该日期的第一个可用时间点
|
|
|
|
|
- # 如果需要随机时间,可以使用 random.choice(target_day_data.times)
|
|
|
|
|
- target_slot = target_day_data.times[0]
|
|
|
|
|
|
|
+ random.shuffle(candidate_slots)
|
|
|
|
|
|
|
|
- # 4. 解析 Slot Label 数据
|
|
|
|
|
- # label 中存储了 {"timestamp": 123456, "datetime": "2026-02-06 10:00:00", ...}
|
|
|
|
|
- try:
|
|
|
|
|
- slot_data = json.loads(target_slot.label)
|
|
|
|
|
- start_time_str = slot_data.get('datetime')
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self._log(f"Failed to parse slot label: {e}")
|
|
|
|
|
|
|
+ if not candidate_slots:
|
|
|
|
|
+ self._log("No slots found after filtering.")
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
- # 5. 准备预定请求数据
|
|
|
|
|
- url = "https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas"
|
|
|
|
|
|
|
+ self._log(f"Found {len(candidate_slots)} candidate slots. Starting booking attempts...")
|
|
|
|
|
|
|
|
- # 计算 finish_time (SuperSaaS通常需要 finish_time,默认时长1小时)
|
|
|
|
|
- start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
- finish_dt = start_dt + timedelta(hours=1)
|
|
|
|
|
- finish_time_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
-
|
|
|
|
|
- # 映射用户信息 (假设 self.config.profile 包含这些字段)
|
|
|
|
|
- # 根据之前的 HTML 分析:
|
|
|
|
|
- # field_1_r -> Passport number
|
|
|
|
|
- # field_2_r -> Reason for Visa
|
|
|
|
|
|
|
+ # [修复点 1]:严谨处理输入参数里的空字符串,防止 dict.get() 返回空字符串触发后端空白报错
|
|
|
|
|
+ first_name = (user_inputs.get('first_name') or '').strip()
|
|
|
|
|
+ last_name = (user_inputs.get('last_name') or '').strip()
|
|
|
|
|
+ full_name = f"{first_name} {last_name}".strip() or "Traveler"
|
|
|
|
|
|
|
|
- first_name = user_inputs.get('first_name', '')
|
|
|
|
|
- last_name = user_inputs.get('last_name', '')
|
|
|
|
|
- phone_country_code = user_inputs.get('phone_country_code', '353')
|
|
|
|
|
- phone_no = user_inputs.get('phone_no', '088888888')
|
|
|
|
|
- address = user_inputs.get('address', "Dublin, Ireland")
|
|
|
|
|
- passport_no = user_inputs.get('passport_no', "")
|
|
|
|
|
- payload = {
|
|
|
|
|
- "reservation[start_time]": start_time_str,
|
|
|
|
|
- "reservation[finish_time]": finish_time_str,
|
|
|
|
|
- "reservation[full_name]": f"{first_name} {last_name}",
|
|
|
|
|
- "reservation[mobile]": f'+{phone_country_code}{phone_no}',
|
|
|
|
|
- "reservation[address]": address,
|
|
|
|
|
- "reservation[description]": "",
|
|
|
|
|
-
|
|
|
|
|
- # 自定义必填字段
|
|
|
|
|
- "reservation[field_1_r]": passport_no,
|
|
|
|
|
- "reservation[field_2_r]": "Tourism",
|
|
|
|
|
-
|
|
|
|
|
- # 系统字段
|
|
|
|
|
- "reservation[resource_id]": self.resource_id,
|
|
|
|
|
- "reservation[xpos]": "",
|
|
|
|
|
- "reservation[ypos]": "",
|
|
|
|
|
- "button": ""
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ phone_code = (user_inputs.get('phone_country_code') or '353').strip()
|
|
|
|
|
+ phone_no = (user_inputs.get('phone') or '088888888').strip()
|
|
|
|
|
+ mobile = f"{phone_code}{phone_no}"
|
|
|
|
|
+
|
|
|
|
|
+ address = (user_inputs.get('address') or "Dublin, Ireland").strip()
|
|
|
|
|
+ passport_no = (user_inputs.get('passport_no') or "P0000000").strip()
|
|
|
|
|
+ nationality = (user_inputs.get('nationality') or 'Chinese').strip()
|
|
|
|
|
+ visa_reason = (user_inputs.get('visa_reason') or 'tourism').strip()
|
|
|
|
|
+
|
|
|
|
|
+ for target_slot in candidate_slots[0:2]:
|
|
|
|
|
+ try:
|
|
|
|
|
+ slot_data = json.loads(target_slot.label)
|
|
|
|
|
+ start_time_str = slot_data.get('datetime')
|
|
|
|
|
+ bk_res_id = slot_data.get('resource_id', self.resource_id)
|
|
|
|
|
+
|
|
|
|
|
+ start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ finish_dt = start_dt + timedelta(hours=1)
|
|
|
|
|
+
|
|
|
|
|
+ # 处理抵离日期确保不为空
|
|
|
|
|
+ arrival_date = (user_inputs.get('arrival_date') or '').strip()
|
|
|
|
|
+ if not arrival_date:
|
|
|
|
|
+ arrival_date = (start_dt + timedelta(days=15)).strftime("%Y-%m-%d")
|
|
|
|
|
+
|
|
|
|
|
+ request_url = f"https://www.supersaas.com/schedule/GreekEmbassyInDublin/Visas?view=day&day={start_dt.day}&month={start_dt.month}"
|
|
|
|
|
+ fmt_start = f"{start_dt.day}/{start_dt.month}/{start_dt.year} {start_dt.strftime('%H:%M')}"
|
|
|
|
|
+ fmt_finish = f"{finish_dt.day}/{finish_dt.month}/{finish_dt.year} {finish_dt.strftime('%H:%M')}"
|
|
|
|
|
+
|
|
|
|
|
+ headers = {
|
|
|
|
|
+ "Referer": request_url,
|
|
|
|
|
+ "Origin": "https://www.supersaas.com",
|
|
|
|
|
+ "Content-Type": "application/x-www-form-urlencoded",
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- headers = {
|
|
|
|
|
- "Referer": url,
|
|
|
|
|
- "Origin": "https://www.supersaas.com",
|
|
|
|
|
- "Content-Type": "application/x-www-form-urlencoded",
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ # ==========================
|
|
|
|
|
+ # Request step 1: 预检查/打开弹窗
|
|
|
|
|
+ # ==========================
|
|
|
|
|
+ payload_stp_1 = {
|
|
|
|
|
+ "reservation[start_time]": fmt_start,
|
|
|
|
|
+ "reservation[finish_time]": fmt_finish,
|
|
|
|
|
+ "reservation[full_name]": full_name,
|
|
|
|
|
+ "reservation[mobile]": mobile,
|
|
|
|
|
+ "reservation[address]": address,
|
|
|
|
|
+ "reservation[resource_id]": bk_res_id,
|
|
|
|
|
+ "button": "",
|
|
|
|
|
+ "reservation[xpos]": "100",
|
|
|
|
|
+ "reservation[ypos]": "200"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self._log(f"[Attempt] Try slot {start_time_str} - Req step 1")
|
|
|
|
|
+ resp_stp_1 = self._perform_request('POST', request_url, data=payload_stp_1, headers=headers)
|
|
|
|
|
|
|
|
- self._log(f"Attempting to book slot: {start_time_str}")
|
|
|
|
|
|
|
+ if "This spot has already been taken" in resp_stp_1.text:
|
|
|
|
|
+ self._log(f"Slot {start_time_str} is TAKEN. Trying next...")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if 'id="reservation_error"' in resp_stp_1.text or 'class="dbox-error"' in resp_stp_1.text:
|
|
|
|
|
+ err_match = re.search(r'<li>(.*?)</li>', resp_stp_1.text)
|
|
|
|
|
+ err_reason = err_match.group(1) if err_match else "Unknown error"
|
|
|
|
|
+ self._log(f"Slot {start_time_str} unavailable ({err_reason}). Trying next...")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # ==========================
|
|
|
|
|
+ # Request step 2: 提交表单
|
|
|
|
|
+ # ==========================
|
|
|
|
|
+ payload_stp_2 = {
|
|
|
|
|
+ "form[5]": passport_no,
|
|
|
|
|
+ "form[8]": nationality,
|
|
|
|
|
+ "form[6]": visa_reason,
|
|
|
|
|
+ "form[7]": arrival_date,
|
|
|
|
|
+ "form[9]": "",
|
|
|
|
|
+ "form_commit": "Submit",
|
|
|
|
|
+
|
|
|
|
|
+ "reservation[start_time]": fmt_start,
|
|
|
|
|
+ "reservation[finish_time]": fmt_finish,
|
|
|
|
|
+ "reservation[full_name]": full_name,
|
|
|
|
|
+ "reservation[mobile]": mobile,
|
|
|
|
|
+ "reservation[address]": address,
|
|
|
|
|
+ "reservation[resource_id]": bk_res_id,
|
|
|
|
|
+ "reservation[xpos]": "100",
|
|
|
|
|
+ "reservation[ypos]": "200"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ self._log(f"[Attempt] Submitting form for {start_time_str} - Req step 2")
|
|
|
|
|
+ resp_book_stp_2 = self._perform_request('POST', request_url, data=payload_stp_2, headers=headers)
|
|
|
|
|
+
|
|
|
|
|
+ # [修复点 2]:正确判断表单是否提交成功(不能只靠200判断,因为即使有误也会返回200状态码呈现红框)
|
|
|
|
|
+ if "Reservation successfully created" in resp_book_stp_2.text:
|
|
|
|
|
+ res.success = True
|
|
|
|
|
+ res.book_date = start_dt.strftime("%Y-%m-%d")
|
|
|
|
|
+ res.book_time = start_dt.strftime("%H:%M")
|
|
|
|
|
+ self._log(f"Booking SUCCESS for {res.book_date} at {res.book_time}")
|
|
|
|
|
+ return res
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 获取表单验证的具体失败原因用于日志记录
|
|
|
|
|
+ if 'id="errorExplanation"' in resp_book_stp_2.text or 'class="errorExplanation"' in resp_book_stp_2.text:
|
|
|
|
|
+ err_match = re.search(r'<li>(.*?)</li>', resp_book_stp_2.text)
|
|
|
|
|
+ err_reason = err_match.group(1) if err_match else "Form validation error"
|
|
|
|
|
+ self._log(f"Submission failed for {start_time_str} ({err_reason}). Trying next...")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._log(f"Submission failed for {start_time_str}: Unknown error. Trying next...")
|
|
|
|
|
+ if self.config.debug:
|
|
|
|
|
+ self._save_debug_html(resp_book_stp_2.text, prefix='book_step2_fail')
|
|
|
|
|
+ continue
|
|
|
|
|
|
|
|
- resp = self._perform_request('POST', url, data=payload, headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- res.success = True
|
|
|
|
|
- res.book_date = start_dt.strftime("%Y-%m-%d") # 格式: YYYY-mm-dd
|
|
|
|
|
- res.book_time = start_dt.strftime("%H:%M") # 格式: hh:mm
|
|
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ self._log(f"Exception trying slot {start_time_str}: {e}")
|
|
|
|
|
+ continue
|
|
|
|
|
|
|
|
- self._log(f"Booking successful for {res.book_date} at {res.book_time}")
|
|
|
|
|
|
|
+ self._log("All candidate slots failed.")
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
|
|
def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
|
|
|
- """
|
|
|
|
|
- 根据用户的期望范围筛选可用日期
|
|
|
|
|
-
|
|
|
|
|
- :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
|
|
|
|
|
- :param start_str: 用户期望开始日期 (YYYY-MM-DD)
|
|
|
|
|
- :param end_str: 用户期望结束日期 (YYYY-MM-DD)
|
|
|
|
|
- :return: 符合要求的日期列表
|
|
|
|
|
- """
|
|
|
|
|
- # 如果没有设置范围,则不过滤,返回所有日期
|
|
|
|
|
if not start_str or not end_str:
|
|
if not start_str or not end_str:
|
|
|
- # 也要打乱一下,保证随机性
|
|
|
|
|
shuffled_dates = list(dates)
|
|
shuffled_dates = list(dates)
|
|
|
random.shuffle(shuffled_dates)
|
|
random.shuffle(shuffled_dates)
|
|
|
return shuffled_dates
|
|
return shuffled_dates
|
|
|
|
|
|
|
|
valid_dates = []
|
|
valid_dates = []
|
|
|
try:
|
|
try:
|
|
|
- # 截取前10位以防带有时分秒
|
|
|
|
|
s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
|
|
s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
|
|
|
e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
|
|
e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
|
|
|
|
|
|
|
|
for date_str in dates:
|
|
for date_str in dates:
|
|
|
curr_date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
curr_date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
|
- # 比较范围 (闭区间)
|
|
|
|
|
if s_date <= curr_date <= e_date:
|
|
if s_date <= curr_date <= e_date:
|
|
|
valid_dates.append(date_str)
|
|
valid_dates.append(date_str)
|
|
|
except ValueError:
|
|
except ValueError:
|
|
@@ -416,7 +470,6 @@ class GrcPlugin(IVSPlg):
|
|
|
self._log(f"HTML saved to: {filename}")
|
|
self._log(f"HTML saved to: {filename}")
|
|
|
|
|
|
|
|
def _get_proxy_url(self):
|
|
def _get_proxy_url(self):
|
|
|
- # 构造代理
|
|
|
|
|
proxy_url = ""
|
|
proxy_url = ""
|
|
|
if self.config.proxy.ip:
|
|
if self.config.proxy.ip:
|
|
|
s = self.config.proxy
|
|
s = self.config.proxy
|
|
@@ -427,14 +480,9 @@ class GrcPlugin(IVSPlg):
|
|
|
return proxy_url
|
|
return proxy_url
|
|
|
|
|
|
|
|
def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
|
|
def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
|
|
|
- """
|
|
|
|
|
- 统一 HTTP 请求封装,严格复刻 C++ 逻辑:
|
|
|
|
|
- 1. 发送 OPTIONS 请求
|
|
|
|
|
- 2. 发送实际请求
|
|
|
|
|
- """
|
|
|
|
|
resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
|
|
resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
|
|
|
if self.config.debug:
|
|
if self.config.debug:
|
|
|
- self._log(f'[perform request] Response={resp.text}\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
|
|
|
|
|
|
|
+ self._log(f'[perform request] Response={resp.text[:200]}...\nMethod={method}, Url={url}, Data={data}, JsonData={json_data}, Params={params}')
|
|
|
if resp.status_code == 200:
|
|
if resp.status_code == 200:
|
|
|
return resp
|
|
return resp
|
|
|
elif resp.status_code == 401:
|
|
elif resp.status_code == 401:
|
|
@@ -448,28 +496,4 @@ class GrcPlugin(IVSPlg):
|
|
|
else:
|
|
else:
|
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
|
|
|
|
|
|
- def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
|
|
|
|
|
- """
|
|
|
|
|
- 根据用户的期望范围筛选可用日期
|
|
|
|
|
-
|
|
|
|
|
- :param dates: API 返回的可用日期列表 (YYYY-MM-DD)
|
|
|
|
|
- :param start_str: 用户期望开始日期 (YYYY-MM-DD)
|
|
|
|
|
- :param end_str: 用户期望结束日期 (YYYY-MM-DD)
|
|
|
|
|
- :return: 符合要求的日期列表
|
|
|
|
|
- """
|
|
|
|
|
- # 如果没有设置范围,则不过滤,返回所有日期
|
|
|
|
|
- if not start_str or not end_str:
|
|
|
|
|
- return dates
|
|
|
|
|
-
|
|
|
|
|
- valid_dates = []
|
|
|
|
|
- # 截取前10位以防带有时分秒
|
|
|
|
|
- s_date = datetime.strptime(start_str[:10], "%Y-%m-%d")
|
|
|
|
|
- e_date = datetime.strptime(end_str[:10], "%Y-%m-%d")
|
|
|
|
|
-
|
|
|
|
|
- for date_str in dates:
|
|
|
|
|
- curr_date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
|
|
|
- # 比较范围 (闭区间)
|
|
|
|
|
- if s_date <= curr_date <= e_date:
|
|
|
|
|
- valid_dates.append(date_str)
|
|
|
|
|
- random.shuffle(valid_dates)
|
|
|
|
|
- return valid_dates
|
|
|
|
|
|
|
+ #[修复点 3]:删除了原有代码最下方重复的 def _filter_dates 方法
|