jerry 4 hónapja
commit
069ef47995

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+__pycache__
+debug_pages

+ 375 - 0
group_coordinator.py

@@ -0,0 +1,375 @@
+# group_coordinator.py
+import threading
+import time
+import random
+import os
+from typing import List, Optional
+from concurrent.futures import ThreadPoolExecutor, wait
+
+# 导入所有依赖
+from vs_types import GroupConfig, QueryWaitMode, VSPlgConfig, VSQueryResult, Task # type: ignore
+from vs_plg import IVSPlg # type: ignore
+from vs_plg_factory import VSPlgFactory # type: ignore
+from toolkit.account_manager import AccountManager # type: ignore
+from toolkit.proxy_manager import ProxyManager # type: ignore
+from toolkit.binding_manager import BindingManager # type: ignore
+from toolkit.thread_pool import ThreadPool # type: ignore
+from vs_log_macros import VSC_INFO, VSC_DEBUG, VSC_WARN, VSC_ERROR # type: ignore
+
+
+class GroupCoordinator:
+    """
+    @brief GroupCoordinator 类
+    负责管理一个组内的签证插件实例,包括实例的创建、健康检查、
+    任务调度、查询和预订流程。
+    """
+    def __init__(self, cfg: GroupConfig):
+        self.m_cfg = cfg
+        self.m_factory = VSPlgFactory() # 插件工厂实例
+        
+        self.m_tasks: List[Task] = [] # 存储所有运行中的任务实例
+        
+        self.m_stop_event = threading.Event() # 用于停止线程
+        self.m_lock = threading.RLock() # 保护共享资源 (m_tasks)
+        
+        self.m_monitor_thread: Optional[threading.Thread] = None
+        self.m_creator_thread: Optional[threading.Thread] = None
+        
+        # 预订操作的线程池,独立于任务调度
+        self.book_executor = ThreadPool(max_workers=5).getInstance() # 使用我们封装的ThreadPool
+
+        VSC_INFO("coordinator", "GroupCoordinator for '%s' initialized.", self.m_cfg.identifier)
+
+    def set_push_callback(self, cb):
+        """
+        @brief 设置推送回调函数 (C++中的PushCallback)
+        Python中可以直接传递可调用对象。
+        """
+        self.push_callback_ = cb
+        VSC_INFO("coordinator", "Push callback set for group '%s'.", self.m_cfg.identifier)
+
+    def start(self):
+        """
+        @brief 启动协调器,包括插件注册和线程启动。
+        """
+        if not self.m_cfg.enable:
+            VSC_WARN("coordinator", "Group '%s' is disabled, not starting.", self.m_cfg.identifier)
+            return
+
+        VSC_INFO("coordinator", "Starting coordinator for group '%s'...", self.m_cfg.identifier)
+        self.m_stop_event.clear()
+
+        # 注册插件
+        plugin_module_path = os.path.join(self.m_cfg.plugin_config.lib_path, f"{self.m_cfg.plugin_config.plugin_bin}")
+        
+        # === 修复点:更智能的类名推导逻辑 ===
+        # 将 snake_case (e.g., "concrete_plugin") 转换为 PascalCase (e.g., "ConcretePlugin")
+        plugin_name = self.m_cfg.plugin_config.plugin_name
+        class_name = "".join(part.title() for part in plugin_name.split('_'))
+        
+        # 调试日志:确认推导出的类名
+        VSC_DEBUG("coordinator", "Inferring class name for plugin '%s': '%s'", plugin_name, class_name)
+
+        self.m_factory.register_plugin(plugin_name, 
+                                       plugin_module_path, 
+                                       class_name)
+
+        self.m_monitor_thread = threading.Thread(target=self.monitor_loop, name=f"Monitor-{self.m_cfg.identifier}")
+        self.m_creator_thread = threading.Thread(target=self.creator_loop, name=f"Creator-{self.m_cfg.identifier}")
+        
+        self.m_monitor_thread.start()
+        self.m_creator_thread.start()
+        VSC_INFO("coordinator", "Coordinator for group '%s' threads started.", self.m_cfg.identifier)
+
+    def stop(self):
+        """
+        @brief 停止协调器,等待所有线程结束。
+        """
+        VSC_INFO("coordinator", "Stopping coordinator for group '%s'...", self.m_cfg.identifier)
+        self.m_stop_event.set() # 发送停止信号
+        
+        if self.m_monitor_thread and self.m_monitor_thread.is_alive():
+            self.m_monitor_thread.join()
+        if self.m_creator_thread and self.m_creator_thread.is_alive():
+            self.m_creator_thread.join()
+        
+        # 关闭预订线程池
+        self.book_executor.shutdown(wait=True)
+        VSC_INFO("coordinator", "Coordinator for group '%s' stopped.", self.m_cfg.identifier)
+
+    def restart(self):
+        """
+        @brief 重启协调器。
+        """
+        VSC_INFO("coordinator", "Restarting coordinator for group '%s'...", self.m_cfg.identifier)
+        self.stop()
+        self.start()
+        VSC_INFO("coordinator", "Coordinator for group '%s' restarted.", self.m_cfg.identifier)
+
+    def group_id(self) -> str:
+        """
+        @brief 获取分组ID。
+        """
+        return self.m_cfg.identifier
+
+    def monitor_loop(self):
+        """
+        @brief 监控循环:定期检查实例健康状况,执行查询任务,并根据结果触发预订。
+        """
+        VSC_INFO("coordinator", "[START] monitor loop starting for group %s", self.m_cfg.identifier)
+        rng = random.Random()
+        
+        while not self.m_stop_event.is_set():
+            sleep_ms = 0.1 + rng.randint(0, 20) / 1000.0
+            time.sleep(sleep_ms)
+            
+            now = time.time()
+            
+            # 拷贝任务列表
+            tasks_to_process = []
+            with self.m_lock:
+                tasks_to_process = list(self.m_tasks)
+            
+            for task in tasks_to_process:
+                with self.m_lock:
+                    if task not in self.m_tasks:
+                        continue 
+
+                if not task.instance.health_check():
+                    continue 
+                
+                if now < task.next_run:
+                    continue
+                
+                # 执行查询
+                is_booking_triggered = False
+                try:
+                    result = task.instance.query()
+                    if result.success:
+                        # === 关键修改:on_query_result 现在会阻塞直到抢票结束 ===
+                        self.on_query_result(task.instance, result)
+                        is_booking_triggered = True
+                    else:
+                        error = task.instance.get_last_error()
+                        if error.error_code != 2001: # 忽略 No availability found
+                             VSC_DEBUG("coordinator", "[%s] Query failed, code=%d, msg=%s", 
+                                       self.m_cfg.identifier, error.error_code, error.error_message)
+                except Exception as e:
+                    VSC_ERROR("coordinator", "[%s] Exception during query: %s", self.m_cfg.identifier, str(e))
+
+                # 计算下次运行时间
+                # 如果刚刚触发了抢票(无论成功失败),建议强制加长一点冷却时间,防止反爬
+                if is_booking_triggered:
+                    interval = rng.randint(30, 60) # 抢完票休息 30-60 秒
+                    VSC_INFO("coordinator", "[%s] Booking attempted, entering cooldown for %d sec.", self.m_cfg.identifier, interval)
+                else:
+                    interval = 30
+                    mode = task.qw_cfg.mode
+                    if mode == QueryWaitMode.Loop:
+                        interval = 0
+                    elif mode == QueryWaitMode.Fixed:
+                        interval = task.qw_cfg.fixed_wait
+                    elif mode == QueryWaitMode.Random:
+                        interval = rng.randint(task.qw_cfg.random_min, task.qw_cfg.random_max)
+                
+                task.next_run = time.time() + interval
+
+            # 清理不健康实例
+            with self.m_lock:
+                initial_size = len(self.m_tasks)
+                self.m_tasks[:] = [t for t in self.m_tasks if t.instance.health_check()]
+                if len(self.m_tasks) < initial_size:
+                    VSC_WARN("coordinator", "[%s] Removed %d unhealthy instance(s). Remaining: %d", 
+                             self.m_cfg.identifier, initial_size - len(self.m_tasks), len(self.m_tasks))
+
+        VSC_INFO("coordinator", "[STOP] monitor loop exiting for group %s", self.m_cfg.identifier)
+
+    def creator_loop(self):
+        """
+        @brief 创建者循环:根据目标实例数量,创建和补充新的插件实例。
+        """
+        VSC_INFO("coordinator", "[START] creator loop starting for group %s", self.m_cfg.identifier)
+        
+        while not self.m_stop_event.is_set():
+            time.sleep(0.1) # 避免空转太快
+            
+            diff = 0
+            with self.m_lock:
+                current_instances_count = len(self.m_tasks)
+                diff = self.m_cfg.target_instances - current_instances_count
+            
+            if diff > 0:
+                VSC_INFO("coordinator", "[%s] Need to create %d new instance(s). Current: %d, Target: %d", 
+                         self.m_cfg.identifier, diff, current_instances_count, self.m_cfg.target_instances)
+                
+                # 准备配置
+                plg_cfg = self._make_plg_config()
+                if not plg_cfg:
+                    VSC_WARN("coordinator", "[%s] Failed to prepare plugin configuration, sleeping 30s.", self.m_cfg.identifier)
+                    time.sleep(30) # 等待资源 (账户/代理) 恢复
+                    continue
+
+                # 在线程池中创建实例,模拟C++的异步创建
+                future = ThreadPool.getInstance().enqueue(self._create_instance, plg_cfg)
+                inst = future.result() # 等待创建完成
+
+                if inst:
+                    with self.m_lock:
+                        # 确保在添加到任务列表之前,实例数量仍然低于目标值
+                        if len(self.m_tasks) < self.m_cfg.target_instances:
+                            new_task = Task(
+                                instance=inst,
+                                qw_cfg=self.m_cfg.query_wait,
+                                next_run=time.time() # 立即执行第一次查询
+                            )
+                            self.m_tasks.append(new_task)
+                            VSC_INFO("coordinator", "[%s] New instance added. Total instances: %d", 
+                                     self.m_cfg.identifier, len(self.m_tasks))
+                        else:
+                            VSC_DEBUG("coordinator", "[%s] Target instances already met, discarding newly created instance.", self.m_cfg.identifier)
+                else:
+                    VSC_WARN("coordinator", "[%s] Failed to create plugin instance.", self.m_cfg.identifier)
+                    # 可以在这里添加重试逻辑或错误处理
+
+            # 模拟创建间隔,避免瞬间创建过多实例
+            time.sleep(random.uniform(1.0, 5.0))
+
+        VSC_INFO("coordinator", "[STOP] creator loop exiting for group %s", self.m_cfg.identifier)
+
+    def _make_plg_config(self) -> Optional[VSPlgConfig]:
+        """
+        @brief 准备插件配置 (账号、代理等)。
+        """
+        VSC_DEBUG("coordinator", "[%s] Preparing plugin configuration...", self.m_cfg.identifier)
+        plg_cfg = VSPlgConfig()
+        
+        # 账号配置
+        if self.m_cfg.need_account:
+            account = AccountManager.Instance().get_next_account(self.m_cfg.account_pool)
+            if not account:
+                VSC_WARN("coordinator", "[%s] No available accounts for pool '%s'", self.m_cfg.identifier, self.m_cfg.account_pool)
+                return None
+            plg_cfg.account.id = account["id"]
+            plg_cfg.account.username = account["username"]
+            plg_cfg.account.password = account["password"]
+            plg_cfg.account.lock_until = account.get("lock_until", "")
+            VSC_DEBUG("coordinator", "[%s] Using account ID %d, username %s", self.m_cfg.identifier, plg_cfg.account.id, plg_cfg.account.username)
+
+        # 代理配置
+        if self.m_cfg.need_proxy:
+            proxy = None
+            if self.m_cfg.need_ip_bind:
+                proxy_id = BindingManager.Instance().get_bounded_proxy_id(self.m_cfg.account_pool, plg_cfg.account.id)
+                if proxy_id is None: # 没有绑定代理,需要获取一个新的并绑定
+                    bounded_ids = BindingManager.Instance().get_bounded_proxies_ids(self.m_cfg.account_pool, self.m_cfg.proxy_pool)
+                    proxy = ProxyManager.Instance().get_unbind_proxy(self.m_cfg.proxy_pool, bounded_ids)
+                    if not proxy:
+                        VSC_WARN("coordinator", "[%s] No available unbind proxy in pool '%s'", self.m_cfg.identifier, self.m_cfg.proxy_pool)
+                        return None
+                    BindingManager.Instance().create_binding(
+                        self.m_cfg.account_pool, plg_cfg.account.id,
+                        self.m_cfg.proxy_pool, proxy["id"], "dynamic")
+                    VSC_INFO("coordinator", "[%s] Created dynamic binding: account %d -> proxy %d", 
+                             self.m_cfg.identifier, plg_cfg.account.id, proxy["id"])
+                else: # 已经有绑定代理,直接获取
+                    all_proxies_in_pool = ProxyManager.Instance()._proxies.get(self.m_cfg.proxy_pool, [])
+                    proxy = next((p for p in all_proxies_in_pool if p["id"] == proxy_id), None)
+                    if not proxy:
+                         VSC_ERROR("coordinator", "[%s] Bounded proxy ID %d not found in pool %s", self.m_cfg.identifier, proxy_id, self.m_cfg.proxy_pool)
+                         return None
+            else:
+                proxy = ProxyManager.Instance().get_next_proxy(self.m_cfg.proxy_pool)
+                if not proxy:
+                    VSC_WARN("coordinator", "[%s] No available proxy in pool '%s'", self.m_cfg.identifier, self.m_cfg.proxy_pool)
+                    return None
+
+            plg_cfg.proxy.id = proxy["id"]
+            plg_cfg.proxy.ip = proxy["ip"]
+            plg_cfg.proxy.port = proxy["port"]
+            plg_cfg.proxy.scheme = proxy["scheme"]
+            plg_cfg.proxy.username = proxy.get("username", "")
+            plg_cfg.proxy.password = proxy.get("password", "")
+            plg_cfg.proxy.lock_until = proxy.get("lock_until", "")
+            VSC_DEBUG("coordinator", "[%s] Using proxy ID %d, IP %s:%d", 
+                      self.m_cfg.identifier, plg_cfg.proxy.id, plg_cfg.proxy.ip, plg_cfg.proxy.port)
+
+        plg_cfg.free_config = self.m_cfg.free_config
+        VSC_DEBUG("coordinator", "[%s] Plugin configuration prepared.", self.m_cfg.identifier)
+        return plg_cfg
+
+    def _create_instance(self, plg_cfg: VSPlgConfig) -> Optional[IVSPlg]:
+        """
+        @brief 创建并初始化单个插件实例。
+        这个方法在 creator_loop 的线程池中执行。
+        """
+        VSC_DEBUG("coordinator", "[%s] Creating plugin instance (plugin=%s)...", self.m_cfg.identifier, self.m_cfg.plugin_config.plugin_name)
+        try:
+            inst = self.m_factory.create(self.m_cfg.identifier, self.m_cfg.plugin_config.plugin_name)
+            inst.set_config(plg_cfg)
+
+            success = inst.create_session()
+            
+            # 无论成功失败都锁定账号
+            if self.m_cfg.need_account and self.m_cfg.account_login_interval > 0:
+                AccountManager.Instance().lock_account(
+                    self.m_cfg.account_pool, plg_cfg.account.id, self.m_cfg.account_login_interval * 60)
+
+            if not success:
+                error = inst.get_last_error()
+                VSC_ERROR("coordinator", "[%s] Create session failed, code=%d, msg=%s",
+                          self.m_cfg.identifier, error.error_code, error.error_message)
+                return None
+            
+            VSC_INFO("coordinator", "[%s] Plugin instance created and session established.", self.m_cfg.identifier)
+            return inst
+        except Exception as e:
+            VSC_ERROR("coordinator", "[%s] Error creating plugin instance: %s", self.m_cfg.identifier, str(e))
+            return None
+
+    def on_query_result(self, sptr: IVSPlg, query_result: VSQueryResult):
+        """
+        @brief 处理查询结果
+        *** 关键修改 ***
+        这里会阻塞直到所有发起的预订任务完成。这防止了 Monitor Loop 继续运行导致
+        同一个账号在抢票的同时又发起新的 Query。
+        """
+        VSC_INFO("coordinator", "[%s] Query result received. BLOCKING monitor loop for booking...", self.m_cfg.identifier)
+
+        # 获取所有当前健康的实例进行并发预订 (通常是当前找到票的这个实例,或者是组内所有实例)
+        # 策略:
+        # 1. 激进策略:所有实例一起抢 (可能导致互踢)
+        # 2. 保守策略:仅当前实例抢 (sptr)
+        # 这里使用保守策略,避免多实例同时操作一个 Pool 里的不同账号去抢同一个 Slot 导致资源竞争过大
+        # 如果你想多实例抢,可以使用 self.m_tasks 里的实例。
+        
+        # 即使只用当前实例,也放入 list 统一处理逻辑
+        instances_for_booking = [sptr] 
+        
+        def book_task(inst: IVSPlg, result: VSQueryResult):
+            try:
+                VSC_INFO("coordinator", "[%s] Starting book() procedure...", inst.get_group_id())
+                book_res = inst.book(result)
+                if book_res.success:
+                    VSC_INFO("coordinator", "[%s] Booking SUCCESS! Order: '%s'", 
+                             inst.get_group_id(), book_res.order_id)
+                    if hasattr(self, 'push_callback_') and self.push_callback_:
+                        self.push_callback_(100, f"Booking Success: {book_res.order_id}".encode('utf-8'), 0) 
+                else:
+                    error = inst.get_last_error()
+                    VSC_ERROR("coordinator", "[%s] Booking FAILED. Code=%d, Msg=%s", 
+                              inst.get_group_id(), error.error_code, error.error_message)
+            except Exception as e:
+                VSC_ERROR("coordinator", "[%s] Exception during booking: %s", inst.get_group_id(), str(e))
+        
+        # 1. 提交任务到线程池并获取 Future 对象
+        futures = []
+        for inst in instances_for_booking:
+            f = self.book_executor.enqueue(book_task, inst, query_result)
+            futures.append(f)
+        
+        # 2. === 阻塞等待 ===
+        # wait 会阻塞当前线程 (Monitor Thread),直到所有 future 完成
+        # 这样确保了抢票期间不会发起新的 Query
+        VSC_INFO("coordinator", "[%s] Waiting for booking tasks to complete...", self.m_cfg.identifier)
+        wait(futures)
+        VSC_INFO("coordinator", "[%s] Booking tasks completed. Resuming monitor loop.", self.m_cfg.identifier)

+ 281 - 0
main.py

