소스 검색

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

swortect 23 시간 전
부모
커밋
2c3beea012

+ 112 - 0
README.md

@@ -365,6 +365,118 @@ cd server/python
 python app.py
 ```
 
+---
+
+## 🧰 服务端部署(生产环境)
+
+服务端由两部分组成:
+- **Node.js 服务**(`server/`,端口默认 3000):提供 REST/WebSocket、任务调度、Playwright 自动化入口、数据库与队列。
+- **Python 发布服务**(`server/python/`,端口默认 5005):负责各平台发布/作品/评论等自动化能力,Node 会通过 `PYTHON_PUBLISH_SERVICE_URL` 调用它。
+
+生产环境推荐把 **Python 服务仅监听本机**(`127.0.0.1:5005`),对外只暴露 Node 的 3000 或经 Nginx 反代的 HTTPS 端口。
+
+### 方案一:单机部署(推荐)
+
+#### 1) 准备基础依赖
+- Node.js 20+、pnpm 8+
+- Python 3.8+
+- MySQL 8.0+(或使用 Docker)
+- Redis 7+(可选,启用任务队列/缓存时需要)
+- Playwright 依赖与浏览器(Node 与 Python 都需要安装 Chromium)
+
+#### 2) 初始化数据库
+```bash
+mysql -u root -p media_manager < database/schema.sql
+```
+
+#### 3) 配置服务端环境变量(Node)
+```bash
+cd server
+cp env.example .env   # Linux/macOS
+copy env.example .env # Windows
+```
+
+生产环境建议至少配置:
+```env
+NODE_ENV=production
+HOST=0.0.0.0
+PORT=3000
+
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_USERNAME=media_manager
+DB_PASSWORD=********
+DB_DATABASE=media_manager
+
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+USE_REDIS_QUEUE=true
+
+JWT_SECRET=********
+ENCRYPTION_KEY=********************************
+
+INTERNAL_API_KEY=********
+PYTHON_PUBLISH_SERVICE_URL=http://127.0.0.1:5005
+NODEJS_API_URL=http://127.0.0.1:3000
+```
+
+#### 4) 部署并启动 Node 服务
+在项目根目录执行:
+```bash
+pnpm install
+pnpm --filter @media-manager/shared build
+pnpm --filter @media-manager/server build
+pnpm --filter @media-manager/server start
+```
+
+Linux 服务器若首次运行 Playwright,建议在 `server/` 目录执行一次:
+```bash
+cd server
+npx playwright install chromium
+npx playwright install-deps chromium
+```
+
+#### 5) 部署并启动 Python 发布服务
+```bash
+cd server/python
+python -m venv venv
+source venv/bin/activate      # Linux/macOS
+.\venv\Scripts\Activate.ps1   # Windows PowerShell
+
+pip install -r requirements.txt
+playwright install chromium
+```
+
+启动(生产建议监听本机):
+```bash
+python app.py --host 127.0.0.1 --port 5005 --headless true
+```
+
+验证:
+```bash
+curl http://127.0.0.1:5005/health
+curl http://127.0.0.1:3000/api/health
+```
+
+### 方案二:Docker 部署(Node + MySQL + Redis)
+
+仓库已提供 [docker-compose.yml](file:///e:/Workspace/multi-platform-media-manage/server/docker-compose.yml) 用于启动 Node/MySQL/Redis,但 **Python 服务仍需单独部署**(按上面的 Python 部署步骤运行在宿主机或另一个容器里)。
+
+在 `server/` 目录执行:
+```bash
+cd server
+docker compose up -d --build
+```
+
+若 Python 跑在宿主机,Node 容器需要能访问到宿主机的 5005:
+- Linux:可将 `PYTHON_PUBLISH_SERVICE_URL` 配置为 `http://host.docker.internal:5005`(Docker 版本需支持)或使用宿主机网络/网关地址
+- 或将 Python 也容器化并与 Node 同网络(自行扩展 compose)
+
+### 运维建议
+- 生产环境务必更换 `JWT_SECRET`、`ENCRYPTION_KEY`、`INTERNAL_API_KEY` 为强随机值
+- 建议使用 Nginx/Caddy 做 HTTPS 终止与反向代理,仅对外暴露 443
+- Python 服务尽量不要暴露到公网(包含浏览器自动化能力)
+
 ## ❓ 常见问题
 
 ### Q: 启动时报错 "Cannot find module '@media-manager/shared'"

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
client/dist-electron/main.js.map


+ 8 - 0
client/src/components.d.ts

@@ -15,10 +15,15 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
+    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -39,6 +44,7 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -47,6 +53,8 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 30 - 12
client/src/views/Publish/index.vue

