You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
976 lines
44 KiB
976 lines
44 KiB
# -*- coding: utf-8 -*-
|
|
"""
|
|
CollectRuntime — 采集执行器 (复用各平台 Client + CookiePool)
|
|
|
|
从 task_worker.py 提取, 适配 task_worker_v2 包结构。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import random
|
|
import re
|
|
import traceback
|
|
from typing import Any, Optional
|
|
|
|
import requests as req_lib
|
|
|
|
from platforms.deepseek import DeepSeekClient, get_ds_session_id
|
|
from platforms.kimi import KimiClient, extract_kimi_from_browser
|
|
from platforms.qianwen import QianwenClient, extract_qianwen_cookies, get_qw_session_id
|
|
from platforms.wenxin import WenxinClient, extract_wenxin_cookies, get_wenxin_share_url
|
|
from platforms.doubao import DoubaoAdapter
|
|
from platforms.base import NO_PROXY, CDP_URL, ensure_chrome_running
|
|
from config import COOKIE_PLATFORM_IDS, COOKIE_DOMAINS, API_TO_INTERNAL, QUOTA_KEYWORDS, normalize_platform_name
|
|
from cookie_pool import CookiePool, parse_cookie_string, cookie_str_to_playwright
|
|
from collector import save_raw_and_parse
|
|
|
|
|
|
# ---------- 停止检测回调 ----------
|
|
# 由 main.py 注入 (避免循环导入); 默认永不停止
|
|
_is_stopping = lambda: False # noqa: E731
|
|
|
|
|
|
def set_stop_check(fn):
|
|
"""让调用方 (main.py) 注入 is_stopping 函数"""
|
|
global _is_stopping
|
|
_is_stopping = fn
|
|
|
|
|
|
def is_stopping() -> bool:
|
|
return _is_stopping()
|
|
|
|
|
|
# ---------- 豆包 cookie 域名列表 ----------
|
|
DOUBAO_COOKIE_DOMAINS = [".doubao.com", "www.doubao.com", "doubao.com"]
|
|
|
|
|
|
# ---------- cookie 灌入工具函数 ----------
|
|
def apply_cookie_str_to_qianwen(client, cookie_str: str):
|
|
"""把 cookie 串灌进 QianwenClient"""
|
|
client._cookie_str = cookie_str if "=" in cookie_str and "Bearer" not in cookie_str[:20] else cookie_str
|
|
jar = parse_cookie_string(cookie_str)
|
|
if jar:
|
|
client._cookie_str = "; ".join(f"{k}={v}" for k, v in jar.items())
|
|
client._xsrf_token = jar.get("XSRF-TOKEN", "") or next(
|
|
(v for k, v in jar.items() if k.upper() == "XSRF-TOKEN"), ""
|
|
)
|
|
return client
|
|
|
|
|
|
def apply_cookie_str_to_wenxin(client, cookie_str: str):
|
|
jar = parse_cookie_string(cookie_str)
|
|
if jar:
|
|
client._cookie_str = "; ".join(f"{k}={v}" for k, v in jar.items())
|
|
else:
|
|
client._cookie_str = cookie_str
|
|
return client
|
|
|
|
|
|
# ============================================================
|
|
# 采集执行器 (复用平台 Client + CookiePool)
|
|
# ============================================================
|
|
class CollectRuntime:
|
|
def __init__(
|
|
self,
|
|
cookie_pool: Optional[CookiePool] = None,
|
|
*,
|
|
cfg: Optional[dict] = None,
|
|
wenxin_browser_share: bool = False,
|
|
browser_fallback_auth: bool = False,
|
|
doubao_cfg: Optional[dict] = None,
|
|
):
|
|
if cookie_pool is None:
|
|
raise ValueError("CollectRuntime 需要 CookiePool 实例")
|
|
self.pool = cookie_pool
|
|
|
|
# 若传入完整 cfg, 从中提取 browser / doubao 子配置
|
|
if cfg is not None:
|
|
br_cfg = cfg.get("browser") or {}
|
|
dc = cfg.get("doubao") or {}
|
|
wenxin_browser_share = wenxin_browser_share or bool(br_cfg.get("wenxin_share", False))
|
|
browser_fallback_auth = browser_fallback_auth or bool(br_cfg.get("fallback_auth", False))
|
|
if doubao_cfg is None:
|
|
doubao_cfg = dc
|
|
|
|
# 文心短链是否走浏览器点击 (默认否 → 不启 Chrome)
|
|
self.wenxin_browser_share = bool(wenxin_browser_share)
|
|
# cookie 池失败时是否允许浏览器抠登录态 (默认否)
|
|
self.browser_fallback_auth = bool(browser_fallback_auth)
|
|
# 豆包浏览器自动化参数
|
|
dc = doubao_cfg or {}
|
|
self.doubao_wait_timeout = int(dc.get("wait_timeout") or 180)
|
|
self.doubao_share = bool(dc.get("share", True))
|
|
self.doubao_cookie_rotate = bool(dc.get("cookie_rotate_on_fail", True))
|
|
self.doubao_max_retry = int(dc.get("max_retry") or 2)
|
|
self.doubao_use_cookie_pool = bool(dc.get("use_cookie_pool", True))
|
|
self.ds_client: Optional[DeepSeekClient] = None
|
|
self.kimi_client: Optional[KimiClient] = None
|
|
self.qw_client: Optional[QianwenClient] = None
|
|
self.wx_client: Optional[WenxinClient] = None
|
|
self.doubao_adapter: Optional[DoubaoAdapter] = None
|
|
self.wx_share_page = None
|
|
self._pw = None
|
|
self._browser = None
|
|
self._ctx = None
|
|
self._cdp = None
|
|
self._doubao_page = None
|
|
self._doubao_dt_exhausted = False
|
|
self._ds_lock = asyncio.Lock()
|
|
self._kimi_lock = asyncio.Lock()
|
|
self._qw_lock = asyncio.Lock()
|
|
self._wx_lock = asyncio.Lock()
|
|
self._db_lock = asyncio.Lock() # 豆包页面串行
|
|
self._session_ids: dict[str, Any] = {}
|
|
self._doubao_cookie_str = ""
|
|
self._chrome_ready = False
|
|
|
|
def _needs_chrome(self, needed_internal: set[str]) -> bool:
|
|
"""
|
|
仅在真正依赖浏览器时启动 Chrome:
|
|
- doubao: CDP 页面自动化 (必须)
|
|
- wenxin + wenxin_browser_share: 分享短链 (可选)
|
|
deepseek / kimi / tongyi 走 cookie 池 HTTP, 默认不启浏览器。
|
|
"""
|
|
if "doubao" in needed_internal:
|
|
return True
|
|
if "wenxin" in needed_internal and self.wenxin_browser_share:
|
|
return True
|
|
return False
|
|
|
|
async def setup(self, needed_internal: set[str]):
|
|
"""按需初始化: 优先 cookie 池; 浏览器仅 doubao 或文心短链分享"""
|
|
need_chrome = self._needs_chrome(needed_internal)
|
|
if need_chrome:
|
|
reasons = []
|
|
if "doubao" in needed_internal:
|
|
reasons.append("doubao采集")
|
|
if "wenxin" in needed_internal and self.wenxin_browser_share:
|
|
reasons.append("文心短链分享")
|
|
print(f"=== 启动浏览器 (原因: {', '.join(reasons)}) ===")
|
|
if not ensure_chrome_running():
|
|
if "doubao" in needed_internal:
|
|
raise RuntimeError("Chrome 未就绪, 豆包无法采集")
|
|
print("[WARN] Chrome 未就绪, 文心短链分享将降级为页面链接")
|
|
need_chrome = False
|
|
else:
|
|
self._chrome_ready = True
|
|
else:
|
|
print("=== 跳过浏览器 (当前开启平台均走 HTTP/cookie 池) ===")
|
|
print(" 提示: 仅 doubao=true 或 browser.wenxin_share=true 时才会启动 Chrome")
|
|
|
|
print("=== Cookie 池认证 (ToolsLoad 对齐) ===")
|
|
for name, pid in COOKIE_PLATFORM_IDS.items():
|
|
if name in needed_internal:
|
|
print(f" 平台 {name} → platform_id={pid}")
|
|
|
|
if "ds" in needed_internal:
|
|
await self._ensure_ds_client()
|
|
|
|
if "kimi" in needed_internal:
|
|
await self._ensure_kimi_client()
|
|
|
|
if "qianwen" in needed_internal:
|
|
await self._ensure_qianwen_client()
|
|
|
|
if "wenxin" in needed_internal:
|
|
await self._ensure_wenxin_client()
|
|
|
|
if need_chrome and self._chrome_ready:
|
|
from playwright.async_api import async_playwright
|
|
self._pw = await async_playwright().start()
|
|
self._browser = await self._pw.chromium.connect_over_cdp(CDP_URL)
|
|
self._ctx = self._browser.contexts[0]
|
|
|
|
if "doubao" in needed_internal:
|
|
await self._setup_doubao()
|
|
|
|
if "wenxin" in needed_internal and self.wenxin_browser_share:
|
|
wx_page = next((p for p in self._ctx.pages if "baidu.com" in p.url), None)
|
|
if not wx_page:
|
|
wx_page = await self._ctx.new_page()
|
|
await wx_page.goto("https://chat.baidu.com", wait_until="domcontentloaded", timeout=20000)
|
|
await asyncio.sleep(2)
|
|
cookie_str = (self.wx_client._cookie_str if self.wx_client else "") or ""
|
|
if cookie_str:
|
|
try:
|
|
await self._ctx.add_cookies(
|
|
cookie_str_to_playwright(cookie_str, COOKIE_DOMAINS["wenxin"])
|
|
)
|
|
await wx_page.goto("https://chat.baidu.com", wait_until="domcontentloaded", timeout=20000)
|
|
await asyncio.sleep(1)
|
|
print(" [auth] 文心 cookie 已注入浏览器分享页")
|
|
except Exception as e:
|
|
print(f" [auth] 文心 cookie 注入失败: {e}")
|
|
self.wx_share_page = wx_page
|
|
print(" [auth] 文心分享页就绪")
|
|
elif "wenxin" in needed_internal:
|
|
print(" [auth] 文心分享: 不使用浏览器, 提交时用 chat.baidu.com/search/{lid}")
|
|
|
|
async def _inject_doubao_cookies(self, cookie_str: str) -> int:
|
|
"""注入豆包 cookie 到多个 domain, 返回注入条数"""
|
|
if not self._ctx or not cookie_str:
|
|
return 0
|
|
cookies = cookie_str_to_playwright(cookie_str, DOUBAO_COOKIE_DOMAINS)
|
|
if not cookies:
|
|
return 0
|
|
await self._ctx.add_cookies(cookies)
|
|
return len(cookies)
|
|
|
|
async def _check_doubao_login(self, page) -> bool:
|
|
"""判断豆包页是否已登录 (有输入框且非登录墙)"""
|
|
try:
|
|
url = page.url or ""
|
|
if "login" in url or "passport" in url:
|
|
return False
|
|
# 常见登录入口文案
|
|
for sel in ("text=登录", "text=手机号登录", "text=扫码登录"):
|
|
try:
|
|
loc = page.locator(sel).first
|
|
if await loc.is_visible(timeout=800):
|
|
# 登录按钮在未登录态常驻; 再确认是否有发消息框
|
|
pass
|
|
except Exception:
|
|
pass
|
|
ta = page.locator("textarea").first
|
|
await ta.wait_for(state="visible", timeout=8000)
|
|
# 若页面提示登录才能使用
|
|
content = ""
|
|
try:
|
|
content = await page.inner_text("body", timeout=3000)
|
|
except Exception:
|
|
pass
|
|
if any(k in (content or "") for k in ("登录后即可", "请先登录", "立即登录", "未登录")):
|
|
# 仍可能有 textarea 占位, 保守判断
|
|
if "登录" in (content or "")[:500] and "发消息" not in (content or ""):
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
print(f" [auth] 豆包登录检测异常: {e}")
|
|
return False
|
|
|
|
async def _bind_doubao_page(self, page) -> None:
|
|
"""为页面建立 CDP 拦截 + DoubaoAdapter"""
|
|
if self._cdp:
|
|
try:
|
|
await self._cdp.detach()
|
|
except Exception:
|
|
pass
|
|
self._cdp = None
|
|
self._doubao_page = page
|
|
self._cdp = await self._ctx.new_cdp_session(page)
|
|
await self._cdp.send("Network.enable")
|
|
self.doubao_adapter = DoubaoAdapter(page, self._cdp)
|
|
|
|
async def _setup_doubao(self):
|
|
"""
|
|
豆包浏览器自动化初始化:
|
|
1) (可选) cookie 池注入
|
|
2) 打开 /chat, 校验登录
|
|
3) 失败则换号再注入; 仍失败则依赖 Chrome 用户态
|
|
4) 挂载 CDP Network 拦截 chat/completion
|
|
"""
|
|
print(" [auth] 豆包自动化初始化 (Playwright CDP)...")
|
|
logged_in = False
|
|
|
|
if self.doubao_use_cookie_pool:
|
|
for attempt in range(max(1, self.doubao_max_retry + 1)):
|
|
sess = self.pool.get_for_internal("doubao")
|
|
if not sess or not sess.get("cookie"):
|
|
print(" [auth] 豆包 cookie 池无数据, 改用浏览器已有登录态")
|
|
break
|
|
self._doubao_cookie_str = sess["cookie"]
|
|
self._session_ids["doubao"] = sess.get("id")
|
|
try:
|
|
n = await self._inject_doubao_cookies(sess["cookie"])
|
|
print(f" [auth] 豆包 cookie 已注入 id={sess.get('id')} entries={n}")
|
|
except Exception as e:
|
|
print(f" [auth] 豆包 cookie 注入失败: {e}")
|
|
self._drop_session("doubao", "invalid")
|
|
continue
|
|
|
|
page = next((p for p in self._ctx.pages if "doubao.com" in (p.url or "")), None)
|
|
if not page:
|
|
page = await self._ctx.new_page()
|
|
try:
|
|
await page.goto("https://www.doubao.com/chat", wait_until="domcontentloaded", timeout=60000)
|
|
await asyncio.sleep(2)
|
|
except Exception as e:
|
|
print(f" [auth] 豆包打开页面失败: {e}")
|
|
self._drop_session("doubao", "invalid")
|
|
continue
|
|
|
|
if await self._check_doubao_login(page):
|
|
await self._bind_doubao_page(page)
|
|
logged_in = True
|
|
print(" [auth] 豆包登录态 OK (cookie 池)")
|
|
break
|
|
print(f" [auth] 豆包 cookie 无效/未登录, 换号 (attempt {attempt + 1})")
|
|
self._drop_session("doubao", "invalid")
|
|
try:
|
|
await self._ctx.clear_cookies()
|
|
except Exception:
|
|
pass
|
|
|
|
if not logged_in:
|
|
# 回退: Chrome user-data 已登录态 (ensure_chrome 带的 profile)
|
|
page = next((p for p in self._ctx.pages if "doubao.com" in (p.url or "")), None)
|
|
if not page:
|
|
page = await self._ctx.new_page()
|
|
await page.goto("https://www.doubao.com/chat", wait_until="domcontentloaded", timeout=60000)
|
|
await asyncio.sleep(2)
|
|
if await self._check_doubao_login(page):
|
|
await self._bind_doubao_page(page)
|
|
logged_in = True
|
|
print(" [auth] 豆包登录态 OK (Chrome 已有会话)")
|
|
else:
|
|
await self._bind_doubao_page(page) # 仍绑定, 采集时再报错
|
|
print(" [auth] ⚠ 豆包未检测到登录, 请在调试 Chrome 中手动登录 doubao.com")
|
|
|
|
print(f" [auth] 豆包浏览器就绪 share={self.doubao_share} wait={self.doubao_wait_timeout}s")
|
|
|
|
# ------ 平台客户端初始化 ------
|
|
|
|
async def _ensure_ds_client(self) -> Optional[DeepSeekClient]:
|
|
async with self._ds_lock:
|
|
if self.ds_client:
|
|
return self.ds_client
|
|
sess = self.pool.get_for_internal("ds")
|
|
if not sess:
|
|
print(" [auth] DeepSeek cookie 池无可用 session")
|
|
return None
|
|
self.ds_client = DeepSeekClient(sess["cookie"], str(sess.get("id", "")))
|
|
self._session_ids["ds"] = sess.get("id")
|
|
print(f" [auth] DeepSeek 就绪 (pool id={sess.get('id')})")
|
|
return self.ds_client
|
|
|
|
async def _ensure_kimi_client(self) -> Optional[KimiClient]:
|
|
async with self._kimi_lock:
|
|
if self.kimi_client:
|
|
return self.kimi_client
|
|
sess = self.pool.get_for_internal("kimi")
|
|
token = sess.get("cookie") if sess else None
|
|
sid = sess.get("id") if sess else None
|
|
if not token and self.browser_fallback_auth and self._chrome_ready:
|
|
print(" [auth] Kimi cookie 池无数据, 尝试浏览器提取...")
|
|
br = await extract_kimi_from_browser()
|
|
if br:
|
|
token, sid = br.get("token"), br.get("id", "-1")
|
|
if not token:
|
|
print(" [auth] Kimi Token 不可用 (cookie 池无数据"
|
|
+ ("; 已关闭 browser.fallback_auth" if not self.browser_fallback_auth else "")
|
|
+ ")")
|
|
return None
|
|
try:
|
|
self.kimi_client = KimiClient(token)
|
|
self._session_ids["kimi"] = sid
|
|
self.kimi_client.cookie_id = sid
|
|
print(f" [auth] Kimi 就绪 (pool id={sid})")
|
|
return self.kimi_client
|
|
except Exception as e:
|
|
print(f" [auth] Kimi 初始化失败: {e}")
|
|
if sid:
|
|
self.pool.invalidate(sid, "invalid")
|
|
return None
|
|
|
|
async def _ensure_qianwen_client(self) -> Optional[QianwenClient]:
|
|
async with self._qw_lock:
|
|
if self.qw_client and self.qw_client._cookie_str:
|
|
return self.qw_client
|
|
client = QianwenClient()
|
|
sess = self.pool.get_for_internal("qianwen")
|
|
if sess and sess.get("cookie"):
|
|
apply_cookie_str_to_qianwen(client, sess["cookie"])
|
|
self._session_ids["qianwen"] = sess.get("id")
|
|
print(f" [auth] 千问 cookie 池就绪 id={sess.get('id')}")
|
|
elif self.browser_fallback_auth and self._chrome_ready:
|
|
cookies = await extract_qianwen_cookies()
|
|
if cookies:
|
|
client.set_cookies(cookies)
|
|
print(" [auth] 千问浏览器 cookie 就绪")
|
|
else:
|
|
print(" [auth] 千问无 cookie (HMAC 匿名仍可聊, 分享不可用)")
|
|
else:
|
|
print(" [auth] 千问 cookie 池无数据, 将尝试 HMAC 匿名采集")
|
|
self.qw_client = client
|
|
return client
|
|
|
|
async def _ensure_wenxin_client(self) -> Optional[WenxinClient]:
|
|
async with self._wx_lock:
|
|
if self.wx_client and self.wx_client._cookie_str:
|
|
return self.wx_client
|
|
client = WenxinClient()
|
|
sess = self.pool.get_for_internal("wenxin")
|
|
if sess and sess.get("cookie"):
|
|
apply_cookie_str_to_wenxin(client, sess["cookie"])
|
|
self._session_ids["wenxin"] = sess.get("id")
|
|
print(f" [auth] 文心 cookie 池就绪 id={sess.get('id')}")
|
|
elif self.browser_fallback_auth and self._chrome_ready:
|
|
cookies = await extract_wenxin_cookies()
|
|
if cookies:
|
|
client._cookie_str = "; ".join(f'{c["name"]}={c["value"]}' for c in cookies)
|
|
print(" [auth] 文心浏览器 cookie 就绪")
|
|
else:
|
|
print(" [auth] 文心无 cookie, 可能被反爬")
|
|
else:
|
|
print(" [auth] 文心 cookie 池无数据 (未启用 browser.fallback_auth)")
|
|
self.wx_client = client
|
|
return client
|
|
|
|
# ------ 会话管理 ------
|
|
|
|
def _drop_session(self, internal: str, reason: str = "invalid"):
|
|
"""丢弃本地 client 并回写 cookie 池状态"""
|
|
sid = self._session_ids.pop(internal, None)
|
|
if sid is not None:
|
|
self.pool.invalidate(sid, reason)
|
|
if internal == "ds":
|
|
self.ds_client = None
|
|
elif internal == "kimi":
|
|
self.kimi_client = None
|
|
elif internal == "qianwen":
|
|
self.qw_client = None
|
|
elif internal == "wenxin":
|
|
self.wx_client = None
|
|
elif internal == "doubao":
|
|
self._doubao_cookie_str = ""
|
|
|
|
# ------ 解析 + 配额检查 ------
|
|
|
|
def _parse_and_check(self, plat_key: str, raw_body, raw_req, prompt: str, deep: bool) -> Optional[dict]:
|
|
if isinstance(raw_body, bytes):
|
|
raw_str = raw_body.decode("utf-8", errors="replace")
|
|
else:
|
|
raw_str = raw_body or ""
|
|
if not raw_str:
|
|
return None
|
|
parsed = save_raw_and_parse(plat_key, raw_str, raw_req)
|
|
if not parsed:
|
|
return None
|
|
answer = (parsed.get("result") or {}).get("answer") or ""
|
|
if not answer.strip():
|
|
print(" ✗ 解析答案为空")
|
|
return None
|
|
if any(re.search(kw, answer) for kw in QUOTA_KEYWORDS):
|
|
print(" ⚠ 命中限流/配额话术, 丢弃")
|
|
# 回写池: 配额类 (plat_key 即内部 key: ds/kimi/qianwen/wenxin/doubao)
|
|
internal = plat_key if plat_key in COOKIE_PLATFORM_IDS else None
|
|
if internal:
|
|
self._drop_session(internal, "quota")
|
|
return None
|
|
parsed["deep_thinking"] = 1 if deep else 0
|
|
parsed["question"] = prompt
|
|
parsed["_raw_req"] = raw_req or ""
|
|
print(f" ✓ 答案 {len(answer)} 字 | 思考 {len((parsed['result'].get('thinking_process') or ''))} 字 "
|
|
f"| 信源 {len(parsed.get('sources') or [])}")
|
|
return parsed
|
|
|
|
# ------ Kimi 诊断工具 ------
|
|
|
|
def _kimi_response_diag(self, raw_body) -> str:
|
|
"""诊断 Kimi 原始响应: unauthenticated / overloaded / quota / 正常帧"""
|
|
if isinstance(raw_body, bytes):
|
|
raw_str = raw_body.decode("utf-8", errors="replace")
|
|
else:
|
|
raw_str = raw_body or ""
|
|
if "unauthenticated" in raw_str or "请登录" in raw_str:
|
|
return "unauthenticated"
|
|
# 算力过载 / 服务端限流: 平台繁忙, 同号等待重试即可
|
|
if "OVERLOADED" in raw_str or "Kimi有点累了" in raw_str or "聊的人太多了" in raw_str or "resource_exhausted" in raw_str:
|
|
return "overloaded"
|
|
# 真正的配额用完: 需要换号
|
|
if any(kw in raw_str for kw in (
|
|
"算力不足", "前往升级", "对话次数已达上限",
|
|
"免费次数用完", "次数已用完",
|
|
)):
|
|
return "quota"
|
|
if "account_abnormal" in raw_str or "账号状态异常" in raw_str:
|
|
return "account_abnormal"
|
|
if '"error"' in raw_str:
|
|
m = re.search(r'"code"\s*:\s*"([^"]+)"', raw_str)
|
|
return f"error:{m.group(1)}" if m else "error"
|
|
if len(raw_str.strip()) < 80 and not raw_str.strip():
|
|
return "empty"
|
|
return "ok"
|
|
|
|
def _kimi_probe_account(self, client) -> str:
|
|
"""
|
|
用 /api/user 探测账号态 (比 Chat 错误更明确)。
|
|
返回: ok / account_abnormal / unauthenticated / unknown
|
|
"""
|
|
try:
|
|
# 与 run.KimiClient._build_headers 对齐 (version 2.0.0)
|
|
headers = {
|
|
"authorization": client.token if str(client.token).startswith("Bearer")
|
|
else f"Bearer {client.token}",
|
|
"origin": "https://www.kimi.com",
|
|
"referer": "https://www.kimi.com/",
|
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
|
"x-msh-device-id": str(getattr(client, "device_id", "") or ""),
|
|
"x-msh-session-id": str(getattr(client, "session_id", "") or ""),
|
|
"x-msh-platform": "web",
|
|
"x-msh-version": "2.0.0",
|
|
"x-traffic-id": str(getattr(client, "traffic_id", "") or ""),
|
|
"x-language": "zh-CN",
|
|
"r-timezone": "Asia/Shanghai",
|
|
}
|
|
resp = client.requests.get(
|
|
"https://www.kimi.com/api/user",
|
|
headers=headers,
|
|
timeout=15,
|
|
impersonate="chrome146",
|
|
)
|
|
text = resp.text or ""
|
|
if "account_abnormal" in text or "账号状态异常" in text:
|
|
return "account_abnormal"
|
|
if resp.status_code in (401, 403) or "unauthenticated" in text or "请登录" in text:
|
|
return "unauthenticated"
|
|
if resp.status_code == 200 and ("id" in text or "name" in text or "email" in text or "user" in text.lower()):
|
|
return "ok"
|
|
return f"http_{resp.status_code}"
|
|
except Exception as e:
|
|
return f"probe_err:{e}"
|
|
|
|
# ------ 单条采集入口 ------
|
|
|
|
async def collect_one(self, task: dict) -> Optional[dict]:
|
|
"""执行单条任务采集, 返回 parsed 结果 dict。失败返回 None。"""
|
|
api_plat = (task.get("platform") or "").strip().lower()
|
|
internal = API_TO_INTERNAL.get(api_plat)
|
|
if not internal:
|
|
print(f" ✗ 不支持的平台: {api_plat}")
|
|
return None
|
|
|
|
prompt = (task.get("prompt") or "").strip()
|
|
if not prompt:
|
|
print(" ✗ 题目为空")
|
|
return None
|
|
|
|
deep = bool(int(task.get("deepThinking") or 0))
|
|
print(f" → 采集 {api_plat}/{internal} DT={int(deep)} pool_id={COOKIE_PLATFORM_IDS.get(internal)} | {prompt[:40]}...")
|
|
|
|
try:
|
|
if internal == "ds":
|
|
return await self._collect_ds(prompt, deep, task)
|
|
if internal == "kimi":
|
|
return await self._collect_kimi(prompt, task, deep=deep)
|
|
if internal == "qianwen":
|
|
return await self._collect_qianwen(prompt, deep, task)
|
|
if internal == "wenxin":
|
|
return await self._collect_wenxin(prompt, deep, task)
|
|
if internal == "doubao":
|
|
return await self._collect_doubao(prompt, deep, task)
|
|
except Exception as e:
|
|
print(f" ✗ 采集异常: {e}")
|
|
traceback.print_exc()
|
|
return None
|
|
return None
|
|
|
|
# ------ 各平台采集 ------
|
|
|
|
async def _collect_ds(self, prompt, deep, task) -> Optional[dict]:
|
|
client = await self._ensure_ds_client()
|
|
if not client:
|
|
print(" ✗ DeepSeek 客户端不可用")
|
|
return None
|
|
try:
|
|
raw_body, raw_req = await asyncio.to_thread(client.chat, prompt, deep)
|
|
except Exception as e:
|
|
err = str(e)
|
|
if "Authorization Failed" in err or "invalid token" in err:
|
|
async with self._ds_lock:
|
|
self._drop_session("ds", "invalid")
|
|
elif "user is muted" in err:
|
|
async with self._ds_lock:
|
|
self._drop_session("ds", "muted")
|
|
raise
|
|
|
|
parsed = self._parse_and_check("ds", raw_body, raw_req, prompt, deep)
|
|
if parsed:
|
|
sid = get_ds_session_id(parsed)
|
|
if sid:
|
|
try:
|
|
sr = req_lib.post(
|
|
"https://chat.deepseek.com/api/v0/share/create",
|
|
headers=client.headers,
|
|
json={"chat_session_id": sid, "message_ids": [1, 2]},
|
|
timeout=30, verify=False, proxies=NO_PROXY,
|
|
)
|
|
biz = ((sr.json() or {}).get("data") or {}).get("biz_data") or {}
|
|
sh_id = biz.get("share_id", "") if isinstance(biz, dict) else ""
|
|
if sh_id:
|
|
parsed["result"]["share_link"] = f"https://chat.deepseek.com/share/{sh_id}"
|
|
except Exception:
|
|
pass
|
|
return parsed
|
|
|
|
async def _collect_kimi(self, prompt, task, deep: bool = False, max_retry=2) -> Optional[dict]:
|
|
"""
|
|
Kimi 采集: 使用 KimiClient (最新 SCENARIO_K2D5 请求体)
|
|
tools: SEARCH + CRON_JOB
|
|
options: thinking / enable_plugin / reasoning_effort
|
|
deep: 任务 deepThinking → options.thinking
|
|
"""
|
|
overload_retries = 0
|
|
max_overload = 5
|
|
for attempt in range(max_retry + 1 + max_overload):
|
|
client = await self._ensure_kimi_client()
|
|
if not client:
|
|
print(" ✗ Kimi 客户端不可用")
|
|
return None
|
|
try:
|
|
raw_body, raw_req = await asyncio.to_thread(
|
|
client.chat, prompt, bool(deep),
|
|
)
|
|
except Exception as e:
|
|
err = str(e)
|
|
if "unauthenticated" in err or "请登录" in err:
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "invalid")
|
|
if attempt < max_retry:
|
|
print(f" ↻ Kimi 异常换号重试 {attempt + 1}/{max_retry}: {e}")
|
|
await asyncio.sleep(random.uniform(3, 8))
|
|
continue
|
|
raise
|
|
|
|
diag = self._kimi_response_diag(raw_body)
|
|
raw_len = len(raw_body) if raw_body is not None else 0
|
|
|
|
if diag in ("unauthenticated", "account_abnormal"):
|
|
probe = self._kimi_probe_account(client)
|
|
detail = f"chat={diag}, user_probe={probe}, raw={raw_len}B"
|
|
if probe == "account_abnormal" or diag == "account_abnormal":
|
|
print(f" ✗ Kimi 账号状态异常 (风控/封禁), {detail}")
|
|
print(" Moonshot: 账号状态异常, 联系 support@moonshot.cn; 池内此类号应淘汰")
|
|
else:
|
|
print(f" ✗ Kimi 未认证, {detail}")
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "invalid")
|
|
if attempt < max_retry:
|
|
print(f" ↻ Kimi 换号重试 {attempt + 1}/{max_retry}")
|
|
await asyncio.sleep(random.uniform(2, 5))
|
|
continue
|
|
print(" ✗ Kimi 认证失败, 重试耗尽 — cookie 池 platform_id=4 账号均不可用, 请补新号")
|
|
return None
|
|
|
|
if diag == "overloaded":
|
|
overload_retries += 1
|
|
if overload_retries <= max_overload:
|
|
wait = random.uniform(3, 5)
|
|
print(f" ⏳ Kimi 算力过载, 同号等待 {wait:.0f}s 后重试 ({overload_retries}/{max_overload})")
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
print(" ✗ Kimi 持续过载, 重试 {max_overload} 次仍失败")
|
|
return None
|
|
|
|
if diag == "quota":
|
|
print(f" ✗ Kimi 配额用完 (响应 {raw_len}B)")
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "quota")
|
|
if attempt < max_retry:
|
|
print(f" ↻ Kimi 配额用完换号重试 {attempt + 1}/{max_retry}")
|
|
await asyncio.sleep(random.uniform(5, 15))
|
|
continue
|
|
print(" ✗ Kimi 配额用完, 重试耗尽")
|
|
return None
|
|
|
|
if diag.startswith("error"):
|
|
print(f" ✗ Kimi API 错误 {diag} (响应 {raw_len}B)")
|
|
if attempt < max_retry:
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "invalid")
|
|
await asyncio.sleep(random.uniform(2, 5))
|
|
continue
|
|
return None
|
|
|
|
parsed = self._parse_and_check("kimi", raw_body, raw_req, prompt, bool(deep))
|
|
if parsed:
|
|
src_count = len(parsed.get("sources") or [])
|
|
if src_count == 0 and attempt < max_retry:
|
|
print(f" ⚠ Kimi 信源为 0, 换号重试 ({attempt + 1}/{max_retry})")
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "invalid")
|
|
await asyncio.sleep(random.uniform(3, 8))
|
|
continue
|
|
|
|
chat_id = parsed.get("chat_id", "")
|
|
msg_ids = parsed.get("message_ids", [])
|
|
if chat_id and msg_ids and client:
|
|
try:
|
|
# 分享接口: JSON, 去掉 connect 协议头 (与 run.py share 一致)
|
|
headers = client._build_headers()
|
|
headers["content-type"] = "application/json"
|
|
headers.pop("connect-protocol-version", None)
|
|
resp = client.requests.post(
|
|
"https://www.kimi.com/apiv2/kimi.gateway.chat.v1.ChatService/CreateChatShare",
|
|
headers=headers,
|
|
json={"chat_id": chat_id, "message_ids": msg_ids},
|
|
timeout=30, impersonate="chrome146",
|
|
)
|
|
if resp.status_code == 200 and resp.content:
|
|
raw = resp.content
|
|
js = raw.find(b"{")
|
|
if js >= 0:
|
|
resp_data = json.loads(raw[js: raw.rfind(b"}") + 1])
|
|
share_id = (resp_data.get("share") or {}).get("id", "") or resp_data.get("id", "")
|
|
if share_id:
|
|
parsed["result"]["share_link"] = f"https://www.kimi.com/share/{share_id}"
|
|
except Exception:
|
|
pass
|
|
return parsed
|
|
|
|
# 解析为空: 可能仍是异常短响应
|
|
print(f" ✗ Kimi 解析为空 diag={diag} raw_len={raw_len}")
|
|
if attempt < max_retry:
|
|
async with self._kimi_lock:
|
|
self._drop_session("kimi", "invalid")
|
|
await asyncio.sleep(random.uniform(3, 6))
|
|
continue
|
|
return None
|
|
|
|
async def _collect_qianwen(self, prompt, deep, task) -> Optional[dict]:
|
|
client = await self._ensure_qianwen_client()
|
|
if not client:
|
|
client = QianwenClient()
|
|
self.qw_client = client
|
|
try:
|
|
raw_body, raw_req = await client.chat(prompt, thinking=deep)
|
|
except Exception as e:
|
|
err = str(e).lower()
|
|
if "401" in err or "login" in err or "auth" in err:
|
|
async with self._qw_lock:
|
|
self._drop_session("qianwen", "invalid")
|
|
raise
|
|
parsed = self._parse_and_check("qianwen", raw_body, raw_req, prompt, deep)
|
|
if parsed and client._cookie_str:
|
|
session_id = get_qw_session_id(parsed)
|
|
if session_id:
|
|
try:
|
|
share_id = await client.create_share(session_id)
|
|
if share_id:
|
|
parsed["result"]["share_link"] = f"https://www.qianwen.com/share/chat/{share_id}"
|
|
except Exception:
|
|
pass
|
|
return parsed
|
|
|
|
async def _collect_wenxin(self, prompt, deep, task) -> Optional[dict]:
|
|
client = await self._ensure_wenxin_client()
|
|
if not client:
|
|
client = WenxinClient()
|
|
self.wx_client = client
|
|
raw_body, raw_req = await client.chat(prompt, thinking=deep)
|
|
parsed = self._parse_and_check("wenxin", raw_body, raw_req, prompt, deep)
|
|
if parsed:
|
|
lid = parsed.get("conversation_lid", "")
|
|
if lid:
|
|
page_link = f"https://chat.baidu.com/search/{lid}"
|
|
if self.wenxin_browser_share and self.wx_share_page:
|
|
try:
|
|
share_url = await get_wenxin_share_url(lid, wx_page=self.wx_share_page)
|
|
parsed["result"]["share_link"] = share_url or page_link
|
|
except Exception:
|
|
parsed["result"]["share_link"] = page_link
|
|
else:
|
|
parsed["result"]["share_link"] = page_link
|
|
return parsed
|
|
|
|
async def _doubao_create_share(self, conv_id: str) -> str:
|
|
"""页内 samantha 两步 API 生成分享链接"""
|
|
if not conv_id or not self.doubao_adapter:
|
|
return ""
|
|
try:
|
|
share_data = await self.doubao_adapter.page.evaluate("""async (convId) => {
|
|
const aid = "497858";
|
|
try {
|
|
const r1 = await fetch("/samantha/thread/share/info?aid=" + aid, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({conversation_id: convId}),
|
|
credentials: "include"
|
|
});
|
|
const d1 = await r1.json();
|
|
const shareToken = d1?.data?.share_token || "";
|
|
const preShareId = d1?.data?.pre_share_id || "";
|
|
const r2 = await fetch("/samantha/thread/share/save?aid=" + aid, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
conversation_id: convId,
|
|
share_token: shareToken,
|
|
share_id: preShareId,
|
|
message_index_end: 2,
|
|
share_type: 1,
|
|
is_allow_seo: false
|
|
}),
|
|
credentials: "include"
|
|
});
|
|
const d2 = await r2.json();
|
|
const sid = d2?.data?.share_id || "";
|
|
return sid ? "https://www.doubao.com/thread/" + sid : "";
|
|
} catch { return ""; }
|
|
}""", conv_id)
|
|
return share_data or ""
|
|
except Exception as e:
|
|
print(f" [doubao] 分享链接失败: {e}")
|
|
return ""
|
|
|
|
async def _doubao_rotate_cookie(self) -> bool:
|
|
"""换一个 cookie 池账号并重新进入聊天页。成功返回 True"""
|
|
if not self.doubao_use_cookie_pool or not self._ctx:
|
|
return False
|
|
self._drop_session("doubao", "invalid")
|
|
try:
|
|
await self._ctx.clear_cookies()
|
|
except Exception:
|
|
pass
|
|
sess = self.pool.get_for_internal("doubao")
|
|
if not sess or not sess.get("cookie"):
|
|
print(" [doubao] 换号失败: 池中无更多 cookie")
|
|
return False
|
|
try:
|
|
n = await self._inject_doubao_cookies(sess["cookie"])
|
|
self._doubao_cookie_str = sess["cookie"]
|
|
self._session_ids["doubao"] = sess.get("id")
|
|
page = self._doubao_page or self.doubao_adapter.page
|
|
await page.goto("https://www.doubao.com/chat", wait_until="domcontentloaded", timeout=60000)
|
|
await asyncio.sleep(2)
|
|
if not await self._check_doubao_login(page):
|
|
print(f" [doubao] 换号后仍未登录 id={sess.get('id')}")
|
|
self._drop_session("doubao", "invalid")
|
|
return False
|
|
await self._bind_doubao_page(page)
|
|
print(f" [doubao] 已换号 id={sess.get('id')} cookies={n}")
|
|
return True
|
|
except Exception as e:
|
|
print(f" [doubao] 换号异常: {e}")
|
|
return False
|
|
|
|
async def _collect_doubao(self, prompt, deep, task) -> Optional[dict]:
|
|
"""
|
|
豆包自动化采集 (与 run.py DoubaoAdapter 一致):
|
|
new_chat → CDP 拦截 completion → 切专家/快速 → 发送 → 等流结束 → 解析 → 分享
|
|
"""
|
|
if deep and self._doubao_dt_exhausted:
|
|
print(" ⚠ 豆包专家次数已用完, 跳过 DT 任务")
|
|
return None
|
|
if not self.doubao_adapter:
|
|
print(" ✗ 豆包 adapter 未初始化 (platform_switches.doubao 是否为 true?)")
|
|
return None
|
|
|
|
async with self._db_lock:
|
|
for attempt in range(max(1, self.doubao_max_retry + 1)):
|
|
if is_stopping():
|
|
return None
|
|
try:
|
|
print(f" [doubao] 开始采集 DT={int(deep)} attempt={attempt + 1} "
|
|
f"pool_sid={self._session_ids.get('doubao')}")
|
|
await self.doubao_adapter.new_chat()
|
|
# 登录墙检测
|
|
if not await self._check_doubao_login(self.doubao_adapter.page):
|
|
print(" ✗ 豆包未登录")
|
|
if self.doubao_cookie_rotate and attempt < self.doubao_max_retry:
|
|
if await self._doubao_rotate_cookie():
|
|
continue
|
|
return None
|
|
|
|
await self.doubao_adapter.setup_intercept()
|
|
await self.doubao_adapter.set_deep_thinking(deep)
|
|
await self.doubao_adapter.send_question(prompt)
|
|
# 自定义超时
|
|
try:
|
|
await asyncio.wait_for(
|
|
self.doubao_adapter._finished_event.wait(),
|
|
timeout=self.doubao_wait_timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
print(f" [WARN] 豆包响应超时 ({self.doubao_wait_timeout}s), 仍尝试取已捕获数据")
|
|
# 流式收尾 (与 run.py wait_done 尾部一致, 略缩短)
|
|
await asyncio.sleep(2)
|
|
for _ in range(30):
|
|
if is_stopping():
|
|
break
|
|
try:
|
|
if "/chat/" in (self.doubao_adapter.page.url or ""):
|
|
break
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(1)
|
|
await asyncio.sleep(1)
|
|
|
|
raw_body, raw_req = self.doubao_adapter.get_last_response()
|
|
if not raw_body:
|
|
print(" ✗ 豆包未捕获到 chat/completion 响应")
|
|
# 可能触发验证码或未登录
|
|
page_txt = ""
|
|
try:
|
|
page_txt = await self.doubao_adapter.page.inner_text("body", timeout=3000)
|
|
except Exception:
|
|
pass
|
|
if any(k in (page_txt or "") for k in ("验证", "滑块", "登录")):
|
|
print(" [doubao] 页面疑似验证码/登录拦截")
|
|
if self.doubao_cookie_rotate and attempt < self.doubao_max_retry:
|
|
if await self._doubao_rotate_cookie():
|
|
continue
|
|
if attempt < self.doubao_max_retry:
|
|
await asyncio.sleep(2)
|
|
continue
|
|
return None
|
|
|
|
# 原始可能很大, 先落盘再解析
|
|
if isinstance(raw_body, bytes):
|
|
raw_str = raw_body.decode("utf-8", errors="replace")
|
|
else:
|
|
raw_str = raw_body
|
|
# 简单错误关键字
|
|
if any(k in raw_str[:500] for k in ("login", "未登录", "401", "403")):
|
|
print(" ✗ 豆包响应含鉴权错误")
|
|
if self.doubao_cookie_rotate and attempt < self.doubao_max_retry:
|
|
if await self._doubao_rotate_cookie():
|
|
continue
|
|
|
|
parsed = self._parse_and_check("doubao", raw_body, raw_req, prompt, deep)
|
|
if parsed is None:
|
|
if deep:
|
|
# 可能是专家配额用完 (配额话术已在 _parse_and_check 处理)
|
|
ans_head = (raw_str or "")[:300]
|
|
if any(k in ans_head for k in ("次数", "专家", "升级", "额度")):
|
|
self._doubao_dt_exhausted = True
|
|
self._drop_session("doubao", "quota")
|
|
print(" ⚠ 豆包专家模式可能已耗尽")
|
|
if attempt < self.doubao_max_retry:
|
|
continue
|
|
return None
|
|
|
|
if self.doubao_share:
|
|
conv_id = parsed.get("conversation_id", "")
|
|
if conv_id:
|
|
share = await self._doubao_create_share(conv_id)
|
|
if share:
|
|
parsed["result"]["share_link"] = share
|
|
print(f" [doubao] 分享链接 OK")
|
|
else:
|
|
print(f" [doubao] 分享链接未生成 (conv={conv_id[:16]}...)")
|
|
return parsed
|
|
|
|
except Exception as e:
|
|
print(f" ✗ 豆包采集异常: {e}")
|
|
traceback.print_exc()
|
|
if self.doubao_cookie_rotate and attempt < self.doubao_max_retry:
|
|
if await self._doubao_rotate_cookie():
|
|
continue
|
|
return None
|
|
return None
|
|
|
|
# ------ 清理 ------
|
|
|
|
async def close(self):
|
|
try:
|
|
if self._cdp:
|
|
await self._cdp.detach()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if self._pw:
|
|
await self._pw.stop()
|
|
except Exception:
|
|
pass
|