Prechádzať zdrojové kódy

fix: 修复内存泄漏问题并优化截图文件管理

主要改动:

## 客户端内存泄漏修复
- BrowserTab组件:增强onUnmounted清理逻辑,确保定时器和响应式数据被正确清理
- 主进程:添加web-contents-destroyed监听器,自动清理已销毁的webContents拦截器
- 任务队列:实现自动清理机制,保留最近100个已完成任务,防止无限增长

## 服务端内存泄漏修复
- WebSocket管理器:添加close方法,正确清理心跳定时器和验证码监听器
- 登录服务:保存所有定时器引用到session,在closeSession中统一清理
- 添加定时器字段到LoginSession类型定义

## Python截图文件管理优化
- 创建server/python/screenshots专用文件夹
- 统一所有Python发布截图的保存路径
- 更新.gitignore规则,不上传截图文件
- 修改微信、小红书等平台的截图保存路径

这些修复将显著改善长时间运行时的内存使用情况。
Ethanfly 1 deň pred
rodič
commit
9778832488

+ 2 - 1
.gitignore

@@ -66,4 +66,5 @@ server/python/**/__pycache__/
 *.egg
 
 # 本地调试截图/临时文件(不上传)
-server/python/weixin_private_msg_*.png
+server/python/screenshots/
+!server/python/screenshots/.gitkeep

+ 20 - 0
client/electron/main.ts

@@ -802,6 +802,21 @@ const networkInterceptors: Map<number, {
   pendingRequests: Map<string, { url: string, timestamp: number }>;
 }> = new Map();
 
+// 清理已销毁的 webContents
+app.on('web-contents-destroyed', (_event: unknown, contents: typeof webContents.prototype) => {
+  const webContentsId = contents.id;
+  if (networkInterceptors.has(webContentsId)) {
+    // 清理网络拦截器
+    try {
+      contents.debugger.detach();
+    } catch (e) {
+      // 忽略错误
+    }
+    networkInterceptors.delete(webContentsId);
+    console.log(`[CDP] 已清理已销毁的 webContents 拦截器: ${webContentsId}`);
+  }
+});
+
 // 启用 CDP 网络拦截
 ipcMain.handle('enable-network-intercept', async (_event: unknown, webContentsId: number, patterns: Array<{ match: string, key: string }>) => {
   try {
@@ -939,6 +954,11 @@ ipcMain.handle('disable-network-intercept', async (_event: unknown, webContentsI
         // 忽略
       }
     }
+    const config = networkInterceptors.get(webContentsId);
+    if (config) {
+      // 清理待处理请求的 Map
+      config.pendingRequests.clear();
+    }
     networkInterceptors.delete(webContentsId);
     console.log(`[CDP] 已禁用网络拦截,webContentsId: ${webContentsId}`);
     return true;

+ 16 - 0
client/src/components/BrowserTab.vue

@@ -4200,6 +4200,22 @@ onUnmounted(() => {
   if (window.electronAPI?.clearWebviewCookies) {
     window.electronAPI.clearWebviewCookies(webviewPartition.value);
   }
+  
+  // 清理定时器 - 确保完全清理
+  if (checkTimer) {
+    clearInterval(checkTimer);
+    checkTimer = null;
+  }
+  if (aiCheckTimer) {
+    clearInterval(aiCheckTimer);
+    aiCheckTimer = null;
+  }
+  
+  // webview 的事件监听器会随组件销毁自动清理
+  // 清理响应式数据
+  apiResponseData.value = {};
+  accountInfo.value = null;
+  aiAnalysis.value = null;
 });
 
 // 监听标签页变化

+ 27 - 0
client/src/stores/taskQueue.ts

@@ -209,6 +209,8 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
         // 静默任务不添加到任务列表中
         if (task && !task.silent && !tasks.value.find(t => t.id === task.id)) {
           tasks.value.unshift(task);
+          // 添加任务后检查是否需要清理
+          autoCleanupTasks();
         }
         break;
       }
@@ -252,6 +254,8 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
               console.log('[TaskQueue] sync_account completed, triggering refresh');
             }
           }
+          // 任务状态变更后检查是否需要清理
+          autoCleanupTasks();
         }
         break;
       }
@@ -508,6 +512,28 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
       t.status === 'pending' || t.status === 'running'
     );
   }