@@ -177,12 +177,12 @@
           <el-switch v-model="createForm.usePublishProxy" />
         </el-form-item>
 
-        <el-form-item v-if="createForm.usePublishProxy" label="代理地区">
+        <el-form-item v-if="createForm.usePublishProxy" label="代理城市">
           <el-cascader
             v-model="createForm.publishProxyRegionPath"
-            :options="publishProxyRegions"
-            :props="{ checkStrictly: true }"
-            placeholder="选择地区(省/市/区县)"
+            :options="publishProxyCityRegions"
+            :props="{ checkStrictly: false }"
+            placeholder="选择城市(省/市)"
             clearable
             filterable
             style="width: 100%"
@@ -353,12 +353,12 @@
           <el-switch v-model="editForm.usePublishProxy" />
         </el-form-item>
 
-        <el-form-item v-if="editForm.usePublishProxy" label="代理地区">
+        <el-form-item v-if="editForm.usePublishProxy" label="代理城市">
           <el-cascader
             v-model="editForm.publishProxyRegionPath"
-            :options="publishProxyRegions"
-            :props="{ checkStrictly: true }"
-            placeholder="选择地区(省/市/区县)"
+            :options="publishProxyCityRegions"
+            :props="{ checkStrictly: false }"
+            placeholder="选择城市(省/市)"
             clearable
             filterable
             style="width: 100%"
@@ -420,6 +420,18 @@ const pagination = reactive({
 });
 
 const publishProxyRegions = ref<any[]>([]);