@@ -0,0 +1,281 @@
+# main.py
+import time
+import os
+import json
+import logging
+
+# 导入必要模块
+from vs_types import GroupConfig, QueryWaitMode, PluginConfig, QueryWaitConfig # type: ignore
+from group_coordinator import GroupCoordinator # type: ignore
+from vs_log_macros import VSC_INFO, VSC_ERROR # type: ignore
+
+def vfs_test():
+    # 0. 检查目录结构
+    if not os.path.exists("plugins/vfs_plugin.py"):
+        print("[ERROR] 找不到插件文件 'plugins/vfs_plugin.py'")
+        print("请将上一条回答中的代码保存为该文件。")
+        return
+
+    # 1. 准备 VFS 业务配置 (free_config)
+    # 这部分 JSON 对应 VfsPlugin 中读取的配置
+    vfs_config = {
+        "verbose": 0,
+        "missionCode": "fra",
+        "missionName": "France",
+        "countryCode": "sgp",
+        "countryName": "Singapore",
+        "cultureCode": "en-US",
+        "language": "en",
+        "website": "https://visa.vfsglobal.com/sgp/en/fra/login",
+        "appointmentType": [
+            {
+                "id": 538,
+                "routing_key": "slot.vfs.sin.fr.tourist",
+                "centerName": "France Visa Application Center, Singapore",
+                "city": "Singapore",
+                "country": "France",
+                "visa_type": "Tourist",
+                "address": "79 Anson Road #15-01 Singapore 079906",
+                "vacCode": "FRSN",
+                "categoryName": "Short Stay",
+                "categoryCode": "02",
+                "subcategoryName": "Short Stay Tourist, Family Visit",
+                "subcategoryCode": "Six",
+                "fee": None,
+                "currency": None
+            }
+        ]
+    }
+
+    # 2. 构造查询策略配置
+    query_wait_config = QueryWaitConfig(
+        mode=QueryWaitMode.Random,
+        random_min=5,  # 最小间隔 5秒
+        random_max=10  # 最大间隔 10秒
+    )
+
+    # 3. 构造插件加载配置
+    # 注意:plugin_name="vfs_plugin" 会被自动推导为类名 "VfsPlugin"
+    plugin_config = PluginConfig(
+        lib_path="plugins",             
+        plugin_name="vfs_plugin", 
+        plugin_bin="vfs_plugin.py",
+        plugin_proto="IVSPlg"
+    )
+    
+    # 4. 构造任务组配置
+    group_config = GroupConfig(
+        identifier="Singapore_France_Visas", # 组名
+        enable=True,                    # 启用
+        need_account=True,              # 需要登录账号
+        account_pool="ie_nl",           # 使用 AccountManager 中的 fr_pool (在 account_manager.py 中预设)
+        need_proxy=True,                # 需要代理
+        proxy_pool="global_proxy",      # 使用 ProxyManager 中的 global_proxy
+        need_ip_bind=False,             # 不强制 IP 绑定
+        account_login_interval=10,      # 登录失败/成功后锁定账号 10 分钟
+        target_instances=1,             # 启动 2 个并发实例 (线程)
+        query_wait=query_wait_config,
+        plugin_config=plugin_config,
+        free_config=json.dumps(vfs_config) # 将业务配置转为 JSON 字符串
+    )
+
+    # 5. 创建协调器
+    coordinator = GroupCoordinator(group_config)
+
+    # 6. 设置回调 (模拟 WebSocket 推送或日志记录)
+    def my_push_callback(type_code: int, data: bytes, size: int):
+        msg = data.decode('utf-8')
+        VSC_INFO("main", ">>> [PUSH CALLBACK] Type: %d, Message: %s", type_code, msg)
+    
+    coordinator.set_push_callback(my_push_callback)
+
+    # 7. 启动
+    try:
+        VSC_INFO("main", "========================================")
+        VSC_INFO("main", "   VFS Python Plugin Tester      ")
+        VSC_INFO("main", "========================================")
+        
+        coordinator.start()
+        
+        time.sleep(3600)
+    except KeyboardInterrupt:
+        VSC_INFO("main", "Ctrl+C detected. Stopping...")
+    except Exception as e:
+        VSC_ERROR("main", "Unexpected Error: %s", str(e))
+    finally:
+        # 8. 停止
+        coordinator.stop()
+        VSC_INFO("main", "Program finished.")
+        
+        
+def tls_test():
+    # 0. 检查目录结构
+    if not os.path.exists("plugins/tls_plugin.py"):
+        print("[ERROR] 找不到插件文件 'plugins/tls_plugin.py'")
+        print("请将上一条回答中的代码保存为该文件。")
+        return
+
+    # 1. 准备 VFS 业务配置 (free_config)
+    # 这部分 JSON 对应 TlsPlugin 中读取的配置
+    tls_config = {
+        "verbose": 0,
+        "embassy_code": "gbLON2fr",
+        "country_code": "gb",
+        "mission_code": "fr",
+        "city": "london",
+        "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
+        "interest_month": "01-2026",
+        "target_labels": ["", "pta"],
+        'website': 'https://visas-fr.tlscontact.com/country/gb/vac/gbLON2fr/'
+    }
+
+    # 2. 构造查询策略配置
+    query_wait_config = QueryWaitConfig(
+        mode=QueryWaitMode.Random,
+        random_min=60,  # 最小间隔 5秒
+        random_max=300  # 最大间隔 10秒
+    )
+
+    # 3. 构造插件加载配置
+    # 注意:plugin_name="tls_plugin" 会被自动推导为类名 "TlsPlugin"
+    plugin_config = PluginConfig(
+        lib_path="plugins",             
+        plugin_name="tls_plugin", 
+        plugin_bin="tls_plugin.py",
+        plugin_proto="IVSPlg"
+    )
+    
+    # 4. 构造任务组配置
+    group_config = GroupConfig(
+        identifier="London_France_Visas", # 组名
+        enable=True,                    # 启用
+        need_account=True,              # 需要登录账号
+        account_pool="gb_fr",           # 使用 AccountManager 中的 uk_pool (在 account_manager.py 中预设)
+        need_proxy=True,                # 需要代理
+        proxy_pool="global_proxy",      # 使用 ProxyManager 中的 global_proxy
+        need_ip_bind=False,             # 不强制 IP 绑定
+        account_login_interval=10,      # 登录失败/成功后锁定账号 10 分钟
+        target_instances=1,             # 启动 2 个并发实例 (线程)
+        query_wait=query_wait_config,
+        plugin_config=plugin_config,
+        free_config=json.dumps(tls_config) # 将业务配置转为 JSON 字符串
+    )
+
+    # 5. 创建协调器
+    coordinator = GroupCoordinator(group_config)
+
+    # 6. 设置回调 (模拟 WebSocket 推送或日志记录)
+    def my_push_callback(type_code: int, data: bytes, size: int):
+        msg = data.decode('utf-8')
+        VSC_INFO("main", ">>> [PUSH CALLBACK] Type: %d, Message: %s", type_code, msg)
+    
+    coordinator.set_push_callback(my_push_callback)
+
+    # 7. 启动
+    try:
+        VSC_INFO("main", "========================================")
+        VSC_INFO("main", "    TLS Python Plugin Tester      ")
+        VSC_INFO("main", "========================================")
+        
+        coordinator.start()
+        
+        time.sleep(3600)
+    except KeyboardInterrupt:
+        VSC_INFO("main", "Ctrl+C detected. Stopping...")
+    except Exception as e:
+        VSC_ERROR("main", "Unexpected Error: %s", str(e))
+    finally:
+        # 8. 停止
+        coordinator.stop()
+        VSC_INFO("main", "Program finished.")
+        
+def bls_test():
+    # 0. 环境检查
+    if not os.path.exists("plugins/bls_plugin.py"):
+        print("[ERROR] 找不到插件文件 'plugins/bls_plugin.py'")
+        return
+
+    # 2. 准备 BLS 业务配置 (Free Config)
+    # 这些字段会被 BlsPlugin 读取并用于填表和逻辑判断
+    bls_config = {
+        "domain": "ireland.blsspainglobal.com", # 目标域名
+        "ocr_service_url": "http://127.0.0.1:8085/predict/vfcode", # OCR 服务地址
+        
+        # 签证参数 (根据实际网站下拉框的值填写)
+        "location": "Dublin",
+        "jurisdiction": None,
+        "visaType": "Schengen Visa/ Short Term Visa",
+        "visaSubType": "Tourist Visa",
+        "appointmentCategory": "Normal",
+        
+        # 申请人信息 (用于 book 阶段)
+        "user_info": {
+            "first_name": "John",
+            "last_name": "Doe",
+            "passport_no": "E12345678",
+            "passport_issue_date": "2020-01-01",
+            "passport_expiry_date": "2030-01-01",
+            "birthday": "1990-01-01",
+            "phone_no": "447700900000",
+            "nationality": "India",
+            "gender": "Male",
+            "passport_image_url": "https://via.placeholder.com/600x400.jpg" # 模拟图片URL
+        }
+    }
+
+    # 3. 构造查询策略
+    query_wait_config = QueryWaitConfig(
+        mode=QueryWaitMode.Fixed,
+        fixed_wait=10 # 每次查询间隔 10 秒
+    )
+
+    # 4. 构造插件配置
+    # plugin_name="bls_plugin" -> 自动推导类名 "BlsPlugin"
+    plugin_config = PluginConfig(
+        lib_path="plugins",             
+        plugin_name="bls_plugin", 
+        plugin_bin="bls_plugin.py",
+        plugin_proto="IVSPlg"
+    )
+    
+    # 5. 构造任务组配置
+    group_config = GroupConfig(
+        identifier="Dublin_Spain_Visas",  # 任务组 ID
+        enable=True,
+        need_account=True,
+        account_pool="ie_es",           # 使用刚才注入的池子
+        need_proxy=True,
+        proxy_pool="ireland_proxies",      # 使用默认代理池
+        target_instances=1,             # 启动 1 个实例
+        account_login_interval=5,       # 登录间隔/锁定时间
+        query_wait=query_wait_config,
+        plugin_config=plugin_config,
+        free_config=json.dumps(bls_config)
+    )
+
+    # 6. 创建协调器
+    coordinator = GroupCoordinator(group_config)
+
+    # 7. 设置回调 (监听抢票成功消息)
+    def push_callback(type_code, data, size):
+        msg = data.decode('utf-8')
+        VSC_INFO("CALLBACK", f"Received Push: Type={type_code}, Msg={msg}")
+    
+    coordinator.set_push_callback(push_callback)
+
+    # 8. 启动运行
+    try:
+        VSC_INFO("main", ">>> Starting BLS Plugin Test...")
+        coordinator.start()
+        
+        time.sleep(3600)
+    except KeyboardInterrupt:
+        VSC_INFO("main", "Ctrl+C detected.")
+    except Exception as e:
+        VSC_ERROR("main", f"Unexpected Error: {e}")
+    finally:
+        coordinator.stop()
+        VSC_INFO("main", "Test Finished.")
+
+if __name__ == "__main__":
+    bls_test()

+ 0 - 0
plugins/__init__.py


+ 463 - 0
plugins/bls_plugin.py

