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 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 直连自动回复"""
# 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_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 并停止托管循环。"""
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 内查找二维码
/