+const publishProxyCityRegions = computed(() => {
+  const provinces = Array.isArray(publishProxyRegions.value) ? publishProxyRegions.value : [];
+  return provinces.map((p: any) => ({
+    ...p,
+    children: Array.isArray(p?.children)
+      ? p.children.map((c: any) => ({
+          ...c,
+          children: undefined,
+        }))
+      : [],
+  }));
+});
 
 const createForm = reactive({
   videoFile: null as File | null,
@@ -499,6 +511,12 @@ function resolvePublishProxyFromRegionPath(regionPath: string[]) {
   };
 }
 
+function normalizePublishProxyCityRegionPath(regionPath: string[]) {
+  const path = Array.isArray(regionPath) ? regionPath.map(v => String(v)) : [];
+  if (path.length <= 2) return path;
+  return path.slice(0, 2);
+}
+
 function formatPublishProxy(publishProxy: any): string {
   const enabled = Boolean(publishProxy?.enabled);
   if (!enabled) return '-';
@@ -672,7 +690,7 @@ async function handleCreate() {
   }
 
   if (createForm.usePublishProxy && publishProxyRegions.value.length > 0 && !createForm.publishProxyRegionPath.length) {
-    ElMessage.warning('请选择代理地区');
+    ElMessage.warning('请选择代理城市');
     return;
   }
   
@@ -761,13 +779,13 @@ function openEditDialog() {
   editForm.scheduledAt = null;
   editForm.usePublishProxy = Boolean(currentTask.value.publishProxy?.enabled);
   editForm.publishProxyRegionPath = Array.isArray((currentTask.value.publishProxy as any)?.regionPath)
-    ? (currentTask.value.publishProxy as any).regionPath.map((v: any) => String(v))
+    ? normalizePublishProxyCityRegionPath((currentTask.value.publishProxy as any).regionPath)
     : [];
 
   const regionCode = String((currentTask.value.publishProxy as any)?.regionCode || '').trim();
   if (editForm.usePublishProxy && !editForm.publishProxyRegionPath.length && regionCode && publishProxyRegions.value.length > 0) {
     const inferred = findRegionPathByCode(publishProxyRegions.value, regionCode);
-    if (inferred) editForm.publishProxyRegionPath = inferred;
+    if (inferred) editForm.publishProxyRegionPath = normalizePublishProxyCityRegionPath(inferred);
   }
   
   showDetailDialog.value = false;
@@ -788,7 +806,7 @@ async function handleRepublish() {
   }
 
   if (editForm.usePublishProxy && publishProxyRegions.value.length > 0 && !editForm.publishProxyRegionPath.length) {
-    ElMessage.warning('请选择代理地区');
+    ElMessage.warning('请选择代理城市');
     return;
   }
   

+ 7 - 0
client/src/views/Settings/index.vue

@@ -34,6 +34,10 @@
               <span class="form-tip">发布时使用该 Key 调用神龙代理获取对应地区的IP</span>
             </el-form-item>
 
+            <el-form-item label="签名">
+              <el-input v-model="publishProxy.signature" type="password" show-password placeholder="填写签名" style="width: 100%" />
+            </el-form-item>
+
             <el-form-item>
               <el-button type="primary" @click="savePublishProxy">保存配置</el-button>
               <el-button @click="loadPublishProxy">刷新</el-button>
@@ -128,6 +132,7 @@ const settings = reactive({
 
 const publishProxy = reactive({
   productKey: '',
+  signature: '',
 });
 
 const users = ref<User[]>([]);
@@ -173,6 +178,7 @@ async function loadPublishProxy() {
   try {
     const config = await request.get('/api/system/publish-proxy');
     publishProxy.productKey = String(config.productKey || '');
+    publishProxy.signature = String(config.signature || '');
   } catch {
     // 错误已处理
   }
@@ -182,6 +188,7 @@ async function savePublishProxy() {
   try {
     await request.put('/api/system/publish-proxy', {
       productKey: publishProxy.productKey,
+      signature: publishProxy.signature,
     });
     ElMessage.success('发布代理配置已保存');
     loadPublishProxy();

BIN
server/python/__pycache__/app.cpython-313.pyc


+ 97 - 52
server/python/app.py

@@ -61,7 +61,9 @@ def load_env_file():
                     # 只在环境变量未设置时加载
                     if key not in os.environ:
                         os.environ[key] = value
-                        print(f"[Config] Loaded: {key}=***" if 'PASSWORD' in key or 'SECRET' in key else f"[Config] Loaded: {key}={value}")
+                        safe_key = key.upper()
+                        is_sensitive = any(p in safe_key for p in ['PASSWORD', 'SECRET', 'TOKEN', 'KEY', 'ENCRYPT'])
+                        print(f"[Config] Loaded: {key}=***" if is_sensitive else f"[Config] Loaded: {key}={value}")
     else:
         print(f"[Config] .env file not found: {env_path}")
 
@@ -137,7 +139,9 @@ def _test_proxy_connectivity(test_url: str, host: str, port: int, username: str
     proxies = {"http": proxy_meta, "https": proxy_meta}
     start = int(round(time.time() * 1000))
     try:
-        resp = requests.get(test_url, proxies=proxies, timeout=timeout)
+        session = requests.Session()
+        session.trust_env = False
+        resp = session.get(test_url, proxies=proxies, timeout=timeout)
         _ = resp.text
         cost = int(round(time.time() * 1000)) - start
         print(f"[Proxy] test ok: {_mask_ip_port(host + ':' + str(port))} cost={cost}ms", flush=True)
@@ -147,47 +151,83 @@ def _test_proxy_connectivity(test_url: str, host: str, port: int, username: str
         return False
 
 
+_PROXY_CACHE_TTL_SECONDS = 20 * 60
+_resolved_proxy_cache = {}
+
+
 def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
     test_url = 'http://myip.ipip.net'
     city = str(proxy_payload.get('city') or '').strip()
     region_code = str(proxy_payload.get('regionCode') or '').strip()
     api_url = str(proxy_payload.get('apiUrl') or '').strip()
     product_key = str(proxy_payload.get('productKey') or '').strip()
+    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()
+
+    if not product_key:
+        raise Exception('缺少神龙产品Key')
+    if not signature:
+        raise Exception('缺少神龙签名')
+
+    if region_code and region_code.isdigit() and len(region_code) == 6:
+        if region_code.endswith('0000'):
+            region_code = ''
+        elif not region_code.endswith('00'):
+            region_code = region_code[:4] + '00'
+
+    cache_key = ''
+    if publish_task_id:
+        cache_key = f"publish_task:{publish_task_id}:area:{region_code or '-'}:isp:{isp or '-'}"
+        now = int(time.time())
+        cached = _resolved_proxy_cache.get(cache_key)
+        if isinstance(cached, dict) and cached.get('expire_at', 0) > now and cached.get('server'):
+            server = str(cached.get('server') or '').strip()
+            if server:
+                print(f"[Proxy] cache hit: task={publish_task_id} area={region_code or '-'} isp={isp or '-'}", flush=True)
+                return {'server': server}
+
+    request_url = api_url or 'http://api.shenlongip.com/ip'
+    params = {
+        'key': product_key,
+        'sign': signature,
+        'count': 1,
+        'pattern': 'json',
+        'mr': 1,
+    }
+    if region_code:
+        params['area'] = region_code
+    if isp:
+        params['isp'] = isp
 
-    request_url = api_url
-    params = {}
-
-    if request_url:
-        if city:
-            params['city'] = city
-    else:
-        if not product_key:
-            raise Exception('缺少神龙产品Key')
-        request_url = 'https://api.shenlongip.com/getip'
-        params = {
-            'key': product_key,
-            'count': 1,
-            'protocol': 'http',
-        }
-        if city:
-            params['city'] = city
-        if region_code:
-            params['city_code'] = region_code
-            params['region_code'] = region_code
-
-    resp = requests.get(request_url, params=params, timeout=15)
-    if resp.status_code >= 400:
-        raise Exception(f"代理提取失败: HTTP {resp.status_code}")
-
+    payload = None
+    session = requests.Session()
+    session.trust_env = False
+    resp = session.get(
+        request_url,
+        params=params,
+        headers={
+            'User-Agent': 'Mozilla/5.0',
+            'Accept': 'application/json',
+        },
+        timeout=15,
+    )
     content_type = (resp.headers.get('content-type') or '').lower()
     raw_text = resp.text or ''
-
-    payload = None
-    if 'application/json' in content_type or raw_text.strip().startswith('{') or raw_text.strip().startswith('['):
-        try:
+    try:
+        if 'application/json' in content_type or raw_text.strip().startswith('{') or raw_text.strip().startswith('['):
             payload = resp.json()
+    except Exception:
+        payload = None
+    if isinstance(payload, dict) and payload.get('code') is not None:
+        try:
+            api_code = int(payload.get('code'))
         except Exception:
-            payload = None
+            api_code = -1
+        if api_code != 200:
+            raise Exception(f"代理提取失败: code={api_code} msg={str(payload.get('msg') or '').strip() or 'unknown'}")
+    elif resp.status_code >= 400:
+        raise Exception(f"代理提取失败: HTTP {resp.status_code}")
 
     def collect_ip_ports(data_list, city_filter: str):
         ips = []
@@ -215,19 +255,13 @@ def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
     if payload is not None:
         if isinstance(payload, dict):
             if isinstance(payload.get('data'), list):
-                ip_ports = collect_ip_ports(payload.get('data'), city)
-                if city and not ip_ports:
-                    ip_ports = collect_ip_ports(payload.get('data'), '')
+                ip_ports = collect_ip_ports(payload.get('data'), '')
             elif isinstance(payload.get('list'), list):
-                ip_ports = collect_ip_ports(payload.get('list'), city)
-                if city and not ip_ports:
-                    ip_ports = collect_ip_ports(payload.get('list'), '')
+                ip_ports = collect_ip_ports(payload.get('list'), '')
             elif payload.get('ip') and payload.get('port'):
-                ip_ports = collect_ip_ports([payload], city)
+                ip_ports = collect_ip_ports([payload], '')
         elif isinstance(payload, list):
-            ip_ports = collect_ip_ports(payload, city)
-            if city and not ip_ports:
-                ip_ports = collect_ip_ports(payload, '')
+            ip_ports = collect_ip_ports(payload, '')
     else:
         ip_ports = _extract_ip_ports(raw_text)
 
@@ -237,7 +271,7 @@ def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
     random.shuffle(ip_ports)
     candidates = ip_ports[: min(10, len(ip_ports))]
 
-    print(f"[Proxy] shenlong resolved: city={city or '-'} regionCode={region_code or '-'} candidates={len(candidates)}/{len(ip_ports)}", flush=True)
+    print(f"[Proxy] shenlong resolved: city={city or '-'} area={region_code or '-'} candidates={len(candidates)}/{len(ip_ports)}", flush=True)
 
     for ip_port in candidates:
         try:
@@ -247,9 +281,14 @@ def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
             continue
 
         if _test_proxy_connectivity(test_url, host, port, timeout=10):
-            return {
-                'server': f"http://{host}:{port}",
-            }
+            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')
 
@@ -281,14 +320,15 @@ CORS(app)
 
 # 配置日志以显示所有 HTTP 请求
 import logging
-logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 # 让 werkzeug 日志显示
 werkzeug_logger = logging.getLogger('werkzeug')
 werkzeug_logger.setLevel(logging.INFO)
 # 添加 StreamHandler 确保输出到控制台
 handler = logging.StreamHandler(sys.stdout)
-handler.setLevel(logging.DEBUG)
+handler.setLevel(logging.INFO)
 werkzeug_logger.addHandler(handler)
+logging.getLogger('urllib3').setLevel(logging.WARNING)
 
 # 添加请求钩子,打印所有收到的请求
 @app.before_request
@@ -513,7 +553,10 @@ def publish_video():
         if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
             provider = str(proxy_payload.get('provider') or 'shenlong').strip().lower()
             if provider == 'shenlong':
-                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload)
+                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)
         
         # 执行发布
         result = asyncio.run(publisher.run(cookie_str, params))
@@ -643,7 +686,10 @@ def publish_ai_assisted():
         if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
             provider = str(proxy_payload.get('provider') or 'shenlong').strip().lower()
             if provider == 'shenlong':
-                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload)
+                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)
         try:
             publisher.user_id = int(data.get("user_id")) if data.get("user_id") is not None else None
         except Exception:
@@ -1499,8 +1545,7 @@ def main():
     print(f"启动服务: http://{args.host}:{args.port}")
     print("=" * 60)
     
-    # 启用 debug 模式以获取详细日志,使用 use_reloader=False 避免重复启动
-    app.run(host=args.host, port=args.port, debug=True, threaded=True, use_reloader=False)
+    app.run(host=args.host, port=args.port, debug=bool(args.debug), threaded=True, use_reloader=False)
 
 
 @app.route('/auto-reply', methods=['POST'])

BIN
server/python/platforms/__pycache__/baijiahao.cpython-313.pyc


BIN
server/python/platforms/__pycache__/base.cpython-313.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-313.pyc


BIN
server/python/platforms/__pycache__/weixin.cpython-313.pyc


BIN
server/python/platforms/__pycache__/xiaohongshu.cpython-313.pyc


+ 25 - 25
server/python/platforms/base.py

@@ -469,6 +469,31 @@ class BasePublisher(ABC):
 
         if not self.user_id or not self.publish_account_id:
             return False
+        
+        node_api_url = os.environ.get('NODEJS_API_URL', 'http://localhost:3000').rstrip('/')
+        internal_api_key = os.environ.get('INTERNAL_API_KEY', 'internal-api-key-default')
+        
+        try:
+            payload = {
+                "user_id": int(self.user_id),
+                "account_id": int(self.publish_account_id),
+                "cookies": json.dumps(cookies, ensure_ascii=False),
+            }
+            resp = requests.post(
+                f"{node_api_url}/api/internal/accounts/update-cookies",
+                headers={
+                    "Content-Type": "application/json",
+                    "X-Internal-API-Key": internal_api_key,
+                },
+                json=payload,
+                timeout=30,
+            )
+            if resp.status_code >= 400:
+                return False
+            data = resp.json() if resp.content else {}
+            return bool(data.get("success", True))
+        except Exception:
+            return False
 
     async def ai_suggest_playwright_selector(self, goal: str, screenshot_base64: str = None) -> dict:
         import os
@@ -572,31 +597,6 @@ class BasePublisher(ABC):
         except Exception as e:
             return {"has_selector": False, "selector": "", "confidence": 0, "notes": f"AI selector 异常: {e}"}
 
-        node_api_url = os.environ.get('NODEJS_API_URL', 'http://localhost:3000').rstrip('/')
-        internal_api_key = os.environ.get('INTERNAL_API_KEY', 'internal-api-key-default')
-
-        try:
-            payload = {
-                "user_id": int(self.user_id),
-                "account_id": int(self.publish_account_id),
-                "cookies": json.dumps(cookies, ensure_ascii=False),
-            }
-            resp = requests.post(
-                f"{node_api_url}/api/internal/accounts/update-cookies",
-                headers={
-                    "Content-Type": "application/json",
-                    "X-Internal-API-Key": internal_api_key,
-                },
-                json=payload,
-                timeout=30,
-            )
-            if resp.status_code >= 400:
-                return False
-            data = resp.json() if resp.content else {}
-            return bool(data.get("success", True))
-        except Exception:
-            return False
-
     async def ai_check_captcha(self, screenshot_base64: str = None) -> dict:
         """
         使用 AI 分析截图检测验证码

+ 136 - 65
server/python/platforms/xiaohongshu.py

@@ -9,6 +9,7 @@ import asyncio
 import os
 import sys
 import time
+import concurrent.futures
 from pathlib import Path
 from typing import List
 from .base import (
@@ -35,6 +36,8 @@ except ImportError:
 # 签名脚本路径
 STEALTH_JS_PATH = MATRIX_PATH / "xhs-api" / "js" / "stealth.min.js"
 
+_xhs_sign_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
+
 
 class XiaohongshuPublisher(BasePublisher):
     """
@@ -93,55 +96,19 @@ class XiaohongshuPublisher(BasePublisher):
         """
         同步签名函数,供 XhsClient 使用。
         
-        注意:发布流程运行在 asyncio 事件循环中(通过 asyncio.run 启动)
-        这里如果再调用 asyncio.run 会触发 “asyncio.run() cannot be called from a running event loop”
-        因此改为使用 sync_playwright 的同步实现(参考 matrix/xhs_uploader)
+        注意:发布流程运行在 asyncio 事件循环中(通过 asyncio.run 启动)
+        XhsClient 以同步方式调用 sign 回调,但我们需要使用 Playwright Async API 进行签名
+        因此当处于事件循环中时,将签名逻辑放到独立线程里执行 asyncio.run
         """
+        def run_async_sign():
+            return asyncio.run(self.get_sign(uri, data=data, a1=a1, web_session=web_session))
+
         try:
-            from playwright.sync_api import sync_playwright
-        except Exception as e:
-            raise Exception(f"缺少 playwright 同步接口支持: {e}")
-        
-        last_exc: Exception | None = None
-        for attempt in range(1, 6):
-            try:
-                with sync_playwright() as playwright:
-                    browser = playwright.chromium.launch(headless=True)
-                    context = browser.new_context()
-                    
-                    if STEALTH_JS_PATH.exists():
-                        context.add_init_script(path=str(STEALTH_JS_PATH))
-                    
-                    page = context.new_page()
-                    page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded", timeout=60000)
-                    
-                    if a1:
-                        context.add_cookies([
-                            {'name': 'a1', 'value': a1, 'domain': ".xiaohongshu.com", 'path': "/"}
-                        ])
-                        page.reload(wait_until="domcontentloaded")
-                    
-                    # 参考 matrix:设置完 cookie 后需要稍等,否则可能出现 window._webmsxyw 不存在
-                    time.sleep(1.5)
-                    
-                    encrypt_params = page.evaluate(
-                        "([url, data]) => window._webmsxyw(url, data)",
-                        [uri, data]
-                    )
-                    
-                    context.close()
-                    browser.close()
-                    
-                    return {
-                        "x-s": encrypt_params["X-s"],
-                        "x-t": str(encrypt_params["X-t"])
-                    }
-            except Exception as e:
-                last_exc = e
-                # 轻微退避重试
-                time.sleep(0.4 * attempt)
-        
-        raise Exception(f"签名失败: {last_exc}")
+            asyncio.get_running_loop()
+            future = _xhs_sign_executor.submit(run_async_sign)
+            return future.result(timeout=120)
+        except RuntimeError:
+            return run_async_sign()
     
     async def publish_via_api(self, cookies: str, params: PublishParams) -> PublishResult:
         """通过 API 发布视频"""
@@ -160,14 +127,45 @@ class XiaohongshuPublisher(BasePublisher):
         
         self.report_progress(20, "正在上传视频...")
         
-        # 创建客户端
-        xhs_client = XhsClient(cookie_string, sign=self.sign_sync)
-        
-        print(f"[{self.platform_name}] 开始调用 create_video_note...")
-        
-        # 发布视频
-        try:
-            result = xhs_client.create_video_note(
+        async def ensure_valid_cookie_for_sdk() -> str | None:
+            await self.init_browser()
+            cookie_list_for_browser = self.parse_cookies(cookie_string)
+            await self.set_cookies(cookie_list_for_browser)
+
+            if not self.page or not self.context:
+                return None
+
+            await self.page.goto("https://creator.xiaohongshu.com/new/home", wait_until="domcontentloaded", timeout=60000)
+            await asyncio.sleep(2)
+
+            current_url = (self.page.url or '').lower()
+            if 'login' in current_url or 'passport' in current_url:
+                if self.headless:
+                    return None
+
+                waited = 0
+                while waited < 180:
+                    current_url = (self.page.url or '').lower()
+                    if 'login' not in current_url and 'passport' not in current_url and 'creator.xiaohongshu.com' in current_url:
+                        break
+                    await asyncio.sleep(2)
+                    waited += 2
+
+                current_url = (self.page.url or '').lower()
+                if 'login' in current_url or 'passport' in current_url:
+                    return None
+
+            cookies_after = await self.context.cookies()
+            try:
+                await self.sync_cookies_to_node(cookies_after)
+            except Exception:
+                pass
+            refreshed_cookie_str = self.cookies_to_string(cookies_after)
+            return refreshed_cookie_str or None
+
+        def call_create_video_note(sdk_cookie_str: str):
+            xhs_client = XhsClient(sdk_cookie_str, sign=self.sign_sync)
+            return xhs_client.create_video_note(
                 title=params.title,
                 desc=params.description or params.title,
                 topics=params.tags or [],
@@ -175,12 +173,42 @@ class XiaohongshuPublisher(BasePublisher):
                 video_path=params.video_path,
                 cover_path=params.cover_path if params.cover_path and os.path.exists(params.cover_path) else None
             )
+
+        print(f"[{self.platform_name}] 开始调用 create_video_note...")
+
+        try:
+            result = call_create_video_note(cookie_string)
             print(f"[{self.platform_name}] SDK 返回结果: {result}")
         except Exception as e:
-            import traceback
-            traceback.print_exc()
-            print(f"[{self.platform_name}] SDK 调用失败: {e}")
-            raise Exception(f"XHS SDK 发布失败: {e}")
+            err_text = str(e)
+            if '无登录信息' in err_text or '"code": -100' in err_text or "'code': -100" in err_text:
+                self.report_progress(15, "登录信息失效,尝试刷新登录信息...")
+                refreshed = await ensure_valid_cookie_for_sdk()
+                if not refreshed:
+                    screenshot_base64 = await self.capture_screenshot()
+                    page_url = await self.get_page_url() if hasattr(self, 'get_page_url') else (self.page.url if self.page else "")
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error="登录已过期,请使用有头浏览器重新登录",
+                        screenshot_base64=screenshot_base64,
+                        page_url=page_url,
+                        status='need_captcha',
+                        need_captcha=True,
+                        captcha_type='login'
+                    )
+                try:
+                    result = call_create_video_note(refreshed)
+                    print(f"[{self.platform_name}] SDK 重试返回结果: {result}")
+                except Exception as e2:
+                    import traceback
+                    traceback.print_exc()
+                    raise Exception(f"XHS SDK 发布失败: {e2}")
+            else:
+                import traceback
+                traceback.print_exc()
+                print(f"[{self.platform_name}] SDK 调用失败: {e}")
+                raise Exception(f"XHS SDK 发布失败: {e}")
         
         # 验证返回结果
         if not result:
@@ -246,9 +274,13 @@ class XiaohongshuPublisher(BasePublisher):
                 # 其他情况尝试 Playwright 方式
                 print(f"[{self.platform_name}] API 方式未成功,尝试 Playwright...")
             except Exception as e:
-                import traceback
-                traceback.print_exc()
-                print(f"[{self.platform_name}] API 发布失败: {e}")
+                err_text = str(e)
+                if '登录已过期' in err_text or '无登录信息' in err_text:
+                    print(f"[{self.platform_name}] API 登录失效,切换到 Playwright 方式...", flush=True)
+                else:
+                    import traceback
+                    traceback.print_exc()
+                    print(f"[{self.platform_name}] API 发布失败: {e}")
                 print(f"[{self.platform_name}] 尝试使用 Playwright 方式...")
         
         # 使用 Playwright 方式发布
@@ -616,6 +648,7 @@ class XiaohongshuPublisher(BasePublisher):
         
         # 等待发布完成(检测 URL 变化或成功提示)
         publish_success = False
+        refresh_retry = 0
         for i in range(20):  # 最多等待 20 秒
             await asyncio.sleep(1)
             
@@ -641,9 +674,47 @@ class XiaohongshuPublisher(BasePublisher):
             try:
                 error_elements = self.page.locator('[class*="error"], .toast-error, [class*="fail"]')
                 if await error_elements.count() > 0:
-                    error_text = await error_elements.first.text_content()
-                    if error_text and len(error_text.strip()) > 0:
-                        raise Exception(f"发布失败: {error_text.strip()}")
+                    first_error = error_elements.first
+                    if await first_error.is_visible():
+                        error_text = (await first_error.text_content()) or ''
+                        error_text = error_text.strip()
+                        if error_text:
+                            if '请刷新' in error_text and refresh_retry < 3:
+                                refresh_retry += 1
+                                print(f"[{self.platform_name}] 检测到临时错误: {error_text},尝试刷新并重试发布({refresh_retry}/3)", flush=True)
+                                try:
+                                    await self.page.reload(wait_until="domcontentloaded")
+                                except Exception:
+                                    pass
+                                await asyncio.sleep(2)
+                                await self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
+                                await asyncio.sleep(1)
+                                republish_clicked = False
+                                for selector in publish_selectors:
+                                    try:
+                                        btn = self.page.locator(selector).first
+                                        if await btn.count() > 0 and await btn.is_visible() and await btn.is_enabled():
+                                            try:
+                                                await btn.click()
+                                            except:
+                                                box = await btn.bounding_box()
+                                                if box:
+                                                    await self.page.mouse.click(box['x'] + box['width']/2, box['y'] + box['height']/2)
+                                            republish_clicked = True
+                                            break
+                                    except:
+                                        continue
+                                continue
+                            screenshot_base64 = await self.capture_screenshot()
+                            page_url = await self.get_page_url()
+                            return PublishResult(
+                                success=False,
+                                platform=self.platform_name,
+                                error=f"发布失败: {error_text}",
+                                screenshot_base64=screenshot_base64,
+                                page_url=page_url,
+                                status='failed'
+                            )
             except Exception as e:
                 if "发布失败" in str(e):
                     raise

BIN
server/python/weixin_private_msg_116080.png


BIN
server/python/weixin_private_msg_116198.png


BIN
server/python/weixin_private_msg_116258.png


+ 1 - 1
server/src/automation/platforms/xiaohongshu.ts

@@ -554,7 +554,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
       const result = await this.publishVideoViaApi(cookies, params, onProgress);
 
       // 检查是否需要验证码
-      if (!result.success && result.errorMessage?.includes('验证码')) {
+      if (!result.success && ((result as any).needCaptcha || result.errorMessage?.includes('验证码'))) {
         logger.info('[Xiaohongshu] Python detected captcha, need headful browser');
         return {
           success: false,

+ 1 - 0
server/src/routes/system.ts

@@ -67,6 +67,7 @@ router.put(
   authorize('admin'),
   [
     body('productKey').optional().isString(),
+    body('signature').optional().isString(),
     validateRequest,
   ],
   asyncHandler(async (req, res) => {

+ 14 - 3
server/src/services/PublishService.ts

@@ -719,6 +719,7 @@ export class PublishService {
     provider: 'shenlong';
     city: string;
     productKey: string;
+    signature: string;
     regionCode?: string;
     regionName?: string;
   }> {
@@ -731,8 +732,17 @@ export class PublishService {
       where: { configKey: 'publish_proxy_shenlong_product_key' },
     });
     const productKey = String(rows?.[0]?.configValue || '').trim();
-    if (!productKey) {
-      return null;
+    const signatureRows = await this.systemConfigRepository.find({
+      where: { configKey: 'publish_proxy_shenlong_signature' },
+    });
+    const signature = String(signatureRows?.[0]?.configValue || '').trim();
+    if (!productKey || !signature) return null;
+
+    const rawRegionCode = publishProxy.regionCode ? String(publishProxy.regionCode).trim() : '';
+    let regionCode: string | undefined = rawRegionCode || undefined;
+    if (regionCode && /^\d{6}$/.test(regionCode)) {
+      if (regionCode.endsWith('0000')) regionCode = undefined;
+      else if (!regionCode.endsWith('00')) regionCode = `${regionCode.slice(0, 4)}00`;
     }
 
     return {
@@ -740,7 +750,8 @@ export class PublishService {
       provider: 'shenlong',
       city: String(publishProxy.city || '').trim(),
       productKey,
-      regionCode: publishProxy.regionCode ? String(publishProxy.regionCode).trim() : undefined,
+      signature,
+      regionCode,
       regionName: publishProxy.regionName ? String(publishProxy.regionName).trim() : undefined,
     };
   }

+ 10 - 2
server/src/services/SystemService.ts

@@ -12,10 +12,12 @@ interface UpdateConfigParams {
 export interface PublishProxyAdminConfig {
   enabled: boolean;
   productKey: string;
+  signature: string;
 }
 
 export interface UpdatePublishProxyAdminConfig {
   productKey?: string;
+  signature?: string;
 }
 
 interface SystemStatus {
@@ -39,6 +41,7 @@ export class SystemService {
     const configs = await this.configRepository.find();
     const configMap = new Map(configs.map(c => [c.configKey, c.configValue]));
     const productKey = (configMap.get('publish_proxy_shenlong_product_key') || '').trim();
+    const signature = (configMap.get('publish_proxy_shenlong_signature') || '').trim();
 
     return {
       allowRegistration: configMap.get('allow_registration') === 'true',
@@ -46,7 +49,7 @@ export class SystemService {
       maxUploadSize: 4096 * 1024 * 1024, // 4GB
       supportedPlatforms: AVAILABLE_PLATFORM_TYPES,
       publishProxy: {
-        enabled: Boolean(productKey),
+        enabled: Boolean(productKey && signature),
         provider: 'shenlong',
         cities: [],
       },
@@ -67,10 +70,12 @@ export class SystemService {
     const configMap = new Map(configs.map(c => [c.configKey, c.configValue]));
 
     const productKey = (configMap.get('publish_proxy_shenlong_product_key') || '').trim();
+    const signature = (configMap.get('publish_proxy_shenlong_signature') || '').trim();
 
     return {
-      enabled: Boolean(productKey),
+      enabled: Boolean(productKey && signature),
       productKey,
+      signature,
     };
   }
 
@@ -78,6 +83,9 @@ export class SystemService {
     if (params.productKey !== undefined) {
       await this.setConfig('publish_proxy_shenlong_product_key', String(params.productKey || '').trim());
     }
+    if (params.signature !== undefined) {
+      await this.setConfig('publish_proxy_shenlong_signature', String(params.signature || '').trim());
+    }
   }
 
   async getPublishProxyCitiesFromApi(): Promise<string[]> {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.