+  
+  // 自动清理老旧任务(保留最近100个已完成任务)
+  function autoCleanupTasks() {
+    const completedTasks = tasks.value.filter(t => 
+      t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'
+    );
+    
+    // 如果已完成任务超过100个,只保留最新的100个
+    if (completedTasks.length > 100) {
+      const sortedCompleted = completedTasks.sort((a, b) => 
+        new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
+      );
+      
+      const toKeep = sortedCompleted.slice(0, 100);
+      const activeTasks = tasks.value.filter(t => 
+        t.status === 'pending' || t.status === 'running'
+      );
+      
+      tasks.value = [...activeTasks, ...toKeep];
+      console.log(`[TaskQueue] Cleaned up ${completedTasks.length - 100} old completed tasks`);
+    }
+  }
 
   return {
     // 状态
@@ -543,6 +569,7 @@ export const useTaskQueueStore = defineStore('taskQueue', () => {
     closeDialog,
     toggleDialog,
     clearCompletedTasks,
+    autoCleanupTasks,
     stopAllPolling,
     // 验证码方法
     submitCaptcha,

+ 362 - 12
server/python/app.py

@@ -150,11 +150,180 @@ def _test_proxy_connectivity(test_url: str, host: str, port: int, username: str
         return False
 
 
+def _test_proxy_for_platform(host: str, port: int, platform: str, timeout: int = 15) -> dict:
+    """
+    测试代理IP对特定平台的可用性
+    
+    Returns:
+        dict: {"ok": bool, "blocked": bool, "cost_ms": int, "error": str}
+    """
+    # 不同平台的测试URL
+    platform_test_urls = {
+        "douyin": "https://creator.douyin.com/",
+        "xiaohongshu": "https://creator.xiaohongshu.com/",
+        "kuaishou": "https://cp.kuaishou.com/",
+        "weixin": "https://channels.weixin.qq.com/",
+    }
+    
+    test_url = platform_test_urls.get(platform, "https://www.baidu.com/")
+    proxy_meta = _build_requests_proxy_meta(host, port)
+    proxies = {"http": proxy_meta, "https": proxy_meta}
+    
+    start = int(round(time.time() * 1000))
+    
+    try:
+        session = requests.Session()
+        session.trust_env = False
+        session.headers.update({
+            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+        })
+        
+        resp = session.get(test_url, proxies=proxies, timeout=timeout, allow_redirects=True)
+        cost_ms = int(round(time.time() * 1000)) - start
+        
+        # 检查是否被重定向到验证页面
+        final_url = resp.url.lower()
+        blocked_indicators = ["captcha", "verify", "challenge", "blocked", "forbidden", "error", "login"]
+        is_blocked = any(indicator in final_url for indicator in blocked_indicators)
+        
+        # 检查响应状态码
+        if resp.status_code == 403 or resp.status_code == 429:
+            is_blocked = True
+        
+        result = {
+            "ok": not is_blocked and resp.status_code < 400,
+            "blocked": is_blocked,
+            "cost_ms": cost_ms,
+            "status_code": resp.status_code,
+            "final_url": resp.url,
+        }
+        
+        if is_blocked:
+            print(f"[Proxy] platform {platform} blocked: {_mask_ip_port(host + ':' + str(port))} status={resp.status_code}", flush=True)
+        else:
+            print(f"[Proxy] platform {platform} ok: {_mask_ip_port(host + ':' + str(port))} cost={cost_ms}ms", flush=True)
+        
+        return result
+        
+    except Exception as e:
+        cost_ms = int(round(time.time() * 1000)) - start
+        print(f"[Proxy] platform {platform} test failed: {_mask_ip_port(host + ':' + str(port))} err={type(e).__name__}", flush=True)
+        return {
+            "ok": False,
+            "blocked": False,
+            "cost_ms": cost_ms,
+            "error": str(e),
+        }
+
+
+def _query_ip_location(ip: str, timeout: int = 5) -> dict:
+    """
+    查询IP的实际地理位置
+    
+    Args:
+        ip: IP地址
+        timeout: 超时时间(秒)
+    
+    Returns:
+        dict: {
+            "country": "国家",
+            "region": "地区",
+            "city": "城市",
+            "isp": "运营商",
+            "success": bool
+        }
+    """
+    try:
+        # 使用 ip-api.com 查询IP位置(免费,支持中文)
+        resp = requests.get(
+            f"http://ip-api.com/json/{ip}?lang=zh-CN",
+            timeout=timeout
+        )
+        data = resp.json()
+        
+        if data.get("status") == "success":
+            return {
+                "success": True,
+                "country": data.get("country", ""),
+                "region": data.get("regionName", ""),
+                "city": data.get("city", ""),
+                "isp": data.get("isp", ""),
+                "lat": data.get("lat", 0),
+                "lon": data.get("lon", 0),
+            }
+        else:
+            return {"success": False, "error": data.get("message", "Unknown error")}
+    except Exception as e:
+        return {"success": False, "error": str(e)}
+
+
+def _verify_proxy_location(host: str, port: int, expected_city: str = "", expected_region: str = "") -> dict:
+    """
+    验证代理IP的实际地理位置是否符合预期
+    
+    Args:
+        host: 代理IP
+        port: 代理端口
+        expected_city: 预期城市
+        expected_region: 预期地区
+    
+    Returns:
+        dict: {
+            "match": bool,
+            "actual_city": str,
+            "expected_city": str,
+            "location": dict
+        }
+    """
+    print(f"[Proxy] 验证IP地理位置: {host}:{port}", flush=True)
+    
+    # 查询IP位置
+    location = _query_ip_location(host)
+    
+    if not location.get("success"):
+        print(f"[Proxy] ⚠️  IP位置查询失败: {location.get('error')}", flush=True)
+        return {
+            "match": False,
+            "error": location.get("error"),
+            "location": location
+        }
+    
+    actual_city = location.get("city", "")
+    actual_region = location.get("region", "")
+    
+    print(f"[Proxy] IP实际位置: {actual_city}, {actual_region}, {location.get('country')}", flush=True)
+    print(f"[Proxy] IP运营商: {location.get('isp')}", flush=True)
+    
+    # 检查是否匹配
+    match = True
+    if expected_city:
+        # 标准化城市名(去掉"市"等后缀)
+        actual_normalized = actual_city.replace("市", "").strip()
+        expected_normalized = expected_city.replace("市", "").strip()
+        
+        if actual_normalized != expected_normalized:
+            match = False
+            print(f"[Proxy] ⚠️  位置不匹配!预期: {expected_city}, 实际: {actual_city}", flush=True)
+        else:
+            print(f"[Proxy] ✓ 位置匹配: {actual_city}", flush=True)
+    
+    return {
+        "match": match,
+        "actual_city": actual_city,
+        "actual_region": actual_region,
+        "expected_city": expected_city,
+        "expected_region": expected_region,
+        "location": location
+    }
+
+
 _PROXY_CACHE_TTL_SECONDS = 20 * 60
 _resolved_proxy_cache = {}
 
 
-def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
+def _resolve_shenlong_proxy(proxy_payload: dict, platform: str = None) -> dict:
     test_url = 'http://myip.ipip.net'
     city = str(proxy_payload.get('city') or '').strip()
     region_code = str(proxy_payload.get('regionCode') or '').strip()
@@ -163,6 +332,9 @@ def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
     signature = str(proxy_payload.get('signature') or '').strip()
     isp = str(proxy_payload.get('isp') or '').strip()
     publish_task_id = str(proxy_payload.get('publish_task_id') or '').strip()
+    
+    # 获取平台信息用于测试
+    target_platform = str(platform or proxy_payload.get('platform') or '').lower().strip()
 
     if not product_key:
         raise Exception('缺少神龙产品Key')
@@ -279,15 +451,39 @@ def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
         except Exception:
             continue
 
-        if _test_proxy_connectivity(test_url, host, port, timeout=10):
-            server = f"http://{host}:{port}"
-            if cache_key:
-                _resolved_proxy_cache[cache_key] = {
-                    'server': server,
-                    'expire_at': int(time.time()) + _PROXY_CACHE_TTL_SECONDS,
-                }
-                print(f"[Proxy] cache set: task={publish_task_id} ttl={_PROXY_CACHE_TTL_SECONDS}s", flush=True)
-            return {'server': server}
+        # 首先测试基础连接
+        if not _test_proxy_connectivity(test_url, host, port, timeout=10):
+            continue
+        
+        # 如果指定了平台,测试平台可用性
+        if target_platform:
+            platform_result = _test_proxy_for_platform(host, port, target_platform, timeout=15)
+            if platform_result.get('blocked'):
+                print(f"[Proxy] platform {target_platform} blocked, trying next...", flush=True)
+                continue
+            if not platform_result.get('ok') and platform_result.get('cost_ms', 0) > 10000:
+                print(f"[Proxy] platform {target_platform} too slow ({platform_result.get('cost_ms')}ms), trying next...", flush=True)
+                continue
+        
+        # 🔧 验证IP地理位置
+        expected_city = city or ""
+        location_result = _verify_proxy_location(host, port, expected_city)
+        
+        # 如果位置不匹配,给出警告但继续使用
+        if not location_result.get("match") and expected_city:
+            actual_city = location_result.get("actual_city", "未知")
+            print(f"[Proxy] ⚠️  位置不匹配警告:预期 {expected_city}, 实际 {actual_city}", flush=True)
+            print(f"[Proxy] 💡 这可能导致微信显示的位置不正确", flush=True)
+            # 不阻止使用,只是警告
+        
+        server = f"http://{host}:{port}"
+        if cache_key:
+            _resolved_proxy_cache[cache_key] = {
+                'server': server,
+                'expire_at': int(time.time()) + _PROXY_CACHE_TTL_SECONDS,
+            }
+            print(f"[Proxy] cache set: task={publish_task_id} ttl={_PROXY_CACHE_TTL_SECONDS}s", flush=True)
+        return {'server': server}
 
     raise Exception('未找到可用代理IP')
 
@@ -447,6 +643,129 @@ def sign_endpoint():
         return jsonify({"error": str(e)}), 500
 
 
+# ==================== 代理测试接口 ====================
+
+@app.route("/proxy/test", methods=["POST"])
+def test_proxy():
+    """
+    测试代理配置
+    
+    请求体:
+    {
+        "provider": "shenlong",           # 代理提供商
+        "productKey": "xxx",              # 神龙产品Key
+        "signature": "xxx",               # 神龙签名
+        "apiUrl": "http://...",           # API地址(可选)
+        "city": "城市",                   # 城市(可选)
+        "regionCode": "区域代码",          # 区域代码(可选)
+        "isp": "运营商",                  # 运营商(可选)
+        "platform": "douyin"              # 目标平台(可选,用于测试平台可用性)
+    }
+    
+    响应:
+    {
+        "success": true,
+        "proxy": "http://x.x.x.x:port",
+        "platform_test": {
+            "ok": true,
+            "blocked": false,
+            "cost_ms": 1234
+        }
+    }
+    """
+    try:
+        data = request.json
+        provider = str(data.get('provider') or 'shenlong').strip().lower()
+        platform = str(data.get('platform') or '').strip().lower()
+        
+        print(f"[Proxy Test] 测试代理: provider={provider}, platform={platform or 'default'}")
+        
+        if provider == 'shenlong':
+            # 使用神龙代理解析
+            proxy_config = _resolve_shenlong_proxy(data, platform=platform)
+            server = proxy_config.get('server', '')
+            
+            result = {
+                "success": True,
+                "proxy": server,
+                "provider": provider,
+            }
+            
+            # 如果指定了平台,测试平台可用性
+            if platform and server:
+                # 从 server 中提取 host 和 port
+                try:
+                    server_clean = server.replace('http://', '').replace('https://', '')
+                    host, port_str = server_clean.split(':')
+                    port = int(port_str)
+                    
+                    platform_result = _test_proxy_for_platform(host, port, platform)
+                    result["platform_test"] = platform_result
+                except Exception as e:
+                    result["platform_test"] = {
+                        "ok": False,
+                        "error": str(e)
+                    }
+            
+            # 🔧 添加IP地理位置信息
+            if server:
+                try:
+                    server_clean = server.replace('http://', '').replace('https://', '')
+                    host = server_clean.split(':')[0]
+                    
+                    # 查询IP位置
+                    location = _query_ip_location(host)
+                    if location.get("success"):
+                        result["ip_location"] = {
+                            "country": location.get("country"),
+                            "region": location.get("region"),
+                            "city": location.get("city"),
+                            "isp": location.get("isp"),
+                        }
+                        
+                        # 检查是否匹配预期城市
+                        expected_city = data.get('city', '').strip()
+                        if expected_city:
+                            actual_city = location.get("city", "")
+                            actual_normalized = actual_city.replace("市", "").strip()
+                            expected_normalized = expected_city.replace("市", "").strip()
+                            
+                            if actual_normalized != expected_normalized:
+                                result["location_match"] = False
+                                result["location_warning"] = f"IP实际位置({actual_city})与预期({expected_city})不符"
+                            else:
+                                result["location_match"] = True
+                except Exception as e:
+                    result["ip_location_error"] = str(e)
+            
+            return jsonify(result)
+        else:
+            return jsonify({
+                "success": False,
+                "error": f"不支持的代理提供商: {provider}"
+            }), 400
+            
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({
+            "success": False,
+            "error": str(e)
+        }), 500
+
+
+@app.route("/proxy/platforms", methods=["GET"])
+def get_supported_platforms():
+    """获取支持的平台列表"""
+    return jsonify({
+        "platforms": [
+            {"id": "douyin", "name": "抖音", "test_url": "https://creator.douyin.com/"},
+            {"id": "xiaohongshu", "name": "小红书", "test_url": "https://creator.xiaohongshu.com/"},
+            {"id": "kuaishou", "name": "快手", "test_url": "https://cp.kuaishou.com/"},
+            {"id": "weixin", "name": "视频号", "test_url": "https://channels.weixin.qq.com/"},
+        ]
+    })
+
+
 # ==================== 统一发布接口 ====================
 
 @app.route("/publish", methods=["POST"])
@@ -490,6 +809,20 @@ def publish_video():
         post_time = data.get("post_time")
         location = data.get("location", "重庆市")
         
+        # 🔧 如果启用了代理,自动使用代理所在地区作为发布位置
+        proxy_payload = data.get('proxy')
+        if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
+            # 优先级:city > regionName > 保持原值
+            proxy_city = proxy_payload.get('city', '').strip()
+            proxy_region = proxy_payload.get('regionName', '').strip()
+            
+            if proxy_city:
+                location = proxy_city
+                print(f"[Publish] 💡 使用代理地区作为发布位置: {location}", flush=True)
+            elif proxy_region:
+                location = proxy_region
+                print(f"[Publish] 💡 使用代理区域作为发布位置: {location}", flush=True)
+        
         # 调试日志
         print(f"[Publish] 收到请求: platform={platform}, title={title}, video_path={video_path}")
         
@@ -555,7 +888,8 @@ def publish_video():
                 proxy_payload_with_task = dict(proxy_payload)
                 if data.get('publish_task_id') is not None:
                     proxy_payload_with_task['publish_task_id'] = data.get('publish_task_id')
-                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload_with_task)
+                # 传递平台参数,用于测试代理对特定平台的可用性
+                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload_with_task, platform=platform)
         
         # 执行发布
         result = asyncio.run(publisher.run(cookie_str, params))
@@ -639,6 +973,21 @@ def publish_ai_assisted():
         tags = data.get("tags", [])
         post_time = data.get("post_time")
         location = data.get("location", "重庆市")
+        
+        # 🔧 如果启用了代理,自动使用代理所在地区作为发布位置
+        proxy_payload = data.get('proxy')
+        if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
+            # 优先级:city > regionName > 保持原值
+            proxy_city = proxy_payload.get('city', '').strip()
+            proxy_region = proxy_payload.get('regionName', '').strip()
+            
+            if proxy_city:
+                location = proxy_city
+                print(f"[AI-Assisted Publish] 💡 使用代理地区作为发布位置: {location}", flush=True)
+            elif proxy_region:
+                location = proxy_region
+                print(f"[AI-Assisted Publish] 💡 使用代理区域作为发布位置: {location}", flush=True)
+        
         return_screenshot = data.get("return_screenshot", True)
         # 支持请求级别的 headless 参数,用于验证码场景下的有头浏览器模式
         headless = data.get("headless", HEADLESS_MODE)
@@ -689,7 +1038,8 @@ def publish_ai_assisted():
                 proxy_payload_with_task = dict(proxy_payload)
                 if data.get('publish_task_id') is not None:
                     proxy_payload_with_task['publish_task_id'] = data.get('publish_task_id')
-                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload_with_task)
+                # 传递平台参数,用于测试代理对特定平台的可用性
+                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload_with_task, platform=platform)
         try:
             publisher.user_id = int(data.get("user_id")) if data.get("user_id") is not None else None
         except Exception:

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 400 - 184
server/python/platforms/base.py