@@ -0,0 +1,463 @@
+import re
+import base64
+import time
+import json
+import random
+import string
+from urllib.parse import urlparse, parse_qs, urlencode
+from typing import Dict, List, Optional, Any
+
+try:
+    from curl_cffi import requests, const
+    from bs4 import BeautifulSoup
+except ImportError:
+    raise ImportError("Missing dependencies. Run: pip install curl-cffi beautifulsoup4")
+
+# 框架依赖
+from vs_plg import IVSPlg, VSError # type: ignore
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus # type: ignore
+from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
+from toolkit.vs_cloud_api import VSCloudApi # type: ignore
+
+
+class BlsPlugin(IVSPlg):
+    """
+    BLS 签证预约插件 (精简版)
+    """
+
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        self.session: Optional[requests.Session] = None
+        
+        # 运行时状态
+        self.book_params: Dict = {} 
+        self.last_error = VSError(0, "OK")
+        self.is_healthy = True
+        
+        # OCR 服务地址默认值
+        self.ocr_service_url = "http://127.0.0.1:8085/predict/vfcode"
+
+    def get_group_id(self) -> str:
+        return self.group_id
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        try:
+            self.free_config = json.loads(config.free_config) if config.free_config else {}
+        except:
+            self.free_config = {}
+        
+        # 从配置中读取 OCR 服务地址,如果没有则使用默认
+        if self.free_config.get("ocr_service_url"):
+            self.ocr_service_url = self.free_config["ocr_service_url"]
+
+    def health_check(self) -> bool:
+        return self.is_healthy
+
+    def get_last_error(self) -> VSError:
+        return self.last_error
+
+    def _set_error(self, code: int, message: str):
+        self.last_error = VSError(code, message)
+        VSC_ERROR("bls_plg", "[%s] Error %d: %s", self.group_id, code, message)
+        if code in [401, 403]: self.is_healthy = False
+
+    # =========================================================================
+    # 1. 登录流程 (Login)
+    # =========================================================================
+    def create_session(self) -> bool:
+        VSC_INFO("bls_plg", "[%s] Creating session...", self.group_id)
+        self.is_healthy = True
+        
+        # 初始化 Session
+        self.session = requests.Session(
+            proxy=self._get_proxy_url(),
+            impersonate="chrome131",
+            curl_options={const.CurlOpt.MAXAGE_CONN: 1800, const.CurlOpt.VERBOSE: False}
+        )
+        domain = self.free_config.get("domain")
+        if not domain: return False
+
+        # 1.1 获取登录页 & 解析参数
+        url = f"https://{domain}/Global/account/login"
+        resp = self._request("GET", url)
+        if not resp: return False
+        
+        soup = BeautifulSoup(resp.text, 'html.parser')
+        form_data = self._extract_hidden_fields(soup)
+        
+        # 解析动态 ID (UserId1, Password1 等)
+        for inp in soup.find_all('input'):
+            iid = inp.get('id', '')
+            if 'UserId' in iid and re.search(r'\d+', iid):
+                form_data["UserIdKey"] = iid # 暂存 Key
+                form_data["UserId"] = re.search(r'\d+', iid).group(0)
+            if 'Password' in iid and re.search(r'\d+', iid):
+                form_data["PasswordKey"] = iid # 暂存 Key
+                form_data["Password"] = re.search(r'\d+', iid).group(0)
+        
+        # 解析 data 参数 (用于验证码)
+        data_val = self._extract_js_var(resp.text, "iframeOpenUrl", r"data=([^&]+)")
+        
+        # 1.2 处理验证码
+        captcha_token = self._solve_bls_captcha(data_val, 'Global/account/login')
+        if not captcha_token: return False
+        
+        # 1.3 提交登录
+        submit_url = f"https://{domain}/Global/account/loginsubmit"
+        payload = form_data
+        payload["X-Requested-With"] = "XMLHttpRequest"
+        payload["CaptchaData"] = captcha_token
+        # 填入账号密码
+        if "UserIdKey" in form_data: payload[form_data["UserIdKey"]] = self.config.account.username
+        if "PasswordKey" in form_data: payload[form_data["PasswordKey"]] = self.config.account.password
+        
+        login_res = self._request("POST", submit_url, data=payload, headers={"Referer": url})
+        if login_res and login_res.json().get('success'):
+            VSC_INFO("bls_plg", "[%s] Login Successful", self.group_id)
+            return True
+            
+        self._set_error(2000, "Login Failed")
+        return False
+
+    # =========================================================================
+    # 2. 查询流程 (Query)
+    # =========================================================================
+    def query(self) -> VSQueryResult:
+        res = VSQueryResult()
+        domain = self.free_config.get("domain")
+        if not self.session: return res
+
+        # 2.1 签证类型验证 (VisaTypeVerification)
+        url_vtv = f"https://{domain}/Global/bls/visatypeverification"
+        resp = self._request("GET", url_vtv)
+        if not resp: return res
+        
+        form_vtv = self._extract_hidden_fields(BeautifulSoup(resp.text, 'html.parser'))
+        captcha_token = self._solve_bls_captcha(referer='Global/bls/visatypeverification')
+        if not captcha_token: return res
+        
+        form_vtv['CaptchaData'] = captcha_token
+        form_vtv["X-Requested-With"] = "XMLHttpRequest"
+        
+        vtv_res = self._request("POST", f"https://{domain}/Global/bls/VisaTypeVerification", data=form_vtv, headers={"Referer": url_vtv})
+        if not vtv_res or not vtv_res.json().get('success'): return res
+        
+        # 2.2 签证类型选择 (VisaType)
+        return_url = vtv_res.json()['returnUrl'] # 包含 data=xxx
+        data_val = re.search(r"data=([^&]+)", return_url).group(1)
+        
+        url_vt = f"https://{domain}/Global/bls/visatype?data={data_val}"
+        resp_vt = self._request("GET", url_vt)
+        if not resp_vt: return res
+        
+        # 这里需要极其复杂的 JS 变量提取 (JS Arrays -> Match Name -> Get ID)
+        # 为了缩减篇幅,假设 _construct_visatype_payload 封装了这些逻辑
+        vt_payload = self._construct_visatype_payload(resp_vt.text, BeautifulSoup(resp_vt.text, 'html.parser'))
+        if not vt_payload: return res
+        
+        vt_res = self._request("POST", f"https://{domain}/Global/bls/VisaType", data=vt_payload, headers={"Referer": url_vt})
+        if not vt_res or not vt_res.json().get('success'):
+            if vt_res and not vt_res.json().get('available'):
+                res.success = True
+                res.availability_status = AvailabilityStatus.NoneAvailable
+            return res
+
+        # 2.3 获取预约参数 (Book Params)
+        final_url = vt_res.json()['returnUrl']
+        q_params = parse_qs(urlparse(final_url).query)
+        self.book_params = {k: v[0] for k, v in q_params.items()}
+        
+        # 2.4 查询日历 (ManageAppointment)
+        url_ma = f"https://{domain}/Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}"
+        resp_ma = self._request("GET", url_ma)
+        if not resp_ma: return res
+        
+        avail_str = self._extract_js_var(resp_ma.text, "var availDates", r"var availDates =(.*?);")
+        if avail_str:
+            avail_json = json.loads(avail_str)
+            # 提取日期
+            dates = [x['DateText'] for x in avail_json['ad'] if x['SingleSlotAvailable']]
+            
+            if dates:
+                res.success = True
+                res.availability_status = AvailabilityStatus.Available
+                res.earliest_date = dates[0]
+                for d in dates:
+                    da = VSQueryResult.DateAvailability(date=d)
+                    da.times.append(VSQueryResult.DateAvailability.TimeSlot(time="00:00", label="Available"))
+                    res.availability.append(da)
+            else:
+                res.success = True
+                res.availability_status = AvailabilityStatus.NoneAvailable
+                
+        return res
+
+    # =========================================================================
+    # 3. 预约流程 (Book)
+    # =========================================================================
+    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+        res = VSBookResult()
+        domain = self.free_config.get("domain")
+        if not self.book_params: return res
+        
+        uinfo = self.free_config.get("user_info", {})
+        
+        # 3.1 获取 Manage Page (为了 Token 和 JS 变量)
+        url_ma = f"https://{domain}/Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}"
+        resp_ma = self._request("GET", url_ma)
+        if not resp_ma: return res
+        ma_soup = BeautifulSoup(resp_ma.text, 'html.parser')
+        ma_form = self._extract_hidden_fields(ma_soup)
+        req_token = ma_form.get('__RequestVerificationToken')
+        
+        # 3.2 上传照片
+        if 'passport_image_url' in uinfo:
+            photo_bytes = requests.get(uinfo['passport_image_url']).content
+            boundary = "----WebKitFormBoundary" + "".join(random.choices(string.ascii_letters + string.digits, k=16))
+            upload_headers = {
+                "content-type": f"multipart/form-data; boundary={boundary}",
+                "requestverificationtoken": req_token,
+                "x-requested-with": "XMLHttpRequest",
+                "Referer": url_ma
+            }
+            body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n"
+                    f"Content-Type: image/jpeg\r\n\r\n").encode("utf-8") + photo_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
+            
+            up_res = self.session.post(f"https://{domain}/Global/query/UploadProfileImage", headers=upload_headers, data=body)
+            if up_res.status_code == 200:
+                ma_form['ApplicantPhotoId'] = up_res.json()['fileId']
+
+        # 3.3 邮箱 OTP 流程
+        data_val = self._extract_js_var(resp_ma.text, "win.iframeOpenUrl", r"data=([^&]+)")
+        # 发送 OTP
+        self._request("GET", f"https://{domain}/Global/blsappointment/SendAppointmentVerificationCode?code={data_val}", headers={"Referer": url_ma, "X-Requested-With": "XMLHttpRequest"})
+        
+        # 读取 OTP (Wait 30s max)
+        otp_code = self._read_otp_email(wait_sec=30)
+        if not otp_code: 
+            self._set_error(3004, "OTP timeout")
+            return res
+            
+        # 验证 OTP
+        verify_payload = {"Code": otp_code, "Value": ma_form.get('EmailCode'), "Id": ma_form.get('Id')}
+        v_res = self._request("POST", f"https://{domain}/Global/blsappointment/VerifyEmail", data=verify_payload, headers={"Referer": url_ma, "requestverificationtoken": req_token})
+        if not v_res or not v_res.json().get('success'): return res
+        
+        ma_form['EmailVerified'] = 'True'
+        ma_form['EmailVerificationCode'] = otp_code
+
+        # 3.4 锁定时间 (简单随机)
+        target_date = slot_info.earliest_date
+        # Query Slots in Day
+        slot_url = f"https://{domain}/Global/blsappointment/GetAvailableSlotsByDate"
+        # 构造复杂的 query params... 省略部分非关键参数
+        slot_params = {
+            "appointmentDate": target_date,
+            "locationId": ma_form.get("LocationId"),
+            "categoryId": ma_form.get("AppointmentCategoryId"),
+            "visaType": ma_form.get("VisaType"),
+            "visaSubType": ma_form.get("VisaSubTypeId"),
+            "applicantCount": 1,
+            "dataSource": ma_form.get("DataSource"),
+            "missionId": ma_form.get("MissionId")
+        }
+        slots_res = self._request("POST", slot_url, params=slot_params, headers={"Referer": url_ma, "requestverificationtoken": req_token})
+        if not slots_res: return res
+        
+        slots_data = sorted(slots_res.json(), key=lambda x: -x["Count"]) # 选剩余最多的
+        if not slots_data or slots_data[0]['Count'] <= 0: return res
+        
+        target_time = slots_data[0]['Name']
+        ma_form['ServerAppointmentDate'] = target_date
+        ma_form['AppointmentDetailsList'] = '[]'
+        # 这里的 key 是动态的 ID,需重新解析 ID
+        date_id = re.search(r'AppointmentDate(\d+)', str(ma_soup)).group(1)
+        slot_id = re.search(r'AppointmentSlot(\d+)', str(ma_soup)).group(1)
+        ma_form[f'AppointmentDate{date_id}'] = target_date
+        ma_form[f'AppointmentSlot{slot_id}'] = target_time
+
+        # 3.5 再次验证码 & 提交 ManageAppointment
+        captcha_token = self._solve_bls_captcha(data_val, f'Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}')
+        if not captcha_token: return res
+        ma_form['CaptchaData'] = captcha_token
+        
+        final_ma_res = self._request("POST", f"https://{domain}/Global/BLSAppointment/ManageAppointment", data=ma_form, headers={"Referer": url_ma})
+        if not final_ma_res: return res
+        
+        appt_model_id = final_ma_res.json().get('model', {}).get('Id')
+        if not appt_model_id: return res
+
+        # 3.6 填写申请表 (VisaAppointmentForm)
+        # 获取页面 -> 解析 JS 变量 -> 映射 UserInfo -> 提交
+        # 这里逻辑较深,核心是映射。简化为提交一个空的 applicants JSON,实际需完整映射。
+        # 假设 _fill_applicant_form 做了这些工作
+        if self._submit_final_form(appt_model_id, uinfo, self.book_params, req_token):
+            # 成功,返回 Liveness 链接
+            res.success = True
+            res.session_id = self._generate_id()
+            res.order_id = res.session_id
+            res.payment_link = f"https://{domain}/Global/BlsAppointment/livenessView?id={appt_model_id}"
+            
+            # 将 Session 信息存入 Cloud 以便前端接管
+            self._save_session_to_cloud(res.session_id, res.payment_link)
+            VSC_INFO("bls_plg", "[%s] Book Success. Liveness URL: %s", self.group_id, res.payment_link)
+            
+        return res
+
+    # =========================================================================
+    # 辅助函数 (Helpers)
+    # =========================================================================
+
+    def _request(self, method, url, **kwargs):
+        print(f'_request {method} {url}')
+        headers = {
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
+        }
+        if 'headers' in kwargs: headers.update(kwargs['headers'])
+        kwargs['headers'] = headers
+        
+        try:
+            resp = self.session.request(method, url, timeout=60, **kwargs)
+            if resp.status_code == 401: self._set_error(401, "Unauthorized")
+            elif resp.status_code in [403, 429]: self._set_error(resp.status_code, "Blocked")
+            if resp.status_code == 200: return resp
+        except Exception as e:
+            VSC_WARN("bls_plg", f"Request Error: {e}")
+        return None
+
+    def _solve_bls_captcha(self, data='', referer='') -> Optional[str]:
+        """
+        验证码处理:获取图片 -> 调用远程 OCR 服务 -> 提交验证
+        """
+        domain = self.free_config.get("domain")
+        url = f"https://{domain}/Global/NewCaptcha/GenerateCaptcha"
+        if data: url = f"https://{domain}/Global/CaptchaPublic/GenerateCaptcha?data={data}"
+        
+        resp = self._request("GET", url, headers={"Referer": f"https://{domain}/{referer}"})
+        if not resp: return None
+        
+        soup = BeautifulSoup(resp.text, 'html.parser')
+        
+        # 1. 提取目标数字
+        target_text = soup.get_text() 
+        target_match = re.search(r'Select\s*(\d+)', target_text)
+        if not target_match: return None
+        target_num = target_match.group(1)
+        
+        selected_ids = []
+        
+        # 2. 遍历图片并调用远程 OCR
+        imgs = soup.find_all('img', class_='captcha-img')
+        for img in imgs:
+            src = img.get('src', '')
+            if 'base64,' in src:
+                b64 = src.split('base64,')[1]
+                img_bytes = base64.b64decode(b64)
+                
+                # === 远程调用 Start ===
+                try:
+                    # 直接发送二进制 body
+                    ocr_resp = requests.post(
+                        self.ocr_service_url, 
+                        data=img_bytes, 
+                        headers={"Content-Type": "application/octet-stream"},
+                        timeout=5
+                    )
+                    if ocr_resp.status_code == 200:
+                        res_json = ocr_resp.json()
+                        ocr_res = res_json.get('data', '').replace('$', '')
+                        
+                        VSC_DEBUG("bls_plg", f"OCR: {ocr_res} (Target: {target_num})")
+                        
+                        if ocr_res == target_num:
+                            selected_ids.append(img.get('id'))
+                except Exception as e:
+                    VSC_WARN("bls_plg", f"OCR Service Failed: {e}")
+                # === 远程调用 End ===
+        
+        if not selected_ids: return None 
+        
+        # 3. 提交选中结果
+        form = self._extract_hidden_fields(soup)
+        form['SelectedImages'] = ",".join(selected_ids)
+        submit_url = f"https://{domain}/Global/{'CaptchaPublic' if data else 'NewCaptcha'}/SubmitCaptcha"
+        
+        res = self._request("POST", submit_url, data=form, headers={"X-Requested-With": "XMLHttpRequest", "Referer": url})
+        if res and res.json().get('captcha'):
+            return res.json()['captcha']
+        return None
+
+    def _extract_hidden_fields(self, soup) -> Dict:
+        params = {}
+        form = soup.find("form")
+        if form:
+            for inp in form.find_all("input"):
+                name = inp.get("name")
+                if name: params[name] = inp.get("value", "")
+        return params
+
+    def _extract_js_var(self, html, context, pattern):
+        # 简单正则提取
+        if context in html:
+            match = re.search(pattern, html)
+            if match: return match.group(1)
+        return ""
+
+    def _construct_visatype_payload(self, html, soup):
+        # 简化版:提取 ID 逻辑。实际需根据 free_config 的 VisaType 名称匹配 JS 数组中的 ID
+        # 这里仅展示结构,核心是利用 self.free_config['visaType'] 等去匹配
+        params = self._extract_hidden_fields(soup)
+        
+        # Helper inner function to find ID from JS array
+        def find_id(var_name, target_name, key="Name", val_key="Id"):
+            json_str = self._extract_js_var(html, f"var {var_name}", rf"var {var_name}\s*=\s*(.*?);")
+            if json_str:
+                try:
+                    data = json.loads(json_str)
+                    for item in data:
+                        if item.get(key) == target_name: return item.get(val_key)
+                except: pass
+            return None
+
+        # 示例:Jurisdiction
+        if self.free_config.get('jurisdiction'):
+            jid = find_id("jurisdictionData", self.free_config['jurisdiction'])
+            if jid: params[f'JurisdictionId{jid}'] = jid # 这里的 Key 也是动态的,BLS 特色
+            
+        # ... 对 Location, VisaType, VisaSubType 重复此逻辑 ...
+        
+        params["X-Requested-With"] = "XMLHttpRequest"
+        params["ResponseData"] = "[]" # 必须字段
+        return params
+
+    def _submit_final_form(self, model_id, uinfo, book_params, token):
+        # 1. Get Form HTML -> 2. Parse JS Data -> 3. Map uinfo -> 4. Post
+        # 略,参考原代码 parse_application_form_excel 和 _fix_applicant_data
+        # 这是一个纯数据映射过程
+        return True
+
+    def _read_otp_email(self, wait_sec=30):
+        # 轮询 Cloud API
+        for _ in range(wait_sec // 5):
+            time.sleep(5)
+            # content = VSCloudApi.Instance().fetch_mail_content(...)
+            # ...
+            pass
+        return "123456" # Mock
+
+    def _save_session_to_cloud(self, sid, url):
+        cookies = json.dumps(requests.utils.dict_from_cookiejar(self.session.cookies))
+        VSCloudApi.Instance().create_http_session(sid, cookies, "", "", "", url, {})
+
+    def _get_proxy_url(self):
+        p = self.config.proxy
+        if not p.ip: return ""
+        if p.username: return f"{p.scheme}://{p.username}:{p.password}@{p.ip}:{p.port}"
+        return f"{p.scheme}://{p.ip}:{p.port}"
+
+    def _generate_id(self):
+        return "".join(random.choices(string.ascii_letters + string.digits, k=8))

+ 126 - 0
plugins/concrete_plugin.py

@@ -0,0 +1,126 @@
+# plugins/concrete_plugin.py
+import time
+import random
+from vs_plg import IVSPlg, VSError # type: ignore
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
+from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
+
+class ConcretePlugin(IVSPlg):
+    """
+    @brief 具体的签证插件实现示例
+    实现了 IVSPlg 接口,模拟签证查询和预订逻辑。
+    """
+    def __init__(self, group_id: str):
+        self._group_id = group_id
+        self._config: Optional[VSPlgConfig] = None
+        self._is_healthy = True
+        self._last_error: VSError = VSError(0, "No error")
+        VSC_INFO("plugin", "[%s] ConcretePlugin initialized.", self._group_id)
+
+    def set_config(self, config: VSPlgConfig):
+        """
+        @brief 设置 API 的配置信息
+        @param config 签证 API 配置对象
+        """
+        self._config = config
+        VSC_DEBUG("plugin", "[%s] Config set: Account ID=%d, Proxy IP=%s:%d",
+                  self._group_id, config.account.id, config.proxy.ip, config.proxy.port)
+
+    def create_session(self) -> bool:
+        """
+        @brief 创建一个新的会话
+        模拟登录耗时,有概率失败。
+        """
+        VSC_INFO("plugin", "[%s] Creating session...", self._group_id)
+        time.sleep(0.5) # 模拟耗时
+        if random.random() < 0.1: # 10% 概率创建失败
+            self._last_error = VSError(1001, "Session creation failed due to network error.")
+            VSC_ERROR("plugin", "[%s] Session creation failed.", self._group_id)
+            return False
+        
+        VSC_INFO("plugin", "[%s] Session created successfully.", self._group_id)
+        return True
+
+    def query(self) -> VSQueryResult:
+        """
+        @brief 查询可用的签证预约信息
+        模拟查询,有10%概率找到可预约时段。
+        """
+        VSC_DEBUG("plugin", "[%s] Performing query...", self._group_id)
+        # 模拟查询耗时
+        time.sleep(random.uniform(0.1, 0.5)) 
+
+        result = VSQueryResult()
+        if random.random() < 0.1: # 10% 概率找到可预约
+            result.success = True
+            result.availability_status = AvailabilityStatus.Available
+            result.earliest_date = "2025-10-01"
+            result.visa_type = "Shengen"
+            result.city = "Paris"
+            result.country = "France"
+            
+            # 模拟可用时间
+            slot1 = VSQueryResult.DateAvailability.TimeSlot(time="09:00", label="Standard")
+            slot2 = VSQueryResult.DateAvailability.TimeSlot(time="10:30", label="Premium")
+            date_avail = VSQueryResult.DateAvailability(date="2025-10-01", times=[slot1, slot2])
+            result.availability.append(date_avail)
+            
+            VSC_INFO("plugin", "[%s] Query found availability for %s", self._group_id, result.earliest_date)
+        else:
+            result.success = False
+            self._last_error = VSError(2001, "No availability found at this time.")
+            VSC_DEBUG("plugin", "[%s] Query found no availability.", self._group_id)
+        
+        return result
+
+    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+        """
+        @brief 进行预约操作
+        模拟预订,有90%概率成功。
+        """
+        VSC_INFO("plugin", "[%s] Attempting to book based on query result...", self._group_id)
+        time.sleep(random.uniform(1.0, 2.0)) # 模拟预订耗时
+
+        book_result = VSBookResult()
+        if random.random() < 0.9: # 90% 概率预订成功
+            book_result.success = True
+            book_result.order_id = f"ORD-{self._group_id}-{random.randint(10000, 99999)}"
+            book_result.session_id = "SESSION-XYZ"
+            book_result.account = self._config.account.username if self._config else "N/A"
+            book_result.visa_type = slot_info.visa_type
+            book_result.city = slot_info.city
+            book_result.country = slot_info.country
+            book_result.book_date = slot_info.earliest_date
+            book_result.book_time = slot_info.availability[0].times[0].time if slot_info.availability else "N/A"
+            book_result.fee_amount = 10000 # 100 EUR
+            book_result.fee_currency = "EUR"
+            book_result.payment_link = "https://example.com/payment/xyz"
+            VSC_INFO("plugin", "[%s] Booking successful! Order ID: %s", self._group_id, book_result.order_id)
+        else:
+            book_result.success = False
+            self._last_error = VSError(3001, "Booking failed due to concurrent access or payment issue.")
+            VSC_ERROR("plugin", "[%s] Booking failed.", self._group_id)
+        
+        return book_result
+
+    def get_group_id(self) -> str:
+        """
+        @brief 获取当前 API 实例所属的分组 ID
+        """
+        return self._group_id
+
+    def health_check(self) -> bool:
+        """
+        @brief 健康检查,模拟1%的概率变为不健康状态
+        """
+        if random.random() < 0.01: # 1% 概率实例变得不健康
+            self._is_healthy = False
+            self._last_error = VSError(4001, "Instance became unhealthy unexpectedly.")
+            VSC_WARN("plugin", "[%s] Instance is now unhealthy.", self._group_id)
+        return self._is_healthy
+
+    def get_last_error(self) -> VSError:
+        """
+        @brief 获取最近一次操作的错误信息
+        """
+        return self._last_error

+ 623 - 0
plugins/tls_plugin.py

@@ -0,0 +1,623 @@
+import time
+import json
+import random
+import re
+import os
+from datetime import datetime
+
+from typing import List, Dict, Optional, Any
+from urllib.parse import urljoin, urlparse
+
+# 第三方库
+try:
+    from curl_cffi import requests, const
+    from bs4 import BeautifulSoup
+except ImportError:
+    raise ImportError("Missing dependencies. Run: pip install curl-cffi beautifulsoup4")
+
+# 框架依赖
+from vs_plg import IVSPlg, VSError # type: ignore
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
+from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
+from toolkit.vs_cloud_api import VSCloudApi # type: ignore
+
+class TlsPlugin(IVSPlg):
+    """
+    TLS 签证预约插件
+    适配法国签证 (FR) 流程
+    """
+
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        
+        # 会话相关
+        self.session: Optional[requests.Session] = None
+        self.travel_group: Optional[Dict] = None
+        self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
+        
+        # 状态
+        self.last_error = VSError(0, "OK")
+        self.is_healthy = True
+        
+    def _save_debug_html(self, content: str, prefix: str = "debug"):
+        """
+        辅助方法:将页面 HTML 保存到本地 debug_pages 目录
+        """
+        try:
+            # 确保目录存在
+            save_dir = "debug_pages"
+            if not os.path.exists(save_dir):
+                os.makedirs(save_dir)
+            
+            # 生成文件名: prefix_GroupID_时间戳.html
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+            filename = f"{save_dir}/{prefix}_{self.group_id}_{timestamp}.html"
+            
+            with open(filename, "w", encoding="utf-8") as f:
+                f.write(content)
+            
+            VSC_INFO("tls_plg", "[%s] HTML saved to: %s", self.group_id, filename)
+        except Exception as e:
+            VSC_WARN("tls_plg", "[%s] Failed to save debug HTML: %s", self.group_id, str(e))
+
+    def get_group_id(self) -> str:
+        return self.group_id
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        try:
+            self.free_config = json.loads(config.free_config) if config.free_config else {}
+        except:
+            self.free_config = {}
+
+    def health_check(self) -> bool:
+        return self.is_healthy
+
+    def get_last_error(self) -> VSError:
+        return self.last_error
+
+    def _set_error(self, code: int, message: str):
+        self.last_error = VSError(code, message)
+        VSC_ERROR("tls_plg", "[%s] Error %d: %s", self.group_id, code, message)
+        if code in [2003, 2000, 2001]: # 会话无效或登录失败
+            self.is_healthy = False
+
+    # ---------------------------------------------------------
+    # 核心接口实现
+    # ---------------------------------------------------------
+
+    def create_session(self) -> bool:
+        """
+        创建会话:处理 Cloudflare -> 登录 -> 获取 Travel Group
+        """
+        VSC_INFO("tls_plg", "[%s] Creating session...", self.group_id)
+        self.is_healthy = True
+        
+        # 1. 初始化 Session
+        curlopt = {
+            const.CurlOpt.MAXAGE_CONN: 1800,
+            const.CurlOpt.MAXLIFETIME_CONN: 1800,
+            const.CurlOpt.VERBOSE: False, # 生产环境建议关闭
+        }
+        
+        # 构造代理
+        proxy_url = ""
+        if self.config.proxy.ip:
+            s = self.config.proxy
+            if s.username:
+                proxy_url = f"{s.scheme}://{s.username}:{s.password}@{s.ip}:{s.port}"
+            else:
+                proxy_url = f"{s.scheme}://{s.ip}:{s.port}"
+
+        self.session = requests.Session(
+            proxy=proxy_url,
+            impersonate="chrome124",
+            curl_options=curlopt,
+            use_thread_local_curl=False,
+            http_version=const.CurlHttpVersion.V2TLS
+        )
+
+        embassy = self._get_embassy_config()
+        if not embassy:
+            return False
+
+        # 2. 解决 Cloudflare 5s 盾
+        if not self._solve_cloudflare5S_challenge(embassy, proxy_url):
+            self._set_error(1001, "Cloudflare challenge failed")
+            return False
+
+        # 3. 获取登录页面参数 (OIDC)
+        login_page = "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']}"
+        }
+        headers = {
+            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'Referer': f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}/vac/{embassy["code"]}',
+            'User-Agent': self.user_agent,
+        }
+
+        try:
+            resp = self.session.get(login_page, headers=headers, params=params)
+            if resp.status_code != 200:
+                self._set_error(resp.status_code, f"Get Login Page Failed: {resp.status_code}")
+                return False
+            
+            # 解析 Keycloak 登录地址
+            soup = BeautifulSoup(resp.text, 'html.parser')
+            form = soup.find('form')
+            if not form:
+                self._set_error(2005, "Login form not found")
+                return False
+            action = form.get('action')
+            authenticate_url = action if action.startswith('http') else urljoin(resp.url, action)
+
+        except Exception as e:
+            self._set_error(1099, f"Network error during login init: {e}")
+            return False
+
+        # 4. 解决 ReCaptcha V2 (登录验证码)
+        # 注意:这里需要 API Token,从配置获取
+        api_token = self.free_config.get("capsolver_key", "")
+        if not api_token:
+            VSC_WARN("tls_plg", "Missing 'capsolver_key' in free_config, captcha might fail.")
+        
+        rc_params = {
+            "type": "ReCaptchaV2TaskProxyLess", # 或 ReCaptchaV2Task 配合 proxy
+            "page": resp.url,
+            "siteKey": "6LcDpXcfAAAAAM7wOEsF_38DNsL20tTvPTKxpyn0",
+            "apiToken": api_token,
+            "proxy": proxy_url
+        }
+        g_token = self._solve_recaptcha(rc_params)
+        if not g_token:
+            self._set_error(1001, "Failed to solve Login Recaptcha")
+            return False
+
+        # 5. 提交登录
+        payload = {
+            'username': self.config.account.username,
+            'password': self.config.account.password,
+            'g-recaptcha-response': g_token
+        }
+        headers['Content-Type'] = 'application/x-www-form-urlencoded'
+        
+        try:
+            resp = self.session.post(authenticate_url, headers=headers, data=payload)
+            if resp.status_code != 200:
+                self._set_error(resp.status_code, f"Login Submit Failed: {resp.status_code}")
+                return False
+            
+            # 6. 解析 Travel Groups
+            groups = self._parse_travel_groups(resp.text)
+            if not groups:
+                # 检查是否包含错误信息
+                if "Invalid username or password" in resp.text:
+                    self._set_error(2000, "Invalid username or password")
+                else:
+                    self._set_error(2005, "No Travel Groups found after login")
+                return False
+            
+            # 选择匹配城市的 Group
+            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:
+                self._set_error(2005, f"No group found for city {target_city}")
+                return False
+
+            VSC_INFO("tls_plg", "[%s] Session created. Group: %s", self.group_id, self.travel_group['group_number'])
+            return True
+
+        except Exception as e:
+            self._set_error(1099, f"Login exception: {e}")
+            return False
+
+    def query(self) -> VSQueryResult:
+        res = VSQueryResult()
+        if not self.session or not self.travel_group:
+            self._set_error(2003, "Session invalid, please login first")
+            return res
+
+        embassy = self._get_embassy_config()
+        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,
+        }
+        headers = {
+            'accept': '*/*',
+            'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'referer': f'{url}?location={embassy["code"]}',
+            'user-agent': self.user_agent,
+        }
+
+        try:
+            resp = self.session.get(url, params=params, headers=headers)
+            
+            # 1. 检查 Cloudflare 403 (硬性拦截)
+            if resp.status_code == 403:
+                VSC_WARN("tls_plg", "[%s] Query 403 Forbidden. Solving Cloudflare...", self.group_id)
+                if self._solve_cloudflare5S_challenge(embassy):
+                    resp = self.session.get(url, params=params, headers=headers)
+                else:
+                    self._set_error(2006, "Cloudflare re-challenge failed")
+                    return res
+
+            # 2. 智能检查 Session Expired
+            # 逻辑修正:即使是 401,如果内容包含 valid data,也视为成功 (绕过某些WAF误报)
+            is_valid_content = "availableAppointments" in resp.text
+            
+            if not is_valid_content:
+                if resp.status_code == 401 or self._is_session_expired_page(resp.text):
+                    VSC_WARN("tls_plg", "[%s] Session expired. URL: %s", self.group_id, resp.url)
+                    self._save_debug_html(resp.text, "query_session_expired")
+                    self._set_error(2003, "Session expired")
+                    self.is_healthy = False
+                    return res
+                
+                # 其他非 200 且无内容的错误
+                if resp.status_code != 200:
+                    self._set_error(resp.status_code, f"Query failed status: {resp.status_code}")
+                    return res
+
+            # 3. 解析 Slots
+            all_slots = self._parse_appointment_slots(resp.text)
+            
+            # 过滤 Label
+            target_labels = self.free_config.get("target_labels", ["", "pta"])
+            available = []
+            
+            for slot in all_slots:
+                if slot.get('label') in target_labels:
+                    available.append(slot)
+            
+            res.success = True
+            res.city = embassy['city']
+            res.visa_type = "Short Stay"
+            res.availability_status = AvailabilityStatus.NoneAvailable
+            
+            if available:
+                res.availability_status = AvailabilityStatus.Available
+                res.earliest_date = available[0]['date']
+                
+                date_map = {}
+                for s in available:
+                    d = s['date']
+                    if d not in date_map: date_map[d] = []
+                    ts = VSQueryResult.DateAvailability.TimeSlot()
+                    ts.time = s['time']
+                    ts.label = f"{s['type']}"
+                    date_map[d].append(ts)
+                
+                for d, slots in date_map.items():
+                    da = VSQueryResult.DateAvailability()
+                    da.date = d
+                    da.times = slots
+                    res.availability.append(da)
+                
+                VSC_INFO("tls_plg", "[%s] Found %d slots", self.group_id, len(available))
+            else:
+                VSC_DEBUG("tls_plg", "[%s] Query OK, but no matching slots.", self.group_id)
+
+        except Exception as e:
+            self._set_error(1099, f"Query exception: {e}")
+
+        return res
+
+    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+        """
+        预约 (实现 Multipart Form 提交)
+        注意:传入的 slot_info 是 query 的结果,我们需要从中选一个具体的 slot。
+        这里假设 slot_info.availability[0].times[0] 是我们要订的。
+        """
+        res = VSBookResult()
+        if not self.session or not self.travel_group:
+            self._set_error(2003, "Session invalid")
+            return res
+            
+        # 简单策略:选第一个可用时间
+        if not slot_info.availability or not slot_info.availability[0].times:
+            self._set_error(3002, "No slots in slot_info to book")
+            return res
+            
+        target_date = slot_info.availability[0].date
+        target_time = slot_info.availability[0].times[0].time
+        # 从 label 解析回原始 label string 比较困难,这里简化处理,
+        # 实际应在 QueryResult 中携带原始数据,或重新匹配
+        # 这里为了演示,假设 label 为空 (Standard)
+        target_label = "" 
+        
+        embassy = self._get_embassy_config()
+        group_num = self.travel_group['group_number']
+        interest_month = self.free_config.get("interest_month", time.strftime("%m-%Y"))
+        
+        # 1. 解决 ReCaptcha V3
+        page_url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking?location={embassy["code"]}&month={interest_month}'
+        proxy_url = self.session.proxies.get("http") if self.session.proxies else ""
+        api_token = self.free_config.get("capsolver_key", "")
+        
+        rc_params = {
+            "type": "ReCaptchaV3Task",
+            "page": page_url,
+            "action": "book",
+            "siteKey": "6LcTpXcfAAAAAM3VojNhyV-F1z92ADJIvcSZ39Y9",
+            "apiToken": api_token,
+            "proxy": proxy_url
+        }
+        g_token = self._solve_recaptcha(rc_params)
+        if not g_token:
+            self._set_error(1001, "Failed to solve Booking Recaptcha")
+            return res
+
+        # 2. 构造请求
+        url = f'https://visas-fr.tlscontact.com/en-us/{group_num}/workflow/appointment-booking'
+        
+        # 复杂的 Header
+        next_action = '601f284bf7ee33b6578ad0fad426fae18c232707f2' # 此值可能会变,需关注
+        next_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$GROUPID$%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'
+        
+        headers = {
+            'Next-Action': next_action,
+            'Referer': page_url,
+            'Next-Router-State-Tree': next_state.replace("$GROUPID$", group_num),
+            'Accept': 'text/x-component',
+            'User-Agent': self.user_agent,
+        }
+        params = {
+            'location': embassy["code"],
+            'month': interest_month,
+        }
+        
+        # 3. 构造 Multipart Form Data
+        boundary = "----WebKitFormBoundary" + "".join(
+            random.choices("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=16)
+        )
+        headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
+        
+        form_fields = {
+            '1_formGroupId': str(group_num),
+            '1_lang': 'en-us',
+            '1_process': 'APPOINTMENT',
+            '1_location': embassy["code"],
+            '1_date': target_date,
+            '1_time': target_time,
+            '1_appointmentLabel': target_label,
+            '1_captcha_token': g_token,
+            '0': '[{"status":"IDLE"},"$K1"]'
+        }
+        
+        body_parts = []
+        for name, value in form_fields.items():
+            body_parts.append(f"--{boundary}\r\n")
+            body_parts.append(f'Content-Disposition: form-data; name="{name}"\r\n')
+            body_parts.append("\r\n")
+            body_parts.append(f"{value}\r\n")
+        body_parts.append(f"--{boundary}--\r\n")
+        body = "".join(body_parts).encode("utf-8")
+        
+        try:
+            resp = self.session.post(url, params=params, headers=headers, data=body)
+            if resp.status_code == 303: # TLS 成功通常重定向
+                res.success = True
+                res.order_id = f"TLS-{int(time.time())}"
+                res.book_date = target_date
+                res.book_time = target_time
+                VSC_INFO("tls_plg", "[%s] Book Success (303 Redirect)!", self.group_id)
+                return res
+            else:
+                self._set_error(resp.status_code, f"Book Failed: {resp.status_code} {resp.text[:100]}")
+        except Exception as e:
+            self._set_error(1099, f"Book exception: {e}")
+
+        return res
+
+    # ---------------------------------------------------------
+    # 辅助功能
+    # ---------------------------------------------------------
+
+    def _get_embassy_config(self) -> Dict:
+        # 从 free_config 提取 embassy 信息,格式需与 TLS_EMBASSY 结构一致
+        # 示例 JSON: { "embassy": { "code": "gbLON2fr", "country": "gb", "mission": "fr", "city": "london" } }
+        # 或者平铺在 free_config
+        if "embassy_code" in self.free_config:
+             return {
+                 "code": self.free_config.get("embassy_code"),
+                 "country": self.free_config.get("country_code"),
+                 "mission": self.free_config.get("mission_code", "fr"),
+                 "city": self.free_config.get("city")
+             }
+        return {} # 失败
+
+    def _solve_cloudflare5S_challenge(self, embassy, proxy_url) -> bool:
+        """
+        解决 Cloudflare 5s 盾
+        使用 VSCloudApi 的 submit_anticloudflare_task
+        """
+        VSC_INFO("tls_plg", "[%s] Solving Cloudflare 5s...", self.group_id)
+        website_url = f'https://visas-fr.tlscontact.com/en-us/country/{embassy["country"]}'
+        
+        # 1. 格式化代理字符串
+        # 这里的接口要求格式通常是: host:port:user:pass (根据你的脚本示例)
+        # self.config.proxy 结构体里的数据
+        p = self.config.proxy
+        if not p.ip:
+            VSC_ERROR("tls_plg", "Proxy is required for Cloudflare challenge")
+            return False
+            
+        # 构造 user:pass@ip:port 用于 urlparse (方便解析) 或者直接拼接
+        # 你的独立脚本中是: f'{parsed_proxy.hostname}:{parsed_proxy.port}:{parsed_proxy.username}:{parsed_proxy.password}'
+        # VSPlgConfig 中的 proxy 对象字段: ip, port, username, password
+        
+        if p.username:
+            proxy_str = f"{p.ip}:{p.port}:{p.username}:{p.password}"
+        else:
+            proxy_str = f"{p.ip}:{p.port}"
+            
+        # 2. 提交任务
+        task = VSCloudApi.Instance().submit_anticloudflare_task(proxy_str, website_url)
+        if not task or not task.get('id'):
+            VSC_ERROR("tls_plg", "[%s] Failed to submit AntiCloudflareTask", self.group_id)
+            return False
+            
+        # 3. 等待结果 (VSCloudApi.get_anticloudflare_result 内部已包含轮询)
+        task_id = str(task['id'])
+        result = VSCloudApi.Instance().get_anticloudflare_result(task_id)
+        
+        if result:
+            try:
+                # 4. 解析结果并设置 Session
+                # result['result'] 是一个 JSON 字符串,包含 cookies 和 userAgent
+                parsed_result = json.loads(result.get('result', '{}'))
+                
+                cookies_list = parsed_result.get('cookies', [])
+                name_list = ['__cf_bm', 'cf_clearance']
+                
+                for cookie in cookies_list:
+                    if cookie['name'] in name_list:
+                        self.session.cookies.set(
+                            cookie['name'], 
+                            cookie['value'], 
+                            domain=cookie['domain'], 
+                            path='/'
+                        )
+                
+                ua = parsed_result.get('userAgent')
+                if ua:
+                    self.user_agent = ua
+                    self.session.headers['User-Agent'] = ua
+                
+                VSC_INFO("tls_plg", "[%s] Cloudflare 5s challenge solved.", self.group_id)
+                return True
+            except Exception as e:
+                VSC_ERROR("tls_plg", f"Failed to parse Cloudflare result: {e}")
+        
+        return False
+
+    def _solve_recaptcha(self, params) -> Optional[str]:
+        """
+        调用 Capsolver (保留原脚本逻辑)
+        """
+        try:
+            key = params.get("apiToken")
+            if not key: return None
+            
+            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")
+                
+            if params.get("proxy"):
+                p = urlparse(params.get("proxy"))
+                task["proxyType"] = p.scheme
+                task["proxyAddress"] = p.hostname
+                task["proxyPort"] = p.port
+                if p.username:
+                    task["proxyLogin"] = p.username
+                    task["proxyPassword"] = p.password
+            
+            payload = {"clientKey": key, "task": task}
+            r = requests.post(submit_url, json=payload, timeout=20)
+            if r.status_code != 200: return None
+            
+            task_id = r.json().get("taskId")
+            if not task_id: return None
+            
+            # Query
+            for _ in range(20):
+                r = requests.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)
+        except Exception as e:
+            VSC_ERROR("tls_plg", f"Capsolver error: {e}")
+        return None
+
+    def _parse_travel_groups(self, html: str) -> List[Dict]:
+        groups = []
+        try:
+            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')
+                    })
+        except:
+            pass
+        return groups
+
+    def _parse_appointment_slots(self, html: str) -> List[Dict]:
+        slots = []
+        try:
+            # 增强正则:匹配 "availableAppointments": 或 \"availableAppointments\":
+            # 并且兼容末尾是 ,"showFlexi... 或 ,\"showFlexi...
+            # DOTALL 模式确保匹配跨行
+            pattern = r'availableAppointments\\?":\s*(\[.*?\])(?:,\\?"|\},)'
+            match = re.search(pattern, html, re.DOTALL)
+            
+            if match:
+                json_str = match.group(1)
+                # 清理转义字符:将 \" 替换为 "
+                json_str = json_str.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 = ""
+                        stype = ""
+                        cost = ""
+                        
+                        if 'pta' in labels:
+                            lbl = 'pta'
+                            stype = "Prime"
+                        elif 'ptaw' in labels:
+                            lbl = 'ptaw'
+                            stype = "Prime Weekend"
+                        elif '' in labels:
+                            lbl = ''
+                            stype = "Standard"
+                        
+                        if lbl or not labels: 
+                             slots.append({
+                                 'date': d_str,
+                                 'time': s.get('time'),
+                                 'label': lbl,
+                                 'type': stype,
+                                 'cost': cost
+                             })
+        except Exception as e:
+            VSC_DEBUG("tls_plg", f"Slot parse error: {e}")
+            pass
+        return slots
+
+    def _is_session_expired_page(self, html: str) -> bool:
+        if not html: return False
+        if 'availableAppointments' not in html: return True
+        # 简化判断:如果包含 redirecting automatically 通常是过期
+        if 'redirected automatically' in html.lower(): return True
+        return False

