Browse Source

feat: update

jerry 3 tháng trước cách đây
mục cha
commit
40130a7d9b

BIN
cf_failed.png


+ 30 - 0
chrome_proxy_auth_plugin/background.js

@@ -0,0 +1,30 @@
+
+    var config = {
+        mode: "fixed_servers",
+        rules: {
+            singleProxy: {
+                scheme: "http",
+                host: "91.193.255.210",
+                port: parseInt(12323)
+            },
+            bypassList: ["localhost"]
+        }
+    };
+
+    chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});
+
+    function callbackFn(details) {
+        return {
+            authCredentials: {
+                username: "14ae212b29a2a",
+                password: "d160bc0854"
+            }
+        };
+    }
+
+    chrome.webRequest.onAuthRequired.addListener(
+        callbackFn,
+        {urls: ["<all_urls>"]},
+        ['blocking']
+    );
+    

+ 20 - 0
chrome_proxy_auth_plugin/manifest.json

@@ -0,0 +1,20 @@
+
+    {
+        "version": "1.0.0",
+        "manifest_version": 2,
+        "name": "Chrome Proxy Auth Extension",
+        "permissions": [
+            "proxy",
+            "tabs",
+            "unlimitedStorage",
+            "storage",
+            "<all_urls>",
+            "webRequest",
+            "webRequestBlocking"
+        ],
+        "background": {
+            "scripts": ["background.js"]
+        },
+        "minimum_chrome_version": "22.0.0"
+    }
+    

+ 1 - 7
config/accounts.json

@@ -495,12 +495,6 @@
             "username":"romicloud@163.com",
             "password": "Visafly@111",
             "lock_until": 0
-        },
-        {
-            "id": 1,
-            "username":"arket_zz@163.com",
-            "password": "Visafly@111",
-            "lock_until": 0
         }
     ],
     "ie_es": [
@@ -511,4 +505,4 @@
             "lock_until": 0
         }
     ]
-}
+}

+ 12 - 12
config/groups.json