+ 363 - 34
server/python/platforms/weixin.py

@@ -24,6 +24,12 @@ import time
 # 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
 WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
 
+# 代理下视频上传持续失败时,可设 WEIXIN_UPLOAD_BYPASS_PROXY=1
+# 仅对上传 CDN 直连,其余页面仍走代理(解决大文件经代理易「网络出错」)
+WEIXIN_UPLOAD_BYPASS_PROXY = os.environ.get(
+    "WEIXIN_UPLOAD_BYPASS_PROXY", "0"
+).strip() in ("1", "true", "yes")
+
 
 def format_short_title(origin_title: str) -> str:
     """
@@ -345,42 +351,78 @@ class WeixinPublisher(BasePublisher):
         return (head + "\n\n<!-- TAIL -->\n\n" + tail)[:20000]
 
     async def init_browser(self, storage_state: str = None):
-        """初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
+        """
+        初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误
+
+        重要:如果配置了代理,全程都会使用代理(包括页面访问和视频上传)
+        """
         from playwright.async_api import async_playwright
 
         playwright = await async_playwright().start()
+
         proxy = (
             self.proxy_config
             if isinstance(getattr(self, "proxy_config", None), dict)
             else None
         )
+
         if proxy and proxy.get("server"):
-            print(f"[{self.platform_name}] 使用代理: {proxy.get('server')}", flush=True)
+            # 启用上传 bypass 时:仅对上传 CDN 直连,其余仍走代理
+            if WEIXIN_UPLOAD_BYPASS_PROXY:
+                bypass = "findeross.weixin.qq.com,upload.weixin.qq.com,*.cos.qq.com,*.myqcloud.com,*.tencentcloudapi.com"
+                proxy = dict(proxy)
+                proxy["bypass"] = bypass
+                print(
+                    f"[{self.platform_name}] 使用代理(上传 CDN 直连): {proxy.get('server')}",
+                    flush=True,
+                )
+                print(
+                    f"[{self.platform_name}] 💡 页面走代理,视频上传 CDN 直连,避免大文件经代理失败",
+                    flush=True,
+                )
+            else:
+                print(
+                    f"[{self.platform_name}] 使用代理(全程): {proxy.get('server')}",
+                    flush=True,
+                )
+                print(
+                    f"[{self.platform_name}] 💡 页面访问和视频上传都将通过代理",
+                    flush=True,
+                )
 
         # 参考 matrix: 使用系统内的 Chrome 浏览器,避免 H264 编码错误
