Files
dy/backend/rpa_engine/playwright_worker.py
T
2026-08-27 18:32:03 +08:00

4302 lines
182 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_stateim_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_iddevice_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 driverIM 直连不依赖这些进程。"""
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) 页面上下文内 fetchSDK 注入 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_protectts_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_hookWorkerManager 注入),同步通知上层自动重登录:
上层用 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 内查找二维码 <img>/<canvas> 并转为 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