Hujiarui 2 місяців тому
батько
коміт
796f38478d

+ 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_instance.create_session()
+        self.m_last_login_time = time.time()
+        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

+ 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()

+ 68 - 9
plugins/tls_plugin.py

@@ -300,10 +300,11 @@ class TlsPlugin(IVSPlg):
         init_y = random.randint(10, viewport_height - 10)
         self.mouse.move(init_x, init_y) 
 
-        max_steps = 10 
+        max_steps = 10
+        stuck_counter = 0
+        last_url = ""
         session_created = False
         has_submitted_login = False
-        
         other_langs = ['fr-fr', 'ar-ar', 'cb-ph', 'id-id', 'km-kh', 'mk-mk', 'ru-ru', 'sq-al', 'th-th', 'tl-ph', 'uz-uz', 'vi-vn', 'zh-cn', 'hy-am', 'zh-hk']
         
         for step in range(max_steps):
@@ -315,6 +316,49 @@ class TlsPlugin(IVSPlg):
             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
+            
+             # 在某个页面卡住3轮以上,直接重试
+            if stuck_counter >= 3:
+                self._log("[WARN] Page stucked, try to refresh...")
+                self.page.refresh()
+                self.page.wait.load_start(timeout=5)
+                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})...")
+                time.sleep(2)
+                self.page.refresh()
+                self.page.wait.load_start(timeout=5)
+                continue
+            
+            # 特征1: 必须是 <p> 标签,且包含指定文案 (绝对不会匹配到 <script> 标签里的字典)
+            spa_error_p = self.page.ele('xpath://p[contains(text(), "It looks like something went wrong")]', timeout=0.1)
+            # 特征2: 必须是 <div> 标签,且直接包含 "Error code:" 文本
+            error_code_div = self.page.ele('xpath://div[contains(text(), "Error code:")]', timeout=0.1)
+            if spa_error_p or error_code_div:
+                extracted_code = "Unknown"
+                if error_code_div:
+                    span_ele = error_code_div.ele('tag:span', timeout=0.1)
+                    if span_ele:
+                        extracted_code = span_ele.text
+                self._log(f"[WARN] Frontend application error page detected (Error Code: {extracted_code}). Triggering fallback page refresh (Step: {step})...")                
+                time.sleep(2)
+                self.page.refresh()
+                self.page.wait.load_start(timeout=5)
+                continue
+            
             cloudflare_blocked_indicators = [
                 "Sorry, you have been blocked" in current_html_content,
                 "You are being rate limited" in current_html_content,
@@ -503,6 +547,12 @@ class TlsPlugin(IVSPlg):
             
             self._log("State: Transitioning or Unknown. Waiting 2 seconds...")
             time.sleep(2)
+            
+            # 如果处于某些特定的复杂页面且久久不动,尝试主动刷新
+            if step > 0 and step % 4 == 0: 
+                self._log("[WARN] Unknown state persisted for too long. Attempting fallback refresh...")
+                self.page.refresh()
+                self.page.wait.load_start(timeout=3)
                 
         if not session_created:
             raise BizLogicError(f"Failed to reach appointment-booking after {max_steps} navigation steps. Stuck at: {self.page.url}")
@@ -530,7 +580,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
@@ -753,21 +803,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}")

+ 393 - 0
plugins/usa_plugin.py

@@ -0,0 +1,393 @@
+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(30, 45)
+        
+        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._random_sleep(30, 45) # 刷新动作属于高危操作,加长延时
+            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")
+        # 绕过盾后,休眠一段时间再处理 waiting room
+        self._random_sleep(15, 30) 
+        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, 60) # 刷新操作触发请求,长延时
+                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(45, 60) # 遇到错误必须拉长延时防止被彻底拉黑
+                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:
+                cf_bypasser.bypass(max_retry=3)
+                self._random_sleep(20, 40)
+                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')
+                        ans_ele.input(ans_text)
+                        self._log(f"-> Find input {ele_id}, successfully filled in the answer for question {q_num}.")
+          
+
+                self.page.ele('#continue').click()
+                self._log("Security answers submitted. Waiting for redirection...")
+                # 提交密保问题,触发POST请求,长延时
+                self._random_sleep(30, 60) 
+                continue
+
+            # 状态 1: 登录页面
+            elif self.page.ele('#signInName', timeout=1):
+                self._log("[State] Login page detected. Submitting credentials...")
+                
+                username_input = self.page.ele('#signInName')
+                username_input.clear()
+                username_input.input(username)
+                
+                password_input = self.page.ele('#password')
+                password_input.clear()
+                password_input.input(password)
+                
+                self.page.ele('#continue').click()
+                has_submitted_login = True
+                self._log("Login form submitted. Waiting for the next step to load...")
+                # 提交登录表单,触发POST请求,必须长延时
+                self._random_sleep(30, 60)
+                continue
+
+            # 状态 3: 预约主页(控制台) -> 选择首签或改签
+            elif self.page.ele('#atlas-sidebar', timeout=1):
+                self._log("[State] At the main booking dashboard. Looking for navigation button...")
+                
+                reschedule_btn = self.page.ele('#reschedule_appointment', timeout=0.5)
+                if reschedule_btn:
+                    self._log("-> Detected [Reschedule Appointment]. Currently in rescheduling mode, clicking to proceed...")
+                    reschedule_btn.click()
+                    # 点击导航按钮,触发页面跳转GET请求,长延时
+                    self._random_sleep(30, 50)
+                else:
+                    schedule_btn = self.page.ele('xpath://ul[@id="atlas-sidebar"]//a[text()="安排预约" or text()="New Appointment" or text()="Schedule Appointment"]', timeout=0.5)
+                    if schedule_btn:
+                        self._log("-> Detected [Schedule Appointment]. Currently in first-time booking mode, clicking to proceed...")
+                        schedule_btn.click()
+                        # 点击导航按钮,触发页面跳转GET请求,长延时
+                        self._random_sleep(30, 50)
+                    else:
+                        self._log("-> [WARN] Sidebar found, but no 'Schedule' or 'Reschedule' button detected. The page may still be loading...")
+                        time.sleep(2) # 仅等待DOM渲染,不发请求,保持短时
+                continue
+
+            else:
+                self._log("[State] In unknown or transitional state. No matching UI elements found. Waiting for next polling cycle...")
+                time.sleep(2) # 仅等待DOM渲染,不发请求,保持短时
+                
+        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
+        
+        # 1. 刷新页面以获取最新数据
+        self.page.refresh()
+        # 【关键修改】:刷新操作触发页面重载请求,必须执行长睡眠 
+        self._random_sleep(30, 60)
+        
+        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('#po

+ 1 - 1
plugins/vfs_plugin.py

@@ -355,7 +355,7 @@ class VfsPlugin(IVSPlg):
         try:
             self.real_ip = self._get_realnetwork_ip()
         except:
-            self.real_ip = "0.0.0.0"
+            self.real_ip = self.config.proxy.ip
 
     def query(self, apt_type: AppointmentType) -> VSQueryResult:
         """查询可预约 Slot"""

+ 1 - 1
utils/cloudflare_bypass_for_scraping.py

@@ -109,7 +109,7 @@ class CloudflareBypasser:
             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 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