Hujiarui 1 месяц назад
Родитель
Сommit
06a929149f

+ 2 - 2
booker_standalone.py

@@ -137,9 +137,9 @@ class BookerStandalone:
         self.m_instance.set_log(self.m_logger)
         self.m_instance.set_log(self.m_logger)
         self.m_instance.set_config(plg_cfg)
         self.m_instance.set_config(plg_cfg)
         
         
-        self.m_instance.create_session()
-        self.m_last_login_time = time.time()
         self.m_next_query_time = time.time() + self._get_wait_interval()
         self.m_next_query_time = time.time() + self._get_wait_interval()
+        self.m_last_login_time = time.time()
+        self.m_instance.create_session()
         self._log("Session created successfully.")
         self._log("Session created successfully.")
 
 
     def start(self):
     def start(self):

+ 57 - 0
config/config_tls.json.example

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

+ 66 - 0
config/config_usa.json.example

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

+ 94 - 0
config/config_vfs.json.example

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

+ 320 - 327
plugins/ita_plugin.py

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

+ 7 - 2
plugins/tls_plugin.py

@@ -300,7 +300,7 @@ class TlsPlugin(IVSPlg):
         init_y = random.randint(10, viewport_height - 10)
         init_y = random.randint(10, viewport_height - 10)
         self.mouse.move(init_x, init_y) 
         self.mouse.move(init_x, init_y) 
 
 
-        max_steps = 10
+        max_steps = 15
         stuck_counter = 0
         stuck_counter = 0
         last_url = ""
         last_url = ""
         session_created = False
         session_created = False
@@ -373,6 +373,11 @@ class TlsPlugin(IVSPlg):
                 time.sleep(3)
                 time.sleep(3)
                 continue
                 continue
             
             
+            if "waitting room" in current_title:
+                cf_bypasser.handle_waiting_room()
+                time.sleep(3)
+                continue
+            
             # 如果语言不匹配, 切换语言到英语
             # 如果语言不匹配, 切换语言到英语
             matched_lang = next((lang for lang in other_langs if lang in current_url), None)
             matched_lang = next((lang for lang in other_langs if lang in current_url), None)
             if matched_lang:
             if matched_lang:
@@ -418,7 +423,7 @@ class TlsPlugin(IVSPlg):
                 raise BizLogicError(message="No applicant added. Cannot proceed to booking.") 
                 raise BizLogicError(message="No applicant added. Cannot proceed to booking.") 
             
             
             # 首页/登录入口页 -> 需要点击进入登录
             # 首页/登录入口页 -> 需要点击进入登录
-            if current_url == tls_url:
+            if tls_url in current_url:
                 if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
                 if self.page.ele("tag:a@@href:login", timeout=1) and not self.page.ele('tag:label@@text():Email', timeout=1):
                     self._log("State: Login Portal. Clicking login link...")
                     self._log("State: Login Portal. Clicking login link...")
                     login_link = self.page.ele("tag:a@@href:login")
                     login_link = self.page.ele("tag:a@@href:login")

+ 19 - 11
plugins/usa_plugin.py

@@ -207,20 +207,17 @@ class UsaPlugin(IVSPlg):
         usa_url = self.free_config.get('usa_url', '') 
         usa_url = self.free_config.get('usa_url', '') 
         self._log(f"Navigating: {usa_url}")
         self._log(f"Navigating: {usa_url}")
         self.page.get(usa_url)
         self.page.get(usa_url)
-        # 初始访问页面,等待长延时,防止过快触发后续操作
-        self._random_sleep(30, 45)
+        self._random_sleep(3, 5)
         
         
         if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
         if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
             self._log(f'Block by cloudflare, try refresh...')
             self._log(f'Block by cloudflare, try refresh...')
             self.page.refresh()
             self.page.refresh()
-            self._random_sleep(30, 45) # 刷新动作属于高危操作,加长延时
             self.page.wait.doc_loaded()
             self.page.wait.doc_loaded()
         
         
         cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
         cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
         if not cf_bypasser.bypass(max_retry=6):
         if not cf_bypasser.bypass(max_retry=6):
             raise BizLogicError("Cloudflare bypass timeout")
             raise BizLogicError("Cloudflare bypass timeout")