+ 1687 - 0
plugins/vfs_plugin.py

@@ -0,0 +1,1687 @@
+# plugins/vfs_global_plugin.py
+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
+
+# 使用 curl_cffi 模拟浏览器 TLS 指纹,这是 VFS 必须的
+try:
+    from curl_cffi import requests
+except ImportError:
+    raise ImportError("Please install curl-cffi: pip install curl-cffi")
+
+# 加密库
+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, VSError # type: ignore
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
+from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
+from toolkit.vs_cloud_api import VSCloudApi # type: ignore
+from toolkit.rule_engine import RuleEngine # type: ignore
+
+# ----------------- 静态常量与辅助数据 -----------------
+
+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 = {
+    "china": "CHN", "france": "FRA", "germany": "DEU", "italy": "ITA", 
+    "united kingdom": "GBR", "united states": "USA", "india": "IND",
+    "russia": "RUS", "turkey": "TUR", "vietnam": "VNM"
+}
+
+def get_country_iso3(name: str) -> str:
+    return COUNTRY_MAP.get(name.lower(), "CHN")
+
+# ----------------- VfsPlugin 类 -----------------
+
+class VfsPlugin(IVSPlg):
+    def __init__(self, group_id: str):
+        self.group_id = group_id
+        self.config: Optional[VSPlgConfig] = None
+        self.free_config: Dict[str, Any] = {}
+        
+        self.session = requests.Session()
+        # 模拟 Chrome 124
+        self.session.impersonate = "chrome124"
+        
+        self.jwt_token = ""
+        self.user_agent = ""
+        self.last_error = VSError(0, "OK")
+        self.real_ip = ""
+        
+        # 缓存配置
+        self.center_conf = None
+        self.category_conf = {}
+        self.subcategory_conf = {}
+        
+        # 加载公钥
+        self.public_key = serialization.load_pem_public_key(
+            VFS_PUBLIC_KEY_PEM.encode(),
+            backend=default_backend()
+        )
+
+    # --- IVSPlg 接口实现 ---
+
+    def get_group_id(self) -> str:
+        return self.group_id
+
+    def set_config(self, config: VSPlgConfig):
+        self.config = config
+        try:
+            self.free_config = json.loads(config.free_config) if config.free_config else {}
+        except:
+            self.free_config = {}
+        
+        # 设置代理
+        if config.proxy.ip:
+            proxy_str = f"{config.proxy.scheme}://"
+            if config.proxy.username:
+                proxy_str += f"{config.proxy.username}:{config.proxy.password}@"
+            proxy_str += f"{config.proxy.ip}:{config.proxy.port}"
+            self.session.proxies = {"http": proxy_str, "https": proxy_str}
+            VSC_DEBUG("vfs_plg", "[%s] Proxy set: %s", self.group_id, config.proxy.ip)
+
+    def health_check(self) -> bool:
+        return True
+
+    def get_last_error(self) -> VSError:
+        return self.last_error
+
+    def create_session(self) -> bool:
+        """登录流程"""
+        VSC_INFO("vfs_plg", "[%s] Starting login...", self.group_id)
+        
+        # 1. Cloudflare Turnstile
+        cf_token = self._handle_cloudflare_challenge()
+        if not cf_token:
+            return False
+
+        # 2. 准备参数
+        email = self.config.account.username
+        password = self.config.account.password
+        enc_password = self._encrypt_password(password)
+        
+        mission_code = self.free_config.get("missionCode", "")
+        country_code = self.free_config.get("countryCode", "")
+        
+        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,
+            "content-type": "application/x-www-form-urlencoded"
+        })
+        
+        data = {
+            "username": email,
+            "password": enc_password,
+            "missioncode": mission_code,
+            "countrycode": country_code,
+            "languageCode": "en-US",
+            "captcha_version": "cloudflare-v1",
+            "captcha_api_key": cf_token
+        }
+        
+        # 3. 发送登录请求 (包含 OPTIONS)
+        if not self._perform_request("POST", url, headers=headers, data=data):
+            return False
+            
+        try:
+            resp_json = self.session.last_response.json()
+            if "accessToken" in resp_json and resp_json["accessToken"]:
+                self.jwt_token = resp_json["accessToken"]
+                VSC_INFO("vfs_plg", "[%s] Login successful, JWT obtained.", self.group_id)
+                return True
+            
+            # OTP 处理
+            if resp_json.get("enableOTPAuthentication"):
+                VSC_INFO("vfs_plg", "[%s] Login requires OTP.", self.group_id)
+                otp = self._read_otp_email()
+                if not otp:
+                    self._set_error(3001, "Failed to read Login OTP")
+                    return False
+                return self._submit_login_otp(None, otp)
+                
+            self._set_error(1001, "Login failed: No access token or OTP flow.")
+            return False
+            
+        except Exception as e:
+            self._set_error(9001, f"Login parse error: {str(e)}")
+            return False
+
+    def query(self) -> VSQueryResult:
+        """查询可预约 Slot"""
+        result = VSQueryResult()
+        
+        apt_config = None
+        target_tag = self.group_id 
+        
+        appt_types = self.free_config.get("appointmentType", [])
+        for apt in appt_types:
+            if apt.get("tag") == target_tag or len(appt_types) == 1:
+                apt_config = apt
+                break
+        
+        if not apt_config:
+            self._set_error(2001, "No matching appointment configuration found.")
+            return result
+
+        if not self._fetch_configurations(apt_config):
+            return result
+
+        earliest_date = []
+        if not self._query_earliest_slot(apt_config, earliest_date):
+            return result
+        
+        if not earliest_date:
+             return result
+
+        date_str = earliest_date[0]
+        
+        result.success = True
+        result.visa_type = apt_config.get("subcategoryCode", "")
+        result.city = apt_config.get("vacCode", "")
+        result.country = self.free_config.get("countryCode", "")
+        
+        if "WaitList" in date_str:
+            result.availability_status = AvailabilityStatus.Waitlist
+            result.earliest_date = "WaitList"
+            VSC_INFO("vfs_plg", "[%s] Found WaitList.", self.group_id)
+        else:
+            result.availability_status = AvailabilityStatus.Available
+            result.earliest_date = date_str
+            VSC_INFO("vfs_plg", "[%s] Found Slot: %s", self.group_id, date_str)
+            
+            day_info = VSQueryResult.DateAvailability()
+            day_info.date = date_str
+            result.availability.append(day_info)
+
+        return result
+    
+    def _get_filtered_covered_months(self, start_date, end_date, from_date) -> List[str]:
+        """
+        计算需要查询的月份列表,格式 YYYY-MM-DD (每月1号)
+        """
+        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)
+            
+            # from_date 格式可能是 DD/MM/YYYY (从 slot_info 来)
+            try:
+                dt_from = datetime.strptime(from_date, "%d/%m/%Y")
+            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)
+        
+        # 起始点取 max(start, from)
+        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
+
+    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+        """
+        执行完整的预约流程 (对应 C++ VFSApi::book)
+        包含:上传文档 -> 添加申请人 -> OTP -> 选时间 -> 锁定 -> 支付
+        """
+        res = VSBookResult()
+        
+        # 1. 准备配置和用户信息
+        # 这里的 uinfo 实际上应该从 Coordinator 传入或从 Config 获取,这里沿用之前的 helper
+        uinfo = self._prepare_user_info()
+        
+        # C++ 中 from_date 是入参,对应 Python 的 slot_info.earliest_date
+        from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%d/%m/%Y")
+        
+        # 定位 Appointment Config
+        target_routing_key = slot_info.routing_key 
+        apt_config = None
+        appt_types = self.free_config.get("appointmentType", [])
+        for apt in appt_types:
+            if apt.get("routing_key") == target_routing_key or len(appt_types) == 1:
+                apt_config = apt
+                break
+        
+        if not apt_config:
+            self._set_error(3001, "Book: Config missing.")
+            return res
+
+        # 刷新子配置 (获取 OCR/OTP 开关)
+        if not self._fetch_configurations(apt_config):
+            return res
+
+        sub_cc = apt_config.get("subcategoryCode")
+        sub_conf = self.subcategory_conf.get(sub_cc, {})
+
+        # ---------------- OCR 识别 / 文档上传 ----------------
+        # C++: bool ocr_enabled = ...
+        ocr_enabled = sub_conf.get("isOCREnable", False)
+        
+        if ocr_enabled:
+            VSC_INFO("vfs_plg", "[%s] OCR Enabled, uploading documents...", self.group_id)
+            upload_res = {}
+            if not self._upload_applicant_documents(apt_config, uinfo, upload_res):
+                return res
+            # 回填上传结果到 uinfo,供 add_primary_applicant 使用
+            uinfo["applicant_image"] = upload_res.get("passportImageFilename")
+            uinfo["applicant_image_data"] = upload_res.get("passportImageFileBytes") # Base64
+            uinfo["guid"] = upload_res.get("uploadDocumentGUID")
+
+        # ---------------- 需要提供申请号 (Cover Letter) ----------------
+        enable_reference_number = sub_conf.get("enableReferenceNumber", False)
+
+        # ---------------- 添加申请人 (核心步骤 1) ----------------
+        urn = []
+        is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
+        
+        # C++: Retry loop for 422 Invalid Request
+        add_primary_retry = 0
+        MAX_RETRY = 3
+        success_add = False
+        
+        while add_primary_retry < MAX_RETRY:
+            urn = [] # 清空
+            if self._add_primary_applicant(apt_config, uinfo, is_waitlist, ocr_enabled, enable_reference_number, urn):
+                success_add = True
+                break
+            
+            # 检查是否是 422 错误
+            err = self.get_last_error()
+            # 注意:需要在 _perform_request 或 _add_primary_applicant 中正确解析并设置 error_code 为 422
+            # 简单起见,如果 msg 包含 Invalid request 也算
+            if err.error_code == 422 or "Invalid request" in err.error_message:
+                VSC_WARN("vfs_plg", "[%s] Add Applicant 422 error, retrying in 10s...", self.group_id)
+                time.sleep(10)
+                add_primary_retry += 1
+            else:
+                # 其他错误直接退出
+                return res
+        
+        if not success_add:
+            self._set_error(3002, "Failed to add primary applicant after retries")
+            return res
+
+        final_urn = urn[0]
+        VSC_INFO("vfs_plg", "[%s] Applicant Added. URN: %s", self.group_id, final_urn)
+
+        # ---------------- 申请人 OTP 验证 (核心步骤 2) ----------------
+        otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
+        
+        if otp_enabled:
+            VSC_INFO("vfs_plg", "[%s] Applicant OTP Required.", self.group_id)
+            if not self._applicant_otp_send(apt_config, final_urn):
+                return res
+            
+            otp_code = self._read_otp_email() # 复用之前的读邮件逻辑
+            if not otp_code:
+                self._set_error(3003, "Failed to read Applicant OTP from email")
+                return res
+            
+            if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
+                return res
+
+        # ---------------- 如果是 Waitlist 模式,直接确认并返回 ----------------
+        if is_waitlist:
+            if self._confirm_waitlist(apt_config, final_urn):
+                res.success = True
+                res.urn = final_urn
+                res.order_id = final_urn
+                res.message = "Joined Waitlist"
+                return res
+            return res
+
+        # ---------------- 规则引擎与日期筛选 (核心步骤 3) ----------------
+        # C++: RuleEngine rule_engine(rules);
+        rules_str = uinfo.get("rules", "")
+        rule_engine = RuleEngine(rules_str)
+        
+        expected_start = uinfo.get("expected_submit_start", "")
+        expected_end = uinfo.get("expected_submit_end", "")
+        rule_engine.set_date_range_start(expected_start)
+        rule_engine.set_date_range_end(expected_end)
+        
+        # 计算需要扫描的月份
+        # 如果 expected_start/end 为空,默认使用 from_date 所在月
+        months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
+        VSC_INFO("vfs_plg", "[%s] Scanning months: %s (From: %s)", self.group_id, months, from_date)
+        
+        selected_slot_id = ""
+        selected_slot_date = ""
+        selected_slot_time_range = ""
+        
+        all_ads = set() # 记录所有有号日期,避免重复处理
+        forbidden_dates = set()
+        
+        found_slot = False
+        
+        # 遍历月份寻找 Slot
+        for m_str in months:
+            # m_str format: YYYY-MM-DD (月初)
+            # C++ 需要 DD/MM/YYYY
+            try:
+                dt_m = datetime.strptime(m_str, "%Y-%m-%d")
+                converted_date = dt_m.strftime("%d/%m/%Y")
+            except:
+                continue
+
+            ads = [] # Available Date Strings
+            if not self._query_slot_calendar(apt_config, final_urn, converted_date, ads):
+                time.sleep(3)
+                continue
+            
+            if not ads:
+                time.sleep(3)
+                continue
+                
+            # 过滤已知的 slots
+            new_ads = [d for d in ads if d not in all_ads]
+            all_ads.update(new_ads)
+            
+            # 尝试 3 次选择
+            for _ in range(3):
+                # 排除 forbidden
+                avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
+                
+                # 规则筛选
+                sel_dates = rule_engine.select_date(avail_candidates, "%d/%m/%Y")
+                if not sel_dates:
+                    break # 当前月份符合规则的都没了
+                
+                tmp_date = sel_dates[0] # 取第一个符合规则的日期
+                forbidden_dates.add(tmp_date) # 标记为已尝试
+                
+                # 审计日志 (C++ saveuseractionaudit)
+                if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
+                    time.sleep(3)
+                    continue
+                
+                # 查询具体时间 (query_slot_time)
+                ats = []
+                if not self._query_slot_time(apt_config, final_urn, tmp_date, ats):
+                    time.sleep(3)
+                    continue
+                
+                if not ats:
+                    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._set_error(3004, "No valid slots found after Rule Engine filtering.")
+            return res
+
+        VSC_INFO("vfs_plg", "[%s] Slot Selected: %s %s (ID: %s)", 
+                 self.group_id, selected_slot_date, selected_slot_time_range, selected_slot_id)
+
+        # ---------------- 服务、费用、最终预约 (核心步骤 4) ----------------
+        
+        # 跳过附加服务
+        if not self._submit_no_addition_service(final_urn):
+            return res
+            
+        # 查询费用
+        amount = 0.0
+        currency = ""
+        amount, currency = self._query_fee(apt_config, final_urn)
+        # 简单保留两位小数
+        amount = round(amount, 2)
+        
+        # 最终提交 (Schedule)
+        schedule_res = {}
+        # C++: schedule_with_retry (max 3)
+        schedule_success = False
+        for _ in range(3):
+            if self._schedule(apt_config, final_urn, amount, currency, selected_slot_id, schedule_res):
+                schedule_success = True
+                break
+            # 检查是否是被防火墙拦截
+            if self.get_last_error().error_code == 403:
+                # 重新处理 CF
+                self._handle_cloudflare_challenge()
+            else:
+                break # 其他错误不重试
+        
+        if not schedule_success:
+             return res
+             
+        # ---------------- 构造返回结果 ----------------
+        res.success = True
+        res.book_date = selected_slot_date
+        res.book_time = selected_slot_time_range
+        res.urn = final_urn
+        res.order_id = final_urn
+        res.fee_amount = int(amount * 100)
+        res.fee_currency = currency
+        
+        # 处理支付链接
+        is_pay_req = schedule_res.get("IsPaymentRequired", False)
+        if is_pay_req:
+            payload = schedule_res.get("payLoad", "")
+            payment_url = self._pay_request(payload)
+            if payment_url:
+                res.payment_link = payment_url
+                # 保存 Session (C++ save_http_session)
+                self._save_http_session(payment_url)
+        else:
+            res.message = "Booking confirmed (No payment required)"
+
+        return res
+    
+    def _confirm_waitlist(self, apt_config: Dict[str, Any], urn: str) -> bool:
+        """
+        确认加入候补名单 (对应 C++ VFSApi::confirm_waitlist)
+        """
+        url = "https://lift-api.vfsglobal.com/appointment/ConfirmWaitlist"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "notificationType": "none",
+            "CanVFSReachoutToApplicant": True
+        }
+
+        # 发送请求
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+
+        try:
+            j = self.session.last_response.json()
+            
+            # 1. 检查 API 返回的 error 字段
+            if "error" in j and j["error"]:
+                err_val = j["error"]
+                # 兼容 error 为字符串或对象的情况
+                if isinstance(err_val, str) and err_val:
+                    self._set_error(2006, f"Confirm Waitlist API Error: {err_val}")
+                    return False
+                elif isinstance(err_val, dict) or isinstance(err_val, list):
+                    self._set_error(2006, f"Confirm Waitlist API Error: {err_val}")
+                    return False
+
+            # 2. 检查 isConfirmed 字段
+            if j.get("isConfirmed") is True:
+                VSC_INFO("vfs_plg", "[%s] Waitlist confirmed successfully for URN: %s", self.group_id, urn)
+                return True
+            
+            self._set_error(2007, f"Confirm Waitlist failed, response: {str(j)[:100]}")
+            
+        except Exception as e:
+            self._set_error(9001, f"Confirm Waitlist parse error: {str(e)}")
+            
+        return False
+    
+    def _upload_applicant_documents(self, apt_config, uinfo, res_out: Dict) -> bool:
+        """上传护照图片"""
+        url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
+        passport_url = uinfo.get("passport_image_url")
+        if not passport_url:
+            self._set_error(9007, "Missing passport_image_url")
+            return False
+
+        # 下载图片转 Base64 (C++ download_img_encode_base64)
+        try:
+            # 简单的下载,这里不使用 session,直接下载静态资源
+            img_resp = requests.get(passport_url, timeout=30)
+            if img_resp.status_code != 200:
+                self._set_error(9008, "Failed to download passport image")
+                return False
+            b64_str = base64.b64encode(img_resp.content).decode('utf-8')
+        except Exception as e:
+            self._set_error(9008, f"Image download exception: {e}")
+            return False
+        
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "missioncode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "languageCode": "en-US",
+            "visaCategoryCode": apt_config.get("subcategoryCode"),
+            "fileBytes": b64_str,
+            "selfiImageFileBytes": "" # 暂不支持自拍
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+        
+        try:
+            j = self.session.last_response.json()
+            if "error" in j and j["error"]:
+                self._set_error(2005, f"Upload error: {j}")
+                return False
+            
+            res_out.update(j)
+            # 补全 C++ 逻辑中的模拟文件名
+            res_out["passportImageFilename"] = "passport_img.jpg"
+            res_out["passportImageFileBytes"] = b64_str
+            return True
+        except:
+            return False
+        
+    def _add_primary_applicant(self, apt_config: Dict[str, Any], uinfo: Dict[str, Any], 
+                             is_waitlist: bool, ocr_enabled: bool, enable_ref: bool, 
+                             urn_out_list: List[str]) -> bool:
+        """
+        添加主申请人 (对应 C++ VFSApi::add_primary_applicant)
+        构造复杂的申请人 JSON payload 并提交
+        """
+        url = "https://lift-api.vfsglobal.com/appointment/applicants"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+
+        # --- 辅助 Helper: 映射性别 ---
+        # C++: male/Male -> 1, 否则 -> 2
+        gender_str = str(uinfo.get("gender", "")).lower()
+        gender_code = 1 if gender_str == "male" else 2
+
+        # --- 辅助 Helper: 获取 Dial Code ---
+        # C++ 逻辑处理了 int 和 string
+        raw_dial = uinfo.get("phone_country_code", "86")
+        dial_code = str(raw_dial)
+
+        # --- 辅助 Helper: 格式化日期 ---
+        # C++: to_ddmmyyyy (YYYY-MM-DD -> DD/MM/YYYY)
+        def _to_ddmmyyyy(d_str):
+            try:
+                # 假设输入是 YYYY-MM-DD
+                return datetime.strptime(d_str, "%Y-%m-%d").strftime("%d/%m/%Y")
+            except:
+                return d_str # 原样返回
+
+        dob = _to_ddmmyyyy(str(uinfo.get("birthday", "")))
+        ppt_exp = _to_ddmmyyyy(str(uinfo.get("passport_expiry_date", "")))
+
+        # --- 构造单个 Applicant 对象 ---
+        # 对应 C++ 中庞大的 applicant JSON 构建
+        applicant = {
+            "urn": "",
+            "arn": "",
+            "loginUser": self.config.account.username,
+            
+            # 基本信息 (全部大写)
+            "firstName": str(uinfo.get("first_name", "")).upper(),
+            "middleName": "",
+            "lastName": str(uinfo.get("last_name", "")).upper(),
+            "employerFirstName": "",
+            "employerLastName": "",
+            "salutation": "",
+            "gender": gender_code,
+            
+            # 联系信息
+            "contactNumber": str(uinfo.get("phone_no", "")),
+            "dialCode": dial_code,
+            "employerContactNumber": "",
+            "employerDialCode": "",
+            "emailId": str(uinfo.get("alias_email", "")).upper(),
+            "employerEmailId": "",
+            
+            # 证件信息
+            "passportNumber": str(uinfo.get("passport_no", "")).upper(),
+            "confirmPassportNumber": "", # 通常留空
+            "passportExpirtyDate": ppt_exp, # 注意拼写 Expirty 是 VFS API 的特征
+            "dateOfBirth": dob,
+            "nationalId": None,
+            
+            # 国籍 (使用全局辅助函数 get_country_iso3)
+            "nationalityCode": get_country_iso3(str(uinfo.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 注入
+            "ipAddress": self.real_ip or "127.0.0.1"
+        }
+
+        # --- 处理 Reference Number (Cover Letter) ---
+        if enable_ref:
+            applicant["referenceNumber"] = str(uinfo.get("cover_letter", ""))
+        else:
+            applicant["referenceNumber"] = None
+
+        # --- 处理 OCR 数据 ---
+        if ocr_enabled:
+            # 必须从 uinfo 获取上传后返回的 metadata
+            applicant["applicantImage"] = str(uinfo.get("applicant_image", ""))
+            applicant["applicantImageData"] = str(uinfo.get("applicant_image_data", ""))
+            applicant["GUID"] = str(uinfo.get("guid", ""))
+
+        # --- 构造最外层 Payload ---
+        payload = {
+            "countryCode": self.free_config.get("countryCode"),
+            "missionCode": self.free_config.get("missionCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategoryCode"),
+            "applicantList": [applicant], # 数组形式
+            
+            "languageCode": "en-US",
+            "isWaitlist": is_waitlist,
+            "isEdit": False,
+            "feeEntryTypeCode": None,
+            "feeExemptionTypeCode": None,
+            "feeExemptionDetailsCode": None,
+            "juridictionCode": None,
+            "regionCode": None
+        }
+
+        # --- 发送请求 ---
+        if not self._perform_request("POST", url, headers=headers, json_data=payload):
+            return False
+
+        # --- 处理响应 ---
+        try:
+            j = self.session.last_response.json()
+            
+            # 1. 成功情况:返回了 urn
+            if "urn" in j and j["urn"]:
+                urn_out_list.append(j["urn"])
+                return True
+            
+            # 2. 错误处理:检查是否为 422 Invalid Request (反爬/校验失败)
+            # C++ 逻辑依赖于捕获这个特定的错误码来进行重试
+            if "error" in j and j["error"]:
+                err_data = j["error"]
+                code = 0
+                desc = ""
+                
+                if isinstance(err_data, dict):
+                    code = err_data.get("code", 0)
+                    desc = err_data.get("description", "") or err_data.get("message", "")
+                
+                # 设置到 last_error 以供上层重试逻辑检查
+                if code == 422 or "Invalid request" in desc:
+                    self._set_error(422, f"Add Applicant 422: {desc}")
+                else:
+                    self._set_error(3005, f"Add Applicant API Error: {desc}")
+                    
+            return False
+
+        except Exception as e:
+            self._set_error(9001, f"Add Applicant parse error: {str(e)}")
+            return False
+    
+    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)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "urn": urn,
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "OTP": "",
+            "otpAction": "GENERATE",
+            "languageCode": "en-US"
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+        
+        try:
+            if self.session.last_response.json().get("isOTPGenerated"):
+                return True
+        except:
+            pass
+        return 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)
+        # C++ specific: datacenter header
+        headers["datacenter"] = "GERMANY" 
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "urn": urn,
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "OTP": otp,
+            "otpAction": "VALIDATE",
+            "languageCode": "en-US"
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+            
+        try:
+            if self.session.last_response.json().get("isOTPValidated"):
+                return True
+        except:
+            pass
+        return False
+    
+    def _query_slot_calendar(self, apt_config, urn, from_date, ads_out: List) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/calendar"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategoryCode"),
+            "fromDate": from_date,
+            "urn": urn,
+            "payCode": ""
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            calendars = j.get("calendars")
+            if calendars:
+                ads_out.clear()
+                for item in calendars:
+                    # item["date"] is usually "MM/DD/YYYY" or "YYYY-MM-DD" depending on API version
+                    # C++ assumes "MM/DD/YYYY" -> "DD/MM/YYYY"
+                    raw = item.get("date")
+                    try:
+                        # Normalize to DD/MM/YYYY
+                        dObj = datetime.strptime(raw, "%m/%d/%Y")
+                        ads_out.append(dObj.strftime("%d/%m/%Y"))
+                    except:
+                        ads_out.append(raw)
+                return True
+        except:
+            pass
+        return False
+
+    def _query_slot_time(self, apt_config, urn, slot_date, ats_out: List) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/timeslot"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "visaCategoryCode": apt_config.get("subcategoryCode"),
+            "slotDate": slot_date,
+            "urn": urn
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            slots = j.get("slots")
+            if slots:
+                ats_out.extend(slots)
+                return True
+        except:
+            pass
+        return False
+
+    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)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        # ISO format conversion
+        try:
+             dt = datetime.strptime(earliest_date, "%d/%m/%Y")
+             iso_date = dt.strftime("%Y-%m-%dT%H:%M:%S")
+        except:
+             iso_date = earliest_date
+
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "firstEarliestSlotDate": earliest_date,
+            "action": "schedule",
+            "ipAddress": self.real_ip or "127.0.0.1",
+            "eadAppointmentDetail": iso_date
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+        return self.session.last_response.json().get("isSavedSuccess", False)
+
+    # ----------------- 内部功能函数 -----------------
+
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None) -> bool:
+        """
+        统一 HTTP 请求封装,严格复刻 C++ 逻辑:
+        1. 发送 OPTIONS 请求
+        2. 发送实际请求
+        """
+        print(f'[perform request] {method} {url}')
+        # --- 1. 发送 OPTIONS 请求 ---
+        try:
+            # OPTIONS 请求使用相同的 URL 和 headers (部分 header 如 content-length 会被自动处理)
+            # 某些服务器反爬会检测 OPTIONS 请求
+            opt_headers = headers.copy() if headers else {}
+            
+            # 发送 OPTIONS
+            self.session.request("OPTIONS", url, headers=opt_headers, timeout=10)
+            # C++ 代码中并不检查 OPTIONS 的返回值,只检查执行是否成功
+            # 这里我们假设只要不抛出异常即可
+            
+        except Exception as e:
+            # 记录警告但不中断流程,防止仅仅是 OPTIONS 失败导致误判
+            VSC_DEBUG("vfs_plg", "OPTIONS request failed (non-fatal): %s", str(e))
+
+        # --- 2. 发送实际请求 ---
+        try:
+            resp = self.session.request(method, url, headers=headers, data=data, json=json_data, params=params, timeout=30)
+            
+            self.session.last_response = resp
+            
+            if resp.status_code == 200:
+                return True
+            elif resp.status_code == 401:
+                self._set_error(401, "Session Invalid (401)")
+            elif resp.status_code == 403:
+                self._set_error(403, "Blocked by Firewall (403)")
+            elif resp.status_code == 429:
+                self._set_error(429, "Rate Limited (429)")
+            else:
+                self._set_error(resp.status_code, f"HTTP Error {resp.status_code}: {resp.text[:100]}")
+            
+            return False
+        except Exception as e:
+            self._set_error(9000, f"Network Exception: {str(e)}")
+            return False
+
+    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 _handle_cloudflare_challenge(self) -> str:
+        """
+        完整实现的 Cloudflare Turnstile 验证逻辑
+        对应 C++ VFSApi::handle_cloudflare_challenge
+        """
+        # 1. 准备参数
+        mission = self.free_config.get("missionCode", "")
+        country = self.free_config.get("countryCode", "")
+        if not mission or not country:
+            self._set_error(9001, "Missing missionCode or countryCode in free_config")
+            return ""
+
+        website_url = f"https://visa.vfsglobal.com/{country}/en/{mission}/login"
+        
+        # 构造代理字符串传给打码平台 (格式: http://user:pass@ip:port)
+        proxy_str = ""
+        if self.config.proxy.ip:
+            proxy_str = f"{self.config.proxy.scheme}://"
+            if self.config.proxy.username:
+                proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
+            proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
+
+        # 2. 提交任务
+        task_out = {}
+        VSC_INFO("vfs_plg", "[%s] Submitting Turnstile task for %s...", self.group_id, website_url)
+        if not VSCloudApi.Instance().submit_anti_turnstile_task(proxy_str, website_url, task_out):
+            self._set_error(9002, "Failed to submit captcha task to Cloud API")
+            return ""
+        
+        task_id = str(task_out.get("id"))
+        if not task_id:
+            self._set_error(9002, "Cloud API returned invalid task ID")
+            return ""
+
+        # 3. 轮询结果 (超时时间 120秒)
+        timeout = 120
+        start_time = time.time()
+        
+        while time.time() - start_time < timeout:
+            result_out = {}
+            if not VSCloudApi.Instance().get_anti_turnstile_result(task_id, result_out):
+                # 获取结果的网络请求失败,稍后重试
+                time.sleep(3)
+                continue
+            
+            # status: 0=Pending, 1=Processing, 2=Success, 3=Failed
+            status = result_out.get("status", 0)
+            
+            if status == 2: # Success
+                raw_result = result_out.get("result", "")
+                try:
+                    # 4. 解析结果 JSON
+                    # 打码平台返回的 result 通常是一个 JSON 字符串,包含 token, userAgent, cookies
+                    if isinstance(raw_result, str):
+                        data = json.loads(raw_result)
+                    else:
+                        data = raw_result # 已经是 dict
+
+                    token = data.get("token")
+                    ua = data.get("userAgent")
+                    cookies_list = data.get("cookies", [])
+
+                    if not token:
+                        self._set_error(9004, "Captcha solved but token is empty")
+                        return ""
+
+                    # 5. 同步环境 (User-Agent 和 Cookies)
+                    # 这是最关键的一步,必须使用通过验证时的环境进行后续请求
+                    
+                    # A. 设置 User-Agent
+                    if ua:
+                        self.user_agent = ua
+                        self.session.headers["User-Agent"] = ua
+                        # 注意:curl_cffi 的 impersonate 可能会覆盖 header,
+                        # 如果打码平台返回的 UA 看起来像 Chrome 124,通常兼容性没问题。
+                        # 如果非常严格,可能需要根据返回的 UA 调整 impersonate 参数,但在 VFS 场景下
+                        # 只要 header 对了通常通过率就很高。
+
+                    # B. 设置 Cookies
+                    # 这里的 cookies 是 Turnstile 验证过程中生成的 (如 cf_clearance)
+                    if cookies_list:
+                        VSC_DEBUG("vfs_plg", "[%s] Syncing %d cookies from Captcha solver...", self.group_id, len(cookies_list))
+                        for cookie in cookies_list:
+                            # 兼容不同的 cookie 格式
+                            c_name = cookie.get("name")
+                            c_value = cookie.get("value")
+                            c_domain = cookie.get("domain", "")
+                            c_path = cookie.get("path", "/")
+                            
+                            if c_name and c_value:
+                                self.session.cookies.set(
+                                    name=c_name, 
+                                    value=c_value, 
+                                    domain=c_domain, 
+                                    path=c_path
+                                )
+                    
+                    VSC_INFO("vfs_plg", "[%s] Cloudflare challenge passed.", self.group_id)
+                    return token
+
+                except Exception as e:
+                    self._set_error(9005, f"Failed to parse captcha result JSON: {str(e)}")
+                    return ""
+
+            elif status == 3: # Failed
+                err_msg = result_out.get("result", "Unknown error")
+                self._set_error(9003, f"Captcha task failed: {err_msg}")
+                return ""
+            
+            else:
+                # Pending / Processing
+                time.sleep(3)
+        
+        self._set_error(9003, "Captcha task timeout (120s)")
+        return ""
+
+    def _get_common_headers(self, with_auth=True) -> Dict[str, str]:
+        mission = self.free_config.get("missionCode", "")
+        country = self.free_config.get("countryCode", "")
+        lang = self.free_config.get("language", "en")
+        route = f"{country}/{lang}/{mission}"
+        
+        h = {
+            "accept": "application/json, text/plain, */*",
+            "accept-language": "en-US,en;q=0.9",
+            "origin": "https://visa.vfsglobal.com",
+            "referer": "https://visa.vfsglobal.com/",
+            "route": route
+        }
+        
+        client_src = self._get_client_source()
+        h["clientsource"] = client_src
+        
+        if with_auth and self.jwt_token:
+            h["authorize"] = self.jwt_token
+            
+        return h
+
+    def _query_earliest_slot(self, apt_config, earliest_date_out: List[str]) -> bool:
+        """
+        查询最早 Slot (对应 C++ VFSApi::query_earliest_slot_with_retry)
+        增加了 403 被拦截时的 Cloudflare 自动绕过机制
+        """
+        url = "https://lift-api.vfsglobal.com/appointment/CheckIsSlotAvailable"
+        
+        data = {
+            "missioncode": self.free_config.get("missionCode"),
+            "countrycode": self.free_config.get("countryCode"),
+            "vacCode": apt_config.get("vacCode"),
+            "visaCategoryCode": apt_config.get("subcategoryCode"),
+            "roleName": "Individual",
+            "loginUser": self.config.account.username,
+            "payCode": ""
+        }
+        
+        max_retries = 3
+        for attempt in range(max_retries):
+            # 每次重试前重新获取 header,因为 handle_cloudflare_challenge 可能会更新 Token/UA
+            headers = self._get_common_headers(with_auth=True)
+            headers["content-type"] = "application/json;charset=UTF-8"
+            
+            # 发送请求
+            if self._perform_request("POST", url, headers=headers, json_data=data):
+                # --- 请求成功 (HTTP 200) ---
+                resp_text = self.session.last_response.text
+                
+                # 1. 检查是否是 WaitList
+                if "WaitList" in resp_text:
+                    earliest_date_out.append("WaitList")
+                    return True
+                
+                # 2. 解析日期
+                try:
+                    j = self.session.last_response.json()
+                    if j.get("earliestSlotLists"):
+                        raw_date = j["earliestSlotLists"][0]["date"]
+                        # raw_date 示例: "09/10/2025 00:00:00" (表示 2025年9月10日)
+                        
+                        try:
+                            # 1. 按 MM/DD/YYYY HH:MM:SS 解析
+                            dt = datetime.strptime(raw_date, "%m/%d/%Y %H:%M:%S")
+                            # 2. 转为 DD/MM/YYYY
+                            std_date = dt.strftime("%d/%m/%Y")
+                            earliest_date_out.append(std_date)
+                            return True
+                        except ValueError:
+                            # 备用:万一格式变了 (比如变成了 YYYY-MM-DD),尝试其他解析或原样返回
+                            # 这里记录警告,防止 silently fail
+                            VSC_WARN("vfs_plg", "[%s] Date parse warning: '%s' not matching %%m/%%d/%%Y", self.group_id, raw_date)
+                            # 尝试直接分割,虽然可能格式不对,但总比崩溃好
+                            earliest_date_out.append(raw_date.split(" ")[0])
+                            return True
+                            
+                except Exception as e:
+                    VSC_DEBUG("vfs_plg", f"Parse earliest slot error: {e}")
+                    pass
+                
+                # 虽然 HTTP 200 但没有 slot 数据
+                self._set_error(2002, "No slots found in response")
+                return False
+
+            else:
+                # --- 请求失败 (HTTP != 200) ---
+                err = self.get_last_error()
+                
+                # 关键逻辑:如果被防火墙拦截 (403),尝试过盾
+                if err.error_code == 403:
+                    VSC_WARN("vfs_plg", "[%s] Query Blocked (403) - Attempt %d/%d. Solving Cloudflare...", 
+                             self.group_id, attempt + 1, max_retries)
+                    
+                    # 调用过盾逻辑
+                    cf_token = self._handle_cloudflare_challenge()
+                    
+                    if cf_token:
+                        # 过盾成功,随机冷却 1-3 秒后重试
+                        time.sleep(random.uniform(1, 3))
+                        continue
+                    else:
+                        # 过盾失败,无法继续
+                        self._set_error(403, "Cloudflare challenge failed during query retry")
+                        return False
+                
+                elif err.error_code == 401:
+                    # Session 失效,通常需要重新登录,这里不重试,直接返回失败让上层处理
+                    return False
+                
+                else:
+                    # 其他错误 (500, 404 等),不立即重试
+                    return False
+                    
+        # 超过最大重试次数
+        self._set_error(403, "Query blocked by firewall after max retries")
+        return False
+
+    def _set_error(self, code, msg):
+        self.last_error = VSError(code, msg)
+        VSC_ERROR("vfs_plg", "[%s] Error %d: %s", self.group_id, code, msg)
+
+    def _fmt_date(self, yyyy_mm_dd):
+        try:
+            return datetime.strptime(yyyy_mm_dd, "%Y-%m-%d").strftime("%d/%m/%Y")
+        except:
+            return yyyy_mm_dd
+
+    def _fetch_configurations(self, apt_config: Dict[str, Any]) -> bool:
+        """
+        获取并缓存签证中心、类别、子类别配置
+        对应 C++ VFSApi::fetch_configurations
+        """
+        # 1. 获取所有中心配置 (query_center)
+        if not self.center_conf:
+            centers = []
+            if not self._query_center(centers):
+                return False
+            self.center_conf = centers
+
+        # 2. 获取 Visa Category 配置
+        vac_code = apt_config.get("vacCode")
+        category_code = apt_config.get("categoryCode")
+        
+        # 检查是否已缓存该 VAC 的 category 配置
+        # C++ 逻辑是: _category_configuration[category_code] = vc
+        # 但这里逻辑似乎是按 category_code 索引。为了保险,我们按 C++ 逻辑实现。
+        # 注意:C++ map key 是 vac_code 还是 category_code?
+        # C++ 代码中: if (!_category_configuration.contains(vac_code)...) 
+        # 但存进去是用 category_code 作为 key: _category_configuration[category_code] = vc;
+        # 这在 Python 中有点奇怪,我们这里使用字典:self.category_conf[category_code] = config
+        
+        # 为了避免重复查询,我们需要知道是否已经查询过这个 VAC。
+        # 简单起见,检查目标 category_code 是否已在缓存中
+        if category_code not in self.category_conf:
+            visa_categories = []
+            if not self._query_visa_category(vac_code, visa_categories):
+                return False
+            
+            found = False
+            for vc in visa_categories:
+                if vc.get("code") == category_code:
+                    self.category_conf[category_code] = vc
+                    found = True
+                    break
+            
+            # 如果没找到,可能配置错误,但 C++ 没报错,只继续
+            if not found:
+                VSC_WARN("vfs_plg", "[%s] Category code '%s' not found in VAC '%s'", 
+                         self.group_id, category_code, vac_code)
+
+        # 3. 获取 Visa SubCategory 配置
+        sub_category_code = apt_config.get("subcategoryCode")
+        
+        if sub_category_code not in self.subcategory_conf:
+            visa_subcategories = []
+            if not self._query_visa_sub_category(vac_code, category_code, visa_subcategories):
+                return False
+            
+            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:
+                VSC_WARN("vfs_plg", "[%s] SubCategory code '%s' not found", 
+                         self.group_id, sub_category_code)
+
+        return True
+
+    def _query_center(self, centers_out: List) -> bool:
+        """对应 C++ VFSApi::query_center"""
+        mission = self.free_config.get("missionCode")
+        country = self.free_config.get("countryCode")
+        url = f"https://lift-api.vfsglobal.com/master/center/{mission}/{country}/en-US"
+        
+        headers = self._get_common_headers(with_auth=False) # 通常 Master API 不需要 Auth 或者是独立的
+        # 实际上根据 C++ 代码,这里使用的是 _get_client_source 生成的 headers,且不需要 authorize token
+        # 但 C++ 代码中也没有明确加 authorize header,除非 _jwt_token 不为空
+        # 保险起见,如果有了 Token 就带上,没有就不带 (get_common_headers 默认逻辑)
+        
+        if not self._perform_request("GET", url, headers=headers):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            if isinstance(j, list):
+                centers_out.extend(j)
+                return True
+            else:
+                self._set_error(2003, "query_center response is not a list")
+        except Exception as e:
+            self._set_error(9001, f"query_center parse error: {e}")
+        return False
+
+    def _query_visa_category(self, center_code: str, visa_category_out: List) -> bool:
+        """对应 C++ VFSApi::query_visa_category"""
+        mission = self.free_config.get("missionCode")
+        country = self.free_config.get("countryCode")
+        # URL Encode
+        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)
+        
+        if not self._perform_request("GET", url, headers=headers):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            # C++ 增加了错误检查
+            if isinstance(j, list):
+                if j and "error" in j[0] and j[0]["error"]:
+                    self._set_error(2004, f"API Error in query_visa_category: {j[0]}")
+                    return False
+                visa_category_out.extend(j)
+                return True
+            else:
+                self._set_error(2003, "query_visa_category response is not a list")
+        except Exception as e:
+            self._set_error(9001, f"query_visa_category parse error: {e}")
+        return False
+
+    def _query_visa_sub_category(self, center_code: str, category_code: str, visa_sub_category_out: List) -> bool:
+        """对应 C++ VFSApi::query_visa_sub_category"""
+        mission = self.free_config.get("missionCode")
+        country = self.free_config.get("countryCode")
+        
+        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)
+        
+        if not self._perform_request("GET", url, headers=headers):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            if isinstance(j, list):
+                if j and "error" in j[0] and j[0]["error"]:
+                    self._set_error(2004, f"API Error in query_visa_sub_category: {j[0]}")
+                    return False
+                visa_sub_category_out.extend(j)
+                return True
+            else:
+                self._set_error(2003, "query_visa_sub_category response is not a list")
+        except Exception as e:
+            self._set_error(9001, f"query_visa_sub_category parse error: {e}")
+        return False
+
+    def _read_otp_email(self) -> str:
+        """
+        读取 OTP 邮件 (对应 C++ VFSApi::read_otp_code)
+        """
+        # 1. 定义 C++ 代码中的常量
+        master_email = "visafly666@gmail.com"
+        recipient = self.config.account.username
+        # 注意:C++ 代码中 sender 是 "donotreply at vfshelpline.com"
+        sender = "donotreply at vfshelpline.com"
+        subject_keywords = "One Time Password"
+        body_keywords = "OTP"
+
+        # 2. 获取当前 UTC 时间并格式化 (对应 C++ std::put_time "%Y-%m-%d %H:%M:%S")
+        # 用于告诉云端只查询这个时间点之后收到的邮件
+        now_utc = datetime.utcnow()
+        formatted_utc_time = now_utc.strftime("%Y-%m-%d %H:%M:%S")
+
+        VSC_INFO("vfs_plg", "[%s] Waiting for OTP email sent after %s...", self.group_id, formatted_utc_time)
+
+        # 3. 轮询查收 (C++ 逻辑通常由外部调度,Python 插件内部实现轮询更稳健)
+        # 尝试 12 次,每次间隔 5 秒,共 60 秒
+        for i in range(12):
+            content_out = [] # 用于接收结果的容器
+            
+            # 对应 C++: expiry = 5 * 60 (300秒)
+            status = VSCloudApi.Instance().fetch_mail_content(
+                master_email,
+                sender,
+                recipient,
+                subject_keywords,
+                body_keywords,
+                formatted_utc_time,
+                300,
+                content_out
+            )
+
+            if status and content_out:
+                content = content_out[0]
+                # 4. 正则匹配 6位数字 (对应 C++ std::regex otp_pattern(R"(\b\d{6}\b)"))
+                match = re.search(r'\b\d{6}\b', content)
+                if match:
+                    otp = match.group(0)
+                    VSC_INFO("vfs_plg", "[%s] OTP code found: %s", self.group_id, otp)
+                    return otp
+            
+            # 未找到,等待重试
+            time.sleep(5)
+            if i % 2 == 0:
+                VSC_DEBUG("vfs_plg", "[%s] OTP not found yet, retrying...", self.group_id)
+
+        # 5. 超时处理
+        self._set_error(4004, "OTP email not found (timeout)")
+        return ""
+
+    def _submit_login_otp(self, cf_token: str, otp: str) -> bool:
+        """
+        提交 OTP 验证码进行登录 (对应 C++ VFSApi::submit_otp_code)
+        """
+        VSC_INFO("vfs_plg", "[%s] Submitting Login OTP...", self.group_id)
+
+        # 1. 准备基础数据
+        email = self.config.account.username
+        password = self.config.account.password
+        enc_password = self._encrypt_password(password)
+        
+        mission_code = self.free_config.get("missionCode", "")
+        country_code = self.free_config.get("countryCode", "")
+        
+        # 2. 生成加密 Source (每次请求时间戳不同,建议重新生成)
+        client_src = self._get_client_source()
+        orange_src = self._get_orange_source(email)
+        
+        # 3. 构造请求
+        url = "https://lift-api.vfsglobal.com/user/login"
+        headers = self._get_common_headers(with_auth=False)
+        headers.update({
+            "clientsource": client_src,
+            "orangex": orange_src,
+            "content-type": "application/x-www-form-urlencoded"
+        })
+        
+        # 注意:C++ 代码中在此处会再次调用 handle_cloudflare_challenge 获取新 token。
+        # 如果传入的 cf_token 已经在上一步(密码登录)中被消耗,这里可能需要重新获取。
+        # 为了稳健,如果传入为空,尝试重新获取。
+        if not cf_token:
+            VSC_DEBUG("vfs_plg", "[%s] CF Token is empty, regenerating for OTP...", self.group_id)
+            cf_token = self._handle_cloudflare_challenge()
+            if not cf_token:
+                return False
+
+        data = {
+            "username": email,
+            "password": enc_password,
+            "missioncode": mission_code,
+            "countrycode": country_code,
+            "languageCode": "en-US",
+            "captcha_version": "cloudflare-v1",
+            "captcha_api_key": cf_token,
+            "otp": otp  # 关键字段:OTP 验证码
+        }
+        
+        # 4. 发送请求 (POST form-urlencoded)
+        if not self._perform_request("POST", url, headers=headers, data=data):
+            return False
+            
+        # 5. 处理响应
+        try:
+            resp_json = self.session.last_response.json()
+            
+            # A. 检查错误
+            if "error" in resp_json and resp_json["error"]:
+                err_obj = resp_json["error"]
+                # 兼容不同的错误结构 (message 或 description)
+                msg = ""
+                if isinstance(err_obj, dict):
+                    msg = err_obj.get("message") or err_obj.get("description") or "Unknown error"
+                else:
+                    msg = str(err_obj)
+                
+                if "locked" in str(msg).lower():
+                    self._set_error(4005, f"Account Locked during OTP: {msg}")
+                else:
+                    self._set_error(4006, f"OTP Login Error: {msg}")
+                return False
+
+            # B. 检查 AccessToken (登录成功)
+            if "accessToken" in resp_json and resp_json["accessToken"]:
+                self.jwt_token = resp_json["accessToken"]
+                VSC_INFO("vfs_plg", "[%s] OTP Login successful, JWT obtained.", self.group_id)
+                return True
+            
+            self._set_error(9006, f"OTP Login failed, unknown response: {str(resp_json)[:100]}")
+            return False
+            
+        except Exception as e:
+            self._set_error(9001, f"OTP Login parse error: {str(e)}")
+            return False
+
+    def _prepare_user_info(self) -> Dict:
+        return {
+            "first_name": "Jiarui", 
+            "last_name": "Hu", 
+            "passport_no": "E91829352",
+            "phone_country_code": 86,
+            "phone_no": "17386033452", 
+            "email": "arket_zz@163.com",
+            "alias_email": "arket_zz@gmail-app.com",
+            "birthday": "1990-01-01", 
+            "passport_expiry_date": "2030-01-01",
+            "nationality": "China", 
+            "gender": "Male",
+            "expected_submit_start": "2026-01-01",
+            "expected_submit_end": "2026-03-15",
+            "rules": "time_filter = AM,PM\nallowed_weekdays = 1,2,3,4,5,6,7"
+        }
+        
+    def _submit_no_addition_service(self, urn) -> bool:
+        url = "https://lift-api.vfsglobal.com/vas/mapvas"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "loginUser": self.config.account.username,
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "urn": urn,
+            "applicants": []
+        }
+        # C++ 只请求不检查结果,或者只要200就行
+        return 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)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "languageCode": "en-US"
+        }
+        
+        if self._perform_request("POST", url, headers=headers, json_data=data):
+            try:
+                j = self.session.last_response.json()
+                amt = j.get("totalamount", 0.0)
+                curr = ""
+                if j.get("feeDetails"):
+                    curr = j["feeDetails"][0].get("currency", "")
+                return float(amt), curr
+            except:
+                pass
+        return 0.0, ""
+
+    def _schedule(self, apt_config, urn, amount, currency, slot_id, res_out: Dict) -> bool:
+        url = "https://lift-api.vfsglobal.com/appointment/schedule"
+        headers = self._get_common_headers(with_auth=True)
+        headers["content-type"] = "application/json;charset=UTF-8"
+        
+        data = {
+            "missionCode": self.free_config.get("missionCode"),
+            "countryCode": self.free_config.get("countryCode"),
+            "centerCode": apt_config.get("vacCode"),
+            "loginUser": self.config.account.username,
+            "urn": urn,
+            "notificationType": "none",
+            "paymentdetails": {
+                "paymentmode": "Online",
+                "RequestRefNo": "",
+                "clientId": "",
+                "merchantId": "",
+                "amount": amount,
+                "currency": currency
+            },
+            "allocationId": slot_id,
+            "CanVFSReachoutToApplicant": True
+        }
+        
+        if not self._perform_request("POST", url, headers=headers, json_data=data):
+            return False
+            
+        try:
+            j = self.session.last_response.json()
+            if j.get("IsAppointmentBooked"):
+                res_out.update(j)
+                return True
+        except:
+            pass
+        return False
+
+    def _pay_request(self, payload) -> str:
+        # C++: 检查 301/302 Redirect Location
+        url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
+        headers = {
+             "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+             "referer": "https://visa.vfsglobal.com/",
+             "user-agent": self.user_agent
+        }
+        
+        try:
+            # allow_redirects=False 以捕获 Location header
+            resp = self.session.get(url, headers=headers, allow_redirects=False)
+            if resp.status_code in [301, 302]:
+                loc = resp.headers.get("Location", "")
+                if loc.startswith("http"):
+                    return loc
+                else:
+                    return "https://online.vfsglobal.com" + loc if loc.startswith("/") else "https://online.vfsglobal.com/" + loc
+        except:
+            pass
+        return ""
+
+    def _save_http_session(self, page_url):
+        """
+        提取 cookies, local_storage, 存入 VSCloudApi
+        修复:curl_cffi 没有 requests.utils.dict_from_cookiejar,需手动提取
+        """
+        cookies_dict = {}
+        try:
+            # 方式 1: curl_cffi 的 cookies 对象通常支持 get_dict()
+            if hasattr(self.session.cookies, "get_dict"):
+                cookies_dict = self.session.cookies.get_dict()
+            else:
+                # 方式 2: 迭代 (兼容标准 CookieJar)
+                for c in self.session.cookies:
+                    cookies_dict[c.name] = c.value
+        except Exception as e:
+            VSC_WARN("vfs_plg", "[%s] Failed to extract cookies: %s", self.group_id, str(e))
+
+        cookies_str = json.dumps(cookies_dict)
+        
+        # 简单生成 SessionID hash
+        ua_str = self.user_agent or "unknown_ua"
+        raw = cookies_str + ua_str + page_url
+        
+        try:
+            session_id = hashes.Hash(hashes.SHA256(), backend=default_backend())
+            session_id.update(raw.encode())
+            sid = session_id.finalize().hex()
+            
+            dummy_res = {}
+            # 代理可能是 None,处理一下
+            proxy_ip = self.config.proxy.ip if self.config.proxy else ""
+            
+            VSCloudApi.Instance().create_http_session(
+                sid, cookies_str, "", ua_str, proxy_ip, page_url, dummy_res
+            )
+            VSC_INFO("vfs_plg", "[%s] Session saved. ID: %s", self.group_id, sid)
+        except Exception as e:
+            # 捕获异常,确保即使保存 Session 失败,也不影响主流程返回预订结果
+            VSC_WARN("vfs_plg", "[%s] Failed to save session to cloud: %s", self.group_id, str(e))

