Files
dy/backend/rpa_engine/playwright_worker.py
T
2026-07-28 15:04:17 +08:00

3323 lines
137 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
from datetime import datetime
from typing import Optional
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,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("rpa_engine")
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,
):
self.account_id = account_id
self.login_mode = login_mode # auto | im_direct | browser
self.credential_prevalidated = bool(credential_prevalidated)
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 = ""
self._sec_user_id_missing_fired = 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 = ""
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
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("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 _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 ""
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
try:
result = await db.execute(
select(Account.im_session_data).where(Account.id == self.account_id)
)
saved_im = result.scalar_one_or_none()
finally:
await db.close()
session = build_im_session_from_storage(storage or {}, saved_im)
ua = await self._load_user_agent()
session.user_agent = ua
if extra:
if extra.get("ws_urls") and not session.ws_urls:
session.ws_urls = list(dict.fromkeys(extra.get("ws_urls") or []))
# 这些是浏览器实时 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
if not await self._require_sec_user_id(
"启动托管时",
refresh_if_missing=True,
refresh_if_stale=True,
):
return False, "缺少 sec_user_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 直连自动回复"""
# 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)时自动下线
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,
)
self._im_service = im_service
from rpa_engine.douyin_im import hosted_registry
if session.my_uid:
hosted_registry.register(session.my_uid)
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
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 = await async_playwright().start()
# 与登录流程一致使用非 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)
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 并停止托管循环。"""
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}),托管已自动下线,请重新登录后再启动托管",
)
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
# The direct-service startup normally preloads the lightweight cache.
# Keep a guard-first fallback for legacy/tests/partial initialization:
# identity safety must not depend on follow-welcome configuration.
guarded_sec_user_id = ""
if not self._follow_config_loaded:
guarded_sec_user_id = await self._require_sec_user_id("托管运行中")
if not guarded_sec_user_id:
return
try:
enabled, content, sec_user_id = (
await self._refresh_follow_welcome_config()
)
except Exception:
if not guarded_sec_user_id:
# Even when the optional config read fails, execute the
# hosting identity guard before surfacing the transient error.
await self._require_sec_user_id("托管运行中")
raise
sec_user_id = str(sec_user_id or guarded_sec_user_id or "").strip()
# sec_user_id remains a hosting invariant. The lightweight cached
# refresh detects a later database removal without making every
# disabled account query the database once per minute.
if not sec_user_id:
sec_user_id = await self._require_sec_user_id("托管运行中")
if not sec_user_id:
return
self._follow_welcome_sec_user_id = sec_user_id
if not enabled or not content:
return
# 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("托管初始化已取消")
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 后核验,
# 避免资料接口波动长期占住其他账号的浏览器登录通道。
if not await self._require_sec_user_id(
"浏览器登录后",
force_refresh=True,
):
return
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 = await async_playwright().start()
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)
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:
"""判断账号是否已登录(不要求私信页已打开)"""
if await self.check_logged_in_by_cookie():
return True
if self._is_404_page():
return False
if await self.check_homepage_login_status():
return True
return await self.check_login_status()
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 check_homepage_login_status(self) -> bool:
"""检查抖音首页是否处于已登录状态"""
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 False
avatar = await self.page.query_selector(
"[class*='avatar'] img, img[class*='avatar'], [class*='Avatar']"
)
if avatar and await avatar.is_visible():
return True
# 抖音登录后导航栏会出现「私信」入口
has_dm_entry = await self.page.evaluate("""() => {
const nodes = [...document.querySelectorAll('a, span, div, button')];
return nodes.some((el) => {
const t = (el.innerText || '').trim();
return (t === '私信' || t === '消息') && el.offsetParent;
});
}""")
if has_dm_entry:
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 = [
"[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",
]
_QR_CONTAINER_SELECTORS = [
"[class*='qrcode-container']",
"[class*='qrcodeContainer']",
"[class*='qrcode']",
"[class*='QrCode']",
"[class*='login-scan']",
"[class*='scan-code']",
]
# 兜底:抓不到二维码元素时,截取整块登录面板 / 登录 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*='account_login']",
]
def _all_frames(self) -> list:
"""页面所有 frame(含登录 iframe);抖音二维码常在 passport iframe 内。"""
try:
return list(self.page.frames)
except Exception:
return [self.page]
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
src = await el.get_attribute("src") or ""
if src.startswith("data:image"):
return 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")
return f"data:image/png;base64,{b64}"
except Exception:
pass
shot = await el.screenshot(type="png")
b64 = base64.b64encode(shot).decode("utf-8")
logger.info(f"Captured QR via element in frame: {sel}")
return f"data:image/png;base64,{b64}"
except Exception as e:
if "Execution context was destroyed" in str(e):
return None
continue
return None
async def _grab_login_panel_shot(self) -> Optional[str]:
"""兜底:截取登录面板 / 登录 iframe 整块(含其中的二维码),用户仍可扫描。"""
for sel in self._QR_CONTAINER_SELECTORS + self._LOGIN_PANEL_SELECTORS:
try:
container = await self.page.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
shot = await container.screenshot(type="png")
b64 = base64.b64encode(shot).decode("utf-8")
logger.info(f"Captured QR via panel screenshot: {sel}")
return f"data:image/png;base64,{b64}"
except Exception as e:
if "Execution context was destroyed" in str(e):
return None
continue
return None
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:
shot = await self.page.screenshot(type="png")
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:
return captcha_img
# 2. 正常获取二维码元素
qr = await self._grab_qr_in_frames()
if qr:
return qr
# 3. 登录面板截图
panel = await self._grab_login_panel_shot()
if panel:
return panel
# 4. 终极兜底:直接截取整个网页视口(保证无论如何都有画面,而不是转圈)
try:
shot = await self.page.screenshot(type="png")
b64 = base64.b64encode(shot).decode("utf-8")
logger.info("Captured QR via full-viewport screenshot fallback")
return 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