-        # 绕过盾后,休眠一段时间再处理 waiting room
-        self._random_sleep(15, 30) 
+        self._random_sleep(3, 5) 
         cf_bypasser.handle_waiting_room()
         cf_bypasser.handle_waiting_room()
         
         
         self._log("Init humanize tools...")
         self._log("Init humanize tools...")
@@ -283,8 +280,10 @@ class UsaPlugin(IVSPlg):
             
             
             # 遇到五秒盾先绕盾
             # 遇到五秒盾先绕盾
             if "just a moment" in current_title:
             if "just a moment" in current_title:
-                cf_bypasser.bypass(max_retry=3)
-                self._random_sleep(20, 40)
+                if not cf_bypasser.bypass(max_retry=3):
+                    continue
+                self._random_sleep(3, 5)
+                cf_bypasser.handle_waiting_room()
                 continue
                 continue
             
             
             if self.page.ele('#post_select', timeout=1):
             if self.page.ele('#post_select', timeout=1):
@@ -339,6 +338,13 @@ class UsaPlugin(IVSPlg):
                 self._log("Login form submitted. Waiting for the next step to load...")
                 self._log("Login form submitted. Waiting for the next step to load...")
                 self._random_sleep(30, 40)
                 self._random_sleep(30, 40)
                 continue
                 continue
+            
+            elif self.page.ele('#continue_application', timeout=1):
+                schedule_btn = self.page.ele('#continue_application', timeout=0.5)
+                self._log("Currently in first-time booking mode, clicking to proceed...")
+                self.mouse.human_click_ele(schedule_btn)
+                self._random_sleep(30, 40)
+            
 
 
             # 状态 3: 预约主页(控制台) -> 选择首签或改签
             # 状态 3: 预约主页(控制台) -> 选择首签或改签
             elif self.page.ele('#atlas-sidebar', timeout=1):
             elif self.page.ele('#atlas-sidebar', timeout=1):
@@ -376,10 +382,12 @@ class UsaPlugin(IVSPlg):
         
         
         self.page.refresh()
         self.page.refresh()
         
         
-        cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
-        if not cf_bypasser.bypass(max_retry=6):
-            raise BizLogicError("Cloudflare bypass timeout")
-        cf_bypasser.handle_waiting_room()
+        if "just a moment" in self.page.title:
+            cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
+            if not cf_bypasser.bypass(max_retry=5):
+                raise BizLogicError("Cloudflare bypass timeout")
+            self._random_sleep(3, 5)
+            cf_bypasser.handle_waiting_room()
         
         
         current_url = self.page.url.lower()
         current_url = self.page.url.lower()
         if 'auth' in current_url or 'login' in current_url:
         if 'auth' in current_url or 'login' in current_url:

+ 40 - 14
plugins/vfs_plugin.py

@@ -23,6 +23,7 @@ from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from toolkit.vs_cloud_api import VSCloudApi
 from toolkit.vs_cloud_api import VSCloudApi
 from toolkit.mihomo_tunnel import MihomoTunnel
 from toolkit.mihomo_tunnel import MihomoTunnel
+from toolkit.captcha_breaker import recognize_vfs_captcha_with_qwen
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 
 
 
 
@@ -273,8 +274,15 @@ class VfsPlugin(IVSPlg):
         self._log("Handling Cloudflare challenge...")
         self._log("Handling Cloudflare challenge...")
         
         
         cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
         cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
-        cf_token = ""
         
         
+        if 'Attention Required! | Cloudflare' in self.page.title and 'Sorry, you have been blocked' in self.page.html:
+            self._log(f'Block by cloudflare, try refresh...')
+            self.page.refresh()
+            self.page.wait.doc_loaded()
+        time.sleep(random.uniform(3, 5))
+        cf_bypasser.handle_waiting_room()
+        
+        cf_token = ""
         for i in range(40):
         for i in range(40):
             time.sleep(1)
             time.sleep(1)
             self._handle_cookie_banner()
             self._handle_cookie_banner()
