import os
import json
import asyncio
import base64
import logging
import time
import io
from datetime import datetime
from typing import Awaitable, Callable, Optional
from PIL import Image
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from playwright.async_api import async_playwright
from models.database import AsyncSessionLocal
from models.models import Account, AutoReplyRule, MessageLog, AccountProfileDetail, FollowWelcomeLog
from utils.received_message_log import record_received_message
from utils.log_limits import (
bound_error_log_content,
bound_message_log_content,
truncate_text,
)
from utils.cookie_store import get_cookie_path, read_cookie_file, analyze_cookie, merge_playwright_cookies
from utils import system_logger
from rpa_engine.douyin_im import DouyinImService
from rpa_engine.douyin_im.session import DouyinImSession
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
from rpa_engine.credential import validate_im_session, build_im_session_from_storage
from rpa_engine.device_profiles import resolve_user_agent
from rpa_engine.runtime_config import (
resolve_headless,
ensure_browser_display,
playwright_proxy,
)
from rpa_engine.egress_channels import clamp_attempts, resolve_fixed_channel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("rpa_engine")
async def _start_playwright_for_browser(headless: Optional[bool] = None):
"""Start the driver only after DISPLAY exists.
Playwright's Node driver inherits the environment at ``start()`` time and
later launches Chromium itself. Starting Xvfb after the driver therefore
leaves headed Chromium without DISPLAY on Linux even though Python can see
it.
"""
if headless is None:
headless = resolve_headless(default=False)
await ensure_browser_display(headless)
return await async_playwright().start(), headless
async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None):
"""统一的 Chromium 启动入口:自动处理无头/有头、虚拟显示与住宅代理。
- headless 默认由 KEFU_BROWSER_HEADLESS 决定(缺省有头,避免抖音安全 SDK 判定)。
- 有头模式在无 DISPLAY 的 Linux 上自动拉起 Xvfb 虚拟显示。
- 配置 KEFU_DOUYIN_PROXY 时浏览器登录也走同一代理,与 IM 请求保持同一出口 IP。
"""
if headless is None:
headless = resolve_headless(default=False)
await ensure_browser_display(headless)
launch_kwargs: dict = {"headless": headless, "args": args}
proxy = playwright_proxy()
if proxy:
launch_kwargs["proxy"] = proxy
logger.info("浏览器将通过代理启动:%s", proxy.get("server"))
return await pw.chromium.launch(**launch_kwargs)
def format_error(exc: BaseException) -> str:
message = str(exc).strip()
if "Target page, context or browser has been closed" in message:
return "浏览器窗口已关闭,请重新点击启动并保持窗口打开"
if message:
return message
name = type(exc).__name__
if name == "CancelledError":
return "RPA 任务被取消,请重新点击启动"
return f"{name}:请确认 Playwright 已安装且勿关闭弹出的浏览器窗口"
class DouyinWorker:
def __init__(
self,
account_id: int,
login_mode: str = "auto",
*,
credential_prevalidated: bool = False,
relogin_hook: Optional[Callable[[int], Awaitable[None]]] = None,
):
self.account_id = account_id
self.login_mode = login_mode # auto | im_direct | browser
self.credential_prevalidated = bool(credential_prevalidated)
# 登录态失效(KICK/INVALID_REQUEST/用户未登录)时通知上层自动重登录;
# 由 WorkerManager 注入,worker 自身不感知 manager,避免循环依赖。
self.relogin_hook: Optional[Callable[[int], Awaitable[None]]] = relogin_hook
self.browser = None
self.context = None
self.page = None
self.playwright = None
self.is_running = False
self.stopping = False
self._task: asyncio.Task | None = None
self._startup_ready = asyncio.Event()
self._startup_error = ""
self.session_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"sessions"
)
os.makedirs(self.session_dir, exist_ok=True)
self.cookie_path = get_cookie_path(account_id)
self._conv_previews = {} # 会话名 -> 最近一条预览文本
self._replied_keys = set() # 已回复消息去重
self._last_reply_at = {} # 会话名 -> 最近一次自动回复时间戳(冷却窗口)
self._reply_cooldown_override = None # 账号专属冷却秒数;None=继承全局设置
self._cooldown_cache = None # (过期时间戳, 生效冷却秒数) 短期缓存,避免每条消息查库
self._cooldown_cache_ttl = 5.0 # 冷却配置缓存有效期(秒)
self._reply_delay_cache = None # (过期时间戳, 生效排队间隔秒数)
self._reply_delay_cache_ttl = 5.0
self._pending_im_messages = [] # 网络监听捕获的新消息
self._message_page_ready = False
self._seen_im_urls = set()
self._session_api_seen = False
self._startup_unread_scan_done = False
self._api_conversations = [] # 从 imapi.douyin.com 解析的会话
self._api_unread_total = 0
self._captured_ws_urls: list[str] = []
self._im_conv_meta: dict[str, dict] = {}
self._im_service: DouyinImService | None = None
# 凭证刷新(解决 ts_sign 过期 7911):加锁 + 冷却,避免风控/重复开浏览器
self._refresh_lock = asyncio.Lock()
self._last_refresh_ts = 0.0
self._refresh_cooldown = 90.0
self._user_agent: str = ""
# 账号表显式配置的伪装 UA(区别于 resolve_user_agent 的默认值):
# 为空表示用户没配置,应保留凭证采集时写入的真实浏览器 UA。
self._raw_user_agent: str = ""
self._sec_user_id_missing_fired = False
self._douyin_logged_out_reported = False
# Lightweight follow-welcome configuration. Disabled accounts refresh
# infrequently, so 500 idle workers do not query Account + sec_user_id
# every minute merely to discover that the feature is still off.
self._follow_config_lock = asyncio.Lock()
self._follow_config_loaded = False
self._follow_config_refresh_at = 0.0
self._follow_welcome_enabled = False
self._follow_welcome_content = ""
self._follow_welcome_sec_user_id = ""
# 登录态保活:定时用已保存登录态访问抖音首页,触发 passport 滑动续期,
# 把「30 天必失效」的 sessionid 变成「持续活跃基本不失效」。
self._keepalive_task: asyncio.Task | None = None
self._keepalive_lock = asyncio.Lock()
self._keepalive_last_result: str = ""
async def _load_user_agent(self) -> str:
"""读取账号配置的伪装设备头,用于浏览器与 IM 全链路一致。"""
if self._user_agent:
return self._user_agent
db = await self.get_db()
try:
result = await db.execute(
select(Account.user_agent).where(Account.id == self.account_id)
)
self._user_agent = resolve_user_agent(result.scalar_one_or_none())
finally:
await db.close()
return self._user_agent
async def _load_raw_user_agent(self) -> str:
"""读取账号表显式配置的伪装 UA;未配置返回空串。
与 _load_user_agent 的区别:后者在账号未配置时回退到默认 Chrome/120,
而凭证采集工具/浏览器模式登录会把“真实采集浏览器”的 UA 写入
storage_state(im_session_data 的 user_agent)。若用默认 UA 去签名一套
真实浏览器(如 Chrome/148)采集的凭证,抖音安全网关会返回 7911/KICK。
"""
if self._raw_user_agent:
return self._raw_user_agent
db = await self.get_db()
raw = ""
try:
result = await db.execute(
select(Account.user_agent).where(Account.id == self.account_id)
)
raw = str(result.scalar_one_or_none() or "").strip()
finally:
await db.close()
self._raw_user_agent = raw
if raw:
self._user_agent = raw
return raw
def _browser_context_options(self, storage_state: dict | None = None) -> dict:
opts = {
"user_agent": self._user_agent or resolve_user_agent(None),
"viewport": {"width": 1280, "height": 800},
"locale": "zh-CN",
}
if storage_state:
opts["storage_state"] = storage_state
return opts
def _should_auto_reply(
self,
prev_preview: str | None,
preview: str,
has_unread: bool,
unread_count: int = 0,
) -> bool:
"""未读消息或预览变化时触发自动回复"""
if has_unread or unread_count > 0:
return True
if prev_preview is None:
return False
return bool(preview and preview != prev_preview)
async def get_db(self):
return AsyncSessionLocal()
def _mark_startup_ready(self) -> None:
self._startup_error = ""
self._startup_ready.set()
def _mark_startup_failed(self, detail: str = "") -> None:
if self._startup_ready.is_set():
return
self._startup_error = (
str(detail or "").strip()
or "托管任务在完成初始化前已退出"
)
self._startup_ready.set()
async def wait_until_ready(self) -> None:
"""Wait until IM startup completed, or raise its initialization error.
Batch admission can await this signal so its concurrency limit covers
UID/frontier/WS/first-poll initialization instead of only covering the
creation of a detached worker task.
"""
await self._startup_ready.wait()
if self._startup_error:
raise RuntimeError(self._startup_error)
async def _refresh_follow_welcome_config(
self,
*,
force: bool = False,
) -> tuple[bool, str, str]:
now = time.monotonic()
if (
not force
and self._follow_config_loaded
and now < self._follow_config_refresh_at
):
return (
self._follow_welcome_enabled,
self._follow_welcome_content,
self._follow_welcome_sec_user_id,
)
async with self._follow_config_lock:
now = time.monotonic()
if (
not force
and self._follow_config_loaded
and now < self._follow_config_refresh_at
):
return (
self._follow_welcome_enabled,
self._follow_welcome_content,
self._follow_welcome_sec_user_id,
)
db = await self.get_db()
try:
row = (
await db.execute(
select(
Account.follow_welcome_enabled,
Account.follow_welcome_content,
AccountProfileDetail.sec_user_id,
)
.outerjoin(
AccountProfileDetail,
AccountProfileDetail.account_id == Account.id,
)
.where(Account.id == self.account_id)
)
).first()
finally:
await db.close()
if row:
enabled, content, sec_user_id = row
self._follow_welcome_enabled = bool(enabled)
self._follow_welcome_content = str(content or "").strip()
self._follow_welcome_sec_user_id = str(sec_user_id or "").strip()
else:
self._follow_welcome_enabled = False
self._follow_welcome_content = ""
self._follow_welcome_sec_user_id = ""
self._follow_config_loaded = True
# Enabled accounts retain the old one-minute configuration
# responsiveness. Disabled accounts perform only one lightweight
# refresh every ten minutes instead of one full Account read/minute.
ttl = 60.0 if self._follow_welcome_enabled else 600.0
self._follow_config_refresh_at = now + ttl
return (
self._follow_welcome_enabled,
self._follow_welcome_content,
self._follow_welcome_sec_user_id,
)
def invalidate_follow_welcome_config(self) -> None:
"""Make the next follow tick reload settings after an account edit."""
self._follow_config_loaded = False
self._follow_config_refresh_at = 0.0
async def _load_sec_user_id(self) -> str:
"""Return the locally persisted Douyin sec_user_id for this account."""
db = await self.get_db()
try:
result = await db.execute(
select(AccountProfileDetail.sec_user_id).where(
AccountProfileDetail.account_id == self.account_id
)
)
return str(result.scalar_one_or_none() or "").strip()
finally:
await db.close()
async def _sec_user_id_is_stale(self) -> bool:
"""Whether the cached profile predates the currently stored Cookie."""
db = await self.get_db()
try:
result = await db.execute(
select(Account.cookie_updated_at, AccountProfileDetail.synced_at)
.outerjoin(
AccountProfileDetail,
AccountProfileDetail.account_id == Account.id,
)
.where(Account.id == self.account_id)
)
row = result.first()
if not row:
return False
cookie_updated_at, profile_synced_at = row
return bool(
cookie_updated_at
and (
profile_synced_at is None
or cookie_updated_at > profile_synced_at
)
)
finally:
await db.close()
async def _refresh_sec_user_id(self) -> str:
"""Resolve and persist sec_user_id once from the account's current Cookie."""
db = await self.get_db()
try:
result = await db.execute(
select(Account.cookie_data, Account.user_agent).where(
Account.id == self.account_id
)
)
row = result.first()
cookie_data = row.cookie_data if row else None
user_agent = row.user_agent if row else None
finally:
await db.close()
if not cookie_data:
return ""
from rpa_engine.account_profile import fetch_douyin_profile_detail_with_sec_user_id
controller = get_traffic_controller()
async with controller.background_slot(
self.account_id,
"sec_user_id profile refresh",
):
detail = await fetch_douyin_profile_detail_with_sec_user_id(
cookie_data,
user_agent,
)
sec_user_id = str(detail.get("sec_user_id") or "").strip()
if not sec_user_id:
if detail.get("logged_out"):
# 抖音已判定登录失效:托管继续跑也收不到、发不出任何私信,
# 必须显式告警,不能只当成一次「资料接口抖动」。
await self._report_douyin_logged_out(str(detail.get("message") or ""))
if detail.get("sec_user_id_status") == "unknown":
raise RuntimeError(
detail.get("message")
or "暂时无法核验 sec_user_id,请稍后重试"
)
return ""
db = await self.get_db()
try:
current_cookie = (
await db.execute(
select(Account.cookie_data)
.where(Account.id == self.account_id)
.with_for_update()
)
).scalar_one_or_none()
if current_cookie != cookie_data:
raise RuntimeError(
"核验 sec_user_id 期间账号 Cookie 已更新,请重新启动托管"
)
result = await db.execute(
select(AccountProfileDetail).where(
AccountProfileDetail.account_id == self.account_id
)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = AccountProfileDetail(account_id=self.account_id)
db.add(profile)
profile.sec_user_id = sec_user_id
profile.synced_at = datetime.utcnow()
for field_name in (
"uid",
"nickname",
"avatar_url",
"unique_id",
"signature",
"video_count",
"follower_count",
"following_count",
"total_favorited",
"favoriting_count",
):
value = detail.get(field_name)
if value is not None and value != "":
setattr(profile, field_name, value)
profile.sync_message = detail.get("message") or None
await db.commit()
logger.info(
"Account %s sec_user_id refreshed and persisted",
self.account_id,
)
return sec_user_id
except IntegrityError as exc:
await db.rollback()
try:
current_cookie = (
await db.execute(
select(Account.cookie_data)
.where(Account.id == self.account_id)
.with_for_update()
)
).scalar_one_or_none()
if current_cookie != cookie_data:
raise RuntimeError(
"核验 sec_user_id 期间账号 Cookie 已更新,请重新启动托管"
)
# A concurrent sync inserted the one-to-one row first. The
# ID fetched from this exact current Cookie is authoritative;
# overwrite the race winner instead of trusting stale data.
race_values = {
"sec_user_id": sec_user_id,
"synced_at": datetime.utcnow(),
}
if detail.get("uid"):
race_values["uid"] = str(detail["uid"])
await db.execute(
update(AccountProfileDetail)
.where(AccountProfileDetail.account_id == self.account_id)
.values(**race_values)
)
await db.commit()
persisted = await self._load_sec_user_id()
if persisted == sec_user_id:
return sec_user_id
except RuntimeError:
await db.rollback()
raise
except Exception:
await db.rollback()
logger.warning(
"Account %s failed to persist sec_user_id: %s",
self.account_id,
exc,
)
raise
except Exception as exc:
await db.rollback()
logger.warning(
"Account %s failed to persist sec_user_id: %s",
self.account_id,
exc,
)
raise
finally:
await db.close()
async def _report_douyin_logged_out(self, message: str) -> None:
"""抖音判定登录失效时告警一次(每轮托管只报一次)。"""
if self._douyin_logged_out_reported:
return
self._douyin_logged_out_reported = True
reason = message or (
"抖音返回「用户未登录」:Cookie 仍在但服务端登录态已失效,"
"请停止托管后重新扫码登录该账号。"
)
logger.warning("Account %s is logged out on Douyin: %s", self.account_id, reason)
system_logger.record(
"抖音登录态已失效,需重新扫码登录",
detail=(
f"{reason} 当前托管既收不到新私信,也无法发送自动回复;"
"账号卡片上的「Cookie 有效」只表示本地还存着 sessionid。"
),
level="error",
category="auth",
account_id=self.account_id,
)
async def _stop_for_missing_sec_user_id(self, stage: str) -> None:
"""Stop hosting once when the account identity lacks sec_user_id."""
if self._sec_user_id_missing_fired:
return
self._sec_user_id_missing_fired = True
reason = (
f"{stage}缺少 sec_user_id,托管已自动退出;"
"请重新登录或在账号管理中同步资料后再启动托管"
)
logger.warning("Account %s %s", self.account_id, reason)
system_logger.record(
"缺少 sec_user_id,托管自动退出",
detail=reason,
level="error",
category="auth",
account_id=self.account_id,
)
self.stopping = True
self.is_running = False
if self._im_service:
self._im_service._running = False
await self.update_account_status("offline", error_msg=reason)
async def _require_sec_user_id(
self,
stage: str,
*,
refresh_if_missing: bool = False,
refresh_if_stale: bool = False,
force_refresh: bool = False,
) -> str:
"""Return sec_user_id or stop hosting when it remains unavailable."""
sec_user_id = str(await self._load_sec_user_id() or "").strip()
should_refresh = force_refresh or (not sec_user_id and refresh_if_missing)
if not should_refresh and refresh_if_stale:
should_refresh = await self._sec_user_id_is_stale()
if should_refresh:
sec_user_id = await self._refresh_sec_user_id()
sec_user_id = str(sec_user_id or "").strip()
if sec_user_id:
return sec_user_id
await self._stop_for_missing_sec_user_id(stage)
return ""
async def _best_effort_sec_user_id(
self,
*,
refresh_if_missing: bool = False,
refresh_if_stale: bool = False,
force_refresh: bool = False,
) -> str:
"""Resolve sec_user_id without making optional profile data block IM."""
try:
sec_user_id = str(await self._load_sec_user_id() or "").strip()
should_refresh = force_refresh or (
not sec_user_id and refresh_if_missing
)
if not should_refresh and refresh_if_stale:
should_refresh = await self._sec_user_id_is_stale()
if should_refresh:
sec_user_id = str(await self._refresh_sec_user_id() or "").strip()
return sec_user_id
except Exception as exc:
logger.warning(
"Account %s could not refresh optional sec_user_id; "
"IM hosting will continue: %s",
self.account_id,
exc,
)
return ""
def _is_browser_alive(self) -> bool:
return bool(
self.page
and not self.page.is_closed()
and self.context
and self.browser
and self.browser.is_connected()
)
async def _safe_goto(self, url: str, **kwargs):
if not self._is_browser_alive():
raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开")
try:
await self.page.goto(url, **kwargs)
except Exception as e:
err = str(e)
if "ERR_ABORTED" in err or "NS_BINDING_ABORTED" in err:
await asyncio.sleep(1)
if self._is_browser_alive():
current = self.page.url or ""
if "douyin.com" in current:
logger.warning(f"Navigation aborted but page is usable: {current}")
return
raise
async def _relaunch_visible_browser(self, storage_state, context_options: dict):
"""无头模式验证失败时,切换为有界面浏览器"""
try:
if self.page:
await self.page.close()
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
except Exception as e:
logger.debug(f"Partial cleanup before relaunch: {e}")
self.browser = await _launch_chromium(
self.playwright,
[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
],
)
if storage_state:
self.context = await self.browser.new_context(
storage_state=storage_state,
**context_options,
)
else:
self.context = await self.browser.new_context(**context_options)
await self.context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
self.page = await self.context.new_page()
async def _load_storage_state(self):
"""从数据库或本地文件加载 Playwright storage_state"""
db = await self.get_db()
try:
result = await db.execute(
select(Account.cookie_data).where(Account.id == self.account_id)
)
cookie_data = result.scalar_one_or_none()
if cookie_data:
return json.loads(cookie_data)
except Exception as e:
logger.warning(f"Failed to load cookie from database: {e}")
finally:
await db.close()
file_data = read_cookie_file(self.account_id)
if file_data:
try:
return json.loads(file_data)
except Exception as e:
logger.warning(f"Failed to load cookie from file: {e}")
return None
async def _persist_cookies(self):
"""登录成功或运行中将 Cookie 同步到文件和数据库(合并 HttpOnly sessionid)"""
if not self.context:
return
storage = await self.context.storage_state()
live_cookies = await self.context.cookies()
storage = merge_playwright_cookies(storage, live_cookies)
cookie_json = json.dumps(storage, ensure_ascii=False, indent=2)
with open(self.cookie_path, "w", encoding="utf-8") as f:
f.write(cookie_json)
db = await self.get_db()
try:
await db.execute(
update(Account).where(Account.id == self.account_id).values(
cookie_data=cookie_json,
cookie_path=self.cookie_path,
cookie_updated_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
await db.commit()
logger.info(f"Account {self.account_id} cookies persisted to database")
except Exception as e:
logger.error(f"Failed to persist cookies: {e}")
await db.rollback()
finally:
await db.close()
async def _build_im_session_from_storage(
self,
storage: dict,
extra: Optional[dict] = None,
) -> DouyinImSession:
db = await self.get_db()
saved_im = None
account_uid = "" # accounts.douyin_uid:拿 cookie 从抖音拉取的权威账号 UID
profile_uid = ""
profile_updated_at = None
cookie_updated_at = None
try:
result = await db.execute(
select(
Account.im_session_data,
Account.cookie_updated_at,
Account.douyin_uid,
AccountProfileDetail.uid,
AccountProfileDetail.updated_at.label("profile_updated_at"),
)
.outerjoin(
AccountProfileDetail,
AccountProfileDetail.account_id == Account.id,
)
.where(Account.id == self.account_id)
)
row = result.one_or_none()
if row:
saved_im = row.im_session_data
account_uid = str(getattr(row, "douyin_uid", None) or "").strip()
profile_uid = str(getattr(row, "uid", None) or "").strip()
profile_updated_at = getattr(row, "profile_updated_at", None)
cookie_updated_at = getattr(row, "cookie_updated_at", None)
finally:
await db.close()
session = build_im_session_from_storage(storage or {}, saved_im)
# 权威 UID 覆盖:accounts.douyin_uid 是拿当前 cookie 从抖音接口拉取后写入的
# 账号标识,最可靠,无条件覆盖。account_profile_details.uid 有串号风险
# (如账号 9 的 profile 里存了别的账号的 UID),仅在资料不早于 cookie 更新
# 时才可信(保留时间戳保护)。
# 之前对 douyin_uid 也套时间戳条件:混合登录态下 tea 解析出的 my_uid 可能
# 是 web_id(device_id != my_uid -> KICK 循环),而资料同步往往滞后于
# cookie 落库,时间戳条件会让错误 UID 一直带病运行。
verified_uid = account_uid if account_uid.isdigit() else ""
if not verified_uid and profile_uid.isdigit():
profile_fresh = (
cookie_updated_at is None
or (
profile_updated_at is not None
and profile_updated_at >= cookie_updated_at
)
)
if profile_fresh:
verified_uid = profile_uid
if verified_uid:
verified_uid = int(verified_uid)
old_uid = int(session.my_uid or 0)
if old_uid and old_uid != verified_uid:
logger.warning(
"Account %s replaced collected IM uid %s with current "
"uid %s (account.douyin_uid=%s profile.uid=%s "
"profile_updated_at=%s cookie_updated_at=%s)",
self.account_id,
old_uid,
verified_uid,
account_uid,
profile_uid,
profile_updated_at,
cookie_updated_at,
)
session.my_uid = verified_uid
# device_id 必须与 my_uid 指向同一账号:protobuf/frontier 的 device_id
# 优先取 session.device_id(见 resolve_proto_device_id),若凭证里残留
# 旧设备号(如 www 域 web_runtime_security_uid),发送时 device_id !=
# my_uid 会被安全网关判为设备指纹异常 -> decision=KICK。
if str(session.device_id or "") != str(verified_uid):
if session.device_id:
logger.info(
"Account %s synced device_id %s -> %s to match verified uid",
self.account_id,
session.device_id,
verified_uid,
)
session.device_id = str(verified_uid)
# Keep the browser runtime device_id for frontier. The protobuf
# sender uses the verified IM UID separately in DouyinAuth.
session.uid_verified = True
# UA 全链路一致原则:a_bogus 签名、IM 请求头、Protobuf body 必须与
# 凭证采集环境(storage_state.user_agent)使用同一 UA,否则安全网关
# 判定设备指纹不一致 -> 7911 / decision=KICK。
# 账号表显式配置的 UA(浏览器登录上下文用它)优先;未配置时保留
# storage_state/im_session_data 里采集写入的真实浏览器 UA,
# 绝不用默认 Chrome/120 去签名一套 Chrome/148 环境采集的凭证。
raw_ua = await self._load_raw_user_agent()
if raw_ua:
session.user_agent = resolve_user_agent(raw_ua)
else:
logger.info(
"Account %s: 未显式配置 UA,保留采集 UA=%s",
self.account_id,
session.user_agent[:60] + "…" if len(session.user_agent or "") > 60 else session.user_agent,
)
if extra:
# 浏览器本次真实建连的 frontier 地址必须排在缓存地址之前。
# 之前写成 "and not session.ws_urls":DB 里那条我们自己拼出来的
# frontier-im 地址永远非空,于是每次重新登录抓到的真实地址都被丢弃,
# 长连接一直用推导出的 token/access_key,收不到抖音下发的私信。
if extra.get("ws_urls"):
session.ws_urls = list(
dict.fromkeys(list(extra["ws_urls"]) + list(session.ws_urls))
)
# 这些是浏览器实时 localStorage 读取到的“最新”签名凭证(含时效性的 ts_sign),
# 必须覆盖来自 DB 的旧值,否则重新登录也刷新不了凭证,导致一直 7911。
if extra.get("keys_str"):
session.keys_str = extra["keys_str"]
if extra.get("web_protect_str"):
session.web_protect_str = extra["web_protect_str"]
if extra.get("my_uid") and not session.my_uid:
session.my_uid = int(extra["my_uid"])
if extra.get("web_id") and not session.web_id:
session.web_id = str(extra["web_id"])
if extra.get("device_id") and not session.device_id:
session.device_id = str(extra["device_id"])
return session
async def _build_im_session(self) -> DouyinImSession:
storage = None
if self.context:
try:
storage = await self.context.storage_state()
except Exception as e:
logger.debug(f"Live storage_state read failed: {e}")
if not storage:
storage = await self._load_storage_state()
keys_str = ""
web_protect_str = ""
web_id = ""
device_id = ""
my_uid = 0
if self.page and not self.page.is_closed():
try:
keys_str = await self.page.evaluate('localStorage["security-sdk/s_sdk_crypt_sdk"]') or ""
web_protect_str = await self.page.evaluate('localStorage["security-sdk/s_sdk_sign_data_key/web_protect"]') or ""
# 关键:device_id / web_id 是 IM 签名绑定的强标识,必须实时采集
raw_tokens = await self.page.evaluate('localStorage["tea_cache_tokens"]') or ""
if raw_tokens:
try:
parsed = json.loads(raw_tokens)
web_id = str(parsed.get("user_unique_id") or parsed.get("web_id") or "")
except Exception:
pass
device_id = await self.page.evaluate('localStorage["web_runtime_security_uid"]') or ""
except Exception as e:
logger.warning(f"Failed to extract keys from localStorage: {e}")
if self.context:
try:
cookies = await self.context.cookies()
for cookie in cookies:
if cookie.get("name") in ("uid_tt", "uid_tt_ss") and cookie.get("value"):
my_uid = int(cookie.get("value"))
break
except Exception as e:
logger.debug(f"Failed to read uid from cookies: {e}")
extra = {
"ws_urls": list(dict.fromkeys(self._captured_ws_urls)),
"keys_str": keys_str,
"web_protect_str": web_protect_str,
"my_uid": my_uid,
"web_id": web_id,
"device_id": device_id,
}
return await self._build_im_session_from_storage(storage or {}, extra)
async def _try_cookie_only_im_start(self, storage_state: dict) -> tuple[bool, str]:
"""Cookie 有效时跳过浏览器,直接 IM 直连托管"""
await self._load_user_agent()
im_session = await self._build_im_session_from_storage(storage_state)
if self.credential_prevalidated:
# Batch preparation already performed the remote credential probe.
# Re-check only the immutable local requirements after rebuilding
# the session, avoiding a duplicate query/user request per account.
from rpa_engine.douyin_im.auth import DouyinAuth
auth = DouyinAuth.from_im_session(im_session)
ok = bool(im_session.can_direct_im() and auth.is_sign_ready())
reason = (
"IM 凭证已在启动队列中校验"
if ok
else "启动后的本地 IM 凭证不再满足直连条件"
)
else:
ok, reason = await validate_im_session(im_session)
if not ok:
logger.warning(f"IM session validation failed: {reason}")
system_logger.record(
"IM 直连凭证校验失败",
detail=reason,
level="warning",
category="auth",
account_id=self.account_id,
)
return False, reason
sec_user_id = await self._best_effort_sec_user_id(
refresh_if_missing=True,
refresh_if_stale=True,
)
if not sec_user_id:
logger.warning(
"Account %s has no verified sec_user_id; continuing IM hosting "
"with follow-welcome polling temporarily unavailable",
self.account_id,
)
logger.info(
f"IM session validated for account {self.account_id} "
f"(uid={im_session.my_uid}, ws={'yes' if im_session.frontier_ws_url() else 'no'})"
)
await self._persist_im_session(
im_session,
status="online",
clear_error=True,
)
logger.info(
f"Account {self.account_id}: cookie-only IM direct mode "
f"(no browser, ws={'yes' if im_session.frontier_ws_url() else 'no'})"
)
await self._run_im_direct_service(im_session)
return True, ""
async def _load_storage_state(self) -> dict | None:
db = await self.get_db()
try:
result = await db.execute(
select(Account.cookie_data).where(Account.id == self.account_id)
)
cookie_data = result.scalar_one_or_none()
if cookie_data:
return json.loads(cookie_data)
except Exception:
pass
finally:
await db.close()
file_data = read_cookie_file(self.account_id)
if file_data:
try:
return json.loads(file_data)
except Exception:
pass
return None
async def _persist_im_session(
self,
session: DouyinImSession,
*,
status: str | None = None,
error_msg: str | None = None,
clear_error: bool = False,
):
if self._im_conv_meta:
session.conv_meta.update(self._im_conv_meta)
payload = json.dumps(session.to_dict(), ensure_ascii=False)
db = await self.get_db()
try:
values = {
"im_session_data": payload,
"updated_at": datetime.utcnow(),
}
if status is not None:
values["status"] = status
if error_msg is not None:
values["error_message"] = error_msg
elif clear_error:
values["error_message"] = None
await db.execute(
update(Account).where(Account.id == self.account_id).values(**values)
)
await db.commit()
logger.info(f"Account {self.account_id} IM session persisted")
except Exception as e:
logger.error(f"Failed to persist IM session: {e}")
await db.rollback()
finally:
await db.close()
async def _harvest_im_credentials(self, timeout: int = 25):
"""打开私信后等待 frontier WebSocket 与 IM API 出现"""
logger.info("Harvesting IM credentials from browser session...")
for i in range(timeout):
if any("frontier" in u for u in self._captured_ws_urls):
logger.info(f"Captured frontier WS after {i}s")
break
if self._session_api_seen:
logger.info(f"IM API seen after {i}s, waiting for WS...")
await asyncio.sleep(1)
if self._captured_ws_urls:
logger.info(f"Captured {len(self._captured_ws_urls)} WebSocket URL(s)")
else:
logger.warning("No WebSocket URL captured; will rely on HTTP polling only")
async def _close_browser_only(self):
"""关闭临时浏览器及 Playwright driver;IM 直连不依赖这些进程。"""
resources = (
("page", self.page, "close"),
("context", self.context, "close"),
("browser", self.browser, "close"),
("playwright", self.playwright, "stop"),
)
for label, resource, method_name in resources:
if not resource:
continue
try:
await getattr(resource, method_name)()
except Exception as e:
logger.debug(f"{label} close skipped: {e}")
self.page = None
self.context = None
self.browser = None
self.playwright = None
async def get_reply_delay(self) -> "int | None":
"""读取账号专属排队间隔;0/NULL 均表示未设置、继承系统默认。"""
db = await self.get_db()
try:
result = await db.execute(
select(Account.reply_delay_seconds).where(
Account.id == self.account_id
)
)
reply_delay = result.scalar_one_or_none()
if reply_delay is None:
return None
value = max(0, int(reply_delay or 0))
return value if value > 0 else None
finally:
await db.close()
async def resolve_reply_delay_seconds(self) -> int:
"""账号专属优先,否则系统默认;两处均未配置时返回 0(立即回复)。"""
now = time.monotonic()
if self._reply_delay_cache and self._reply_delay_cache[0] > now:
return self._reply_delay_cache[1]
override = None
try:
override = await self.get_reply_delay()
except Exception as exc:
logger.debug(f"resolve reply delay override failed: {exc}")
if override is not None:
effective = override
else:
try:
from auth.system_settings import get_cached_settings
effective = max(0, int(get_cached_settings().auto_reply_delay_seconds or 0))
except Exception:
effective = 0
self._reply_delay_cache = (now + self._reply_delay_cache_ttl, effective)
return effective
async def get_reply_cooldown(self) -> "int | None":
"""读取该账号专属冷却秒数;返回 None 表示继承全局设置。"""
db = await self.get_db()
try:
result = await db.execute(
select(Account.reply_cooldown_seconds).where(
Account.id == self.account_id
)
)
reply_cooldown = result.scalar_one_or_none()
if reply_cooldown is None:
return None
return max(0, int(reply_cooldown))
finally:
await db.close()
async def resolve_cooldown_seconds(self) -> int:
"""实时解析「自动回复冷却时间」:账号专属优先,否则取全局系统设置。
带 5 秒短缓存,改设置后最多 5 秒内生效,无需重启托管。"""
now = time.monotonic()
if self._cooldown_cache and self._cooldown_cache[0] > now:
return self._cooldown_cache[1]
override = None
try:
override = await self.get_reply_cooldown()
except Exception as e:
logger.debug(f"resolve cooldown override failed: {e}")
if override is not None:
effective = override
else:
try:
from auth.system_settings import get_cached_settings
effective = max(0, int(get_cached_settings().auto_reply_cooldown_seconds or 0))
except Exception:
effective = 0
self._reply_cooldown_override = override
self._cooldown_cache = (now + self._cooldown_cache_ttl, effective)
return effective
async def _run_im_direct_service(self, session: DouyinImSession):
"""运行 IM API + WebSocket 直连自动回复"""
# 公网通道配置独立存于账号表。固定通道在启动时解析一次供 WS 使用;
# HTTP 发送仍会在每次建连时校验,账号编辑后的配置无需重启即可生效。
row = None
db = await self.get_db()
try:
try:
row = (
await db.execute(
select(
Account.egress_public_ip,
Account.egress_auto_attempts,
).where(Account.id == self.account_id)
)
).one_or_none()
except Exception as exc:
# A worker may be created by an isolated test or during a
# rolling deployment before the startup migration finishes.
logger.debug("load account egress config failed: %s", exc)
finally:
await db.close()
session.egress_public_ip = str((row.egress_public_ip if row else "") or "").strip()
session.egress_auto_attempts = clamp_attempts(
row.egress_auto_attempts if row else 1
)
session.egress_source_ip = ""
if session.egress_public_ip:
try:
route = await resolve_fixed_channel(session.egress_public_ip)
session.egress_source_ip = str(route.source_ip or "")
except Exception as exc:
logger.warning(
"Account %s selected egress %s is not currently resolvable: %s",
self.account_id,
session.egress_public_ip,
exc,
)
# Cache the only account fields needed by the follow-welcome timer.
# Disabled accounts subsequently avoid the old full Account query on
# every minute tick.
await self._refresh_follow_welcome_config(force=True)
reply_delay = await self.get_reply_delay()
im_service = DouyinImService(
session=session,
match_reply=self.match_and_reply,
log_fn=self.log_message,
received_log_fn=self.log_received_message,
account_id=self.account_id,
reply_delay_seconds=reply_delay or 0,
# 账号设置优先、系统默认兜底;改设置后最多 5 秒生效,无需重启托管。
reply_delay_resolver=self.resolve_reply_delay_seconds,
# 关注欢迎语:周期性检测新粉丝并自动私信(约每 60s)
follow_tick=self.follow_welcome_tick,
# IM 登录失效(INVALID_REQUEST/KICK)时自动下线
on_session_invalid=self.on_im_session_invalid,
# Batch admission waits for UID/frontier/WS/first-poll completion;
# it no longer releases its slot immediately after create_task().
on_ready=self._mark_startup_ready,
# 实时解析冷却时间(账号专属优先,否则全局),改设置无需重启托管
cooldown_resolver=self.resolve_cooldown_seconds,
# 不在发送链路上自动开浏览器刷新:实测重载页面并不会重生 web_protect,
# 反而每次失败阻塞 ~22s("反应特别慢"),且无法解决 7911 风控。
refresh_credentials=None,
# 第二套发送方案:HTTP 签名发送被 KICK/7911/INVALID_REQUEST 拒绝时,
# 用浏览器页面上下文重发(真实 JS 签名,可自愈被踢会话)。
send_fallback=self.send_im_via_browser_page,
)
self._im_service = im_service
from rpa_engine.douyin_im import hosted_registry
if session.my_uid:
hosted_registry.register(session.my_uid)
# 托管运行期间定期活跃抖音首页,给 passport 登录态滑动续期
await self._start_keepalive()
try:
await im_service.run()
finally:
if session.my_uid:
hosted_registry.unregister(session.my_uid)
if self._im_service is im_service:
await im_service.stop()
self._im_service = None
await self._stop_keepalive()
# ---------- 登录态保活(keepalive) ----------
# 抖音 web 登录态(sessionid/passport)有有效期且无 refresh token 可自动换新,
# 但服务端对「持续活跃」的账号做滑动续期。IM 通道(imapi + frontier WS)的活跃
# 并不刷新 passport 登录态,所以托管期间需要定期用已保存登录态打开一次抖音首页,
# 让页面自带 JS 触发 passport 活跃请求,把登录态从「30 天必失效」延长为
# 「持续活跃基本不失效」。行为等同真人打开网页,风险低。
def _keepalive_interval(self) -> float:
try:
return max(300.0, float(os.getenv("KEFU_KEEPALIVE_INTERVAL", "21600")))
except (TypeError, ValueError):
return 21600.0
def _keepalive_disabled(self) -> bool:
return os.getenv("KEFU_KEEPALIVE_DISABLED", "").strip().lower() in (
"1", "true", "yes",
)
@staticmethod
def _cookie_expires_map(cookies: list) -> dict:
"""提取 passport 关键 cookie 的过期时间(epoch 秒),用于观测是否滑动续期。"""
names = (
"sid_guard", "sessionid", "sessionid_ss",
"sid_tt", "sid_tt_ss", "uid_tt", "uid_tt_ss",
)
out: dict = {}
for c in cookies or []:
name = (c.get("name") or "").lower()
if name in names and c.get("value"):
try:
exp = int(float(c.get("expires") or 0))
except (TypeError, ValueError):
exp = 0
out[name] = exp if exp > 0 else 0
return out
@staticmethod
def _fmt_expires_map(m: dict) -> str:
from datetime import datetime as _dt
parts = []
for name, exp in sorted(m.items()):
if exp:
parts.append(
f"{name}={_dt.fromtimestamp(exp).strftime('%m-%d %H:%M')}"
)
else:
parts.append(f"{name}=session")
return ", ".join(parts) if parts else "(none)"
async def _start_keepalive(self) -> None:
if self._keepalive_task and not self._keepalive_task.done():
return
if self._keepalive_disabled():
logger.info(
f"Account {self.account_id}: keepalive disabled by KEFU_KEEPALIVE_DISABLED"
)
return
self._keepalive_task = asyncio.create_task(
self._keepalive_loop(),
name=f"douyin-keepalive-{self.account_id}",
)
async def _stop_keepalive(self) -> None:
task = self._keepalive_task
self._keepalive_task = None
if task and task is not asyncio.current_task() and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
async def _keepalive_loop(self) -> None:
"""周期保活:让服务端认为账号持续活跃,滑动续期 passport 登录态。"""
interval = self._keepalive_interval()
logger.info(
f"Account {self.account_id}: keepalive loop started "
f"(every {interval / 3600:.1f}h, timeout-based, low risk)"
)
while not self.stopping and self.is_running:
await asyncio.sleep(interval)
if self.stopping or not self.is_running:
break
try:
ok, detail = await self._keepalive_touch()
self._keepalive_last_result = detail
if ok:
logger.info(f"Account {self.account_id}: keepalive ok - {detail}")
else:
# 保活发现登录态失效:IM 通道很快也会报错并触发
# on_im_session_invalid → relogin_hook 自动重登录,这里不重复处理。
logger.warning(
f"Account {self.account_id}: keepalive failed - {detail}"
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
f"Account {self.account_id}: keepalive exception: {exc}"
)
logger.info(f"Account {self.account_id}: keepalive loop stopped")
async def _keepalive_touch(self) -> tuple[bool, str]:
"""打开抖音首页触发 passport 活跃续期,并重新持久化 cookie。
在全局 browser_slot 内执行,与扫码登录/凭证刷新等浏览器操作互斥,
保证同一时刻只有一个有头浏览器实例。
"""
if self._keepalive_lock.locked():
return False, "上一次保活仍在进行"
async with self._keepalive_lock:
storage_state = await self._load_storage_state()
if not storage_state:
return False, "未找到已保存的登录态"
before_exp = self._cookie_expires_map(storage_state.get("cookies") or [])
controller = get_traffic_controller()
async with controller.browser_slot(self.account_id, "keepalive"):
pw = None
browser = None
context = None
page = None
saved_browser_refs = (
self.playwright,
self.browser,
self.context,
self.page,
)
try:
pw, browser_headless = await _start_playwright_for_browser()
import sys
args = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
]
if sys.platform == "win32":
args.append("--start-minimized")
browser = await _launch_chromium(
pw, args, headless=browser_headless
)
ua = self._user_agent or resolve_user_agent(None)
context = await browser.new_context(
storage_state=storage_state,
user_agent=ua,
viewport={"width": 1280, "height": 800},
locale="zh-CN",
)
await context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
page = await context.new_page()
# 临时挂到 self,复用 _has_visible_login_prompt / _persist_cookies;
# browser_slot 全局串行保证不会与登录流程并发争抢这些字段。
(
self.playwright,
self.browser,
self.context,
self.page,
) = (pw, browser, context, page)
# 默认访问私信页(更贴近真实活跃,触发 IM 域请求);可用
# KEFU_KEEPALIVE_URL 覆盖,/im 异常时回退首页。
import random as _random
target_url = os.getenv(
"KEFU_KEEPALIVE_URL", "https://www.douyin.com/im"
)
try:
await page.goto(
target_url,
wait_until="domcontentloaded",
timeout=30000,
)
except Exception:
await page.goto(
"https://www.douyin.com/",
wait_until="domcontentloaded",
timeout=30000,
)
# 随机停留 + 轻微滚动,避免固定机械节奏
await asyncio.sleep(_random.uniform(4, 8))
try:
await page.mouse.wheel(0, 600)
await asyncio.sleep(_random.uniform(0.5, 1.5))
except Exception:
pass
if await self._has_visible_login_prompt():
return False, (
"页面显示未登录(服务端登录态已失效,将触发自动重登录)"
)
# 观测 passport cookie 是否发生滑动续期(expires 变大)
after_exp = self._cookie_expires_map(await self.context.cookies())
if before_exp:
renewed = [
k for k in before_exp
if before_exp.get(k) and after_exp.get(k)
and after_exp[k] > before_exp[k]
]
logger.info(
f"Account {self.account_id}: keepalive passport expires "
f"before[{self._fmt_expires_map(before_exp)}] "
f"after[{self._fmt_expires_map(after_exp)}] "
f"renewed={','.join(renewed) or 'none'}"
)
# 活跃访问后 cookie(msToken 等)可能更新,重新落库
try:
await self._persist_cookies()
except Exception as exc:
logger.warning(
f"Account {self.account_id}: keepalive persist cookies "
f"failed: {exc}"
)
return True, f"已访问 {target_url} 并刷新登录态"
except asyncio.CancelledError:
raise
except Exception as exc:
return False, f"保活访问失败:{exc}"
finally:
(
self.playwright,
self.browser,
self.context,
self.page,
) = saved_browser_refs
if page is not None:
try:
await page.close()
except Exception:
pass
if context is not None:
try:
await context.close()
except Exception:
pass
if browser is not None:
try:
await browser.close()
except Exception:
pass
if pw is not None:
try:
await pw.stop()
except Exception:
pass
async def send_im_via_browser_page(
self,
conversation_id: str,
content: str,
) -> tuple[bool, str]:
"""第二套发送方案:浏览器页面上下文内重发私信。
HTTP 签名发送被抖音安全网关拒绝(decision=KICK / 7911 / INVALID_REQUEST)
时的兜底:用已保存的登录态打开抖音页面,由页面自带 security-sdk 在真实
浏览器环境里生成 a_bogus / bd-ticket-guard 并完成发送——绕开 Node execjs
的签名模拟;浏览器重新加载页面也会重建安全会话,可自愈被服务端踢掉的
登录态。仅文本/表情/卡片内容可用,图片需先走 HTTP 上传链路。
返回 (是否成功, 详情)。失败不会抛异常,只记录日志。
"""
from rpa_engine.douyin_im.auth import DouyinAuth
from rpa_engine.douyin_im.conv_util import normalize_conversation_id, resolve_peer_uid
from rpa_engine.douyin_im.pb_decode import analyze_send_response
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
from rpa_engine.douyin_im.reply_payload import build_msg_payload, parse_reply_content
timeout = float(os.getenv("KEFU_BROWSER_SEND_TIMEOUT", "45"))
async def _attempt() -> tuple[bool, str]:
try:
session = await self._build_im_session()
except Exception as exc:
return False, f"无法构建 IM 会话:{exc}"
if not session.can_direct_im():
return False, "Cookie 缺失,浏览器兜底无法发送"
auth = DouyinAuth.from_im_session(session)
my_uid = int(session.my_uid or 0)
if not my_uid:
my_uid = int(auth.get_uid() or 0)
if not my_uid:
return False, "无法获取 my_uid"
conv_id = normalize_conversation_id(conversation_id, my_uid)
peer_uid = resolve_peer_uid(conv_id, my_uid)
if not peer_uid:
return False, "无法从会话 ID 解析对方用户 ID"
# 1) 解析新鲜会话票据(unsigned 接口,发送被踢后依然可用)
try:
async with DouyinImHttpClient(session, account_id=self.account_id) as http:
resolved_id, short_id, ticket = await http.resolve_conversation_meta(
auth, conv_id, my_uid, peer_uid
)
except Exception as exc:
return False, f"解析会话票据失败:{exc}"
if not short_id or not ticket:
return False, "未拿到会话 ticket/short_id"
if resolved_id:
conv_id = resolved_id
# 2) 构造与 HTTP 发送一致的 protobuf 报文
reply_spec = parse_reply_content(content)
if reply_spec.get("type") == "image":
return False, "浏览器兜底暂不支持图片回复,请改用纯文字"
try:
request_proto = await asyncio.to_thread(
ProtoBuilder.build_send_message_request,
auth,
conv_id,
short_id,
ticket,
*build_msg_payload(reply_spec),
)
except Exception as exc:
return False, f"构造发送报文失败:{exc}"
body_b64 = base64.b64encode(request_proto.SerializeToString()).decode("ascii")
s_v_web_id = session.cookies.get("s_v_web_id", "")
ms_token = session.cookies.get("msToken", "")
params = {
"verifyFp": s_v_web_id,
"fp": s_v_web_id,
}
if ms_token:
params["msToken"] = ms_token
# 3) 打开页面:让 security-sdk 加载并接管签名。与登录/凭证刷新一致,
# 用非 headless + 最小化(headless 易被抖音安全 SDK 判定而生成无效签名)。
pw = None
browser = None
context = None
page = None
try:
pw, browser_headless = await _start_playwright_for_browser()
# 浏览器页面 UA 必须与会话发送 UA 完全一致(a_bogus 绑定 UA),
# 直接使用 session.user_agent——它已被 _build_im_session 修正为
# 凭证采集环境的真实 UA,而不是账号表默认值。
context_ua = session.user_agent or resolve_user_agent(None)
import sys
token_args = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
]
if sys.platform == "win32":
token_args.append("--start-minimized")
browser = await _launch_chromium(
pw,
token_args,
headless=browser_headless,
)
storage_state = await self._load_storage_state()
context = await browser.new_context(
storage_state=storage_state or {},
user_agent=context_ua,
viewport={"width": 1280, "height": 800},
locale="zh-CN",
)
await context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
page = await context.new_page()
await page.goto(
"https://www.douyin.com/message",
wait_until="domcontentloaded",
timeout=30000,
)
# 等待安全 SDK 初始化(与 _reharvest_security_tokens 相同的轮询节奏)
sdk_ready = False
for _ in range(15):
try:
sdk_ready = bool(
await page.evaluate(
'Boolean(localStorage["security-sdk/s_sdk_crypt_sdk"])'
)
)
except Exception:
sdk_ready = False
if sdk_ready:
break
await asyncio.sleep(1)
if not sdk_ready:
return False, "页面未加载 security-sdk,无法进行真实签名发送"
await asyncio.sleep(1.5)
# 4) 页面上下文内 fetch:SDK 注入 a_bogus/bd-ticket-guard,携带同域 Cookie
js_result = await page.evaluate(
"""async (args) => {
const bin = atob(args.bodyB64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const qs = new URLSearchParams(args.params).toString();
const url = args.url + (qs ? '?' + qs : '');
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 15000);
try {
const r = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-protobuf',
'Accept': 'application/x-protobuf',
'Referer': 'https://www.douyin.com/',
'Origin': 'https://www.douyin.com',
},
body: buf,
credentials: 'include',
signal: ctl.signal,
});
const ab = await r.arrayBuffer();
const bytes = new Uint8Array(ab);
let b64 = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
b64 += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return { http: r.status, bodyB64: btoa(b64) };
} catch (e) {
return { error: String(e && e.message || e) };
} finally { clearTimeout(timer); }
}""",
{
"url": "https://imapi.douyin.com/v1/message/send",
"params": params,
"bodyB64": body_b64,
},
)
if not isinstance(js_result, dict) or js_result.get("error"):
return False, f"页面内发送请求失败:{js_result}"
body_bytes = base64.b64decode(js_result.get("bodyB64") or "")
http_status = js_result.get("http")
if not body_bytes:
return False, f"页面内发送无响应体(http={http_status})"
result = analyze_send_response(body_bytes)
if result.get("ok"):
self._im_conv_meta[conv_id] = {
"conversation_short_id": short_id,
"ticket": ticket,
}
return True, (
f"页面内发送成功 server_message_id={result.get('server_message_id')} "
f"resp[{result.get('summary')}]"
)
decision = str(result.get("decision") or "").strip().upper()
if decision:
return False, f"页面内发送仍被安全网关拒绝 decision={decision}"
sc = result.get("status_code")
if sc is not None and sc != 0:
return False, f"页面内发送被拒绝 status_code={sc} {result.get('summary') or ''}"
return False, f"页面内发送未确认投递(http={http_status} resp[{result.get('summary')}])"
finally:
if page is not None:
try:
await page.close()
except Exception:
pass
if context is not None:
try:
await context.close()
except Exception:
pass
if browser is not None:
try:
await browser.close()
except Exception:
pass
if pw is not None:
try:
await pw.stop()
except Exception:
pass
try:
ok, detail = await asyncio.wait_for(_attempt(), timeout=timeout)
except asyncio.TimeoutError:
return False, f"浏览器兜底发送超时({int(timeout)}s)"
except Exception as exc:
return False, f"浏览器兜底发送异常:{exc}"
if not ok:
logger.warning(
"Account %s browser fallback send failed for %s: %s",
self.account_id,
conversation_id,
detail,
)
return ok, detail
async def refresh_im_credentials(self) -> bool:
"""后台用一次性 headless 浏览器重新采集最新 web_protect/keys(含新鲜 ts_sign),
就地更新正在运行的 IM 会话,解决 ts_sign 过期导致的发送 7911。
返回 True 表示成功刷新了 web_protect(下次发送将使用新凭证)。"""
service = self._im_service
if service is None:
return False
if self._refresh_lock.locked():
# 已有刷新在进行:等它结束后直接复用结果(避免并发开多个浏览器)
async with self._refresh_lock:
return time.time() - self._last_refresh_ts < self._refresh_cooldown
async with self._refresh_lock:
now = time.time()
if now - self._last_refresh_ts < self._refresh_cooldown:
logger.info(
f"Account {self.account_id}: credential refresh skipped (cooldown)"
)
return False
storage_state = await self._load_storage_state()
if not storage_state:
system_logger.record(
"刷新私信凭证失败",
detail="未找到已保存的登录态,无法重新采集 web_protect,请重新登录",
level="error",
category="auth",
account_id=self.account_id,
)
return False
system_logger.record(
"开始刷新私信签名凭证",
detail="检测到发送返回 7911(ts_sign 失效),后台静默重新采集 web_protect…",
level="warning",
category="auth",
account_id=self.account_id,
)
web_protect_str, keys_str = await self._reharvest_security_tokens(storage_state)
if not web_protect_str:
system_logger.record(
"刷新私信凭证失败",
detail="浏览器未能采集到 web_protect(Cookie 可能已失效,请用浏览器模式重新登录)",
level="error",
category="auth",
account_id=self.account_id,
)
return False
# 就地更新运行中的会话:send_text_message 每次都读取 session.web_protect_str
session = service.session
session.web_protect_str = web_protect_str
if keys_str:
session.keys_str = keys_str
self._last_refresh_ts = time.time()
try:
await self._persist_im_session(session)
except Exception as e:
logger.debug(f"persist refreshed im session failed: {e}")
system_logger.record(
"私信签名凭证已刷新",
detail="已重新采集 web_protect(含新鲜 ts_sign),将自动重试发送",
level="success",
category="auth",
account_id=self.account_id,
)
return True
async def _reharvest_security_tokens(self, storage_state: dict) -> tuple[str, str]:
"""打开一次性 headless 浏览器,加载已保存登录态,等待安全 SDK 写入最新
localStorage 后读取 web_protect / keys。返回 (web_protect_str, keys_str)。"""
pw = None
browser = None
context = None
page = None
web_protect_str = ""
keys_str = ""
try:
pw, browser_headless = await _start_playwright_for_browser()
# 与登录流程一致使用非 headless + 最小化:headless 易被抖音安全 SDK 判定,
# 可能生成无效 ts_sign,反而刷新无效。
await self._load_user_agent()
import sys
token_args = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
]
if sys.platform == "win32":
token_args.append("--start-minimized")
browser = await _launch_chromium(
pw,
token_args,
headless=browser_headless,
)
context = await browser.new_context(
storage_state=storage_state,
user_agent=self._user_agent,
viewport={"width": 1280, "height": 800},
locale="zh-CN",
)
await context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
page = await context.new_page()
await page.goto(
"https://www.douyin.com",
wait_until="domcontentloaded",
timeout=30000,
)
# 等待安全 SDK 初始化并写入新鲜 web_protect(ts_sign)
for _ in range(20):
try:
web_protect_str = await page.evaluate(
'localStorage["security-sdk/s_sdk_sign_data_key/web_protect"]'
) or ""
keys_str = await page.evaluate(
'localStorage["security-sdk/s_sdk_crypt_sdk"]'
) or ""
except Exception:
pass
if web_protect_str and keys_str:
break
await asyncio.sleep(1)
except Exception as e:
logger.warning(f"_reharvest_security_tokens failed: {format_error(e)}")
finally:
for closer in (
getattr(page, "close", None),
getattr(context, "close", None),
getattr(browser, "close", None),
getattr(pw, "stop", None),
):
if closer is None:
continue
try:
await closer()
except Exception:
pass
return web_protect_str, keys_str
async def _finalize_login_session(self):
"""登录成功后立即持久化 Cookie 和用户名(不等私信页)"""
try:
await self.page.wait_for_load_state("domcontentloaded", timeout=10000)
except Exception as e:
logger.debug(f"domcontentloaded wait skipped: {e}")
await self._persist_cookies()
username = await self.get_logged_username()
avatar_url = await self.get_logged_avatar()
db = await self.get_db()
try:
await db.execute(
update(Account).where(Account.id == self.account_id).values(
username=username,
avatar_url=avatar_url or None,
)
)
await db.commit()
logger.info(f"Account {self.account_id} login saved as {username}")
except Exception as e:
logger.error(f"Failed to save username: {e}")
await db.rollback()
finally:
await db.close()
async def update_account_status(self, status: str, qr_code: str = None, error_msg: str = None, clear_error: bool = False):
"""更新数据库中账号的状态"""
db = await self.get_db()
try:
update_data = {"status": status, "updated_at": datetime.utcnow()}
if qr_code is not None:
update_data["qr_code_base64"] = qr_code
update_data["error_message"] = None
if error_msg is not None:
update_data["error_message"] = error_msg
elif clear_error:
update_data["error_message"] = None
await db.execute(
update(Account).where(Account.id == self.account_id).values(**update_data)
)
await db.commit()
logger.info(f"Account {self.account_id} status updated to {status}")
except Exception as e:
logger.error(f"Failed to update account status: {e}")
await db.rollback()
finally:
await db.close()
async def get_rules(self):
"""获取当前账号专属的自动回复规则(不含全局/其他账号规则)"""
db = await self.get_db()
try:
stmt = select(AutoReplyRule).where(
AutoReplyRule.account_id == self.account_id,
AutoReplyRule.is_active == True,
).order_by(AutoReplyRule.sort_order.asc(), AutoReplyRule.id.asc())
result = await db.execute(stmt)
rules = list(result.scalars().all())
if not rules:
logger.warning(f"Account {self.account_id} has no active auto-reply rules")
return rules
except Exception as e:
logger.error(f"Failed to fetch rules: {e}")
return []
finally:
await db.close()
async def log_received_message(
self,
*,
sender_name: str,
sender_id: str | None = None,
sender_avatar: str | None = None,
raw_content: str,
conversation_id: str | None = None,
message_type: int | None = None,
server_message_id: str | None = None,
):
await record_received_message(
account_id=self.account_id,
raw_content=raw_content,
sender_name=sender_name,
sender_id=sender_id,
sender_avatar=sender_avatar,
conversation_id=conversation_id,
message_type=message_type,
server_message_id=server_message_id,
)
async def log_message(
self,
sender_name: str,
sender_id: str,
message: str,
reply: str = None,
status: str = "received",
error: str = None,
sender_avatar: str = None,
):
"""记录消息收发日志"""
db = await self.get_db()
try:
log = MessageLog(
account_id=self.account_id,
sender_name=sender_name,
sender_id=sender_id,
sender_avatar=sender_avatar or None,
message_content=bound_message_log_content(message),
reply_content=(
bound_message_log_content(reply) if reply is not None else None
),
status=status,
error_message=(
bound_error_log_content(error) if error is not None else None
),
created_at=datetime.utcnow()
)
db.add(log)
await db.commit()
logger.debug(
"Logged message: sender=%s, msg=%s, reply=%s",
sender_name,
truncate_text(message, 300),
truncate_text(reply, 300) if reply is not None else None,
)
except Exception as e:
logger.error(f"Failed to log message: {e}")
await db.rollback()
finally:
await db.close()
async def match_and_reply(self, message_content: str) -> list[str]:
"""根据消息内容匹配回复规则,返回需逐条发送的回复 payload 列表。无匹配则不回复。"""
from rpa_engine.douyin_im.reply_payload import split_reply_payloads
rules = await self.get_rules()
if not rules:
return []
fallback_reply = None
text = message_content or ""
for rule in rules:
if rule.match_type == "exact" and text.strip() == rule.keyword.strip():
return split_reply_payloads(rule.reply_content)
elif rule.match_type == "contains" and rule.keyword in text:
return split_reply_payloads(rule.reply_content)
elif rule.match_type == "regex":
import re
try:
if re.search(rule.keyword, text):
return split_reply_payloads(rule.reply_content)
except Exception as e:
logger.error(f"Regex match error: {e}")
elif rule.match_type == "default" and fallback_reply is None:
fallback_reply = rule.reply_content
if fallback_reply:
return split_reply_payloads(fallback_reply)
return []
async def on_im_session_invalid(self, reason: str):
"""IM 登录失效时自动下线:标记账号 offline 并停止托管循环。
若配置了 relogin_hook(WorkerManager 注入),同步通知上层自动重登录:
上层用 browser 模式重新启动 worker——浏览器流程会探测页面登录态,
未登录则自动弹二维码(前端账号卡片展示),扫码成功后自动采集凭证
并恢复托管。用户只需扫码,无需手动停止/启动。
"""
logger.warning(f"Account {self.account_id} IM 登录失效,自动下线:{reason}")
self.stopping = True
self.is_running = False
try:
if self._im_service:
self._im_service._running = False
except Exception:
pass
await self.update_account_status(
"offline",
error_msg=(
f"IM 登录已失效({reason}),正在自动重登录,"
"请留意账号卡片上的登录二维码并扫码"
),
)
if self.relogin_hook:
try:
await self.relogin_hook(self.account_id)
except Exception as exc:
logger.error(
f"Account {self.account_id}: relogin_hook failed: {exc}"
)
async def follow_welcome_tick(self):
"""检测新粉丝,给「已互相关注」且未发送过的新粉丝发送一次关注欢迎语。
- 首次运行会把现有粉丝全部「种子化」(不发送),避免给历史粉丝群发;
- 之后仅对新出现且 follow_status==2(互关)的粉丝发送,每人仅一次(DB 去重,重启仍生效)。
"""
if not self._im_service:
return
from rpa_engine.douyin_im.follower_poll import fetch_recent_followers
try:
enabled, content, sec_user_id = (
await self._refresh_follow_welcome_config()
)
except Exception:
raise
if not enabled or not content:
return
sec_user_id = str(sec_user_id or "").strip()
if not sec_user_id:
sec_user_id = await self._best_effort_sec_user_id(
refresh_if_missing=True,
)
if not sec_user_id:
logger.warning(
"Account %s skipped follow-welcome polling because sec_user_id "
"is temporarily unavailable",
self.account_id,
)
return
self._follow_welcome_sec_user_id = sec_user_id
# 1) 功能已启用时才读取已处理过的粉丝集合
db = await self.get_db()
try:
rows = (
await db.execute(
select(FollowWelcomeLog.follower_uid).where(
FollowWelcomeLog.account_id == self.account_id
)
)
).scalars().all()
known = set(rows)
first_run = len(known) == 0
finally:
await db.close()
controller = get_traffic_controller()
async with controller.background_slot(self.account_id, "follower poll"):
followers = await fetch_recent_followers(
self._im_service.session,
sec_user_id,
count=20,
)
if not followers:
return
# 2) 首次运行:种子化现有粉丝,不发送
if first_run:
db = await self.get_db()
try:
for f in followers:
db.add(
FollowWelcomeLog(
account_id=self.account_id,
follower_uid=f["uid"],
status="seed",
detail="首次运行种子,不发送",
)
)
await db.commit()
logger.info(
f"follow welcome: account {self.account_id} 首次运行,种子化 {len(followers)} 个现有粉丝(不发送)"
)
except Exception as e:
await db.rollback()
logger.error(f"follow welcome seed failed: {e}")
finally:
await db.close()
return
# 3) 给「新出现 + 已互关」的粉丝发送欢迎语
my_uid = str(getattr(self._im_service.session, "my_uid", "") or "")
for f in followers:
uid = f["uid"]
if not uid or uid in known:
continue
# 非互关:暂不发送、也不记录,待其与本账号互关后再触发
if f.get("follow_status") != 2:
continue
conv_id = f"0:1:{uid}:{my_uid}" if my_uid else uid
sent = False
err = ""
try:
sent = await self._im_service.send_message(conv_id, content)
if not sent:
err = self._im_service.last_error or "发送失败"
except Exception as e:
err = str(e)
db = await self.get_db()
try:
db.add(
FollowWelcomeLog(
account_id=self.account_id,
follower_uid=uid,
status="sent" if sent else "failed",
detail=None if sent else (err or "")[:500],
)
)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"follow welcome log failed: {e}")
finally:
await db.close()
known.add(uid)
await self.log_message(
sender_name=f.get("nickname") or uid,
sender_id=uid,
message="[新粉丝关注]",
reply=content,
status="replied" if sent else "failed",
error=None if sent else err,
)
logger.info(
"follow welcome -> %s (uid=%s) sent=%s err=%s",
f.get("nickname") or uid, uid, sent, err,
)
# 轻微间隔,降低频控风险
await asyncio.sleep(1.0)
async def start(self):
"""启动 RPA 任务"""
if self._task and not self._task.done():
return
self.stopping = False
self.is_running = True
self._startup_ready = asyncio.Event()
self._startup_error = ""
task = asyncio.create_task(
self._run_loop(),
name=f"douyin-worker-{self.account_id}",
)
self._task = task
def _log_task_result(done_task: asyncio.Task):
try:
exc = done_task.exception()
if exc:
logger.error(f"Worker {self.account_id} task exited with error: {format_error(exc)}")
except asyncio.CancelledError:
pass
finally:
if self._task is done_task:
self._task = None
task.add_done_callback(_log_task_result)
async def stop(self):
"""停止 RPA 任务"""
self.stopping = True
self.is_running = False
self._mark_startup_failed("托管初始化已取消")
await self._stop_keepalive()
if self._im_service:
await self._im_service.stop()
task = self._task
if task and task is not asyncio.current_task() and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if self._task is task:
self._task = None
await self.update_account_status("offline")
await self.cleanup()
async def _run_loop(self):
logger.info(f"Starting worker loop for account {self.account_id}")
try:
storage_state = await self._load_storage_state()
cookie_info = analyze_cookie(
json.dumps(storage_state, ensure_ascii=False) if storage_state else None
)
# The start API (including the bulk endpoint) persists "starting"
# before this task is spawned. Rewriting it here caused one extra
# transaction per account and amplified SQLite lock contention.
if self.login_mode == "im_direct":
if not storage_state:
self._mark_startup_failed("未保存 Cookie,无法直连 IM")
await self.update_account_status(
"error",
error_msg="未保存 Cookie,无法直连 IM",
)
return
started, reason = await self._try_cookie_only_im_start(storage_state)
if started:
return
self._mark_startup_failed(
reason or "凭证验证失败,无法直连 IM"
)
if self.stopping:
return
await self.update_account_status(
"error",
error_msg=reason or "凭证验证失败,无法直连 IM。请更新 Cookie 或选择浏览器登录",
)
return
if self.login_mode == "browser":
await self._run_browser_im_flow(storage_state, cookie_info)
return
# auto:先尝试 Cookie 直连,失败再浏览器(兼容旧调用)
if cookie_info["cookie_valid"] and storage_state:
started, _reason = await self._try_cookie_only_im_start(storage_state)
if started:
return
if self.stopping:
return
logger.info(
f"Account {self.account_id}: cookie-only IM failed, "
"falling back to browser flow"
)
await self._run_browser_im_flow(storage_state, cookie_info)
except asyncio.CancelledError:
logger.warning(f"Worker {self.account_id} cancelled")
self._mark_startup_failed("托管初始化已取消")
if not self.stopping:
await self.update_account_status("offline", error_msg="RPA 任务已中断,请重新点击启动")
raise
except Exception as e:
logger.exception(f"Error in RPA worker loop: {e}")
self._mark_startup_failed(format_error(e))
if not self.stopping:
await self.update_account_status("error", error_msg=format_error(e))
system_logger.record(
"托管任务异常退出",
detail=format_error(e),
level="error",
category="system",
account_id=self.account_id,
)
finally:
self.is_running = False
self._mark_startup_failed()
if not self.stopping:
await self.cleanup()
async def _run_browser_im_flow(self, storage_state: dict | None, cookie_info: dict):
"""浏览器重任务全局串行;完成采集后释放通道,再运行常驻 IM。"""
controller = get_traffic_controller()
async with controller.browser_slot(self.account_id, "browser credential harvest"):
try:
prepared = await self._prepare_browser_im_flow(storage_state, cookie_info)
finally:
# On cancellation/error, close network-active browser resources
# before another account is allowed into the single browser lane.
await self._close_browser_only()
if prepared is None or not self.is_running:
return
im_session, im_ok, im_reason = prepared
# 资料接口不依赖浏览器,必须在释放全局 browser slot 后核验,
# 避免资料接口波动长期占住其他账号的浏览器登录通道。
# sec_user_id 只服务于「关注欢迎语」轮询,收私信与自动回复都不需要它。
# 这里过去用 _require_sec_user_id 直接退出托管,导致资料接口拿不到
# sec_user_id 的账号浏览器登录后立刻下线、永远不会自动回复。
if not await self._best_effort_sec_user_id(force_refresh=True):
logger.warning(
"Account %s has no verified sec_user_id after browser login; "
"continuing IM hosting with follow-welcome polling unavailable",
self.account_id,
)
system_logger.record(
"未能核验 sec_user_id,关注欢迎语暂不可用",
detail=(
"私信接收与自动回复不依赖 sec_user_id,托管继续运行;"
"如需「关注后自动欢迎语」,请在账号管理中同步资料。"
),
level="warning",
category="auth",
account_id=self.account_id,
)
if im_ok:
await self._persist_im_session(
im_session,
status="online",
clear_error=True,
)
else:
logger.warning(
f"Account {self.account_id}: browser harvest done but messaging not ready: {im_reason}"
)
system_logger.record(
"浏览器采集完成但私信发送未就绪",
detail=im_reason,
level="warning",
category="auth",
account_id=self.account_id,
)
await self._persist_im_session(
im_session,
status="online",
error_msg=im_reason,
)
logger.info(
f"Account {self.account_id}: switching to IM direct mode "
f"(ws={'yes' if im_session.frontier_ws_url() else 'no'})"
)
await self._run_im_direct_service(im_session)
async def _prepare_browser_im_flow(self, storage_state: dict | None, cookie_info: dict):
"""浏览器登录 + 采集 IM 凭证 + 切换直连模式(首次登录或无 Cookie 时使用)"""
await self._load_user_agent()
# 残缺登录态:有 sid_guard/sid_tt 但无 sessionid。此时浏览器“看起来已登录”
# (能正常浏览抖音),于是永远不弹二维码,但 IM 又因缺 sessionid 拉不到会话、发不了私信。
# 直接丢弃旧登录态,用全新上下文强制重新扫码登录,拿到真正的 sessionid。
if storage_state and not cookie_info.get("has_sessionid"):
logger.warning(
f"Account {self.account_id}: saved cookies lack sessionid; "
"discarding stale session to force a fresh QR login"
)
system_logger.record(
"凭证缺少 sessionid,强制重新扫码登录",
detail="已保存的 Cookie 含 sid_guard/sid_tt 但无 sessionid,"
"浏览器会误判为已登录而不弹二维码。已丢弃旧登录态,将打开全新登录页扫码。",
level="warning",
category="auth",
account_id=self.account_id,
)
storage_state = None
self.playwright, browser_headless = await _start_playwright_for_browser()
import sys
args = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
]
if sys.platform == "win32":
args.append("--start-minimized")
try:
self.browser = await _launch_chromium(
self.playwright,
args,
headless=browser_headless,
)
logger.info(f"Account {self.account_id}: opening browser for IM setup (minimized)")
except RuntimeError:
# 无虚拟显示等带操作指引的错误原样抛出,避免被通用提示覆盖
raise
except Exception as e:
raise RuntimeError(
f"浏览器启动失败,请执行 playwright install chromium:{format_error(e)}"
) from e
context_options = self._browser_context_options()
if storage_state:
logger.info(
f"Loading saved session for account {self.account_id} "
f"(static check: {cookie_info['reason']})"
)
try:
self.context = await self.browser.new_context(
storage_state=storage_state,
**context_options,
)
except Exception as e:
logger.warning(f"Failed to load saved cookies, creating fresh context: {e}")
self.context = await self.browser.new_context(**context_options)
else:
logger.info("No session found. Creating new context.")
self.context = await self.browser.new_context(**context_options)
await self.context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
self.page = await self.context.new_page()
is_logged_in = False
if cookie_info["cookie_valid"] and await self.check_logged_in_by_cookie():
logger.info(
f"Valid cookies for account {self.account_id}, skipping login page"
)
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
await asyncio.sleep(3)
is_logged_in = await self._verify_login_state()
else:
is_logged_in = await self._probe_existing_login()
if is_logged_in:
logger.info(
f"Account {self.account_id} already logged in in browser, using current session"
)
elif cookie_info["cookie_valid"]:
logger.warning(
f"Valid cookies rejected at runtime for account {self.account_id}, "
"falling back to login flow"
)
is_logged_in = await self._probe_existing_login()
else:
logger.info("Not logged in. Navigating to Douyin Homepage to login...")
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
await asyncio.sleep(3)
is_logged_in = await self._verify_login_state()
if not is_logged_in:
is_logged_in = await self._perform_login()
if not is_logged_in:
logger.warning("Login timed out or worker stopped.")
await self.update_account_status(
"offline",
error_msg="登录超时,请在弹出的浏览器窗口完成抖音扫码登录",
)
await self.cleanup()
return
await self._finalize_login_session()
await self._setup_im_network_listener()
await self._navigate_to_message_center()
await self._harvest_im_credentials(timeout=25)
await self._persist_cookies()
im_session = await self._build_im_session()
im_ok, im_reason = await validate_im_session(im_session)
return im_session, im_ok, im_reason
def _is_404_page(self) -> bool:
# 抖音网页为单页应用,不会跳转到 /404,这里仅作占位(始终视为正常)
return False
async def _verify_login_state(self) -> bool:
"""判断账号是否已登录(不要求私信页已打开)"""
# A stale sessionid can survive a server-side KICK. Page-level login
# prompts are authoritative negative evidence and must win over the
# mere presence of that cookie, otherwise browser refresh skips QR
# login and gets stuck behind the message-center login dialog.
if await self._has_visible_login_prompt():
return False
if self._is_404_page():
return False
if await self.check_homepage_login_status():
return True
return await self.check_logged_in_by_cookie()
async def _probe_existing_login(self) -> bool:
"""打开浏览器后从首页探测登录态"""
logger.info("Probing existing login state from homepage...")
try:
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
await asyncio.sleep(3)
return await self._verify_login_state()
except Exception as e:
logger.warning(f"Homepage probe skipped: {e}")
return False
async def _has_visible_login_prompt(self) -> bool:
"""Return True when the current page visibly asks the user to log in."""
try:
login_modal = await self.page.query_selector(
"#login-pannel, [class*='login-guide'], [class*='login-mask'], [class*='account_login']"
)
if login_modal and await login_modal.is_visible():
return True
return bool(await self.page.evaluate("""() => {
const labels = new Set(['登录', '登录/注册', '立即登录']);
return [...document.querySelectorAll('button, a, [role="button"], p')]
.some((el) => labels.has((el.innerText || '').trim()) && el.offsetParent);
}"""))
except Exception as e:
logger.debug(f"Visible login prompt check failed: {e}")
return False
async def check_homepage_login_status(self) -> bool:
"""检查抖音首页是否处于已登录状态"""
try:
# 抖音登录后右上角有头像;未登录则有醒目的「登录」按钮/登录弹窗
if await self._has_visible_login_prompt():
return False
avatar = await self.page.query_selector(
"header [class*='avatar'] img, header img[class*='avatar'], "
"[data-e2e*='user-avatar'] img, [data-e2e='user-avatar']"
)
if avatar and await avatar.is_visible():
return True
# 抖音登录后导航栏会出现「私信」入口
except Exception as e:
logger.debug(f"Homepage login check failed: {e}")
return False
async def check_login_status(self) -> bool:
"""检查是否已登录(综合页面元素)"""
try:
return await self.check_homepage_login_status()
except Exception as e:
logger.error(f"Error checking login status: {e}")
return False
DOUYIN_COOKIE_DOMAINS = ("douyin.com", "amemv.com", "snssdk.com", "iesdouyin.com")
DOUYIN_LOGIN_COOKIES = {
"sessionid",
"sessionid_ss",
"sid_tt",
"sid_tt_ss",
"uid_tt",
"uid_tt_ss",
"sid_guard",
"passport_auth_status",
"passport_auth_status_ss",
"login_status",
"odin_tt",
}
async def check_logged_in_by_cookie(self) -> bool:
"""通过抖音登录 Cookie 判断是否已登录。
必须含真实 sessionid / sessionid_ss:仅有 sid_tt / sid_guard 等残缺登录
Cookie 不算登录。否则会误判“已登录”而跳过扫码,导致二维码永远弹不出来,
且后续因缺 sessionid 根本无法发私信。
"""
try:
cookies = await self.context.cookies()
seen_login = []
for cookie in cookies:
name = (cookie.get("name") or "").lower()
value = cookie.get("value") or ""
if not value:
continue
if name in ("sessionid", "sessionid_ss"):
logger.info(f"Login cookie detected: {name} @ {cookie.get('domain')}")
return True
if name in self.DOUYIN_LOGIN_COOKIES:
seen_login.append(name)
if seen_login:
logger.info(
"Partial login cookies present but no sessionid "
f"({','.join(sorted(set(seen_login)))}); treat as NOT logged in"
)
return False
except Exception as e:
logger.debug(f"Cookie login check failed: {e}")
return False
async def _perform_login(self) -> bool:
"""打开抖音登录页,等待用户扫码;成功后立即保存 Cookie(不依赖二维码抓取)"""
logger.info("Starting Douyin login flow (browser scan)...")
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
await asyncio.sleep(2)
if await self._verify_login_state():
logger.info("Already logged in before opening login panel")
await self._persist_cookies()
return True
await self._open_login_qr_panel()
await self.update_account_status("logging_in", clear_error=True)
timeout = 180
elapsed = 0
last_qr_refresh = 0
while elapsed < timeout and self.is_running:
if not self._is_browser_alive():
raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开")
if await self._verify_login_state():
logger.info("Douyin login detected, persisting cookies")
await asyncio.sleep(1)
await self._persist_cookies()
return True
if elapsed - last_qr_refresh >= 10:
last_qr_refresh = elapsed
try:
qr_image = await self._capture_login_qr_image()
if qr_image:
await self.update_account_status(
"logging_in",
qr_code=qr_image,
clear_error=True,
)
except Exception as e:
logger.debug(f"QR refresh skipped: {e}")
await asyncio.sleep(2)
elapsed += 2
if await self._verify_login_state():
await self._persist_cookies()
return True
return False
async def try_message_page_access(self) -> bool:
"""尝试打开私信页验证是否可用"""
try:
if not self._is_browser_alive():
return False
return await self._navigate_to_message_center()
except Exception as e:
logger.debug(f"Message page access check failed: {e}")
return False
async def wait_for_qr_login(self, qr_session: dict) -> bool:
"""等待用户在弹出的浏览器窗口中扫码并在抖音 App 内确认。
不自行调用抖音登录接口(避免风控签名问题),
页面自身的 JS 会完成扫码登录流程,我们只需轮询登录 Cookie 判断是否成功。
"""
timeout = 180 # 3 分钟超时
elapsed = 0
last_qr_refresh = 0
logger.info("Waiting for user to scan QR code and confirm on Douyin app...")
while elapsed < timeout and self.is_running:
if not self._is_browser_alive():
raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开")
if await self._verify_login_state():
logger.info("Login detected via cookies/page state")
return True
# 二维码 2 分钟会过期,定期刷新图像,保证前端展示最新二维码
if elapsed - last_qr_refresh >= 15:
last_qr_refresh = elapsed
await self._refresh_qr_image()
await asyncio.sleep(2)
elapsed += 2
return await self._verify_login_state()
# 抖音登录二维码相关选择器(class 名为 hash,尽量用通用属性匹配)
_QR_SELECTORS = [
# Douyin 新版登录弹窗常见结构
"[class*='qrcode'] img",
"[class*='QrCode'] img",
"[class*='qr-code'] img",
"img[class*='qrcode']",
"img[src*='qrcode']",
"img[alt*='二维码']",
"img[alt*='qr']",
"[class*='qrcode'] canvas",
"canvas[class*='qrcode']",
"[class*='scan'] img",
"[class*='scan'] canvas",
# 登录弹窗内最可能的 img / canvas
"[class*='login-guide'] img",
"[class*='login-guide'] canvas",
"[class*='login-panel'] img",
"[class*='login-panel'] canvas",
"[class*='login_pannel'] img",
"[class*='login_pannel'] canvas",
"[class*='account_login'] img",
"[class*='account_login'] canvas",
"#login-pannel img",
"#login-pannel canvas",
"#login-pannel [class*='qrcode']",
]
_QR_CONTAINER_SELECTORS = [
"[class*='qrcode-container']",
"[class*='qrcodeContainer']",
"[class*='qrcode']",
"[class*='QrCode']",
"[class*='login-scan']",
"[class*='scan-code']",
"[class*='login-guide']",
"[class*='login-panel']",
"[class*='login_pannel']",
"[class*='account_login']",
"#login-pannel",
]
# 兜底:抓不到二维码元素时,截取整块登录面板 / 登录 iframe,用户仍可扫描其中的码
_LOGIN_PANEL_SELECTORS = [
"#login-pannel",
"[id='login-pannel']",
"iframe[src*='passport']",
"iframe[src*='login']",
"iframe[src*='sso']",
"[class*='login_panel']",
"[class*='login-panel']",
"[class*='login_pannel']",
"[class*='account_login']",
"[class*='login-guide']",
"[class*='login-mask']",
]
def _all_frames(self) -> list:
"""页面所有 frame(含登录 iframe);抖音二维码常在 passport iframe 内。"""
try:
return list(self.page.frames)
except Exception:
return [self.page]
@staticmethod
def _looks_like_qr_box(box: dict) -> bool:
"""根据尺寸/长宽比判断一个元素是否像二维码区域。"""
if not box:
return False
w = box.get("width", 0)
h = box.get("height", 0)
if w < 80 or h < 80 or w > 600 or h > 600:
return False
ratio = min(w, h) / max(w, h)
return ratio >= 0.75
async def _grab_qr_in_frames(self) -> Optional[str]:
"""在所有 frame 内查找二维码
/