-        # 非 headless 时添加 slow_mo 便于观察点击操作
         launch_opts = {"headless": self.headless}
         if not self.headless:
-            launch_opts["slow_mo"] = 400  # 每个操作间隔 400ms,便于观看
+            launch_opts["slow_mo"] = 400
             print(
                 f"[{self.platform_name}] 有头模式 + slow_mo=400ms,浏览器将可见",
                 flush=True,
             )
+
         try:
             launch_opts["channel"] = "chrome"
             if proxy and proxy.get("server"):
                 launch_opts["proxy"] = proxy
+                # 代理下大文件上传优化:禁用 QUIC,部分代理对 QUIC 支持不佳易导致连接中断
+                launch_opts.setdefault("args", []).append("--disable-quic")
             self.browser = await playwright.chromium.launch(**launch_opts)
-            print(f"[{self.platform_name}] 使用系统 Chrome 浏览器", flush=True)
+            mode = "代理模式" if proxy else "直连模式"
+            print(
+                f"[{self.platform_name}] 使用系统 Chrome 浏览器({mode})", flush=True
+            )
         except Exception as e:
             print(
                 f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}", flush=True
             )
             if "channel" in launch_opts:
                 del launch_opts["channel"]
+            if proxy and proxy.get("server"):
+                launch_opts["proxy"] = proxy
+                if "--disable-quic" not in (launch_opts.get("args") or []):
+                    launch_opts.setdefault("args", []).append("--disable-quic")
             self.browser = await playwright.chromium.launch(**launch_opts)
 