@@ -687,17 +695,32 @@ class VfsPlugin(IVSPlg):
         recipient = self.config.account.username
         recipient = self.config.account.username
         subject_keywords = "One Time Password"
         subject_keywords = "One Time Password"
         body_keywords = "OTP"
         body_keywords = "OTP"
+        
         if not sent_at:
         if not sent_at:
             now_utc = datetime.utcnow()
             now_utc = datetime.utcnow()
             sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
             sent_at = now_utc.strftime("%Y-%m-%d %H:%M:%S")
+            
         self._log(f"Waiting for OTP email...")
         self._log(f"Waiting for OTP email...")
         content_out = VSCloudApi.Instance().fetch_mail_content(
         content_out = VSCloudApi.Instance().fetch_mail_content(
-            master_email, sender, recipient, subject_keywords, body_keywords, sent_at, 300
+            master_email, sender, recipient, subject_keywords, body_keywords, sent_at, 300, only_text=False
         )
         )
         if content_out:
         if content_out:
-            match = re.search(r'\b\d{6}\b', content_out)
+            parts = content_out.split("\n")
+            text_parts = [p for p in parts if not p.startswith("data:image")]
+            email_text = " ".join(text_parts)
+            image_urls = [p for p in parts if p.startswith("data:image")]
+            match = re.search(r'\b\d{6}\b', email_text)
             if match:
             if match:
+                self._log("Found OTP from email text via Regex.")
                 return match.group(0)
                 return match.group(0)
+            if image_urls:
+                self._log("OTP not found in text, trying LLM image recognition...")
+                target_image_b64 = image_urls[0]
+                
+                otp_code = recognize_vfs_captcha_with_qwen(target_image_b64)
+                if otp_code:
+                    self._log(f"Successfully extracted OTP from image: {otp_code}")
+                    return otp_code
         raise BizLogicError(message="OTP code not found")
         raise BizLogicError(message="OTP code not found")
 
 
 
 
@@ -968,22 +991,25 @@ class VfsPlugin(IVSPlg):
         return resp.json()
         return resp.json()
 
 
     def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
     def _upload_applicant_documents(self, apt_config, user_inputs) -> Dict:
-        """上传图片:先下载外部图片,再通过浏览器上传到 VFS"""
-        import requests as standard_requests # 使用标准库下载外部资源
-        
+        """上传图片:根据前缀判断是下载外部图片还是读取本地文件,并上传到 VFS"""
+        import requests as standard_requests
         url = f"https://{self.vfs_api_domain}/appointment/UploadApplicantDocument"
         url = f"https://{self.vfs_api_domain}/appointment/UploadApplicantDocument"
-        passport_url = user_inputs.get("passport_image_url")
-        if not passport_url:
+        passport_path_or_url = user_inputs.get("passport_image_url")
+        if not passport_path_or_url:
             raise NotFoundError(message="Missing passport_image_url")
             raise NotFoundError(message="Missing passport_image_url")
 
 
-        try:
-            img_resp = standard_requests.get(passport_url, timeout=30)
+        if passport_path_or_url.startswith("http://") or passport_path_or_url.startswith("https://"):
+            img_resp = standard_requests.get(passport_path_or_url, timeout=30)
             if img_resp.status_code != 200:
             if img_resp.status_code != 200:
                 raise BizLogicError(message=f"Failed to download passport image: {img_resp.status_code}")
                 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}")