@@ -2,7 +2,7 @@
     {
         "identifier": "VFS_IE_NL",
         "debug": false,
-        "enable": true,
+        "enable": false,
         "need_account": true,
         "local_account_pool": "ie_nl",
         "need_proxy": true,
@@ -239,7 +239,7 @@
         "need_account": true,
         "local_account_pool": "gb_nl",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "iproyal",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -255,8 +255,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -351,8 +351,8 @@
     },
     {
         "identifier": "VFS_IE_AT",
-        "debug": true,
-        "enable": true,
+        "debug": false,
+        "enable": false,
         "need_account": true,
         "local_account_pool": "ie_at",
         "need_proxy": true,
@@ -702,15 +702,15 @@
     {
         "identifier": "TLS_GB_FR",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "gb_fr",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "iproyal",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "auto.slot.lon.fr.tourist",
-        "order_account_online_limit": 5,
+        "order_account_online_limit": 0,
         "account_bind_applicant": true,
         "session_max_life": 30,
         "query_wait": {
@@ -721,8 +721,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "tls_plugin",
-            "plugin_bin": "tls_plugin.py",
+            "plugin_name": "tls_plugin2",
+            "plugin_bin": "tls_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -737,7 +737,7 @@
             "visa_type": "Tourist",
             "routing_key": "slot.lon.fr.tourist",
             "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
-            "interest_month": "01-2026",
+            "interest_month": "02-2026",
             "target_labels": [
                 "",
                 "pta"

+ 220 - 38
config/proxies.json

@@ -1,5 +1,5 @@
 {
-    "global_proxies": [
+    "oxylabs": [
         {
             "id": 100003,
             "ip": "disp.oxylabs.io",
@@ -17,56 +17,247 @@
             "port": 8002,
             "scheme": "http",
             "username": "user-visafly_zFNdf"
+        }
+    ],
+    "dc": [
+        {
+            "id": 100001,
+            "ip": "157.22.72.100",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
         },
         {
-            "id": 100029,
-            "ip": "95.135.130.175",
+            "id": 100002,
+            "ip": "193.202.9.27",
             "lock_until": 0,
-            "password": "hmuROCk1FDebCnL",
-            "port": 46247,
+            "password": "o4bQTdjF",
+            "port": 8080,
             "scheme": "http",
-            "username": "GB6o2vBrXFjz4ya"
+            "username": "mix306YSSTWFF"
         },
         {
-            "id": 100030,
-            "ip": "95.135.130.29",
+            "id": 100003,
+            "ip": "45.80.105.101",
             "lock_until": 0,
-            "password": "WmqFTSvRvtxChIT",
-            "port": 43740,
+            "password": "o4bQTdjF",
+            "port": 8080,
             "scheme": "http",
-            "username": "JUcjydi0HKZzWC6"
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100004,
+            "ip": "170.168.240.243",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
         },
         {
-            "id": 100031,
-            "ip": "95.135.130.45",
+            "id": 100005,
+            "ip": "45.140.206.239",
             "lock_until": 0,
-            "password": "QSve9BePC91VxjO",
-            "port": 41537,
-            "scheme": "socks5",
-            "username": "XmigvOGAseidkxG"
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100006,
+            "ip": "157.22.125.162",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100007,
+            "ip": "193.233.89.216",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100008,
+            "ip": "157.22.74.157",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100009,
+            "ip": "170.168.174.129",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100010,
+            "ip": "212.119.43.241",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100011,
+            "ip": "45.80.104.167",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100012,
+            "ip": "45.148.233.17",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100013,
+            "ip": "185.61.223.103",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100014,
+            "ip": "157.22.18.89",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100015,
+            "ip": "212.119.45.239",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
+        },
+        {
+            "id": 100016,
+            "ip": "5.181.170.159",
+            "lock_until": 0,
+            "password": "o4bQTdjF",
+            "port": 8080,
+            "scheme": "http",
+            "username": "mix306YSSTWFF"
         }
     ],
-    "spain_proxies": [
+    "iproyal": [
         {
-            "id": 110029,
-            "ip": "disp.oxylabs.io",
+            "id": 100021,
+            "ip": "89.33.195.129",
             "lock_until": 0,
-            "password": "jYubEsw6~g3z1Uh",
-            "port": 8004,
+            "password": "d160bc0854",
+            "port": 12323,
             "scheme": "http",
-            "username": "user-visafly_zFNdf"
+            "username": "14ae212b29a2a"
         },
         {
-            "id": 110030,
-            "ip": "disp.oxylabs.io",
+            "id": 100022,
+            "ip": "89.33.195.64",
             "lock_until": 0,
-            "password": "jYubEsw6~g3z1Uh",
-            "port": 8005,
+            "password": "d160bc0854",
+            "port": 12323,
             "scheme": "http",
-            "username": "user-visafly_zFNdf"
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100023,
+            "ip": "89.33.195.93",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100024,
+            "ip": "89.33.195.42",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100025,
+            "ip": "95.170.29.126",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100026,
+            "ip": "91.193.255.166",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100027,
+            "ip": "91.193.255.60",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100028,
+            "ip": "91.193.255.210",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100029,
+            "ip": "91.193.255.149",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
+        },
+        {
+            "id": 100030,
+            "ip": "91.193.255.245",
+            "lock_until": 0,
+            "password": "d160bc0854",
+            "port": 12323,
+            "scheme": "http",
+            "username": "14ae212b29a2a"
         }
     ],
-    "ireland_proxies": [
+    "proxy_cheap": [
         {
             "id": 100029,
             "ip": "95.135.130.175",
@@ -84,15 +275,6 @@
             "port": 43740,
             "scheme": "http",
             "username": "JUcjydi0HKZzWC6"
-        },
-        {
-            "id": 100031,
-            "ip": "95.135.130.45",
-            "lock_until": 0,
-            "password": "QSve9BePC91VxjO",
-            "port": 41537,
-            "scheme": "socks5",
-            "username": "XmigvOGAseidkxG"
         }
     ],
     "local": [

+ 208 - 0
drission_request_lib.py

@@ -0,0 +1,208 @@
+import json
+import time
+from urllib.parse import urlparse, urlencode
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+class BrowserResponse:
+    """
+    模拟 requests.Response 对象
+    """
+    def __init__(self, result_dict):
+        # 防止 result_dict 为 None 的保护机制
+        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 {}
+            self._json = json.loads(self.text)
+        return self._json
+
+    @property
+    def content(self):
+        return self.text.encode('utf-8')
+
+class DrissionHttpClient:
+    def __init__(self, proxy=None, headless=False, user_data_dir=None):
+        """
+        初始化浏览器
+        """
+        co = ChromiumOptions()
+        # 自动分配端口
+        co.auto_port()
+        
+        # 模拟配置
+        if proxy:
+            co.set_proxy(proxy)
+        if headless:
+            co.headless(True)
+        if user_data_dir:
+            co.set_user_data_path(user_data_dir)
+        
+        # 优化启动参数
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        # 保持浏览器窗口大小,避免被检测为 headless 尺寸
+        co.set_argument('--window-size=1920,1080')
+
+        self.page = ChromiumPage(co)
+        self.current_domain = None  # 初始化为 None
+
+    def _ensure_domain_context(self, url):
+        """
+        确保浏览器处于目标域名的上下文中
+        """
+        parsed = urlparse(url)
+        target_domain = parsed.netloc
+        
+        # --- [修复点] ---
+        # 1. 检查 self.current_domain 是否为 None
+        # 2. 检查 target_domain 是否在当前 domain 中 (处理子域名)
+        if not self.current_domain or (target_domain and target_domain not in self.current_domain):
+            base_url = f"{parsed.scheme}://{target_domain}"
+            
+            # VFS 特殊优化:直接去 Login 页建立 Session
+            if "vfsglobal" in target_domain:
+                base_url = "https://visa.vfsglobal.com/gbr/en/nld/login"
+            
+            print(f"[Browser] Switching Context -> {base_url}")
+            try:
+                self.page.get(base_url)
+                # 等待 Cloudflare 验证完成 (DrissionPage 会自动处理大部分等待,但强制 sleep 更稳)
+                time.sleep(5) 
+                self.current_domain = target_domain
+            except Exception as e:
+                print(f"[Browser] Warning: Navigation failed: {e}")
+
+    def request(self, method, url, params=None, json_data=None, data=None, headers=None, timeout=30):
+        """
+        执行请求
+        """
+        self._ensure_domain_context(url)
+
+        # 1. 处理 URL 参数
+        if params:
+            if '?' in url:
+                url += '&' + urlencode(params)
+            else:
+                url += '?' + urlencode(params)
+
+        # 2. 构造 JS fetch 选项
+        fetch_options = {
+            "method": method.upper(),
+            "headers": headers or {},
+            "credentials": "include" # 关键:带上 Cookie
+        }
+
+        # 3. 处理 Body
+        if json_data:
+            fetch_options['body'] = json.dumps(json_data)
+            # 这里的 Content-Type 会覆盖 headers 里的
+            fetch_options['headers']['Content-Type'] = 'application/json'
+        elif data:
+            if isinstance(data, dict):
+                fetch_options['body'] = urlencode(data)
+                fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
+            else:
+                fetch_options['body'] = data
+
+        # 4. 注入 JS (使用 await 确保同步返回)
+        # 这里的 return 会被 DrissionPage 捕获并传回 Python
+        js_script = f"""
+        const url = "{url}";
+        const options = {json.dumps(fetch_options)};
+        
+        return fetch(url, options)
+            .then(async response => {{
+                const text = await response.text();
+                const headers = {{}};
+                response.headers.forEach((value, key) => headers[key] = value);
+                
+                return {{
+                    status: response.status,
+                    body: text,
+                    headers: headers,
+                    url: response.url
+                }};
+            }})
+            .catch(error => {{
+                return {{
+                    status: 0,
+                    body: error.toString(),
+                    headers: {{}},
+                    url: url
+                }};
+            }});
+        """
+
+        print(f"[Browser] {method} {url}")
+        
+        # run_js 在 4.0+ 版本可以直接拿到 return 的值
+        try:
+            result = self.page.run_js(js_script, timeout=timeout)
+        except Exception as e:
+            # 发生超时或脚本错误
+            print(f"[Browser] JS Execution Error: {e}")
+            result = {"status": 0, "body": str(e)}
+
+        return BrowserResponse(result)
+
+    def get(self, url, **kwargs):
+        return self.request("GET", url, **kwargs)
+
+    def post(self, url, **kwargs):
+        return self.request("POST", url, **kwargs)
+    
+    def close(self):
+        self.page.quit()
+
+
+# --- 测试入口 ---
+if __name__ == "__main__":
+    # 使用 headless=False 观察浏览器行为
+    client = DrissionHttpClient(headless=False)
+    
+    try:
+        # 1. 登录接口
+        url = "https://lift-api.vfsglobal.com/user/login"
+        
+        # 2. 你的 Payload
+        # 请替换为真实的测试账号,或者随便填触发 401 也可以验证连通性
+        payload = {
+            "username": "test_user",
+            "password": "test_password",
+            "missioncode": "nld",
+            "countrycode": "gbr",
+        }
+
+        # 3. Headers (浏览器会自动处理大部分,这里只加业务头)
+        headers = {
+            "route": "gbr/en/nld",
+            # "clientsource": "...", # 如果需要
+        }
+
+        print("--- Start Request ---")
+        resp = client.post(url, json_data=payload, headers=headers)
+        
+        print(f"\nResponse Status: {resp.status_code}")
+        print(f"Response Body Preview: {resp.text[:200]}")
+        
+        if resp.status_code == 429:
+            print("依然限流,请更换 IP")
+        elif resp.status_code == 0:
+            print("浏览器 Fetch 失败,检查网络或浏览器控制台")
+            
+    except Exception as e:
+        print(f"Main Error: {e}")
+        import traceback
+        traceback.print_exc()
+        
+    finally:
+        # 为了看到结果,暂停一下再关闭
+        time.sleep(5)
+        client.close()

+ 1 - 1
main_server.py

@@ -27,7 +27,7 @@ def main():
     # 后台的 GroupCoordinators 已经在各自的线程里跑了。
     VSC_INFO("main", "Starting Web API on port 8000...")
     try:
-        run_web_server()
+        # run_web_server()
         while True:
             time.sleep(3600)
     except KeyboardInterrupt:

+ 561 - 0
plugins/ita_plugin.py

@@ -0,0 +1,561 @@
+import time
+import json
+import random
+import re
+import os
+import base64
+from datetime import datetime
+from typing import List, Dict, Optional, Any, Callable
+from urllib.parse import urlencode, urlparse
+
+# DrissionPage 核心
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+from vs_plg import IVSPlg
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from toolkit.vs_cloud_api import VSCloudApi
+
+# ==========================================
+# 1. 辅助函数:代理插件 & 响应封装
+# ==========================================
+def create_proxy_auth_extension(ip, port, username, password, plugin_path="./chrome_proxy_auth_plugin"):
+    if not os.path.exists(plugin_path): os.makedirs(plugin_path)
+    manifest_json = """
+    {
+        "version": "1.0.0", "manifest_version": 2, "name": "Chrome Proxy Auth",
+        "permissions": ["proxy", "tabs", "unlimitedStorage", "storage", "<all_urls>", "webRequest", "webRequestBlocking"],
+        "background": {"scripts": ["background.js"]}, "minimum_chrome_version": "22.0.0"
+    }"""
+    background_js = f"""
+    var config = {{mode: "fixed_servers", rules: {{singleProxy: {{scheme: "http", host: "{ip}", port: parseInt({port})}}, bypassList: ["localhost"]}}}};
+    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
+    chrome.webRequest.onAuthRequired.addListener(function(details) {{
+        return {{authCredentials: {{username: "{username}", password: "{password}"}}}};
+    }}, {{urls: ["<all_urls>"]}}, ['blocking']);
+    """
+    with open(os.path.join(plugin_path, "manifest.json"), "w") as f: f.write(manifest_json)
+    with open(os.path.join(plugin_path, "background.js"), "w") as f: f.write(background_js)
+    return os.path.abspath(plugin_path)
+
+class BrowserResponse:
+    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
+
+# ==========================================
+# 2. ItaPlugin 核心逻辑
+# ==========================================
+class ItaPlugin(IVSPlg):
+
+    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.page: Optional[ChromiumPage] = None
+        self.session_create_time: float = 0
+        
+        # Prenotami 特有配置
+        self._service_id = 0 
+        self._host = 'https://prenotami.esteri.it'
+
+    def get_group_id(self) -> str:
+        return self.group_id
+    
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        self.logger = logger
+        
+    def _log(self, message): 
+        if self.logger:
+            self.logger(f'[ItaPlugin] [{self.group_id}] {message}')
+        else:
+            print(f'[ItaPlugin] [{self.group_id}] {message}')
+        
+    def set_config(self, config: VSPlgConfig): 
+        self.config = config
+        self.free_config = config.free_config or {}
+        # Service ID (e.g., 1321 for Ireland, 5059 for Guangzhou)
+        self._service_id = self.free_config.get('service_id', 0)
+
+    def health_check(self) -> bool:
+        if not self.is_healthy or not self.page:
+            return False
+        try:
+            if not self.page.run_js("return 1;"):
+                return False
+        except:
+            return False
+        if self.config.session_max_life > 0:
+            if time.time() - self.session_create_time > self.config.session_max_life * 60:
+                self._log("Session expired.")
+                return False
+        return True
+
+    # -------------------------------------------------------------
+    # 1. Create Session (Login)
+    # -------------------------------------------------------------
+    def create_session(self):
+        """
+        全浏览器登录流程:
+        1. 启动浏览器
+        2. 解决 ReCaptcha
+        3. 登录并维持 Session
+        """
+        self._log("Initializing Browser Session...")
+        co = ChromiumOptions()
+        co.auto_port()
+        
+        if self.config.proxy and self.config.proxy.ip:
+            p = self.config.proxy
+            if p.username and p.password:
+                self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
+                co.add_extension(create_proxy_auth_extension(p.ip, p.port, p.username, p.password))
+            else:
+                co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+
+        co.headless(False) 
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--window-size=1920,1080')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+
+        try:
+            self.page = ChromiumPage(co)
+            
+            login_url = f"{self._host}/Home"
+            self._log(f"Navigating to {login_url}")
+            self.page.get(login_url)
+            
+            # 等待登录框
+            if not self.page.wait.ele_displayed('#login-email', timeout=20):
+                raise BizLogicError("Login page not loaded")
+
+            # 填充用户名密码
+            self.page.ele('#login-email').input(self.config.account.username)
+            self.page.ele('#login-password').input(self.config.account.password)
+            
+            # 解决 ReCaptcha V2
+            self._handle_login_captcha()
+            
+            # 提交登录
+            self._log("Submitting login...")
+            self.page.ele('xpath://*[@id="login-form"]/button').click()
+            
+            # 等待登录成功 (通常会跳转到 /UserArea 或 /Services)
+            time.sleep(3)
+            if "Home" in self.page.url and not self.page.ele('#logoutForm'):
+                 # 检查是否有错误提示
+                 if self.page.ele('.alert-danger'):
+                     err = self.page.ele('.alert-danger').text
+                     raise PermissionDeniedError(f"Login Failed: {err}")
+                 raise BizLogicError("Login Failed: Unknown reason")
+
+            self._log("Login Successful.")
+            
+            # 访问服务列表页以保活
+            self.page.get(f"{self._host}/Services")
+            
+            self.session_create_time = time.time()
+
+        except Exception as e:
+            self._log(f"Create Session Failed: {e}")
+            if self.page:
+                self.page.quit()
+                self.page = None
+            raise e
+
+    def _handle_login_captcha(self):
+        """处理登录页面的 ReCaptcha"""
+        if self.page.ele('#recaptcha-anchor') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
+            self._log("Solving ReCaptcha...")
+            api_token = self.free_config.get("capsolver_key", "")
+            if not api_token:
+                self._log("WARN: No capsolver_key, manual solve required.")
+                time.sleep(5)
+                return
+
+            site_key = "6LdkwrIqAAAAAC4NX-g_j7lEx9vh1rg94ZL2cFfY" # Prenotami Site Key
+            
+            rc_params = {
+                "type": "ReCaptchaV2TaskProxyLess", 
+                "page": self.page.url,
+                "siteKey": site_key, 
+                "apiToken": api_token
+            }
+            g_token = self._solve_recaptcha(rc_params)
+            
+            # 注入 Token
+            js = f"""
+            var el = document.getElementById('g-recaptcha-response');
+            if(el) {{ el.value = "{g_token}"; }}
+            """
+            self.page.run_js(js)
+            self._log("Captcha solved & injected.")
+
+    # -------------------------------------------------------------
+    # 2. Query Availability
+    # -------------------------------------------------------------
+    def query(self) -> VSQueryResult:
+        res = VSQueryResult()
+        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
+        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 串
+            }
+            
+            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}")
+            
+            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)
+                if time_slots:
+                    res.success = True
+                    res.availability_status = AvailabilityStatus.Available
+                    res.earliest_date = day
+                    
+                    # 转换结构
+                    ts_list = []
+                    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=day, times=ts_list))
+        
+        return res
+
+    # -------------------------------------------------------------
+    # 3. Book
+    # -------------------------------------------------------------
+    def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
+        res = VSBookResult()
+        res.success = False
+        
+        if not slot_info.availability:
+            raise NotFoundError("No slots to book")
+            
+        target_date = slot_info.availability[0].date
+        # 取第一个时间段
+        target_slot = slot_info.availability[0].times[0]
+        slot_id = target_slot.label # 我们在 query 里把 ID 存在了 label
+        slot_text = target_slot.time # "10:00 - 10:30"
+        
+        # 1. 获取 OTP (GenerateOTP)
+        self._log("Requesting OTP...")
+        otp_url = f"{self._host}/BookingCalendar/GenerateOTP?ServiceID={self._service_id}"
+        self._perform_request("POST", otp_url)
+        
+        # 2. 等待并读取邮件
+        self._log("Waiting for email code...")
+        time.sleep(10) # 稍微等一下发信
+        email_account = self.config.account.email
+        # 使用 CloudAPI 读取 (假设已配置)
+        otp_code = VSCloudApi.Instance().get_email_verify_code(email_account)
+        
+        if not otp_code:
+            raise BizLogicError("Failed to retrieve OTP code")
+        self._log(f"Got OTP: {otp_code}")
+
+        # 3. 提交详细信息 (Fill User Info)
+        # 这是最复杂的一步,涉及文件上传 (Multipart)
+        self._log("Submitting User Details & Files...")
+        
+        # 准备文件 (转 Base64 传给 JS)
+        passport_pdf_path = user_inputs.get('passport_pdf_path')
+        irp_pdf_path = user_inputs.get('irp_pdf_path')
+        
+        def file_to_b64(path):
+            if not path or not os.path.exists(path): return ""
+            with open(path, "rb") as f:
+                return base64.b64encode(f.read()).decode('utf-8')
+
+        ppt_b64 = file_to_b64(passport_pdf_path)
+        irp_b64 = file_to_b64(irp_pdf_path)
+        
+        # 构造 JS FormData 提交脚本
+        # 注意:这里需要根据 Service ID (Dublin/Canton) 动态调整字段 ID
+        # 下面以 Dublin (1321) 的字段为例,如果是 Canton 需要修改 _Id 和 _TipoDatoAddizionale
+        # 为了通用性,这里演示 Dublin 的结构,请根据实际 Service ID 调整 mapping
+        
+        # 假设是 Dublin (根据提供的源码分析)
+        boundary = '----WebKitFormBoundaryRandomString'
+        submit_url = f"{self._host}/Services/Booking/{self._service_id}"
+        
+        # 注入 JS 执行
+        js_submit = f"""
+        const url = "{submit_url}";
+        const fd = new FormData();
+        
+        // 基础字段
+        fd.append('ServizioDescrizione', 'D Visa Application');
+        fd.append('MessaggioRassicuranteWaitingList', 'True');
+        fd.append('isWaitingListEnabled', 'False');
+        fd.append('IDServizioConsolare', '35');
+        fd.append('IDServizioErogato', '{self._service_id}');
+        fd.append('IdTipoPrenotazione', '1'); // Single
+        fd.append('NumMaxAccompagnatori', '3');
+        fd.append('NumAccompagnatoriSelected', '0');
+        
+        // 动态字段 (Dublin 示例)
+        // [0] Other citizenship -> User Input
+        fd.append('DatiAddizionaliPrenotante[0]._Descrizione', 'Other citizenship/s');
+        fd.append('DatiAddizionaliPrenotante[0]._testo', '{user_inputs.get("citizen", "China")}');
+        fd.append('DatiAddizionaliPrenotante[0]._Obbligatorio', 'False');
+        fd.append('DatiAddizionaliPrenotante[0]._Id', '61738');
+        fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoDatoAddizionale', '26');
+        fd.append('DatiAddizionaliPrenotante[0]._TipoDatoAddizionale.IDTipoControllo', '2');
+        
+        // [1] Full address -> User Input
+        fd.append('DatiAddizionaliPrenotante[1]._Descrizione', 'Full residence address');
+        fd.append('DatiAddizionaliPrenotante[1]._testo', '{user_inputs.get("address", "")}');
+        fd.append('DatiAddizionaliPrenotante[1]._Obbligatorio', 'True');
+        fd.append('DatiAddizionaliPrenotante[1]._Id', '61739');
+        fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoDatoAddizionale', '25');
+        fd.append('DatiAddizionaliPrenotante[1]._TipoDatoAddizionale.IDTipoControllo', '2');
+
+        // [2] Passport Num
+        fd.append('DatiAddizionaliPrenotante[2]._Descrizione', 'Passport number');
+        fd.append('DatiAddizionaliPrenotante[2]._testo', '{user_inputs.get("passport", "")}');
+        fd.append('DatiAddizionaliPrenotante[2]._Obbligatorio', 'True');
+        fd.append('DatiAddizionaliPrenotante[2]._Id', '61740');
+        fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoDatoAddizionale', '2');
+        fd.append('DatiAddizionaliPrenotante[2]._TipoDatoAddizionale.IDTipoControllo', '2');
+
+        // [3] Reason (Select)
+        fd.append('DatiAddizionaliPrenotante[3]._Descrizione', 'Reason for visit');
+        fd.append('DatiAddizionaliPrenotante[3]._Obbligatorio', 'True');
+        fd.append('DatiAddizionaliPrenotante[3]._Id', '61741');
+        fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoDatoAddizionale', '34');
+        fd.append('DatiAddizionaliPrenotante[3]._TipoDatoAddizionale.IDTipoControllo', '3');
+        fd.append('DatiAddizionaliPrenotante[3]._idSelezionato', '42'); // 42 = Tourism? Need verify
+
+        // OTP
+        fd.append('otp-input', '{otp_code}');
+        fd.append('PrivacyCheck', 'true');
+        
+        // 文件处理 (Base64 -> Blob -> FormData)
+        // 注意:这里假设页面上有文件上传的对应 ID,或者我们直接硬编码 FormData
+        // 原始抓包并未显示文件字段名,通常是 File_0, File_1
+        // 我们需要将 base64 转 blob
+        
+        async function addFile(b64, name, filename) {{
+            if(!b64) return;
+            const res = await fetch(`data:application/pdf;base64,${{b64}}`);
+            const blob = await res.blob();
+            fd.append(name, blob, filename);
+        }}
+        
+        // 并行处理文件
+        await Promise.all([
+            addFile('{ppt_b64}', 'File_0', 'passport.pdf'), // 假设 File_0 是护照
+            addFile('{irp_b64}', 'File_1', 'irp.pdf')       // 假设 File_1 是 IRP
+        ]);
+
+        // 发送 POST
+        return fetch(url, {{
+            method: 'POST',
+            body: fd
+        }}).then(async r => {{
+            return {{ status: r.status, url: r.url, text: await r.text() }};
+        }}).catch(e => {{ return {{ status: 0, text: e.toString() }}; }});
+        """
+        
+        result_dict = self.page.run_js(js_submit)
+        resp = BrowserResponse(result_dict)
+        
+        if resp.status_code == 302 or "BookingCalendar" in resp.url:
+            self._log("User Info Submitted Successfully.")
+        else:
+            self._log(f"User Info Submit Failed: {resp.text[:100]}")
+            # 如果 OTP 错误,页面会返回特定错误信息
+            if "Codice errato" in resp.text:
+                raise BizLogicError("Invalid OTP Code")
+            return res # Fail
+
+        # 4. 最终确认预约 (InsertNewBooking)
+        self._log("Finalizing Booking...")
+        final_url = f"{self._host}/BookingCalendar/InsertNewBooking"
+        final_payload = {
+            "idCalendarioGiornaliero": slot_id,
+            "selectedDay": target_date,
+            "selectedHour": slot_text # "10:00 - 10:30(2)"
+        }
+        # 这里用 Form-UrlEncoded
+        resp_final = self._perform_request("POST", final_url, data=final_payload)
+        
+        if resp_final.status_code == 200:
+            self._log("Booking Confirmed!")
+            res.success = True
+            res.book_date = target_date
+            res.book_time = slot_text
+        else:
+            self._log(f"Final Booking Failed: {resp_final.status_code}")
+            
+        return res
+
+    # -------------------------------------------------------------
+    # 4. Helpers
+    # -------------------------------------------------------------
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None):
+        """JS Fetch Wrapper"""
+        if not self.page: raise BizLogicError("Browser not init")
+        
+        fetch_opts = { "method": method.upper(), "headers": headers or {}, "credentials": "include" }
+        if json_data:
+            fetch_opts['body'] = json.dumps(json_data)
+            fetch_opts['headers']['Content-Type'] = 'application/json; charset=UTF-8'
+        elif data:
+            if isinstance(data, dict):
+                from urllib.parse import urlencode
+                fetch_opts['body'] = urlencode(data)
+                fetch_opts['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
+            else:
+                fetch_opts['body'] = data
+
+        js = f"""
+        return fetch("{url}", {json.dumps(fetch_opts)})
+        .then(async r => {{
+            const h = {{}}; r.headers.forEach((v, k) => h[k] = v);
+            return {{ status: r.status, body: await r.text(), headers: h, url: r.url }};
+        }}).catch(e => {{ return {{ status: 0, body: e.toString() }}; }});
+        """
+        return BrowserResponse(self.page.run_js(js, timeout=60)) # 文件上传可能较慢,给60s
+
+    def _solve_recaptcha(self, params) -> str:
+        # 复用通用的 Capsolver 逻辑
+        key = params.get("apiToken")
+        import requests as req
+        task = { "type": params.get("type"), "websiteURL": params.get("page"), "websiteKey": params.get("siteKey") }
+        r = req.post("https://api.capsolver.com/createTask", json={"clientKey": key, "task": task}, timeout=20)
+        if r.status_code != 200: raise BizLogicError("Capsolver submit failed")
+        tid = r.json().get("taskId")
+        for _ in range(20):
+            r = req.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": tid}, timeout=20)
+            if r.status_code == 200 and r.json().get("status") == "ready":
+                return r.json()["solution"]["gRecaptchaResponse"]
+            time.sleep(3)
+        raise BizLogicError("Capsolver timeout")
+
+    def _parse_valid_days(self, text):
+        # 提取 DateLibere (YYYY-MM-DD)
+        # 格式: {"DateLibere":"22/10/2024 00:00:00","SlotLiberi":1,"SlotRimanenti":1}
+        # 原始正则: r'{"DateLibere":"(.*?)","SlotLiberi":\d+,"SlotRimanenti":(-?\d+)}'
+        days = []
+        try:
+            matches = re.findall(r'{"DateLibere":"(.*?)".*?"SlotRimanenti":(-?\d+)}', text)
+            for d_str, rem in matches:
+                if int(rem) != -1:
+                    # 22/10/2024 -> 2024-10-22
+                    dt = datetime.strptime(d_str[:10], "%d/%m/%Y")
+                    days.append(dt.strftime("%Y-%m-%d"))
+        except: pass
+        return days
+
+    def _parse_time_slots(self, text):
+        # 提取 IDCalendarioServizioGiornaliero, StartTime, EndTime, Remain
+        slots = []
+        try:
+            # 原始逻辑比较复杂,这里简化正则
+            # 查找 SlotRimanenti > 0 的记录
+            # 关键是 IDCalendarioServizioGiornaliero
+            raw_list = json.loads(text)
+            # Prenotami 返回的是一个 JSON 列表字符串
+            for item in raw_list:
+                remain = item.get('SlotRimanenti', -1)
+                if remain > 0:
+                    start = item['OrarioInizioFascia']
+                    end = item['OrarioFineFascia']
+                    s_time = f"{start['Hours']:02d}:{start['Minutes']:02d}"
+                    e_time = f"{end['Hours']:02d}:{end['Minutes']:02d}"
+                    slots.append({
+                        'id': item['IDCalendarioServizioGiornaliero'],
+                        'start': s_time,
+                        'end': e_time,
+                        'remain': remain
+                    })
+        except: pass
+        return slots

+ 675 - 0
plugins/tls_plugin2.py

@@ -0,0 +1,675 @@
+import time
+import json
+import random
+import re
+import os
+from datetime import datetime
+from typing import List, Dict, Optional, Any, Callable
+from urllib.parse import urljoin, urlparse, urlencode
+
+# DrissionPage 核心
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+from vs_plg import IVSPlg
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.vs_cloud_api import VSCloudApi
+
+# --- 辅助函数:创建代理插件 ---
+def create_proxy_auth_extension(ip, port, username, password, plugin_path="./chrome_proxy_auth_plugin"):
+    if not os.path.exists(plugin_path):
+        os.makedirs(plugin_path)
+
+    manifest_json = """
+    {
+        "version": "1.0.0",
+        "manifest_version": 2,
+        "name": "Chrome Proxy Auth Extension",
+        "permissions": ["proxy", "tabs", "unlimitedStorage", "storage", "<all_urls>", "webRequest", "webRequestBlocking"],
+        "background": {"scripts": ["background.js"]},
+        "minimum_chrome_version": "22.0.0"
+    }
+    """
+    background_js = f"""
+    var config = {{
+        mode: "fixed_servers",
+        rules: {{
+            singleProxy: {{scheme: "http", host: "{ip}", port: parseInt({port})}},
+            bypassList: ["localhost"]
+        }}
+    }};
+    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
+    function callbackFn(details) {{
+        return {{authCredentials: {{username: "{username}", password: "{password}"}}}};
+    }}
+    chrome.webRequest.onAuthRequired.addListener(
+        callbackFn, {{urls: ["<all_urls>"]}}, ['blocking']
+    );
+    """
+    with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
+        f.write(manifest_json)
+    with open(os.path.join(plugin_path, "background.js"), "w") as f:
+        f.write(background_js)
+    return os.path.abspath(plugin_path)
+
+
+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 TlsPlugin2(IVSPlg):
+    """
+    TLSContact 签证预约插件 (DrissionPage 版)
+    """
+
+    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.page: Optional[ChromiumPage] = None
+        
+        self.travel_group: Optional[Dict] = None
+        self.session_create_time: float = 0
+        self.real_ip: str = "0.0.0.0"
+
+    def get_group_id(self) -> str:
+        return self.group_id
+    
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        self.logger = logger
+    
+    def _log(self, message):
+        if self.logger:
+            self.logger(f'[TlsPlugin] [{self.group_id}] {message}')
+        else:
+            print(f'[TlsPlugin] [{self.group_id}] {message}')
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        self.free_config = config.free_config or {}
+
+    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 * 60:
+                self._log(f"Session expired.")
+                return False
+        return True
+
+    def create_session(self):
+        """
+        全浏览器会话创建:过盾 -> JS注入登录 -> 原生跳转
+        """
+        self._log("Initializing Browser Session (Full Browser Mode)...")
+        co = ChromiumOptions()
+        co.auto_port()
+        
+        if self.config.proxy and self.config.proxy.ip:
+            p = self.config.proxy
+            if p.username and p.password:
+                self._log(f"Proxy: {p.ip}:{p.port} (Auth)")
+                co.add_extension(create_proxy_auth_extension(p.ip, p.port, p.username, p.password))
+            else:
+                co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+
+        co.headless(False)
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--disable-blink-features=AutomationControlled')
+
+        try:
+            self.page = ChromiumPage(co)
+            
+            embassy = self.free_config.get('center', {})
+            if not embassy: raise NotFoundError("center config missing")
+
+            login_url = "https://visas-fr.tlscontact.com/en-us/login"
+            params = {
+                "issuerId": embassy["code"], "country": embassy["country"], "vac": embassy["code"],
+                "redirect": f"/en-us/country/{embassy['country']}/vac/{embassy['code']}"
+            }
+            full_login_url = f"{login_url}?{urlencode(params)}"
+            
+            self._log(f"Navigating: {full_login_url}")
+            self.page.get(full_login_url)
+            
+            # --- Cloudflare 过盾 ---
+            cf = CloudflareBypasser(self.page, log=self.config.debug)
+            if not cf.bypass(max_retry=15):
+                raise BizLogicError("Cloudflare bypass timeout")
+
+            # --- 登录页面检查 ---
+            if not self.page.ele('#email-input-field'):
+                self._log("Reloading Login Page...")
+                self.page.get(full_login_url)
+                if not self.page.wait.ele_displayed('#email-input-field', timeout=15):
+                    raise BizLogicError("Login form not loaded")
+
+            # --- JS 注入登录 ---
+            g_token = ""
+            if self.page.ele('.g-recaptcha') or self.page.ele('xpath://iframe[contains(@src, "recaptcha")]'):
+                self._log("Solving ReCaptcha...")
+                rc_params = {
+                    "type": "ReCaptchaV2TaskProxyLess", "page": self.page.url,
+                    "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0", 
+                    "apiToken": self.free_config.get("capsolver_key", "")
+                }
+                g_token = self._solve_recaptcha(rc_params)
+
+            username = self.config.account.username
+            password = self.config.account.password
+            
+            # 使用 JS 直接操作 DOM 并 click,让浏览器处理 302
+            js_login = f"""
+            var u = document.getElementById('email-input-field');
+            if(u) {{ u.value = "{username}"; u.dispatchEvent(new Event('input', {{bubbles:true}})); }}
+            
+            var p = document.getElementById('password-input-field');
+            if(p) {{ p.value = "{password}"; p.dispatchEvent(new Event('input', {{bubbles:true}})); }}
+            
+            var g = document.getElementById('g-recaptcha-response');
+            if(g) {{ g.value = "{g_token}"; }}
+            
+            var btn = document.getElementById('btn-login');
+            if(btn) {{ btn.click(); return true; }} else {{ return false; }}
+            """
+            
+            self._log("Submitting Login via JS...")
+            if not self.page.run_js(js_login): raise BizLogicError("Login button missing")
+
+            # --- 等待跳转 ---
+            self._log("Waiting for redirect...")
+            self.page.wait.url_change('login-actions', exclude=True, timeout=45)
+            
+            # 检查是否失败
+            if "login-actions" in self.page.url or "auth" in self.page.url:
+                err = "Unknown Login Error"
+                if "Invalid username" in self.page.html: err = "Invalid Credentials"
+                raise BizLogicError(f"Login Failed: {err}")
+
+            # --- 提取 Dashboard 信息 ---
+            self._log("Waiting for dashboard...")
+            self.page.wait.load_start()
+            time.sleep(5)
+            
+            html = self.page.html
+            self._check_page_is_session_expired_or_invalid("My travel group", html)
+            groups = self._parse_travel_groups(html)
+            
+            target_city = embassy['city'].lower()
+            for g in groups:
+                if g['location'].lower() == target_city:
+                    self.travel_group = g
+                    break
+            
+            if not self.travel_group: raise NotFoundError(f"Group not found for {target_city}")
+            
+            self.session_create_time = time.time()
+            self.real_ip = self._get_realnetwork_ip()
+            self._log(f"Session Ready. Group: {self.travel_group['group_number']}")
+
+        except Exception as e:
+            self._log(f"Session Create Error: {e}")
+            if self.page: self.page.quit(); self.page = None
+            raise e
+
+    def query(self) -> VSQueryResult:
+        res = VSQueryResult()
+        res.success = False
+        
+        embassy = self.free_config.get('center', {})
+        group_num = self.travel_group['group_number']
+        interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
+        
+        url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
+        params = {
+            'location': embassy["code"],
+            'month': interest_month,
+        }
+        
+        # DrissionPage 自动处理 Cloudflare,直接 fetch 即可
+        try:
+            resp = self._perform_request("GET", url, params=params, retry_count=1)
+        except Exception as e:
+            self._log(f"Query request failed: {e}")
+            raise e
+
+        self._check_page_is_session_expired_or_invalid('Book your appointment', resp.text)
+
+        # 解析 Slots
+        all_slots = self._parse_appointment_slots(resp.text)
+
+        target_labels = self.free_config.get("target_labels", ["", "pta"])
+        # 根据配置过滤
+        available = [s for s in all_slots if s.get("label") in target_labels]
+
+        res.city = self.free_config.get('city', '')
+        res.country = self.free_config.get('country', '')
+        res.visa_type = self.free_config.get('visa_type', '')
+        res.routing_key = self.free_config.get('routing_key', '')
+        
+        if available:
+            res.success = True
+            res.availability_status = AvailabilityStatus.Available
+            res.earliest_date = available[0]["date"]
+            date_map: dict[str, list[TimeSlot]] = {}
+            for s in available:
+                d = s["date"]
+                date_map.setdefault(d, []).append(
+                    TimeSlot(time=s["time"], label=str(s.get("label", "")))
+                )
+            res.availability = [DateAvailability(date=d, times=slots) for d, slots in date_map.items()]
+        else:
+            res.success = False
+            res.availability_status = AvailabilityStatus.NoneAvailable
+        return res
+
+    def book(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
+        res = VSBookResult()
+        res.success = False
+        
+        embassy = self.free_config.get('center', {})
+        group_num = self.travel_group['group_number']
+        
+        available_dates = [da.date for da in slot_info.availability]
+        exp_start = user_inputs.get('expected_start_date', '')
+        exp_end = user_inputs.get('expected_end_date', '')
+        support_pta = user_inputs.get('support_pta', True)
+
+        target_labels = ['']
+        if support_pta:
+            target_labels.append('pta')
+        
+        valid_dates = self._filter_dates(available_dates, exp_start, exp_end)
+        if not valid_dates:
+            raise NotFoundError(message="No dates match user constraints")
+        
+        selected_date = None
+        selected_time = None
+        selected_label = None
+        
+        for d in valid_dates:
+            for da in slot_info.availability:
+                if da.date == d:
+                    for t in da.times:
+                        if t.label in target_labels:
+                            selected_date = d
+                            selected_time = t
+                            selected_label = t.label
+                            break
+            if selected_date: break
+            
+        if not selected_date:
+             raise NotFoundError(message="No suitable slot found")
+
+        # 2. 解决 ReCaptcha V3 (Action: book)
+        page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={embassy["code"]}&month={selected_date[:7]}'
+        
+        api_token = self.free_config.get("capsolver_key", "")
+        rc_params = {
+            "type": "ReCaptchaV3Task",
+            "page": page_url,
+            "action": "book", 
+            "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
+            "apiToken": api_token,
+            # "proxy": self._get_proxy_url() # ProxyLess
+        }
+        g_token = self._solve_recaptcha(rc_params)
+
+        # 3. 构造 Next.js Payload
+        # 注意:在 JS 中构造 FormData 比在 Python 中拼 Multipart 更容易且不易出错
+        ACTION_ID = "60d0616946df1fc4e7c094ca6a7a04f134d0be3d53"
+        url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
+        
+        # State Tree 字符串
+        router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%5B%22lang%22%2C%22en-us%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%5B%22groupId%22%2C%22'+str(group_num)+'%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22workflow%22%2C%7B%22children%22%3A%5B%22appointment-booking%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D'
+
+        # 构造 JS 代码执行 fetch
+        # 使用 FormData 对象来处理 multipart
+        js_script = f"""
+        const url = "{url}";
+        const formData = new FormData();
+        
+        formData.append('1_formGroupId', '{group_num}');
+        formData.append('1_lang', 'en-us');
+        formData.append('1_process', 'APPOINTMENT');
+        formData.append('1_location', '{embassy["code"]}');
+        formData.append('1_date', '{selected_date}');
+        formData.append('1_time', '{selected_time.time}');
+        formData.append('1_appointmentLabel', '{selected_label}');
+        formData.append('1_captcha_token', '{g_token}');
+        formData.append('0', '[{{"status":"IDLE"}},"$K1"]');
+        
+        const headers = {{
+            'Next-Action': '{ACTION_ID}',
+            'Next-Router-State-Tree': decodeURIComponent('{router_state}'),
+            'Accept': 'text/x-component'
+        }};
+        
+        return fetch(url, {{
+            method: 'POST',
+            headers: headers,
+            body: formData
+        }}).then(async response => {{
+            const text = await response.text();
+            const headers = {{}};
+            response.headers.forEach((value, key) => headers[key] = value);
+            return {{
+                status: response.status,
+                body: text,
+                headers: headers,
+                url: response.url
+            }};
+        }}).catch(err => {{
+            return {{ status: 0, body: err.toString(), headers: {{}}, url: url }};
+        }});
+        """
+        
+        self._log("Submitting booking request via JS Fetch...")
+        res_dict = self.page.run_js(js_script)
+        resp = BrowserResponse(res_dict)
+
+        # 4. 结果判定
+        # Next.js Server Action 重定向通常是 303,但 fetch 可能会自动跟随
+        # 如果 fetch 跟随了,url 会变;如果没跟随(Redirect mode: manual),status 是 303
+        
+        if resp.status_code == 303 or (resp.status_code == 200 and "appointment-confirmation" in resp.url):
+             self._log(f"Booking Success! URL: {resp.url}")
+             res.success = True
+             res.book_date = selected_date
+             res.book_time = selected_time
+             return res
+
+        if resp.status_code == 200:
+            if "APPOINTMENT_LIMIT_REACHED" in resp.text:
+                 self._log("Failed: Appointment Limit Reached")
+            elif "Invalid captcha" in resp.text:
+                 self._log("Failed: Invalid Captcha")
+            else:
+                 self._log(f"Booking Failed (Unknown 200): {resp.text[:200]}")
+        else:
+            self._log(f"Booking Failed. Status: {resp.status_code}")
+
+        return res
+
+    # --- 辅助方法 ---
+
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
+        """
+        在浏览器上下文中注入 JS 执行 Fetch
+        """
+        if not self.page:
+            raise BizLogicError("Browser not initialized")
+
+        if params:
+            from urllib.parse import urlencode
+            if '?' in url:
+                url += '&' + urlencode(params)
+            else:
+                url += '?' + urlencode(params)
+
+        fetch_options = {
+            "method": method.upper(),
+            "headers": headers or {},
+            "credentials": "include"
+        }
+
+        # Body 处理
+        if json_data:
+            fetch_options['body'] = json.dumps(json_data)
+            fetch_options['headers']['Content-Type'] = 'application/json'
+        elif data:
+             if isinstance(data, dict):
+                from urllib.parse import urlencode
+                fetch_options['body'] = urlencode(data)
+                fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
+             else:
+                 fetch_options['body'] = data
+
+        js_script = f"""
+        const url = "{url}";
+        const options = {json.dumps(fetch_options)};
+        
+        return fetch(url, options)
+            .then(async response => {{
+                const text = await response.text();
+                const headers = {{}};
+                response.headers.forEach((value, key) => headers[key] = value);
+                
+                return {{
+                    status: response.status,
+                    body: text,
+                    headers: headers,
+                    url: response.url
+                }};
+            }})
+            .catch(error => {{
+                return {{
+                    status: 0,
+                    body: error.toString(),
+                    headers: {{}},
+                    url: url
+                }};
+            }});
+        """
+        
+        res_dict = self.page.run_js(js_script, timeout=30)
+        resp = BrowserResponse(res_dict)
+        
+        if resp.status_code == 200:
+            return resp
+        elif resp.status_code == 401:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError()
+        elif resp.status_code == 403:
+            # [关键修改] 遇到 403 Forbidden,尝试绕盾并重试
+            # 最多重试 2 次
+            if retry_count < 2:
+                self._log(f"HTTP 403 Detected. Cloudflare session expired? 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()
+        else:
+             # 如果是 0,可能是 fetch 报错
+            if resp.status_code == 0:
+                 raise BizLogicError(f"Network Error: {resp.text}")
+            # TLS 业务错误
+            raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
+        
+
+    def _refresh_firewall_session(self) -> bool:
+        """
+        主动刷新页面以触发 Cloudflare 挑战并尝试通过
+        """
+        try:
+            # 1. 刷新当前页面 (通常 Dashboard 页)
+            # 这会强制浏览器重新进行 HTTP 请求,从而触发 Cloudflare 拦截页
+            self._log("Refreshing page to trigger Cloudflare...")
+            self.page.refresh()
+            
+            # 2. 调用 CloudflareBypasser
+            cf = CloudflareBypasser(self.page, log=self.config.debug)
+            
+            # 3. 尝试过盾 (尝试次数稍多一点,因为此时可能网络不稳定)
+            success = cf.bypass(max_retry=10)
+            
+            if success:
+                # 再次确认页面是否正常加载 (非 403 页面)
+                title = self.page.title.lower()
+                if "access denied" in title:
+                    return False
+                
+                # 等待 DOM 稍微稳定
+                time.sleep(2)
+                return True
+            
+            return False
+        except Exception as e:
+            self._log(f"Error during firewall refresh: {e}")
+            return False
+
+    def _get_realnetwork_ip(self):
+        """新标签页获取 IP,规避 CORS"""
+        try:
+            tab = self.page.new_tab("https://api.ipify.org/?format=json")
+            if tab.ele('tag:pre'):
+                json_text = tab.ele('tag:pre').text
+            else:
+                json_text = tab.ele('tag:body').text
+            ip = json.loads(json_text)['ip']
+            tab.close()
+            return ip
+        except Exception:
+            # 尝试清理
+            try:
+                if self.page.tabs_count > 1: self.page.close_tabs(self.page.tabs[-1])
+            except: pass
+            return "0.0.0.0"
+
+    def _solve_recaptcha(self, params) -> str:
+        """调用 VSCloudApi 解决 ReCaptcha"""
+        key = params.get("apiToken")
+        if not key: raise NotFoundError("Api-token required")
+        
+        submit_url = "https://api.capsolver.com/createTask"
+        task = {
+            "type": params.get("type"),
+            "websiteURL": params.get("page"),
+            "websiteKey": params.get("siteKey"),
+        }
+        if params.get("action"):
+            task["pageAction"] = params.get("action")
+            
+        # 注意:使用 DrissionPage 后,通常是 ProxyLess 模式
+        # 除非你想让 Capsolver 也用同样的代理(通常不需要,除非风控极严)
+        
+        payload = {"clientKey": key, "task": task}
+        import requests as req # 局部引用,避免混淆
+        r = req.post(submit_url, json=payload, timeout=20)
+        if r.status_code != 200:
+            raise BizLogicError(message="Failed to submit capsolver task")
+        
+        task_id = r.json().get("taskId")
+        for _ in range(20):
+            r = req.post("https://api.capsolver.com/getTaskResult", json={"clientKey": key, "taskId": task_id}, timeout=20)
+            if r.status_code == 200:
+                d = r.json()
+                if d.get("status") == "ready":
+                    return d["solution"]["gRecaptchaResponse"]
+            time.sleep(3)
+        raise BizLogicError(message="Capsolver task timeout")
+
+    def _parse_travel_groups(self, html: str) -> List[Dict]:
+        groups = []
+        js_pattern = r'\\"travelGroups\\":\s*(\[.*?\]),\\"availableCountriesToCreateGroups'
+        js_match = re.search(js_pattern, html, re.DOTALL)
+        if js_match:
+            json_str = js_match.group(1).replace(r'\"', '"')
+            data = json.loads(json_str)
+            for g in data:
+                groups.append({
+                    'group_name': g.get('groupName'),
+                    'group_number': g.get('formGroupId'),
+                    'location': g.get('vacName')
+                })
+        else:
+            self._log('Parsed travel group page, but not found travelGroups')
+        return groups
+
+    def _parse_appointment_slots(self, html: str) -> List[Dict]:
+        slots = []
+        pattern = r'"availableAppointments\\":\s*(\[.*\]),\\"showFlexiAppointment'
+        match = re.search(pattern, html, re.DOTALL)
+        
+        if match:
+            json_str = match.group(1).replace(r'\"', '"')
+            data = json.loads(json_str)
+            for day in data:
+                d_str = day.get('day')
+                for s in day.get('slots', []):
+                    labels = s.get('labels', [])
+                    lbl = ""
+                    # 简化逻辑:TLS label 列表
+                    if 'pta' in labels: lbl = 'pta'
+                    elif 'ptaw' in labels: lbl = 'ptaw'
+                    elif '' in labels or not labels: lbl = ''
+                    
+                    slots.append({
+                        'date': d_str,
+                        'time': s.get('time'),
+                        'label': lbl
+                    })
+        return slots
+  
+    def _check_page_is_session_expired_or_invalid(self, keyword, html: str) -> bool:
+        if not html:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError()
+        
+        # 将 html 转小写检查
+        html_lower = html.lower()
+        if keyword.lower() not in html_lower: 
+            if 'redirected automatically' in html_lower:
+                self.is_healthy = False
+                raise SessionExpiredOrInvalidError("Redirected automatically")
+            if 'login' in html_lower and 'password' in html_lower:
+                self.is_healthy = False
+                raise SessionExpiredOrInvalidError("Redirected to login")
+            if 'session expired' in html_lower:
+                self.is_healthy = False
+                raise SessionExpiredOrInvalidError("Session expired")
+            
+    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

+ 30 - 3
plugins/vfs_plugin.py

@@ -30,10 +30,37 @@ t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
 GQIDAQAB
 -----END PUBLIC KEY-----"""
 
+
 COUNTRY_MAP = {
-    "china": "CHN", "france": "FRA", "germany": "DEU", "italy": "ITA", 
-    "united kingdom": "GBR", "united states": "USA", "india": "IND",
-    "russia": "RUS", "turkey": "TUR", "vietnam": "VNM"
+    "afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND",  "angola": "AGO",
+    "antigua and barbuda": "ATG", "argentina": "ARG", "armenia": "ARM", "australia": "AUS", "austria": "AUT",
+    "azerbaijan": "AZE", "bahamas": "BHS", "bahrain": "BHR", "bangladesh": "BGD", "barbados": "BRB", "belarus": "BLR",
+    "belgium": "BEL", "belize": "BLZ", "benin": "BEN", "bhutan": "BTN", "bolivia": "BOL", "bosnia and herzegovina": "BIH",
+    "botswana": "BWA", "brazil": "BRA", "brunei": "BRN", "bulgaria": "BGR", "burkina faso": "BFA", "burundi": "BDI",
+    "cabo verde": "CPV", "cambodia": "KHM", "cameroon": "CMR", "canada": "CAN", "central african republic": "CAF",
+    "chad": "TCD", "chile": "CHL", "china": "CHN", "colombia": "COL", "comoros": "COM", "congo (brazzaville)": "COG",
+    "congo (kinshasa)": "COD", "costa rica": "CRI", "croatia": "HRV", "cuba": "CUB", "cyprus": "CYP", "czech republic": "CZE",
+    "denmark": "DNK", "djibouti": "DJI", "dominica": "DMA", "dominican republic": "DOM", "ecuador": "ECU", "egypt": "EGY",
+    "el salvador": "SLV", "equatorial guinea": "GNQ", "eritrea": "ERI", "estonia": "EST", "eswatini": "SWZ", "ethiopia": "ETH",
+    "fiji": "FJI", "finland": "FIN", "france": "FRA", "gabon": "GAB", "gambia": "GMB", "georgia": "GEO", "germany": "DEU",
+    "ghana": "GHA", "greece": "GRC", "grenada": "GRD", "guatemala": "GTM", "guinea": "GIN", "guinea-bissau": "GNB", "guyana": "GUY",
+    "haiti": "HTI", "honduras": "HND", "hungary": "HUN", "iceland": "ISL", "india": "IND", "indonesia": "IDN", "iran": "IRN",
+    "iraq": "IRQ", "ireland": "IRL", "israel": "ISR", "italy": "ITA", "jamaica": "JAM", "japan": "JPN", "jordan": "JOR",
+    "kazakhstan": "KAZ", "kenya": "KEN", "kiribati": "KIR", "korea, north": "PRK", "korea, south": "KOR", "kuwait": "KWT",
+    "kyrgyzstan": "KGZ", "laos": "LAO", "latvia": "LVA", "lebanon": "LBN", "lesotho": "LSO", "liberia": "LBR", "libya": "LBY",
+    "liechtenstein": "LIE", "lithuania": "LTU", "luxembourg": "LUX", "madagascar": "MDG", "malawi": "MWI", "malaysia": "MYS",
+    "maldives": "MDV", "mali": "MLI", "malta": "MLT", "marshall islands": "MHL", "mauritania": "MRT", "mauritius": "MUS",
+    "mexico": "MEX", "micronesia": "FSM", "moldova": "MDA", "monaco": "MCO", "mongolia": "MNG", "montenegro": "MNE", "morocco": "MAR",
+    "mozambique": "MOZ", "myanmar": "MMR", "namibia": "NAM", "nauru": "NRU", "nepal": "NPL", "netherlands": "NLD", "new zealand": "NZL",
+    "nicaragua": "NIC", "niger": "NER", "nigeria": "NGA", "north macedonia": "MKD", "norway": "NOR", "oman": "OMN", "pakistan": "PAK",
+    "palau": "PLW", "panama": "PAN", "papua new guinea": "PNG", "paraguay": "PRY", "peru": "PER", "philippines": "PHL", "poland": "POL",
+    "portugal": "PRT", "qatar": "QAT", "romania": "ROU", "russia": "RUS", "rwanda": "RWA", "saudi arabia": "SAU", "senegal": "SEN",
+    "serbia": "SRB", "seychelles": "SYC", "sierra leone": "SLE", "singapore": "SGP", "slovakia": "SVK", "slovenia": "SVN",
+    "solomon islands": "SLB", "somalia": "SOM", "south africa": "ZAF", "spain": "ESP", "sri lanka": "LKA", "sudan": "SDN",
+    "suriname": "SUR", "sweden": "SWE", "switzerland": "CHE", "syria": "SYR", "tajikistan": "TJK", "tanzania": "TZA", "thailand": "THA",
+    "timor-leste": "TLS", "togo": "TGO", "tonga": "TON", "tunisia": "TUN", "turkey": "TUR", "turkmenistan": "TKM", "uganda": "UGA",
+    "ukraine": "UKR", "united arab emirates": "ARE", "united kingdom": "GBR", "united states": "USA", "uruguay": "URY", "uzbekistan": "UZB",
+    "vanuatu": "VUT", "venezuela": "VEN", "vietnam": "VNM", "yemen": "YEM", "zambia": "ZMB", "zimbabwe": "ZWE"
 }
 
 def get_country_iso3(name: str) -> str:

+ 1376 - 0
plugins/vfs_plugin2.py

@@ -0,0 +1,1376 @@
+# plugins/vfs_plugin2.py
+
+import os
+import time
+import json
+import random
+import base64
+import re
+import urllib.parse
+from datetime import datetime
+from typing import Dict, Any, Optional, List, Tuple, Callable
+
+# DrissionPage 核心引入
+from DrissionPage import ChromiumPage, ChromiumOptions
+from DrissionPage.common import Settings
+
+# 加密库
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.backends import default_backend
+
+from vs_plg import IVSPlg 
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from toolkit.vs_cloud_api import VSCloudApi 
+
+# ----------------- 静态常量与辅助数据 -----------------
+
+VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
+LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
+t59GV321kN0iquYRHrdh3ygfDDHlS9rROQeBqRga0ncSADtbLMrBPqXJjPCoV76y
+t92towriKoH75BhiazY0mghm4LjmAWrV0u/GNpV3tk9bxbtHEXGaFmxCJqjg+7x6
+1e5wXLfvpj9w1QsiSWOSJxLOyICz/9ByxXycQQFdNmjnnnwco9Gt/Mi33NYH71j0
+5oXIjklFC4lvJqaqSY5lS7Vwb9oCt9zX9J0Yz4z4e/3V+0jgRnWOFGofyks4FKe2
+GQIDAQAB
+-----END PUBLIC KEY-----"""
+
+# (Country Map 省略以节省篇幅,请保持原样)
+COUNTRY_MAP = {
+    "afghanistan": "AFG", "albania": "ALB", "algeria": "DZA", "andorra": "AND",  "angola": "AGO",
+    "china": "CHN", "united kingdom": "GBR", "netherlands": "NLD", 
+    # ... 请保留你原来的完整映射 ...
+}
+
+def get_country_iso3(name: str) -> str:
+    return COUNTRY_MAP.get(name.lower(), "CHN")
+
+def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%d"):
+    try:
+        dt = datetime.strptime(data_str, date_str_format)
+        return dt.strftime(target_format)
+    except:
+        return data_str
+
+def create_proxy_auth_extension(ip, port, username, password, plugin_path="./chrome_proxy_auth_plugin"):
+    """
+    创建一个 Chrome 插件来自动处理代理认证
+    """
+    if not os.path.exists(plugin_path):
+        os.makedirs(plugin_path)
+
+    # 1. manifest.json
+    manifest_json = """
+    {
+        "version": "1.0.0",
+        "manifest_version": 2,
+        "name": "Chrome Proxy Auth Extension",
+        "permissions": [
+            "proxy",
+            "tabs",
+            "unlimitedStorage",
+            "storage",
+            "<all_urls>",
+            "webRequest",
+            "webRequestBlocking"
+        ],
+        "background": {
+            "scripts": ["background.js"]
+        },
+        "minimum_chrome_version": "22.0.0"
+    }
+    """
+
+    # 2. background.js
+    background_js = f"""
+    var config = {{
+        mode: "fixed_servers",
+        rules: {{
+            singleProxy: {{
+                scheme: "http",
+                host: "{ip}",
+                port: parseInt({port})
+            }},
+            bypassList: ["localhost"]
+        }}
+    }};
+
+    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
+
+    function callbackFn(details) {{
+        return {{
+            authCredentials: {{
+                username: "{username}",
+                password: "{password}"
+            }}
+        }};
+    }}
+
+    chrome.webRequest.onAuthRequired.addListener(
+        callbackFn,
+        {{urls: ["<all_urls>"]}},
+        ['blocking']
+    );
+    """
+
+    with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
+        f.write(manifest_json)
+    
+    with open(os.path.join(plugin_path, "background.js"), "w") as f:
+        f.write(background_js)
+
+    return os.path.abspath(plugin_path)
+
+# --- 模拟 Requests Response 对象 ---
+class BrowserResponse:
+    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
+
+    @property
+    def content(self):
+        return self.text.encode('utf-8')
+
+class VfsPlugin2(IVSPlg):
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        self.logger = None
+        
+        # 替换 requests.Session 为 DrissionPage
+        self.page: Optional[ChromiumPage] = None
+        
+        self.jwt_token: str = ""
+        self.real_ip: str = ""
+        self.is_healthy: bool = True
+        
+        self.center_conf = None
+        self.category_conf: Dict = {}
+        self.subcategory_conf: Dict = {}
+        
+        self.public_key = serialization.load_pem_public_key(
+            VFS_PUBLIC_KEY_PEM.encode(),
+            backend=default_backend()
+        )
+        self.session_create_time: float = 0
+
+    def get_group_id(self) -> str:
+        return self.group_id
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        self.free_config = config.free_config or {}
+        
+    def set_log(self, logger: Callable[[str], None]) -> None:
+        self.logger = logger
+    
+    def _log(self, message):
+        if self.logger:
+            self.logger(f'[VfsPlugin] [{self.group_id}] {message}')
+        else:
+            print(f'[VfsPlugin] [{self.group_id}] {message}')
+
+    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 * 60:
+                self._log(f"Session expired.")
+                return False
+        return True
+
+    def create_session(self) -> None:
+        """
+        使用 DrissionPage 创建会话:
+        1. 启动浏览器
+        2. 导航到登录页
+        3. 自动过盾并提取 Token
+        4. JS fetch 登录
+        """
+        self._log("Initializing Browser Session...")
+        
+        # 0. 配置浏览器
+        co = ChromiumOptions()
+        co.auto_port() # 自动分配端口
+        
+        if self.config.proxy and self.config.proxy.ip:
+            p = self.config.proxy
+        
+        # 情况 A: 有账号密码 -> 使用插件方案
+        if p.username and p.password:
+            self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
+            plugin_path = create_proxy_auth_extension(
+                ip=p.ip,
+                port=p.port,
+                username=p.username,
+                password=p.password
+            )
+            co.add_extension(plugin_path)
+        
+        # 情况 B: 无账号密码 (IP白名单模式) -> 直接设置
+        else:
+            self._log(f"Configuring standard proxy: {p.ip}:{p.port}")
+            co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+            
+        # 无头模式 (生产环境建议 True, 调试 False)
+        # co.headless(True) 
+        co.headless(False) # 调试时设为 False 方便观察
+        
+        # 反爬参数
+        co.set_argument('--no-sandbox')
+        co.set_argument('--disable-gpu')
+        co.set_argument('--window-size=1920,1080')
+        # 禁用自动化特征
+        co.set_argument('--disable-blink-features=AutomationControlled')
+
+        try:
+            self.page = ChromiumPage(co)
+            
+            # 1. 导航到登录页面 (建立 Context)
+            mission = self.free_config.get("mission_code", "")
+            country = self.free_config.get("country_code", "")
+            lang = self.free_config.get("language", "en")
+            
+            if not mission or not country:
+                raise BizLogicError("Missing mission/country code config")
+
+            login_page_url = f"https://visa.vfsglobal.com/{country}/{lang}/{mission}/login"
+            self._log(f"Navigating to {login_page_url}...")
+            
+            self.page.get(login_page_url)
+            
+            # 2. 等待 Cloudflare 验证通过
+            # DrissionPage 会自动处理 Turnstile,我们只需要等待结果出现
+            # 通常 CF 的 widget 会生成一个 hidden input name="cf-turnstile-response"
+            self._log("Waiting for Cloudflare challenge...")
+            
+            # 最多等待 30 秒
+            cf_token = ""
+            for _ in range(10):
+                # 间隔 1 秒
+                time.sleep(1)
+                self._handle_cookie_banner()
+                # 尝试从 DOM 获取 Token
+                try:
+                    # 检查是否有 cf-turnstile-response 元素且有值
+                    ele = self.page.ele('xpath://input[@name="cf-turnstile-response"]')
+                    if ele and ele.value:
+                        cf_token = ele.value
+                        self._log("Cloudflare Turnstile token extracted from DOM.")
+                        break
+                except:
+                    pass
+                
+                # 也可以检查是否已经看到了登录框 (id="mat-input-0" 或 form)
+                if self.page.ele('xpath://form'):
+                    self._log("Login form detected.")
+                    # 即使 form 出来了,有时候 token 还在生成,稍微再等一下
+            
+            # 如果没拿到 token,尝试直接继续,或者报错
+            # 注意:有些 VFS 页面可能没有显式的 turnstile,而是隐式的
+            if not cf_token:
+                self._log("[WARN] Could not extract Turnstile token. Trying to proceed anyway...")
+
+            # 3. 准备登录 API 参数
+            email = self.config.account.username
+            password = self.config.account.password
+            enc_password = self._encrypt_password(password)
+            
+            client_src = self._get_client_source()
+            orange_src = self._get_orange_source(email)
+            
+            url = "https://lift-api.vfsglobal.com/user/login"
+            headers = self._get_common_headers(with_auth=False)
+            headers.update({
+                "clientsource": client_src,
+                "orangex": orange_src,
+                # DrissionPage fetch 不需要 content-type,json参数会自动加
+            })
+            
+            data = {
+                "username": email,
+                "password": enc_password,
+                "missioncode": mission,
+                "countrycode": country,
+                "languageCode": "en-US",
+                "captcha_version": "cloudflare-v1",
+                "captcha_api_key": cf_token  # 填入提取到的 Token
+            }
+            
+            self._log("Sending Login Request via Browser Fetch...")
+            resp = self._perform_request("POST", url, headers=headers, json_data=data)
+            resp_json = resp.json()
+
+            # 分支 1: 登录成功
+            if resp_json.get('accessToken'):
+                self.jwt_token = resp_json["accessToken"]
+                self._log("Login successful, JWT obtained.")
+            
+            # 分支 2: OTP
+            elif resp_json.get("enableOTPAuthentication"):
+                self._log("Login requires OTP.")
+                otp = self._read_otp_email()
+                self._submit_login_otp(cf_token, otp)
+            
+            else:
+                raise BizLogicError(f"Login failed: {resp.text[:200]}")
+
+            self.session_create_time = time.time()
+            # 获取真实IP (用于日志)
+            try:
+                self.real_ip = self._get_realnetwork_ip()
+            except:
+                self.real_ip = "0.0.0.0"
+                
+        except Exception as e:
+            self._log(f"Create Session Failed: {e}")
+            if self.page:
+                self.page.quit()
+                self.page = None
+            raise e
+
+    def query(self) -> VSQueryResult:
+        """查询可预约 Slot"""
+        result = VSQueryResult() 
+        appt_types = self.free_config.get("appointment_types", [])
+        if not appt_types:
+            raise NotFoundError(message="No matching appointment configuration found.")
+        
+        apt_config = random.choice(appt_types)
+        
+        try:
+            self._fetch_configurations(apt_config)
+            earliest_date = self._query_earliest_slot(apt_config)
+            
+            result.success = False
+            result.availability_status = AvailabilityStatus.NoneAvailable
+            result.visa_type = apt_config.get("visa_type", "")
+            result.city = apt_config.get("city", "")
+            
+            if earliest_date:
+                result.success = True
+                if "WaitList" in earliest_date:
+                    result.availability_status = AvailabilityStatus.Waitlist
+                else:
+                    result.availability_status = AvailabilityStatus.Available
+                    result.earliest_date = earliest_date
+                    result.availability = [DateAvailability(date=earliest_date, times=[])]
+                    self._log(f"Slot Found! Date: {earliest_date}")
+            else:
+                self._log("No slots available.")
+                
+        except Exception as e:
+            self._log(f"Query Error: {e}")
+            raise e
+            
+        return result
+
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
+        """
+        核心方法:在 DrissionPage 浏览器上下文中注入 JS 执行 fetch
+        """
+        if not self.page:
+            raise BizLogicError("Browser session not initialized")
+
+        # 1. 确保在正确的上下文 (VFS 登录页或 API 域名)
+        # create_session 已经打开了页面,这里通常不需要额外跳转
+        # 如果页面崩溃或跳转了,可能需要恢复
+        
+        # 2. 构造参数
+        if params:
+            if '?' in url:
+                url += '&' + urllib.parse.urlencode(params)
+            else:
+                url += '?' + urllib.parse.urlencode(params)
+
+        fetch_options = {
+            "method": method.upper(),
+            "headers": headers or {},
+            "credentials": "include" # 关键:带上浏览器 Cookie
+        }
+        
+        if json_data:
+            fetch_options['body'] = json.dumps(json_data)
+            fetch_options['headers']['Content-Type'] = 'application/json'
+        elif data:
+            if isinstance(data, dict):
+                fetch_options['body'] = urllib.parse.urlencode(data)
+                fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
+            else:
+                fetch_options['body'] = data
+
+        # 3. 注入 JS
+        js_script = f"""
+        const url = "{url}";
+        const options = {json.dumps(fetch_options)};
+        
+        return fetch(url, options)
+            .then(async response => {{
+                const text = await response.text();
+                const headers = {{}};
+                response.headers.forEach((value, key) => headers[key] = value);
+                
+                return {{
+                    status: response.status,
+                    body: text,
+                    headers: headers,
+                    url: response.url
+                }};
+            }})
+            .catch(error => {{
+                return {{
+                    status: 0,
+                    body: error.toString(),
+                    headers: {{}},
+                    url: url
+                }};
+            }});
+        """
+        
+        if self.config.debug:
+            self._log(f"[Browser Fetch] {method} {url}")
+
+        try:
+            # run_js 直接返回 return 的对象
+            res_dict = self.page.run_js(js_script, timeout=30)
+        except Exception as e:
+            raise BizLogicError(f"Browser JS Execution Error: {e}")
+
+        resp = BrowserResponse(res_dict)
+        
+        # 4. 统一处理状态码
+        if resp.status_code == 200:
+            return resp
+        elif resp.status_code == 401:
+            self.is_healthy = False
+            raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
+        elif resp.status_code == 403:
+            raise PermissionDeniedError(f"403 Forbidden: {resp.text[:100]}")
+        elif resp.status_code == 429:
+            self.is_healthy = False
+            raise RateLimiteddError(f"429 Rate Limit: {resp.text[:100]}")
+        elif resp.status_code == 0:
+            raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
+        else:
+            # 允许 400 业务错误通过,交给上层解析 (例如登录失败)
+            if url.endswith("/login") and resp.status_code == 400:
+                return resp
+            raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
+
+    def _handle_cookie_banner(self):
+        """
+        处理 OneTrust Cookie 遮挡
+        策略:尝试点击“接受所有”,如果点不到就直接移除 DOM
+        """
+        try:
+            # 使用 JS 处理最快,且不会因为元素运动报错
+            js = """
+            try {
+                // 1. 尝试点击 '接受所有' 按钮
+                var acceptBtn = document.getElementById('onetrust-accept-btn-handler');
+                if (acceptBtn) {
+                    acceptBtn.click();
+                    return true;
+                }
+                
+                // 2. 如果没有按钮,或者还在遮挡,直接把整个 banner 删掉
+                var banner = document.getElementById('onetrust-banner-sdk');
+                if (banner) {
+                    banner.style.display = 'none'; // 隐藏
+                    banner.remove(); // 或者移除
+                    return true;
+                }
+            } catch(e) {}
+            return false;
+            """
+            self.page.run_js(js)
+        except:
+            pass
+
+    def _get_proxy_url(self):
+        if self.config.proxy and self.config.proxy.ip:
+            s = self.config.proxy
+            if s.username:
+                return f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
+            else:
+                return f"{s.scheme}://{s.ip}:{s.port}"
+        return None
+    
+    def _get_realnetwork_ip(self):
+        """
+        通过新建标签页获取 IP
+        解决 CORS 403 问题:新标签页请求属于 Top-Level Navigation,
+        不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
+        """
+        try:
+            # 1. 新建一个标签页 (后台静默打开)
+            tab = self.page.new_tab("https://api.ipify.org/?format=json")
+            
+            # 2. 获取页面内容 (DrissionPage 会自动等待页面加载)
+            # ipify 返回的是纯 JSON 文本,通常在 body 或 pre 标签里
+            if tab.ele('tag:pre'):
+                json_text = tab.ele('tag:pre').text
+            else:
+                json_text = tab.ele('tag:body').text
+            
+            # 3. 提取 IP
+            ip = json.loads(json_text)['ip']
+            
+            # 4. 务必关闭标签页,释放资源
+            tab.close()
+            
+            self._log(f"Real Network IP: {ip}")
+            return ip
+            
+        except Exception as e:
+            self._log(f"[WARN] Failed to check IP via new tab: {e}")
+            # 尝试清理可能没关掉的标签页
+            try:
+                if self.page.tabs_count > 1:
+                    tab.close()
+            except:
+                pass
+            return "0.0.0.0"
+
+    def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
+        # DrissionPage 浏览器会自动带上 Origin, Referer, User-Agent, Sec-CH-UA 等
+        # 这里只需要补充业务特定的 Headers
+        mission = self.free_config.get("mission_code", "")
+        country = self.free_config.get("country_code", "")
+        lang = self.free_config.get("language", "en")
+        route = f"{country}/{lang}/{mission}"
+        
+        h = {
+            "accept": "application/json, text/plain, */*",
+            # "origin": ... 浏览器自动处理
+            # "referer": ... 浏览器自动处理
+            "route": route
+        }
+        
+        # 即使是浏览器环境,VFS 也需要这两个加密参数
+        # 注意:这里可能需要从 JS 获取,或者保持 Python 生成
+        # 如果 Python 生成的总是报错,可以考虑把加密逻辑移到 JS 里跑
+        h["clientsource"] = self._get_client_source()
+        
+        if with_auth and self.jwt_token:
+            h["authorize"] = self.jwt_token
+            
+        return h
+
+    def _encrypt_password(self, password: str) -> str:
+        ciphertext = self.public_key.encrypt(
+            password.encode(),
+            padding.OAEP(
+                mgf=padding.MGF1(algorithm=hashes.SHA256()),
+                algorithm=hashes.SHA256(),
+                label=None
+            )
+        )
+        return base64.b64encode(ciphertext).decode()
+
+    def _get_orange_source(self, email: str) -> str:
+        timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
+        payload = f"{email};{timestamp}"
+        return self._encrypt_password(payload)
+
+    def _get_client_source(self) -> str:
+        timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
+        payload = f"GA;{timestamp}Z"
+        return self._encrypt_password(payload)
+
+    def _query_earliest_slot(self, apt_config) -> Optional[str]:
+        url = "https://lift-api.vfsglobal.com/appointment/CheckIsSlotAvailable"
+        data = {
+            "missioncode": self.free_config.get("mission_code"),
+            "countrycode": self.free_config.get("country_code"),
+            "vacCode": apt_config.get("vac_code"),
+            "visaCategoryCode": apt_config.get("subcategory_code"),
+            "roleName": "Individual",
+            "loginUser": self.config.account.username,
+            "payCode": ""
+        }
+        headers = self._get_common_headers(with_auth=True)
+        # fetch 不需要显式 content-type application/json,json_data会自动处理
+        
+        # DrissionPage 不需要手动处理 403 绕盾,因为浏览器本身就在盾后面
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        
+        if "WaitList" in resp.text:
+            return "WaitList"
+
+        j = resp.json()
+        if j.get("earliestSlotLists"):
+            raw_date = j["earliestSlotLists"][0]["date"]
+            return to_yyyymmdd(raw_date, "%m/%d/%Y %H:%M:%S")
+        return ""
+
+    def _fetch_configurations(self, apt_config: Dict[str, Any]):
+        if not self.center_conf:
+            self.center_conf = self._query_center()
+
+        vac_code = apt_config.get("vac_code")
+        category_code = apt_config.get("category_code")
+        
+        if category_code not in self.category_conf:
+            visa_categories = self._query_visa_category(vac_code)
+            found = False
+            for vc in visa_categories:
+                if vc.get("code") == category_code:
+                    self.category_conf[category_code] = vc
+                    found = True
+                    break
+            if not found:
+                self._log(f"WARN: Category {category_code} not found")
+
+        sub_category_code = apt_config.get("subcategory_code")
+        if sub_category_code not in self.subcategory_conf:
+            visa_subcategories = self._query_visa_sub_category(vac_code, category_code)
+            found = False
+            for svc in visa_subcategories:
+                if svc.get("code") == sub_category_code:
+                    self.subcategory_conf[sub_category_code] = svc
+                    found = True
+                    break
+            if not found:
+                self._log(f"WARN: SubCategory {sub_category_code} not found")
+
+    def _query_center(self) -> List:
+        mission = self.free_config.get("mission_code")
+        country = self.free_config.get("country_code")
+        url = f"https://lift-api.vfsglobal.com/master/center/{mission}/{country}/en-US"
+        headers = self._get_common_headers(with_auth=False)
+        resp = self._perform_request("GET", url, headers=headers)
+        return resp.json()
+   
+    def _query_visa_category(self, center_code: str) -> List:
+        mission = self.free_config.get("mission_code")
+        country = self.free_config.get("country_code")
+        enc_center = urllib.parse.quote(center_code)
+        url = f"https://lift-api.vfsglobal.com/master/visacategory/{mission}/{country}/{enc_center}/en-US"
+        headers = self._get_common_headers(with_auth=False)
+        resp = self._perform_request("GET", url, headers=headers)
+        return resp.json()
+ 
+    def _query_visa_sub_category(self, center_code: str, category_code: str) -> List:
+        mission = self.free_config.get("mission_code")
+        country = self.free_config.get("country_code")
+        enc_center = urllib.parse.quote(center_code)
+        enc_cat = urllib.parse.quote(category_code)
+        url = f"https://lift-api.vfsglobal.com/master/subvisacategory/{mission}/{country}/{enc_center}/{enc_cat}/en-US"
+        headers = self._get_common_headers(with_auth=False)
+        resp = self._perform_request("GET", url, headers=headers)
+        return resp.json()            
+
+    def _read_otp_email(self) -> str:
+        # 保持原样,这部分使用云API读取邮件,不依赖本地网络库
+        master_email = "visafly666@gmail.com"
+        recipient = self.config.account.username
+        sender = "donotreply at vfshelpline.com"
+        subject_keywords = "One Time Password"
+        body_keywords = "OTP"
+        now_utc = datetime.utcnow()
+        formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
+        self._log(f"Waiting for OTP email...")
+        for i in range(12):
+            content_out = VSCloudApi.Instance().fetch_mail_content(
+                master_email, sender, recipient, subject_keywords, body_keywords, formatted_utc_time, 300
+            )
+            if content_out:
+                match = re.search(r'\b\d{6}\b', content_out)
+                if match:
+                    return match.group(0)
+            time.sleep(5)
+        raise NotFoundError(message="OTP email not found")
+
+    def _submit_login_otp(self, old_cf_token: str, otp: str):
+        self._log("Submitting Login OTP...")
+        
+        # --- [新增] 必须刷新 Token ---
+        # 旧的 old_cf_token 已经在第一步登录时失效了
+        new_cf_token = self._refresh_turnstile_token()
+        # ---------------------------
+
+        email = self.config.account.username
+        password = self.config.account.password
+        enc_password = self._encrypt_password(password)
+        mission = self.free_config.get("mission_code", "")
+        country = self.free_config.get("country_code", "")
+        
+        client_src = self._get_client_source()
+        orange_src = self._get_orange_source(email)
+        
+        url = "https://lift-api.vfsglobal.com/user/login"
+        headers = self._get_common_headers(with_auth=False)
+        headers.update({
+            "clientsource": client_src,
+            "orangex": orange_src
+        })
+        
+        data = {
+            "username": email,
+            "password": enc_password,
+            "missioncode": mission,
+            "countrycode": country,
+            "languageCode": "en-US",
+            "captcha_version": "cloudflare-v1",
+            "captcha_api_key": new_cf_token, # <--- 使用新 Token
+            "otp": otp 
+        }
+        
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        resp_json = resp.json()
+        
+        if resp_json.get("accessToken"):
+            self.jwt_token = resp_json["accessToken"]
+            self._log("OTP Login successful.")
+            return
+
+        # 增加错误详情日志
+        error_desc = resp_json.get("description", resp.text)
+        raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
+    
+    def _refresh_turnstile_token(self) -> str:
+        """
+        强制刷新 Cloudflare Turnstile 并获取新 Token (增强版)
+        """
+        self._log("Refreshing Cloudflare Turnstile token...")
+        
+        # 1. JS 强制重置
+        # 加上 try-catch 防止页面没有 turnstile 对象导致崩溃
+        js_reset = """
+        try {
+            var input = document.querySelector('input[name="cf-turnstile-response"]');
+            if (input) input.value = "";
+            window.turnstile.reset(); 
+        } catch(e) {
+            console.log("Turnstile reset error:", e);
+        }
+        """
+        self.page.run_js(js_reset)
+        
+        # 2. 轮询等待 (增加到 30 秒)
+        # 策略:检测 Token -> 如果没有且有 iframe -> 点击 iframe 触发验证
+        for i in range(60): # 60 * 0.5s = 30s
+            time.sleep(0.5)
+            
+            # A. 尝试直接获取 Token (使用 JS 获取更稳定)
+            token = self.page.run_js('return document.querySelector("input[name=\'cf-turnstile-response\']")?.value')
+            if token:
+                self._log("Turnstile token refreshed successfully.")
+                return token
+            
+            # B. 如果等待了 3 秒还没结果,尝试寻找 iframe 并点击
+            # Cloudflare 有时需要用户点一下 "Verify you are human"
+            if i > 6 and (i % 5 == 0): # 每隔 2.5 秒尝试点一次
+                try:
+                    # 查找包含 turnstile 或 cloudflare 的 iframe
+                    # VFS 页面通常只有一个
+                    cf_iframe = self.page.ele('xpath://iframe[contains(@src, "turnstile") or contains(@src, "cloudflare")]')
+                    if cf_iframe:
+                        # 尝试点击 iframe 的中心位置
+                        # self._log("Clicking Cloudflare widget to activate...")
+                        cf_iframe.click(by_js=True) 
+                except Exception:
+                    pass
+        
+        # 如果超时,为了调试,打印一下当前页面源码的一部分或截图(可选)
+        raise BizLogicError("Failed to refresh Cloudflare Turnstile token (Timeout)")
+
+    # -------------------------------------------------------------
+    # 核心预约逻辑 (DrissionPage 版)
+    # -------------------------------------------------------------
+
+    def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
+        """
+        执行完整的预约流程
+        """
+        self._log("Starting booking process...")
+        
+        # 1. 准备数据
+        user_email = user_inputs.get('email')
+        # 生成别名邮箱 (防止邮箱被 VFS 黑名单)
+        user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
+        
+        res = VSBookResult()
+        slot_routing_key = slot_info.routing_key
+        # 如果没有 earliest_date,默认从今天开始
+        from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
+        
+        # 2. 查找对应的配置
+        apt_config = None
+        appt_types = self.free_config.get("appointment_types", [])
+        for apt in appt_types:
+            if apt.get("routing_key") == slot_routing_key:
+                apt_config = apt
+                break
+        
+        if not apt_config:
+            raise NotFoundError(message="Book: Config missing for this routing key.")
+
+        # 确保配置已加载 (SubCategory 等)
+        self._fetch_configurations(apt_config)
+
+        sub_cc = apt_config.get("subcategory_code")
+        sub_conf = self.subcategory_conf.get(sub_cc, {})
+
+        # 3. OCR 识别 / 文档上传 (如果需要)
+        # 上传结果存入 user_inputs 供后续使用
+        ocr_enabled = sub_conf.get("isOCREnable", False)
+        if ocr_enabled:
+            self._log("OCR Enabled, uploading documents...")
+            upload_res = self._upload_applicant_documents(apt_config, user_inputs)
+            user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
+            user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes")
+            user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
+
+        enable_reference_number = sub_conf.get("enableReferenceNumber", False)
+
+        # 4. 添加申请人 (核心步骤 1)
+        final_urn = None
+        is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
+        
+        # 重试机制:添加申请人有时候会因为并发冲突失败
+        MAX_RETRY = 3
+        for i in range(MAX_RETRY):
+            try:
+                final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
+                if final_urn:
+                    break
+            except Exception as e:
+                self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {e}")
+                time.sleep(2)
+        
+        if not final_urn:
+            raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
+
+        self._log(f"Applicant Added. URN: {final_urn}")
+
+        # 5. 申请人 OTP 验证 (核心步骤 2 - 视配置而定)
+        otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
+        if otp_enabled:
+            self._log("Applicant OTP Required.")
+            if not self._applicant_otp_send(apt_config, final_urn):
+                raise BizLogicError(message='Applicant OTP send failed')
+            
+            # 复用之前的读邮件逻辑
+            otp_code = self._read_otp_email()
+            if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
+                raise BizLogicError(message='Applicant OTP verify failed')
+
+        # 6. Waitlist 模式直接返回
+        if is_waitlist:
+            if self._confirm_waitlist(apt_config, final_urn):
+                res.success = True
+                res.urn = final_urn
+                self._log("Waitlist confirmed.")
+                return res
+            raise BizLogicError(message='Confirm waitlist failed')
+
+        # 7. 寻找具体的时间槽 (核心步骤 3)
+        expected_start = user_inputs.get("expected_start_date", "")
+        expected_end = user_inputs.get("expected_end_date", "")
+        
+        # 计算需要扫描的月份
+        months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
+        self._log(f"Scanning months: {months} (Start looking from: {from_date})")
+        
+        selected_slot_id = ""
+        selected_slot_date = ""
+        selected_slot_time_range = ""
+        
+        all_ads = set()
+        forbidden_dates = set()
+        found_slot = False
+        
+        for m_str in months:
+            self._log(f"Checking calendar for {m_str}...")
+            # 查询日历
+            ads = self._query_slot_calendar(apt_config, final_urn, m_str)
+            
+            # 去重
+            new_ads = [d for d in ads if d not in all_ads]
+            all_ads.update(new_ads)
+            
+            # 尝试选中一个日期
+            # 这里做一个简单循环,如果选中日期没时间了,就换一个日期
+            for _ in range(3):
+                avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
+                # 根据用户期望过滤
+                sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
+                
+                if not sel_dates:
+                    break # 当前月没有符合要求的日期,去下一个月
+                
+                tmp_date = sel_dates[0] # 取第一个(通常 _filter_dates 里已经 shuffle 过了)
+                forbidden_dates.add(tmp_date) # 标记为已尝试
+                
+                # 关键:Audit Log (锁定日期)
+                # VFS 要求在查 timeslot 之前必须先发这个请求
+                if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
+                    self._log(f"Audit failed for {tmp_date}, skipping...")
+                    time.sleep(1)
+                    continue
+                
+                # 查询具体时间
+                ats = self._query_slot_time(apt_config, final_urn, tmp_date)
+                if not ats:
+                    self._log(f"No timeslots for {tmp_date}")
+                    continue
+                
+                # 随机选一个时间
+                sel_tm = random.choice(ats)
+                
+                selected_slot_id = sel_tm.get("allocationId")
+                selected_slot_date = tmp_date
+                selected_slot_time_range = sel_tm.get("slot")
+                
+                found_slot = True
+                break
+            
+            if found_slot:
+                break
+                
+        if not found_slot:
+            self._log("No valid slots found after scanning.") 
+            res.success = False
+            return res
+
+        self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
+
+        # 8. 服务与费用 (核心步骤 4)
+        self._submit_no_addition_service(final_urn)
+        amount, currency = self._query_fee(apt_config, final_urn)
+        
+        # 9. 最终提交
+        self._log("Submitting schedule...")
+        schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
+        
+        if not schedule_res.get("IsAppointmentBooked"):
+            self._log(f"Booking failed: {schedule_res}") 
+            res.success = False
+            return res
+             
+        # 10. 构造成功结果
+        res.success = True
+        res.account = self.config.account.username
+        res.book_date = selected_slot_date
+        res.book_time = selected_slot_time_range
+        res.urn = final_urn
+        res.fee_amount = int(amount * 100)
+        res.fee_currency = currency
+        
+        # 11. 处理支付链接
+        if schedule_res.get("IsPaymentRequired", False):
+            payload = schedule_res.get("payLoad", "")
+            if payload:
+                self._log("Processing payment link...")
+                payment_url = self._pay_request(payload)
+                if payment_url:
+                    res.payment_link = payment_url
+                    
+        return res
+
+    # -------------------------------------------------------------
+    # 辅助方法实现 (DrissionPage 适配版)
+    # -------------------------------------------------------------
+
+    def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
+        """上传图片:先下载外部图片,再通过浏览器上传到 VFS"""
+        import requests as standard_requests # 使用标准库下载外部资源
+        
+        url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
+        passport_url = user_inputs.get("passport_image_url")
+        if not passport_url:
+            raise NotFoundError(message="Missing passport_image_url")
+
+        # 下载图片 (不走代理或走系统代理,不使用 DrissionPage,因为是外部链接)
+        try:
+            img_resp = standard_requests.get(passport_url, timeout=30)
+            if img_resp.status_code != 200:
+                raise BizLogicError(message=f"Failed to download passport image: {img_resp.status_code}")
+            b64_str = base64.b64encode(img_resp.content).decode('utf-8')
+        except Exception as e:
+            raise BizLogicError(message=f"Image download error: {e}")
+  
+        headers = self._get_common_headers(with_auth=True)
+        # DrissionPage fetch 不需要显式 content-type application/json,json_data会自动处理
+        
+        data = {
+            "missioncode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "languageCode": "en-US",
+            "visaCategoryCode": apt_config.get("subcategory_code"),
+            "fileBytes": b64_str,
+            "selfiImageFileBytes": ""
+        }
+        
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        result = resp.json()
+        
+        # 补充返回数据供后续使用
+        result["passportImageFilename"] = "passport_img.jpg"
+        result["passportImageFileBytes"] = b64_str
+        return result
+   
+    def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any], 
+                             is_waitlist: bool, ocr_enabled: bool, enable_ref: bool) -> str:
+        """构造申请人 payload 并提交"""
+        url = "https://lift-api.vfsglobal.com/appointment/applicants"
+        headers = self._get_common_headers(with_auth=True)
+
+        gender_str = str(user_inputs.get("gender", "")).lower()
+        gender_code = 1 if gender_str == "male" else 2
+
+        raw_dial = user_inputs.get("phone_country_code", "86")
+        dial_code = str(raw_dial)
+
+        # 日期格式转换 YYYY-MM-DD -> DD/MM/YYYY
+        def _to_ddmmyyyy(d_str):
+            try:
+                return datetime.strptime(str(d_str), "%Y-%m-%d").strftime("%d/%m/%Y")
+            except:
+                return str(d_str)
+
+        dob = _to_ddmmyyyy(user_inputs.get("birthday", ""))
+        ppt_exp = _to_ddmmyyyy(user_inputs.get("passport_expiry_date", ""))
+
+        applicant = {
+            "urn": "",
+            "arn": "",
+            "loginUser": self.config.account.username,
+            "firstName": str(user_inputs.get("first_name", "")).upper(),
+            "middleName": "",
+            "lastName": str(user_inputs.get("last_name", "")).upper(),
+            "employerFirstName": "",
+            "employerLastName": "",
+            "salutation": "",
+            "gender": gender_code,
+            "contactNumber": str(user_inputs.get("phone", "")),
+            "dialCode": dial_code,
+            "employerContactNumber": "",
+            "employerDialCode": "",
+            "emailId": str(user_inputs.get("alias_email", "")).upper(),
+            "employerEmailId": "",
+            "passportNumber": str(user_inputs.get("passport_no", "")).upper(),
+            "confirmPassportNumber": "",
+            "passportExpirtyDate": ppt_exp,
+            "dateOfBirth": dob,
+            "nationalId": None,
+            "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
+            "state": None, "city": None, "addressline1": None, "addressline2": None, "pincode": None,
+            "isEndorsedChild": False, "applicantType": 0, "vlnNumber": None, "applicantGroupId": 0,
+            "parentPassportNumber": "", "parentPassportExpiry": "", "dateOfDeparture": None,
+            "entryType": "", "eoiVisaType": "", "passportType": "", "vfsReferenceNumber": "",
+            "familyReunificationCerificateNumber": "", "PVRequestRefNumber": "", "PVStatus": "",
+            "PVStatusDescription": "", "PVCanAllowRetry": True, "PVisVerified": False,
+            "eefRegistrationNumber": "", "isAutoRefresh": True, "helloVerifyNumber": "",
+            "OfflineCClink": "", "idenfystatuscheck": False, "vafStatus": None,
+            "SpecialAssistance": "", "AdditionalRefNo": None, "juridictionCode": "",
+            "canInitiateVAF": False, "canEditVAF": False, "canDeleteVAF": False,
+            "canDownloadVAF": False, "Retryleft": "",
+            # 这里的 IP 应该已经在 create_session 时获取到了
+            "ipAddress": self.real_ip
+        }
+
+        if enable_ref:
+            applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
+        else:
+            applicant["referenceNumber"] = None
+
+        if ocr_enabled:
+            applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
+            applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
+            applicant["GUID"] = str(user_inputs.get("guid", ""))
+
+        payload = {
+            "countryCode": self.free_config.get("country_code"),
+            "missionCode": self.free_config.get("mission_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategory_code"),
+            "applicantList": [applicant],
+            "languageCode": "en-US",
+            "isWaitlist": is_waitlist,
+            "isEdit": False,
+            "feeEntryTypeCode": None, "feeExemptionTypeCode": None, 
+            "feeExemptionDetailsCode": None, "juridictionCode": None, "regionCode": None
+        }
+
+        resp = self._perform_request("POST", url, headers=headers, json_data=payload)
+        return resp.json().get("urn")
+    
+    def _applicant_otp_send(self, apt_config, urn) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
+        headers = self._get_common_headers(with_auth=True)
+        data = {
+            "urn": urn,
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "OTP": "",
+            "otpAction": "GENERATE",
+            "languageCode": "en-US"
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json().get("isOTPGenerated", False)
+
+    def _applicant_otp_verify(self, apt_config, urn, otp) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
+        headers = self._get_common_headers(with_auth=True)
+        # VFS 这里的 header 有时需要 datacenter,原代码有就加上
+        headers["datacenter"] = "GERMANY" 
+        data = {
+            "urn": urn,
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "OTP": otp,
+            "otpAction": "VALIDATE",
+            "languageCode": "en-US"
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json().get("isOTPValidated", False)
+        
+    def _query_slot_calendar(self, apt_config, urn, from_date) -> List:
+        url = "https://lift-api.vfsglobal.com/appointment/calendar"
+        headers = self._get_common_headers(with_auth=True)
+        
+        # 将 YYYY-MM-DD 转为 DD/MM/YYYY 用于 API
+        dt_m = datetime.strptime(from_date, "%Y-%m-%d")
+        converted_date = dt_m.strftime("%d/%m/%Y")
+        
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategory_code"),
+            "fromDate": converted_date,
+            "urn": urn,
+            "payCode": ""
+        }
+        
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        calendars = resp.json().get("calendars")
+        ads_out = []
+        if calendars:
+            for item in calendars:
+                # API 返回可能是 MM/DD/YYYY 或 DD/MM/YYYY,VFS 比较乱
+                # 通常是 MM/DD/YYYY
+                raw = item.get("date")
+                ads_out.append(to_yyyymmdd(raw, "%m/%d/%Y"))
+        return ads_out
+     
+    def _query_slot_time(self, apt_config, urn, slot_date) -> List:
+        url = "https://lift-api.vfsglobal.com/appointment/timeslot"
+        headers = self._get_common_headers(with_auth=True)
+        
+        dt_m = datetime.strptime(slot_date, "%Y-%m-%d")
+        converted_date = dt_m.strftime("%d/%m/%Y")
+        
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategory_code"),
+            "slotDate": converted_date,
+            "urn": urn
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json().get("slots", [])
+
+    def _saveuseractionaudit(self, apt_config, urn, earliest_date) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/saveuseractionaudit"
+        headers = self._get_common_headers(with_auth=True)
+        
+        dt = datetime.strptime(earliest_date, "%Y-%m-%d")
+
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "firstEarliestSlotDate": dt.strftime("%d/%m/%Y"),
+            "action": "schedule",
+            "ipAddress": self.real_ip,
+            "eadAppointmentDetail": dt.strftime("%Y-%m-%dT%H:%M:%S")
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json().get("isSavedSuccess", False)
+        
+    def _submit_no_addition_service(self, urn):
+        url = "https://lift-api.vfsglobal.com/vas/mapvas"
+        headers = self._get_common_headers(with_auth=True)
+        data = {
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "urn": urn,
+            "applicants": []
+        }
+        # 只要不报错即可
+        self._perform_request("POST", url, headers=headers, json_data=data)
+
+    def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
+        url = "https://lift-api.vfsglobal.com/appointment/fees"
+        headers = self._get_common_headers(with_auth=True)
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "languageCode": "en-US"
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        j = resp.json()
+        total = j.get("totalamount", 0.0)
+        currency = "EUR"
+        if j.get("feeDetails"):
+            currency = j["feeDetails"][0].get("currency", "EUR")
+        return total, currency
+
+    def _schedule(self, apt_config, urn, amount, currency, slot_id) -> Dict:
+        url = "https://lift-api.vfsglobal.com/appointment/schedule"
+        headers = self._get_common_headers(with_auth=True)
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "notificationType": "none",
+            "paymentdetails": {
+                "paymentmode": "Online",
+                "RequestRefNo": "",
+                "clientId": "",
+                "merchantId": "",
+                "amount":  amount,
+                "currency": currency
+            },
+            "allocationId": str(slot_id),
+            "CanVFSReachoutToApplicant": True
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json()
+
+    def _pay_request(self, payload) -> str:
+        """
+        解析支付重定向 URL (DrissionPage 新标签页版)
+        """
+        # 初始 URL,通常是一个 Redirect 接口
+        start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
+        final_url = ""
+        
+        try:
+            self._log("Resolving payment redirect...")
+            # 使用新标签页去跑,以免当前会话状态丢失
+            pay_tab = self.page.new_tab(start_url)
+            
+            # 等待跳转完成 (通常会跳到 Stripe, WorldPay 或其他支付网关)
+            # 等待直到 URL 不再是 PayRequest
+            pay_tab.wait.url_change(start_url, timeout=15)
+            
+            final_url = pay_tab.url
+            self._log(f"Payment URL resolved: {final_url}")
+            
+            # 关闭标签页
+            pay_tab.close()
+            
+        except Exception as e:
+            self._log(f"[WARN] Failed to resolve payment URL: {e}")
+            try:
+                pay_tab.close()
+            except:
+                pass
+                
+        return final_url
+
+    def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/ConfirmWaitlist"
+        headers = self._get_common_headers(with_auth=True)
+        data = {
+            "missionCode": self.free_config.get("mission_code"),
+            "countryCode": self.free_config.get("country_code"),
+            "centerCode": apt_config.get("vac_code"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "notificationType": "none",
+            "CanVFSReachoutToApplicant": True
+        }
+        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        return resp.json().get("isConfirmed", False)
+
+    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 = []
+        try:
+            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
+        except:
+            return dates
+
+    def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
+        fmt = "%Y-%m-%d"
+        try:
+            dt_start = datetime.strptime(start_date, fmt) if start_date else datetime.now()
+            dt_end = datetime.strptime(end_date, fmt) if end_date else datetime.now().replace(year=datetime.now().year + 1)
+            try:
+                dt_from = datetime.strptime(from_date, fmt)
+            except:
+                dt_from = datetime.now()
+        except:
+            return []
+
+        dt_start = dt_start.replace(day=1)
+        dt_end = dt_end.replace(day=1)
+        dt_from = dt_from.replace(day=1)
+        curr = max(dt_start, dt_from)
+        
+        months = []
+        while curr <= dt_end:
+            months.append(curr.strftime(fmt))
+            if curr.month == 12:
+                curr = curr.replace(year=curr.year + 1, month=1)
+            else:
+                curr = curr.replace(month=curr.month + 1)
+        return months

+ 3 - 3
toolkit/vs_cloud_api.py

@@ -40,7 +40,7 @@ class VSCloudApi:
         2. 发送实际请求
         """
         resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params)
-        VSC_DEBUG('vs_cloud', f'[perform request] {method} {url} {data} {json_data} {params} {resp.text}')
+        VSC_INFO('vs_cloud', f'[perform request] {method} {url} {data} {json_data} {params} {resp.text}')
         if resp.status_code == 200:
             return resp
         elif resp.status_code == 401:
@@ -122,7 +122,7 @@ class VSCloudApi:
         
         payload = {
             "command": "AntiCloudflareTurnstileTask",
-            "args": json.dumps(args), 
+            "args": args, 
             "status": 0
         }
 
@@ -158,7 +158,7 @@ class VSCloudApi:
         }
         data = {
             "command": "AntiCloudflareTask", 
-            "args": json.dumps(args),
+            "args": args,
             "status": 0
         }
         headers = self._get_headers()

+ 101 - 0
utils/cloudflare_bypass_for_scraping.py

@@ -0,0 +1,101 @@
+import time
+from DrissionPage import ChromiumPage 
+
+
+class CloudflareBypasser:
+    def __init__(self, driver: ChromiumPage, log=True):
+        self.driver = driver
+        self.log = log
+        
+    def log_message(self, message):
+        if self.log:
+            print(message)
+    
+    def search_recursively_shadow_root_with_iframe(self, ele, depth=0, max_depth=3):
+        if depth > max_depth:
+            self.log_message(f"Max depth {max_depth} reached during iframe search.")
+            return None
+
+        if ele.shadow_root:
+            if ele.shadow_root.child().tag == "iframe":
+                return ele.shadow_root.child()
+        else:
+            for child in ele.children():
+                result = self.search_recursively_shadow_root_with_iframe(child, depth + 1, max_depth)
+                if result:
+                    return result
+        return None
+
+    def search_recursively_shadow_root_with_cf_input(self,ele):
+        if ele.shadow_root:
+            if ele.shadow_root.ele("tag:input"):
+                return ele.shadow_root.ele("tag:input")
+        else:
+            for child in ele.children():
+                result = self.search_recursively_shadow_root_with_cf_input(child)
+                if result:
+                    return result
+        return None
+    
+    def locate_cf_button(self, dfs=False):
+        try:
+            button = None
+            eles = self.driver.eles("tag:input")
+            for ele in eles:
+                attrs = ele.attrs
+                if "name" in attrs and "type" in attrs:
+                    if "turnstile" in attrs["name"] and attrs["type"] == "hidden":
+                        if ele.parent() and ele.parent().shadow_root:
+                            button = ele.parent().shadow_root.child()("tag:body").shadow_root("tag:input")
+                            break
+
+            if button:
+                return button
+            else:
+                if dfs:
+                    self.log_message("Basic search failed. Searching for button recursively.")
+                    ele = self.driver.ele("tag:body")
+                    iframe = self.search_recursively_shadow_root_with_iframe(ele)
+                    if iframe:
+                        return self.search_recursively_shadow_root_with_cf_input(iframe("tag:body"))
+                    else:
+                        self.log_message("Iframe not found. Button search failed.")
+            return None
+
+        except Exception as e:
+            self.log_message(f"Error locating verification button: {e}")
+            return None
+    
+    def click_verification_button(self, is_dfs):
+        try:
+            button = self.locate_cf_button(dfs=is_dfs)
+            if button:
+                self.log_message("Verification button found. Attempting to click.")
+                if button.states.is_displayed and button.states.is_enabled:
+                    button.click()
+                    time.sleep(1)  # 确保事件触发
+                else:
+                    self.log_message("Button is not clickable.")
+            else:
+                self.log_message("Verification button not found.")
+
+        except Exception as e:
+            self.log_message(f"Error clicking verification button: {e}")
+
+    def is_bypassed(self):
+        try:
+            title = self.driver.title.lower()
+            return "just a moment" not in title and "请稍候" not in title
+        except Exception as e:
+            self.log_message(f"Error checking page title: {e}")
+        return False
+
+    def bypass(self, max_retry=5):
+        for i in range(max_retry):
+            if self.is_bypassed():
+                return True
+            self.log_message(f"Verification page detected.  Trying to bypass times={i}...")
+            self.click_verification_button(False)
+            time.sleep(2)
+        return self.is_bypassed()
+