hujiarui 1 неделя назад
Родитель
Сommit
da404ebd7d
9 измененных файлов с 323 добавлено и 80 удалено
  1. 8 2
      Dockerfile
  2. 42 9
      configure.py
  3. 4 0
      deps.txt
  4. 2 3
      docker-compose.yml
  5. 44 0
      download_deps.py
  6. 51 21
      plugins/tls_plugin.py
  7. 171 0
      toolkit/mihomo_tunnel.py
  8. 1 4
      toolkit/vs_cloud_api.py
  9. 0 41
      tools/clash_api.py

+ 8 - 2
Dockerfile

@@ -6,10 +6,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
     LANG=C.UTF-8 \
     DISPLAY=:99 \
     XDG_CONFIG_HOME=/tmp/xdg_config \
-    CHROME_BIN=/opt/ungoogled-chromium/chrome
+    CHROME_BIN=/opt/ungoogled-chromium/chrome \
+    MIHOMO_BIN=/bin/mihomo
 
 # 安装依赖(移除 chromium / driver)
 RUN apt-get update && apt-get install -y --no-install-recommends \
+    gzip \
     xz-utils \
     xvfb \
     xauth \
@@ -44,11 +46,15 @@ RUN dbus-uuidgen > /var/lib/dbus/machine-id
 WORKDIR /app
 
 # 复制并解压 chromium
-COPY ungoogled-chromium-144.0.7559.132-1-x86_64_linux.tar.xz /tmp/chrome.tar.xz
+COPY downloads/ungoogled-chromium-144.0.7559.132-1-x86_64_linux.tar.xz /tmp/chrome.tar.xz
 RUN mkdir -p /opt/ungoogled-chromium && \
     tar -xJf /tmp/chrome.tar.xz -C /opt/ungoogled-chromium --strip-components=1 && \
     rm /tmp/chrome.tar.xz
 
+# 复制并解压 mihomo
+COPY downloads/mihomo-linux-amd64-alpha-98aa7e6.gz /tmp/mihomo.gz
+RUN gzip -dc /tmp/mihomo.gz > /bin/mihomo && chmod +x /bin/mihomo && rm /tmp/mihomo.gz
+
 # 依赖安装
 COPY requirements.txt .
 RUN pip install --no-cache-dir -r requirements.txt

+ 42 - 9
configure.py

@@ -1,5 +1,7 @@
-# -------- 配置部分 --------
-TEST_TASK = None                        # 测试任务,这里配置以后任务轮转函数直接使用这个任务
+# 测试任务,这里配置以后任务轮转函数直接使用这个任务
+
+
+TEST_TASK = None
 # TEST_TASK = {
 #     "status": "running",
 #     "priority": 10,
@@ -33,7 +35,9 @@ TEST_TASK = None                        # 测试任务,这里配置以后任
 #     "expire_at":  "2000-01-01T01:00:00"
 # }
 
-TEST_ACCOUNT = None                     # 测试账号,这里配置以后账号轮转函数直接使用这个账号
+
+# 测试账号,这里配置以后账号轮转函数直接使用这个账号
+TEST_ACCOUNT = None
 # TEST_ACCOUNT = {
 #     "pool_name": "tls.gb.fr.sentinel",
 #     "username": "robertolord2257@gmail-app.com",
@@ -46,7 +50,9 @@ TEST_ACCOUNT = None                     # 测试账号,这里配置以后账
 #     "updated_at": "2000-01-01T01:00:00"
 # }
 
-TEST_PROXY = None                        # 测试代理,这里配置以后ip轮转函数直接使用这个代理
+
+# 测试代理,这里配置以后ip轮转函数直接使用这个代理
+TEST_PROXY = None
 # TEST_PROXY = {
 #     "pool_name": "local",
 #     "proto": "http",
@@ -57,9 +63,36 @@ TEST_PROXY = None                        # 测试代理,这里配置以后ip
 #     "id": 0
 # }
 
-CHROME_PATH = None                       # Chrome bin 的路径, 这个优先级最高,其次CHROME_BIN 的环境变量,最后系统默认值
 
-CLASH_SWITCH_NODE = False                # 是否要启用Clash 轮换
-CLASH_API_URL = "http://127.0.0.1:9090"  # Clash 本地 API
-CLASH_API_KEY = "esZnx8"                 # Clash API 密钥
-CLASH_GROUP_NAME = "♻️ TLS-A"             # 要轮换的策略组名称 ♻️ TLS-A ♻️ TLS-B ♻️ TLS-C
+# Chrome bin 的路径, 这个优先级最高,其次CHROME_BIN 的环境变量,最后系统默认值
+CHROME_PATH = None
+# CHROME_PATH = "E:/ungoogled-chromium_144.0.7559.132-1.1_windows_x64/chrome.exe"
+
+
+# Mohomo bin 的路径, 这个优先级最高,其次MIHOMO_BIN 的环境变量,最后系统默认值
+MIHOMO_BIN_PATH = None
+# MIHOMO_BIN_PATH = 'mihomo-windows-amd64-alpha-98aa7e6/mihomo-windows-amd64.exe'
+
+
+# proxy tunnel 中继节点, 列表中随机选择
+MIHOMO_RELAY_NODES = None                 
+# MIHOMO_RELAY_NODES = [
+#     {
+#         "name": "RelayNode",
+#         "type": "ss",
+#         "server": "odko6qmr.mobilfunk.top",
+#         "port": 12012,
+#         "cipher": "aes-128-gcm",
+#         "password": "haMLMXirByn6rGVh",
+#         "plugin": "obfs",
+#         "plugin-opts": {
+#             "mode": "http",
+#             "host": "c0f69828a7c1.microsoft.com"
+#         },
+#         "udp": True
+#     }
+# ]
+
+
+# 临时测试预约提交
+TLS_TEST_BOOK_AFTER_QUERY = False

+ 4 - 0
deps.txt

@@ -0,0 +1,4 @@
+mihomo-linux-amd64-alpha-98aa7e6.gz|https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/mihomo-linux-amd64-alpha-98aa7e6.gz
+mihomo-windows-amd64-alpha-98aa7e6.zip|https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/mihomo-windows-amd64-alpha-98aa7e6.zip
+ungoogled-chromium-144.0.7559.132-1-x86_64_linux.tar.xz|https://github.com/adryfish/fingerprint-chromium/releases/download/144.0.7559.132/ungoogled-chromium-144.0.7559.132-1-x86_64_linux.tar.xz
+ungoogled-chromium_144.0.7559.132-1.1_windows_x64.zip|https://github.com/adryfish/fingerprint-chromium/releases/download/144.0.7559.132/ungoogled-chromium_144.0.7559.132-1.1_windows_x64.zip

+ 2 - 3
docker-compose.yml

@@ -17,7 +17,7 @@ services:
       - TZ=Asia/Shanghai
       - DISPLAY=:99
       - CHROME_BIN=/opt/ungoogled-chromium/chrome
-    # 资源限制
+      - MIHOMO_BIN=/bin/mihomo
     deploy:
       resources:
         limits:
@@ -40,7 +40,7 @@ services:
       - TZ=Asia/Shanghai
       - DISPLAY=:99
       - CHROME_BIN=/opt/ungoogled-chromium/chrome
-    # 资源限制
+      - MIHOMO_BIN=/bin/mihomo
     deploy:
       resources:
         limits:
@@ -59,7 +59,6 @@ services:
       - ./logs:/app/logs
     environment:
       - TZ=Asia/Shanghai
-    # 资源限制极低,因为它只是个网络请求脚本,不运行浏览器
     deploy:
       resources:
         limits:

+ 44 - 0
download_deps.py

@@ -0,0 +1,44 @@
+import os
+import requests
+
+DEPS_FILE = "deps.txt"
+SAVE_DIR = "downloads"
+os.makedirs(SAVE_DIR, exist_ok=True)
+def download_file(filename, url):
+    save_path = os.path.join(SAVE_DIR, filename)
+    print(f"\nStart download:")
+    print(f"Filename: {filename}")
+    print(f"Link: {url}")
+    r = requests.get(url, stream=True, timeout=60)
+    r.raise_for_status()
+    total = 0
+    with open(save_path, "wb") as f:
+        for chunk in r.iter_content(chunk_size=8192):
+            if chunk:
+                f.write(chunk)
+                total += len(chunk)
+    print(f"Download finished: {save_path}")
+    print(f"File size: {round(total / 1024 / 1024, 2)} MB")
+
+def main():
+    with open(DEPS_FILE, "r", encoding="utf-8") as f:
+        lines = f.readlines()
+    for line in lines:
+        line = line.strip()
+        if not line:
+            continue
+        if line.startswith("#"):
+            continue
+        try:
+            filename, url = line.split("|", 1)
+            download_file(
+                filename.strip(),
+                url.strip()
+            )
+        except Exception as e:
+            print(f"\nDownload failed:")
+            print(line)
+            print(e)
+
+if __name__ == "__main__":
+    main()

+ 51 - 21
plugins/tls_plugin.py

@@ -16,7 +16,7 @@ import configure
 from vs_plg import IVSPlg
 from vs_types import VSPlgConfig, AppointmentType, 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.mihomo_tunnel import MihomoTunnel
 from utils.mouse import HumanMouse
 from utils.keyboard import HumanKeyboard
 from utils.fingerprint_utils import FingerprintGenerator
@@ -193,9 +193,26 @@ class TlsPlugin(IVSPlg):
         
         if self.config.proxy and self.config.proxy.ip:
             p = self.config.proxy
+            self._log(f'Current proxy id={p.id}')
             if p.username and p.password:
                 self._log(f"Starting Proxy Tunnel for {p.ip}...")
-                self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
+                exit_node = {
+                    "name": "ExitNode",
+                    "type": p.proto,
+                    "server": p.ip,
+                    "port": p.port,
+                    "username": p.username,
+                    "password": p.password
+                }
+                relay_node = None
+                if configure.MIHOMO_RELAY_NODES:
+                    relay_node = random.choice(configure.MIHOMO_RELAY_NODES)
+                mihomo_path = configure.MIHOMO_BIN_PATH
+                if not mihomo_path:
+                    mihomo_path = os.getenv("MIHOMO_BIN")
+                if not mihomo_path:
+                    raise BizLogicError(message='Mihomo path is null, You need set mihomo bin path in configure or os env')
+                self.tunnel = MihomoTunnel(mihomo_path, exit_node=exit_node, relay_node=relay_node)
                 local_proxy = self.tunnel.start()
                 self._log(f"Tunnel started at {local_proxy}")
                 co.set_argument(f'--proxy-server={local_proxy}')
@@ -486,6 +503,34 @@ class TlsPlugin(IVSPlg):
             self._log("No slots available.")
             res.success = False
             res.availability_status = AvailabilityStatus.NoneAvailable
+            
+        # TODO(TEST): 临时测试预约提交
+        if configure.TLS_TEST_BOOK_AFTER_QUERY:
+            test_date = "2026-06-10"
+            test_time = "09:00"
+            test_label = ""
+            test_dt = datetime.strptime(test_date, "%Y-%m-%d")
+            query_res = VSQueryResult()
+            query_res.success = True
+            query_res.availability_status = AvailabilityStatus.Available
+            query_res.earliest_date = test_dt
+            query_res.availability = [
+                DateAvailability(
+                    date=test_dt,
+                    times=[TimeSlot(time=test_time, label=test_label)]
+                )
+            ]
+            self._log(f"[TEST] using fixed June slot: {test_date} {test_time} {test_label}")
+            test_userinput = {
+                "support_pta": False,
+                "expected_end_date": "2100-01-01",
+                "expected_start_date": "2000-01-01"
+            }
+            try:
+                self.book(query_res, test_userinput)
+            except Exception as e:
+                self._log(f"[TEST] book() after query failed: {e}")
+            self.is_healthy = False
         return res
     
     def book_bak(self, slot_info: VSQueryResult, user_inputs: Dict = None) -> VSBookResult:
@@ -636,7 +681,7 @@ class TlsPlugin(IVSPlg):
         self._log("Submitting booking request via JS Fetch...")
         self.page.run_js(js_script)
         
-        packet = self.page.listen.wait(timeout=10)
+        packet = self.page.listen.wait(timeout=15)
         if not packet:
             raise BizLogicError(message='Listening data failed')
         
@@ -873,24 +918,15 @@ class TlsPlugin(IVSPlg):
         主动刷新页面以触发 Cloudflare 挑战并尝试通过
         """
         try:
-            # 1. 刷新当前页面 (通常 Dashboard 页)
-            # 这会强制浏览器重新进行 HTTP 请求,从而触发 Cloudflare 拦截页
             self._log("Refreshing page to trigger Cloudflare...")
             self.page.refresh()
-            
-            # 2. 调用 CloudflareBypasser
             cf = CloudflareBypasser(self.page, log=self.config.debug)
-            
-            # 3. 尝试过盾 (尝试次数稍多一点,因为此时可能网络不稳定)
-            success = cf.bypass(max_retry=10)
+            success = cf.bypass(max_retry=6)
             
             if success:
-                # 再次确认页面是否正常加载 (非 403 页面)
                 title = self.page.title.lower()
                 if "access denied" in title:
                     return False
-                
-                # 等待 DOM 稍微稳定
                 time.sleep(2)
                 return True
             
@@ -1015,16 +1051,13 @@ class TlsPlugin(IVSPlg):
         """
         销毁浏览器并彻底删除临时文件
         """
-        # 1. 关闭浏览器
         if self.page:
             try:
-                self.page.quit(force=True) # 这会关闭 Chrome 进程
+                self.page.quit(force=True)
             except Exception:
-                pass # 忽略已关闭的错误
+                pass
             self.page = None
         
-        # 2. 删除文件
-        # 注意:Chrome 关闭后可能需要几百毫秒释放文件锁,稍微等待
         if os.path.exists(self.root_workspace):
             for _ in range(3):
                 try:
@@ -1032,14 +1065,11 @@ class TlsPlugin(IVSPlg):
                     shutil.rmtree(self.root_workspace, ignore_errors=True)
                     break
                 except Exception as e:
-                    # 如果删除失败(通常是Windows文件占用),重试
                     self._log(f"Cleanup retry: {e}")
                     time.sleep(0.5)
             
-            # 如果依然存在,打印警告(虽然 ignore_errors=True 会掩盖报错,但可以 check exists)
             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

+ 171 - 0
toolkit/mihomo_tunnel.py

@@ -0,0 +1,171 @@
+import os
+import time
+import socket
+import subprocess
+import yaml
+import tempfile
+import shutil
+
+class MihomoTunnel:
+    """
+    Manage local proxy tunnels using Mihomo (Clash Meta).
+    Supports multi-instance concurrency with isolated temp directories.
+    Adapted for the new 'dialer-proxy' chain routing mechanism.
+    """
+    def __init__(self, mihomo_bin_path, exit_node, relay_node=None, enable_log=False):
+        self.mihomo_bin_path = os.path.abspath(mihomo_bin_path)
+        self.exit_node = exit_node
+        self.relay_node = relay_node
+        self.enable_log = enable_log
+        
+        self.process = None
+        self.local_port = 0
+        self.log_file_obj = None
+        
+        # Create an isolated temporary directory for this instance
+        self.temp_dir = tempfile.mkdtemp(prefix="mihomo_tunnel_")
+        self.config_path = os.path.join(self.temp_dir, "config.yaml")
+        self.log_path = os.path.join(self.temp_dir, "run.log")
+
+    def _get_free_port(self):
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            s.bind(('127.0.0.1', 0))
+            return s.getsockname()[1]
+
+    def _generate_config(self):
+        self.local_port = self._get_free_port()
+
+        config = {
+            "allow-lan": False,
+            "bind-address": "127.0.0.1",
+            "log-level": "warning" if self.enable_log else "silent",
+            "mixed-port": self.local_port,
+            "mode": "rule",
+            "proxies": [],
+            "rules": []
+        }
+
+        # Deep copy to avoid modifying the original dictionary 
+        # in case it is reused by other instances.
+        exit_node_config = dict(self.exit_node)
+
+        # Handle proxy chaining using the new 'dialer-proxy' feature
+        if self.relay_node:
+            # 1. Add relay node to proxies
+            config["proxies"].append(self.relay_node)
+            
+            # 2. Tell the exit node to dial through the relay node
+            exit_node_config["dialer-proxy"] = self.relay_node["name"]
+        
+        # 3. Add exit node to proxies
+        config["proxies"].append(exit_node_config)
+        
+        # 4. Route all traffic to the exit node
+        config["rules"].append(f"MATCH,{exit_node_config['name']}")
+
+        with open(self.config_path, 'w', encoding='utf-8') as f:
+            yaml.dump(config, f, allow_unicode=False, sort_keys=False)
+
+    def start(self):
+        if not os.path.exists(self.mihomo_bin_path):
+            raise FileNotFoundError(f"Mihomo binary not found: {self.mihomo_bin_path}")
+
+        self._generate_config()
+
+        if self.enable_log:
+            self.log_file_obj = open(self.log_path, 'w', encoding='utf-8')
+            stdout_target = self.log_file_obj
+        else:
+            stdout_target = subprocess.DEVNULL
+
+        try:
+            self.process = subprocess.Popen(
+                [self.mihomo_bin_path, "-d", self.temp_dir, "-f", self.config_path],
+                stdout=stdout_target,
+                stderr=subprocess.STDOUT,
+                cwd=self.temp_dir
+            )
+
+            time.sleep(1.5)
+            if self.process.poll() is not None:
+                error_msg = "Mihomo failed to start and exited immediately."
+                if self.enable_log:
+                    self.log_file_obj.close()
+                    with open(self.log_path, 'r', encoding='utf-8') as f:
+                        error_msg += f"\nLog:\n{f.read()}"
+                raise RuntimeError(error_msg)
+
+            return f"127.0.0.1:{self.local_port}"
+
+        except Exception as e:
+            self.stop()
+            raise e
+
+    def stop(self):
+        if self.process and self.process.poll() is None:
+            self.process.terminate()
+            try:
+                self.process.wait(timeout=3)
+            except subprocess.TimeoutExpired:
+                self.process.kill()
+        self.process = None
+
+        if self.log_file_obj and not self.log_file_obj.closed:
+            self.log_file_obj.close()
+
+        if os.path.exists(self.temp_dir):
+            try:
+                shutil.rmtree(self.temp_dir, ignore_errors=True)
+            except Exception:
+                pass
+
+    def __del__(self):
+        self.stop()
+
+
+# ================= TEST SCRIPT =================
+if __name__ == "__main__":
+    MIHOMO_EXE = "E:/coordinator/mihomo-windows-amd64-alpha-98aa7e6/mihomo-windows-amd64.exe" if os.name == 'nt' else "./mihomo"
+
+    exit_node = {
+        "name": "ExitNode",
+        "type": "http",
+        "server": "46.203.77.84",
+        "port": 41588,
+        "username": "nKX6cH1jvBWJlFZ",
+        "password": "vySUmO3VMCKkYDS"
+    }
+
+    relay_node = {
+        "name": "RelayNode",
+        "type": "ss",
+        "server": "odko6qmr.mobilfunk.top",
+        "port": 12012,
+        "cipher": "aes-128-gcm",
+        "password": "haMLMXirByn6rGVh",
+        "plugin": "obfs",
+        "plugin-opts": {
+            "mode": "http",
+            "host": "c0f69828a7c1.microsoft.com"
+        },
+        "udp": True
+    }
+
+    print("Starting Mihomo Tunnel...")
+    
+    # Enable log to False for production (no disk writing, no console output)
+    tunnel = MihomoTunnel(MIHOMO_EXE, exit_node, relay_node, enable_log=True)
+    
+    try:
+        local_proxy = tunnel.start()
+        print(f"Success! Proxy mapped to: {local_proxy}")
+        
+        while True:
+            time.sleep(10)
+    except KeyboardInterrupt:
+        print("\nStopping tunnel...")
+    except Exception as e:
+        print(f"\nError: {e}")
+    finally:
+        tunnel.stop()
+        print("Tunnel closed and temp files cleaned.")

+ 1 - 4
toolkit/vs_cloud_api.py

@@ -8,7 +8,7 @@ from datetime import datetime
 from typing import Dict, Any, List, Optional
 from vs_types import NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError 
 from vs_log_macros import VSC_ERROR, VSC_INFO, VSC_WARN, VSC_DEBUG
-from tools.clash_api import switch_next_node
+
 
 class VSCloudApi:
     """