-  
+            img_data = img_resp.content
+        else:
+            if not os.path.exists(passport_path_or_url):
+                raise BizLogicError(message=f"Local passport image file not found: {passport_path_or_url}")
+            with open(passport_path_or_url, "rb") as f:
+                img_data = f.read()
+        b64_str = base64.b64encode(img_data).decode('utf-8')
+            
         headers = self._get_common_headers(with_auth=True)
         headers = self._get_common_headers(with_auth=True)
         
         
         data = {
         data = {

+ 38 - 3
toolkit/captcha_breaker.py

@@ -2,6 +2,8 @@ import base64
 import os
 import os
 from openai import OpenAI
 from openai import OpenAI
 
 
+QWEN_API_KEY = "sk-893e895724c6403d81374e515ffaf427"
+
 def encode_image_to_base64(image_path):
 def encode_image_to_base64(image_path):
     with open(image_path, "rb") as image_file:
     with open(image_path, "rb") as image_file:
         return base64.b64encode(image_file.read()).decode('utf-8')
         return base64.b64encode(image_file.read()).decode('utf-8')
@@ -40,10 +42,43 @@ def recognize_captcha_with_qwen(image_path, api_key):
     except Exception as e:
     except Exception as e:
         print(f"Qwen 识别错误: {e}")
         print(f"Qwen 识别错误: {e}")
         return None
         return None
+    
+def recognize_vfs_captcha_with_qwen(base64_image):
+    # 利用 OpenAI 的包,调用阿里云的兼容 API 接口
+    client = OpenAI(
+        api_key=QWEN_API_KEY,
+        base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
+    )
+        
+    prompt = "图片中有多组验证码,其中OTP箭头指向的验证码为真,其余为假。只输出真实验证码的内容,不要任何多余的汉字或标点符号。"
+
+    try:
+        response = client.chat.completions.create(
+            model="qwen-vl-max", # 也可以用更便宜的 qwen-vl-plus
+            messages=[
+                {
+                    "role": "user",
+                    "content":[
+                        {"type": "text", "text": prompt},
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": base64_image
+                            }
+                        }
+                    ]
+                }
+            ],
+            temperature=0.0
+        )
+        return response.choices[0].message.content.strip()
+    except Exception as e:
+        print(f"Qwen 识别错误: {e}")
+        return None
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     # 填入阿里云百炼 (DashScope) 的 API-KEY
     # 填入阿里云百炼 (DashScope) 的 API-KEY
-    API_KEY = "sk-893e895724c6403d81374e515ffaf427"
-    IMAGE_PATH = "captcha.png"
+
+    IMAGE_PATH = "/home/jerry/workspace/coordinator/data/275c31f009e612b65eb92cb52bb1d98.png"
     
     
-    print(f"Qwen-VL 识别结果: {recognize_captcha_with_qwen(IMAGE_PATH, API_KEY)}")
+    print(f"Qwen-VL 识别结果: {recognize_vfs_captcha_with_qwen(IMAGE_PATH, QWEN_API_KEY)}")

+ 4 - 2
toolkit/vs_cloud_api.py

@@ -333,7 +333,8 @@ class VSCloudApi:
         subject_keywords: str,
         subject_keywords: str,
         body_keywords: str,
         body_keywords: str,
         sent_date: str,
         sent_date: str,
-        expiry: int
+        expiry: int,
+        only_text: bool = True,
     ) -> Optional[str]:
     ) -> Optional[str]:
         """
         """
         获取邮件内容
         获取邮件内容
@@ -345,7 +346,8 @@ class VSCloudApi:
             "subjectKeywords": subject_keywords,
             "subjectKeywords": subject_keywords,
             "bodyKeywords": body_keywords,
             "bodyKeywords": body_keywords,
             "sentDate": sent_date,
             "sentDate": sent_date,
-            "expiry": str(expiry)
+            "expiry": str(expiry),
+            "onlyText": only_text,
         }
         }
         
         
         url = f"{self.base_url}/api/email-authorizations/fetch"
         url = f"{self.base_url}/api/email-authorizations/fetch"

+ 1 - 1
utils/cloudflare_bypass_for_scraping.py

@@ -110,7 +110,7 @@ class CloudflareBypasser:
                 html = self.driver.html.lower()
                 html = self.driver.html.lower()
                 if "cloudflare" not in html:
                 if "cloudflare" not in html:
                     break
                     break
-                if "file d'attente" in html or "waiting room" in html:
+                if "file d'attente" in html or "waitting room" in html:
                     if time.time() - wait_start > 60 * 60:
                     if time.time() - wait_start > 60 * 60:
                         self.log_message("Waiting room timeout (1h).")
                         self.log_message("Waiting room timeout (1h).")
                         break
                         break