jerry 1 місяць тому
батько
коміт
db5d18f16e
6 змінених файлів з 509 додано та 290 видалено
  1. 15 15
      config/groups.json
  2. 221 41
      config/proxies.json
  3. 2 5
      gco.py
  4. 239 215
      plugins/grc_plugin.py
  5. 2 2
      plugins/vfs_plugin2.py
  6. 30 12
      vs_log_macros.py

+ 15 - 15
config/groups.json

@@ -6,7 +6,7 @@
         "need_account": true,
         "local_account_pool": "ie_nl",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -63,7 +63,7 @@
         "need_account": true,
         "local_account_pool": "sg_fr",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -120,7 +120,7 @@
         "need_account": true,
         "local_account_pool": "au_fr",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -193,7 +193,7 @@
         "need_account": true,
         "local_account_pool": "gb_it",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -266,7 +266,7 @@
         "need_account": true,
         "local_account_pool": "gb_nl",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -339,7 +339,7 @@
         "need_account": true,
         "local_account_pool": "gb_no",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -396,7 +396,7 @@
         "need_account": true,
         "local_account_pool": "ie_at",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -453,7 +453,7 @@
         "need_account": true,
         "local_account_pool": "ie_dk",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -510,7 +510,7 @@
         "need_account": true,
         "local_account_pool": "ie_fi",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -567,7 +567,7 @@
         "need_account": true,
         "local_account_pool": "ie_hu",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -624,7 +624,7 @@
         "need_account": true,
         "local_account_pool": "ie_is",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
         "target_instances": 1,
         "account_login_interval": 180,
