jerry 3 ヶ月 前
コミット
f55eb7a49c
10 ファイル変更542 行追加522 行削除
  1. 77 0
      Dockerfile
  2. 42 42
      config/groups.json
  3. 25 0
      docker-compose.yml
  4. 0 208
      drission_request_lib.py
  5. 21 0
      entrypoint.sh
  6. 53 85
      plugins/ita_plugin.py
  7. 59 84
      plugins/tls_plugin2.py
  8. 71 89
      plugins/vfs_plugin2.py
  9. 18 14
      requirements.txt
  10. 176 0
      toolkit/proxy_tunnel.py

+ 77 - 0
Dockerfile

@@ -0,0 +1,77 @@
+FROM python:3.10-slim-bookworm
+
+# 设置环境变量
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1 \
+    TZ=Asia/Shanghai \
+    LANG=C.UTF-8 \
+    # 显式指定 Chromium 路径
+    CHROME_BIN=/usr/bin/chromium \
+    # 设置显示端口 (配合 Xvfb)
+    DISPLAY=:99 \
+    # 防止 XDG 目录报错
+    XDG_CONFIG_HOME=/tmp/xdg_config
+
+# 1. 安装系统依赖
+# xvfb: 虚拟显示器
+# chromium: 浏览器
+# libgl1: opencv/ddddocr 必须的图形库依赖
+# dumb-init: 僵尸进程回收
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    chromium \
+    chromium-driver \
+    xvfb \
+    xauth \
+    fonts-noto-cjk \
+    fonts-wqy-zenhei \
+    procps \
+    dumb-init \
+    libgl1 \
+    libglib2.0-0 \
+    # --- 新增 DBus 依赖 ---
+    dbus \
+    dbus-x11 \
+    # --- 原有的 Chromium 依赖 ---
+    libnss3 \
+    libnspr4 \
+    libatk1.0-0 \
+    libatk-bridge2.0-0 \
+    libcups2 \
+    libdrm2 \
+    libxkbcommon0 \
+    libxcomposite1 \
+    libxdamage1 \
+    libxfixes3 \
+    libxrandr2 \
+    libgbm1 \
+    libasound2 \
+    libpango-1.0-0 \
+    libpangocairo-1.0-0 \
+    && apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
+
+RUN dbus-uuidgen > /var/lib/dbus/machine-id
+
+# 2. 设置工作目录
+WORKDIR /app
+
+# 3. 复制依赖并安装
+COPY requirements.txt .
+# 使用清华源,并额外指定 torch 的源(如果是 requirements.txt 里写了 index-url 则这里不需要 -f)
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 4. 复制启动脚本 (处理 Xvfb 锁文件)
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+# 5. 复制项目代码
+COPY . .
+
+# 6. 创建临时目录权限
+RUN mkdir -p /app/temp_browser_data && chmod 777 /app/temp_browser_data
+
+# 7. 入口点
+ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+
+# 8. 通过启动脚本运行
+CMD ["/entrypoint.sh"]

+ 42 - 42
config/groups.json

@@ -2,11 +2,11 @@
     {
         "identifier": "VFS_IE_NL",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_nl",
         "need_proxy": true,
-        "proxy_pool": "ireland_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -21,8 +21,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -53,11 +53,11 @@
     {
         "identifier": "VFS_SG_FR",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "sg_fr",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -73,8 +73,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -105,11 +105,11 @@
     {
         "identifier": "VFS_AU_FR",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "au_fr",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -125,8 +125,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -170,11 +170,11 @@
     {
         "identifier": "VFS_GB_IT",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "gb_it",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -190,8 +190,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -239,7 +239,7 @@
         "need_account": true,
         "local_account_pool": "gb_nl",
         "need_proxy": true,
-        "proxy_pool": "proxy_cheap",
+        "proxy_pool": "iproyal",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -300,11 +300,11 @@
     {
         "identifier": "VFS_GB_NO",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "gb_no",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -320,8 +320,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -352,11 +352,11 @@
     {
         "identifier": "VFS_IE_AT",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_at",
         "need_proxy": true,
-        "proxy_pool": "ireland_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -372,8 +372,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -404,11 +404,11 @@
     {
         "identifier": "VFS_IE_DK",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_dk",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -424,8 +424,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -456,11 +456,11 @@
     {
         "identifier": "VFS_IE_FI",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_fi",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -476,8 +476,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -508,11 +508,11 @@
     {
         "identifier": "VFS_IE_HU",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_hu",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -528,8 +528,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -560,11 +560,11 @@
     {
         "identifier": "VFS_IE_IS",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "ie_is",
         "need_proxy": true,
-        "proxy_pool": "global_proxies",
+        "proxy_pool": "proxy_cheap",
         "target_instances": 1,
         "account_login_interval": 30,
         "order_account_routing": "",
@@ -580,8 +580,8 @@
         },
         "plugin_config": {
             "lib_path": "plugins",
-            "plugin_name": "vfs_plugin",
-            "plugin_bin": "vfs_plugin.py",
+            "plugin_name": "vfs_plugin2",
+            "plugin_bin": "vfs_plugin2.py",
             "plugin_proto": "IVSPlg"
         },
         "free_config": {
@@ -702,7 +702,7 @@
     {
         "identifier": "TLS_GB_FR",
         "debug": false,
-        "enable": false,
+        "enable": true,
         "need_account": true,
         "local_account_pool": "gb_fr",
         "need_proxy": true,

+ 25 - 0
docker-compose.yml

@@ -0,0 +1,25 @@
+version: '3.8'
+
+services:
+  visa-bot:
+    build: .
+    image: coordinator:latest
+    container_name: coordinator
+    restart: unless-stopped
+    # 【关键】Chromium 在 Docker 中必须要有足够的共享内存
+    shm_size: '2gb'
+    ports:
+      - "127.0.0.1:8002:8000" # 如果你有 FastAPI 服务
+    volumes:
+      - ./config:/app/config
+      - ./logs:/app/logs
+    environment:
+      - TZ=Asia/Shanghai
+      - DISPLAY=:99
+      - CHROME_BIN=/usr/bin/chromium
+    # 资源限制
+    deploy:
+      resources:
+        limits:
+          cpus: '2.0'
+          memory: 4G

+ 0 - 208
drission_request_lib.py

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

+ 21 - 0
entrypoint.sh

@@ -0,0 +1,21 @@
+#!/bin/bash
+
+# 1. 清理锁文件
+rm -f /tmp/.X99-lock
+# 清理 DBus 锁文件 (防止重启报错)
+rm -f /var/run/dbus/pid
+
+# 2. [新增] 启动 DBus 服务
+# 这解决了 Failed to connect to socket /run/dbus/system_bus_socket 错误
+mkdir -p /var/run/dbus
+dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address &
+
+# 3. 启动 Xvfb
+Xvfb :99 -ac -screen 0 1920x1080x24 -nolisten tcp &
+
+# 等待服务就绪
+echo "Waiting for Xvfb and DBus..."
+sleep 3
+
+# 4. 启动 Python
+python3 main_server.py

+ 53 - 85
plugins/ita_plugin.py

@@ -15,79 +15,9 @@ from DrissionPage import ChromiumPage, ChromiumOptions
 
 from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
+from toolkit.proxy_tunnel import ProxyTunnel
 from toolkit.vs_cloud_api import VSCloudApi
 
-# ==========================================
-# 1. 辅助函数:代理插件 & 响应封装
-# ==========================================
-def create_proxy_auth_extension(ip, port, username, password, plugin_path):
-    """
-    创建一个 Chrome 插件来自动处理代理认证
-    """
-    if not os.path.exists(plugin_path):
-        os.makedirs(plugin_path)
-
-    # 1. manifest.json
-    manifest_json = """
-    {
-        "version": "1.0.0",
-        "manifest_version": 2,
-        "name": "Chrome Proxy Auth Extension",
-        "permissions": [
-            "proxy",
-            "tabs",
-            "unlimitedStorage",
-            "storage",
-            "<all_urls>",
-            "webRequest",
-            "webRequestBlocking"
-        ],
-        "background": {
-            "scripts": ["background.js"]
-        },
-        "minimum_chrome_version": "22.0.0"
-    }
-    """
-
-    # 2. background.js
-    background_js = f"""
-    var config = {{
-        mode: "fixed_servers",
-        rules: {{
-            singleProxy: {{
-                scheme: "http",
-                host: "{ip}",
-                port: parseInt({port})
-            }},
-            bypassList: ["localhost"]
-        }}
-    }};
-
-    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
-
-    function callbackFn(details) {{
-        return {{
-            authCredentials: {{
-                username: "{username}",
-                password: "{password}"
-            }}
-        }};
-    }}
-
-    chrome.webRequest.onAuthRequired.addListener(
-        callbackFn,
-        {{urls: ["<all_urls>"]}},
-        ['blocking']
-    );
-    """
-
-    with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
-        f.write(manifest_json)
-    
-    with open(os.path.join(plugin_path, "background.js"), "w") as f:
-        f.write(background_js)
-
-    return os.path.abspath(plugin_path)
 
 class BrowserResponse:
     def __init__(self, result_dict):
@@ -127,12 +57,14 @@ class ItaPlugin(IVSPlg):
         self.instance_id = uuid.uuid4().hex[:8]
         self.root_workspace = os.path.abspath(os.path.join("temp_browser_data", f"{self.group_id}_{self.instance_id}"))
         # 定义子目录:代理插件目录 & 浏览器用户数据目录
-        self.proxy_ext_path = os.path.join(self.root_workspace, "proxy_ext")
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
         
         # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
+            
+        # 持有隧道实例
+        self.tunnel = None
         
         self.session_create_time: float = 0
 
@@ -178,31 +110,62 @@ class ItaPlugin(IVSPlg):
         2. 解决 ReCaptcha
         3. 登录并维持 Session
         """
-        self._log("Initializing Browser Session...")
+        self._log(f"Initializing Session (ID: {self.instance_id})...")
         co = ChromiumOptions()
-        co.auto_port()
+        # -------------------------------------------------------------
+        # [核心修复] 解决 'not enough values to unpack'
+        # -------------------------------------------------------------
+        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
+        # 2. 我们手动随机生成一个端口
+        import random
+        import socket
+        
+        def get_free_port():
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', 0))
+                return s.getsockname()[1]
+        
+        debug_port = get_free_port()
+        self._log(f"Assigned Debug Port: {debug_port}")
         
         # --- [关键配置] 设置独立的用户数据目录 ---
         # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
         # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
         co.set_user_data_path(self.user_data_path)
         
+        # --- 1. 指定浏览器路径 (适配 Docker) ---
+        chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+        
+        # --- [核心修改] 代理配置 ---
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
+            
             if p.username and p.password:
-                self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
-                # [关键调用] 生成该实例独享的插件
-                plugin_path = create_proxy_auth_extension(
-                    p.ip, p.port, p.username, p.password, 
-                    self.proxy_ext_path # 传入唯一路径
-                )
-                co.add_extension(plugin_path)
+                self._log(f"Starting Proxy Tunnel for {p.ip}...")
+                
+                # 1. 启动本地隧道
+                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                local_proxy = self.tunnel.start()
+                
+                self._log(f"Tunnel started at {local_proxy}")
+                
+                # 2. Chrome 连接本地免密端口
+                # 必须使用 --proxy-server 强制指定,绝对稳健
+                co.set_argument(f'--proxy-server={local_proxy}')
+                
             else:
-                co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+                # 无密码代理,直接用
+                proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
 
         co.headless(False) 
         co.set_argument('--no-sandbox')
         co.set_argument('--disable-gpu')
+        co.set_argument('--disable-dev-shm-usage')
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
 
@@ -657,12 +620,17 @@ class ItaPlugin(IVSPlg):
                     break
                 except Exception as e:
                     # 如果删除失败(通常是Windows文件占用),重试
-                    if self.logger: self.logger(f"Cleanup retry: {e}")
+                    self._log(f"Cleanup retry: {e}")
                     time.sleep(0.5)
             
             # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
-            if os.path.exists(self.root_workspace) and self.logger:
-                 self.logger(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+            if os.path.exists(self.root_workspace):
+                 self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+        # 3. [新增] 关闭代理隧道
+        if self.tunnel:
+            try: self.tunnel.stop()
+            except: pass
+            self.tunnel = None
                  
     def __del__(self):
         """

+ 59 - 84
plugins/tls_plugin2.py

@@ -15,77 +15,9 @@ from DrissionPage import ChromiumPage, ChromiumOptions
 from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
+from toolkit.proxy_tunnel import ProxyTunnel
 from toolkit.vs_cloud_api import VSCloudApi
 
-def create_proxy_auth_extension(ip, port, username, password, plugin_path):
-    """
-    创建一个 Chrome 插件来自动处理代理认证
-    """
-    if not os.path.exists(plugin_path):
-        os.makedirs(plugin_path)
-
-    # 1. manifest.json
-    manifest_json = """
-    {
-        "version": "1.0.0",
-        "manifest_version": 2,
-        "name": "Chrome Proxy Auth Extension",
-        "permissions": [
-            "proxy",
-            "tabs",
-            "unlimitedStorage",
-            "storage",
-            "<all_urls>",
-            "webRequest",
-            "webRequestBlocking"
-        ],
-        "background": {
-            "scripts": ["background.js"]
-        },
-        "minimum_chrome_version": "22.0.0"
-    }
-    """
-
-    # 2. background.js
-    background_js = f"""
-    var config = {{
-        mode: "fixed_servers",
-        rules: {{
-            singleProxy: {{
-                scheme: "http",
-                host: "{ip}",
-                port: parseInt({port})
-            }},
-            bypassList: ["localhost"]
-        }}
-    }};
-
-    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
-
-    function callbackFn(details) {{
-        return {{
-            authCredentials: {{
-                username: "{username}",
-                password: "{password}"
-            }}
-        }};
-    }}
-
-    chrome.webRequest.onAuthRequired.addListener(
-        callbackFn,
-        {{urls: ["<all_urls>"]}},
-        ['blocking']
-    );
-    """
-
-    with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
-        f.write(manifest_json)
-    
-    with open(os.path.join(plugin_path, "background.js"), "w") as f:
-        f.write(background_js)
-
-    return os.path.abspath(plugin_path)
-
 
 class BrowserResponse:
     """模拟 requests.Response"""
@@ -129,12 +61,14 @@ class TlsPlugin2(IVSPlg):
         self.instance_id = uuid.uuid4().hex[:8]
         self.root_workspace = os.path.abspath(os.path.join("temp_browser_data", f"{self.group_id}_{self.instance_id}"))
         # 定义子目录:代理插件目录 & 浏览器用户数据目录
-        self.proxy_ext_path = os.path.join(self.root_workspace, "proxy_ext")
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
-        
+    
         # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
+            
+        # 持有隧道实例
+        self.tunnel = None
         
         self.session_create_time: float = 0
 
@@ -179,29 +113,65 @@ class TlsPlugin2(IVSPlg):
         """
         self._log(f"Initializing Session (ID: {self.instance_id})...")
         co = ChromiumOptions()
-        co.auto_port()
+        # -------------------------------------------------------------
+        # [核心修复] 解决 'not enough values to unpack'
+        # -------------------------------------------------------------
+        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
+        # 2. 我们手动随机生成一个端口
+        import random
+        import socket
+        
+        def get_free_port():
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', 0))
+                return s.getsockname()[1]
+        
+        debug_port = get_free_port()
+        self._log(f"Assigned Debug Port: {debug_port}")
+        
+        # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
+        co.set_local_port(debug_port)
         
         # --- [关键配置] 设置独立的用户数据目录 ---
         # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
         # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
         co.set_user_data_path(self.user_data_path)
         
+        # --- 1. 指定浏览器路径 (适配 Docker) ---
+        chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+        
+        # --- [核心修改] 代理配置 ---
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
+            
             if p.username and p.password:
-                self._log(f"Proxy: {p.ip}:{p.port} (Auth)")
-                # [关键调用] 生成该实例独享的插件
-                plugin_path = create_proxy_auth_extension(
-                    p.ip, p.port, p.username, p.password, 
-                    self.proxy_ext_path # 传入唯一路径
-                )
-                co.add_extension(plugin_path)
+                self._log(f"Starting Proxy Tunnel for {p.ip}...")
+                
+                # 1. 启动本地隧道
+                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                local_proxy = self.tunnel.start()
+                
+                self._log(f"Tunnel started at {local_proxy}")
+                
+                # 2. Chrome 连接本地免密端口
+                # 必须使用 --proxy-server 强制指定,绝对稳健
+                co.set_argument(f'--proxy-server={local_proxy}')
+                
             else:
-                co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+                # 无密码代理,直接用
+                proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
 
         co.headless(False)
         co.set_argument('--no-sandbox')
         co.set_argument('--disable-gpu')
+        # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
+        co.set_argument('--disable-dev-shm-usage')
+        co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
 
         try:
@@ -735,13 +705,18 @@ class TlsPlugin2(IVSPlg):
                     break
                 except Exception as e:
                     # 如果删除失败(通常是Windows文件占用),重试
-                    if self.logger: self.logger(f"Cleanup retry: {e}")
+                    self._log(f"Cleanup retry: {e}")
                     time.sleep(0.5)
             
             # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
-            if os.path.exists(self.root_workspace) and self.logger:
-                 self.logger(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
-                 
+            if os.path.exists(self.root_workspace):
+                 self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+        # 3. [新增] 关闭代理隧道
+        if self.tunnel:
+            try: self.tunnel.stop()
+            except: pass
+            self.tunnel = None
+        
     def __del__(self):
         """
         析构函数:当对象被垃圾回收时自动调用

+ 71 - 89
plugins/vfs_plugin2.py

@@ -24,6 +24,7 @@ from cryptography.hazmat.backends import default_backend
 from vs_plg import IVSPlg 
 from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, DateAvailability, AvailabilityStatus, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from toolkit.vs_cloud_api import VSCloudApi
+from toolkit.proxy_tunnel import ProxyTunnel
 from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
 
 
@@ -81,75 +82,6 @@ def to_yyyymmdd(data_str: str, date_str_format: str, target_format: str="%Y-%m-%
     except:
         return data_str
 
-def create_proxy_auth_extension(ip, port, username, password, plugin_path):
-    """
-    创建一个 Chrome 插件来自动处理代理认证
-    """
-    if not os.path.exists(plugin_path):
-        os.makedirs(plugin_path)
-
-    # 1. manifest.json
-    manifest_json = """
-    {
-        "version": "1.0.0",
-        "manifest_version": 2,
-        "name": "Chrome Proxy Auth Extension",
-        "permissions": [
-            "proxy",
-            "tabs",
-            "unlimitedStorage",
-            "storage",
-            "<all_urls>",
-            "webRequest",
-            "webRequestBlocking"
-        ],
-        "background": {
-            "scripts": ["background.js"]
-        },
-        "minimum_chrome_version": "22.0.0"
-    }
-    """
-
-    # 2. background.js
-    background_js = f"""
-    var config = {{
-        mode: "fixed_servers",
-        rules: {{
-            singleProxy: {{
-                scheme: "http",
-                host: "{ip}",
-                port: parseInt({port})
-            }},
-            bypassList: ["localhost"]
-        }}
-    }};
-
-    chrome.proxy.settings.set({{value: config, scope: "regular"}}, function() {{}});
-
-    function callbackFn(details) {{
-        return {{
-            authCredentials: {{
-                username: "{username}",
-                password: "{password}"
-            }}
-        }};
-    }}
-
-    chrome.webRequest.onAuthRequired.addListener(
-        callbackFn,
-        {{urls: ["<all_urls>"]}},
-        ['blocking']
-    );
-    """
-
-    with open(os.path.join(plugin_path, "manifest.json"), "w") as f:
-        f.write(manifest_json)
-    
-    with open(os.path.join(plugin_path, "background.js"), "w") as f:
-        f.write(background_js)
-
-    return os.path.abspath(plugin_path)
-
 # --- 模拟 Requests Response 对象 ---
 class BrowserResponse:
     def __init__(self, result_dict):
@@ -201,10 +133,11 @@ class VfsPlugin2(IVSPlg):
         # 生成唯一实例 ID
         self.instance_id = uuid.uuid4().hex[:8]
         self.root_workspace = os.path.abspath(os.path.join("temp_browser_data", f"{self.group_id}_{self.instance_id}"))
-        # 定义子目录:代理插件目录 & 浏览器用户数据目录
-        self.proxy_ext_path = os.path.join(self.root_workspace, "proxy_ext")
         self.user_data_path = os.path.join(self.root_workspace, "user_data")
         
+        # 持有隧道实例
+        self.tunnel = None
+        
         # 确保根目录存在 (子目录由具体逻辑创建)
         if not os.path.exists(self.root_workspace):
             os.makedirs(self.root_workspace)
@@ -259,31 +192,65 @@ class VfsPlugin2(IVSPlg):
         
         # 0. 配置浏览器
         co = ChromiumOptions()
-        co.auto_port() 
+        # -------------------------------------------------------------
+        # [核心修复] 解决 'not enough values to unpack'
+        # -------------------------------------------------------------
+        # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
+        # 2. 我们手动随机生成一个端口
+        import random
+        import socket
+        
+        def get_free_port():
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', 0))
+                return s.getsockname()[1]
+        
+        debug_port = get_free_port()
+        self._log(f"Assigned Debug Port: {debug_port}")
+        
+        # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
+        co.set_local_port(debug_port)
         
         # --- [关键配置] 设置独立的用户数据目录 ---
         # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
         # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
         co.set_user_data_path(self.user_data_path)
         
-        # 代理配置
+        # --- 1. 指定浏览器路径 (适配 Docker) ---
+        chrome_path = os.getenv("CHROME_BIN")
+        if chrome_path and os.path.exists(chrome_path):
+            co.set_paths(browser_path=chrome_path)
+        
+        # --- [核心修改] 代理配置 ---
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
+            
             if p.username and p.password:
-                self._log(f"Configuring authenticated proxy: {p.ip}:{p.port}")
-                # [关键调用] 生成该实例独享的插件
-                plugin_path = create_proxy_auth_extension(
-                    p.ip, p.port, p.username, p.password, 
-                    self.proxy_ext_path # 传入唯一路径
-                )
-                co.add_extension(plugin_path)
+                self._log(f"Starting Proxy Tunnel for {p.ip}...")
+                
+                # 1. 启动本地隧道
+                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                local_proxy = self.tunnel.start()
+                
+                self._log(f"Tunnel started at {local_proxy}")
+                
+                # 2. Chrome 连接本地免密端口
+                # 必须使用 --proxy-server 强制指定,绝对稳健
+                co.set_argument(f'--proxy-server={local_proxy}')
+                
             else:
-                self._log(f"Configuring standard proxy: {p.ip}:{p.port}")
-                co.set_proxy(f"{p.scheme}://{p.ip}:{p.port}")
+                # 无密码代理,直接用
+                proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
+                co.set_argument(f'--proxy-server={proxy_str}')
+        else:
+            self._log("[WARN] No proxy configured!")
             
         co.headless(False) 
         co.set_argument('--no-sandbox')
         co.set_argument('--disable-gpu')
+        # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
+        co.set_argument('--disable-dev-shm-usage') 
+        
         co.set_argument('--window-size=1920,1080')
         co.set_argument('--disable-blink-features=AutomationControlled')
 
@@ -454,7 +421,7 @@ class VfsPlugin2(IVSPlg):
             
         return result
 
-    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None):
+    def _perform_request(self, method, url, headers=None, data=None, json_data=None, params=None, retry_count=0):
         """
         核心方法:在 DrissionPage 浏览器上下文中注入 JS 执行 fetch
         """
@@ -534,7 +501,16 @@ class VfsPlugin2(IVSPlg):
             self.is_healthy = False
             raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
         elif resp.status_code == 403:
-            raise PermissionDeniedError(f"403 Forbidden: {resp.text[:100]}")
+            if "Just a moment" in resp.text or "cloudflare" in resp.text.lower():
+                self._log(f"HTTP 403 (Cloudflare) detected. Re-verifying (Try {retry_count+1}/3)...")
+                if retry_count < 3:
+                    new_token = self._refresh_turnstile_token()
+                    if new_token:
+                        self._log("In-page verification success. Retrying...")
+                        if json_data and "captcha_api_key" in json_data:
+                            json_data["captcha_api_key"] = new_token
+                        return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
+            raise PermissionDeniedError(f"HTTP 403 Forbidden: {resp.text[:100]}")
         elif resp.status_code == 429:
             self.is_healthy = False
             raise RateLimiteddError(f"429 Rate Limit: {resp.text[:100]}")
@@ -682,7 +658,7 @@ class VfsPlugin2(IVSPlg):
         # fetch 不需要显式 content-type application/json,json_data会自动处理
         
         # DrissionPage 不需要手动处理 403 绕盾,因为浏览器本身就在盾后面
-        resp = self._perform_request("POST", url, headers=headers, json_data=data)
+        resp = self._perform_request("POST", url, headers=headers, json_data=data, retry_count=2)
         
         if "WaitList" in resp.text:
             return "WaitList"
@@ -802,7 +778,7 @@ class VfsPlugin2(IVSPlg):
             "countrycode": country,
             "languageCode": "en-US",
             "captcha_version": "cloudflare-v1",
-            "captcha_api_key": new_cf_token, # <--- 使用新 Token
+            "captcha_api_key": new_cf_token,
             "otp": otp 
         }
         
@@ -1469,12 +1445,18 @@ class VfsPlugin2(IVSPlg):
                     break
                 except Exception as e:
                     # 如果删除失败(通常是Windows文件占用),重试
-                    if self.logger: self.logger(f"Cleanup retry: {e}")
+                    self._log(f"Cleanup retry: {e}")
                     time.sleep(0.5)
             
             # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
-            if os.path.exists(self.root_workspace) and self.logger:
-                 self.logger(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+            if os.path.exists(self.root_workspace):
+                 self._log(f"[WARN] Failed to fully remove workspace: {self.root_workspace}")
+                 
+        # 3. [新增] 关闭代理隧道
+        if self.tunnel:
+            try: self.tunnel.stop()
+            except: pass
+            self.tunnel = None
                  
     def __del__(self):
         """

+ 18 - 14
requirements.txt

@@ -1,14 +1,18 @@
-DrissionPage==4.1.0.12
-PIL==UNKNOWN
-bs4==0.0.2
-cryptography==38.0.4
-curl-cffi==0.10.0
-ddddocr==1.5.3
-fastapi==0.128.0
-numpy==1.24.2
-pydantic==2.12.5
-requests-toolbelt==1.0.0
-requests==2.31.0
-torch==2.1.0.dev20230404
-torchvision==0.16.0.dev20230404
-uvicorn==0.39.0
+DrissionPage>=4.0.0
+bs4
+cryptography
+curl-cffi>=0.7.0
+ddddocr
+fastapi
+numpy
+pydantic
+requests-toolbelt
+requests
+uvicorn
+psutil
+loguru
+mitmproxy>=10.0.0
+# 建议安装 CPU 版本的 torch 以节省约 2GB 空间,
+# 如果必须用 GPU 版,请直接写 torch torchvision
+torch --index-url https://download.pytorch.org/whl/cpu
+torchvision --index-url https://download.pytorch.org/whl/cpu

+ 176 - 0
toolkit/proxy_tunnel.py

@@ -0,0 +1,176 @@
+import socket
+import threading
+import select
+import base64
+import time
+
+class ProxyTunnel:
+    """
+    【轻量级版】管理本地代理隧道
+    不依赖 mitmproxy,使用纯 Socket 实现 TCP 盲转发和 Header 注入。
+    资源占用极低,启动速度快,无子进程僵死风险。
+    """
+    def __init__(self, upstream_ip, upstream_port, username, password):
+        self.upstream_ip = upstream_ip
+        self.upstream_port = int(upstream_port)
+        self.username = username
+        self.password = password
+        
+        # 预先计算 Proxy-Authorization 头,避免运行时计算
+        auth_str = f"{username}:{password}"
+        b64_auth = base64.b64encode(auth_str.encode()).decode()
+        self.auth_header = f"Proxy-Authorization: Basic {b64_auth}\r\n"
+        
+        self.server_socket = None
+        self.local_port = 0
+        self.running = False
+        self.listen_thread = None
+
+    def start(self):
+        """启动本地监听,返回 '127.0.0.1:port'"""
+        try:
+            self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            
+            # 绑定到随机空闲端口
+            self.server_socket.bind(('127.0.0.1', 0))
+            self.local_port = self.server_socket.getsockname()[1]
+            self.server_socket.listen(100) # 允许一定的并发连接
+            
+            self.running = True
+            
+            # 启动后台线程处理连接请求
+            self.listen_thread = threading.Thread(target=self._accept_loop, daemon=True)
+            self.listen_thread.start()
+            
+            # Socket 绑定成功即代表启动成功,无需等待
+            return f"127.0.0.1:{self.local_port}"
+        except Exception as e:
+            self.stop()
+            raise RuntimeError(f"Failed to start lightweight tunnel: {e}")
+
+    def stop(self):
+        """停止服务"""
+        self.running = False
+        if self.server_socket:
+            try:
+                # 关闭 Socket 会触发 accept 抛出 OSError,从而结束 _accept_loop
+                self.server_socket.close()
+            except Exception:
+                pass
+        self.server_socket = None
+
+    def _accept_loop(self):
+        """循环接收浏览器的连接"""
+        while self.running:
+            try:
+                # 设置超时以便能响应 stop 信号
+                if self.server_socket:
+                    self.server_socket.settimeout(1.0)
+                    try:
+                        client_sock, _ = self.server_socket.accept()
+                    except socket.timeout:
+                        continue
+                    except OSError:
+                        # Socket 关闭时触发
+                        break
+                    
+                    # 为每个连接启动一个线程进行转发
+                    t = threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True)
+                    t.start()
+                else:
+                    break
+            except Exception:
+                break
+
+    def _handle_client(self, client_sock):
+        """处理单个连接:注入 Header -> 双向转发"""
+        upstream_sock = None
+        try:
+            client_sock.settimeout(30) # 防止半开连接
+            
+            # 1. 读取浏览器发来的第一个包 (通常是 CONNECT 或 GET)
+            first_packet = client_sock.recv(16384)
+            if not first_packet:
+                client_sock.close()
+                return
+
+            # 2. 连接远程代理
+            upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            upstream_sock.settimeout(10) # 连接超时
+            upstream_sock.connect((self.upstream_ip, self.upstream_port))
+            
+            # 3. [关键步骤] 在第一个包中注入 Proxy-Authorization
+            sep = b'\r\n'
+            idx = first_packet.find(sep)
+            if idx != -1:
+                # 插入 Auth 头
+                new_packet = first_packet[:idx+2] + self.auth_header.encode() + first_packet[idx+2:]
+            else:
+                new_packet = first_packet # 异常情况直接透传
+
+            # 4. 发送修改后的包给远程代理
+            upstream_sock.sendall(new_packet)
+            
+            # 5. 进入双向盲转发模式 (Tunneling)
+            self._pipe_sockets(client_sock, upstream_sock)
+
+        except Exception:
+            pass
+        finally:
+            # 显式拆分 try-except 以避免语法解析错误
+            if client_sock:
+                try:
+                    client_sock.close()
+                except Exception:
+                    pass
+            if upstream_sock:
+                try:
+                    upstream_sock.close()
+                except Exception:
+                    pass
+
+    def _pipe_sockets(self, sock1, sock2):
+        """高效的双向数据转发"""
+        sockets = [sock1, sock2]
+        try:
+            sock1.setblocking(0)
+            sock2.setblocking(0)
+        except Exception:
+            return
+        
+        last_activity = time.time()
+        IDLE_TIMEOUT = 60 # 60秒无数据传输则断开
+        
+        while self.running:
+            try:
+                # 使用 select 监听可读状态
+                r, _, x = select.select(sockets, [], sockets, 1.0)
+                
+                if x: 
+                    break # 发生错误
+                
+                if not r:
+                    # 空闲检查
+                    if time.time() - last_activity > IDLE_TIMEOUT:
+                        break
+                    continue
+                
+                for s in r:
+                    try:
+                        data = s.recv(65536) # 64KB buffer
+                        if not data:
+                            return # 连接关闭
+                        
+                        # 转发给对方
+                        target = sock2 if s is sock1 else sock1
+                        target.sendall(data)
+                        last_activity = time.time()
+                    except Exception:
+                        return
+                    
+            except Exception:
+                break
+
+    def __del__(self):
+        self.stop()