root 4 долоо хоног өмнө
parent
commit
992a907be1

+ 1 - 0
booker_builtin.py

@@ -308,6 +308,7 @@ class BuiltinBookerGCO:
                         next_remote_ping = time.time() + random.gauss(self.m_cfg.booker.keep_alive, 5) 
                         next_remote_ping = time.time() + random.gauss(self.m_cfg.booker.keep_alive, 5) 
                     )
                     )
                 )
                 )
+            success = True
             self._log(f"+++ Built-in Booker spawned: {plg_cfg.account.username}")
             self._log(f"+++ Built-in Booker spawned: {plg_cfg.account.username}")
         except Exception as e:
         except Exception as e:
             err_str = str(e)
             err_str = str(e)

+ 199 - 0
booker_standalone.py

@@ -0,0 +1,199 @@
+import os
+import time
+import random
+from datetime import datetime
+from typing import List, Dict, Callable
+
+from vs_types import BookerStandaloneConfig, QueryWaitMode, VSPlgConfig, VSQueryResult, VSBookResult
+from vs_plg_factory import VSPlgFactory
+from utils.safe_redis_cli import SafeRedisClient
+from toolkit.vs_cloud_api import VSCloudApi
+from toolkit.thread_pool import ThreadPool
+
+
+class BookerStandalone:
+    def __init__(self, config: BookerStandaloneConfig, redis_conf: Dict, logger: Callable[[str], None] = None):
+        self.m_logger = logger
+        self.m_factory = VSPlgFactory()
+        self.m_cfg = config
+        self.redis_client = SafeRedisClient(redis_conf, self.m_logger)
+        self.m_instance = None
+        self.m_running = False
+        self.m_next_query_time = 0.0
+        self.m_last_login_time = 0.0
+        self.m_current_dates = {}
+
+    def _log(self, message):
+        if self.m_logger:
+            self.m_logger(f'[BOOKER] {message}')
+        else:
+            print(f'[BOOKER] {message}')
+
+    def _get_wait_interval(self) -> float:
+        """根据配置计算下一次查询的等待时间"""
+        if self.m_cfg.query_wait.mode == QueryWaitMode.Loop:
+            return 1.0
+        elif  self.m_cfg.query_wait.mode == QueryWaitMode.Fixed:
+            return float(self.m_cfg.query_wait.fixed_wait)
+        elif  self.m_cfg.query_wait.mode == QueryWaitMode.Random:
+            return random.uniform(self.m_cfg.query_wait.random_min, self.m_cfg.query_wait.random_max)
+        return 30.0
+    
+    def _is_within_active_hours(self) -> bool:
+        """
+        判断当前是否在允许创建实例的时间段内
+        """
+        start_str = self.m_cfg.active_time_start
+        end_str = self.m_cfg.active_time_end
+        current_bj_time = datetime.utcnow().time()
+        start_time = datetime.strptime(start_str, "%H:%M").time()
+        end_time = datetime.strptime(end_str, "%H:%M").time()
+        return start_time <= current_bj_time <= end_time
+    
+    def update_config(self, new_cfg: BookerStandaloneConfig):
+        """
+        动态更新配置
+        """
+        pass
+    
+    def _notify_date_changes(self, query_result: VSQueryResult):
+        current_earliest_date = query_result.earliest_date
+        apt_type = query_result.apt_type
+        if current_earliest_date:
+            last_date = self.m_current_dates.get(apt_type.routing_key)
+            if last_date != current_earliest_date:
+                self.m_current_dates[apt_type.routing_key] = current_earliest_date
+                def _push_to_wx():
+                    try:
+                        push_content = (
+                            f"📢【档期变化通知】\n"
+                            f"最早日期: {query_result.earliest_date}\n"
+                            f"目标国家: {apt_type.country}\n"
+                            f"递交城市: {apt_type.city}\n"
+                            f"签证类型: {apt_type.visa_type}\n"
+                            f"Routing: {apt_type.routing_key}"
+                        )
+                        VSCloudApi.Instance().push_weixin_text(push_content)
+                    except Exception as e:
+                        self._log(f"Failed to notify to cloud: {e}")
+                ThreadPool.getInstance().enqueue(_push_to_wx)
+    
+    def _notify_book_result(self, book_result: VSBookResult):
+        if book_result.success:
+            def _update_cloud_success():
+                try:
+                    push_content = (
+                        f"🎉 【预定成功通知】\n"
+                        f"━━━━━━━━━━━━━━━\n"
+                        f"预约账号: {book_result.account}\n"
+                        f"预约日期: {book_result.book_date}\n"
+                        f"预约时间: {book_result.book_time}\n"
+                        f"预约编号: {book_result.urn}\n"
+                        f"支付链接: {book_result.payment_link if book_result.payment_link else '无需支付/暂无'}\n"
+                        f"━━━━━━━━━━━━━━━\n"
+                    )
+                    VSCloudApi.Instance().push_weixin_text(push_content)
+                except Exception as e:
+                    self._log(f"Failed to update success state to cloud: {e}")
+            ThreadPool.getInstance().enqueue(_update_cloud_success)
+    
+    def _countdown_wait(self, seconds: float, wait_for='') -> bool:
+        """倒计时等待,动态刷新同一行,返回 False 表示被 stop 中断"""
+        end = time.time() + seconds
+        last_remaining = None
+        while self.m_running and time.time() < end:
+            remaining = int(end - time.time())
+            if remaining != last_remaining:
+                print(f"[BOOKER] Next {wait_for} in {remaining} seconds... (stop to exit)", end='\r', flush=True)
+                last_remaining = remaining
+            time.sleep(1)
+        print()
+        return self.m_running    
+
+    def _init_instance(self):
+        """初始化单实例并进行登录创建会话"""
+        if self.m_instance is not None:
+            self.m_instance.cleanup()
+        
+        now = time.time()
+        if now < self.m_last_login_time + self.m_cfg.login_interval:
+            wait = self.m_last_login_time + self.m_cfg.login_interval - now
+            self._countdown_wait(wait, wait_for='init instance')
+            
+        plugin_name = self.m_cfg.plugin_config.plugin_name
+        class_name = "".join(part.title() for part in plugin_name.split('_'))
+        plugin_path = os.path.join(self.m_cfg.plugin_config.lib_path, self.m_cfg.plugin_config.plugin_bin)
+        self.m_factory.register_plugin(plugin_name, plugin_path, class_name)
+        
+        plg_cfg = VSPlgConfig()
+        plg_cfg.debug = self.m_cfg.debug
+        plg_cfg.account = self.m_cfg.account
+        proxy = VSCloudApi.Instance().get_next_proxy(self.m_cfg.proxy_pool, proxy_cd=600)
+        plg_cfg.proxy = type(plg_cfg.proxy)(**proxy)
+        plg_cfg.free_config = self.m_cfg.free_config
+        plg_cfg.session_max_life = self.m_cfg.session_max_life
+        
+        self.m_instance = self.m_factory.create("single_task", plugin_name)
+        self.m_instance.set_log(self.m_logger)
+        self.m_instance.set_config(plg_cfg)
+        
+        self.m_next_query_time = time.time() + self._get_wait_interval()
+        self.m_last_login_time = time.time()
+        self.m_instance.create_session()
+        self._log("Session created successfully.")
+
+    def start(self):
+        """
+        单线程无限循环:心跳保活 -> 时间判定 -> 查询 -> 预定
+        """
+        self.m_running = True
+        self._log("Auto Booker Started.")
+        
+        while self.m_running:
+            if not self._is_within_active_hours():
+                continue
+            try:
+                self._init_instance()
+                break
+            except Exception as e:
+                self._log(f"Failed to create session: {e}. Retrying in 10s...")
+                time.sleep(10)
+
+        while self.m_running:
+            try:
+                now = time.time()
+                if not self._is_within_active_hours():
+                    continue
+                
+                if not self.m_instance.health_check():
+                    self._log("Health check failed. Session dead. Recreating session...")
+                    self._init_instance()
+                    continue
+                
+                if now < self.m_next_query_time:
+                    wait = self.m_next_query_time - now
+                    self._countdown_wait(wait, wait_for="query slot")
+
+                apt_types = self.m_cfg.appointment_types
+                weights = [float(t.weight) for t in apt_types]
+                apt_type = random.choices(apt_types, weights=weights, k=1)[0]
+
+                self._log(f"Querying slots for {apt_type.routing_key}...")
+                query_result = self.m_instance.query(apt_type)
+                query_result.apt_type = apt_type
+                self.m_next_query_time = time.time() + self._get_wait_interval()
+                self._notify_date_changes(query_result)
+                if query_result.success:
+                    self._log("🔥 SLOT FOUND! Initiating AUTO-BOOKING...")
+                    # 5. 执行预定
+                    book_result = self.m_instance.book(query_result, self.m_cfg.user_preferences)
+                    self._notify_book_result(book_result)
+                    if book_result.success:
+                        self._log(f"🎉 BOOKING SUCCESSFUL for {apt_type.routing_key}!")
+                        break
+            except Exception as e:
+                self._log(f"Loop Exception: {e}")
+                
+    def stop(self):
+        """外部中断时调用"""
+        self.m_running = False

+ 57 - 0
config/config_tls.json.example

@@ -0,0 +1,57 @@
+{
+  "redis": {
+    "host": "text.skin",
+    "port": 6379,
+    "db": 0,
+    "password": "STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8"
+  },
+  "booker_standalone_config": {
+    "debug": true,
+    "plugin_config": {
+      "lib_path": "plugins",
+      "plugin_name": "tls_plugin",
+      "plugin_bin": "tls_plugin.py",
+      "plugin_proto": "IVSPlg"
+    },
+    "account": {
+      "username": "18305503085@163.com",
+      "password": "Zhangww@201207"
+    },
+    "proxy_pool": ["proxy-cheap"],
+    "query_wait": {
+      "mode": "Random",
+      "fixed_wait": 10,
+      "random_min": 55,
+      "random_max": 65
+    },
+    "login_interval": 1800,
+    "session_max_life": 3600,
+    "active_time_start": "00:00",
+    "active_time_end": "23:30",
+    "appointment_types": [
+      {
+        "weight": 10,
+        "routing_key": "slot.sha.fr.tourist",
+        "city": "Shanghai",
+        "visa_type": "Tourist",
+        "country": "France"
+      }
+    ],
+    "user_preferences": {
+      "support_pta": false,
+      "expected_end_date": "2024-07-01",
+      "expected_start_date": "2024-07-20"
+    },
+    "free_config": {
+      "tls_url": "https://visas-fr.tlscontact.com/en-us/country/cn/vac/cnSHA2fr",
+      "location": "Shanghai",
+      "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
+      "login_captcha": {
+        "solve_advance": false,
+        "site_key": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
+        "page_url": "https://i2-auth.visas-fr.tlscontact.com",
+        "task": "ReCaptchaV2TaskProxyLess"
+      }
+    }
+  }
+}

+ 66 - 0
config/config_usa.json.example

@@ -0,0 +1,66 @@
+{
+  "redis": {
+    "host": "text.skin",
+    "port": 6379,
+    "db": 0,
+    "password": "STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8"
+  },
+  "booker_standalone_config": {
+    "debug": true,
+    "plugin_config": {
+      "lib_path": "plugins",
+      "plugin_name": "usa_plugin",
+      "plugin_bin": "usa_plugin.py",
+      "plugin_proto": "IVSPlg"
+    },
+    "account": {
+      "username": "Max0105888",
+      "password": "Zl265498"
+    },
+    "proxy_pool": [
+      "proxy-cheap"
+    ],
+    "query_wait": {
+      "mode": "Random",
+      "fixed_wait": 10,
+      "random_min": 60,
+      "random_max": 600
+    },
+    "login_interval": 3600,
+    "session_max_life": 3600,
+    "active_time_start": "00:00",
+    "active_time_end": "23:30",
+    "appointment_types": [
+      {
+        "weight": 10,
+        "routing_key": "slot.wuh.us.tourist",
+        "city": "Wuhan",
+        "visa_type": "Tourist",
+        "country": "USA"
+      }
+    ],
+    "user_preferences": {
+      "expected_date_start": "2026-06-01",
+      "expected_date_end": "2026-08-05"
+    },
+    "free_config": {
+      "applicant": "jinhao ma",
+      "usa_url": "https://www.usvisascheduling.com",
+      "location": "WUHAN",
+      "security": {
+        "1_quest": {
+          "q": "What is your mother's surname?",
+          "a": "zhou"
+        },
+        "2_quest": {
+          "q": "What is your least favorite food?",
+          "a": "kfc"
+        },
+        "3_quest": {
+          "q": "Who was your childhood hero?",
+          "a": "123456"
+        }
+      }
+    }
+  }
+}

+ 94 - 0
config/config_vfs.json.example

@@ -0,0 +1,94 @@
+{
+    "redis": {
+        "host": "text.skin",
+        "port": 6379,
+        "db": 0,
+        "password": "STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8"
+    },
+    "booker_standalone_config": {
+        "debug": true,
+        "plugin_config": {
+            "lib_path": "plugins",
+            "plugin_name": "vfs_plugin",
+            "plugin_bin": "vfs_plugin.py",
+            "plugin_proto": "IVSPlg"
+        },
+        "account": {
+            "username": "italyvisa888@text.skin",
+            "password": "Visafly@111"
+        },
+        "proxy_pool": [
+            "proxy-cheap"
+        ],
+        "query_wait": {
+            "mode": "Random",
+            "fixed_wait": 10,
+            "random_min": 120,
+            "random_max": 180
+        },
+        "login_interval": 7200,
+        "session_max_life": 1800,
+        "active_time_start": "00:00",
+        "active_time_end": "23:30",
+        "appointment_types": [
+            {
+                "weight": 0,
+                "routing_key": "slot.csx.it.tourist",
+                "city": "Changsha",
+                "visa_type": "Tourist",
+                "country": "Italy"
+            },
+            {
+                "weight": 10,
+                "routing_key": "slot.bjs.it.tourist",
+                "city": "Beijing",
+                "visa_type": "Tourist",
+                "country": "Italy"
+            }
+        ],
+        "user_preferences": {
+            "email": "italyvisa666@text.skin",
+            "phone": "019074224604",
+            "gender": "female",
+            "birthday": "1997-09-09",
+            "last_name": "ZHAO",
+            "first_name": "LINJIE",
+            "nationality": "China",
+            "passport_no": "EE4571001",
+            "expected_end_date": "2026-07-03",
+            "phone_country_code": "86",
+            "expected_start_date": "2026-06-12",
+            "passport_expiry_date": "2028-10-08",
+            "social_media_account": "13370148298",
+            "passport_image_url": "data/passport.jpg"
+        },
+        "free_config": {
+            "mission_code": "ita",
+            "mission_name": "Italy",
+            "country_code": "chn",
+            "country_name": "China",
+            "culture_code": "en-US",
+            "language": "en",
+            "apt_configs": {
+                "slot.csx.it.tourist": {
+                    "center_name": "Italy Visa Application Center, Changsha",
+                    "address": "1F, Changsha Rongchuang Steigenberger Hotel, No. 3-9, Xueyuan Road, Changsha County, Changsha City",
+                    "vac_code": "CGA",
+                    "category_name": "SchengenVisa",
+                    "category_code": "Schengen Visa",
+                    "subcategory_name": "C visa / Schengen visa",
+                    "subcategory_code": "Schengen "
+                },
+                "slot.bjs.it.tourist": {
+                    "center_name": "Italy Visa Application Center, Beijing",
+                    "address": "East 101, Floor B1, Block C, Guanghua Lu SOHO II, No. 9 Guanghua Lu, Chaoyang District",
+                    "vac_code": "BJII",
+                    "category_name": "Beijing Italy",
+                    "category_code": "Beijing Italy",
+                    "subcategory_name": "Standard Beijing Italy",
+                    "subcategory_code": "BJ Italy Standard"
+                }
+            }
+        }
+    }
+}

+ 55 - 0
main_standalone.py

@@ -0,0 +1,55 @@
+import os
+import time
+import json
+import argparse
+from vs_types import BookerStandaloneConfig
+from gco_wrapper import GCOWrapper
+from logger_setup import setup_app_logger
+from booker_standalone import BookerStandalone
+
+
+def load_config(path: str):
+    if not os.path.exists(path):
+        return {}
+    with open(path, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+def main():
+    parser = argparse.ArgumentParser(description="Standalone Booker Runner")
+    parser.add_argument(
+        "-c", "--config",
+        type=str,
+        required=False,
+        default="config/config.json",
+        help="Path to standalone booker config.json"
+    )
+
+    args = parser.parse_args()
+    config_path = args.config
+    
+    app_logger = setup_app_logger("Booker")
+    app_logger.info("Booker Logger is ready! All VSC macros are hooked.")
+
+    cfg_data = load_config(config_path)
+
+    redis_conf = cfg_data.get('redis')
+
+    booker_standalone_config = cfg_data.get('booker_standalone_config')
+    cfg = BookerStandaloneConfig.from_json(booker_standalone_config)
+
+    gco_class = BookerStandalone
+
+    wrapper = GCOWrapper(gco_class=gco_class, gco_cfg=cfg, redis_conf=redis_conf)
+    wrapper.load()
+    wrapper.start()
+    app_logger.info(f"Successfully started booker. Press Ctrl+C to stop.")
+    
+    try:
+        while True:
+            time.sleep(1)
+    except KeyboardInterrupt:
+        app_logger.info("Shutting down Bookers...")
+        wrapper.stop()
+
+if __name__ == "__main__":
+    main()

+ 320 - 327
plugins/ita_plugin.py

@@ -7,16 +7,17 @@ import shutil
 import re
 import re
 import os
 import os
 import base64
 import base64
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime
 from datetime import datetime
 from typing import List, Dict, Optional, Any, Callable
 from typing import List, Dict, Optional, Any, Callable
 from urllib.parse import urlencode, urlparse
 from urllib.parse import urlencode, urlparse
 
 
-# DrissionPage 核心
 from DrissionPage import ChromiumPage, ChromiumOptions
 from DrissionPage import ChromiumPage, ChromiumOptions
 
 
+import configure
 from vs_plg import IVSPlg
 from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
-from toolkit.proxy_tunnel import ProxyTunnel
+from toolkit.mihomo_tunnel import MihomoTunnel
 from toolkit.vs_cloud_api import VSCloudApi
 from toolkit.vs_cloud_api import VSCloudApi
 from utils.mouse import HumanMouse
 from utils.mouse import HumanMouse
 from utils.keyboard import HumanKeyboard
 from utils.keyboard import HumanKeyboard
@@ -53,21 +54,14 @@ class ItaPlugin(IVSPlg):
         
         
         # Prenotami 特有配置
         # Prenotami 特有配置
         self._service_id = 0 
         self._service_id = 0 
-        self._host = 'https://prenotami.esteri.it'
+        self.ita_url = 'https://prenotami.esteri.it'
         
         
-                
-        # --- [核心修改] 并发隔离与资源管理 ---
-        # 生成唯一实例 ID
         self.instance_id = uuid.uuid4().hex[:8]
         self.instance_id = uuid.uuid4().hex[:8]
         self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
         self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
-        # 定义子目录:代理插件目录 & 浏览器用户数据目录
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
         
         
-        # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
             os.makedirs(self.root_workspace)
-            
-        # 持有隧道实例
         self.tunnel = None
         self.tunnel = None
         self.session_create_time: float = 0
         self.session_create_time: float = 0
     
     
@@ -103,19 +97,12 @@ class ItaPlugin(IVSPlg):
                 return False
                 return False
         return True
         return True
 
 
-    # -------------------------------------------------------------
-    # 1. Create Session (Login)
-    # -------------------------------------------------------------
     def create_session(self):
     def create_session(self):
         """
         """
-        全浏览器登录流程:
-        1. 启动浏览器
-        2. 解决 ReCaptcha
-        3. 登录并维持 Session
+        全浏览器会话创建:过盾 -> JS注入登录 -> 状态机自动路由导航 -> 到达目标页
         """
         """
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         co = ChromiumOptions()
         co = ChromiumOptions()
-        
         def get_free_port():
         def get_free_port():
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                 s.bind(('', 0))
                 s.bind(('', 0))
@@ -123,115 +110,245 @@ class ItaPlugin(IVSPlg):
         
         
         debug_port = get_free_port()
         debug_port = get_free_port()
         self._log(f"Assigned Debug Port: {debug_port}")
         self._log(f"Assigned Debug Port: {debug_port}")
+        self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
+    
+        co.set_local_port(debug_port)       
         co.set_user_data_path(self.user_data_path)
         co.set_user_data_path(self.user_data_path)
         
         
-        chrome_path = os.getenv("CHROME_BIN")
+        chrome_path = configure.CHROME_PATH
+        if not chrome_path:
+            chrome_path = os.getenv("CHROME_BIN")
         if chrome_path and os.path.exists(chrome_path):
         if chrome_path and os.path.exists(chrome_path):
             co.set_paths(browser_path=chrome_path)
             co.set_paths(browser_path=chrome_path)
         
         
         if self.config.proxy and self.config.proxy.ip:
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
             p = self.config.proxy
-            
             if p.username and p.password:
             if p.username and p.password:
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
-                
-                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                exit_node = {
+                    "name": "ExitNode",
+                    "type": p.proto,
+                    "server": p.ip,
+                    "port": p.port,
+                    "username": p.username,
+                    "password": p.password
+                }
+                relay_node = None
+                if configure.MIHOMO_RELAY_NODES:
+                    relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
+                mihomo_path = configure.MIHOMO_BIN_PATH
+                if not mihomo_path:
+                    mihomo_path = os.getenv("MIHOMO_BIN")
+                if not mihomo_path:
+                    raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
+                self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
                 local_proxy = self.tunnel.start()
                 local_proxy = self.tunnel.start()
-                
                 self._log(f"Tunnel started at {local_proxy}")
                 self._log(f"Tunnel started at {local_proxy}")
                 co.set_argument(f'--proxy-server={local_proxy}')
                 co.set_argument(f'--proxy-server={local_proxy}')
-                
             else:
             else:
                 proxy_str = f"{p.proto}://{p.ip}:{p.port}"
                 proxy_str = f"{p.proto}://{p.ip}:{p.port}"
                 co.set_argument(f'--proxy-server={proxy_str}')
                 co.set_argument(f'--proxy-server={proxy_str}')
         else:
         else:
             self._log("[WARN] No proxy configured!")
             self._log("[WARN] No proxy configured!")
 
 
-        fingerprint_gen = FingerprintGenerator()
-        specific_fp = fingerprint_gen.generate(self.config.account.username)
-        self._log(f'browser fingerprint={specific_fp}')
+        specific_fp = FingerprintGenerator().generate(self.config.account.username)
+        fp_seed = specific_fp.get("seed")
+        fp_platform = specific_fp.get("platform")
+        fp_brand = specific_fp.get("brand")
+        self._log(f'browser fingerprint seed={fp_seed}')
 
 
-        co.headless(False) 
+        co.headless(False)
         co.set_argument('--no-sandbox')
         co.set_argument('--no-sandbox')
-        co.set_argument('--disable-gpu')
         co.set_argument('--disable-dev-shm-usage')
         co.set_argument('--disable-dev-shm-usage')
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
         co.set_argument('--disable-blink-features=AutomationControlled')
-        co.set_argument(f"--fingerprint={specific_fp.get('seed')}")
-        co.set_argument(f"--fingerprint-platform={specific_fp.get('platform')}")
-        co.set_argument(f"--fingerprint-brand={specific_fp.get('brand')}")
-        try:
-            self.page = ChromiumPage(co)
-            login_url = f"{self._host}/Home"
-            self._log(f"Navigating to {login_url}")
-            self.page.get(login_url)
+        co.set_argument(f"--fingerprint={fp_seed}")
+        co.set_argument(f"--fingerprint-platform={fp_platform}")
+        co.set_argument(f"--fingerprint-brand={fp_brand}")
+        
+        self.page = ChromiumPage(co)
+        if self.config.debug:
+            self.page.get('https://example.com')
+            js_script = """
+            function getFingerprint() {
+                let webglVendor = 'Unknown';
+                let webglRenderer = 'Unknown';
+                try {
+                    let canvas = document.createElement('canvas');
+                    let gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
+                    if (gl) {
+                        let debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
+                        if (debugInfo) {
+                            webglVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
+                            webglRenderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
+                        }
+                    }
+                } catch(e) {}
+
+                return {
+                    "User-Agent": navigator.userAgent,
+                    "Platform": navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform,
+                    "Brands": navigator.userAgentData ? navigator.userAgentData.brands.map(b => b.brand).join(', ') : 'Not Supported',
+                    "CPU Cores": navigator.hardwareConcurrency,
+                    "Language": navigator.language,
+                    "Timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
+                    "WebGL Vendor": webglVendor,
+                    "WebGL Renderer": webglRenderer
+                };
+            }
+            return getFingerprint();
+            """
+            fp_data = self.page.run_js(js_script)
+            self._log("================ 预检浏览器指纹数据 ================")
+            self._log(json.dumps(fp_data, indent=4, ensure_ascii=False))
+            self._log("====================================================")
             
             
-            self._log("Init humanize tools...")
-            self.mouse = HumanMouse(self.page, debug=True)
-            self.keyboard = HumanKeyboard(self.page)
-            self._log("Random mouse start position...")
-            viewport_width = self.page.rect.viewport_size[0]
-            viewport_height = self.page.rect.viewport_size[1]
-            init_x = random.randint(10, viewport_width - 10)
-            init_y = random.randint(10, viewport_height - 10)
-            self.mouse.move(init_x, init_y) 
+        self.page = ChromiumPage(co)
+        ita_url = self.ita_url
+        self._log(f"Navigating to {ita_url}")
+        self.page.get(ita_url)
+        
+        self._log("Init humanize tools...")
+        self.mouse = HumanMouse(self.page, debug=True)
+        self.keyboard = HumanKeyboard(self.page)
+        self._log("Random mouse start position...")
+        viewport_width = self.page.rect.viewport_size[0]
+        viewport_height = self.page.rect.viewport_size[1]
+        init_x = random.randint(10, viewport_width - 10)
+        init_y = random.randint(10, viewport_height - 10)
+        self.mouse.move(init_x, init_y) 
+        
+        switch_en_btn = self.page.ele('tag:a@@href=/Language/ChangeLanguage?lang=2')
+        self.mouse.human_click_ele(switch_en_btn)
+        self.page.wait.load_start() 
+        time.sleep(5)
+
+        max_steps = 15
+        stuck_counter = 0
+        last_url = ""
+        session_created = False
+        has_submitted_login = False
+        
+        username = self.config.account.username
+        password = self.config.account.password
+        
+        for step in range(max_steps):
+            self.page.wait.doc_loaded()
+            time.sleep(1)
             
             
-            # 等待登录框
-            if not self.page.wait.ele_displayed('#login-email', timeout=20):
-                raise BizLogicError("Login page not loaded")
-
-            # 填充用户名密码
-            self.mouse.human_click_ele(self.page.ele('#login-email'))
-            self.keyboard.type_text(self.config.account.username)
+            current_url = self.page.url
+            current_title = self.page.title.lower()
+            current_html_content = self.page.html
+            self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
             
             
-            self.mouse.human_click_ele(self.page.ele('#login-password'))
-            self.keyboard.type_text(self.config.account.password)
+            # --- [异常处理与反爬对抗层] ---
+            if current_url == last_url:
+                stuck_counter += 1
+            else:
+                last_url = current_url
+                stuck_counter = 0
+                
+            if stuck_counter >= 3:
+                self._log("[WARN] Page stucked, try to refresh...")
+                self.page.refresh()
+                self._random_sleep(5, 8)
+                stuck_counter = 0
+                continue
             
             
-            # 先定位
-            self._log("Locating Login button...")
-            login_btn = self.page.ele('#captcha-trigger')
+            server_error_indicators = ["502 Bad Gateway", "503 Service Temporarily Unavailable", "error 1020"]
+            if any(err in current_html_content for err in server_error_indicators):
+                self._log(f"[WARN] Server network error, try to refresh (Step: {step})...")
+                self.page.refresh()
+                self._random_sleep(10, 15)
+                continue
             
             
-            self.mouse.human_click_ele(login_btn)
-            self._log("Login button clicked.")
+            # Cloudflare 拦截
+            cloudflare_blocked_indicators = [
+                "Sorry, you have been blocked",
+                "You are being rate limited",
+                "Cloudflare Ray ID"
+            ]
+            if any(indicator in current_html_content for indicator in cloudflare_blocked_indicators):
+                raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
             
             
-            # 等待 URL 变化或特定元素出现
-            # 成功通常跳转到 /UserArea, 失败则留在 /Home
-            end_time = time.time() + 45
-            login_success = False
+            # 遇到五秒盾先绕盾 (保留你原有的逻辑)
+            if "just a moment" in current_title or "cloudflare" in current_title:
+                # 假设你有 cf_bypasser 实例
+                # if not cf_bypasser.bypass(max_retry=3): continue
+                self._log("[State] CF 5-second shield detected. Waiting...")
+                time.sleep(5)
+                continue
             
             
-            while time.time() < end_time:
+            # 状态 0: 成功到达目标预约页面 (Target Page)
+            if "/Services" in current_url and self.page.ele('.app-menu', timeout=1):
+                self._log("🎉 Successfully reached the Booking Services page! Session created successfully!")                
+                self.session_create_time = time.time()
+                session_created = True
+                break
+
+            # 状态 1: 登录前的初始首页
+            elif self.page.ele('#pingid-button', timeout=1):
+                self._log("[State] Initial Landing Page detected. Clicking login button...")
+                login_btn = self.page.ele('#pingid-button')
+                self.mouse.human_click_ele(login_btn)
+                self._log("Redirecting to PingID...")
+                self._random_sleep(3, 5) # 替代 time.sleep(5)
+                continue
+
+            # 状态 2: 真正的表单登录页 (PingID)
+            elif self.page.ele('@name=callback_1', timeout=1):
+                self._log("[State] PingID Login Form detected. Submitting credentials...")
+                
+                print("正在输入账号...")
+                user_input = self.page.ele('@name=callback_1')
+                self.mouse.human_click_ele(user_input)
+                user_input.input(username, clear=True)
+                time.sleep(1) # 模拟人手停顿
+                
+                print("正在输入密码...")
+                pwd_input = self.page.ele('@type=password')
+                self.mouse.human_click_ele(pwd_input)
+                pwd_input.input(password, clear=True)
                 time.sleep(1)
                 time.sleep(1)
-                curr_url = self.page.url
                 
                 
-                # 成功特征
-                if "/UserArea" in curr_url or "/Services" in curr_url:
-                    login_success = True
-                    break
+                print("正在点击登录按钮...")
+                submit_btn = self.page.ele('tag:button@type=submit') 
+                self.mouse.human_click_ele(submit_btn)
                 
                 
-                # 失败特征
-                if self.page.ele('.validation-summary-errors') or self.page.ele('.field-validation-error'):
-                    err_text = self.page.ele('.validation-summary-errors').text if self.page.ele('.validation-summary-errors') else "Unknown validation error"
-                    raise PermissionDeniedError(f"Login Failed: {err_text}")
+                has_submitted_login = True
+                self._log("Login form submitted. Waiting for dashboard to load...")
+                self._random_sleep(5, 7)
+                continue
+
+            # 状态 3: 登录成功后的主控制台 (导航栏页面)
+            elif self.page.ele('.app-menu', timeout=1):
+                self._log("[State] Dashboard Menu detected.")
                 
                 
-                # 检查是否有弹窗错误
-                if "Home" in curr_url and self.page.ele('#logoutForm'):
-                     # 有时候虽然在 Home 但出现了 Logout 按钮,也算成功
-                     login_success = True
-                     break
-
-            if not login_success:
-                # 截图保留现场
-                # self.page.get_screenshot(path="login_fail.jpg")
-                raise BizLogicError("Login Failed: Timeout waiting for redirect (Captcha score too low?)")
-
-            self._log("Login Successful.")
-            
-            self.session_create_time = time.time()
+                switch_en_btn = self.page.ele('tag:a@@href=/Language/ChangeLanguage?lang=2', timeout=0.5)
+                if switch_en_btn:
+                    self._log("Found 'English' language switch button. Clicking to change language...")
+                    self.mouse.human_click_ele(switch_en_btn)
+                    self._random_sleep(4, 6)
+                    continue
+                
+                # 动作 B: 如果没有英文切换按钮(说明已经是英文或不存在),则查找并点击 Book 按钮
+                book_btn = self.page.ele('@href=/Services', timeout=0.5)
+                if book_btn:
+                    self._log("Found 'Book' menu element. Clicking to proceed to reservation...")
+                    self.mouse.human_click_ele(book_btn)
+                    self._random_sleep(4, 6)
+                    continue
+                
+                self._log("[WARN] On Dashboard, but neither 'English' nor 'Book' button was found. Waiting...")
+                time.sleep(2)
+                continue
 
 
-        except Exception as e:
-            self._log(f"Create Session Failed: {e}")
-            self.cleanup()
-            raise e
+            else:
+                self._log("[State] In unknown or transitional state. Waiting for next polling cycle...")
+                time.sleep(2)
+                
+        if not session_created:
+            raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
 
 
     # -------------------------------------------------------------
     # -------------------------------------------------------------
     # 2. Query Availability
     # 2. Query Availability
@@ -241,101 +358,119 @@ class ItaPlugin(IVSPlg):
         res.success = False
         res.success = False
         res.availability_status = AvailabilityStatus.NoneAvailable
         res.availability_status = AvailabilityStatus.NoneAvailable
         
         
-        if not self._service_id:
-            raise BizLogicError("Service ID not configured")
-
-        # 1. 检查 Slot 是否可用 (Check Availability Endpoint)
-        check_url = f"{self._host}/Services/Booking/{self._service_id}"
-        
-        # 使用 Fetch 发起检查请求
-        resp = self._perform_request("GET", check_url, headers={
-            "Referer": f"{self._host}/Services",
-            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
-        })
-        
-        # 302 跳转处理逻辑
-        if resp.status_code == 200:
-            # 200 表示进入了预约页,有号
-            self._log("Slot Check: 200 OK (Availability Detected)")
-            pass 
-        elif "BookingCalendar" in resp.url: # 或者是被重定向到了 Calendar
-             self._log("Slot Check: Redirected to Calendar (Availability Detected)")
-             pass
+        # 假设要预约的服务和到访原因
+        TARGET_SERVICE = "National and Schengen Visas"
+        REASON_FOR_VISIT = "Tourism" # 对应 value 42 的选项文本
+
+        # ==========================================
+        # 步骤 1:在服务列表中寻找并点击目标服务的 Book
+        # ==========================================
+        print("等待服务列表加载...")
+        self.page.wait.eles_loaded('@aria-controls=dataTableServices', timeout=10)
+
+        print(f"正在查找 [{TARGET_SERVICE}] 的 Book 按钮...")
+        # 使用 XPath:找包含 TARGET_SERVICE 文本的行(tr),再找该行里包含 Book 的超链接(a)
+        target_book_btn_xpath = f'xpath://tr[contains(., "{TARGET_SERVICE}")]//a[contains(text(), "Book")]'
+        book_btn = self.page.ele(target_book_btn_xpath)
+
+        if not book_btn:
+            print(f"未找到 {TARGET_SERVICE} 的 Book 按钮,可能当前无号,程序退出。")
+            # 这里可以根据你的逻辑 return 或者 raise Exception
         else:
         else:
-            # 被重定向回 Home 或 Service,说明没号或 Session 过期
-            if "Home" in resp.url or "Login" in resp.url:
-                self.is_healthy = False
-                raise SessionExpiredOrInvalidError("Session expired during query")
-            self._log("Slot Check: No availability (Redirected back)")
-            return res
-
-        # 2. 查询月份 (Query Month)
-        # 默认查询当月,或者配置的月份
-        tar_dates = self.free_config.get("target_dates", [])
-        if not tar_dates:
-            # 默认查下个月
-            next_month = datetime.now().replace(day=28) + datetime.timedelta(days=4)
-            tar_dates = [next_month.strftime("%Y-%m-%d")]
-
-        all_slots = []
-        
-        # Prenotami 需要先 retrieve server info
-        self._perform_request("GET", f"{self._host}/BookingCalendar/RetrieveServerInfo")
-
-        for date_str in tar_dates:
-            # 构造月份格式 2026-01-05 -> 2026-01-01 (API 需要)
-            try:
-                dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
-            except:
-                try:
-                    dt = datetime.strptime(date_str, "%Y-%m-%d")
-                except:
-                    dt = datetime.now()
-            
-            # API 需要格式: 2025-11-05T... 格式的字符串作为 selectedDay
-            # 实际上 RetrieveCalendarAvailability 只需要由前端日历控件触发的格式
-            
-            # 查询日历 API
-            cal_url = f"{self._host}/BookingCalendar/RetrieveCalendarAvailability"
-            cal_payload = {
-                "_Servizio": str(self._service_id),
-                "selectedDay": date_str # 原样传配置里的 ISO 串
-            }
+            self.mouse.human_click_ele(book_btn)
+            self.page.wait.load_start()
+            time.sleep(3)
+
+
+        # ==========================================
+        # 步骤 2:填写预约表单
+        # ==========================================
+        print("等待预约表单加载...")
+        self.page.wait.eles_loaded('#bookingForm', timeout=10)
+
+        print("1. 选择预约类型...")
+        typeofbooking_ddl = self.page.ele('#typeofbookingddl')
+        # DrissionPage select 方法直接按文本选中,会自动触发网页的 JS 联动
+        typeofbooking_ddl.select('Individual booking')
+        time.sleep(1)
+
+        print("2. 选择到访原因...")
+        reason_ddl = self.page.ele('#ddls_0')
+        reason_ddl.select(REASON_FOR_VISIT)
+        time.sleep(1)
+
+        print("3. 填写备注...")
+        notes_input = self.page.ele('#BookingNotes')
+        notes_input.clear()
+        notes_input.input('N/A') # 选填,填入 N/A 或者留空
+        time.sleep(1)
+
+        # ==========================================
+        # 步骤 3:处理 OTP 验证码
+        # ==========================================
+        print("4. 点击发送 OTP 验证码...")
+        otp_send_btn = self.page.ele('#otp-send')
+        self.mouse.human_click_ele(otp_send_btn)
+
+        # 这是一个 AJAX 请求,会转圈圈。我们需要等待成功提示出现
+        print("等待验证码发送成功的提示...")
+        self.page.wait.ele_displayed('#IdOtpSent', timeout=15) # 等待绿字 "New code sent!" 显示
+        print("验证码已发送!")
+
+        print("5. 填写默认验证码 123456 ...")
+        otp_input = self.page.ele('#otp-input')
+        self.mouse.human_click_ele(otp_input)
+        otp_input.input('123456')
+        time.sleep(1)
+
+        # ==========================================
+        # 步骤 4:勾选隐私政策并提交
+        # ==========================================
+        print("6. 勾选隐私政策...")
+        privacy_checkbox = self.page.ele('#PrivacyCheck')
+        # 判断一下如果没勾上才去点,防止重复点击取消了
+        if not privacy_checkbox.states.is_checked:
+            self.mouse.human_click_ele(privacy_checkbox)
+        time.sleep(1)
+
+        print("7. 点击 Forward 提交表单...")
+        forward_btn = self.page.ele('#btnAvanti')
+        self.mouse.human_click_ele(forward_btn)
+
+        # 提交后页面会跳转,等待加载开始
+        self.page.wait.load_start()
+        print("表单已提交!当前页面:", self.page.url)
+
+        time.sleep(5)
+
+        valid_dates = []
             
             
-            resp_cal = self._perform_request("POST", cal_url, json_data=cal_payload)
-            
-            if resp_cal.status_code != 200: continue
-            
-            # 解析有效日期
-            valid_days = self._parse_valid_days(resp_cal.text)
-            self._log(f"Valid days for {date_str}: {valid_days}")
-            
-            if valid_dates:
-                res.success = True
-                res.availability_status = AvailabilityStatus.Available
-                earliest_date = valid_dates[0]
-                earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
-                res.earliest_date = earliest_dt
-                for day in valid_days:
-                    # 查询具体 Slot
-                    slot_url = f"{self._host}/BookingCalendar/RetrieveTimeSlots"
-                    slot_payload = {
-                        "selectedDay": day, # YYYY-MM-DD
-                        "idService": str(self._service_id)
-                    }
-                    resp_slot = self._perform_request("POST", slot_url, json_data=slot_payload)
-                    
-                    time_slots = self._parse_time_slots(resp_slot.text)
-                    ts_list = []
-                    if time_slots:
-                        # 转换结构
-                        for ts in time_slots:
-                            # ts: {'id': 123, 'start': '10:00', 'end': '10:30', 'remain': 1}
-                            ts_list.append(TimeSlot(
-                                time=f"{ts['start']} - {ts['end']}",
-                                label=str(ts['id']) # 将 ID 存入 label 以便 book 使用
-                            ))
-                    res.availability.append(DateAvailability(date=datetime.strptime(day, "%d-%m-%Y"), times=ts_list))
+        if valid_dates:
+            res.success = True
+            res.availability_status = AvailabilityStatus.Available
+            earliest_date = valid_dates[0]
+            earliest_dt = datetime.strptime(earliest_date, "%Y-%m-%d")
+            res.earliest_date = earliest_dt
+            for day in valid_days:
+                # 查询具体 Slot
+                slot_url = f"{self._host}/BookingCalendar/RetrieveTimeSlots"
+                slot_payload = {
+                    "selectedDay": day, # YYYY-MM-DD
+                    "idService": str(self._service_id)
+                }
+                resp_slot = self._perform_request("POST", slot_url, json_data=slot_payload)
+                
+                time_slots = self._parse_time_slots(resp_slot.text)
+                ts_list = []
+                if time_slots:
+                    # 转换结构
+                    for ts in time_slots:
+                        # ts: {'id': 123, 'start': '10:00', 'end': '10:30', 'remain': 1}
+                        ts_list.append(TimeSlot(
+                            time=f"{ts['start']} - {ts['end']}",
+                            label=str(ts['id']) # 将 ID 存入 label 以便 book 使用
+                        ))
+                res.availability.append(DateAvailability(date=datetime.strptime(day, "%d-%m-%Y"), times=ts_list))
         return res
         return res
 
 
     # -------------------------------------------------------------
     # -------------------------------------------------------------
@@ -507,77 +642,6 @@ class ItaPlugin(IVSPlg):
             self._log(f"Final Booking Failed: {resp_final.status_code}")
             self._log(f"Final Booking Failed: {resp_final.status_code}")
             
             
         return res
         return res
-
-    # -------------------------------------------------------------
-    # 4. Helpers
-    # -------------------------------------------------------------
-    
-    def _get_proxy_url(self):
-            # 构造代理
-        proxy_url = ""
-        if self.config.proxy.ip:
-            s = self.config.proxy
-            if s.username:
-                proxy_url = f"{s.proto}://{s.username}:{s.password}@{s.ip}:{s.port}"
-            else:
-                proxy_url = f"{s.proto}://{s.ip}:{s.port}"
-        return proxy_url
-
-    def _solve_and_inject_prenotami_captcha(self):
-        """
-        专门处理 Prenotami 的 ReCaptcha Enterprise
-        """
-        self._log("Solving ReCaptcha Enterprise (Action: LOGIN)...")
-        
-        api_token = self.free_config.get("capsolver_key", "")
-        if not api_token:
-            raise BizLogicError("Capsolver Key is required for Prenotami")
-
-        # 从 HTML 源码中提取的信息
-        site_key = "6LdkwrIqAAAAAC4NX-g_j7lEx9vh1rg94ZL2cFfY"
-        page_url = self.page.url
-        
-        # 注意:Prenotami 的这个 Key 其实是混合模式,
-        # 虽然它是 V3 (Enterprise),但很多打码平台用 V2 接口也能解,或者必须用 V3 Enterprise 接口
-        # 建议先尝试 ReCaptchaV3EnterpriseTaskProxyLess
-        
-        # 修正为最标准的 V3 Enterprise 配置
-        rc_params = {
-            "type": "ReCaptchaV3EnterpriseTaskProxyless",
-            "page": page_url,
-            "siteKey": site_key,
-            "action": "LOGIN", # 关键参数
-            "minScore": 0.7,   # 要求高分
-            "apiToken": api_token,
-            # "proxy": self._get_proxy_url()
-        }
-        
-        g_token = self._solve_recaptcha(rc_params)
-        self._log(f"Captcha Solved. Token length: {len(g_token)}")
-        
-        hook_js = f"""
-            // 1. 填充隐藏域 (双重保险)
-            var input = document.getElementById('g-recaptcha-response');
-            if(input) {{
-                input.value = "{g_token}";
-            }}
-
-            // 2. 劫持 grecaptcha.execute 和 grecaptcha.enterprise.execute
-            // 无论网页用哪个版本,都拦截下来
-            var mockExecute = function() {{
-                console.log("Recaptcha execution intercepted!");
-                return Promise.resolve("{g_token}");
-            }};
-
-            if (window.grecaptcha) {{
-                window.grecaptcha.execute = mockExecute;
-                if (window.grecaptcha.enterprise) {{
-                    window.grecaptcha.enterprise.execute = mockExecute;
-                }}
-            }}
-        """
-        self._log("Injecting ReCaptcha Hook...")
-        self.page.run_js(hook_js) 
     
     
     def _perform_request(self, method, url, headers=None, data=None, json_data=None):
     def _perform_request(self, method, url, headers=None, data=None, json_data=None):
         """JS Fetch Wrapper"""
         """JS Fetch Wrapper"""
@@ -604,77 +668,6 @@ class ItaPlugin(IVSPlg):
         """
         """
         return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
         return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
 
 
-    def _solve_recaptcha(self, params) -> str:
-        """
-        调用 YesCaptcha API 识别
-        """
-        client_key = params.get("apiToken")
-        
-        # 1. 选择任务类型
-        # 根据文档:RecaptchaV3TaskProxylessM1S7 强制 0.7 分,适合登录
-        task_type = "RecaptchaV3TaskProxyless" # 默认
-        if params.get("minScore") == 0.7:
-            task_type = "RecaptchaV3TaskProxylessM1S7"
-        elif params.get("minScore") == 0.9:
-            task_type = "RecaptchaV3TaskProxylessM1S9"
-            
-        # 2. 构造创建任务请求
-        create_url = "https://api.yescaptcha.com/createTask"
-        create_data = {
-            "clientKey": client_key,
-            "task": {
-                "type": task_type,
-                "websiteURL": params.get("page"),
-                "websiteKey": params.get("siteKey"),
-                "pageAction": params.get("action") # YesCaptcha 要求的字段名是 pageAction
-            }
-        }
-        
-        import requests as req
-        try:
-            # 发送创建任务请求
-            r = req.post(create_url, json=create_data, timeout=20)
-            if r.status_code != 200:
-                raise BizLogicError(f"YesCaptcha Create Failed: {r.text}")
-            
-            res_json = r.json()
-            if res_json.get("errorId") != 0:
-                raise BizLogicError(f"YesCaptcha Error: {res_json.get('errorDescription')}")
-                
-            task_id = res_json.get("taskId")
-            if not task_id:
-                raise BizLogicError("YesCaptcha returned no taskId")
-            
-            # 3. 轮询获取结果
-            result_url = "https://api.yescaptcha.com/getTaskResult"
-            for _ in range(30): # 最多等 60-90秒
-                time.sleep(3)
-                
-                r = req.post(result_url, json={"clientKey": client_key, "taskId": task_id}, timeout=20)
-                d = r.json()
-                
-                # 识别中
-                if d.get("status") == "processing":
-                    continue
-                
-                # 识别成功
-                if d.get("status") == "ready":
-                    solution = d.get("solution", {})
-                    token = solution.get("gRecaptchaResponse")
-                    if token:
-                        return token
-                    else:
-                        raise BizLogicError("YesCaptcha ready but no token found")
-                
-                # 识别失败
-                if d.get("errorId") != 0:
-                    raise BizLogicError(f"YesCaptcha Task Failed: {d.get('errorDescription')}")
-                    
-        except Exception as e:
-            raise BizLogicError(f"Captcha Solver Exception: {e}")
-            
-        raise BizLogicError("YesCaptcha timeout")
-
     def _parse_valid_days(self, text):
     def _parse_valid_days(self, text):
         # 提取 DateLibere (YYYY-MM-DD)
         # 提取 DateLibere (YYYY-MM-DD)
         # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}
         # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}

+ 23 - 9
plugins/tls_plugin.py

@@ -300,7 +300,7 @@ class TlsPlugin(IVSPlg):
         init_y = random.randint(10, viewport_height - 10)
         init_y = random.randint(10, viewport_height - 10)
         self.mouse.move(init_x, init_y) 
         self.mouse.move(init_x, init_y) 
 
 
-        max_steps = 10
+        max_steps = 15
         stuck_counter = 0
         stuck_counter = 0
         last_url = ""
         last_url = ""
         session_created = False
         session_created = False
@@ -373,6 +373,11 @@ class TlsPlugin(IVSPlg):
                 time.sleep(3)
                 time.sleep(3)
                 continue
                 continue
             
             
+            if "waitting room" in current_title:
+                cf_bypasser.handle_waiting_room()
+                time.sleep(3)
+                continue
+            
             # 如果语言不匹配, 切换语言到英语
             # 如果语言不匹配, 切换语言到英语
             matched_lang = next((lang for lang in other_langs if lang in current_url), None)
             matched_lang = next((lang for lang in other_langs if lang in current_url), None)
             if matched_lang:
             if matched_lang:
@@ -418,7 +423,7 @@ class TlsPlugin(IVSPlg):
                 raise BizLogicError(message="No applicant added. Cannot proceed to booking.") 
                 raise BizLogicError(message="No applicant added. Cannot proceed to booking.") 
             
             
             # 首页/登录入口页 -> 需要点击进入登录
             # 首页/登录入口页 -> 需要点击进入登录
-            if current_url == tls_url:
+            if tls_url in current_url:
                 if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
                 if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
                     self._log("State: Login Portal. Clicking login link...")
                     self._log("State: Login Portal. Clicking login link...")
                     login_link = self.page.ele("tag:a@@href:login")
                     login_link = self.page.ele("tag:a@@href:login")
@@ -580,7 +585,7 @@ class TlsPlugin(IVSPlg):
                     TimeSlot(time=s["time"], label=str(s.get("label", "")))
                     TimeSlot(time=s["time"], label=str(s.get("label", "")))
                 )
                 )
             res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
             res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
-            self._log(f"Slot Found! size={len(slots)}")
+            self._log(f"Slot Found! earliest_date={earliest_date}, size={len(slots)}")
         else:
         else:
             self._log("No slots available.")
             self._log("No slots available.")
             res.success = False
             res.success = False
@@ -803,21 +808,30 @@ class TlsPlugin(IVSPlg):
         
         
         if resp.status_code == 200:
         if resp.status_code == 200:
             return resp
             return resp
+            
         elif resp.status_code == 401:
         elif resp.status_code == 401:
             self.is_healthy = False
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
             raise SessionExpiredOrInvalidError()
-        elif resp.status_code == 403:
+            
+        # 将 403 和 429 合并,共享绕盾重试逻辑
+        elif resp.status_code in (403, 429):
             if retry_count < 2:
             if retry_count < 2:
-                self._log(f"HTTP 403 Detected. Cloudflare session expired? Attempting refresh (Try {retry_count+1}/2)...")
+                # 动态打印是 403 还是 429
+                self._log(f"HTTP {resp.status_code} Detected. Cloudflare block or rate limit? Attempting refresh (Try {retry_count+1}/2)...")
                 if self._refresh_firewall_session():
                 if self._refresh_firewall_session():
                     self._log("Firewall session refreshed. Retrying request...")
                     self._log("Firewall session refreshed. Retrying request...")
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                 else:
                 else:
                     self._log("Failed to refresh firewall session.")    
                     self._log("Failed to refresh firewall session.")    
-            raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
-        elif resp.status_code == 429:
-            self.is_healthy = False
-            raise RateLimiteddError()
+            
+            # 如果重试 2 次仍然失败,或者刷新防火墙失败,则按原来的逻辑抛出各自的异常
+            if resp.status_code == 403:
+                raise PermissionDeniedError(f"HTTP 403: {resp.text[:100]}")
+            else:
+                self.is_healthy = False
+                # 注意:保留了原代码中 RateLimiteddError 的拼写(双d)
+                raise RateLimiteddError(f"HTTP 429: {resp.text[:100]}")
+                
         else:
         else:
             if resp.status_code == 0:
             if resp.status_code == 0:
                  raise BizLogicError(f"Network Error: {resp.text}")
                  raise BizLogicError(f"Network Error: {resp.text}")

+ 598 - 0
plugins/usa_plugin.py

@@ -0,0 +1,598 @@
+import time
+import json
+import random
+import re
+import os
+import uuid
+import shutil
+import socket
+from datetime import date, datetime
+from typing import List, Dict, Optional, Any, Callable
+from urllib.parse import urljoin, urlparse, urlencode, parse_qs
+from concurrent.futures import ThreadPoolExecutor
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+import configure
+from vs_plg import IVSPlg
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.mihomo_tunnel import MihomoTunnel
+from utils.mouse import HumanMouse
+from utils.keyboard import HumanKeyboard
+from utils.fingerprint_utils import FingerprintGenerator
+from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+
+
+class BrowserResponse:
+    """模拟 requests.Response"""
+    def __init__(self, result_dict):
+        result_dict = result_dict or {}
+        self.status_code = result_dict.get('status', 0)
+        self.text = result_dict.get('body', '')
+        self.headers = result_dict.get('headers', {})
+        self.url = result_dict.get('url', '')
+        self._json = None
+
+    def json(self):
+        if self._json is None:
+            if not self.text:
+                return {}
+            try:
+                self._json = json.loads(self.text)
+            except:
+                self._json = {}
+        return self._json
+
+
+class UsaPlugin(IVSPlg):
+    
+    LOCATIONS = {
+        "SHANGHAI": {"name": "SHANGHAI", "id": "096bf614-b0db-ec11-a7b4-001dd80234f6"},
+        "WUHAN": {"name": "WUHAN", "id": "7b6af614-b0db-ec11-a7b4-001dd80234f6"},
+        "SHENYANG": {"name": "SHENYANG", "id": "0f6bf614-b0db-ec11-a7b4-001dd80234f6"},
+    }
+
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        self.is_healthy = True
+        self.logger = None
+        
+        self.mouse = None
+        self.keyboard = None
+        self.page: Optional[ChromiumPage] = None
+        
+        self.instance_id = uuid.uuid4().hex[:8]
+        self.root_workspace = os.path.abspath(os.path.join("data/temp_browser_data", f"{self.group_id}.{self.instance_id}"))
+        self.user_data_path = os.path.join(self.root_workspace, "user_data")
+        
+        if not os.path.exists(self.root_workspace):
+            os.makedirs(self.root_workspace)     
+
+        self.tunnel = None
+        self.session_create_time: float = 0
+
+    def _log(self, message):
+        if self.logger:
+            self.logger(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
+        else:
+            print(f'[UsaPlugin] [{self.group_id}] [{self.instance_id}] {message}')
+
+    def _random_sleep(self, min_sec=30, max_sec=60):
+        """
+        核心防限速控制:模拟人类阅读和操作的长时间停顿,确保网络请求间隔在30-60秒
+        """
+        sleep_time = random.uniform(min_sec, max_sec)
+        self._log(f"Anti-Rate-Limit: Sleeping for {sleep_time:.2f} seconds...")
+        time.sleep(sleep_time)
+
+    def _get_free_port(self):
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            s.bind(('', 0))
+            return s.getsockname()[1]
+
+    def set_config(self, config: VSPlgConfig):
+        """设置 API 的配置信息"""
+        self.config = config
+        self.free_config = config.free_config or {}
+
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        """设置日志输出工具"""
+        self.logger = logger
+        
+    def keep_alive(self):
+        pass
+
+    def health_check(self) -> bool:
+        if not self.is_healthy:
+            return False
+        if self.page is None:
+            return False
+        try:
+            if not self.page.run_js("return 1;"):
+                return False
+        except:
+            return False
+            
+        if self.config.session_max_life > 0:
+            current_time = time.time()
+            elapsed_time = current_time - self.session_create_time
+            if elapsed_time > self.config.session_max_life:
+                self._log(f"Session expired.")
+                return False
+        return True
+    
+    def _save_screenshot(self, name_prefix):
+        try:
+            timestamp = int(time.time())
+            filename = f"{self.instance_id}_{name_prefix}_{timestamp}.jpg"
+            save_path = os.path.join("data", filename)
+            os.makedirs("data", exist_ok=True)
+            self.page.get_screenshot(path=save_path, full_page=False)
+            self._log(f"Screenshot saved to {save_path}")
+        except Exception as e:
+            self._log(f"Failed to save screenshot: {e}")
+
+    def create_session(self) -> None:
+        """创建一个新的会话 (包含初始化浏览器、过CF验证和执行登录)"""
+        
+        self._log(f"Initializing Session (ID: {self.instance_id})...")
+
+        def get_free_port():
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', 0))
+                return s.getsockname()[1]
+        
+        co = ChromiumOptions()
+        debug_port = get_free_port()
+        self._log(f"Assigned Debug Port: {debug_port}")
+        self._log(f"Account id={self.config.account.id}, proxy id={self.config.proxy.id}")
+        
+        co.set_local_port(debug_port)       
+        co.set_user_data_path(self.user_data_path)
+        
+        chrome_path = configure.CHROME_PATH
+        if not chrome_path:
+            chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+        
+        if self.config.proxy and self.config.proxy.ip:
+            p = self.config.proxy
+            if p.username and p.password:
+                self._log(f"Starting Proxy Tunnel for {p.ip}...")
+                exit_node = {
+                    "name": "ExitNode",
+                    "type": p.proto,
+                    "server": p.ip,
+                    "port": p.port,
+                    "username": p.username,
+                    "password": p.password
+                }
+                relay_node = None
+                if configure.MIHOMO_RELAY_NODES:
+                    relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
+                mihomo_path = configure.MIHOMO_BIN_PATH
+                if not mihomo_path:
+                    mihomo_path = os.getenv("MIHOMO_BIN")
+                if not mihomo_path:
+                    raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
+                self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
+                local_proxy = self.tunnel.start()
+                self._log(f"Tunnel started at {local_proxy}")
+                co.set_argument(f'--proxy-server={local_proxy}')
+            else:
+                proxy_str = f"{p.proto}://{p.ip}:{p.port}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
+
+        specific_fp = FingerprintGenerator().generate(self.config.account.username)
+        fp_seed = specific_fp.get("seed")
+        fp_platform = specific_fp.get("platform")
+        fp_brand = specific_fp.get("brand")
+        self._log(f'browser fingerprint seed={fp_seed}')
+        
+        co.headless(False)
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-dev-shm-usage')
+        co.set_argument('--window-size=1920,1080')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+        co.set_argument(f"--fingerprint={fp_seed}")
+        co.set_argument(f"--fingerprint-platform={fp_platform}")
+        co.set_argument(f"--fingerprint-brand={fp_brand}")
+        
+        self.page = ChromiumPage(co)
+        # 获取基础 URL
+        usa_url = self.free_config.get('usa_url', '') 
+        self._log(f"Navigating: {usa_url}")
+        self.page.get(usa_url)
+        self._random_sleep(3, 5)
+        
+        if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
+            self._log(f'Block by cloudflare, try refresh...')
+            self.page.refresh()
+            self.page.wait.doc_loaded()
+        
+        cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
+        if not cf_bypasser.bypass(max_retry=6):
+            raise BizLogicError("Cloudflare bypass timeout")
+        self._random_sleep(3, 5) 
+        cf_bypasser.handle_waiting_room()
+        
+        self._log("Init humanize tools...")
+        self.mouse = HumanMouse(self.page, debug=False)
+        self.keyboard = HumanKeyboard(self.page)
+        
+        viewport_width = self.page.rect.viewport_size[0]
+        viewport_height = self.page.rect.viewport_size[1]
+        init_x = random.randint(10, viewport_width - 10)
+        init_y = random.randint(10, viewport_height - 10)
+        self.mouse.move(init_x, init_y) 
+        
+        username = self.config.account.username
+        password = self.config.account.password
+        security = self.free_config.get('security', {})
+
+        max_steps = 15 # 由于状态多,步数可以稍微调大一点
+        stuck_counter = 0
+        last_url = ""
+        session_created = False
+        has_submitted_login = False
+        
+        for step in range(max_steps):
+            self.page.wait.doc_loaded()
+            time.sleep(1) # 这个用于等待页面DOM渲染,保留短时,因为不是发请求
+            
+            current_url = self.page.url
+            current_title = self.page.title.lower()
+            current_html_content = self.page.html
+            self._log(f"--- [Router Step {step+1}] Current URL: {current_url} ---")
+            
+            # --- [异常处理层] ---
+            if current_url == last_url:
+                stuck_counter += 1
+            else:
+                last_url = current_url
+                stuck_counter = 0
+            if stuck_counter >= 3:
+                self._log("[WARN] Page stucked, try to refresh...")
+                self.page.refresh()
+                self._random_sleep(30, 40)
+                stuck_counter = 0
+                continue
+            
+            # 网络出现故障,直接重试
+            server_error_indicators = ["502 Bad Gateway", "503 Service Temporarily Unavailable"]
+            if any(err in current_html_content for err in server_error_indicators):
+                self._log(f"[WARN] Server network error, try to refresh (Step: {step})...")
+                self.page.refresh()
+                self._random_sleep(30, 40)
+                continue
+            
+            cloudflare_blocked_indicators = [
+                "Sorry, you have been blocked" in current_html_content,
+                "You are being rate limited" in current_html_content,
+                "Cloudflare Ray ID" in current_html_content
+            ]
+            if any(cloudflare_blocked_indicators):
+                raise BizLogicError(message="Blocked by Cloudflare WAF. Need to change IP or browser fingerprint.")
+            
+            # 遇到五秒盾先绕盾
+            if "just a moment" in current_title:
+                if not cf_bypasser.bypass(max_retry=3):
+                    continue
+                self._random_sleep(3, 5)
+                cf_bypasser.handle_waiting_room()
+                continue
+            
+            if self.page.ele('#post_select', timeout=1):
+                self._log("🎉 Successfully reached the Slot Search page (Target Page). Session created successfully!")                
+                self.session_create_time = time.time()
+                session_created = True
+                break
+
+            # 状态 2: 密保问题页面
+            elif self.page.ele('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]', timeout=1):
+                self._log("[State] Security question verification detected. Filling in answers...")
+                answer_eles = self.page.eles('xpath://input[starts-with(@id, "kba") and contains(@id, "_response")]')
+                for ans_ele in answer_eles:
+                    ele_id = ans_ele.attr('id')
+                    match = re.search(r'kba(\d+)_response', ele_id)
+                    if match:
+                        q_num = match.group(1)
+                        config_key = f"{q_num}_quest"
+                        q_data = security.get(config_key)
+                        ans_text = q_data.get('a')
+                        self.mouse.human_click_ele(ans_ele)
+                        self.keyboard.type_text(ans_text, humanize=True)
+                        self._log(f"-> Find input {ele_id}, successfully filled in the answer for question {q_num}.")
+                        
+                continue_btn = self.page.ele('#continue')
+                self.mouse.human_click_ele(continue_btn)
+                self._log("Security answers submitted. Waiting for redirection...")
+                self._random_sleep(30, 40) 
+                continue
+
+            # 状态 1: 登录页面
+            elif self.page.ele('#signInName', timeout=1):
+                self._log("[State] Login page detected. Submitting credentials...")
+                username_selector = '#signInName'
+                username_input = self.page.ele(username_selector)
+                self.mouse.human_click_ele(username_input)
+                username_input.clear()
+                self.keyboard.type_text(username, humanize=True)
+                self._random_sleep(3, 5) 
+                
+                password_selector = '#password'
+                password_input = self.page.ele(password_selector)
+                self.mouse.human_click_ele(password_input)
+                password_input.clear()
+                self.keyboard.type_text(password, humanize=True)
+                self._random_sleep(3, 5) 
+                
+                continue_btn_selector = '#continue'
+                continue_btn = self.page.ele(continue_btn_selector)
+                self.mouse.human_click_ele(continue_btn)                
+                has_submitted_login = True
+                self._log("Login form submitted. Waiting for the next step to load...")
+                self._random_sleep(30, 40)
+                continue
+            
+            elif self.page.ele('#continue_application', timeout=1):
+                schedule_btn = self.page.ele('#continue_application', timeout=0.5)
+                self._log("Currently in first-time booking mode, clicking to proceed...")
+                self.mouse.human_click_ele(schedule_btn)
+                self._random_sleep(30, 40)
+            
+
+            # 状态 3: 预约主页(控制台) -> 选择首签或改签
+            elif self.page.ele('#atlas-sidebar', timeout=1):
+                self._log("[State] At the main booking dashboard. Looking for navigation button...")
+                reschedule_btn_selector = '#reschedule_appointment'
+                schedule_btn_selector = '#schedule_appointment'
+                reschedule_btn = self.page.ele(reschedule_btn_selector, timeout=0.5)
+                if reschedule_btn:
+                    self._log("Currently in rescheduling mode, clicking to proceed...")
+                    self.mouse.human_click_ele(reschedule_btn)
+                    self._random_sleep(30, 40)
+                else:
+                    schedule_btn = self.page.ele(schedule_btn_selector, timeout=0.5)
+                    if schedule_btn:
+                        self._log("Currently in first-time booking mode, clicking to proceed...")
+                        self.mouse.human_click_ele(schedule_btn)
+                        self._random_sleep(30, 40)
+                    else:
+                        self._log("Not found schedule or reschedule button. The page may still be loading...")
+                        time.sleep(2)
+                continue
+
+            else:
+                self._log("[State] In unknown or transitional state. Waiting for next polling cycle...")
+                time.sleep(2)
+                
+        if not session_created:
+            raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
+
+    def query(self, apt_type: AppointmentType) -> VSQueryResult:
+        """查询可用的签证预约信息"""
+        self._log("Querying available slots...")
+        res = VSQueryResult()
+        res.success = False
+        
+        self.page.refresh()
+        
+        if "just a moment" in self.page.title:
+            cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
+            if not cf_bypasser.bypass(max_retry=5):
+                raise BizLogicError("Cloudflare bypass timeout")
+            self._random_sleep(3, 5)
+            cf_bypasser.handle_waiting_room()
+        
+        current_url = self.page.url.lower()
+        if 'auth' in current_url or 'login' in current_url:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError()
+        
+        applicant = self.free_config.get('applicant')
+        location_name = self.free_config.get('location')
+        location_id = self.LOCATIONS.get(location_name.upper(), {}).get('id')
+
+        # 2. 等待页面元素
+        self.page.ele(f"xpath://label[text()='{applicant}']", timeout=60)
+        post_select = self.page.ele('#post_select', timeout=60)
+        
+        # ==================== 新增:网络监听逻辑 ====================
+        
+        # 开启监听目标 API
+        target_api = 'get-family-consular-schedule-days'
+        self.page.listen.start(target_api)
+        
+        # 3. 选择领事馆 (此操作会触发上述 API 的 AJAX 请求)
+        self.page.ele(f"xpath://select[@id='post_select']/option[@value='{location_id}']", timeout=60)
+        post_select.select.by_value(location_id) 
+        
+        # 4. 等待拦截 API 响应 (设置超时时间)
+        self._log("Waiting for schedule dates API response...")
+        packet = self.page.listen.wait(timeout=30)
+        self.page.listen.stop()
+        
+        if not packet:
+            raise BizLogicError("Timeout waiting for schedule API response")
+            
+        status_code = packet.response.status
+        raw_resp = packet.response.raw_body
+        self._log(f"API Response Status: {status_code}")
+        
+        # 处理 HTTP 返回码不是200的情况
+        if status_code != 200:
+            if status_code == 403:
+                raise PermissionDeniedError(f"HTTP 403: {raw_resp[:512]}")
+            if status_code == 429:
+                self.is_healthy = False
+                raise RateLimiteddError(f"HTTP 429: {raw_resp[:512]}")
+            raise BizLogicError(f"HTTP {status_code} error. resp={raw_resp[0:512]}")
+
+        # 5. 解析返回的数据
+        available_dates = []
+        match = re.search(r'(\{.*\})', raw_resp, re.DOTALL)
+        if match:
+            json_str = match.group(1)
+            data = json.loads(json_str)
+        else:
+            data = json.loads(raw_resp)
+
+        schedule_days = data.get("ScheduleDays", [])
+        if schedule_days:
+            for day_obj in schedule_days:
+                date_str = day_obj.get("Date")
+                if date_str:
+                    available_dates.append(date_str)
+
+        # 6. 处理最终结果 (保持与你原有返回结构一致)
+        if available_dates:
+            # 确保日期是有序的
+            available_dates.sort()
+            
+            res.success = True
+            res.availability_status = AvailabilityStatus.Available
+            earliest_date = available_dates[0]
+            res.earliest_date = datetime.strptime(earliest_date, "%Y-%m-%d")
+            res.availability = [
+                DateAvailability(date=datetime.strptime(d, "%Y-%m-%d"), times=[])
+                for d in available_dates
+            ]
+            self._log(f"Slot Found! earliest_date={earliest_date}, size={len(available_dates)}")
+        else:
+            res.success = False
+            res.availability_status = AvailabilityStatus.NoneAvailable
+            self._log("No slots available.")
+            
+        return res
+
+    def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
+        """进行预约操作"""
+        res = VSBookResult()
+        res.success = False
+        
+        exp_start = user_inputs.get('expected_date_start', '')
+        exp_end = user_inputs.get('expected_date_end', '')
+
+        available_dates_str =[
+            da.date.strftime("%Y-%m-%d")
+            for da in slot_info.availability if da.date
+        ]
+        
+        valid_dates_list = self._filter_dates(available_dates_str, exp_start, exp_end)
+        if not valid_dates_list:
+            raise NotFoundError(message="No dates match user constraints")
+        
+        selected_slot_date = random.choice(valid_dates_list)
+        book_date_obj = datetime.strptime(selected_slot_date, "%Y-%m-%d").date()
+        
+        # jQuery UI Datepicker 月份是 0-11
+        target_day = str(book_date_obj.day)
+        target_month = str(book_date_obj.month - 1) 
+        target_year = str(book_date_obj.year)
+        
+        self._log(f"Target booking date: {selected_slot_date}. Navigating calendar...")
+        
+        # 1. 适配不在当前日历的情况:通过下拉框选择年份和月份
+        year_select = self.page.ele('.ui-datepicker-year', timeout=10)  # 查找年份下拉框
+        if year_select and year_select.value != target_year:
+            self._log(f"Changing year to {target_year}")
+            year_select.select.by_value(target_year)
+            self._random_sleep(0.5, 1)
+
+        month_select = self.page.ele('.ui-datepicker-month', timeout=10) # 查找月份下拉框
+        if month_select and month_select.value != target_month:
+            self._log(f"Changing month to {target_month}")
+            month_select.select.by_value(target_month)
+            self._random_sleep(0.5, 1)
+
+        # 2. 点击目标日期
+        self._log(f"Clicking date {selected_slot_date}...")
+        target_cell_selector = f"xpath://td[@data-year='{target_year}' and @data-month='{target_month}']//a[text()='{target_day}']"
+        
+        target_date_cell = self.page.ele(target_cell_selector, timeout=10)
+        if not target_date_cell:
+             raise BizLogicError(f"Target date element not found for {selected_slot_date} after navigating.")
+             
+        self.mouse.human_click_ele(target_date_cell)
+        
+        self._log("Waiting for available times to load...")
+        self._random_sleep(3, 5)
+
+        # 3. 等待并选择时间
+        self._log("Selecting earliest available time...")
+        slot_time_selector = "css:#time_select input[name='schedule-entries']"
+        first_time_radio = self.page.ele(slot_time_selector, timeout=15)
+        if not first_time_radio:
+            raise BizLogicError("Failed to load time slots after clicking date. Might be rate limited or slots gone.")
+            
+        selected_slot_time = first_time_radio.parent().text.strip()
+        self.mouse.human_click_ele(first_time_radio)
+        
+        # 4. 点击提交
+        self._random_sleep(1, 2)
+        self._log("Submitting booking...")
+        submit_appointment_selector = "#submitbtn"
+        submit_button = self.page.ele(submit_appointment_selector, timeout=10)
+        self.mouse.human_click_ele(submit_button)
+        self._log("Booking submitted successfully!")
+
+        # 构造返回结果
+        res = VSBookResult()
+        res.success = True
+        res.book_date = selected_slot_date
+        res.book_time = selected_slot_time
+        res.account = self.config.account.username
+        return res
+            
+    def _filter_dates(self, dates: List[str], start_str: str, end_str: str) -> List[str]:
+        if not start_str or not end_str:
+            return dates
+        valid_dates = []
+        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
+    
+    # --- 资源清理核心方法 ---
+    def cleanup(self):
+        """
+        销毁浏览器并彻底删除临时文件
+        """
+        if self.page:
+            try:
+                self.page.quit(force=True)
+            except Exception:
+                pass
+            self.page = None
+        
+        if os.path.exists(self.root_workspace):
+            for _ in range(3):
+                try:
+                    time.sleep(0.2)
+                    shutil.rmtree(self.root_workspace, ignore_errors=True)
+                    break
+                except Exception as e:
+                    self._log(f"Cleanup retry: {e}")
+                    time.sleep(0.5)
+            
+            if os.path.exists(self.root_workspace):
+                 self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+        if self.tunnel:
+            try: self.tunnel.stop()
+            except: pass
+            self.tunnel = None
+        
+    def __del__(self):
+        """
+        析构函数:当对象被垃圾回收时自动调用
+        """
+        self.cleanup()

+ 4 - 2
utils/cloudflare_bypass_for_scraping.py

@@ -108,8 +108,10 @@ class CloudflareBypasser:
         while True:
         while True:
             try:
             try:
                 html = self.driver.html.lower()
                 html = self.driver.html.lower()
-                if "file d'attente" in html or "waiting room" in html:
-                    if time.time() - wait_start > 6 * 60:
+                if "cloudflare" not in html:
+                    break
+                if "file d'attente" in html or "waitting room" in html:
+                    if time.time() - wait_start > 60 * 60:
                         self.log_message("Waiting room timeout (1h).")
                         self.log_message("Waiting room timeout (1h).")
                         break
                         break
                     self.log_message("In Waiting Room... Waiting for auto-refresh.")
                     self.log_message("In Waiting Room... Waiting for auto-refresh.")

+ 1 - 1
vs_plg.py

@@ -54,7 +54,7 @@ class IVSPlg(ABC):
     @abstractmethod
     @abstractmethod
     def keep_alive(self):
     def keep_alive(self):
         """
         """
-        @brief 会话保活, 该函数不允许抛异常
+        @brief 会话保活, 如果会定时调用query则不需要调用该函数, 该函数不允许抛异常
         """
         """
         pass
         pass
 
 

+ 18 - 0
vs_types.py

@@ -134,6 +134,24 @@ class VSProxy(BaseModel):
     port: int = 0
     port: int = 0
     username: str = ""
     username: str = ""
     password: str = ""
     password: str = ""
+    
+class BookerStandaloneConfig(BaseModel):
+    debug: bool = False
+    account: VSAccount = Field(default_factory=VSAccount)
+    proxy_pool: List[str] = Field(default_factory=list)
+    active_time_start: str = "00:00"    # 默认执行起始时间
+    active_time_end: str = "23:59"      # 默认执行结束时间
+    session_max_life: int = 30*60       # 单位 秒
+    query_wait: QueryWaitConfig = Field(default_factory=QueryWaitConfig)
+    login_interval: int = 1800          # 登录间隔
+    plugin_config: PluginConfig = Field(default_factory=PluginConfig)
+    appointment_types: List[AppointmentType] = Field(default_factory=list)
+    user_preferences: Dict[str, Any] = Field(default_factory=dict)
+    free_config: Dict[str, Any] = Field(default_factory=dict)
+
+    @classmethod
+    def from_json(cls, data: Dict[str, Any]) -> "BookerStandaloneConfig":
+        return cls.model_validate(data)
 
 
 class VSPlgConfig(BaseModel):
 class VSPlgConfig(BaseModel):
     debug: bool = False
     debug: bool = False