@@ -783,7 +783,7 @@
     {
         "identifier": "TLS_GB_FR",
         "debug": false,
-        "enable": true,
+        "enable": false,
         "need_account": true,
         "local_account_pool": "gb_fr",
         "need_proxy": true,
@@ -919,13 +919,13 @@
     {
         "identifier": "GreekEmbassy_IE_GR",
         "debug": false,
-        "enable": true,
+        "enable": false,
         "need_account": true,
         "local_account_pool": "ie_gr",
         "need_proxy": true,
-        "proxy_pool": "iproyal",
+        "proxy_pool": "isp_all",
         "proxy_lock_interval": 5,
-        "target_instances": 1,
+        "target_instances": 4,
         "account_login_interval": 1,
         "order_account_routing": "",
         "order_account_online_limit": 0,

+ 221 - 41
config/proxies.json

@@ -1,5 +1,5 @@
 {
-    "iproyal": [
+    "isp_all": [
         {
             "id": 100021,
             "ip": "195.178.151.14",
@@ -97,84 +97,264 @@
             "username": "14a7fe11fea49"
         },
         {
-            "id": 100033,
-            "ip": "89.33.195.129",
-            "password": "d160bc0854",
+            "id": 110000,
+            "ip": "95.135.130.10",
+            "port": 46107,
+            "username": "Iz1WuKKwt1KUzEe",
+            "password": "G7syngmdyGURblY",
+            "scheme": "http"
+        },
+        {
+            "id": 110001,
+            "ip": "95.135.130.148",
+            "port": 41128,
+            "username": "wq1FoNR1X7Q8mA8",
+            "password": "5Wqzc346MIWp3Ha",
+            "scheme": "http"
+        },
+        {
+            "id": 110002,
+            "ip": "95.135.130.187",
+            "port": 47912,
+            "username": "pypMVfmyeaYvjJt",
+            "password": "fjS0zLk1ulBN1c2",
+            "scheme": "http"
+        },
+        {
+            "id": 110003,
+            "ip": "95.135.130.213",
+            "port": 41562,
+            "username": "f1MjAP4WsrDJ0Wn",
+            "password": "4j4aAeOJlXKwDSy",
+            "scheme": "http"
+        },
+        {
+            "id": 110004,
+            "ip": "95.135.130.224",
+            "port": 46199,
+            "username": "ax10mM3PdfARTWE",
+            "password": "G484440GpByzphQ",
+            "scheme": "http"
+        },
+        {
+            "id": 110005,
+            "ip": "95.135.130.36",
+            "port": 44888,
+            "username": "m239fOR14o2HrVc",
+            "password": "s43qSsHt9SAIjpt",
+            "scheme": "http"
+        },
+        {
+            "id": 110006,
+            "ip": "95.135.130.54",
+            "port": 46211,
+            "username": "iyGDJtEtIJlY2Gu",
+            "password": "Mh0cIEnX7iCuA3X",
+            "scheme": "http"
+        },
+        {
+            "id": 110007,
+            "ip": "95.135.130.56",
+            "port": 43462,
+            "username": "CLkHdJdtiG3279w",
+            "password": "NHeox1Z5rPmmHjK",
+            "scheme": "http"
+        },
+        {
+            "id": 110008,
+            "ip": "95.135.130.77",
+            "port": 43018,
+            "username": "bxqjlbGdCW8jgnc",
+            "password": "5ijclb1RmYMA1DM",
+            "scheme": "http"
+        },
+        {
+            "id": 110009,
+            "ip": "95.135.130.85",
+            "port": 44066,
+            "username": "z9GREx6mPvfVyxj",
+            "password": "f3rCKSLqpGX1i5I",
+            "scheme": "http"
+        }
+    ],
+    "iproyal": [
+        {
+            "id": 100021,
+            "ip": "195.178.151.14",
+            "password": "919e0ee7ee",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14a7fe11fea49"
+        },
+        {
+            "id": 100022,
+            "ip": "195.178.151.20",
+            "password": "919e0ee7ee",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14a7fe11fea49"
+        },
+        {
+            "id": 100023,
+            "ip": "195.178.151.134",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100034,
-            "ip": "89.33.195.64",
-            "password": "d160bc0854",
+            "id": 100024,
+            "ip": "195.178.151.187",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100035,
-            "ip": "89.33.195.93",
-            "password": "d160bc0854",
+            "id": 100025,
+            "ip": "165.254.9.248",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100036,
-            "ip": "89.33.195.42",
-            "password": "d160bc0854",
+            "id": 100026,
+            "ip": "165.254.9.156",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100037,
-            "ip": "95.170.29.126",
-            "password": "d160bc0854",
+            "id": 100027,
+            "ip": "109.72.116.223",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100038,
-            "ip": "91.193.255.166",
-            "password": "d160bc0854",
+            "id": 100028,
+            "ip": "109.72.116.124",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100039,
-            "ip": "91.193.255.60",
-            "password": "d160bc0854",
+            "id": 100029,
+            "ip": "109.72.116.184",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100040,
-            "ip": "91.193.255.210",
-            "password": "d160bc0854",
+            "id": 100030,
+            "ip": "89.33.195.58",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100041,
-            "ip": "91.193.255.149",
-            "password": "d160bc0854",
+            "id": 100031,
+            "ip": "89.33.195.43",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
         },
         {
-            "id": 100042,
-            "ip": "91.193.255.245",
-            "password": "d160bc0854",
+            "id": 100032,
+            "ip": "95.170.29.68",
+            "password": "919e0ee7ee",
             "port": 12323,
             "scheme": "http",
-            "username": "14ae212b29a2a"
+            "username": "14a7fe11fea49"
+        }
+    ],
+    "proxy-cheap": [
+        {
+            "id": 110000,
+            "ip": "95.135.130.10",
+            "port": 46107,
+            "username": "Iz1WuKKwt1KUzEe",
+            "password": "G7syngmdyGURblY",
+            "scheme": "http"
+        },
+        {
+            "id": 110001,
+            "ip": "95.135.130.148",
+            "port": 41128,
+            "username": "wq1FoNR1X7Q8mA8",
+            "password": "5Wqzc346MIWp3Ha",
+            "scheme": "http"
+        },
+        {
+            "id": 110002,
+            "ip": "95.135.130.187",
+            "port": 47912,
+            "username": "pypMVfmyeaYvjJt",
+            "password": "fjS0zLk1ulBN1c2",
+            "scheme": "http"
+        },
+        {
+            "id": 110003,
+            "ip": "95.135.130.213",
+            "port": 41562,
+            "username": "f1MjAP4WsrDJ0Wn",
+            "password": "4j4aAeOJlXKwDSy",
+            "scheme": "http"
+        },
+        {
+            "id": 110004,
+            "ip": "95.135.130.224",
+            "port": 46199,
+            "username": "ax10mM3PdfARTWE",
+            "password": "G484440GpByzphQ",
+            "scheme": "http"
+        },
+        {
+            "id": 110005,
+            "ip": "95.135.130.36",
+            "port": 44888,
+            "username": "m239fOR14o2HrVc",
+            "password": "s43qSsHt9SAIjpt",
+            "scheme": "http"
+        },
+        {
+            "id": 110006,
+            "ip": "95.135.130.54",
+            "port": 46211,
+            "username": "iyGDJtEtIJlY2Gu",
+            "password": "Mh0cIEnX7iCuA3X",
+            "scheme": "http"
+        },
+        {
+            "id": 110007,
+            "ip": "95.135.130.56",
+            "port": 43462,
+            "username": "CLkHdJdtiG3279w",
+            "password": "NHeox1Z5rPmmHjK",
+            "scheme": "http"
+        },
+        {
+            "id": 110008,
+            "ip": "95.135.130.77",
+            "port": 43018,
+            "username": "bxqjlbGdCW8jgnc",
+            "password": "5ijclb1RmYMA1DM",
+            "scheme": "http"
+        },
+        {
+            "id": 110009,
+            "ip": "95.135.130.85",
+            "port": 44066,
+            "username": "z9GREx6mPvfVyxj",
+            "password": "f3rCKSLqpGX1i5I",
+            "scheme": "http"
         }
     ],
     "local": [

+ 2 - 5
gco.py

@@ -336,7 +336,7 @@ class GCO:
                         lock_duration=self.m_cfg.account_login_interval * 60
                     )
                 except Exception as e:
-                    self._log(f"Get built-in account failed: {e}")
+                    # self._log(f"Get built-in account failed: {e}")
                     account = None
 
                 if account:
@@ -347,9 +347,6 @@ class GCO:
                     config_ready = True
                     task_ref = None
                     self._log(f"Selected Built-in: {plg_cfg.account.username}")
-                else:
-                    self._log("No available built-in account")
-
 
             # B. 次选补充订单账号 (如果内置不需要 或 池子空了)
             # 只有当 limit > 0 时才尝试
@@ -534,7 +531,7 @@ class GCO:
                 self._log(f"Task {task_id} marked as GRABBED.")
             
             else:
-                self._log(f"❌ Booking Failed for Order {order_id}: {book_res.message}")
+                self._log(f"❌ Booking Failed for Order {order_id}")
 
         except Exception as e:
             self._log(f"Exception during booking for Order {order_id}: {e}")

+ 239 - 215
plugins/grc_plugin.py

@@ -4,7 +4,7 @@ import random
 import re
 import os
 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
 
@@ -27,6 +27,8 @@ class GrcPlugin(IVSPlg):
         self.logger = None
         self.session: Optional[requests.Session] = None
         self.resource_id = '1123832'
+        self.rp_id = None            # 用于 AJAX 查询 (例如 778129)
+        self.token = None            # 用于 AJAX 查询
         self.session_create_time: float = 0
 
     def get_group_id(self) -> str:
@@ -85,7 +87,17 @@ class GrcPlugin(IVSPlg):
         }
         
         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 "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._log(f"Session created successfully. (User: {self.config.account.username})")
         
@@ -97,7 +109,6 @@ class GrcPlugin(IVSPlg):
         else:
             # 其他未知错误
             self._save_debug_html(resp.text, prefix='login_unknown_fail')
-            # 打印 URL 辅助调试,看是否跳转了
             self._log(f"Login check failed. Current URL: {resp.url}")
             raise BizLogicError(message='Login failed: Unknown response')
         
@@ -119,6 +130,33 @@ class GrcPlugin(IVSPlg):
             if block[0] <= timestamp < block[1]:
                 return True
         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:
         res = VSQueryResult()
@@ -131,8 +169,6 @@ class GrcPlugin(IVSPlg):
         }
 
         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:
             self.is_healthy = False
@@ -141,255 +177,273 @@ class GrcPlugin(IVSPlg):
         res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
         if res_id_match:
             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
         len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
         if len_match:
             default_length = int(len_match.group(1))
             
-        # 提取每日营业时间 (open_times)
         open_times = None
         ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
         if ot_match:
             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)
         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)
         if cursor_match:
             y, m, d, h = map(int, cursor_match.groups())
             start_date = datetime(y, m + 1, d, h, tzinfo=timezone.utc)
         else:
             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]] = {}