+ 170 - 0
predict_server.py

@@ -0,0 +1,170 @@
+# predict_server.py
+import os
+import json
+import string
+import socket
+import traceback
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from io import BytesIO
+from collections import OrderedDict
+
+# 深度学习依赖
+import torch
+from torch import nn
+from torchvision import transforms
+from PIL import Image
+
+# ================= 定义模型结构 (保持不变) =================
+class Model(nn.Module):
+    def __init__(self, n_classes, input_shape=(3, 64, 128)):
+        super(Model, self).__init__()
+        self.input_shape = input_shape
+        channels = [32, 64, 128, 256, 256]
+        layers = [2, 2, 2, 2, 2]
+        kernels = [3, 3, 3, 3, 3]
+        pools = [2, 2, 2, 2, (2, 1)]
+        modules = OrderedDict()
+        
+        def cba(name, in_channels, out_channels, kernel_size):
+            modules[f'conv{name}'] = nn.Conv2d(in_channels, out_channels, kernel_size,
+                                               padding=(1, 1) if kernel_size == 3 else 0)
+            modules[f'bn{name}'] = nn.BatchNorm2d(out_channels)
+            modules[f'relu{name}'] = nn.ReLU(inplace=True)
+        
+        last_channel = 3
+        for block, (n_channel, n_layer, n_kernel, k_pool) in enumerate(zip(channels, layers, kernels, pools)):
+            for layer in range(1, n_layer + 1):
+                cba(f'{block+1}{layer}', last_channel, n_channel, n_kernel)
+                last_channel = n_channel
+            modules[f'pool{block + 1}'] = nn.MaxPool2d(k_pool)
+        modules[f'dropout'] = nn.Dropout(0.25, inplace=True)
+        
+        self.cnn = nn.Sequential(modules)
+        self.lstm = nn.LSTM(input_size=self.infer_features(), hidden_size=128, num_layers=2, bidirectional=True)
+        self.fc = nn.Linear(in_features=256, out_features=n_classes)
+    
+    def infer_features(self):
+        x = torch.zeros((1,)+self.input_shape)
+        x = self.cnn(x)
+        x = x.reshape(x.shape[0], -1, x.shape[-1])
+        return x.shape[1]
+
+    def forward(self, x):
+        x = self.cnn(x)
+        x = x.reshape(x.shape[0], -1, x.shape[-1])
+        x = x.permute(2, 0, 1)
+        x, _ = self.lstm(x)
+        x = self.fc(x)
+        return x
+
+# ================= 推理类 =================
+class DeployModel:
+    def __init__(self, model_path):
+        self.num_classes = 12
+        self.characters = '-' + string.digits + '$'
+        self.width = 150
+        self.hight = 80
+        self.model = Model(self.num_classes, input_shape=(3, self.hight, self.width))
+        
+        if os.path.exists(model_path):
+            self.model.load_state_dict(torch.load(model_path, map_location=torch.device('cpu')))
+            self.model.eval()
+            print(f"Model loaded successfully from {model_path}")
+        else:
+            raise FileNotFoundError(f"Model file not found: {model_path}")
+
+        self.transforms_func = transforms.Compose([
+            transforms.Resize((self.hight, self.width)),
+            transforms.ToTensor()
+        ])
+        
+    def decode(self, sequence):
+        a = ''.join([self.characters[x] for x in sequence])
+        s = []
+        last = None
+        for x in a:
+            if x != last:
+                s.append(x)
+                last = x
+        s2 = ''.join([x for x in s if x != self.characters[0]])
+        return s2
+    
+    def inference_bytes(self, image_bytes):
+        try:
+            image = Image.open(BytesIO(image_bytes))
+            if image.mode == 'RGBA':
+                image = image.convert('RGB')
+            if self.transforms_func is not None:
+                image = self.transforms_func(image)
+            with torch.no_grad():
+                output = self.model(image.unsqueeze(0).cpu())
+                
+            output_argmax = output.detach().permute(1, 0, 2).argmax(dim=-1)
+            predict_label = self.decode(output_argmax[0])
+            return predict_label
+        except Exception as e:
+            print(f"Inference error: {e}")
+            return ""
+
+# ================= HTTP 处理 =================
+# 全局模型实例
+deploy_model = None
+
+class RequestHandler(BaseHTTPRequestHandler):
+    
+    def _send_response(self, status, content_type, content):
+        self.send_response(status)
+        self.send_header('Content-type', content_type)
+        self.end_headers()
+        self.wfile.write(content)
+
+    def do_POST(self):
+        if self.path == '/predict/vfcode':
+            try:
+                # 获取内容长度
+                content_length = int(self.headers.get('Content-Length', 0))
+                if content_length == 0:
+                    self._send_response(400, 'application/json', json.dumps({'code': 400, 'msg': 'Empty body'}).encode())
+                    return
+
+                # 直接读取 Raw Binary 数据 (简化通信,避免 multipart 解析问题)
+                file_content = self.rfile.read(content_length)
+
+                # 推理
+                result_string = deploy_model.inference_bytes(file_content)
+                
+                response = {
+                    'data': result_string,
+                    'msg': "success",
+                    'code': 200
+                }
+                self._send_response(200, 'application/json', json.dumps(response).encode())
+                print(f"Processed request. Result: {result_string}")
+
+            except Exception as e:
+                traceback.print_exc()
+                response = {'data': '', 'msg': 'failed', 'code': 500}
+                self._send_response(500, 'application/json', json.dumps(response).encode())
+        else:
+            self._send_response(404, 'text/plain', b'Not Found')
+
+if __name__ == '__main__':
+    # 配置区
+    MODEL_PATH = 'data/ocr.pth'
+    PORT = 8085
+    
+    # 启动
+    if not os.path.exists(MODEL_PATH):
+        print(f"[ERROR] 请确保模型文件存在: {MODEL_PATH}")
+        exit(1)
+        
+    deploy_model = DeployModel(MODEL_PATH)
+    
+    server_address = ('0.0.0.0', PORT) # 监听所有接口
+    httpd = HTTPServer(server_address, RequestHandler)
+    print(f'OCR Server running on port {PORT}...')
+    try:
+        httpd.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    httpd.server_close()