-        # 设置 HTTP Headers 防止重定向
+        # 设置 HTTP Headers
         headers = {
             "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
             "Referer": "https://channels.weixin.qq.com/platform/post/list",
@@ -394,6 +436,11 @@ class WeixinPublisher(BasePublisher):
         )
 
         self.page = await self.context.new_page()
+
+        # 注入反检测脚本
+        if hasattr(self, "inject_stealth_if_available"):
+            await self.inject_stealth_if_available()
+
         return self.page
 
     async def set_schedule_time(self, publish_date: datetime):
@@ -442,18 +489,41 @@ class WeixinPublisher(BasePublisher):
         await self.page.locator("div.input-editor").click()
 
     async def handle_upload_error(self, video_path: str):
-        """处理上传错误"""
+        """处理上传错误(含代理下「网络出错」重试优化)"""
         if not self.page:
             return
 
+        using_proxy = isinstance(
+            getattr(self, "proxy_config", None), dict
+        ) and self.proxy_config.get("server")
+
+        # 代理模式下先等待,给代理/网络恢复时间,避免连续重试加剧失败
+        if using_proxy:
+            wait_sec = 25
+            print(
+                f"[{self.platform_name}] 代理模式:检测到上传错误,等待 {wait_sec} 秒后重试...",
+                flush=True,
+            )
+            await asyncio.sleep(wait_sec)
+
         print(f"[{self.platform_name}] 视频出错了,重新上传中...")
 
         # 出错时先截一张当前页面的图,方便排查(代理问题、视频格式问题等)
         try:
             timestamp = int(time.time() * 1000)
-            screenshot_path = f"weixin_upload_error_{timestamp}.png"
+            screenshot_dir = os.path.join(
+                os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+                "screenshots",
+            )
+            os.makedirs(screenshot_dir, exist_ok=True)
+            screenshot_path = os.path.join(
+                screenshot_dir, f"weixin_upload_error_{timestamp}.png"
+            )
             await self.page.screenshot(path=screenshot_path, full_page=True)
-            print(f"[{self.platform_name}] 上传错误截图已保存: {screenshot_path}", flush=True)
+            print(
+                f"[{self.platform_name}] 上传错误截图已保存: {screenshot_path}",
+                flush=True,
+            )
         except Exception as e:
             print(f"[{self.platform_name}] 保存上传错误截图失败: {e}", flush=True)
 
@@ -470,6 +540,7 @@ class WeixinPublisher(BasePublisher):
         if not self.page:
             return
 
+        print(f"[{self.platform_name}] 开始添加标题: {params.title}", flush=True)
         await self.page.locator("div.input-editor").click()
         await self.page.keyboard.type(params.title)
 
@@ -479,7 +550,124 @@ class WeixinPublisher(BasePublisher):
                 await self.page.keyboard.type("#" + tag)
                 await self.page.keyboard.press("Space")
 