-        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:
             res.success = True
             res.availability_status = AvailabilityStatus.Available
-            
-            # 按日期排序
             sorted_dates = sorted(valid_slots_map.keys())
             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:
-            self._log("No available slots found.")
+            self._log("No slots found.")
+            
         return res
 
     def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
+        if user_inputs is None:
+            user_inputs = {}
+            
         res = VSBookResult()
         res.success = False
 
-        # 1. 准备日期筛选参数
+        # --- 1. 筛选并收集所有可用 Slot ---
         exp_start = user_inputs.get('expected_start_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:
-            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
 
-        # 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
 
-        # 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
 
     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:
-            # 也要打乱一下,保证随机性
             shuffled_dates = list(dates)
             random.shuffle(shuffled_dates)
             return shuffled_dates
             
         valid_dates = []
         try:
-            # 截取前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)
         except ValueError:
@@ -416,7 +470,6 @@ class GrcPlugin(IVSPlg):
         self._log(f"HTML saved to: {filename}")
             
     def _get_proxy_url(self):
-        # 构造代理
         proxy_url = ""
         if self.config.proxy.ip:
             s = self.config.proxy
@@ -427,14 +480,9 @@ class GrcPlugin(IVSPlg):
         return proxy_url
     
     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)
         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:
             return resp
         elif resp.status_code == 401:
@@ -448,28 +496,4 @@ class GrcPlugin(IVSPlg):
         else:
             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 方法

+ 2 - 2
plugins/vfs_plugin2.py

@@ -1181,7 +1181,7 @@ class VfsPlugin2(IVSPlg):
             "dateOfApplication": None,
             "selectedSubvisaCategory": None,
             "gender": gender_code,
-            "contactNumber": str(user_inputs.get("phone", "")),
+            "contactNumber": str(user_inputs.get("phone", "")).lstrip("0"),
             "dialCode": dial_code,
             "employerContactNumber": "",
             "employerDialCode": "",
@@ -1234,7 +1234,7 @@ class VfsPlugin2(IVSPlg):
         }
 
         if enable_ref:
-            applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
+            applicant["referenceNumber"] = str(user_inputs.get("cover_letter_id", ""))
         else:
             applicant["referenceNumber"] = None
 

+ 30 - 12
vs_log_macros.py

@@ -1,5 +1,6 @@
 # vs_log_macros.py
 import logging
+from logging.handlers import RotatingFileHandler
 import os
 import sys
 
@@ -13,24 +14,41 @@ if not os.path.exists(LOG_DIR):
 LOG_FILE = os.path.join(LOG_DIR, "vs_app.log")
 
 # 2. 定义日志格式 (包含时间)
-# 格式示例: [2023-10-27 10:30:01] [INFO] [MainThread] [TAG] Message...
-LOG_FORMAT = '[%(asctime)s] [%(levelname)s] [%(threadName)s] %(message)s'
+# 格式示例:[2023-10-27 10:30:01] [INFO] [MainThread] [TAG] Message...
+LOG_FORMAT = '[%(asctime)s][%(levelname)s][%(threadName)s]%(message)s'
 DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
 
 # 3. 获取日志级别
 log_level_str = os.environ.get("VSC_LOG_LEVEL", "INFO").upper()
 log_level = getattr(logging, log_level_str, logging.INFO)
 
-# 4. 配置 logging (同时输出到控制台和文件)
+# 4. 配置日志滚动参数 (支持通过环境变量覆盖)
+# 默认单个日志文件大小: 10 MB (10 * 1024 * 1024 bytes)
+MAX_BYTES = int(os.environ.get("VSC_LOG_MAX_BYTES", 10 * 1024 * 1024))
+# 默认保留的备份文件数量: 5
+BACKUP_COUNT = int(os.environ.get("VSC_LOG_BACKUP_COUNT", 10))
+
+# 5. 创建 handlers
+# 控制台 handler
+console_handler = logging.StreamHandler(sys.stdout)
+
+# 滚动文件 handler
+rolling_file_handler = RotatingFileHandler(
+    filename=LOG_FILE,
+    mode='a',
+    maxBytes=MAX_BYTES,
+    backupCount=BACKUP_COUNT,
+    encoding='utf-8'
+)
+
+# 6. 配置 logging (同时输出到控制台和滚动文件)
 logging.basicConfig(
     level=log_level,
     format=LOG_FORMAT,
     datefmt=DATE_FORMAT,
     handlers=[
-        # 输出到控制台
-        logging.StreamHandler(sys.stdout),
-        # 输出到文件 (追加模式,UTF-8编码)
-        logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
+        console_handler,
+        rolling_file_handler
     ]
 )
 
@@ -39,15 +57,15 @@ logging.basicConfig(
 def VSC_INFO(tag, message, *args):
     """
     Usage: VSC_INFO("network", "Connected to %s:%d", ip, port)
-    Output: [Time] [INFO] [Thread] [network] Connected to 127.0.0.1:80
+    Output: [Time][INFO][Thread][network]Connected to 127.0.0.1:80
     """
-    logging.info(f"[{tag}] {message}", *args)
+    logging.info(f"[{tag}]{message}", *args)
 
 def VSC_DEBUG(tag, message, *args):
-    logging.debug(f"[{tag}] {message}", *args)
+    logging.debug(f"[{tag}]{message}", *args)
 
 def VSC_WARN(tag, message, *args):
-    logging.warning(f"[{tag}] {message}", *args)
+    logging.warning(f"[{tag}]{message}", *args)
 
 def VSC_ERROR(tag, message, *args):
-    logging.error(f"[{tag}] {message}", *args)
+    logging.error(f"[{tag}]{message}", *args)