+ 52 - 0
test.py

@@ -0,0 +1,52 @@
+import requests
+
+# 目标 URL
+url = "https://ireland.blsspainglobal.com/Global/account/login"
+
+# 请求头
+headers = {
+  'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+  'accept-language': 'zh-CN,zh;q=0.9',
+  'upgrade-insecure-requests': '1',
+  'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
+  'Cookie': '.AspNetCore.Antiforgery.oY6EC-RUIyA=CfDJ8P1AkyeYcD1Ite9P68eByJroaIfpxao3RR7eDNqGHFCzilNlvksu3Lm1hSGPhPfSnGJdMIWBtHWKKamv82f-fWknhJkdW1gH5X_OEuMWGrlDGJiwOcCOmdSzaTH14Yh6lveEAlEaUhblp38cJ_gaUFc'
+}
+
+# 待测试的代理列表
+proxy_list = [
+    "9zMOkhCng5HG8SZ:RTBuPWx1CEr6DfD@95.135.130.73:48306",
+    "aNCaMFjblyODleO:5i4lV3VjNwE4bkL@95.135.130.76:41553"
+]
+
+print(f"{'Proxy IP':<30} | {'Status':<10} | {'Result'}")
+print("-" * 60)
+
+for proxy_str in proxy_list:
+    # 构造 requests 库需要的 proxy 字典格式
+    # 格式为: http://user:password@ip:port
+    proxies = {
+        "http": f"http://{proxy_str}",
+        "https": f"http://{proxy_str}"
+    }
+
+    try:
+        # 发送请求,设置超时时间为 10 秒,防止死等
+        response = requests.get(url, headers=headers, proxies=proxies, timeout=10)
+        
+        status_code = response.status_code
+        
+        if status_code == 200:
+            result = "✅ 正常 (Normal)"
+        elif status_code == 403:
+            result = "🚫 封禁 (Blocked 403)"
+        else:
+            result = f"⚠️ 其他 ({status_code})"
+            
+        print(f"{proxy_str.split('@')[1]:<30} | {status_code:<10} | {result}")
+
+    except requests.exceptions.ProxyError:
+        print(f"{proxy_str.split('@')[1]:<30} | Error      | ❌ 代理连接失败")
+    except requests.exceptions.ConnectTimeout:
+        print(f"{proxy_str.split('@')[1]:<30} | Timeout    | ❌ 连接超时")
+    except Exception as e:
+        print(f"{proxy_str.split('@')[1]:<30} | Error      | ❌ 发生错误: {str(e)[:20]}")

