import os import json import asyncio import base64 import logging import time import io from datetime import datetime from typing import Awaitable, Callable, Optional from PIL import Image from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from playwright.async_api import async_playwright from models.database import AsyncSessionLocal from models.models import Account, AutoReplyRule, MessageLog, AccountProfileDetail, FollowWelcomeLog from utils.received_message_log import record_received_message from utils.log_limits import ( bound_error_log_content, bound_message_log_content, truncate_text, ) from utils.cookie_store import get_cookie_path, read_cookie_file, analyze_cookie, merge_playwright_cookies from utils import system_logger from rpa_engine.douyin_im import DouyinImService from rpa_engine.douyin_im.session import DouyinImSession from rpa_engine.douyin_im.frontier import ensure_frontier_ws from rpa_engine.douyin_im.http_client import DouyinImHttpClient from rpa_engine.douyin_im.traffic_control import get_traffic_controller from rpa_engine.credential import validate_im_session, build_im_session_from_storage from rpa_engine.device_profiles import resolve_user_agent from rpa_engine.runtime_config import ( resolve_headless, ensure_browser_display, playwright_proxy, ) from rpa_engine.egress_channels import clamp_attempts, resolve_fixed_channel logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("rpa_engine") async def _start_playwright_for_browser(headless: Optional[bool] = None): """Start the driver only after DISPLAY exists. Playwright's Node driver inherits the environment at ``start()`` time and later launches Chromium itself. Starting Xvfb after the driver therefore leaves headed Chromium without DISPLAY on Linux even though Python can see it. """ if headless is None: headless = resolve_headless(default=False) await ensure_browser_display(headless) return await async_playwright().start(), headless async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None): """统一的 Chromium 启动入口:自动处理无头/有头、虚拟显示与住宅代理。 - headless 默认由 KEFU_BROWSER_HEADLESS 决定(缺省有头,避免抖音安全 SDK 判定)。 - 有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。 - 配置 KEFU_DOUYIN_PROXY 时浏览器登录也走同一代理,与 IM 请求保持同一出口 IP。 """ if headless is None: headless = resolve_headless(default=False) await ensure_browser_display(headless) launch_kwargs: dict = {"headless": headless, "args": args} proxy = playwright_proxy() if proxy: launch_kwargs["proxy"] = proxy logger.info("浏览器将通过代理启动:%s", proxy.get("server")) return await pw.chromium.launch(**launch_kwargs) def format_error(exc: BaseException) -> str: message = str(exc).strip() if "Target page, context or browser has been closed" in message: return "浏览器窗口已关闭,请重新点击启动并保持窗口打开" if message: return message name = type(exc).__name__ if name == "CancelledError": return "RPA 任务被取消,请重新点击启动" return f"{name}:请确认 Playwright 已安装且勿关闭弹出的浏览器窗口" class DouyinWorker: def __init__( self, account_id: int, login_mode: str = "auto", *, credential_prevalidated: bool = False, relogin_hook: Optional[Callable[[int], Awaitable[None]]] = None, ): self.account_id = account_id self.login_mode = login_mode # auto | im_direct | browser self.credential_prevalidated = bool(credential_prevalidated) # 登录态失效(KICK/INVALID_REQUEST/用户未登录)时通知上层自动重登录; # 由 WorkerManager 注入,worker 自身不感知 manager,避免循环依赖。 self.relogin_hook: Optional[Callable[[int], Awaitable[None]]] = relogin_hook self.browser = None self.context = None self.page = None self.playwright = None self.is_running = False self.stopping = False self._task: asyncio.Task | None = None self._startup_ready = asyncio.Event() self._startup_error = "" self.session_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "sessions" ) os.makedirs(self.session_dir, exist_ok=True) self.cookie_path = get_cookie_path(account_id) self._conv_previews = {} # 会话名 -> 最近一条预览文本 self._replied_keys = set() # 已回复消息去重 self._last_reply_at = {} # 会话名 -> 最近一次自动回复时间戳(冷却窗口) self._reply_cooldown_override = None # 账号专属冷却秒数;None=继承全局设置 self._cooldown_cache = None # (过期时间戳, 生效冷却秒数) 短期缓存,避免每条消息查库 self._cooldown_cache_ttl = 5.0 # 冷却配置缓存有效期(秒) self._reply_delay_cache = None # (过期时间戳, 生效排队间隔秒数) self._reply_delay_cache_ttl = 5.0 self._pending_im_messages = [] # 网络监听捕获的新消息 self._message_page_ready = False self._seen_im_urls = set() self._session_api_seen = False self._startup_unread_scan_done = False self._api_conversations = [] # 从 imapi.douyin.com 解析的会话 self._api_unread_total = 0 self._captured_ws_urls: list[str] = [] self._im_conv_meta: dict[str, dict] = {} self._im_service: DouyinImService | None = None # 凭证刷新(解决 ts_sign 过期 7911):加锁 + 冷却,避免风控/重复开浏览器 self._refresh_lock = asyncio.Lock() self._last_refresh_ts = 0.0 self._refresh_cooldown = 90.0 self._user_agent: str = "" # 账号表显式配置的伪装 UA(区别于 resolve_user_agent 的默认值): # 为空表示用户没配置,应保留凭证采集时写入的真实浏览器 UA。 self._raw_user_agent: str = "" self._sec_user_id_missing_fired = False self._douyin_logged_out_reported = False # Lightweight follow-welcome configuration. Disabled accounts refresh # infrequently, so 500 idle workers do not query Account + sec_user_id # every minute merely to discover that the feature is still off. self._follow_config_lock = asyncio.Lock() self._follow_config_loaded = False self._follow_config_refresh_at = 0.0 self._follow_welcome_enabled = False self._follow_welcome_content = "" self._follow_welcome_sec_user_id = "" # 登录态保活:定时用已保存登录态访问抖音首页,触发 passport 滑动续期, # 把「30 天必失效」的 sessionid 变成「持续活跃基本不失效」。 self._keepalive_task: asyncio.Task | None = None self._keepalive_lock = asyncio.Lock() self._keepalive_last_result: str = "" async def _load_user_agent(self) -> str: """读取账号配置的伪装设备头,用于浏览器与 IM 全链路一致。""" if self._user_agent: return self._user_agent db = await self.get_db() try: result = await db.execute( select(Account.user_agent).where(Account.id == self.account_id) ) self._user_agent = resolve_user_agent(result.scalar_one_or_none()) finally: await db.close() return self._user_agent async def _load_raw_user_agent(self) -> str: """读取账号表显式配置的伪装 UA;未配置返回空串。 与 _load_user_agent 的区别:后者在账号未配置时回退到默认 Chrome/120, 而凭证采集工具/浏览器模式登录会把“真实采集浏览器”的 UA 写入 storage_state(im_session_data 的 user_agent)。若用默认 UA 去签名一套 真实浏览器(如 Chrome/148)采集的凭证,抖音安全网关会返回 7911/KICK。 """ if self._raw_user_agent: return self._raw_user_agent db = await self.get_db() raw = "" try: result = await db.execute( select(Account.user_agent).where(Account.id == self.account_id) ) raw = str(result.scalar_one_or_none() or "").strip() finally: await db.close() self._raw_user_agent = raw if raw: self._user_agent = raw return raw def _browser_context_options(self, storage_state: dict | None = None) -> dict: opts = { "user_agent": self._user_agent or resolve_user_agent(None), "viewport": {"width": 1280, "height": 800}, "locale": "zh-CN", } if storage_state: opts["storage_state"] = storage_state return opts def _should_auto_reply( self, prev_preview: str | None, preview: str, has_unread: bool, unread_count: int = 0, ) -> bool: """未读消息或预览变化时触发自动回复""" if has_unread or unread_count > 0: return True if prev_preview is None: return False return bool(preview and preview != prev_preview) async def get_db(self): return AsyncSessionLocal() def _mark_startup_ready(self) -> None: self._startup_error = "" self._startup_ready.set() def _mark_startup_failed(self, detail: str = "") -> None: if self._startup_ready.is_set(): return self._startup_error = ( str(detail or "").strip() or "托管任务在完成初始化前已退出" ) self._startup_ready.set() async def wait_until_ready(self) -> None: """Wait until IM startup completed, or raise its initialization error. Batch admission can await this signal so its concurrency limit covers UID/frontier/WS/first-poll initialization instead of only covering the creation of a detached worker task. """ await self._startup_ready.wait() if self._startup_error: raise RuntimeError(self._startup_error) async def _refresh_follow_welcome_config( self, *, force: bool = False, ) -> tuple[bool, str, str]: now = time.monotonic() if ( not force and self._follow_config_loaded and now < self._follow_config_refresh_at ): return ( self._follow_welcome_enabled, self._follow_welcome_content, self._follow_welcome_sec_user_id, ) async with self._follow_config_lock: now = time.monotonic() if ( not force and self._follow_config_loaded and now < self._follow_config_refresh_at ): return ( self._follow_welcome_enabled, self._follow_welcome_content, self._follow_welcome_sec_user_id, ) db = await self.get_db() try: row = ( await db.execute( select( Account.follow_welcome_enabled, Account.follow_welcome_content, AccountProfileDetail.sec_user_id, ) .outerjoin( AccountProfileDetail, AccountProfileDetail.account_id == Account.id, ) .where(Account.id == self.account_id) ) ).first() finally: await db.close() if row: enabled, content, sec_user_id = row self._follow_welcome_enabled = bool(enabled) self._follow_welcome_content = str(content or "").strip() self._follow_welcome_sec_user_id = str(sec_user_id or "").strip() else: self._follow_welcome_enabled = False self._follow_welcome_content = "" self._follow_welcome_sec_user_id = "" self._follow_config_loaded = True # Enabled accounts retain the old one-minute configuration # responsiveness. Disabled accounts perform only one lightweight # refresh every ten minutes instead of one full Account read/minute. ttl = 60.0 if self._follow_welcome_enabled else 600.0 self._follow_config_refresh_at = now + ttl return ( self._follow_welcome_enabled, self._follow_welcome_content, self._follow_welcome_sec_user_id, ) def invalidate_follow_welcome_config(self) -> None: """Make the next follow tick reload settings after an account edit.""" self._follow_config_loaded = False self._follow_config_refresh_at = 0.0 async def _load_sec_user_id(self) -> str: """Return the locally persisted Douyin sec_user_id for this account.""" db = await self.get_db() try: result = await db.execute( select(AccountProfileDetail.sec_user_id).where( AccountProfileDetail.account_id == self.account_id ) ) return str(result.scalar_one_or_none() or "").strip() finally: await db.close() async def _sec_user_id_is_stale(self) -> bool: """Whether the cached profile predates the currently stored Cookie.""" db = await self.get_db() try: result = await db.execute( select(Account.cookie_updated_at, AccountProfileDetail.synced_at) .outerjoin( AccountProfileDetail, AccountProfileDetail.account_id == Account.id, ) .where(Account.id == self.account_id) ) row = result.first() if not row: return False cookie_updated_at, profile_synced_at = row return bool( cookie_updated_at and ( profile_synced_at is None or cookie_updated_at > profile_synced_at ) ) finally: await db.close() async def _refresh_sec_user_id(self) -> str: """Resolve and persist sec_user_id once from the account's current Cookie.""" db = await self.get_db() try: result = await db.execute( select(Account.cookie_data, Account.user_agent).where( Account.id == self.account_id ) ) row = result.first() cookie_data = row.cookie_data if row else None user_agent = row.user_agent if row else None finally: await db.close() if not cookie_data: return "" from rpa_engine.account_profile import fetch_douyin_profile_detail_with_sec_user_id controller = get_traffic_controller() async with controller.background_slot( self.account_id, "sec_user_id profile refresh", ): detail = await fetch_douyin_profile_detail_with_sec_user_id( cookie_data, user_agent, ) sec_user_id = str(detail.get("sec_user_id") or "").strip() if not sec_user_id: if detail.get("logged_out"): # 抖音已判定登录失效:托管继续跑也收不到、发不出任何私信, # 必须显式告警,不能只当成一次「资料接口抖动」。 await self._report_douyin_logged_out(str(detail.get("message") or "")) if detail.get("sec_user_id_status") == "unknown": raise RuntimeError( detail.get("message") or "暂时无法核验 sec_user_id,请稍后重试" ) return "" db = await self.get_db() try: current_cookie = ( await db.execute( select(Account.cookie_data) .where(Account.id == self.account_id) .with_for_update() ) ).scalar_one_or_none() if current_cookie != cookie_data: raise RuntimeError( "核验 sec_user_id 期间账号 Cookie 已更新,请重新启动托管" ) result = await db.execute( select(AccountProfileDetail).where( AccountProfileDetail.account_id == self.account_id ) ) profile = result.scalar_one_or_none() if profile is None: profile = AccountProfileDetail(account_id=self.account_id) db.add(profile) profile.sec_user_id = sec_user_id profile.synced_at = datetime.utcnow() for field_name in ( "uid", "nickname", "avatar_url", "unique_id", "signature", "video_count", "follower_count", "following_count", "total_favorited", "favoriting_count", ): value = detail.get(field_name) if value is not None and value != "": setattr(profile, field_name, value) profile.sync_message = detail.get("message") or None await db.commit() logger.info( "Account %s sec_user_id refreshed and persisted", self.account_id, ) return sec_user_id except IntegrityError as exc: await db.rollback() try: current_cookie = ( await db.execute( select(Account.cookie_data) .where(Account.id == self.account_id) .with_for_update() ) ).scalar_one_or_none() if current_cookie != cookie_data: raise RuntimeError( "核验 sec_user_id 期间账号 Cookie 已更新,请重新启动托管" ) # A concurrent sync inserted the one-to-one row first. The # ID fetched from this exact current Cookie is authoritative; # overwrite the race winner instead of trusting stale data. race_values = { "sec_user_id": sec_user_id, "synced_at": datetime.utcnow(), } if detail.get("uid"): race_values["uid"] = str(detail["uid"]) await db.execute( update(AccountProfileDetail) .where(AccountProfileDetail.account_id == self.account_id) .values(**race_values) ) await db.commit() persisted = await self._load_sec_user_id() if persisted == sec_user_id: return sec_user_id except RuntimeError: await db.rollback() raise except Exception: await db.rollback() logger.warning( "Account %s failed to persist sec_user_id: %s", self.account_id, exc, ) raise except Exception as exc: await db.rollback() logger.warning( "Account %s failed to persist sec_user_id: %s", self.account_id, exc, ) raise finally: await db.close() async def _report_douyin_logged_out(self, message: str) -> None: """抖音判定登录失效时告警一次(每轮托管只报一次)。""" if self._douyin_logged_out_reported: return self._douyin_logged_out_reported = True reason = message or ( "抖音返回「用户未登录」:Cookie 仍在但服务端登录态已失效," "请停止托管后重新扫码登录该账号。" ) logger.warning("Account %s is logged out on Douyin: %s", self.account_id, reason) system_logger.record( "抖音登录态已失效,需重新扫码登录", detail=( f"{reason} 当前托管既收不到新私信,也无法发送自动回复;" "账号卡片上的「Cookie 有效」只表示本地还存着 sessionid。" ), level="error", category="auth", account_id=self.account_id, ) async def _stop_for_missing_sec_user_id(self, stage: str) -> None: """Stop hosting once when the account identity lacks sec_user_id.""" if self._sec_user_id_missing_fired: return self._sec_user_id_missing_fired = True reason = ( f"{stage}缺少 sec_user_id,托管已自动退出;" "请重新登录或在账号管理中同步资料后再启动托管" ) logger.warning("Account %s %s", self.account_id, reason) system_logger.record( "缺少 sec_user_id,托管自动退出", detail=reason, level="error", category="auth", account_id=self.account_id, ) self.stopping = True self.is_running = False if self._im_service: self._im_service._running = False await self.update_account_status("offline", error_msg=reason) async def _require_sec_user_id( self, stage: str, *, refresh_if_missing: bool = False, refresh_if_stale: bool = False, force_refresh: bool = False, ) -> str: """Return sec_user_id or stop hosting when it remains unavailable.""" sec_user_id = str(await self._load_sec_user_id() or "").strip() should_refresh = force_refresh or (not sec_user_id and refresh_if_missing) if not should_refresh and refresh_if_stale: should_refresh = await self._sec_user_id_is_stale() if should_refresh: sec_user_id = await self._refresh_sec_user_id() sec_user_id = str(sec_user_id or "").strip() if sec_user_id: return sec_user_id await self._stop_for_missing_sec_user_id(stage) return "" async def _best_effort_sec_user_id( self, *, refresh_if_missing: bool = False, refresh_if_stale: bool = False, force_refresh: bool = False, ) -> str: """Resolve sec_user_id without making optional profile data block IM.""" try: sec_user_id = str(await self._load_sec_user_id() or "").strip() should_refresh = force_refresh or ( not sec_user_id and refresh_if_missing ) if not should_refresh and refresh_if_stale: should_refresh = await self._sec_user_id_is_stale() if should_refresh: sec_user_id = str(await self._refresh_sec_user_id() or "").strip() return sec_user_id except Exception as exc: logger.warning( "Account %s could not refresh optional sec_user_id; " "IM hosting will continue: %s", self.account_id, exc, ) return "" def _is_browser_alive(self) -> bool: return bool( self.page and not self.page.is_closed() and self.context and self.browser and self.browser.is_connected() ) async def _safe_goto(self, url: str, **kwargs): if not self._is_browser_alive(): raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开") try: await self.page.goto(url, **kwargs) except Exception as e: err = str(e) if "ERR_ABORTED" in err or "NS_BINDING_ABORTED" in err: await asyncio.sleep(1) if self._is_browser_alive(): current = self.page.url or "" if "douyin.com" in current: logger.warning(f"Navigation aborted but page is usable: {current}") return raise async def _relaunch_visible_browser(self, storage_state, context_options: dict): """无头模式验证失败时,切换为有界面浏览器""" try: if self.page: await self.page.close() if self.context: await self.context.close() if self.browser: await self.browser.close() except Exception as e: logger.debug(f"Partial cleanup before relaunch: {e}") self.browser = await _launch_chromium( self.playwright, [ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-setuid-sandbox", ], ) if storage_state: self.context = await self.browser.new_context( storage_state=storage_state, **context_options, ) else: self.context = await self.browser.new_context(**context_options) await self.context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" ) self.page = await self.context.new_page() async def _load_storage_state(self): """从数据库或本地文件加载 Playwright storage_state""" db = await self.get_db() try: result = await db.execute( select(Account.cookie_data).where(Account.id == self.account_id) ) cookie_data = result.scalar_one_or_none() if cookie_data: return json.loads(cookie_data) except Exception as e: logger.warning(f"Failed to load cookie from database: {e}") finally: await db.close() file_data = read_cookie_file(self.account_id) if file_data: try: return json.loads(file_data) except Exception as e: logger.warning(f"Failed to load cookie from file: {e}") return None async def _persist_cookies(self): """登录成功或运行中将 Cookie 同步到文件和数据库(合并 HttpOnly sessionid)""" if not self.context: return storage = await self.context.storage_state() live_cookies = await self.context.cookies() storage = merge_playwright_cookies(storage, live_cookies) cookie_json = json.dumps(storage, ensure_ascii=False, indent=2) with open(self.cookie_path, "w", encoding="utf-8") as f: f.write(cookie_json) db = await self.get_db() try: await db.execute( update(Account).where(Account.id == self.account_id).values( cookie_data=cookie_json, cookie_path=self.cookie_path, cookie_updated_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) ) await db.commit() logger.info(f"Account {self.account_id} cookies persisted to database") except Exception as e: logger.error(f"Failed to persist cookies: {e}") await db.rollback() finally: await db.close() async def _build_im_session_from_storage( self, storage: dict, extra: Optional[dict] = None, ) -> DouyinImSession: db = await self.get_db() saved_im = None account_uid = "" # accounts.douyin_uid:拿 cookie 从抖音拉取的权威账号 UID profile_uid = "" profile_updated_at = None cookie_updated_at = None try: result = await db.execute( select( Account.im_session_data, Account.cookie_updated_at, Account.douyin_uid, AccountProfileDetail.uid, AccountProfileDetail.updated_at.label("profile_updated_at"), ) .outerjoin( AccountProfileDetail, AccountProfileDetail.account_id == Account.id, ) .where(Account.id == self.account_id) ) row = result.one_or_none() if row: saved_im = row.im_session_data account_uid = str(getattr(row, "douyin_uid", None) or "").strip() profile_uid = str(getattr(row, "uid", None) or "").strip() profile_updated_at = getattr(row, "profile_updated_at", None) cookie_updated_at = getattr(row, "cookie_updated_at", None) finally: await db.close() session = build_im_session_from_storage(storage or {}, saved_im) # 权威 UID 覆盖:accounts.douyin_uid 是拿当前 cookie 从抖音接口拉取后写入的 # 账号标识,最可靠,无条件覆盖。account_profile_details.uid 有串号风险 # (如账号 9 的 profile 里存了别的账号的 UID),仅在资料不早于 cookie 更新 # 时才可信(保留时间戳保护)。 # 之前对 douyin_uid 也套时间戳条件:混合登录态下 tea 解析出的 my_uid 可能 # 是 web_id(device_id != my_uid -> KICK 循环),而资料同步往往滞后于 # cookie 落库,时间戳条件会让错误 UID 一直带病运行。 verified_uid = account_uid if account_uid.isdigit() else "" if not verified_uid and profile_uid.isdigit(): profile_fresh = ( cookie_updated_at is None or ( profile_updated_at is not None and profile_updated_at >= cookie_updated_at ) ) if profile_fresh: verified_uid = profile_uid if verified_uid: verified_uid = int(verified_uid) old_uid = int(session.my_uid or 0) if old_uid and old_uid != verified_uid: logger.warning( "Account %s replaced collected IM uid %s with current " "uid %s (account.douyin_uid=%s profile.uid=%s " "profile_updated_at=%s cookie_updated_at=%s)", self.account_id, old_uid, verified_uid, account_uid, profile_uid, profile_updated_at, cookie_updated_at, ) session.my_uid = verified_uid # device_id 必须与 my_uid 指向同一账号:protobuf/frontier 的 device_id # 优先取 session.device_id(见 resolve_proto_device_id),若凭证里残留 # 旧设备号(如 www 域 web_runtime_security_uid),发送时 device_id != # my_uid 会被安全网关判为设备指纹异常 -> decision=KICK。 if str(session.device_id or "") != str(verified_uid): if session.device_id: logger.info( "Account %s synced device_id %s -> %s to match verified uid", self.account_id, session.device_id, verified_uid, ) session.device_id = str(verified_uid) # Keep the browser runtime device_id for frontier. The protobuf # sender uses the verified IM UID separately in DouyinAuth. session.uid_verified = True # UA 全链路一致原则:a_bogus 签名、IM 请求头、Protobuf body 必须与 # 凭证采集环境(storage_state.user_agent)使用同一 UA,否则安全网关 # 判定设备指纹不一致 -> 7911 / decision=KICK。 # 账号表显式配置的 UA(浏览器登录上下文用它)优先;未配置时保留 # storage_state/im_session_data 里采集写入的真实浏览器 UA, # 绝不用默认 Chrome/120 去签名一套 Chrome/148 环境采集的凭证。 raw_ua = await self._load_raw_user_agent() if raw_ua: session.user_agent = resolve_user_agent(raw_ua) else: logger.info( "Account %s: 未显式配置 UA,保留采集 UA=%s", self.account_id, session.user_agent[:60] + "…" if len(session.user_agent or "") > 60 else session.user_agent, ) if extra: # 浏览器本次真实建连的 frontier 地址必须排在缓存地址之前。 # 之前写成 "and not session.ws_urls":DB 里那条我们自己拼出来的 # frontier-im 地址永远非空,于是每次重新登录抓到的真实地址都被丢弃, # 长连接一直用推导出的 token/access_key,收不到抖音下发的私信。 if extra.get("ws_urls"): session.ws_urls = list( dict.fromkeys(list(extra["ws_urls"]) + list(session.ws_urls)) ) # 这些是浏览器实时 localStorage 读取到的“最新”签名凭证(含时效性的 ts_sign), # 必须覆盖来自 DB 的旧值,否则重新登录也刷新不了凭证,导致一直 7911。 if extra.get("keys_str"): session.keys_str = extra["keys_str"] if extra.get("web_protect_str"): session.web_protect_str = extra["web_protect_str"] if extra.get("my_uid") and not session.my_uid: session.my_uid = int(extra["my_uid"]) if extra.get("web_id") and not session.web_id: session.web_id = str(extra["web_id"]) if extra.get("device_id") and not session.device_id: session.device_id = str(extra["device_id"]) return session async def _build_im_session(self) -> DouyinImSession: storage = None if self.context: try: storage = await self.context.storage_state() except Exception as e: logger.debug(f"Live storage_state read failed: {e}") if not storage: storage = await self._load_storage_state() keys_str = "" web_protect_str = "" web_id = "" device_id = "" my_uid = 0 if self.page and not self.page.is_closed(): try: keys_str = await self.page.evaluate('localStorage["security-sdk/s_sdk_crypt_sdk"]') or "" web_protect_str = await self.page.evaluate('localStorage["security-sdk/s_sdk_sign_data_key/web_protect"]') or "" # 关键:device_id / web_id 是 IM 签名绑定的强标识,必须实时采集 raw_tokens = await self.page.evaluate('localStorage["tea_cache_tokens"]') or "" if raw_tokens: try: parsed = json.loads(raw_tokens) web_id = str(parsed.get("user_unique_id") or parsed.get("web_id") or "") except Exception: pass device_id = await self.page.evaluate('localStorage["web_runtime_security_uid"]') or "" except Exception as e: logger.warning(f"Failed to extract keys from localStorage: {e}") if self.context: try: cookies = await self.context.cookies() for cookie in cookies: if cookie.get("name") in ("uid_tt", "uid_tt_ss") and cookie.get("value"): my_uid = int(cookie.get("value")) break except Exception as e: logger.debug(f"Failed to read uid from cookies: {e}") extra = { "ws_urls": list(dict.fromkeys(self._captured_ws_urls)), "keys_str": keys_str, "web_protect_str": web_protect_str, "my_uid": my_uid, "web_id": web_id, "device_id": device_id, } return await self._build_im_session_from_storage(storage or {}, extra) async def _try_cookie_only_im_start(self, storage_state: dict) -> tuple[bool, str]: """Cookie 有效时跳过浏览器,直接 IM 直连托管""" await self._load_user_agent() im_session = await self._build_im_session_from_storage(storage_state) if self.credential_prevalidated: # Batch preparation already performed the remote credential probe. # Re-check only the immutable local requirements after rebuilding # the session, avoiding a duplicate query/user request per account. from rpa_engine.douyin_im.auth import DouyinAuth auth = DouyinAuth.from_im_session(im_session) ok = bool(im_session.can_direct_im() and auth.is_sign_ready()) reason = ( "IM 凭证已在启动队列中校验" if ok else "启动后的本地 IM 凭证不再满足直连条件" ) else: ok, reason = await validate_im_session(im_session) if not ok: logger.warning(f"IM session validation failed: {reason}") system_logger.record( "IM 直连凭证校验失败", detail=reason, level="warning", category="auth", account_id=self.account_id, ) return False, reason sec_user_id = await self._best_effort_sec_user_id( refresh_if_missing=True, refresh_if_stale=True, ) if not sec_user_id: logger.warning( "Account %s has no verified sec_user_id; continuing IM hosting " "with follow-welcome polling temporarily unavailable", self.account_id, ) logger.info( f"IM session validated for account {self.account_id} " f"(uid={im_session.my_uid}, ws={'yes' if im_session.frontier_ws_url() else 'no'})" ) await self._persist_im_session( im_session, status="online", clear_error=True, ) logger.info( f"Account {self.account_id}: cookie-only IM direct mode " f"(no browser, ws={'yes' if im_session.frontier_ws_url() else 'no'})" ) await self._run_im_direct_service(im_session) return True, "" async def _load_storage_state(self) -> dict | None: db = await self.get_db() try: result = await db.execute( select(Account.cookie_data).where(Account.id == self.account_id) ) cookie_data = result.scalar_one_or_none() if cookie_data: return json.loads(cookie_data) except Exception: pass finally: await db.close() file_data = read_cookie_file(self.account_id) if file_data: try: return json.loads(file_data) except Exception: pass return None async def _persist_im_session( self, session: DouyinImSession, *, status: str | None = None, error_msg: str | None = None, clear_error: bool = False, ): if self._im_conv_meta: session.conv_meta.update(self._im_conv_meta) payload = json.dumps(session.to_dict(), ensure_ascii=False) db = await self.get_db() try: values = { "im_session_data": payload, "updated_at": datetime.utcnow(), } if status is not None: values["status"] = status if error_msg is not None: values["error_message"] = error_msg elif clear_error: values["error_message"] = None await db.execute( update(Account).where(Account.id == self.account_id).values(**values) ) await db.commit() logger.info(f"Account {self.account_id} IM session persisted") except Exception as e: logger.error(f"Failed to persist IM session: {e}") await db.rollback() finally: await db.close() async def _harvest_im_credentials(self, timeout: int = 25): """打开私信后等待 frontier WebSocket 与 IM API 出现""" logger.info("Harvesting IM credentials from browser session...") for i in range(timeout): if any("frontier" in u for u in self._captured_ws_urls): logger.info(f"Captured frontier WS after {i}s") break if self._session_api_seen: logger.info(f"IM API seen after {i}s, waiting for WS...") await asyncio.sleep(1) if self._captured_ws_urls: logger.info(f"Captured {len(self._captured_ws_urls)} WebSocket URL(s)") else: logger.warning("No WebSocket URL captured; will rely on HTTP polling only") async def _close_browser_only(self): """关闭临时浏览器及 Playwright driver;IM 直连不依赖这些进程。""" resources = ( ("page", self.page, "close"), ("context", self.context, "close"), ("browser", self.browser, "close"), ("playwright", self.playwright, "stop"), ) for label, resource, method_name in resources: if not resource: continue try: await getattr(resource, method_name)() except Exception as e: logger.debug(f"{label} close skipped: {e}") self.page = None self.context = None self.browser = None self.playwright = None async def get_reply_delay(self) -> "int | None": """读取账号专属排队间隔;0/NULL 均表示未设置、继承系统默认。""" db = await self.get_db() try: result = await db.execute( select(Account.reply_delay_seconds).where( Account.id == self.account_id ) ) reply_delay = result.scalar_one_or_none() if reply_delay is None: return None value = max(0, int(reply_delay or 0)) return value if value > 0 else None finally: await db.close() async def resolve_reply_delay_seconds(self) -> int: """账号专属优先,否则系统默认;两处均未配置时返回 0(立即回复)。""" now = time.monotonic() if self._reply_delay_cache and self._reply_delay_cache[0] > now: return self._reply_delay_cache[1] override = None try: override = await self.get_reply_delay() except Exception as exc: logger.debug(f"resolve reply delay override failed: {exc}") if override is not None: effective = override else: try: from auth.system_settings import get_cached_settings effective = max(0, int(get_cached_settings().auto_reply_delay_seconds or 0)) except Exception: effective = 0 self._reply_delay_cache = (now + self._reply_delay_cache_ttl, effective) return effective async def get_reply_cooldown(self) -> "int | None": """读取该账号专属冷却秒数;返回 None 表示继承全局设置。""" db = await self.get_db() try: result = await db.execute( select(Account.reply_cooldown_seconds).where( Account.id == self.account_id ) ) reply_cooldown = result.scalar_one_or_none() if reply_cooldown is None: return None return max(0, int(reply_cooldown)) finally: await db.close() async def resolve_cooldown_seconds(self) -> int: """实时解析「自动回复冷却时间」:账号专属优先,否则取全局系统设置。 带 5 秒短缓存,改设置后最多 5 秒内生效,无需重启托管。""" now = time.monotonic() if self._cooldown_cache and self._cooldown_cache[0] > now: return self._cooldown_cache[1] override = None try: override = await self.get_reply_cooldown() except Exception as e: logger.debug(f"resolve cooldown override failed: {e}") if override is not None: effective = override else: try: from auth.system_settings import get_cached_settings effective = max(0, int(get_cached_settings().auto_reply_cooldown_seconds or 0)) except Exception: effective = 0 self._reply_cooldown_override = override self._cooldown_cache = (now + self._cooldown_cache_ttl, effective) return effective async def _run_im_direct_service(self, session: DouyinImSession): """运行 IM API + WebSocket 直连自动回复""" # 公网通道配置独立存于账号表。固定通道在启动时解析一次供 WS 使用; # HTTP 发送仍会在每次建连时校验,账号编辑后的配置无需重启即可生效。 row = None db = await self.get_db() try: try: row = ( await db.execute( select( Account.egress_public_ip, Account.egress_auto_attempts, ).where(Account.id == self.account_id) ) ).one_or_none() except Exception as exc: # A worker may be created by an isolated test or during a # rolling deployment before the startup migration finishes. logger.debug("load account egress config failed: %s", exc) finally: await db.close() session.egress_public_ip = str((row.egress_public_ip if row else "") or "").strip() session.egress_auto_attempts = clamp_attempts( row.egress_auto_attempts if row else 1 ) session.egress_source_ip = "" if session.egress_public_ip: try: route = await resolve_fixed_channel(session.egress_public_ip) session.egress_source_ip = str(route.source_ip or "") except Exception as exc: logger.warning( "Account %s selected egress %s is not currently resolvable: %s", self.account_id, session.egress_public_ip, exc, ) # Cache the only account fields needed by the follow-welcome timer. # Disabled accounts subsequently avoid the old full Account query on # every minute tick. await self._refresh_follow_welcome_config(force=True) reply_delay = await self.get_reply_delay() im_service = DouyinImService( session=session, match_reply=self.match_and_reply, log_fn=self.log_message, received_log_fn=self.log_received_message, account_id=self.account_id, reply_delay_seconds=reply_delay or 0, # 账号设置优先、系统默认兜底;改设置后最多 5 秒生效,无需重启托管。 reply_delay_resolver=self.resolve_reply_delay_seconds, # 关注欢迎语:周期性检测新粉丝并自动私信(约每 60s) follow_tick=self.follow_welcome_tick, # IM 登录失效(INVALID_REQUEST/KICK)时自动下线 on_session_invalid=self.on_im_session_invalid, # Batch admission waits for UID/frontier/WS/first-poll completion; # it no longer releases its slot immediately after create_task(). on_ready=self._mark_startup_ready, # 实时解析冷却时间(账号专属优先,否则全局),改设置无需重启托管 cooldown_resolver=self.resolve_cooldown_seconds, # 不在发送链路上自动开浏览器刷新:实测重载页面并不会重生 web_protect, # 反而每次失败阻塞 ~22s("反应特别慢"),且无法解决 7911 风控。 refresh_credentials=None, # 第二套发送方案:HTTP 签名发送被 KICK/7911/INVALID_REQUEST 拒绝时, # 用浏览器页面上下文重发(真实 JS 签名,可自愈被踢会话)。 send_fallback=self.send_im_via_browser_page, ) self._im_service = im_service from rpa_engine.douyin_im import hosted_registry if session.my_uid: hosted_registry.register(session.my_uid) # 托管运行期间定期活跃抖音首页,给 passport 登录态滑动续期 await self._start_keepalive() try: await im_service.run() finally: if session.my_uid: hosted_registry.unregister(session.my_uid) if self._im_service is im_service: await im_service.stop() self._im_service = None await self._stop_keepalive() # ---------- 登录态保活(keepalive) ---------- # 抖音 web 登录态(sessionid/passport)有有效期且无 refresh token 可自动换新, # 但服务端对「持续活跃」的账号做滑动续期。IM 通道(imapi + frontier WS)的活跃 # 并不刷新 passport 登录态,所以托管期间需要定期用已保存登录态打开一次抖音首页, # 让页面自带 JS 触发 passport 活跃请求,把登录态从「30 天必失效」延长为 # 「持续活跃基本不失效」。行为等同真人打开网页,风险低。 def _keepalive_interval(self) -> float: try: return max(300.0, float(os.getenv("KEFU_KEEPALIVE_INTERVAL", "21600"))) except (TypeError, ValueError): return 21600.0 def _keepalive_disabled(self) -> bool: return os.getenv("KEFU_KEEPALIVE_DISABLED", "").strip().lower() in ( "1", "true", "yes", ) @staticmethod def _cookie_expires_map(cookies: list) -> dict: """提取 passport 关键 cookie 的过期时间(epoch 秒),用于观测是否滑动续期。""" names = ( "sid_guard", "sessionid", "sessionid_ss", "sid_tt", "sid_tt_ss", "uid_tt", "uid_tt_ss", ) out: dict = {} for c in cookies or []: name = (c.get("name") or "").lower() if name in names and c.get("value"): try: exp = int(float(c.get("expires") or 0)) except (TypeError, ValueError): exp = 0 out[name] = exp if exp > 0 else 0 return out @staticmethod def _fmt_expires_map(m: dict) -> str: from datetime import datetime as _dt parts = [] for name, exp in sorted(m.items()): if exp: parts.append( f"{name}={_dt.fromtimestamp(exp).strftime('%m-%d %H:%M')}" ) else: parts.append(f"{name}=session") return ", ".join(parts) if parts else "(none)" async def _start_keepalive(self) -> None: if self._keepalive_task and not self._keepalive_task.done(): return if self._keepalive_disabled(): logger.info( f"Account {self.account_id}: keepalive disabled by KEFU_KEEPALIVE_DISABLED" ) return self._keepalive_task = asyncio.create_task( self._keepalive_loop(), name=f"douyin-keepalive-{self.account_id}", ) async def _stop_keepalive(self) -> None: task = self._keepalive_task self._keepalive_task = None if task and task is not asyncio.current_task() and not task.done(): task.cancel() try: await task except (asyncio.CancelledError, Exception): pass async def _keepalive_loop(self) -> None: """周期保活:让服务端认为账号持续活跃,滑动续期 passport 登录态。""" interval = self._keepalive_interval() logger.info( f"Account {self.account_id}: keepalive loop started " f"(every {interval / 3600:.1f}h, timeout-based, low risk)" ) while not self.stopping and self.is_running: await asyncio.sleep(interval) if self.stopping or not self.is_running: break try: ok, detail = await self._keepalive_touch() self._keepalive_last_result = detail if ok: logger.info(f"Account {self.account_id}: keepalive ok - {detail}") else: # 保活发现登录态失效:IM 通道很快也会报错并触发 # on_im_session_invalid → relogin_hook 自动重登录,这里不重复处理。 logger.warning( f"Account {self.account_id}: keepalive failed - {detail}" ) except asyncio.CancelledError: raise except Exception as exc: logger.warning( f"Account {self.account_id}: keepalive exception: {exc}" ) logger.info(f"Account {self.account_id}: keepalive loop stopped") async def _keepalive_touch(self) -> tuple[bool, str]: """打开抖音首页触发 passport 活跃续期,并重新持久化 cookie。 在全局 browser_slot 内执行,与扫码登录/凭证刷新等浏览器操作互斥, 保证同一时刻只有一个有头浏览器实例。 """ if self._keepalive_lock.locked(): return False, "上一次保活仍在进行" async with self._keepalive_lock: storage_state = await self._load_storage_state() if not storage_state: return False, "未找到已保存的登录态" before_exp = self._cookie_expires_map(storage_state.get("cookies") or []) controller = get_traffic_controller() async with controller.browser_slot(self.account_id, "keepalive"): pw = None browser = None context = None page = None saved_browser_refs = ( self.playwright, self.browser, self.context, self.page, ) try: pw, browser_headless = await _start_playwright_for_browser() import sys args = [ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-setuid-sandbox", ] if sys.platform == "win32": args.append("--start-minimized") browser = await _launch_chromium( pw, args, headless=browser_headless ) ua = self._user_agent or resolve_user_agent(None) context = await browser.new_context( storage_state=storage_state, user_agent=ua, viewport={"width": 1280, "height": 800}, locale="zh-CN", ) await context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" ) page = await context.new_page() # 临时挂到 self,复用 _has_visible_login_prompt / _persist_cookies; # browser_slot 全局串行保证不会与登录流程并发争抢这些字段。 ( self.playwright, self.browser, self.context, self.page, ) = (pw, browser, context, page) # 默认访问私信页(更贴近真实活跃,触发 IM 域请求);可用 # KEFU_KEEPALIVE_URL 覆盖,/im 异常时回退首页。 import random as _random target_url = os.getenv( "KEFU_KEEPALIVE_URL", "https://www.douyin.com/im" ) try: await page.goto( target_url, wait_until="domcontentloaded", timeout=30000, ) except Exception: await page.goto( "https://www.douyin.com/", wait_until="domcontentloaded", timeout=30000, ) # 随机停留 + 轻微滚动,避免固定机械节奏 await asyncio.sleep(_random.uniform(4, 8)) try: await page.mouse.wheel(0, 600) await asyncio.sleep(_random.uniform(0.5, 1.5)) except Exception: pass if await self._has_visible_login_prompt(): return False, ( "页面显示未登录(服务端登录态已失效,将触发自动重登录)" ) # 观测 passport cookie 是否发生滑动续期(expires 变大) after_exp = self._cookie_expires_map(await self.context.cookies()) if before_exp: renewed = [ k for k in before_exp if before_exp.get(k) and after_exp.get(k) and after_exp[k] > before_exp[k] ] logger.info( f"Account {self.account_id}: keepalive passport expires " f"before[{self._fmt_expires_map(before_exp)}] " f"after[{self._fmt_expires_map(after_exp)}] " f"renewed={','.join(renewed) or 'none'}" ) # 活跃访问后 cookie(msToken 等)可能更新,重新落库 try: await self._persist_cookies() except Exception as exc: logger.warning( f"Account {self.account_id}: keepalive persist cookies " f"failed: {exc}" ) return True, f"已访问 {target_url} 并刷新登录态" except asyncio.CancelledError: raise except Exception as exc: return False, f"保活访问失败:{exc}" finally: ( self.playwright, self.browser, self.context, self.page, ) = saved_browser_refs if page is not None: try: await page.close() except Exception: pass if context is not None: try: await context.close() except Exception: pass if browser is not None: try: await browser.close() except Exception: pass if pw is not None: try: await pw.stop() except Exception: pass async def send_im_via_browser_page( self, conversation_id: str, content: str, ) -> tuple[bool, str]: """第二套发送方案:浏览器页面上下文内重发私信。 HTTP 签名发送被抖音安全网关拒绝(decision=KICK / 7911 / INVALID_REQUEST) 时的兜底:用已保存的登录态打开抖音页面,由页面自带 security-sdk 在真实 浏览器环境里生成 a_bogus / bd-ticket-guard 并完成发送——绕开 Node execjs 的签名模拟;浏览器重新加载页面也会重建安全会话,可自愈被服务端踢掉的 登录态。仅文本/表情/卡片内容可用,图片需先走 HTTP 上传链路。 返回 (是否成功, 详情)。失败不会抛异常,只记录日志。 """ from rpa_engine.douyin_im.auth import DouyinAuth from rpa_engine.douyin_im.conv_util import normalize_conversation_id, resolve_peer_uid from rpa_engine.douyin_im.pb_decode import analyze_send_response from rpa_engine.douyin_im.proto_builder import ProtoBuilder from rpa_engine.douyin_im.reply_payload import build_msg_payload, parse_reply_content timeout = float(os.getenv("KEFU_BROWSER_SEND_TIMEOUT", "45")) async def _attempt() -> tuple[bool, str]: try: session = await self._build_im_session() except Exception as exc: return False, f"无法构建 IM 会话:{exc}" if not session.can_direct_im(): return False, "Cookie 缺失,浏览器兜底无法发送" auth = DouyinAuth.from_im_session(session) my_uid = int(session.my_uid or 0) if not my_uid: my_uid = int(auth.get_uid() or 0) if not my_uid: return False, "无法获取 my_uid" conv_id = normalize_conversation_id(conversation_id, my_uid) peer_uid = resolve_peer_uid(conv_id, my_uid) if not peer_uid: return False, "无法从会话 ID 解析对方用户 ID" # 1) 解析新鲜会话票据(unsigned 接口,发送被踢后依然可用) try: async with DouyinImHttpClient(session, account_id=self.account_id) as http: resolved_id, short_id, ticket = await http.resolve_conversation_meta( auth, conv_id, my_uid, peer_uid ) except Exception as exc: return False, f"解析会话票据失败:{exc}" if not short_id or not ticket: return False, "未拿到会话 ticket/short_id" if resolved_id: conv_id = resolved_id # 2) 构造与 HTTP 发送一致的 protobuf 报文 reply_spec = parse_reply_content(content) if reply_spec.get("type") == "image": return False, "浏览器兜底暂不支持图片回复,请改用纯文字" try: request_proto = await asyncio.to_thread( ProtoBuilder.build_send_message_request, auth, conv_id, short_id, ticket, *build_msg_payload(reply_spec), ) except Exception as exc: return False, f"构造发送报文失败:{exc}" body_b64 = base64.b64encode(request_proto.SerializeToString()).decode("ascii") s_v_web_id = session.cookies.get("s_v_web_id", "") ms_token = session.cookies.get("msToken", "") params = { "verifyFp": s_v_web_id, "fp": s_v_web_id, } if ms_token: params["msToken"] = ms_token # 3) 打开页面:让 security-sdk 加载并接管签名。与登录/凭证刷新一致, # 用非 headless + 最小化(headless 易被抖音安全 SDK 判定而生成无效签名)。 pw = None browser = None context = None page = None try: pw, browser_headless = await _start_playwright_for_browser() # 浏览器页面 UA 必须与会话发送 UA 完全一致(a_bogus 绑定 UA), # 直接使用 session.user_agent——它已被 _build_im_session 修正为 # 凭证采集环境的真实 UA,而不是账号表默认值。 context_ua = session.user_agent or resolve_user_agent(None) import sys token_args = [ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-setuid-sandbox", ] if sys.platform == "win32": token_args.append("--start-minimized") browser = await _launch_chromium( pw, token_args, headless=browser_headless, ) storage_state = await self._load_storage_state() context = await browser.new_context( storage_state=storage_state or {}, user_agent=context_ua, viewport={"width": 1280, "height": 800}, locale="zh-CN", ) await context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" ) page = await context.new_page() await page.goto( "https://www.douyin.com/message", wait_until="domcontentloaded", timeout=30000, ) # 等待安全 SDK 初始化(与 _reharvest_security_tokens 相同的轮询节奏) sdk_ready = False for _ in range(15): try: sdk_ready = bool( await page.evaluate( 'Boolean(localStorage["security-sdk/s_sdk_crypt_sdk"])' ) ) except Exception: sdk_ready = False if sdk_ready: break await asyncio.sleep(1) if not sdk_ready: return False, "页面未加载 security-sdk,无法进行真实签名发送" await asyncio.sleep(1.5) # 4) 页面上下文内 fetch:SDK 注入 a_bogus/bd-ticket-guard,携带同域 Cookie js_result = await page.evaluate( """async (args) => { const bin = atob(args.bodyB64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const qs = new URLSearchParams(args.params).toString(); const url = args.url + (qs ? '?' + qs : ''); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15000); try { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-protobuf', 'Accept': 'application/x-protobuf', 'Referer': 'https://www.douyin.com/', 'Origin': 'https://www.douyin.com', }, body: buf, credentials: 'include', signal: ctl.signal, }); const ab = await r.arrayBuffer(); const bytes = new Uint8Array(ab); let b64 = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { b64 += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return { http: r.status, bodyB64: btoa(b64) }; } catch (e) { return { error: String(e && e.message || e) }; } finally { clearTimeout(timer); } }""", { "url": "https://imapi.douyin.com/v1/message/send", "params": params, "bodyB64": body_b64, }, ) if not isinstance(js_result, dict) or js_result.get("error"): return False, f"页面内发送请求失败:{js_result}" body_bytes = base64.b64decode(js_result.get("bodyB64") or "") http_status = js_result.get("http") if not body_bytes: return False, f"页面内发送无响应体(http={http_status})" result = analyze_send_response(body_bytes) if result.get("ok"): self._im_conv_meta[conv_id] = { "conversation_short_id": short_id, "ticket": ticket, } return True, ( f"页面内发送成功 server_message_id={result.get('server_message_id')} " f"resp[{result.get('summary')}]" ) decision = str(result.get("decision") or "").strip().upper() if decision: return False, f"页面内发送仍被安全网关拒绝 decision={decision}" sc = result.get("status_code") if sc is not None and sc != 0: return False, f"页面内发送被拒绝 status_code={sc} {result.get('summary') or ''}" return False, f"页面内发送未确认投递(http={http_status} resp[{result.get('summary')}])" finally: if page is not None: try: await page.close() except Exception: pass if context is not None: try: await context.close() except Exception: pass if browser is not None: try: await browser.close() except Exception: pass if pw is not None: try: await pw.stop() except Exception: pass try: ok, detail = await asyncio.wait_for(_attempt(), timeout=timeout) except asyncio.TimeoutError: return False, f"浏览器兜底发送超时({int(timeout)}s)" except Exception as exc: return False, f"浏览器兜底发送异常:{exc}" if not ok: logger.warning( "Account %s browser fallback send failed for %s: %s", self.account_id, conversation_id, detail, ) return ok, detail async def refresh_im_credentials(self) -> bool: """后台用一次性 headless 浏览器重新采集最新 web_protect/keys(含新鲜 ts_sign), 就地更新正在运行的 IM 会话,解决 ts_sign 过期导致的发送 7911。 返回 True 表示成功刷新了 web_protect(下次发送将使用新凭证)。""" service = self._im_service if service is None: return False if self._refresh_lock.locked(): # 已有刷新在进行:等它结束后直接复用结果(避免并发开多个浏览器) async with self._refresh_lock: return time.time() - self._last_refresh_ts < self._refresh_cooldown async with self._refresh_lock: now = time.time() if now - self._last_refresh_ts < self._refresh_cooldown: logger.info( f"Account {self.account_id}: credential refresh skipped (cooldown)" ) return False storage_state = await self._load_storage_state() if not storage_state: system_logger.record( "刷新私信凭证失败", detail="未找到已保存的登录态,无法重新采集 web_protect,请重新登录", level="error", category="auth", account_id=self.account_id, ) return False system_logger.record( "开始刷新私信签名凭证", detail="检测到发送返回 7911(ts_sign 失效),后台静默重新采集 web_protect…", level="warning", category="auth", account_id=self.account_id, ) web_protect_str, keys_str = await self._reharvest_security_tokens(storage_state) if not web_protect_str: system_logger.record( "刷新私信凭证失败", detail="浏览器未能采集到 web_protect(Cookie 可能已失效,请用浏览器模式重新登录)", level="error", category="auth", account_id=self.account_id, ) return False # 就地更新运行中的会话:send_text_message 每次都读取 session.web_protect_str session = service.session session.web_protect_str = web_protect_str if keys_str: session.keys_str = keys_str self._last_refresh_ts = time.time() try: await self._persist_im_session(session) except Exception as e: logger.debug(f"persist refreshed im session failed: {e}") system_logger.record( "私信签名凭证已刷新", detail="已重新采集 web_protect(含新鲜 ts_sign),将自动重试发送", level="success", category="auth", account_id=self.account_id, ) return True async def _reharvest_security_tokens(self, storage_state: dict) -> tuple[str, str]: """打开一次性 headless 浏览器,加载已保存登录态,等待安全 SDK 写入最新 localStorage 后读取 web_protect / keys。返回 (web_protect_str, keys_str)。""" pw = None browser = None context = None page = None web_protect_str = "" keys_str = "" try: pw, browser_headless = await _start_playwright_for_browser() # 与登录流程一致使用非 headless + 最小化:headless 易被抖音安全 SDK 判定, # 可能生成无效 ts_sign,反而刷新无效。 await self._load_user_agent() import sys token_args = [ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-setuid-sandbox", ] if sys.platform == "win32": token_args.append("--start-minimized") browser = await _launch_chromium( pw, token_args, headless=browser_headless, ) context = await browser.new_context( storage_state=storage_state, user_agent=self._user_agent, viewport={"width": 1280, "height": 800}, locale="zh-CN", ) await context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" ) page = await context.new_page() await page.goto( "https://www.douyin.com", wait_until="domcontentloaded", timeout=30000, ) # 等待安全 SDK 初始化并写入新鲜 web_protect(ts_sign) for _ in range(20): try: web_protect_str = await page.evaluate( 'localStorage["security-sdk/s_sdk_sign_data_key/web_protect"]' ) or "" keys_str = await page.evaluate( 'localStorage["security-sdk/s_sdk_crypt_sdk"]' ) or "" except Exception: pass if web_protect_str and keys_str: break await asyncio.sleep(1) except Exception as e: logger.warning(f"_reharvest_security_tokens failed: {format_error(e)}") finally: for closer in ( getattr(page, "close", None), getattr(context, "close", None), getattr(browser, "close", None), getattr(pw, "stop", None), ): if closer is None: continue try: await closer() except Exception: pass return web_protect_str, keys_str async def _finalize_login_session(self): """登录成功后立即持久化 Cookie 和用户名(不等私信页)""" try: await self.page.wait_for_load_state("domcontentloaded", timeout=10000) except Exception as e: logger.debug(f"domcontentloaded wait skipped: {e}") await self._persist_cookies() username = await self.get_logged_username() avatar_url = await self.get_logged_avatar() db = await self.get_db() try: await db.execute( update(Account).where(Account.id == self.account_id).values( username=username, avatar_url=avatar_url or None, ) ) await db.commit() logger.info(f"Account {self.account_id} login saved as {username}") except Exception as e: logger.error(f"Failed to save username: {e}") await db.rollback() finally: await db.close() async def update_account_status(self, status: str, qr_code: str = None, error_msg: str = None, clear_error: bool = False): """更新数据库中账号的状态""" db = await self.get_db() try: update_data = {"status": status, "updated_at": datetime.utcnow()} if qr_code is not None: update_data["qr_code_base64"] = qr_code update_data["error_message"] = None if error_msg is not None: update_data["error_message"] = error_msg elif clear_error: update_data["error_message"] = None await db.execute( update(Account).where(Account.id == self.account_id).values(**update_data) ) await db.commit() logger.info(f"Account {self.account_id} status updated to {status}") except Exception as e: logger.error(f"Failed to update account status: {e}") await db.rollback() finally: await db.close() async def get_rules(self): """获取当前账号专属的自动回复规则(不含全局/其他账号规则)""" db = await self.get_db() try: stmt = select(AutoReplyRule).where( AutoReplyRule.account_id == self.account_id, AutoReplyRule.is_active == True, ).order_by(AutoReplyRule.sort_order.asc(), AutoReplyRule.id.asc()) result = await db.execute(stmt) rules = list(result.scalars().all()) if not rules: logger.warning(f"Account {self.account_id} has no active auto-reply rules") return rules except Exception as e: logger.error(f"Failed to fetch rules: {e}") return [] finally: await db.close() async def log_received_message( self, *, sender_name: str, sender_id: str | None = None, sender_avatar: str | None = None, raw_content: str, conversation_id: str | None = None, message_type: int | None = None, server_message_id: str | None = None, ): await record_received_message( account_id=self.account_id, raw_content=raw_content, sender_name=sender_name, sender_id=sender_id, sender_avatar=sender_avatar, conversation_id=conversation_id, message_type=message_type, server_message_id=server_message_id, ) async def log_message( self, sender_name: str, sender_id: str, message: str, reply: str = None, status: str = "received", error: str = None, sender_avatar: str = None, ): """记录消息收发日志""" db = await self.get_db() try: log = MessageLog( account_id=self.account_id, sender_name=sender_name, sender_id=sender_id, sender_avatar=sender_avatar or None, message_content=bound_message_log_content(message), reply_content=( bound_message_log_content(reply) if reply is not None else None ), status=status, error_message=( bound_error_log_content(error) if error is not None else None ), created_at=datetime.utcnow() ) db.add(log) await db.commit() logger.debug( "Logged message: sender=%s, msg=%s, reply=%s", sender_name, truncate_text(message, 300), truncate_text(reply, 300) if reply is not None else None, ) except Exception as e: logger.error(f"Failed to log message: {e}") await db.rollback() finally: await db.close() async def match_and_reply(self, message_content: str) -> list[str]: """根据消息内容匹配回复规则,返回需逐条发送的回复 payload 列表。无匹配则不回复。""" from rpa_engine.douyin_im.reply_payload import split_reply_payloads rules = await self.get_rules() if not rules: return [] fallback_reply = None text = message_content or "" for rule in rules: if rule.match_type == "exact" and text.strip() == rule.keyword.strip(): return split_reply_payloads(rule.reply_content) elif rule.match_type == "contains" and rule.keyword in text: return split_reply_payloads(rule.reply_content) elif rule.match_type == "regex": import re try: if re.search(rule.keyword, text): return split_reply_payloads(rule.reply_content) except Exception as e: logger.error(f"Regex match error: {e}") elif rule.match_type == "default" and fallback_reply is None: fallback_reply = rule.reply_content if fallback_reply: return split_reply_payloads(fallback_reply) return [] async def on_im_session_invalid(self, reason: str): """IM 登录失效时自动下线:标记账号 offline 并停止托管循环。 若配置了 relogin_hook(WorkerManager 注入),同步通知上层自动重登录: 上层用 browser 模式重新启动 worker——浏览器流程会探测页面登录态, 未登录则自动弹二维码(前端账号卡片展示),扫码成功后自动采集凭证 并恢复托管。用户只需扫码,无需手动停止/启动。 """ logger.warning(f"Account {self.account_id} IM 登录失效,自动下线:{reason}") self.stopping = True self.is_running = False try: if self._im_service: self._im_service._running = False except Exception: pass await self.update_account_status( "offline", error_msg=( f"IM 登录已失效({reason}),正在自动重登录," "请留意账号卡片上的登录二维码并扫码" ), ) if self.relogin_hook: try: await self.relogin_hook(self.account_id) except Exception as exc: logger.error( f"Account {self.account_id}: relogin_hook failed: {exc}" ) async def follow_welcome_tick(self): """检测新粉丝,给「已互相关注」且未发送过的新粉丝发送一次关注欢迎语。 - 首次运行会把现有粉丝全部「种子化」(不发送),避免给历史粉丝群发; - 之后仅对新出现且 follow_status==2(互关)的粉丝发送,每人仅一次(DB 去重,重启仍生效)。 """ if not self._im_service: return from rpa_engine.douyin_im.follower_poll import fetch_recent_followers try: enabled, content, sec_user_id = ( await self._refresh_follow_welcome_config() ) except Exception: raise if not enabled or not content: return sec_user_id = str(sec_user_id or "").strip() if not sec_user_id: sec_user_id = await self._best_effort_sec_user_id( refresh_if_missing=True, ) if not sec_user_id: logger.warning( "Account %s skipped follow-welcome polling because sec_user_id " "is temporarily unavailable", self.account_id, ) return self._follow_welcome_sec_user_id = sec_user_id # 1) 功能已启用时才读取已处理过的粉丝集合 db = await self.get_db() try: rows = ( await db.execute( select(FollowWelcomeLog.follower_uid).where( FollowWelcomeLog.account_id == self.account_id ) ) ).scalars().all() known = set(rows) first_run = len(known) == 0 finally: await db.close() controller = get_traffic_controller() async with controller.background_slot(self.account_id, "follower poll"): followers = await fetch_recent_followers( self._im_service.session, sec_user_id, count=20, ) if not followers: return # 2) 首次运行:种子化现有粉丝,不发送 if first_run: db = await self.get_db() try: for f in followers: db.add( FollowWelcomeLog( account_id=self.account_id, follower_uid=f["uid"], status="seed", detail="首次运行种子,不发送", ) ) await db.commit() logger.info( f"follow welcome: account {self.account_id} 首次运行,种子化 {len(followers)} 个现有粉丝(不发送)" ) except Exception as e: await db.rollback() logger.error(f"follow welcome seed failed: {e}") finally: await db.close() return # 3) 给「新出现 + 已互关」的粉丝发送欢迎语 my_uid = str(getattr(self._im_service.session, "my_uid", "") or "") for f in followers: uid = f["uid"] if not uid or uid in known: continue # 非互关:暂不发送、也不记录,待其与本账号互关后再触发 if f.get("follow_status") != 2: continue conv_id = f"0:1:{uid}:{my_uid}" if my_uid else uid sent = False err = "" try: sent = await self._im_service.send_message(conv_id, content) if not sent: err = self._im_service.last_error or "发送失败" except Exception as e: err = str(e) db = await self.get_db() try: db.add( FollowWelcomeLog( account_id=self.account_id, follower_uid=uid, status="sent" if sent else "failed", detail=None if sent else (err or "")[:500], ) ) await db.commit() except Exception as e: await db.rollback() logger.error(f"follow welcome log failed: {e}") finally: await db.close() known.add(uid) await self.log_message( sender_name=f.get("nickname") or uid, sender_id=uid, message="[新粉丝关注]", reply=content, status="replied" if sent else "failed", error=None if sent else err, ) logger.info( "follow welcome -> %s (uid=%s) sent=%s err=%s", f.get("nickname") or uid, uid, sent, err, ) # 轻微间隔,降低频控风险 await asyncio.sleep(1.0) async def start(self): """启动 RPA 任务""" if self._task and not self._task.done(): return self.stopping = False self.is_running = True self._startup_ready = asyncio.Event() self._startup_error = "" task = asyncio.create_task( self._run_loop(), name=f"douyin-worker-{self.account_id}", ) self._task = task def _log_task_result(done_task: asyncio.Task): try: exc = done_task.exception() if exc: logger.error(f"Worker {self.account_id} task exited with error: {format_error(exc)}") except asyncio.CancelledError: pass finally: if self._task is done_task: self._task = None task.add_done_callback(_log_task_result) async def stop(self): """停止 RPA 任务""" self.stopping = True self.is_running = False self._mark_startup_failed("托管初始化已取消") await self._stop_keepalive() if self._im_service: await self._im_service.stop() task = self._task if task and task is not asyncio.current_task() and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass if self._task is task: self._task = None await self.update_account_status("offline") await self.cleanup() async def _run_loop(self): logger.info(f"Starting worker loop for account {self.account_id}") try: storage_state = await self._load_storage_state() cookie_info = analyze_cookie( json.dumps(storage_state, ensure_ascii=False) if storage_state else None ) # The start API (including the bulk endpoint) persists "starting" # before this task is spawned. Rewriting it here caused one extra # transaction per account and amplified SQLite lock contention. if self.login_mode == "im_direct": if not storage_state: self._mark_startup_failed("未保存 Cookie,无法直连 IM") await self.update_account_status( "error", error_msg="未保存 Cookie,无法直连 IM", ) return started, reason = await self._try_cookie_only_im_start(storage_state) if started: return self._mark_startup_failed( reason or "凭证验证失败,无法直连 IM" ) if self.stopping: return await self.update_account_status( "error", error_msg=reason or "凭证验证失败,无法直连 IM。请更新 Cookie 或选择浏览器登录", ) return if self.login_mode == "browser": await self._run_browser_im_flow(storage_state, cookie_info) return # auto:先尝试 Cookie 直连,失败再浏览器(兼容旧调用) if cookie_info["cookie_valid"] and storage_state: started, _reason = await self._try_cookie_only_im_start(storage_state) if started: return if self.stopping: return logger.info( f"Account {self.account_id}: cookie-only IM failed, " "falling back to browser flow" ) await self._run_browser_im_flow(storage_state, cookie_info) except asyncio.CancelledError: logger.warning(f"Worker {self.account_id} cancelled") self._mark_startup_failed("托管初始化已取消") if not self.stopping: await self.update_account_status("offline", error_msg="RPA 任务已中断,请重新点击启动") raise except Exception as e: logger.exception(f"Error in RPA worker loop: {e}") self._mark_startup_failed(format_error(e)) if not self.stopping: await self.update_account_status("error", error_msg=format_error(e)) system_logger.record( "托管任务异常退出", detail=format_error(e), level="error", category="system", account_id=self.account_id, ) finally: self.is_running = False self._mark_startup_failed() if not self.stopping: await self.cleanup() async def _run_browser_im_flow(self, storage_state: dict | None, cookie_info: dict): """浏览器重任务全局串行;完成采集后释放通道,再运行常驻 IM。""" controller = get_traffic_controller() async with controller.browser_slot(self.account_id, "browser credential harvest"): try: prepared = await self._prepare_browser_im_flow(storage_state, cookie_info) finally: # On cancellation/error, close network-active browser resources # before another account is allowed into the single browser lane. await self._close_browser_only() if prepared is None or not self.is_running: return im_session, im_ok, im_reason = prepared # 资料接口不依赖浏览器,必须在释放全局 browser slot 后核验, # 避免资料接口波动长期占住其他账号的浏览器登录通道。 # sec_user_id 只服务于「关注欢迎语」轮询,收私信与自动回复都不需要它。 # 这里过去用 _require_sec_user_id 直接退出托管,导致资料接口拿不到 # sec_user_id 的账号浏览器登录后立刻下线、永远不会自动回复。 if not await self._best_effort_sec_user_id(force_refresh=True): logger.warning( "Account %s has no verified sec_user_id after browser login; " "continuing IM hosting with follow-welcome polling unavailable", self.account_id, ) system_logger.record( "未能核验 sec_user_id,关注欢迎语暂不可用", detail=( "私信接收与自动回复不依赖 sec_user_id,托管继续运行;" "如需「关注后自动欢迎语」,请在账号管理中同步资料。" ), level="warning", category="auth", account_id=self.account_id, ) if im_ok: await self._persist_im_session( im_session, status="online", clear_error=True, ) else: logger.warning( f"Account {self.account_id}: browser harvest done but messaging not ready: {im_reason}" ) system_logger.record( "浏览器采集完成但私信发送未就绪", detail=im_reason, level="warning", category="auth", account_id=self.account_id, ) await self._persist_im_session( im_session, status="online", error_msg=im_reason, ) logger.info( f"Account {self.account_id}: switching to IM direct mode " f"(ws={'yes' if im_session.frontier_ws_url() else 'no'})" ) await self._run_im_direct_service(im_session) async def _prepare_browser_im_flow(self, storage_state: dict | None, cookie_info: dict): """浏览器登录 + 采集 IM 凭证 + 切换直连模式(首次登录或无 Cookie 时使用)""" await self._load_user_agent() # 残缺登录态:有 sid_guard/sid_tt 但无 sessionid。此时浏览器“看起来已登录” # (能正常浏览抖音),于是永远不弹二维码,但 IM 又因缺 sessionid 拉不到会话、发不了私信。 # 直接丢弃旧登录态,用全新上下文强制重新扫码登录,拿到真正的 sessionid。 if storage_state and not cookie_info.get("has_sessionid"): logger.warning( f"Account {self.account_id}: saved cookies lack sessionid; " "discarding stale session to force a fresh QR login" ) system_logger.record( "凭证缺少 sessionid,强制重新扫码登录", detail="已保存的 Cookie 含 sid_guard/sid_tt 但无 sessionid," "浏览器会误判为已登录而不弹二维码。已丢弃旧登录态,将打开全新登录页扫码。", level="warning", category="auth", account_id=self.account_id, ) storage_state = None self.playwright, browser_headless = await _start_playwright_for_browser() import sys args = [ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-setuid-sandbox", ] if sys.platform == "win32": args.append("--start-minimized") try: self.browser = await _launch_chromium( self.playwright, args, headless=browser_headless, ) logger.info(f"Account {self.account_id}: opening browser for IM setup (minimized)") except RuntimeError: # 无虚拟显示等带操作指引的错误原样抛出,避免被通用提示覆盖 raise except Exception as e: raise RuntimeError( f"浏览器启动失败,请执行 playwright install chromium:{format_error(e)}" ) from e context_options = self._browser_context_options() if storage_state: logger.info( f"Loading saved session for account {self.account_id} " f"(static check: {cookie_info['reason']})" ) try: self.context = await self.browser.new_context( storage_state=storage_state, **context_options, ) except Exception as e: logger.warning(f"Failed to load saved cookies, creating fresh context: {e}") self.context = await self.browser.new_context(**context_options) else: logger.info("No session found. Creating new context.") self.context = await self.browser.new_context(**context_options) await self.context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" ) self.page = await self.context.new_page() is_logged_in = False if cookie_info["cookie_valid"] and await self.check_logged_in_by_cookie(): logger.info( f"Valid cookies for account {self.account_id}, skipping login page" ) await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded") await asyncio.sleep(3) is_logged_in = await self._verify_login_state() else: is_logged_in = await self._probe_existing_login() if is_logged_in: logger.info( f"Account {self.account_id} already logged in in browser, using current session" ) elif cookie_info["cookie_valid"]: logger.warning( f"Valid cookies rejected at runtime for account {self.account_id}, " "falling back to login flow" ) is_logged_in = await self._probe_existing_login() else: logger.info("Not logged in. Navigating to Douyin Homepage to login...") await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded") await asyncio.sleep(3) is_logged_in = await self._verify_login_state() if not is_logged_in: is_logged_in = await self._perform_login() if not is_logged_in: logger.warning("Login timed out or worker stopped.") await self.update_account_status( "offline", error_msg="登录超时,请在弹出的浏览器窗口完成抖音扫码登录", ) await self.cleanup() return await self._finalize_login_session() await self._setup_im_network_listener() await self._navigate_to_message_center() await self._harvest_im_credentials(timeout=25) await self._persist_cookies() im_session = await self._build_im_session() im_ok, im_reason = await validate_im_session(im_session) return im_session, im_ok, im_reason def _is_404_page(self) -> bool: # 抖音网页为单页应用,不会跳转到 /404,这里仅作占位(始终视为正常) return False async def _verify_login_state(self) -> bool: """判断账号是否已登录(不要求私信页已打开)""" # A stale sessionid can survive a server-side KICK. Page-level login # prompts are authoritative negative evidence and must win over the # mere presence of that cookie, otherwise browser refresh skips QR # login and gets stuck behind the message-center login dialog. if await self._has_visible_login_prompt(): return False if self._is_404_page(): return False if await self.check_homepage_login_status(): return True return await self.check_logged_in_by_cookie() async def _probe_existing_login(self) -> bool: """打开浏览器后从首页探测登录态""" logger.info("Probing existing login state from homepage...") try: await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded") await asyncio.sleep(3) return await self._verify_login_state() except Exception as e: logger.warning(f"Homepage probe skipped: {e}") return False async def _has_visible_login_prompt(self) -> bool: """Return True when the current page visibly asks the user to log in.""" try: login_modal = await self.page.query_selector( "#login-pannel, [class*='login-guide'], [class*='login-mask'], [class*='account_login']" ) if login_modal and await login_modal.is_visible(): return True return bool(await self.page.evaluate("""() => { const labels = new Set(['登录', '登录/注册', '立即登录']); return [...document.querySelectorAll('button, a, [role="button"], p')] .some((el) => labels.has((el.innerText || '').trim()) && el.offsetParent); }""")) except Exception as e: logger.debug(f"Visible login prompt check failed: {e}") return False async def check_homepage_login_status(self) -> bool: """检查抖音首页是否处于已登录状态""" try: # 抖音登录后右上角有头像;未登录则有醒目的「登录」按钮/登录弹窗 if await self._has_visible_login_prompt(): return False avatar = await self.page.query_selector( "header [class*='avatar'] img, header img[class*='avatar'], " "[data-e2e*='user-avatar'] img, [data-e2e='user-avatar']" ) if avatar and await avatar.is_visible(): return True # 抖音登录后导航栏会出现「私信」入口 except Exception as e: logger.debug(f"Homepage login check failed: {e}") return False async def check_login_status(self) -> bool: """检查是否已登录(综合页面元素)""" try: return await self.check_homepage_login_status() except Exception as e: logger.error(f"Error checking login status: {e}") return False DOUYIN_COOKIE_DOMAINS = ("douyin.com", "amemv.com", "snssdk.com", "iesdouyin.com") DOUYIN_LOGIN_COOKIES = { "sessionid", "sessionid_ss", "sid_tt", "sid_tt_ss", "uid_tt", "uid_tt_ss", "sid_guard", "passport_auth_status", "passport_auth_status_ss", "login_status", "odin_tt", } async def check_logged_in_by_cookie(self) -> bool: """通过抖音登录 Cookie 判断是否已登录。 必须含真实 sessionid / sessionid_ss:仅有 sid_tt / sid_guard 等残缺登录 Cookie 不算登录。否则会误判“已登录”而跳过扫码,导致二维码永远弹不出来, 且后续因缺 sessionid 根本无法发私信。 """ try: cookies = await self.context.cookies() seen_login = [] for cookie in cookies: name = (cookie.get("name") or "").lower() value = cookie.get("value") or "" if not value: continue if name in ("sessionid", "sessionid_ss"): logger.info(f"Login cookie detected: {name} @ {cookie.get('domain')}") return True if name in self.DOUYIN_LOGIN_COOKIES: seen_login.append(name) if seen_login: logger.info( "Partial login cookies present but no sessionid " f"({','.join(sorted(set(seen_login)))}); treat as NOT logged in" ) return False except Exception as e: logger.debug(f"Cookie login check failed: {e}") return False async def _perform_login(self) -> bool: """打开抖音登录页,等待用户扫码;成功后立即保存 Cookie(不依赖二维码抓取)""" logger.info("Starting Douyin login flow (browser scan)...") await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded") await asyncio.sleep(2) if await self._verify_login_state(): logger.info("Already logged in before opening login panel") await self._persist_cookies() return True await self._open_login_qr_panel() await self.update_account_status("logging_in", clear_error=True) timeout = 180 elapsed = 0 last_qr_refresh = 0 while elapsed < timeout and self.is_running: if not self._is_browser_alive(): raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开") if await self._verify_login_state(): logger.info("Douyin login detected, persisting cookies") await asyncio.sleep(1) await self._persist_cookies() return True if elapsed - last_qr_refresh >= 10: last_qr_refresh = elapsed try: qr_image = await self._capture_login_qr_image() if qr_image: await self.update_account_status( "logging_in", qr_code=qr_image, clear_error=True, ) except Exception as e: logger.debug(f"QR refresh skipped: {e}") await asyncio.sleep(2) elapsed += 2 if await self._verify_login_state(): await self._persist_cookies() return True return False async def try_message_page_access(self) -> bool: """尝试打开私信页验证是否可用""" try: if not self._is_browser_alive(): return False return await self._navigate_to_message_center() except Exception as e: logger.debug(f"Message page access check failed: {e}") return False async def wait_for_qr_login(self, qr_session: dict) -> bool: """等待用户在弹出的浏览器窗口中扫码并在抖音 App 内确认。 不自行调用抖音登录接口(避免风控签名问题), 页面自身的 JS 会完成扫码登录流程,我们只需轮询登录 Cookie 判断是否成功。 """ timeout = 180 # 3 分钟超时 elapsed = 0 last_qr_refresh = 0 logger.info("Waiting for user to scan QR code and confirm on Douyin app...") while elapsed < timeout and self.is_running: if not self._is_browser_alive(): raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开") if await self._verify_login_state(): logger.info("Login detected via cookies/page state") return True # 二维码 2 分钟会过期,定期刷新图像,保证前端展示最新二维码 if elapsed - last_qr_refresh >= 15: last_qr_refresh = elapsed await self._refresh_qr_image() await asyncio.sleep(2) elapsed += 2 return await self._verify_login_state() # 抖音登录二维码相关选择器(class 名为 hash,尽量用通用属性匹配) _QR_SELECTORS = [ # Douyin 新版登录弹窗常见结构 "[class*='qrcode'] img", "[class*='QrCode'] img", "[class*='qr-code'] img", "img[class*='qrcode']", "img[src*='qrcode']", "img[alt*='二维码']", "img[alt*='qr']", "[class*='qrcode'] canvas", "canvas[class*='qrcode']", "[class*='scan'] img", "[class*='scan'] canvas", # 登录弹窗内最可能的 img / canvas "[class*='login-guide'] img", "[class*='login-guide'] canvas", "[class*='login-panel'] img", "[class*='login-panel'] canvas", "[class*='login_pannel'] img", "[class*='login_pannel'] canvas", "[class*='account_login'] img", "[class*='account_login'] canvas", "#login-pannel img", "#login-pannel canvas", "#login-pannel [class*='qrcode']", ] _QR_CONTAINER_SELECTORS = [ "[class*='qrcode-container']", "[class*='qrcodeContainer']", "[class*='qrcode']", "[class*='QrCode']", "[class*='login-scan']", "[class*='scan-code']", "[class*='login-guide']", "[class*='login-panel']", "[class*='login_pannel']", "[class*='account_login']", "#login-pannel", ] # 兜底:抓不到二维码元素时,截取整块登录面板 / 登录 iframe,用户仍可扫描其中的码 _LOGIN_PANEL_SELECTORS = [ "#login-pannel", "[id='login-pannel']", "iframe[src*='passport']", "iframe[src*='login']", "iframe[src*='sso']", "[class*='login_panel']", "[class*='login-panel']", "[class*='login_pannel']", "[class*='account_login']", "[class*='login-guide']", "[class*='login-mask']", ] def _all_frames(self) -> list: """页面所有 frame(含登录 iframe);抖音二维码常在 passport iframe 内。""" try: return list(self.page.frames) except Exception: return [self.page] @staticmethod def _looks_like_qr_box(box: dict) -> bool: """根据尺寸/长宽比判断一个元素是否像二维码区域。""" if not box: return False w = box.get("width", 0) h = box.get("height", 0) if w < 80 or h < 80 or w > 600 or h > 600: return False ratio = min(w, h) / max(w, h) return ratio >= 0.75 async def _grab_qr_in_frames(self) -> Optional[str]: """在所有 frame 内查找二维码 / 并转为 data URL。""" for frame in self._all_frames(): for sel in self._QR_SELECTORS: try: el = await frame.query_selector(sel) except Exception: continue if not el: continue try: if not await el.is_visible(): continue box = await el.bounding_box() or {} if not self._looks_like_qr_box(box): logger.debug(f"QR selector matched but box unlikely: {sel} {box}") continue src = await el.get_attribute("src") or "" if src.startswith("data:image"): logger.info(f"Captured QR via data-src in frame: {sel}") return self._upscale_qr_image(src) if src.startswith("http"): try: resp = await self.page.request.get(src) if resp.ok: b64 = base64.b64encode(await resp.body()).decode("utf-8") logger.info(f"Captured QR via http-src in frame: {sel}") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception: pass # canvas 直接转 data URL tag = await el.evaluate("e => e.tagName.toLowerCase()") if tag == "canvas": data_url = await el.evaluate("e => e.toDataURL('image/png')") if data_url and data_url.startswith("data:image"): logger.info(f"Captured QR via canvas.toDataURL in frame: {sel}") return self._upscale_qr_image(data_url) shot = await el.screenshot(type="png") b64 = base64.b64encode(shot).decode("utf-8") logger.info(f"Captured QR via element screenshot in frame: {sel}") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception as e: if "Execution context was destroyed" in str(e): return None logger.debug(f"QR selector {sel} failed in frame: {e}") continue return None async def _grab_qr_generic_in_frames(self) -> Optional[str]: """泛化查找:在所有 frame 中找登录弹窗内最大的方型 img/canvas。""" best_el = None best_score = 0 best_frame = None login_container_sels = " ".join(self._QR_CONTAINER_SELECTORS + self._LOGIN_PANEL_SELECTORS) for frame in self._all_frames(): try: # 优先只在登录容器内查找 candidates = await frame.query_selector_all( f"{login_container_sels} img, {login_container_sels} canvas" ) if not candidates: # 兜底:扫描全页 img/canvas candidates = await frame.query_selector_all("img, canvas") for el in candidates: try: if not await el.is_visible(): continue box = await el.bounding_box() or {} if not self._looks_like_qr_box(box): continue # 优先选择长宽比接近 1:1 的 w, h = box.get("width", 0), box.get("height", 0) ratio_score = min(w, h) / max(w, h) area = w * h score = area * ratio_score if score > best_score: best_score = score best_el = el best_frame = frame except Exception: continue except Exception as e: logger.debug(f"Generic QR scan failed in frame {frame.url}: {e}") continue if not best_el: return None try: tag = await best_el.evaluate("e => e.tagName.toLowerCase()") if tag == "canvas": data_url = await best_el.evaluate("e => e.toDataURL('image/png')") if data_url and data_url.startswith("data:image"): logger.info(f"Captured QR via generic canvas.toDataURL in frame {best_frame.url[:60]}") return self._upscale_qr_image(data_url) src = await best_el.get_attribute("src") or "" if src.startswith("data:image"): logger.info("Captured QR via generic data-src") return self._upscale_qr_image(src) if src.startswith("http"): try: resp = await self.page.request.get(src) if resp.ok: b64 = base64.b64encode(await resp.body()).decode("utf-8") logger.info("Captured QR via generic http-src") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception: pass shot = await best_el.screenshot(type="png") b64 = base64.b64encode(shot).decode("utf-8") logger.info("Captured QR via generic element screenshot") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception as e: if "Execution context was destroyed" in str(e): return None logger.debug(f"Generic QR capture failed: {e}") return None async def _grab_login_panel_shot(self) -> Optional[str]: """兜底:截取登录面板 / 登录 iframe 整块(含其中的二维码),用户仍可扫描。""" # 所有 frame 都可能是登录面板(尤其是 passport iframe) for frame in self._all_frames(): for sel in self._QR_CONTAINER_SELECTORS + self._LOGIN_PANEL_SELECTORS: try: container = await frame.query_selector(sel) if not container or not await container.is_visible(): continue box = await container.bounding_box() if not box or box.get("width", 0) < 80 or box.get("height", 0) < 80: continue # 如果面板太小(只是容器),截图出来 QR 也会小,尝试放大 viewport 再截 await self._ensure_panel_fits(box) shot = await container.screenshot(type="png") b64 = base64.b64encode(shot).decode("utf-8") logger.info(f"Captured QR via panel screenshot: {sel} in frame {frame.url[:60]}") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception as e: if "Execution context was destroyed" in str(e): return None logger.debug(f"Panel screenshot {sel} failed: {e}") continue return None async def _ensure_panel_fits(self, box: dict): """如果登录面板尺寸较大,临时放大 viewport 以保证截图清晰。""" try: needed_w = int(box.get("x", 0) + box.get("width", 0) + 50) needed_h = int(box.get("y", 0) + box.get("height", 0) + 50) cur = await self.page.viewport_size() if cur and (needed_w > cur.get("width", 0) or needed_h > cur.get("height", 0)): await self.page.set_viewport_size({ "width": max(needed_w, cur.get("width", 1280)), "height": max(needed_h, cur.get("height", 900)), }) await asyncio.sleep(0.3) except Exception as e: logger.debug(f"ensure_panel_fits skipped: {e}") async def _crop_center_viewport_shot(self) -> Optional[str]: """截取视口中心区域,通常登录弹窗在此。""" try: viewport = await self.page.viewport_size() vw, vh = viewport.get("width", 1280), viewport.get("height", 900) # 中心 700x800 区域,覆盖常见登录弹窗 cw, ch = min(700, vw), min(800, vh) x = max(0, (vw - cw) // 2) y = max(0, (vh - ch) // 2) shot = await self.page.screenshot( type="png", clip={"x": x, "y": y, "width": cw, "height": ch}, timeout=15000, ) b64 = base64.b64encode(shot).decode("utf-8") logger.info(f"Captured QR via center viewport clip: {x},{y} {cw}x{ch}") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception as e: logger.debug(f"Center viewport clip failed: {e}") return None def _upscale_qr_image(self, data_url: str, min_size: int = 280) -> str: """如果二维码图像小于 min_size,使用 Pillow 放大,提高手机扫描成功率。""" try: if not data_url.startswith("data:image"): return data_url header, b64data = data_url.split(",", 1) raw = base64.b64decode(b64data) img = Image.open(io.BytesIO(raw)) w, h = img.size if w >= min_size and h >= min_size: return data_url scale = max(min_size / w, min_size / h) new_size = (int(w * scale), int(h * scale)) # 二维码用最近邻放大更锐利 upscaled = img.resize(new_size, Image.NEAREST) buf = io.BytesIO() fmt = "PNG" if "png" in header else "JPEG" upscaled.save(buf, format=fmt) new_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") logger.info(f"Upscaled QR image from {w}x{h} to {new_size[0]}x{new_size[1]}") return f"data:image/{fmt.lower()};base64,{new_b64}" except Exception as e: logger.debug(f"QR upscale failed: {e}") return data_url async def _check_and_grab_captcha(self) -> Optional[str]: """检测页面是否显示了验证码(滑块/点击等),如果显示了,则对验证码区域或整页截图。""" captcha_selectors = [ "#captcha-container", ".secsdk-captcha-drag-wrapper", "[class*='secsdk-captcha']", "[class*='captcha-modal']", "[class*='captcha_widget']", "[id*='captcha-wrapper']", "iframe[src*='captcha']", "iframe[src*='secsdk']", "[class*='verify-sub-panel']", "[class*='verify-active']", ] # 1. 遍历所有 frame 寻找可见的验证码元素并截图 for frame in self._all_frames(): for sel in captcha_selectors: try: el = await frame.query_selector(sel) if el and await el.is_visible(): box = await el.bounding_box() if box and box.get("width", 0) > 80 and box.get("height", 0) > 80: shot = await el.screenshot(type="png") b64 = base64.b64encode(shot).decode("utf-8") logger.info(f"Captured captcha element via: {sel} in frame {frame.url[:50]}") return f"data:image/png;base64,{b64}" except Exception: continue # 2. 也可以在主页面检查是否有可见的验证码 iframe 元素并直接截图 iframe try: iframes = await self.page.query_selector_all("iframe[src*='captcha'], iframe[src*='secsdk']") for iframe in iframes: if iframe and await iframe.is_visible(): box = await iframe.bounding_box() if box and box.get("width", 0) > 80 and box.get("height", 0) > 80: shot = await iframe.screenshot(type="png") b64 = base64.b64encode(shot).decode("utf-8") logger.info("Captured captcha iframe from parent page") return f"data:image/png;base64,{b64}" except Exception as e: logger.debug(f"Parent page iframe captcha screenshot failed: {e}") # 3. 针对主页面,检查是否有包含验证字样的可见覆盖层,作为兜底 try: has_visible_captcha_text = await self.page.evaluate("""() => { const text = document.body ? document.body.innerText : ''; const hasKeywords = text.includes('验证') || text.includes('安全验证') || text.includes('滑动') || text.includes('智能验证') || text.includes('验证码'); if (hasKeywords) { const divs = [...document.querySelectorAll('div')]; return divs.some(d => d.offsetParent && parseInt(window.getComputedStyle(d).zIndex) > 100); } return false; }""") if has_visible_captcha_text: # 验证码通常出现在视口中央,截取中央区域避免整页字体加载超时 center = await self._crop_center_viewport_shot() if center: logger.info("Captured center viewport screenshot due to detected captcha text") return center shot = await self.page.screenshot(type="png", timeout=15000) b64 = base64.b64encode(shot).decode("utf-8") logger.info("Captured full-page screenshot due to detected captcha text") return f"data:image/png;base64,{b64}" except Exception as e: logger.debug(f"Full-page captcha check failed: {e}") return None async def _grab_qr_data_url(self) -> Optional[str]: """统一二维码抓取:先检测验证码并截图,失败再找二维码,接着回退到登录面板 / 中心区域,最后才截整页。""" if not self._is_browser_alive(): return None # 1. 优先检测并截图验证码 captcha_img = await self._check_and_grab_captcha() if captcha_img: logger.info("QR capture: returned captcha image") return captcha_img # 2. 精确选择器获取二维码元素 qr = await self._grab_qr_in_frames() if qr: logger.info("QR capture: returned via precise selector") return qr # 3. 泛化查找登录弹窗内的方型 img/canvas generic_qr = await self._grab_qr_generic_in_frames() if generic_qr: logger.info("QR capture: returned via generic scan") return generic_qr # 4. 登录面板 / iframe 截图 panel = await self._grab_login_panel_shot() if panel: logger.info("QR capture: returned via login panel screenshot") return panel # 5. 中心区域兜底(比整页截图更聚焦,二维码不会太小) center = await self._crop_center_viewport_shot() if center: logger.info("QR capture: returned via center viewport clip") return center # 6. 终极兜底:直接截取整个网页视口 try: shot = await self.page.screenshot(type="png", timeout=15000) b64 = base64.b64encode(shot).decode("utf-8") logger.warning("QR capture: fell back to full-viewport screenshot") return self._upscale_qr_image(f"data:image/png;base64,{b64}") except Exception as e: logger.warning(f"Full-page screenshot fallback failed: {e}") return None async def _refresh_qr_image(self): """从页面重新抓取二维码图像并更新到数据库(含过期自动刷新)""" try: if not self._is_browser_alive(): return # 若二维码已过期,点击刷新区域让页面重新生成 expired = await self.page.query_selector( "[class*='expire'] *, [class*='refresh'], [class*='Refresh']" ) if expired and await expired.is_visible(): logger.info("QR code expired, refreshing...") await expired.click(force=True) await asyncio.sleep(1.5) qr = await self._grab_qr_data_url() if qr: await self.update_account_status("logging_in", qr_code=qr) except Exception as e: logger.debug(f"QR refresh skipped: {e}") async def _open_login_qr_panel(self): """打开抖音网页版扫码登录面板,并确保切换到扫码登录标签""" # 1. 抖音首页未登录时通常会自动弹出登录框;若没有则点击「登录」按钮 login_modal = await self.page.query_selector( "#login-pannel, [class*='login-guide'], [class*='account_login'], [class*='login-mask']" ) is_modal_visible = False if login_modal: try: is_modal_visible = await login_modal.is_visible() except Exception: pass if not is_modal_visible: clicked = await self.page.evaluate("""() => { const nodes = [...document.querySelectorAll('button, span, div, a, p')]; for (const el of nodes) { const t = (el.innerText || '').trim(); if ((t === '登录' || t === '登录/注册' || t === '立即登录') && el.offsetParent) { el.click(); return t; } } return ''; }""") if clicked: logger.info(f"Clicked login button: {clicked}") await asyncio.sleep(2) # 2. 无论登录框是自动弹出还是手动点击弹出的,都在所有 frame 里寻找「扫码登录」并切换 # (登录框可能渲染在 iframe 中,必须遍历所有 frame,且不能因为 modal 已经可见就提前 return) for frame in self._all_frames(): try: switched = await frame.evaluate("""() => { const nodes = [...document.querySelectorAll('span, div, a, button, p')]; let best = null; for (const el of nodes) { const t = (el.innerText || '').trim(); if ((t === '扫码登录' || t === '扫码') && el.offsetParent) { if (!best || el.children.length < best.children.length) { best = el; } } } if (best) { best.click(); return 'switched'; } return ''; }""") if switched: logger.info(f"Switched to QR login tab in frame: {frame.url[:60]}") await asyncio.sleep(1.5) break except Exception as e: logger.debug(f"Failed to check/switch to QR login in frame: {e}") async def _capture_login_qr_image(self) -> Optional[str]: """尝试从当前页面截取抖音登录二维码(跨 frame + 面板兜底)""" if not self._is_browser_alive(): return None if await self._verify_login_state(): return None return await self._grab_qr_data_url() async def get_login_qr_code(self) -> dict: """触发并截取抖音登录二维码""" qr_result = { "qr_code": None, "error": None, } try: if await self._verify_login_state(): logger.info("Browser already logged in, skip QR code") qr_result["already_logged_in"] = True return qr_result await self._open_login_qr_panel() for _ in range(40): if not self._is_browser_alive(): qr_result["error"] = "浏览器窗口已关闭" return qr_result if await self._verify_login_state(): logger.info("Login completed while capturing QR code") qr_result["already_logged_in"] = True return qr_result try: qr_image = await self._capture_login_qr_image() if qr_image: qr_result["qr_code"] = qr_image return qr_result except Exception as e: if "Execution context was destroyed" in str(e): await asyncio.sleep(1) if await self._verify_login_state(): logger.info("Login detected after page navigation") qr_result["already_logged_in"] = True return qr_result else: logger.debug(f"QR capture attempt skipped: {e}") await asyncio.sleep(0.5) if await self._verify_login_state(): logger.info("Already logged in after QR wait") qr_result["already_logged_in"] = True return qr_result logger.warning("QR code not found after waiting; will wait for browser login") qr_result["error"] = "未能获取抖音登录二维码,请在弹出的浏览器中完成扫码登录" return qr_result except Exception as e: logger.error(f"Failed to get login QR code: {e}") if await self._verify_login_state(): qr_result["already_logged_in"] = True return qr_result qr_result["error"] = format_error(e) return qr_result async def get_logged_avatar(self) -> str: """获取登录后的账号头像 URL""" try: avatar_selectors = [ ".header-user-avatar img", "[class*='user-avatar'] img", "[class*='avatar-container'] img", "header img[class*='avatar']", "[class*='avatar'] img", ] for sel in avatar_selectors: el = await self.page.query_selector(sel) if el: src = await el.get_attribute("src") if src and src.startswith("http"): return src.strip() return "" except Exception: return "" async def get_logged_username(self) -> str: """获取登录后的账号用户名""" try: nickname_selectors = [ "[class*='nickname']", "[class*='Nickname']", "[class*='user-name']", "[class*='userName']", "[class*='account-name']", ] for sel in nickname_selectors: el = await self.page.query_selector(sel) if el: name = await el.text_content() if name and name.strip(): return name.strip() return f"抖音账号_{self.account_id}" except Exception: return f"抖音账号_{self.account_id}" async def _wait_for_im_panel(self, timeout: int = 25) -> bool: """等待私信会话列表渲染完成""" for i in range(timeout): if self._session_api_seen: logger.info("IM panel ready via API session data") return True if self._api_unread_total > 0 or any( c.get("hasUnread") or c.get("unreadCount") for c in self._api_conversations ): logger.info("IM panel ready via API unread/conversations") return True if await self._im_overlay_visible(): logger.info("IM panel ready via overlay detection") return True result = await self._discover_conversations_merged() rows = result.get("rows", []) if isinstance(result, dict) else [] if rows: logger.info(f"IM panel ready: {len(rows)} conversations ({result.get('panelClass', '')[:20]})") return True if i and i % 5 == 0: diag = await self._page_diagnostics() logger.info(f"Waiting for IM panel... {diag}") await asyncio.sleep(1) logger.warning(f"IM panel not ready after {timeout}s: {await self._page_diagnostics()}") return False async def _im_overlay_visible(self) -> bool: """检测抖音网页私信侧栏/弹层是否已打开""" try: return await self.page.evaluate("""() => { const selectors = [ '[class*="im-"]', '[class*="Im"]', '[class*="message-panel"]', '[class*="chat-list"]', '[class*="conversation"]', '[class*="Conversation"]', ]; for (const sel of selectors) { const nodes = document.querySelectorAll(sel); for (const el of nodes) { if (!el.offsetParent) continue; const rect = el.getBoundingClientRect(); if (rect.width > 200 && rect.height > 200) return true; } } const text = document.body?.innerText || ''; return text.includes('发消息') && (text.includes('私信') || text.includes('会话')); }""") except Exception: return False async def _page_diagnostics(self) -> str: try: info = await self.page.evaluate("""() => ({ url: location.href, title: document.title, iframes: document.querySelectorAll('iframe').length, textLen: (document.body?.innerText || '').length, })""") return str(info) except Exception as e: return f"diag error: {e}" async def _click_message_entry(self) -> str: """在页面上点击私信/消息入口""" return await self.page.evaluate("""() => { const keywords = ['私信', '消息', '消息中心', '我的消息']; const nodes = [...document.querySelectorAll('a, button, span, div, li, [role="button"], [role="menuitem"]')]; for (const kw of keywords) { for (const el of nodes) { const t = (el.innerText || '').trim(); if (!t || t.length > 20) continue; if (t === kw || t.startsWith(kw)) { if (el.offsetParent) { el.click(); return 'text:' + kw; } } } } for (const a of document.querySelectorAll('a[href]')) { const href = (a.getAttribute('href') || '').toLowerCase(); if (!a.offsetParent) continue; if (href.includes('message') || href.includes('/im') || href.includes('inbox') || href.includes('chat')) { a.click(); return 'href:' + href; } } const icons = document.querySelectorAll('[class*="message"], [class*="Message"], [class*="im-"], [class*="IM"]'); for (const el of icons) { if (!el.offsetParent) continue; const rect = el.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) continue; if (el.closest('a, button, [role="button"]')) { (el.closest('a, button, [role="button"]') || el).click(); return 'icon:' + (el.className || '').slice(0, 40); } } return ''; }""") async def _click_user_avatar_menu(self) -> bool: """点击头像展开菜单后再找消息入口""" selectors = [ ".header-user-avatar", "[class*='user-avatar']", "[class*='UserAvatar']", "[class*='avatar-container']", "img[class*='avatar']", ] for sel in selectors: el = await self.page.query_selector(sel) if el and await el.is_visible(): await el.click(force=True) await asyncio.sleep(1.5) return True return False async def _open_message_via_direct_url(self) -> bool: """尝试直接打开抖音私信页""" for url in ( "https://www.douyin.com/?recommend=1", "https://www.douyin.com", ): await self._safe_goto(url, wait_until="domcontentloaded") await asyncio.sleep(3) try: await self.page.wait_for_load_state("networkidle", timeout=12000) except Exception: pass clicked = await self._click_message_entry() if clicked: logger.info(f"Clicked message entry: {clicked}") await asyncio.sleep(4) if await self._wait_for_im_panel(timeout=20): return True return False async def _open_message_via_homepage(self) -> bool: """从首页 UI 进入私信""" logger.info("Opening messages via homepage UI...") await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded") await asyncio.sleep(4) try: await self.page.wait_for_load_state("networkidle", timeout=15000) except Exception: pass clicked = await self._click_message_entry() if clicked: logger.info(f"Clicked message entry: {clicked}") await asyncio.sleep(4) if await self._wait_for_im_panel(timeout=20): return True if await self._click_user_avatar_menu(): clicked = await self._click_message_entry() if clicked: logger.info(f"Clicked message entry from user menu: {clicked}") await asyncio.sleep(4) if await self._wait_for_im_panel(timeout=20): return True return False async def _navigate_to_message_center(self, force: bool = False) -> bool: """进入抖音私信页""" if not self._is_browser_alive(): return False if not force and self._message_page_ready: result = await self._discover_conversations_merged() if result.get("rows") or self._session_api_seen: return True strategies = [ self._open_message_via_direct_url, self._open_message_via_homepage, ] for strategy in strategies: try: if await strategy(): self._message_page_ready = True logger.info(f"Message center ready at {self.page.url}") return True except Exception as e: logger.warning(f"Message navigation failed: {format_error(e)}") self._message_page_ready = False logger.error(f"All message navigation strategies failed: {await self._page_diagnostics()}") return False async def _ensure_message_page(self, force: bool = False) -> bool: """确保私信页可用""" if self._is_404_page(): force = True if not force and self._message_page_ready: result = await self._discover_conversations_merged() if result.get("rows"): return True return await self._navigate_to_message_center(force=force) def _make_reply_key(self, sender: str, content: str) -> str: return f"{sender}::{content}" def _sender_in_cooldown(self, sender: str, cooldown: int) -> bool: if cooldown <= 0 or not sender: return False last = self._last_reply_at.get(sender) if last is None: return False return (time.monotonic() - last) < cooldown async def _setup_im_network_listener(self): """监听 IM 相关网络请求与 WebSocket,辅助捕获新消息""" async def on_response(response): url = response.url lower = url.lower() if not any(d in lower for d in ("douyin.com", "amemv.com", "snssdk.com")): return if not any( k in lower for k in ("im", "message", "chat", "session", "conversation", "private", "inbox", "stranger", "notice", "/v1/", "/v2/") ): return if url not in self._seen_im_urls: self._seen_im_urls.add(url) logger.info(f"IM API: {url[:120]}") try: ct = response.headers.get("content-type", "") if response.status != 200: return if "imapi.douyin.com" in lower: body = await response.body() if body: from rpa_engine.douyin_im.im_proto import extract_conv_meta_from_response_bytes meta = extract_conv_meta_from_response_bytes(body) if meta: self._im_conv_meta.update(meta) logger.info(f"Captured IM conv meta from API: {len(meta)} conversation(s)") if "json" not in ct: return data = await response.json() if "imapi.douyin.com" in lower: self._parse_douyin_imapi(url, data) self._extract_messages_from_api(data) self._extract_conversations_from_api(data) except Exception: pass self.page.on("response", on_response) def on_websocket(ws): lower = ws.url.lower() if not any(k in lower for k in ("im", "message", "chat", "frontier", "ws", "imapi")): return if ws.url not in self._captured_ws_urls: self._captured_ws_urls.append(ws.url) logger.info(f"IM WebSocket: {ws.url[:120]}") def on_frame(payload): asyncio.create_task(self._handle_ws_frame(payload)) ws.on("framereceived", on_frame) self.page.on("websocket", on_websocket) async def _handle_ws_frame(self, payload): try: from rpa_engine.douyin_im.protocol import parse_ws_payload from rpa_engine.douyin_im.message_content import serialize_message_content if isinstance(payload, bytes): items = parse_ws_payload(payload) for item in items: content = item.get("content") or "" if not content: continue self._pending_im_messages.append({ "sender": item.get("sender_name") or item.get("sender_uid") or "未知用户", "content": content, "conversation_id": item.get("conversation_id") or "", }) return text = str(payload).strip() if not text or text[0] not in "{[": return data = json.loads(text) self._extract_messages_from_api(data) self._extract_conversations_from_api(data) except Exception: pass def _extract_messages_from_api(self, data, depth=0, sender_hint: str = ""): """从 IM API / WebSocket 响应中递归提取消息(含图片/表情)。""" from rpa_engine.douyin_im.message_content import parse_incoming_message if depth > 8: return if isinstance(data, dict): sender = ( data.get("senderName") or data.get("sender_name") or data.get("userName") or data.get("nickname") or data.get("fromUserName") or data.get("peerName") or sender_hint or "" ) from_self = data.get("fromSelf") or data.get("isSelf") or data.get("self") content = parse_incoming_message(data) if content and len(content) < 2000 and not from_self: self._pending_im_messages.append({ "sender": str(sender or "未知用户"), "content": content, }) for v in data.values(): self._extract_messages_from_api(v, depth + 1, str(sender or sender_hint)) elif isinstance(data, list): for item in data: self._extract_messages_from_api(item, depth + 1, sender_hint) def _extract_conversations_from_api(self, data, depth=0): """从会话列表 API 中提取预览变化""" if depth > 8: return if isinstance(data, dict): name = ( data.get("userName") or data.get("nickname") or data.get("peerName") or data.get("sessionName") or data.get("name") ) preview = ( data.get("lastMessage") or data.get("lastMsg") or data.get("preview") or data.get("brief") or data.get("content") ) if isinstance(preview, dict): preview = preview.get("text") or preview.get("content") or preview.get("message") unread_raw = data.get("unreadCount") or data.get("unread") or data.get("hasUnread") unread_count = 0 if isinstance(unread_raw, bool): has_unread = unread_raw else: try: unread_count = int(unread_raw or 0) has_unread = unread_count > 0 except (TypeError, ValueError): has_unread = bool(unread_raw) if isinstance(name, str) and name.strip() and isinstance(preview, str) and preview.strip(): sender = name.strip() content = preview.strip() prev = self._conv_previews.get(sender) if self._should_auto_reply(prev, content, has_unread, unread_count): self._pending_im_messages.append({ "sender": sender, "content": content, "_from_session_list": True, "_unread": has_unread or unread_count > 0, }) if prev is None: self._conv_previews[sender] = content self._session_api_seen = True for v in data.values(): self._extract_conversations_from_api(v, depth + 1) elif isinstance(data, list): for item in data: self._extract_conversations_from_api(item, depth + 1) def _extract_text_content(self, value) -> str: from rpa_engine.douyin_im.message_content import parse_incoming_message if value is None: return "" if isinstance(value, dict): parsed = parse_incoming_message(value) if parsed: return parsed for key in ("text", "content", "message", "msg", "title", "desc"): text = value.get(key) if isinstance(text, str) and text.strip(): return text.strip() if isinstance(value, str): return value.strip() return "" def _extract_conversation_name(self, data: dict) -> str: for key in ("nick_name", "nickname", "userName", "name", "sec_name", "remark_name"): val = data.get(key) if isinstance(val, str) and val.strip(): return val.strip() for nested_key in ("core_info", "conversation_core_info", "user_info", "peer_info", "target_user"): nested = data.get(nested_key) if isinstance(nested, dict): name = self._extract_conversation_name(nested) if name: return name return "" def _upsert_api_conversation(self, name: str, preview: str, unread_count: int): name = (name or "").strip() if not name: return preview = (preview or "").strip() unread_count = max(0, int(unread_count or 0)) for idx, conv in enumerate(self._api_conversations): if conv.get("name") == name: self._api_conversations[idx] = { "index": idx, "name": name, "preview": preview or conv.get("preview", ""), "hasUnread": unread_count > 0, "unreadCount": unread_count, } self._session_api_seen = True return self._api_conversations.append({ "index": len(self._api_conversations), "name": name, "preview": preview, "hasUnread": unread_count > 0, "unreadCount": unread_count, }) self._session_api_seen = True def _ingest_douyin_conversation_node(self, data: dict): if not isinstance(data, dict): return unread_raw = ( data.get("unread_count") or data.get("unread_cnt") or data.get("unreadCount") or data.get("badge_count") or 0 ) try: unread_count = int(unread_raw or 0) except (TypeError, ValueError): unread_count = 0 name = self._extract_conversation_name(data) preview = "" for msg_key in ("last_message", "latest_message", "last_msg", "lastMessage", "preview", "brief"): preview = self._extract_text_content(data.get(msg_key)) if preview: break if name and (preview or unread_count > 0): self._upsert_api_conversation(name, preview, unread_count) if unread_count > 0 or self._should_auto_reply( self._conv_previews.get(name), preview, unread_count > 0, unread_count ): self._pending_im_messages.append({ "sender": name, "content": preview or "[未读消息]", "_from_session_list": True, "_unread": unread_count > 0, }) def _ingest_douyin_conversations(self, data, depth: int = 0): if depth > 12: return if isinstance(data, dict): keys = set(data.keys()) if keys & { "unread_count", "unread_cnt", "unreadCount", "conversation_id", "conversation_short_id", "core_info", "conversation_core_info", }: self._ingest_douyin_conversation_node(data) for value in data.values(): self._ingest_douyin_conversations(value, depth + 1) elif isinstance(data, list): for item in data: self._ingest_douyin_conversations(item, depth + 1) def _parse_douyin_imapi(self, url: str, data): lower = url.lower() if "imapi.douyin.com" not in lower: return if any(k in lower for k in ("conversation/list", "get_conversation_list", "stranger/get_conversation")): before = len(self._api_conversations) self._ingest_douyin_conversations(data) added = len(self._api_conversations) - before if added or self._api_conversations: logger.info( f"Parsed Douyin conversations from API: total={len(self._api_conversations)}, new={added}" ) elif "unread_count" in lower and isinstance(data, dict): total = 0 for key in ("total_unread", "unread_count", "unread_total", "count"): try: total = max(total, int(data.get(key) or 0)) except (TypeError, ValueError): pass for key, val in data.items(): if "unread" in str(key).lower(): try: total = max(total, int(val or 0)) except (TypeError, ValueError): pass if total > 0: self._api_unread_total = total logger.info(f"Douyin unread total from API: {total}") elif any(k in lower for k in ("get_message", "get_user_message", "message/get")): self._ingest_douyin_conversations(data) self._extract_messages_from_api(data) async def _discover_conversations_merged(self) -> dict: """合并 DOM 与 IM API 会话列表""" dom_result = await self._discover_conversations() dom_rows = dom_result.get("rows", []) if isinstance(dom_result, dict) else [] if dom_rows: return dom_result if self._api_conversations: return { "rows": list(self._api_conversations), "panelClass": "api", } return dom_result async def _discover_conversations(self) -> dict: """弹性探测左侧会话列表(含 iframe)""" return await self.page.evaluate("""() => { function collectRows(root) { const rows = []; const panelSelectors = [ '[class*="session-list"]', '[class*="SessionList"]', '[class*="conversation-list"]', '[class*="ConversationList"]', '[class*="chat-list"]', '[class*="message-list"]', '[class*="im-list"]', '[class*="IMList"]', '[class*="inbox"]', '[class*="Inbox"]', '[class*="dialog-list"]', '[class*="DialogList"]', ]; let panel = null; for (const sel of panelSelectors) { panel = root.querySelector(sel); if (panel) break; } if (!panel) { const candidates = root.querySelectorAll('[class*="left"], [class*="side"], [class*="list"]'); for (const c of candidates) { if (c.querySelectorAll('li, [role="listitem"]').length >= 1) { panel = c; break; } } } if (!panel) return { rows: [], panelClass: '' }; const itemSelectors = [ '[class*="session"]', '[class*="Session"]', '[class*="conversation"]', '[class*="Conversation"]', '[class*="chat-item"]', '[class*="ChatItem"]', '[class*="message-item"]', '[class*="MessageItem"]', '[class*="dialog-item"]', '[class*="DialogItem"]', 'li', '[role="listitem"]', ]; const seen = new Set(); for (const sel of itemSelectors) { panel.querySelectorAll(sel).forEach((el) => { if (!el.offsetParent || seen.has(el)) return; const text = (el.innerText || '').trim(); if (!text || text.length > 300 || text.length < 2) return; if (/^私信$|^消息$|^登录$|^搜索/.test(text)) return; seen.add(el); const lines = text.split('\\n').map(s => s.trim()).filter(Boolean); const name = lines[0] || '未知用户'; const preview = lines.length > 1 ? lines[lines.length - 1] : ''; const cls = (el.className || '') + ' ' + (el.getAttribute('class') || ''); let hasUnread = /unread|badge|dot|new/i.test(cls) || !!el.querySelector('[class*="unread"],[class*="badge"],[class*="dot"],[class*="new"],[class*="count"]'); let unreadCount = 0; const badge = el.querySelector('[class*="unread"],[class*="badge"],[class*="dot"],[class*="count"],[class*="num"]'); if (badge) { const n = parseInt((badge.innerText || '').trim(), 10); if (!isNaN(n) && n > 0) { unreadCount = n; hasUnread = true; } } rows.push({ index: rows.length, name, preview, hasUnread, unreadCount }); }); } return { rows, panelClass: panel.className || '' }; } let result = collectRows(document); if (!result.rows.length) { for (const frame of document.querySelectorAll('iframe')) { try { const doc = frame.contentDocument; if (!doc) continue; result = collectRows(doc); if (result.rows.length) { result.panelClass = 'iframe:' + (result.panelClass || ''); break; } } catch (e) {} } } return result; }""") async def _click_conversation(self, index: int): """点击指定索引的会话项""" await self.page.evaluate("""(idx) => { const panelSelectors = [ '[class*="session-list"]', '[class*="SessionList"]', '[class*="conversation-list"]', '[class*="chat-list"]', '[class*="message-list"]', '[class*="im-list"]', ]; let panel = null; for (const sel of panelSelectors) { panel = document.querySelector(sel); if (panel) break; } if (!panel) panel = document.querySelector('[class*="left"], [class*="side"], [class*="list"]'); if (!panel) return false; const itemSelectors = [ '[class*="session"]', '[class*="conversation"]', '[class*="chat-item"]', '[class*="message-item"]', 'li', '[role="listitem"]', ]; const items = []; const seen = new Set(); for (const sel of itemSelectors) { panel.querySelectorAll(sel).forEach((el) => { if (!el.offsetParent || seen.has(el)) return; const text = (el.innerText || '').trim(); if (!text || text.length > 300 || text.length < 2) return; seen.add(el); items.push(el); }); } if (items[idx]) { items[idx].click(); return true; } return false; }""", index) async def _extract_last_incoming_message(self) -> str: """从当前打开的聊天窗口提取最后一条对方消息(含图片/表情 URL)。""" return await self.page.evaluate("""() => { function mediaFromNode(n) { const img = n.querySelector('img'); if (img && img.src && img.src.startsWith('http')) { const w = img.naturalWidth || img.width || null; const h = img.naturalHeight || img.height || null; const alt = (img.alt || '').trim(); const cls = ((n.className || '') + ' ' + (img.className || '')).toLowerCase(); const isSticker = /sticker|emoji|emot|gif|表情/.test(cls + alt); const payload = { type: isSticker ? 'sticker' : 'image', text: isSticker ? '[表情包]' : '[图片]', url: img.src }; if (w) payload.width = w; if (h) payload.height = h; if (alt) payload.name = alt; return JSON.stringify(payload); } const audio = n.querySelector('audio'); if (audio && audio.src && audio.src.startsWith('http')) { return JSON.stringify({ type: 'voice', text: '[语音]', url: audio.src }); } const video = n.querySelector('video'); if (video && video.src && video.src.startsWith('http')) { return JSON.stringify({ type: 'video', text: '[视频]', url: video.src }); } return ''; } const panelSelectors = [ '[class*="message-list"]', '[class*="MessageList"]', '[class*="chat-content"]', '[class*="ChatContent"]', '[class*="msg-list"]', '[class*="dialog"]', '[class*="im-chat"]', '[id*="message"]', ]; let panel = null; for (const sel of panelSelectors) { const els = document.querySelectorAll(sel); for (const el of els) { if (el.offsetParent && el.querySelector('[class*="msg"], [class*="message"], pre, img')) { panel = el; break; } } if (panel) break; } if (!panel) panel = document.body; const candidates = panel.querySelectorAll( '[class*="message"], [class*="msg"], [class*="bubble"], pre, span, div' ); const msgs = []; candidates.forEach((n) => { const media = mediaFromNode(n); const t = media || (n.innerText || '').trim(); if (!t || t.length > 2000) return; let cls = (n.className || '') + ' ' + (n.parentElement?.className || ''); const style = window.getComputedStyle(n); const isSelf = /self|mine|right|send|outgoing|owner/i.test(cls) || style.textAlign === 'right' || style.justifyContent === 'flex-end' || n.closest('[class*="self"],[class*="mine"],[class*="right"],[class*="send"]'); msgs.push({ text: t, isSelf: !!isSelf }); }); for (let i = msgs.length - 1; i >= 0; i--) { if (!msgs[i].isSelf) return msgs[i].text; } return ''; }""") async def _send_chat_reply(self, text: str) -> bool: """在聊天输入框输入并发送回复""" focused = await self.page.evaluate("""() => { function findInput(root) { const inputs = [ ...root.querySelectorAll('textarea'), ...root.querySelectorAll('[contenteditable="true"]'), ...root.querySelectorAll('[class*="editor"]'), ...root.querySelectorAll('[class*="input"]'), ]; for (const el of inputs) { if (!el.offsetParent) continue; const rect = el.getBoundingClientRect(); if (rect.width < 50) continue; el.focus(); el.click(); return true; } return false; } if (findInput(document)) return true; for (const frame of document.querySelectorAll('iframe')) { try { if (frame.contentDocument && findInput(frame.contentDocument)) return true; } catch (e) {} } return false; }""") if not focused: return False await self.page.keyboard.press("Control+A") await self.page.keyboard.press("Backspace") await self.page.keyboard.type(text, delay=30) await asyncio.sleep(0.4) sent = await self.page.evaluate("""() => { const all = [...document.querySelectorAll('button, [role="button"], span, div, a')]; for (const el of all) { const t = (el.innerText || '').trim(); if (t === '发送' && el.offsetParent) { el.click(); return true; } } const sendEl = document.querySelector('[class*="send"], [class*="Send"]'); if (sendEl && sendEl.offsetParent) { sendEl.click(); return true; } return false; }""") if not sent: await self.page.keyboard.press("Enter") await asyncio.sleep(1) return True async def _process_conversation_reply(self, conv: dict) -> bool: """打开会话并对未读/新消息自动回复""" name = conv.get("name", "未知用户") preview = conv.get("preview", "") has_unread = conv.get("hasUnread", False) unread_count = conv.get("unreadCount", 0) logger.info( f"Processing conversation [{name}]: preview={preview!r}, " f"unread={has_unread}, count={unread_count}" ) clicked = await self._click_conversation(conv["index"]) if not clicked: clicked = await self._click_conversation_by_name(name) if not clicked: logger.warning(f"Failed to open conversation [{name}]") return False await asyncio.sleep(1.5) last_msg = await self._extract_last_incoming_message() if not last_msg: last_msg = preview if not last_msg: logger.warning(f"No message content for [{name}]") return False await self._handle_incoming_message(name, last_msg) if preview: self._conv_previews[name] = preview return True async def _reply_all_unread_conversations(self, rows: list) -> int: """回复所有未读会话""" unread_rows = [ r for r in rows if r.get("hasUnread") or (r.get("unreadCount") or 0) > 0 ] if not unread_rows: return 0 logger.info(f"Found {len(unread_rows)} unread conversations, auto-replying...") replied = 0 for conv in unread_rows: if await self._process_conversation_reply(conv): replied += 1 await asyncio.sleep(0.8) return replied async def _handle_incoming_message(self, sender_name: str, message_content: str): """处理一条收到的消息:匹配规则并自动回复""" if not message_content: return await self.log_received_message( sender_name=sender_name, raw_content=message_content, ) reply_key = self._make_reply_key(sender_name, message_content) if reply_key in self._replied_keys: return replies = await self.match_and_reply(message_content) if not replies: replies = await self.match_and_reply("") if not replies: await self.log_message( sender_name=sender_name, sender_id=None, message=message_content, reply=None, status="ignored", error="未配置任何自动回复规则,请在「自动回复规则」中添加至少一条启用规则", ) self._replied_keys.add(reply_key) return # 冷却窗口:同一用户在设定时间内,无论发多少条消息,只自动回复一次(账号设置优先,否则全局) cooldown = await self.resolve_cooldown_seconds() if self._sender_in_cooldown(sender_name, cooldown): logger.info( f"Auto-reply to {sender_name} skipped: within {cooldown}s cooldown window" ) self._replied_keys.add(reply_key) return # 进入回复流程前先打时间戳,确保发送期间到达的消息也被抑制 if cooldown > 0: self._last_reply_at[sender_name] = time.monotonic() from rpa_engine.douyin_im.reply_payload import format_reply_display reply_displays: list[str] = [] sent_any = False last_error = "" for index, reply_content in enumerate(replies): if index > 0: await asyncio.sleep(0.6) reply_display = format_reply_display(reply_content) reply_displays.append(reply_display) logger.info( f"Replying to {sender_name} ({index + 1}/{len(replies)}): " f"{message_content!r} -> {reply_display!r}" ) # 浏览器模式仅支持在输入框键入文本,网址/卡片降级为可读摘要发送 sent = await self._send_chat_reply(reply_display) if sent: sent_any = True else: last_error = "找不到输入框或发送按钮" combined_display = " | ".join(reply_displays) if sent_any: self._replied_keys.add(reply_key) await self.log_message( sender_name=sender_name, sender_id=None, message=message_content, reply=combined_display, status="replied", ) else: # 发送彻底失败:清除冷却时间戳,避免把没收到回复的用户锁在冷却窗口内 if cooldown > 0: self._last_reply_at.pop(sender_name, None) await self.log_message( sender_name=sender_name, sender_id=None, message=message_content, reply=combined_display, status="failed", error=last_error or "找不到输入框或发送按钮", ) system_logger.record( "自动回复失败(浏览器模式)", detail=f"回复 {sender_name} 失败:{last_error or '找不到输入框或发送按钮'}(收到:{message_content})", level="error", category="send", account_id=self.account_id, ) async def message_monitor_loop(self): """消息监听与自动回复主循环(DOM + 网络 + WebSocket)""" logger.info("Entering message monitor loop...") await self._setup_im_network_listener() if not await self._ensure_message_page(): logger.warning("Message page not ready, will retry in monitor loop") else: result = await self._discover_conversations_merged() rows = result.get("rows", []) if isinstance(result, dict) else [] if rows: count = await self._reply_all_unread_conversations(rows) logger.info(f"Startup unread auto-reply done: {count} conversations processed") self._startup_unread_scan_done = True elif self._api_unread_total > 0: logger.info( f"Startup: API reports {self._api_unread_total} unread, waiting for conversation list..." ) loop_count = 0 while self.is_running: if not self._is_browser_alive(): logger.warning(f"Browser closed for account {self.account_id}, stopping monitor") await self.update_account_status("offline", error_msg="浏览器窗口已关闭,托管已停止") self.is_running = False break try: loop_count += 1 if loop_count % 12 == 0: await self._persist_cookies() if not self._message_page_ready or loop_count % 30 == 1: await self._ensure_message_page(force=loop_count % 30 == 1) # 处理网络 / WebSocket 捕获的消息 pending = self._pending_im_messages[:] self._pending_im_messages.clear() for msg in pending: sender = msg.get("sender", "未知用户") content = msg.get("content", "") is_unread = msg.get("_unread", False) prev = self._conv_previews.get(sender) if not self._should_auto_reply(prev, content, is_unread): if prev is None and content: self._conv_previews[sender] = content continue await self._click_conversation_by_name(sender) await asyncio.sleep(1.2) last_msg = await self._extract_last_incoming_message() or content await self._handle_incoming_message(sender, last_msg) if content: self._conv_previews[sender] = content # DOM / API 探测会话列表 result = await self._discover_conversations_merged() rows = result.get("rows", []) if isinstance(result, dict) else [] if loop_count % 6 == 1: logger.info( f"Monitor tick #{loop_count}: found {len(rows)} conversations, " f"api={len(self._api_conversations)}, unread_api={self._api_unread_total}, " f"page_ready={self._message_page_ready}, pending_ws={len(pending)}" + (f", panel={result.get('panelClass', '')[:40]}" if rows else "") ) if not rows: logger.info(f"Page state: {await self._page_diagnostics()}") # 每轮优先处理所有未读会话 unread_rows = [ r for r in rows if r.get("hasUnread") or (r.get("unreadCount") or 0) > 0 ] if unread_rows: await self._reply_all_unread_conversations(unread_rows) for conv in rows: name = conv.get("name", "未知用户") preview = conv.get("preview", "") has_unread = conv.get("hasUnread", False) unread_count = conv.get("unreadCount", 0) prev_preview = self._conv_previews.get(name) if has_unread or unread_count > 0: continue if not self._should_auto_reply(prev_preview, preview, False, 0): if prev_preview is None and preview: self._conv_previews[name] = preview continue await self._process_conversation_reply(conv) await asyncio.sleep(5) except Exception as e: err = format_error(e) logger.error(f"Error in message monitor loop: {err}") if "浏览器窗口已关闭" in err or "has been closed" in str(e): await self.update_account_status("offline", error_msg=err) self.is_running = False break await asyncio.sleep(10) async def _click_conversation_by_name(self, name: str) -> bool: """按名称点击会话(含 iframe)""" try: clicked = await self.page.evaluate("""(targetName) => { function tryClick(root) { const selectors = [ 'li', '[role="listitem"]', '[class*="session"]', '[class*="conversation"]', '[class*="chat-item"]', '[class*="dialog"]', ]; for (const sel of selectors) { for (const el of root.querySelectorAll(sel)) { const text = (el.innerText || '').trim(); if (!text || !el.offsetParent) continue; const firstLine = text.split('\\n')[0].trim(); if (firstLine === targetName || text.startsWith(targetName)) { el.click(); return true; } } } return false; } if (tryClick(document)) return true; for (const frame of document.querySelectorAll('iframe')) { try { if (frame.contentDocument && tryClick(frame.contentDocument)) return true; } catch (e) {} } return false; }""", name) return bool(clicked) except Exception as e: logger.debug(f"Click conversation by name failed: {e}") return False async def cleanup(self): """释放资源""" logger.info(f"Cleaning up worker {self.account_id}") if self._im_service: try: await self._im_service.stop() except Exception: pass self._im_service = None try: if self.page: await self.page.close() if self.context: await self.context.close() if self.browser: await self.browser.close() if self.playwright: await self.playwright.stop() except Exception as e: logger.error(f"Error during cleanup: {e}") finally: self.page = None self.context = None self.browser = None self.playwright = None