-        print(f"[{self.platform_name}] 成功添加标题和 {len(params.tags)} 个话题")
+        print(
+            f"[{self.platform_name}] ✓ 成功添加标题和 {len(params.tags)} 个话题",
+            flush=True,
+        )
+
+        # 🔧 设置位置(使用代理地区或默认位置)
+        print(f"[{self.platform_name}] 准备设置位置: {params.location}", flush=True)
+        if params.location:
+            await self.set_location(params.location)
+        else:
+            print(f"[{self.platform_name}] ⚠️  未设置位置,跳过", flush=True)
+
+    async def set_location(self, location: str):
+        """设置发布位置"""
+        if not self.page or not location:
+            return
+
+        try:
+            print(f"[{self.platform_name}] 正在设置位置: {location}", flush=True)
+
+            # 等待页面稳定
+            await asyncio.sleep(1)
+
+            # 尝试多种方式找到位置设置元素
+            location_selectors = [
+                # 位置输入框
+                'input[placeholder*="位置"]',
+                'input[placeholder*="所在"]',
+                'input[placeholder*="地点"]',
+                # 位置按钮
+                'div:has-text("所在位置")',
+                'div:has-text("添加位置")',
+                'span:has-text("位置")',
+            ]
+
+            location_element = None
+            for selector in location_selectors:
+                try:
+                    element = self.page.locator(selector).first
+                    if await element.count() > 0 and await element.is_visible():
+                        location_element = element
+                        print(
+                            f"[{self.platform_name}] 找到位置元素: {selector}",
+                            flush=True,
+                        )
+                        break
+                except:
+                    continue
+
+            if not location_element:
+                print(f"[{self.platform_name}] 未找到位置设置元素,跳过", flush=True)
+                return
+
+            # 点击位置元素
+            await location_element.click()
+            await asyncio.sleep(1)
+
+            # 查找位置输入框
+            input_selectors = [
+                'input[placeholder*="搜索"]',
+                'input[placeholder*="输入"]',
+                'input[type="text"]',
+            ]
+
+            location_input = None
+            for selector in input_selectors:
+                try:
+                    element = self.page.locator(selector).first
+                    if await element.count() > 0 and await element.is_visible():
+                        location_input = element
+                        break
+                except:
+                    continue
+
+            if location_input:
+                # 输入位置
+                await location_input.fill(location)
+                await asyncio.sleep(1)
+
+                # 查找匹配的位置选项并点击
+                try:
+                    # 等待位置建议出现
+                    await asyncio.sleep(1)
+
+                    # 查找包含位置文本的选项
+                    option = self.page.locator(f'text="{location}"').first
+                    if await option.count() > 0:
+                        await option.click()
+                        print(
+                            f"[{self.platform_name}] ✓ 位置设置成功: {location}",
+                            flush=True,
+                        )
+                    else:
+                        # 如果没有精确匹配,选择第一个建议
+                        first_option = self.page.locator(
+                            'div[class*="location"] li, div[class*="suggest"] div'
+                        ).first
+                        if await first_option.count() > 0:
+                            await first_option.click()
+                            print(
+                                f"[{self.platform_name}] ✓ 位置已设置(自动选择)",
+                                flush=True,
+                            )
+                except Exception as e:
+                    print(f"[{self.platform_name}] ⚠️  选择位置失败: {e}", flush=True)
+
+                    # 按 Escape 关闭位置选择器
+                    await self.page.keyboard.press("Escape")
+            else:
+                print(f"[{self.platform_name}] 未找到位置输入框", flush=True)
+                await self.page.keyboard.press("Escape")
+
+        except Exception as e:
+            print(f"[{self.platform_name}] 设置位置失败: {e}", flush=True)
+            try:
+                await self.page.keyboard.press("Escape")
+            except:
+                pass
 
     async def add_short_title(self):
         """添加短标题"""
@@ -614,6 +802,14 @@ class WeixinPublisher(BasePublisher):
 
         self.report_progress(10, "正在打开上传页面...")
 