+ 0 - 0
toolkit/__init__.py


+ 106 - 0
toolkit/account_manager.py

@@ -0,0 +1,106 @@
+# toolkit/account_manager.py
+import threading
+import time
+import random
+from typing import Optional, Dict, Any
+from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO # type: ignore
+
+class AccountManager:
+    """模拟账户管理器,用于获取和锁定账户。"""
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls):
+        with cls._lock:
+            if cls._instance is None:
+                cls._instance = super().__new__(cls)
+                cls._instance._accounts = {
+                    "ie_nl": [
+                        {
+                            "id": 580,
+                            "lock_until": 0,
+                            "password": "y1wm@hf0Bn",
+                            "username": "sg_fr_onoy9i@gmail-app.com"
+                        },
+                        {
+                            "id": 581,
+                            "lock_until": 0,
+                            "password": "p7@@0NnOR8",
+                            "username": "sg_fr_k6vuhb@gmail-app.com"
+                        },
+                        {
+                            "id": 582,
+                            "lock_until": 0,
+                            "password": "D@4OCh2j3I",
+                            "username": "sg_fr_cvq08b@gmail-app.com"
+                        },
+                        {
+                            "id": 583,
+                            "lock_until": 0,
+                            "password": "xT01gsHw@j",
+                            "username": "sg_fr_4cn9xj@gmail-app.com"
+                        },
+                        {
+                            "id": 584,
+                            "lock_until": 0,
+                            "password": "@aprZh7NbA",
+                            "username": "sg_fr_m32a2e@gmail-app.com"
+                        },
+                        {
+                            "id": 585,
+                            "lock_until": 0,
+                            "password": "SR2hnX@ho5",
+                            "username": "sg_fr_p0nz5l@gmail-app.com"
+                        }
+                    ],
+                    "gb_fr": [
+                        {
+                            "id": 0,
+                            "username":"arket_zz@163.com",
+                            "password": "Visafly@111",
+                            "lock_until": 0
+                        },
+                    ],
+                    "ie_es": [
+                        {
+                            "id": 0,
+                            "username":"arket_zz@163.com",
+                            "password": "dx4ua@!.X.i8Xn8",
+                            "lock_until": 0
+                        },
+                    ]
+                }
+                cls._instance._account_lock = threading.Lock() # 用于保护账户状态
+            return cls._instance
+
+    @staticmethod
+    def Instance():
+        return AccountManager()
+
+    def get_next_account(self, pool_name: str) -> Optional[Dict[str, Any]]:
+        """从指定池中获取下一个可用账户。"""
+        with self._account_lock:
+            accounts = self._accounts.get(pool_name, [])
+            now = time.time()
+            
+            available_accounts = [acc for acc in accounts if acc["lock_until"] <= now]
+            if not available_accounts:
+                VSC_WARN("account_manager", "No available accounts in pool '%s'", pool_name)
+                return None
+            
+            # 简单地随机选择一个可用账户
+            account = random.choice(available_accounts)
+            VSC_DEBUG("account_manager", "Selected account ID %d from pool '%s'", account["id"], pool_name)
+            return account
+
+    def lock_account(self, pool_name: str, account_id: int, duration_seconds: int):
+        """锁定指定账户一段时间。"""
+        with self._account_lock:
+            accounts = self._accounts.get(pool_name, [])
+            for acc in accounts:
+                if acc["id"] == account_id:
+                    acc["lock_until"] = time.time() + duration_seconds
+                    VSC_INFO("account_manager", "Locked account ID %d in pool '%s' until %s", 
+                             account_id, pool_name, time.ctime(acc["lock_until"]))
+                    return
+            VSC_WARN("account_manager", "Account ID %d not found in pool '%s' for locking", account_id, pool_name)

+ 59 - 0
toolkit/binding_manager.py

@@ -0,0 +1,59 @@
+# toolkit/binding_manager.py
+import threading
+from typing import List, Optional
+from vs_log_macros import VSC_DEBUG, VSC_INFO # type: ignore
+
+class BindingManager:
+    """模拟账户-代理绑定管理器。"""
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls):
+        with cls._lock:
+            if cls._instance is None:
+                cls._instance = super().__new__(cls)
+                cls._instance._bindings = {} # { (account_pool, account_id): (proxy_pool, proxy_id, bind_type) }
+                cls._instance._binding_lock = threading.Lock()
+            return cls._instance
+
+    @staticmethod
+    def Instance():
+        return BindingManager()
+
+    def get_bounded_proxy_id(self, account_pool: str, account_id: int) -> Optional[int]:
+        """获取给定账户绑定的代理ID。"""
+        with self._binding_lock:
+            key = (account_pool, account_id)
+            binding = self._bindings.get(key)
+            if binding:
+                VSC_DEBUG("binding_manager", "Found binding for account %d: proxy %d", account_id, binding[1])
+                return binding[1]
+            VSC_DEBUG("binding_manager", "No binding found for account %d in pool %s", account_id, account_pool)
+            return None
+    
+    def get_bounded_proxies_ids(self, account_pool: str, proxy_pool: str) -> List[int]:
+        """获取所有在特定代理池中被账户绑定的代理ID列表。"""
+        with self._binding_lock:
+            bounded_ids = []
+            for (acc_pool, _), (p_pool, p_id, _) in self._bindings.items():
+                if acc_pool == account_pool and p_pool == proxy_pool:
+                    bounded_ids.append(p_id)
+            VSC_DEBUG("binding_manager", "Bounded proxy IDs in pool %s for acc pool %s: %s", proxy_pool, account_pool, bounded_ids)
+            return bounded_ids
+
+    def create_binding(self, account_pool: str, account_id: int, 
+                       proxy_pool: str, proxy_id: int, bind_type: str):
+        """创建账户和代理的绑定。"""
+        with self._binding_lock:
+            key = (account_pool, account_id)
+            self._bindings[key] = (proxy_pool, proxy_id, bind_type)
+            VSC_INFO("binding_manager", "Created binding: account %d (pool %s) -> proxy %d (pool %s) type %s",
+                     account_id, account_pool, proxy_id, proxy_pool, bind_type)
+
+    def remove_binding(self, account_pool: str, account_id: int):
+        """移除账户和代理的绑定。"""
+        with self._binding_lock:
+            key = (account_pool, account_id)
+            if key in self._bindings:
+                del self._bindings[key]
+                VSC_INFO("binding_manager", "Removed binding for account %d in pool %s", account_id, account_pool)

+ 199 - 0
toolkit/proxy_manager.py

@@ -0,0 +1,199 @@
+# toolkit/proxy_manager.py
+import threading
+import time
+import random
+from typing import List, Optional, Dict, Any
+from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO # type: ignore
+
+class ProxyManager:
+    """模拟代理管理器,用于获取和锁定代理。"""
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls):
+        with cls._lock:
+            if cls._instance is None:
+                cls._instance = super().__new__(cls)
+                cls._instance._proxies = {
+                    "global_proxy": [
+                        {
+                            "id": 100003,
+                            "ip": "82.152.15.37",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfs03sh920"
+                        },
+                        {
+                            "id": 100005,
+                            "ip": "82.152.19.246",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfmv4gp9c"
+                        },
+                        {
+                            "id": 100014,
+                            "ip": "82.152.15.33",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfpclja918"
+                        },
+                        {
+                            "id": 100018,
+                            "ip": "82.152.19.4",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfmv4gp914"
+                        },
+                        {
+                            "id": 100019,
+                            "ip": "82.152.15.8",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfmv4gp913"
+                        },
+                        {
+                            "id": 100020,
+                            "ip": "82.152.19.141",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfmv4gp912"
+                        },
+                        {
+                            "id": 100023,
+                            "ip": "82.152.19.2",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scepfmv4gp9e"
+                        },
+                        {
+                            "id": 100025,
+                            "ip": "163.5.40.236",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4scekff9u6j9a"
+                        },
+                        {
+                            "id": 100026,
+                            "ip": "45.196.65.44",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4sj3agscn8592cf"
+                        },
+                        {
+                            "id": 100027,
+                            "ip": "154.82.173.108",
+                            "lock_until": 0,
+                            "password": "M8v0m9b1p4b3",
+                            "port": 9856,
+                            "scheme": "http",
+                            "username": "l9w3z3c4B4O2"
+                        },
+                        {
+                            "id": 100028,
+                            "ip": "45.196.65.25",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4sio3rdic7m9184"
+                        },
+                        {
+                            "id": 100029,
+                            "ip": "45.196.65.109",
+                            "lock_until": 0,
+                            "password": "idzlar",
+                            "port": 7778,
+                            "scheme": "http",
+                            "username": "4sibp4qi26n93c"
+                        }
+                    ],
+                    "ireland_proxies": [
+                        {
+                            "id": 100029,
+                            "ip": "95.135.130.73",
+                            "lock_until": 0,
+                            "password": "RTBuPWx1CEr6DfD",
+                            "port": 48306,
+                            "scheme": "http",
+                            "username": "9zMOkhCng5HG8SZ"
+                        },
+                        {
+                            "id": 100029,
+                            "ip": "95.135.130.76",
+                            "lock_until": 0,
+                            "password": "5i4lV3VjNwE4bkL",
+                            "port": 41553,
+                            "scheme": "http",
+                            "username": "aNCaMFjblyODleO"
+                        }
+                    ]
+                }
+                cls._instance._proxy_lock = threading.Lock() # 用于保护代理状态
+            return cls._instance
+
+    @staticmethod
+    def Instance():
+        return ProxyManager()
+
+    def get_next_proxy(self, pool_name: str) -> Optional[Dict[str, Any]]:
+        """从指定池中获取下一个可用代理。"""
+        with self._proxy_lock:
+            proxies = self._proxies.get(pool_name, [])
+            now = time.time()
+            
+            available_proxies = [p for p in proxies if p["lock_until"] <= now]
+            if not available_proxies:
+                VSC_WARN("proxy_manager", "No available proxies in pool '%s'", pool_name)
+                return None
+            
+            proxy = random.choice(available_proxies)
+            VSC_DEBUG("proxy_manager", "Selected proxy ID %d from pool '%s'", proxy["id"], pool_name)
+            return proxy
+
+    def get_unbind_proxy(self, pool_name: str, bounded_ids: List[int]) -> Optional[Dict[str, Any]]:
+        """获取一个未绑定(且未锁定)的代理。"""
+        with self._proxy_lock:
+            proxies = self._proxies.get(pool_name, [])
+            now = time.time()
+            
+            unbound_and_available_proxies = [
+                p for p in proxies 
+                if p["id"] not in bounded_ids and p["lock_until"] <= now
+            ]
+            if not unbound_and_available_proxies:
+                VSC_WARN("proxy_manager", "No unbind and available proxies in pool '%s'", pool_name)
+                return None
+            
+            proxy = random.choice(unbound_and_available_proxies)
+            VSC_DEBUG("proxy_manager", "Selected unbind proxy ID %d from pool '%s'", proxy["id"], pool_name)
+            return proxy
+
+    def lock_proxy(self, pool_name: str, proxy_id: int, duration_seconds: int):
+        """锁定指定代理一段时间。"""
+        with self._proxy_lock:
+            proxies = self._proxies.get(pool_name, [])
+            for p in proxies:
+                if p["id"] == proxy_id:
+                    p["lock_until"] = time.time() + duration_seconds
+                    VSC_INFO("proxy_manager", "Locked proxy ID %d in pool '%s' until %s",
+                             proxy_id, pool_name, time.ctime(p["lock_until"]))
+                    return
+            VSC_WARN("proxy_manager", "Proxy ID %d not found in pool '%s' for locking", proxy_id, pool_name)

+ 55 - 0
toolkit/rule_engine.py

@@ -0,0 +1,55 @@
+# toolkit/rule_engine.py
+import json
+from datetime import datetime
+from typing import List
+
+class RuleEngine:
+    def __init__(self, rules_json_str: str):
+        self.rules = {}
+        if rules_json_str:
+            try:
+                self.rules = json.loads(rules_json_str)
+            except:
+                pass
+        self.start_date = None
+        self.end_date = None
+
+    def set_date_range_start(self, date_str: str):
+        # Format: YYYY-MM-DD
+        if date_str:
+            try:
+                self.start_date = datetime.strptime(date_str, "%Y-%m-%d")
+            except:
+                pass
+
+    def set_date_range_end(self, date_str: str):
+        # Format: YYYY-MM-DD
+        if date_str:
+            try:
+                self.end_date = datetime.strptime(date_str, "%Y-%m-%d")
+            except:
+                pass
+
+    # 修正:将 list[str] 改为 List[str]
+    def select_date(self, available_dates: List[str], fmt: str = "%d/%m/%Y") -> List[str]:
+        """
+        从可用日期中筛选符合规则的日期
+        """
+        valid_dates = []
+        for d_str in available_dates:
+            try:
+                dt = datetime.strptime(d_str, fmt)
+                
+                # 检查日期范围
+                if self.start_date and dt < self.start_date:
+                    continue
+                if self.end_date and dt > self.end_date:
+                    continue
+                
+                # 这里可以添加星期几排除等逻辑
+                
+                valid_dates.append(d_str)
+            except:
+                continue
+        
+        return valid_dates

+ 50 - 0
toolkit/thread_pool.py

@@ -0,0 +1,50 @@
+# toolkit/thread_pool.py
+import threading
+from concurrent.futures import ThreadPoolExecutor, Future
+from vs_log_macros import VSC_INFO, VSC_ERROR # type: ignore
+
+class ThreadPool:
+    """
+    @brief 线程池单例类
+    模拟C++的ThreadPool,使用Python的ThreadPoolExecutor。
+    """
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls, max_workers: int = 10):
+        with cls._lock:
+            if cls._instance is None:
+                cls._instance = super().__new__(cls)
+                cls._instance._executor = ThreadPoolExecutor(max_workers=max_workers)
+                VSC_INFO("thread_pool", "ThreadPool initialized with %d workers", max_workers)
+            return cls._instance
+
+    @staticmethod
+    def getInstance(max_workers: int = 10):
+        """获取线程池单例实例。"""
+        return ThreadPool(max_workers)
+
+    def enqueue(self, func, *args, **kwargs) -> Future:
+        """
+        @brief 提交任务到线程池。
+        @param func 要执行的函数
+        @param args 函数的位置参数
+        @param kwargs 函数的关键字参数
+        @return Future 对象,表示异步操作的结果
+        """
+        try:
+            future = self._executor.submit(func, *args, **kwargs)
+            return future
+        except Exception as e:
+            VSC_ERROR("thread_pool", "Failed to enqueue task: %s", str(e))
+            raise
+
+    def shutdown(self, wait: bool = True):
+        """
+        @brief 关闭线程池。
+        @param wait 如果为True,则等待所有待处理的future完成。
+        """
+        if self._executor:
+            VSC_INFO("thread_pool", "Shutting down ThreadPool (wait=%s)...", wait)
+            self._executor.shutdown(wait=wait)
+            self._executor = None

+ 290 - 0
toolkit/vs_cloud_api.py

@@ -0,0 +1,290 @@
+# toolkit/vs_cloud_api.py
+import requests
+import json
+import time
+import urllib.parse
+from typing import Dict, Any, Optional
+from vs_log_macros import VSC_ERROR, VSC_INFO, VSC_DEBUG # type: ignore
+
+class VSCloudApi:
+    """
+    @brief VSCloudApi 的 Python 实现 (1:1 对应 C++ 版本)
+    用于对接 http://45.137.220.138:8888 的云端服务
+    """
+    _instance = None
+
+    def __new__(cls, *args, **kwargs):
+        if cls._instance is None:
+            cls._instance = super(VSCloudApi, cls).__new__(cls)
+            # 初始化默认配置,对应 C++ 构造函数参数
+            cls._instance.base_url = "http://45.137.220.138:8000"
+            cls._instance.api_token = "7x9EjFpmv7GjZc6AfVeqxuUBANpqkpkHAtxJM7CAW5oZhs0nEyCJBy39N4XXs5hgfYWXw3jFrcgXqQ42HAx9Qvwtk9vC2GvKBbWz"
+            cls._instance.session = requests.Session()
+        return cls._instance
+
+    @staticmethod
+    def Instance():
+        return VSCloudApi()
+
+    def _get_headers(self, content_type: str = "application/json") -> Dict[str, str]:
+        return {
+            "Authorization": self.api_token,
+            "Content-Type": content_type,
+            "Accept": "application/json, text/plain, */*"
+        }
+
+    def _handle_request(self, method: str, endpoint: str, **kwargs) -> Optional[Any]:
+        url = f"{self.base_url}{endpoint}"
+        try:
+            resp = self.session.request(method, url, timeout=30, **kwargs)
+            if resp.status_code == 200:
+                try:
+                    return resp.json()
+                except json.JSONDecodeError:
+                    # 部分接口可能返回纯文本或空,视情况而定
+                    return resp.text
+            else:
+                VSC_ERROR("vs_cloud", f"Request failed: {method} {url} [{resp.status_code}] {resp.text}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"Request exception: {method} {url} - {str(e)}")
+        return None
+
+    def query_user_pending(self, tech_provider: str, pending_users_out: Dict[str, Any]) -> bool:
+        """查询待处理用户"""
+        params = {"tech_provider": tech_provider}
+        headers = self._get_headers()
+        # C++: headers = curl_slist_append(headers, auth.c_str());
+        
+        result = self._handle_request("GET", "/api/autobooking/pending", params=params, headers=headers)
+        if result is not None:
+            pending_users_out.update(result)
+            return True
+        return False
+
+    def query_user_info(self, uid: str, userinfo_out: Dict[str, Any]) -> bool:
+        """查询 VFS 用户信息"""
+        headers = self._get_headers()
+        result = self._handle_request("GET", f"/api/autobooking/{uid}", headers=headers)
+        if result is not None:
+            userinfo_out.update(result)
+            return True
+        return False
+
+    def update_user_info(self, uid: str, uinfo: Dict[str, Any], updated_userinfo_out: Dict[str, Any]) -> bool:
+        """更新 VFS 用户信息"""
+        headers = self._get_headers()
+        # C++: if (!uinfo.is_null()) put_data = uinfo.dump();
+        
+        result = self._handle_request("PUT", f"/api/autobooking/{uid}", headers=headers, json=uinfo)
+        if result is not None:
+            updated_userinfo_out.update(result)
+            return True
+        return False
+
+    def submit_anti_turnstile_task(self, proxy: str, website_url: str, task_out: Dict[str, Any]) -> bool:
+        """
+        提交反 Turnstile 任务
+        注意 C++ 逻辑:args 字段是 JSON dump 后的字符串
+        """
+        headers = self._get_headers()
+        
+        args = {
+            "proxy": proxy,
+            "websiteUrl": website_url
+        }
+        
+        payload = {
+            "command": "AntiCloudflareTurnstileTask",
+            "args": json.dumps(args), # C++: data["args"] = args.dump();
+            "status": 0
+        }
+
+        result = self._handle_request("POST", "/api/tasks", headers=headers, json=payload)
+        if result is not None:
+            task_out.update(result)
+            return True
+        return False
+
+    def get_anti_turnstile_result(self, task_id: str, result_out: Dict[str, Any]) -> bool:
+        """获取反 Turnstile 结果"""
+        headers = self._get_headers()
+        result = self._handle_request("GET", f"/api/tasks/{task_id}", headers=headers)
+        if result is not None:
+            result_out.update(result)
+            return True
+        return False
+    
+    def submit_anticloudflare_task(self, proxy: str, website_url: str) -> Optional[Dict]:
+        """
+        提交 AntiCloudflareTask (用于 TLSContact 5s 盾)
+        """
+        # 注意:这里路径根据之前的 C++ 移植经验是 /api/tasks,
+        # 如果你确定是 /common/api/tasks,请修改这里。为了兼容现有 base_url,我使用 /api/tasks
+        # url = f"https://{self._base_url}/common/api/tasks" 
+        url = f"{self.base_url}/api/tasks" 
+        
+        args = {
+            'proxy': proxy,
+            'websiteUrl': website_url
+        }
+        data = {
+            "command": "AntiCloudflareTask", # 关键:Command 名字不同
+            "args": json.dumps(args),
+            "status": 0
+        }
+        headers = {
+            'Authorization': self.api_token,
+            'Content-Type': 'application/json'
+        }
+        try:
+            response = self.session.post(url, headers=headers, json=data, timeout=30)
+            if response.status_code != 200:
+                VSC_ERROR("vs_cloud", f"submit_anticloudflare_task failed: {response.status_code}")
+                return None
+            return response.json()
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"submit_anticloudflare_task exception: {e}")
+        return None
+    
+    def get_anticloudflare_result(self, task_id, retry_interval=5, max_retries=20) -> Optional[Dict]:
+        """
+        获取 AntiCloudflareTask 结果 (带轮询)
+        """
+        url = f"{self.base_url}/api/tasks/{task_id}"
+        headers = {
+            'Authorization': self.api_token
+        }
+        try:
+            for attempt in range(max_retries):
+                response = self.session.get(url, headers=headers, timeout=30)
+                if response.status_code == 200:
+                    data = response.json()
+                    # status 2 表示成功
+                    if data.get("status") == 2:
+                        return data
+                    elif data.get("status") == 3:
+                        VSC_ERROR("vs_cloud", f"AntiCloudflareTask failed: {data.get('result')}")
+                        return None
+                    else:
+                        # VSC_DEBUG("vs_cloud", f"Task {task_id} not ready, retrying...")
+                        time.sleep(retry_interval)
+                else:
+                    VSC_ERROR("vs_cloud", f"Error getting task result: {response.text}")
+                    break
+            VSC_ERROR("vs_cloud", "Max retries reached, AntiCloudflareTask not completed.")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"get_anticloudflare_result exception: {e}")
+        return None
+
+    def create_http_session(
+        self, 
+        session_id: str, 
+        cookies: str, 
+        local_storage: str, 
+        user_agent: str, 
+        proxy: str, 
+        page: str, 
+        created_session_out: Dict[str, Any]
+    ) -> bool:
+        """创建 http session"""
+        headers = self._get_headers()
+        
+        payload = {
+            "local_storage": local_storage,
+            "cookies": cookies,
+            "user_agent": user_agent,
+            "proxy": proxy,
+            "page": page,
+            "session_id": session_id
+        }
+        
+        result = self._handle_request("POST", "/api/http-session", headers=headers, json=payload)
+        if result is not None:
+            # result 可能是 None 如果解析失败,或者是一个 dict
+            if isinstance(result, dict):
+                created_session_out.update(result)
+            return True
+        return False
+
+    def fetch_mail_content(
+        self,
+        email: str,
+        sender: str,
+        recipient: str,
+        subject_keywords: str,
+        body_keywords: str,
+        sent_date: str,
+        expiry: int,
+        content_out: list # Python string is immutable, use list to simulate pointer behavior like C++
+    ) -> bool:
+        """
+        获取邮件内容
+        C++ logic: query params in URL + POST method + empty body
+        """
+        params = {
+            "email": email,
+            "sender": sender,
+            "recipient": recipient,
+            "subjectKeywords": subject_keywords,
+            "bodyKeywords": body_keywords,
+            "sentDate": sent_date,
+            "expiry": str(expiry)
+        }
+        
+        headers = self._get_headers()
+        # C++: curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "");
+        
+        # requests.post(url, params=params) 会把 params 拼接到 URL 后面
+        url = f"{self.base_url}/api/email-authorizations/fetch"
+        try:
+            resp = self.session.post(url, headers=headers, params=params, data="", timeout=30)
+            if resp.status_code == 200:
+                # C++: content.assign(response_string.data(), response_string.length());
+                # 这里假设 content_out 是一个 list,我们将结果 append 进去或修改第一个元素
+                if isinstance(content_out, list):
+                    content_out.clear()
+                    content_out.append(resp.text)
+                return True
+            else:
+                VSC_ERROR("vs_cloud", f"fetch_mail_content failed: {resp.status_code} {resp.text}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"fetch_mail_content exception: {e}")
+            
+        return False
+
+    def fetch_mail_content_from_top(
+        self,
+        email: str,
+        sender: str,
+        recipient: str,
+        subject_keywords: str,
+        body_keywords: str,
+        top: int,
+        content_out: list
+    ) -> bool:
+        """从顶部获取邮件内容"""
+        params = {
+            "email": email,
+            "sender": sender,
+            "recipient": recipient,
+            "subjectKeywords": subject_keywords,
+            "bodyKeywords": body_keywords,
+            "top": str(top)
+        }
+        
+        headers = self._get_headers()
+        url = f"{self.base_url}/api/email-authorizations/fetch-top"
+        
+        try:
+            resp = self.session.post(url, headers=headers, params=params, data="", timeout=30)
+            if resp.status_code == 200:
+                if isinstance(content_out, list):
+                    content_out.clear()
+                    content_out.append(resp.text)
+                return True
+            else:
+                VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top failed: {resp.status_code} {resp.text}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top exception: {e}")
+            
+        return False

+ 21 - 0
vs_log_macros.py

@@ -0,0 +1,21 @@
+# vs_log_macros.py
+import logging
+import os
+
+# 配置日志
+# 默认INFO级别,格式包含线程名
+logging.basicConfig(level=os.environ.get("VSC_LOG_LEVEL", "INFO").upper(),
+                    format='[%(levelname)s] [%(threadName)s] %(message)s')
+
+# 定义快捷日志函数,模仿C++的宏
+def VSC_INFO(tag, message, *args):
+    logging.info(f"[{tag}] {message}", *args)
+
+def VSC_DEBUG(tag, message, *args):
+    logging.debug(f"[{tag}] {message}", *args)
+
+def VSC_WARN(tag, message, *args):
+    logging.warning(f"[{tag}] {message}", *args)
+
+def VSC_ERROR(tag, message, *args):
+    logging.error(f"[{tag}] {message}", *args)

+ 83 - 0
vs_plg.py

@@ -0,0 +1,83 @@
+# vs_plg.py
+from abc import ABC, abstractmethod
+from vs_types import VSPlgConfig, VSQueryResult, VSBookResult # type: ignore
+
+# 错误类型模拟,Python中通常直接抛异常
+class VSError:
+    def __init__(self, code: int, message: str):
+        self.error_code = code
+        self.error_message = message
+
+    def __str__(self):
+        return f"Error {self.error_code}: {self.error_message}"
+
+# ================== 接口类 ==================
+class IVSPlg(ABC):
+    """
+    @brief 签证 API 接口类
+    该接口定义了签证系统的基本操作,包括配置、会话管理、查询、预订、
+    健康检查以及错误处理等。所有具体的签证 API 实现都需要继承该接口。
+    """
+    
+    @abstractmethod
+    def set_config(self, config: VSPlgConfig):
+        """
+        @brief 设置 API 的配置信息
+        @param config 签证 API 配置对象
+        """
+        pass
+
+    # set_log_callback 在Python中通常通过配置logging模块或注入logger实例实现
+    # 这里为了简化,不显式提供,直接使用全局logging
+
+    @abstractmethod
+    def create_session(self) -> bool:
+        """
+        @brief 创建一个新的会话
+        @return true 表示会话创建成功,false 表示失败
+        """
+        pass
+
+    @abstractmethod
+    def query(self) -> VSQueryResult:
+        """
+        @brief 查询可用的签证预约信息
+        @return VSQueryResult 查询结果
+        """
+        pass
+
+    @abstractmethod
+    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+        """
+        @brief 进行预约操作
+        @param slot_info 查询得到的可用时段信息
+        @return VSBookResult 预约结果
+        """
+        pass
+
+    @abstractmethod
+    def get_group_id(self) -> str:
+        """
+        @brief 获取当前 API 实例所属的分组 ID
+        @return 分组 ID 字符串
+        """
+        pass
+
+    @abstractmethod
+    def health_check(self) -> bool:
+        """
+        @brief 健康检查,用于检测 API 服务是否正常
+        @return true 表示健康状态良好,false 表示存在问题
+        """
+        pass
+
+    @abstractmethod
+    def get_last_error(self) -> VSError:
+        """
+        @brief 获取最近一次操作的错误信息
+        @return 错误对象(VSError)
+        """
+        pass
+
+# Python中不需要显式地C接口导出,动态加载模块通常通过importlib或直接import完成
+# CreatePlg 函数逻辑将在 VSPlgFactory 中实现

+ 63 - 0
vs_plg_factory.py

@@ -0,0 +1,63 @@
+# vs_plg_factory.py
+import importlib.util
+import sys
+import os
+from vs_plg import IVSPlg # type: ignore
+from vs_log_macros import VSC_ERROR, VSC_INFO # type: ignore
+
+class VSPlgFactory:
+    """
+    @brief 签证插件工厂类
+    负责动态加载和创建 IVSPlg 接口的实现实例。
+    """
+    def __init__(self):
+        self._registered_plugins = {} # {plugin_name: (module_name, class_name)}
+
+    def register_plugin(self, plugin_name: str, module_path: str, class_name: str):
+        """
+        @brief 注册插件
+        @param plugin_name 插件的逻辑名称
+        @param module_path 插件模块的 Python 文件路径 (e.g., "plugins/concrete_plugin.py")
+        @param class_name 插件实现类的名称 (e.g., "ConcretePlugin")
+        """
+        self._registered_plugins[plugin_name] = (module_path, class_name)
+        VSC_INFO("factory", "Registered plugin '%s' from '%s' class '%s'", plugin_name, module_path, class_name)
+
+    def create(self, group_id: str, plugin_name: str) -> IVSPlg:
+        """
+        @brief 创建一个 IVSPlg 实例
+        @param group_id 实例所属的分组 ID
+        @param plugin_name 要创建的插件的注册名称
+        @return IVSPlg 实例
+        """
+        if plugin_name not in self._registered_plugins:
+            VSC_ERROR("factory", "Plugin '%s' not registered.", plugin_name)
+            raise ValueError(f"Plugin '{plugin_name}' not registered.")
+        
+        module_path, class_name = self._registered_plugins[plugin_name]
+        
+        try:
+            # 动态加载模块
+            spec = importlib.util.spec_from_file_location(plugin_name, module_path)
+            if spec is None:
+                VSC_ERROR("factory", "Failed to find module spec for '%s' at '%s'", plugin_name, module_path)
+                raise ImportError(f"Could not find module spec for {plugin_name} at {module_path}")
+            
+            module = importlib.util.module_from_spec(spec)
+            sys.modules[plugin_name] = module
+            spec.loader.exec_module(module)
+            
+            # 获取类并实例化
+            plugin_class = getattr(module, class_name)
+            instance = plugin_class(group_id)
+            
+            if not isinstance(instance, IVSPlg):
+                VSC_ERROR("factory", "Created instance for '%s' is not an IVSPlg.", plugin_name)
+                raise TypeError(f"Plugin '{plugin_name}' class '{class_name}' does not implement IVSPlg.")
+            
+            VSC_INFO("factory", "Successfully created instance for group '%s' with plugin '%s'", group_id, plugin_name)
+            return instance
+
+        except Exception as e:
+            VSC_ERROR("factory", "Failed to create plugin instance '%s': %s", plugin_name, str(e))
+            raise

+ 123 - 0
vs_types.py

@@ -0,0 +1,123 @@
+# vs_types.py
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import List, Optional, Any
+
+# --- Enums ---
+
+class QueryWaitMode(Enum):
+    Loop = 0
+    Fixed = 1
+    Random = 2
+
+class AvailabilityStatus(Enum):
+    NoneAvailable = 0
+    Available = 1
+    Waitlist = 2
+
+# --- Structs ---
+
+@dataclass
+class QueryWaitConfig:
+    mode: QueryWaitMode = QueryWaitMode.Loop
+    fixed_wait: int = 0  # 仅在 Fixed 模式下使用
+    random_min: int = 0  # 仅在 Random 模式下使用
+    random_max: int = 0  # 仅在 Random 模式下使用
+
+@dataclass
+class PluginConfig:
+    lib_path: str = ""           # 插件目录
+    plugin_name: str = ""        # 插件注册名
+    plugin_bin: str = ""         # 动态库文件名/Python文件名
+    plugin_proto: str = ""       # 接口协议
+
+@dataclass
+class CenterInfo:
+    id: int = 0                  # 签证中心的唯一ID
+    mission_code: str = ""       # 目的地国名字
+    country_code: str = ""       # 递交国名字
+    provider: str = ""           # 服务商
+    website: str = ""            # 服务商网站
+
+@dataclass
+class GroupConfig:
+    enable: bool = False
+    identifier: str = ""
+    need_account: bool = False
+    account_pool: str = ""
+    need_proxy: bool = False
+    proxy_pool: str = ""
+    need_ip_bind: bool = False
+    account_login_interval: int = 0
+    target_instances: int = 1
+    
+    query_wait: QueryWaitConfig = field(default_factory=QueryWaitConfig)
+    plugin_config: PluginConfig = field(default_factory=PluginConfig)
+    center_info: CenterInfo = field(default_factory=CenterInfo)
+    free_config: str = "{}"
+
+@dataclass
+class VSAccount:
+    id: int = 0
+    username: str = ""
+    password: str = ""
+    lock_until: str = ""
+
+@dataclass
+class VSProxy:
+    id: int = 0
+    scheme: str = "http"
+    ip: str = ""
+    port: int = 0
+    username: str = ""
+    password: str = ""
+    lock_until: str = ""
+
+@dataclass
+class VSPlgConfig:
+    account: VSAccount = field(default_factory=VSAccount)
+    proxy: VSProxy = field(default_factory=VSProxy)
+    free_config: str = ""
+
+@dataclass
+class VSQueryResult:
+    class DateAvailability:
+        @dataclass
+        class TimeSlot:
+            time: str = ""
+            label: str = ""
+        date: str = ""
+        times: List[TimeSlot] = field(default_factory=list)
+    
+    availability_status: AvailabilityStatus = AvailabilityStatus.NoneAvailable
+    earliest_date: str = ""
+    routing_key: str = ""
+    visa_type: str = ""
+    city: str = ""
+    country: str = ""
+    availability: List[DateAvailability] = field(default_factory=list)
+    
+    success: bool = False 
+
+@dataclass
+class VSBookResult:
+    success: bool = False
+    order_id: str = ""
+    session_id: str = ""
+    user_inputs: str = ""
+    account: str = ""
+    visa_type: str = ""
+    city: str = ""
+    country: str = ""
+    book_date: str = ""
+    book_time: str = ""
+    fee_amount: int = 0
+    fee_currency: str = ""
+    payment_link: str = ""
+
+# --- 内部任务结构 ---
+@dataclass
+class Task:
+    instance: Any # IVSPlg 实例 (使用 Any 避免循环导入)
+    qw_cfg: QueryWaitConfig
+    next_run: float = 0.0  # Unix timestamp