@@ -329,9 +329,6 @@ class VSCloudApi:
         proxy_cd: int = 60
     ):
         if configure.TEST_PROXY:
-            if configure.CLASH_SWITCH_NODE:
-                node = switch_next_node()
-                VSC_INFO('-', f'proxy node={node}')
             return configure.TEST_PROXY 
         url = f'{self.base_url}/api/proxy/next-ip'
         payload = {

+ 0 - 41
tools/clash_api.py

@@ -1,41 +0,0 @@
-#!/usr/bin/env python3
-import requests
-import configure
-from vs_types import NotFoundError, BizLogicError 
-
-
-def switch_next_node():
-    headers = {
-        "Authorization": f"Bearer {configure.CLASH_API_KEY}",
-        "Content-Type": "application/json"
-    }
-    try:
-        resp = requests.get(f"{configure.CLASH_API_URL}/proxies", headers=headers, timeout=5)
-    except Exception as e:
-        raise BizLogicError(f'Fetch proxies error, e={e}')
-    proxies_data = resp.json()
-    proxies = proxies_data.get('proxies')
-    
-    if configure.CLASH_GROUP_NAME not in proxies:
-        raise NotFoundError(message=f"Group '{configure.CLASH_GROUP_NAME}' not found")
-    
-    group = proxies[configure.CLASH_GROUP_NAME]
-    all_nodes = group['all']
-    current_node = group['now']
-    
-    try:
-        idx = all_nodes.index(current_node)
-        next_idx = (idx + 1) % len(all_nodes)
-        next_node = all_nodes[next_idx]
-    except ValueError:
-        next_node = all_nodes[0]
-    
-    data = {"name": next_node}
-    try:
-        resp = requests.put(f"{configure.CLASH_API_URL}/proxies/{configure.CLASH_GROUP_NAME}", headers=headers, json=data, timeout=5)
-    except Exception as e:
-        raise BizLogicError(f'Switch to {next_node} error, e={e}')
-    return next_node
-
-if __name__ == "__main__":
-    switch_next_node()