+        # 代理模式下拉长超时,避免大文件上传经代理时超时
+        using_proxy = isinstance(
+            getattr(self, "proxy_config", None), dict
+        ) and self.proxy_config.get("server")
+        if using_proxy:
+            self.page.set_default_timeout(300000)  # 5 分钟
+            print(f"[{self.platform_name}] 代理模式:已设置 5 分钟操作超时", flush=True)
+
         # 访问上传页面 - 使用 domcontentloaded 替代 networkidle,避免代理慢速导致超时
         await self.page.goto(
             self.publish_url, wait_until="domcontentloaded", timeout=90000
@@ -624,6 +820,12 @@ class WeixinPublisher(BasePublisher):
         except Exception:
             pass
         await asyncio.sleep(3)
+        # 代理模式下多等几秒,让代理连接稳定后再上传
+        if using_proxy:
+            print(
+                f"[{self.platform_name}] 代理模式:等待 8 秒后开始上传...", flush=True
+            )
+            await asyncio.sleep(8)
 
         # 检查是否跳转到登录页
         current_url = self.page.url
@@ -1023,36 +1225,157 @@ class WeixinPublisher(BasePublisher):
 
         self.report_progress(30, "等待视频上传完成...")
 
-        # 等待上传完成(最多约 6 分钟),期间如多次出错会自动尝试重新上传
+        # 代理模式下增加重试次数和总时长,应对「网络出错」等不稳定情况
+        using_proxy = isinstance(
+            getattr(self, "proxy_config", None), dict
+        ) and self.proxy_config.get("server")
+        max_upload_error_retries = 20 if using_proxy else 5
+        loop_count = 300 if using_proxy else 200  # 代理模式约 15 分钟
+        if using_proxy:
+            print(
+                f"[{self.platform_name}] 代理模式:上传重试上限 {max_upload_error_retries} 次,总等待约 15 分钟",
+                flush=True,
+            )
+
         upload_completed = False
-        for _ in range(120):
+        upload_error_retry_count = 0
+        for i in range(loop_count):
             try:
-                button_info = await self.page.get_by_role(
-                    "button", name="发表"
-                ).get_attribute("class")
-                if "weui-desktop-btn_disabled" not in button_info:
-                    print(f"[{self.platform_name}] 视频上传完毕")
-
-                    # 上传封面
-                    self.report_progress(50, "正在上传封面...")
-                    await self.upload_cover(params.cover_path)
-                    upload_completed = True
-                    break
-                else:
-                    # 检查上传错误
-                    if await self.page.locator("div.status-msg.error").count():
-                        if await self.page.locator(
-                            'div.media-status-content div.tag-inner:has-text("删除")'
-                        ).count():
+                # 每 30 秒打印一次进度,避免“卡住”的错觉
+                if i > 0 and i % 10 == 0:
+                    print(
+                        f"[{self.platform_name}] 仍在等待上传完成... ({i * 3}s)",
+                        flush=True,
+                    )
+
+                # 尝试多种选择器定位“发表”按钮(页面结构可能变化)
+                publish_btn = None
+                for sel in [
+                    'div.form-btns button:has-text("发表")',
+                    'button:has-text("发表")',
+                    'button:has-text("立即发表")',
+                    '[role="button"]:has-text("发表")',
+                ]:
+                    try:
+                        el = self.page.locator(sel).first
+                        if await el.count() > 0 and await el.is_visible():
+                            publish_btn = el
+                            break
+                    except Exception:
+                        continue
+
+                if publish_btn:
+                    btn_class = await publish_btn.get_attribute("class") or ""
+                    if (
+                        "weui-desktop-btn_disabled" not in btn_class
+                        and "disabled" not in btn_class.lower()
+                    ):
+                        print(f"[{self.platform_name}] 视频上传完毕")
+
+                        # 上传封面
+                        self.report_progress(50, "正在上传封面...")
+                        await self.upload_cover(params.cover_path)
+                        upload_completed = True
+                        break
+
+                # 检查上传错误(div.status-msg.error,含「网络出错了,请稍候上传」)
+                has_error = await self.page.locator("div.status-msg.error").count() > 0
+                has_delete_btn = (
+                    await self.page.locator(
+                        'div.media-status-content div.tag-inner:has-text("删除")'
+                    ).count()
+                    > 0
+                )
+                if has_error and has_delete_btn:
+                    upload_error_retry_count += 1
+                    print(
+                        f"[{self.platform_name}] 检测到上传错误,第 {upload_error_retry_count} 次重试",
+                        flush=True,
+                    )
+                    if upload_error_retry_count >= max_upload_error_retries:
+                        print(
+                            f"[{self.platform_name}] 上传错误重试已达 {max_upload_error_retries} 次,放弃",
+                            flush=True,
+                        )
+                        break
+                    # 代理模式下,第 6 次失败时尝试整页刷新以重建代理连接
+                    if using_proxy and upload_error_retry_count == 6:
+                        print(
+                            f"[{self.platform_name}] 代理模式:尝试整页刷新以重建连接...",
+                            flush=True,
+                        )
+                        try:
+                            await self.page.reload(
+                                wait_until="domcontentloaded", timeout=60000
+                            )
+                            await asyncio.sleep(8)
+                            await self.page.wait_for_selector(
+                                "div.upload-content, input[type='file']", timeout=20000
+                            )
+                            upload_el = self.page.locator("div.upload-content").first
+                            if (
+                                await upload_el.count() > 0
+                                and await upload_el.is_visible()
+                            ):
+                                async with self.page.expect_file_chooser(
+                                    timeout=10000
+                                ) as fc:
+                                    await upload_el.click()
+                                chooser = await fc.value
+                                await chooser.set_files(params.video_path)
+                                print(
+                                    f"[{self.platform_name}] 刷新后重新上传成功",
+                                    flush=True,
+                                )
+                            else:
+                                file_input = self.page.locator(
+                                    'input[type="file"]'
+                                ).first
+                                if await file_input.count() > 0:
+                                    await file_input.set_input_files(params.video_path)
+                            await asyncio.sleep(2)
+                            await self.add_title_tags(params)
+                            upload_error_retry_count = 0
+                        except Exception as e:
+                            print(
+                                f"[{self.platform_name}] 整页刷新重传失败: {e}",
+                                flush=True,
+                            )
                             await self.handle_upload_error(params.video_path)
+                    else:
+                        await self.handle_upload_error(params.video_path)
+                else:
+                    upload_error_retry_count = 0  # 无错误时重置计数
 
-                    await asyncio.sleep(3)
-            except:
+                await asyncio.sleep(3)
+            except Exception as e:
+                print(f"[{self.platform_name}] 等待上传时异常: {e}", flush=True)
                 await asyncio.sleep(3)
 
         # 如果一直没有等到“发表”按钮可用,认为上传失败,直接返回失败结果并附带截图
         if not upload_completed:
-            screenshot_base64 = await self.capture_screenshot()
+            try:
+                screenshot_base64 = await self.capture_screenshot()
+            except Exception as e:
+                print(f"[{self.platform_name}] 截图失败: {e}", flush=True)
+                screenshot_base64 = ""
+            try:
+                ts = int(time.time() * 1000)
+                screenshot_dir = os.path.join(
+                    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+                    "screenshots",
+                )
+                os.makedirs(screenshot_dir, exist_ok=True)
+                err_path = os.path.join(
+                    screenshot_dir, f"weixin_upload_timeout_{ts}.png"
+                )
+                await self.page.screenshot(path=err_path, full_page=True)
+                print(
+                    f"[{self.platform_name}] 超时/失败截图已保存: {err_path}",
+                    flush=True,
+                )
+            except Exception as e:
+                print(f"[{self.platform_name}] 保存失败截图到文件失败: {e}", flush=True)
             page_url = await self.get_page_url()
             return PublishResult(
                 success=False,
@@ -2567,8 +2890,14 @@ class WeixinPublisher(BasePublisher):
                     print(f"[{self.platform_name}] 使用备用选择器加载成功")
                 except:
                     # 截图调试
-                    screenshot_path = (
-                        f"weixin_private_msg_{int(asyncio.get_event_loop().time())}.png"
+                    screenshot_dir = os.path.join(
+                        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+                        "screenshots",
+                    )
+                    os.makedirs(screenshot_dir, exist_ok=True)
+                    screenshot_path = os.path.join(
+                        screenshot_dir,
+                        f"weixin_private_msg_{int(asyncio.get_event_loop().time())}.png",
                     )
                     await self.page.screenshot(path=screenshot_path)
                     print(

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 350 - 217
server/python/platforms/xiaohongshu.py


+ 0 - 0
server/python/screenshots/.gitkeep


+ 39 - 10
server/src/services/login/BaseLoginService.ts

@@ -190,20 +190,23 @@ export abstract class BaseLoginService extends EventEmitter {
    * URL 监控 - 检测是否跳转到成功页面
    */
   protected startUrlMonitor(sessionId: string): void {
+    const session = this.sessions.get(sessionId);
+    if (!session) return;
+    
     const checkInterval = setInterval(async () => {
-      const session = this.sessions.get(sessionId);
+      const currentSession = this.sessions.get(sessionId);
 
       // 会话不存在或已处理,停止监控
-      if (!session || session.status !== 'pending') {
+      if (!currentSession || currentSession.status !== 'pending') {
         clearInterval(checkInterval);
         return;
       }
 
       try {
-        const currentUrl = session.page.url();
+        const currentUrl = currentSession.page.url();
 
         // 检测是否跳转到成功页面
-        const isSuccess = await this.isUrlLoginSuccess(session, currentUrl);
+        const isSuccess = await this.isUrlLoginSuccess(currentSession, currentUrl);
 
         if (isSuccess) {
           logger.info(`[${this.displayName}] URL 检测到登录成功: ${currentUrl}`);
@@ -216,6 +219,9 @@ export abstract class BaseLoginService extends EventEmitter {
         this.handleBrowserClosed(sessionId);
       }
     }, this.CHECK_INTERVAL);
+    
+    // 保存定时器引用以便后续清理
+    session.urlMonitorTimer = checkInterval;
   }
 
   protected async isUrlLoginSuccess(session: LoginSession, currentUrl: string): Promise<boolean> {
@@ -415,16 +421,19 @@ export abstract class BaseLoginService extends EventEmitter {
    * 设置超时
    */
   protected setupTimeout(sessionId: string): void {
-    setTimeout(() => {
-      const session = this.sessions.get(sessionId);
-      if (session && session.status === 'pending') {
-        session.status = 'timeout';
-        session.error = '登录超时';
+    const session = this.sessions.get(sessionId);
+    if (!session) return;
+    
+    const timeoutTimer = setTimeout(() => {
+      const currentSession = this.sessions.get(sessionId);
+      if (currentSession && currentSession.status === 'pending') {
+        currentSession.status = 'timeout';
+        currentSession.error = '登录超时';
 
         logger.warn(`[${this.displayName}] 登录超时: ${sessionId}`);
         this.emit('loginResult', {
           sessionId,
-          userId: session.userId,
+          userId: currentSession.userId,
           status: 'timeout',
           error: '登录超时',
         });
@@ -432,6 +441,9 @@ export abstract class BaseLoginService extends EventEmitter {
         this.closeSession(sessionId);
       }
     }, this.LOGIN_TIMEOUT);
+    
+    // 保存定时器引用以便后续清理
+    session.timeoutTimer = timeoutTimer;
   }
 
   /**
@@ -501,8 +513,25 @@ export abstract class BaseLoginService extends EventEmitter {
     if (!session) return;
 
     try {
+      // 清理所有定时器
+      if (session.urlMonitorTimer) {
+        clearInterval(session.urlMonitorTimer);
+        session.urlMonitorTimer = undefined;
+      }
+      if (session.aiMonitorTimer) {
+        clearInterval(session.aiMonitorTimer);
+        session.aiMonitorTimer = undefined;
+      }
+      if (session.timeoutTimer) {
+        clearTimeout(session.timeoutTimer);
+        session.timeoutTimer = undefined;
+      }
+      
+      // 清理 AI 助手
       session.aiAssistant?.stopMonitoring();
       await session.aiAssistant?.destroy().catch(() => {});
+      
+      // 关闭浏览器资源
       await session.page?.close().catch(() => {});
       await session.context?.close().catch(() => {});
       await session.browser?.close().catch(() => {});

+ 6 - 0
server/src/services/login/types.ts

@@ -72,6 +72,12 @@ export interface LoginSession {
   apiData?: Record<string, any>;
   /** AI 助手 */
   aiAssistant?: AILoginAssistant;
+  /** URL 监控定时器 */
+  urlMonitorTimer?: NodeJS.Timeout;
+  /** AI 监控定时器 */
+  aiMonitorTimer?: NodeJS.Timeout;
+  /** 超时定时器 */
+  timeoutTimer?: NodeJS.Timeout;
 }
 
 // ==================== 账号信息类型 ====================

+ 45 - 1
server/src/websocket/index.ts

@@ -16,6 +16,8 @@ class WebSocketManager {
   private clients: Map<number, Set<AuthenticatedWebSocket>> = new Map();
   // 验证码回调监听器
   private captchaListeners: Map<string, (code: string) => void> = new Map();
+  // 心跳检测定时器
+  private heartbeatTimer: NodeJS.Timeout | null = null;
 
   setup(server: HttpServer): void {
     this.wss = new WebSocketServer({ server, path: '/ws' });
@@ -49,7 +51,7 @@ class WebSocketManager {
     });
 
     // 心跳检测
-    setInterval(() => {
+    this.heartbeatTimer = setInterval(() => {
       this.wss?.clients.forEach((ws: AuthenticatedWebSocket) => {
         if (!ws.isAlive) {
           this.removeClient(ws);
@@ -216,6 +218,48 @@ class WebSocketManager {
     this.captchaListeners.delete(captchaTaskId);
     logger.info(`[WS] Removed captcha listener for ${captchaTaskId}`);
   }
+  
+  /**
+   * 清理所有监听器
+   */
+  clearAllCaptchaListeners(): void {
+    this.captchaListeners.clear();
+    logger.info('[WS] Cleared all captcha listeners');
+  }
+  
+  /**
+   * 关闭 WebSocket 服务器并清理资源
+   */
+  close(): void {
+    // 清理心跳定时器
+    if (this.heartbeatTimer) {
+      clearInterval(this.heartbeatTimer);
+      this.heartbeatTimer = null;
+    }
+    
+    // 清理所有验证码监听器
+    this.clearAllCaptchaListeners();
+    
+    // 关闭所有客户端连接
+    this.clients.forEach((userClients) => {
+      userClients.forEach((ws) => {
+        try {
+          ws.close();
+        } catch (e) {
+          // 忽略关闭错误
+        }
+      });
+    });
+    this.clients.clear();
+    
+    // 关闭 WebSocket 服务器
+    if (this.wss) {
+      this.wss.close();
+      this.wss = null;
+    }
+    
+    logger.info('[WS] WebSocket server closed');
+  }
 }
 
 export const wsManager = new WebSocketManager();

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov