root 4 nedēļas atpakaļ
vecāks
revīzija
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) 
                     )
                 )
+            success = True
             self._log(f"+++ Built-in Booker spawned: {plg_cfg.account.username}")
         except Exception as 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 os
 import base64
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime
 from typing import List, Dict, Optional, Any, Callable
 from urllib.parse import urlencode, urlparse
 
-# DrissionPage 核心
 from DrissionPage import ChromiumPage, ChromiumOptions
 
+import configure
 from vs_plg import IVSPlg
 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 utils.mouse import HumanMouse
 from utils.keyboard import HumanKeyboard
@@ -53,21 +54,14 @@ class ItaPlugin(IVSPlg):
         
         # Prenotami 特有配置
         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.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
     
@@ -103,19 +97,12 @@ class ItaPlugin(IVSPlg):
                 return False
         return True
 
-    # -------------------------------------------------------------
-    # 1. Create Session (Login)
-    # -------------------------------------------------------------
     def create_session(self):
         """
-        全浏览器登录流程:
-        1. 启动浏览器
-        2. 解决 ReCaptcha
-        3. 登录并维持 Session
+        全浏览器会话创建:过盾 -> JS注入登录 -> 状态机自动路由导航 -> 到达目标页
         """
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         co = ChromiumOptions()
-        
         def get_free_port():
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                 s.bind(('', 0))
@@ -123,115 +110,245 @@ class ItaPlugin(IVSPlg):
         
         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 = 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):
             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}...")
-                
-                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()
-                
                 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!")
 
-        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('--disable-gpu')
         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={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)
-                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
@@ -241,101 +358,119 @@ class ItaPlugin(IVSPlg):
         res.success = False
         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:
-            # 被重定向回 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
 
     # -------------------------------------------------------------
@@ -507,77 +642,6 @@ class ItaPlugin(IVSPlg):
             self._log(f"Final Booking Failed: {resp_final.status_code}")
             
         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):
         """JS Fetch Wrapper"""
@@ -604,77 +668,6 @@ class ItaPlugin(IVSPlg):
         """
         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):
         # 提取 DateLibere (YYYY-MM-DD)
         # 格式: {"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)
         self.mouse.move(init_x, init_y) 
 
-        max_steps = 10
+        max_steps = 15
         stuck_counter = 0
         last_url = ""
         session_created = False
@@ -373,6 +373,11 @@ class TlsPlugin(IVSPlg):
                 time.sleep(3)
                 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)
             if matched_lang:
@@ -418,7 +423,7 @@ class TlsPlugin(IVSPlg):
                 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):
                     self._log("State: Login Portal. Clicking login link...")
                     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", "")))
                 )
             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:
             self._log("No slots available.")
             res.success = False
@@ -803,21 +808,30 @@ class TlsPlugin(IVSPlg):
         
         if resp.status_code == 200:
             return resp
+            
         elif resp.status_code == 401:
             self.is_healthy = False
             raise SessionExpiredOrInvalidError()
-        elif resp.status_code == 403:
+            
+        # 将 403 和 429 合并,共享绕盾重试逻辑
+        elif resp.status_code in (403, 429):
             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():
                     self._log("Firewall session refreshed. Retrying request...")
                     return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
                 else:
                     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:
             if resp.status_code == 0:
                  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:
             try:
                 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).")
                         break
                     self.log_message("In Waiting Room... Waiting for auto-refresh.")

+ 1 - 1
vs_plg.py

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

+ 18 - 0
vs_types.py

@@ -134,6 +134,24 @@ class VSProxy(BaseModel):
     port: int = 0
     username: 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):
     debug: bool = False