2847 lines
119 KiB
Python
2847 lines
119 KiB
Python
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.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.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.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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 启动错峰门:批量启动大量账号时,把各 worker 的启动时刻按固定间隔排开,
|
||
# 避免同一瞬间大量凭证校验/WS 建连/首轮拉取叠峰。
|
||
# 空闲时单个账号启动无需等待;只有短时间内大量启动才会排队。
|
||
# KEFU_WORKER_START_INTERVAL_SECONDS:相邻两个 worker 启动的最小间隔,默认 1.5s,<=0 关闭。
|
||
# ---------------------------------------------------------------------------
|
||
_start_gate = {"lock": None, "next_at": 0.0}
|
||
|
||
|
||
async def _startup_stagger(account_id: int) -> None:
|
||
try:
|
||
interval = float(os.getenv("KEFU_WORKER_START_INTERVAL_SECONDS", "") or 1.5)
|
||
except ValueError:
|
||
interval = 1.5
|
||
if interval <= 0:
|
||
return
|
||
if _start_gate["lock"] is None:
|
||
_start_gate["lock"] = asyncio.Lock()
|
||
async with _start_gate["lock"]:
|
||
now = time.monotonic()
|
||
wait = max(0.0, _start_gate["next_at"] - now)
|
||
_start_gate["next_at"] = max(now, _start_gate["next_at"]) + interval
|
||
if wait > 0:
|
||
if wait > 5:
|
||
logger.info(
|
||
f"Account {account_id}: start queued, waiting {wait:.1f}s to smooth batch startup"
|
||
)
|
||
await asyncio.sleep(wait)
|
||
|
||
|
||
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"):
|
||
self.account_id = account_id
|
||
self.login_mode = login_mode # auto | im_direct | browser
|
||
self.browser = None
|
||
self.context = None
|
||
self.page = None
|
||
self.playwright = None
|
||
self.is_running = False
|
||
self.stopping = False
|
||
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._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 = ""
|
||
|
||
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).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
self._user_agent = resolve_user_agent(account.user_agent if account else 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 _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).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
if account and account.cookie_data:
|
||
return json.loads(account.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).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
if account:
|
||
saved_im = account.im_session_data
|
||
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"])
|
||
ensure_frontier_ws(session)
|
||
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)
|
||
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
|
||
|
||
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)
|
||
await self.update_account_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).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
if account and account.cookie_data:
|
||
return json.loads(account.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):
|
||
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:
|
||
await db.execute(
|
||
update(Account).where(Account.id == self.account_id).values(
|
||
im_session_data=payload,
|
||
updated_at=datetime.utcnow(),
|
||
)
|
||
)
|
||
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 进程供下次登录;IM 直连不再依赖浏览器"""
|
||
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"Browser close skipped: {e}")
|
||
finally:
|
||
self.page = None
|
||
self.context = None
|
||
self.browser = None
|
||
|
||
async def get_reply_delay(self) -> int:
|
||
db = await self.get_db()
|
||
try:
|
||
result = await db.execute(select(Account).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
if not account:
|
||
return 0
|
||
return max(0, int(account.reply_delay_seconds or 0))
|
||
finally:
|
||
await db.close()
|
||
|
||
async def get_reply_cooldown(self) -> "int | None":
|
||
"""读取该账号专属冷却秒数;返回 None 表示继承全局设置。"""
|
||
db = await self.get_db()
|
||
try:
|
||
result = await db.execute(select(Account).where(Account.id == self.account_id))
|
||
account = result.scalar_one_or_none()
|
||
if not account or account.reply_cooldown_seconds is None:
|
||
return None
|
||
return max(0, int(account.reply_cooldown_seconds))
|
||
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 直连自动回复"""
|
||
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,
|
||
# 关注欢迎语:周期性检测新粉丝并自动私信(约每 60s)
|
||
follow_tick=self.follow_welcome_tick,
|
||
# IM 登录失效(INVALID_REQUEST)时自动下线
|
||
on_session_invalid=self.on_im_session_invalid,
|
||
# 实时解析冷却时间(账号专属优先,否则全局),改设置无需重启托管
|
||
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=message,
|
||
reply_content=reply,
|
||
status=status,
|
||
error_message=error,
|
||
created_at=datetime.utcnow()
|
||
)
|
||
db.add(log)
|
||
await db.commit()
|
||
logger.info(f"Logged message: sender={sender_name}, msg={message}, reply={reply}")
|
||
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
|
||
|
||
# 1) 读账号配置 + 本账号 sec_user_id + 已处理过的粉丝集合
|
||
db = await self.get_db()
|
||
try:
|
||
acc = (
|
||
await db.execute(select(Account).where(Account.id == self.account_id))
|
||
).scalar_one_or_none()
|
||
if not acc or not acc.follow_welcome_enabled:
|
||
return
|
||
content = (acc.follow_welcome_content or "").strip()
|
||
if not content:
|
||
return
|
||
detail = (
|
||
await db.execute(
|
||
select(AccountProfileDetail).where(
|
||
AccountProfileDetail.account_id == self.account_id
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
sec_user_id = (detail.sec_user_id if detail else "") or ""
|
||
if not sec_user_id:
|
||
logger.warning(
|
||
f"follow welcome: account {self.account_id} 缺少 sec_user_id,跳过(请先在账号管理同步资料)"
|
||
)
|
||
return
|
||
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()
|
||
|
||
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) 给「新出现 + 已互关」的粉丝发送欢迎语(支持文本/网址/链接卡片)
|
||
from rpa_engine.douyin_im.reply_payload import (
|
||
format_reply_display,
|
||
serialize_reply_log,
|
||
split_reply_payloads,
|
||
)
|
||
|
||
welcome_payloads = split_reply_payloads(content)
|
||
if not welcome_payloads:
|
||
return
|
||
|
||
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 = ""
|
||
reply_display = serialize_reply_log(welcome_payloads)
|
||
try:
|
||
for index, payload in enumerate(welcome_payloads):
|
||
if index > 0:
|
||
await asyncio.sleep(0.6)
|
||
ok = await self._im_service.send_message(conv_id, payload)
|
||
if not ok:
|
||
err = self._im_service.last_error or "发送失败"
|
||
reply_display = format_reply_display(payload)
|
||
break
|
||
else:
|
||
sent = True
|
||
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=reply_display,
|
||
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 任务"""
|
||
self.stopping = False
|
||
self.is_running = True
|
||
task = asyncio.create_task(self._run_loop())
|
||
|
||
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
|
||
|
||
task.add_done_callback(_log_task_result)
|
||
|
||
async def stop(self):
|
||
"""停止 RPA 任务"""
|
||
self.stopping = True
|
||
self.is_running = False
|
||
if self._im_service:
|
||
await self._im_service.stop()
|
||
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:
|
||
await _startup_stagger(self.account_id)
|
||
if self.stopping:
|
||
return
|
||
storage_state = await self._load_storage_state()
|
||
cookie_info = analyze_cookie(
|
||
json.dumps(storage_state, ensure_ascii=False) if storage_state else None
|
||
)
|
||
await self.update_account_status("starting", clear_error=True)
|
||
|
||
if self.login_mode == "im_direct":
|
||
if not storage_state:
|
||
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
|
||
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
|
||
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")
|
||
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}")
|
||
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
|
||
if not self.stopping:
|
||
await self.cleanup()
|
||
|
||
async def _run_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)
|
||
await self._persist_im_session(im_session)
|
||
if im_ok:
|
||
await self.update_account_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.update_account_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._close_browser_only()
|
||
await self._run_im_direct_service(im_session)
|
||
|
||
def _is_404_page(self) -> bool:
|
||
# 抖音网页为单页应用,不会跳转到 /404,这里仅作占位(始终视为正常)
|
||
return False
|
||
|
||
async def _verify_login_state(self) -> bool:
|
||
"""判断账号是否已登录(不要求私信页已打开)"""
|
||
if await self.check_logged_in_by_cookie():
|
||
return True
|
||
if self._is_404_page():
|
||
return False
|
||
if await self.check_homepage_login_status():
|
||
return True
|
||
return await self.check_login_status()
|
||
|
||
async def _probe_existing_login(self) -> bool:
|
||
"""打开浏览器后从首页探测登录态"""
|
||
logger.info("Probing existing login state from homepage...")
|
||
try:
|
||
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
|
||
await asyncio.sleep(3)
|
||
return await self._verify_login_state()
|
||
except Exception as e:
|
||
logger.warning(f"Homepage probe skipped: {e}")
|
||
return False
|
||
|
||
async def check_homepage_login_status(self) -> bool:
|
||
"""检查抖音首页是否处于已登录状态"""
|
||
try:
|
||
# 抖音登录后右上角有头像;未登录则有醒目的「登录」按钮/登录弹窗
|
||
login_modal = await self.page.query_selector(
|
||
"#login-pannel, [class*='login-guide'], [class*='login-mask'], [class*='account_login']"
|
||
)
|
||
if login_modal and await login_modal.is_visible():
|
||
return False
|
||
|
||
avatar = await self.page.query_selector(
|
||
"[class*='avatar'] img, img[class*='avatar'], [class*='Avatar']"
|
||
)
|
||
if avatar and await avatar.is_visible():
|
||
return True
|
||
|
||
# 抖音登录后导航栏会出现「私信」入口
|
||
has_dm_entry = await self.page.evaluate("""() => {
|
||
const nodes = [...document.querySelectorAll('a, span, div, button')];
|
||
return nodes.some((el) => {
|
||
const t = (el.innerText || '').trim();
|
||
return (t === '私信' || t === '消息') && el.offsetParent;
|
||
});
|
||
}""")
|
||
if has_dm_entry:
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"Homepage login check failed: {e}")
|
||
return False
|
||
|
||
async def check_login_status(self) -> bool:
|
||
"""检查是否已登录(综合页面元素)"""
|
||
try:
|
||
return await self.check_homepage_login_status()
|
||
except Exception as e:
|
||
logger.error(f"Error checking login status: {e}")
|
||
return False
|
||
|
||
DOUYIN_COOKIE_DOMAINS = ("douyin.com", "amemv.com", "snssdk.com", "iesdouyin.com")
|
||
DOUYIN_LOGIN_COOKIES = {
|
||
"sessionid",
|
||
"sessionid_ss",
|
||
"sid_tt",
|
||
"sid_tt_ss",
|
||
"uid_tt",
|
||
"uid_tt_ss",
|
||
"sid_guard",
|
||
"passport_auth_status",
|
||
"passport_auth_status_ss",
|
||
"login_status",
|
||
"odin_tt",
|
||
}
|
||
|
||
async def check_logged_in_by_cookie(self) -> bool:
|
||
"""通过抖音登录 Cookie 判断是否已登录。
|
||
|
||
必须含真实 sessionid / sessionid_ss:仅有 sid_tt / sid_guard 等残缺登录
|
||
Cookie 不算登录。否则会误判“已登录”而跳过扫码,导致二维码永远弹不出来,
|
||
且后续因缺 sessionid 根本无法发私信。
|
||
"""
|
||
try:
|
||
cookies = await self.context.cookies()
|
||
seen_login = []
|
||
for cookie in cookies:
|
||
name = (cookie.get("name") or "").lower()
|
||
value = cookie.get("value") or ""
|
||
if not value:
|
||
continue
|
||
if name in ("sessionid", "sessionid_ss"):
|
||
logger.info(f"Login cookie detected: {name} @ {cookie.get('domain')}")
|
||
return True
|
||
if name in self.DOUYIN_LOGIN_COOKIES:
|
||
seen_login.append(name)
|
||
if seen_login:
|
||
logger.info(
|
||
"Partial login cookies present but no sessionid "
|
||
f"({','.join(sorted(set(seen_login)))}); treat as NOT logged in"
|
||
)
|
||
return False
|
||
except Exception as e:
|
||
logger.debug(f"Cookie login check failed: {e}")
|
||
return False
|
||
|
||
async def _perform_login(self) -> bool:
|
||
"""打开抖音登录页,等待用户扫码;成功后立即保存 Cookie(不依赖二维码抓取)"""
|
||
logger.info("Starting Douyin login flow (browser scan)...")
|
||
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
|
||
await asyncio.sleep(2)
|
||
|
||
if await self._verify_login_state():
|
||
logger.info("Already logged in before opening login panel")
|
||
await self._persist_cookies()
|
||
return True
|
||
|
||
await self._open_login_qr_panel()
|
||
await self.update_account_status("logging_in", clear_error=True)
|
||
|
||
timeout = 180
|
||
elapsed = 0
|
||
last_qr_refresh = 0
|
||
|
||
while elapsed < timeout and self.is_running:
|
||
if not self._is_browser_alive():
|
||
raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开")
|
||
|
||
if await self._verify_login_state():
|
||
logger.info("Douyin login detected, persisting cookies")
|
||
await asyncio.sleep(1)
|
||
await self._persist_cookies()
|
||
return True
|
||
|
||
if elapsed - last_qr_refresh >= 10:
|
||
last_qr_refresh = elapsed
|
||
try:
|
||
qr_image = await self._capture_login_qr_image()
|
||
if qr_image:
|
||
await self.update_account_status(
|
||
"logging_in",
|
||
qr_code=qr_image,
|
||
clear_error=True,
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"QR refresh skipped: {e}")
|
||
|
||
await asyncio.sleep(2)
|
||
elapsed += 2
|
||
|
||
if await self._verify_login_state():
|
||
await self._persist_cookies()
|
||
return True
|
||
return False
|
||
|
||
async def try_message_page_access(self) -> bool:
|
||
"""尝试打开私信页验证是否可用"""
|
||
try:
|
||
if not self._is_browser_alive():
|
||
return False
|
||
return await self._navigate_to_message_center()
|
||
except Exception as e:
|
||
logger.debug(f"Message page access check failed: {e}")
|
||
return False
|
||
|
||
async def wait_for_qr_login(self, qr_session: dict) -> bool:
|
||
"""等待用户在弹出的浏览器窗口中扫码并在抖音 App 内确认。
|
||
|
||
不自行调用抖音登录接口(避免风控签名问题),
|
||
页面自身的 JS 会完成扫码登录流程,我们只需轮询登录 Cookie 判断是否成功。
|
||
"""
|
||
timeout = 180 # 3 分钟超时
|
||
elapsed = 0
|
||
last_qr_refresh = 0
|
||
logger.info("Waiting for user to scan QR code and confirm on Douyin app...")
|
||
|
||
while elapsed < timeout and self.is_running:
|
||
if not self._is_browser_alive():
|
||
raise RuntimeError("浏览器窗口已关闭,请重新点击启动并保持窗口打开")
|
||
|
||
if await self._verify_login_state():
|
||
logger.info("Login detected via cookies/page state")
|
||
return True
|
||
|
||
# 二维码 2 分钟会过期,定期刷新图像,保证前端展示最新二维码
|
||
if elapsed - last_qr_refresh >= 15:
|
||
last_qr_refresh = elapsed
|
||
await self._refresh_qr_image()
|
||
|
||
await asyncio.sleep(2)
|
||
elapsed += 2
|
||
|
||
return await self._verify_login_state()
|
||
|
||
# 抖音登录二维码相关选择器(class 名为 hash,尽量用通用属性匹配)
|
||
_QR_SELECTORS = [
|
||
"[class*='qrcode'] img",
|
||
"[class*='QrCode'] img",
|
||
"[class*='qr-code'] img",
|
||
"img[class*='qrcode']",
|
||
"img[src*='qrcode']",
|
||
"img[alt*='二维码']",
|
||
"img[alt*='qr']",
|
||
"[class*='qrcode'] canvas",
|
||
"canvas[class*='qrcode']",
|
||
"[class*='scan'] img",
|
||
"[class*='scan'] canvas",
|
||
]
|
||
_QR_CONTAINER_SELECTORS = [
|
||
"[class*='qrcode-container']",
|
||
"[class*='qrcodeContainer']",
|
||
"[class*='qrcode']",
|
||
"[class*='QrCode']",
|
||
"[class*='login-scan']",
|
||
"[class*='scan-code']",
|
||
]
|
||
# 兜底:抓不到二维码元素时,截取整块登录面板 / 登录 iframe,用户仍可扫描其中的码
|
||
_LOGIN_PANEL_SELECTORS = [
|
||
"#login-pannel",
|
||
"[id='login-pannel']",
|
||
"iframe[src*='passport']",
|
||
"iframe[src*='login']",
|
||
"iframe[src*='sso']",
|
||
"[class*='login_panel']",
|
||
"[class*='login-panel']",
|
||
"[class*='account_login']",
|
||
]
|
||
|
||
def _all_frames(self) -> list:
|
||
"""页面所有 frame(含登录 iframe);抖音二维码常在 passport iframe 内。"""
|
||
try:
|
||
return list(self.page.frames)
|
||
except Exception:
|
||
return [self.page]
|
||
|
||
async def _grab_qr_in_frames(self) -> Optional[str]:
|
||
"""在所有 frame 内查找二维码 <img>/<canvas> 并转为 data URL。"""
|
||
for frame in self._all_frames():
|
||
for sel in self._QR_SELECTORS:
|
||
try:
|
||
el = await frame.query_selector(sel)
|
||
except Exception:
|
||
continue
|
||
if not el:
|
||
continue
|
||
try:
|
||
if not await el.is_visible():
|
||
continue
|
||
src = await el.get_attribute("src") or ""
|
||
if src.startswith("data:image"):
|
||
return src
|
||
if src.startswith("http"):
|
||
try:
|
||
resp = await self.page.request.get(src)
|
||
if resp.ok:
|
||
b64 = base64.b64encode(await resp.body()).decode("utf-8")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception:
|
||
pass
|
||
shot = await el.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info(f"Captured QR via element in frame: {sel}")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception as e:
|
||
if "Execution context was destroyed" in str(e):
|
||
return None
|
||
continue
|
||
return None
|
||
|
||
async def _grab_login_panel_shot(self) -> Optional[str]:
|
||
"""兜底:截取登录面板 / 登录 iframe 整块(含其中的二维码),用户仍可扫描。"""
|
||
for sel in self._QR_CONTAINER_SELECTORS + self._LOGIN_PANEL_SELECTORS:
|
||
try:
|
||
container = await self.page.query_selector(sel)
|
||
if not container or not await container.is_visible():
|
||
continue
|
||
box = await container.bounding_box()
|
||
if not box or box.get("width", 0) < 80 or box.get("height", 0) < 80:
|
||
continue
|
||
shot = await container.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info(f"Captured QR via panel screenshot: {sel}")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception as e:
|
||
if "Execution context was destroyed" in str(e):
|
||
return None
|
||
continue
|
||
return None
|
||
|
||
async def _check_and_grab_captcha(self) -> Optional[str]:
|
||
"""检测页面是否显示了验证码(滑块/点击等),如果显示了,则对验证码区域或整页截图。"""
|
||
captcha_selectors = [
|
||
"#captcha-container",
|
||
".secsdk-captcha-drag-wrapper",
|
||
"[class*='secsdk-captcha']",
|
||
"[class*='captcha-modal']",
|
||
"[class*='captcha_widget']",
|
||
"[id*='captcha-wrapper']",
|
||
"iframe[src*='captcha']",
|
||
"iframe[src*='secsdk']",
|
||
"[class*='verify-sub-panel']",
|
||
"[class*='verify-active']",
|
||
]
|
||
|
||
# 1. 遍历所有 frame 寻找可见的验证码元素并截图
|
||
for frame in self._all_frames():
|
||
for sel in captcha_selectors:
|
||
try:
|
||
el = await frame.query_selector(sel)
|
||
if el and await el.is_visible():
|
||
box = await el.bounding_box()
|
||
if box and box.get("width", 0) > 80 and box.get("height", 0) > 80:
|
||
shot = await el.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info(f"Captured captcha element via: {sel} in frame {frame.url[:50]}")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception:
|
||
continue
|
||
|
||
# 2. 也可以在主页面检查是否有可见的验证码 iframe 元素并直接截图 iframe
|
||
try:
|
||
iframes = await self.page.query_selector_all("iframe[src*='captcha'], iframe[src*='secsdk']")
|
||
for iframe in iframes:
|
||
if iframe and await iframe.is_visible():
|
||
box = await iframe.bounding_box()
|
||
if box and box.get("width", 0) > 80 and box.get("height", 0) > 80:
|
||
shot = await iframe.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info("Captured captcha iframe from parent page")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception as e:
|
||
logger.debug(f"Parent page iframe captcha screenshot failed: {e}")
|
||
|
||
# 3. 针对主页面,检查是否有包含验证字样的可见覆盖层,作为兜底
|
||
try:
|
||
has_visible_captcha_text = await self.page.evaluate("""() => {
|
||
const text = document.body ? document.body.innerText : '';
|
||
const hasKeywords = text.includes('验证') || text.includes('安全验证') || text.includes('滑动') || text.includes('智能验证') || text.includes('验证码');
|
||
if (hasKeywords) {
|
||
const divs = [...document.querySelectorAll('div')];
|
||
return divs.some(d => d.offsetParent && parseInt(window.getComputedStyle(d).zIndex) > 100);
|
||
}
|
||
return false;
|
||
}""")
|
||
if has_visible_captcha_text:
|
||
shot = await self.page.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info("Captured full-page screenshot due to detected captcha text")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception as e:
|
||
logger.debug(f"Full-page captcha check failed: {e}")
|
||
|
||
return None
|
||
|
||
async def _grab_qr_data_url(self) -> Optional[str]:
|
||
"""统一二维码抓取:先检测验证码并截图,失败再找二维码,接着回退到登录面板,最后如果都失败,直接截取整页。"""
|
||
if not self._is_browser_alive():
|
||
return None
|
||
|
||
# 1. 优先检测并截图验证码
|
||
captcha_img = await self._check_and_grab_captcha()
|
||
if captcha_img:
|
||
return captcha_img
|
||
|
||
# 2. 正常获取二维码元素
|
||
qr = await self._grab_qr_in_frames()
|
||
if qr:
|
||
return qr
|
||
|
||
# 3. 登录面板截图
|
||
panel = await self._grab_login_panel_shot()
|
||
if panel:
|
||
return panel
|
||
|
||
# 4. 终极兜底:直接截取整个网页视口(保证无论如何都有画面,而不是转圈)
|
||
try:
|
||
shot = await self.page.screenshot(type="png")
|
||
b64 = base64.b64encode(shot).decode("utf-8")
|
||
logger.info("Captured QR via full-viewport screenshot fallback")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception as e:
|
||
logger.warning(f"Full-page screenshot fallback failed: {e}")
|
||
|
||
return None
|
||
|
||
async def _refresh_qr_image(self):
|
||
"""从页面重新抓取二维码图像并更新到数据库(含过期自动刷新)"""
|
||
try:
|
||
if not self._is_browser_alive():
|
||
return
|
||
|
||
# 若二维码已过期,点击刷新区域让页面重新生成
|
||
expired = await self.page.query_selector(
|
||
"[class*='expire'] *, [class*='refresh'], [class*='Refresh']"
|
||
)
|
||
if expired and await expired.is_visible():
|
||
logger.info("QR code expired, refreshing...")
|
||
await expired.click(force=True)
|
||
await asyncio.sleep(1.5)
|
||
|
||
qr = await self._grab_qr_data_url()
|
||
if qr:
|
||
await self.update_account_status("logging_in", qr_code=qr)
|
||
except Exception as e:
|
||
logger.debug(f"QR refresh skipped: {e}")
|
||
|
||
async def _open_login_qr_panel(self):
|
||
"""打开抖音网页版扫码登录面板,并确保切换到扫码登录标签"""
|
||
# 1. 抖音首页未登录时通常会自动弹出登录框;若没有则点击「登录」按钮
|
||
login_modal = await self.page.query_selector(
|
||
"#login-pannel, [class*='login-guide'], [class*='account_login'], [class*='login-mask']"
|
||
)
|
||
is_modal_visible = False
|
||
if login_modal:
|
||
try:
|
||
is_modal_visible = await login_modal.is_visible()
|
||
except Exception:
|
||
pass
|
||
|
||
if not is_modal_visible:
|
||
clicked = await self.page.evaluate("""() => {
|
||
const nodes = [...document.querySelectorAll('button, span, div, a, p')];
|
||
for (const el of nodes) {
|
||
const t = (el.innerText || '').trim();
|
||
if ((t === '登录' || t === '登录/注册' || t === '立即登录') && el.offsetParent) {
|
||
el.click();
|
||
return t;
|
||
}
|
||
}
|
||
return '';
|
||
}""")
|
||
if clicked:
|
||
logger.info(f"Clicked login button: {clicked}")
|
||
await asyncio.sleep(2)
|
||
|
||
# 2. 无论登录框是自动弹出还是手动点击弹出的,都在所有 frame 里寻找「扫码登录」并切换
|
||
# (登录框可能渲染在 iframe 中,必须遍历所有 frame,且不能因为 modal 已经可见就提前 return)
|
||
for frame in self._all_frames():
|
||
try:
|
||
switched = await frame.evaluate("""() => {
|
||
const nodes = [...document.querySelectorAll('span, div, a, button, p')];
|
||
let best = null;
|
||
for (const el of nodes) {
|
||
const t = (el.innerText || '').trim();
|
||
if ((t === '扫码登录' || t === '扫码') && el.offsetParent) {
|
||
if (!best || el.children.length < best.children.length) {
|
||
best = el;
|
||
}
|
||
}
|
||
}
|
||
if (best) {
|
||
best.click();
|
||
return 'switched';
|
||
}
|
||
return '';
|
||
}""")
|
||
if switched:
|
||
logger.info(f"Switched to QR login tab in frame: {frame.url[:60]}")
|
||
await asyncio.sleep(1.5)
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"Failed to check/switch to QR login in frame: {e}")
|
||
|
||
async def _capture_login_qr_image(self) -> Optional[str]:
|
||
"""尝试从当前页面截取抖音登录二维码(跨 frame + 面板兜底)"""
|
||
if not self._is_browser_alive():
|
||
return None
|
||
if await self._verify_login_state():
|
||
return None
|
||
return await self._grab_qr_data_url()
|
||
|
||
async def get_login_qr_code(self) -> dict:
|
||
"""触发并截取抖音登录二维码"""
|
||
qr_result = {
|
||
"qr_code": None,
|
||
"error": None,
|
||
}
|
||
try:
|
||
if await self._verify_login_state():
|
||
logger.info("Browser already logged in, skip QR code")
|
||
qr_result["already_logged_in"] = True
|
||
return qr_result
|
||
|
||
await self._open_login_qr_panel()
|
||
|
||
for _ in range(40):
|
||
if not self._is_browser_alive():
|
||
qr_result["error"] = "浏览器窗口已关闭"
|
||
return qr_result
|
||
|
||
if await self._verify_login_state():
|
||
logger.info("Login completed while capturing QR code")
|
||
qr_result["already_logged_in"] = True
|
||
return qr_result
|
||
|
||
try:
|
||
qr_image = await self._capture_login_qr_image()
|
||
if qr_image:
|
||
qr_result["qr_code"] = qr_image
|
||
return qr_result
|
||
except Exception as e:
|
||
if "Execution context was destroyed" in str(e):
|
||
await asyncio.sleep(1)
|
||
if await self._verify_login_state():
|
||
logger.info("Login detected after page navigation")
|
||
qr_result["already_logged_in"] = True
|
||
return qr_result
|
||
else:
|
||
logger.debug(f"QR capture attempt skipped: {e}")
|
||
|
||
await asyncio.sleep(0.5)
|
||
|
||
if await self._verify_login_state():
|
||
logger.info("Already logged in after QR wait")
|
||
qr_result["already_logged_in"] = True
|
||
return qr_result
|
||
|
||
logger.warning("QR code not found after waiting; will wait for browser login")
|
||
qr_result["error"] = "未能获取抖音登录二维码,请在弹出的浏览器中完成扫码登录"
|
||
return qr_result
|
||
except Exception as e:
|
||
logger.error(f"Failed to get login QR code: {e}")
|
||
if await self._verify_login_state():
|
||
qr_result["already_logged_in"] = True
|
||
return qr_result
|
||
qr_result["error"] = format_error(e)
|
||
return qr_result
|
||
|
||
async def get_logged_avatar(self) -> str:
|
||
"""获取登录后的账号头像 URL"""
|
||
try:
|
||
avatar_selectors = [
|
||
".header-user-avatar img",
|
||
"[class*='user-avatar'] img",
|
||
"[class*='avatar-container'] img",
|
||
"header img[class*='avatar']",
|
||
"[class*='avatar'] img",
|
||
]
|
||
for sel in avatar_selectors:
|
||
el = await self.page.query_selector(sel)
|
||
if el:
|
||
src = await el.get_attribute("src")
|
||
if src and src.startswith("http"):
|
||
return src.strip()
|
||
return ""
|
||
except Exception:
|
||
return ""
|
||
|
||
async def get_logged_username(self) -> str:
|
||
"""获取登录后的账号用户名"""
|
||
try:
|
||
nickname_selectors = [
|
||
"[class*='nickname']",
|
||
"[class*='Nickname']",
|
||
"[class*='user-name']",
|
||
"[class*='userName']",
|
||
"[class*='account-name']",
|
||
]
|
||
for sel in nickname_selectors:
|
||
el = await self.page.query_selector(sel)
|
||
if el:
|
||
name = await el.text_content()
|
||
if name and name.strip():
|
||
return name.strip()
|
||
return f"抖音账号_{self.account_id}"
|
||
except Exception:
|
||
return f"抖音账号_{self.account_id}"
|
||
|
||
async def _wait_for_im_panel(self, timeout: int = 25) -> bool:
|
||
"""等待私信会话列表渲染完成"""
|
||
for i in range(timeout):
|
||
if self._session_api_seen:
|
||
logger.info("IM panel ready via API session data")
|
||
return True
|
||
if self._api_unread_total > 0 or any(
|
||
c.get("hasUnread") or c.get("unreadCount") for c in self._api_conversations
|
||
):
|
||
logger.info("IM panel ready via API unread/conversations")
|
||
return True
|
||
if await self._im_overlay_visible():
|
||
logger.info("IM panel ready via overlay detection")
|
||
return True
|
||
result = await self._discover_conversations_merged()
|
||
rows = result.get("rows", []) if isinstance(result, dict) else []
|
||
if rows:
|
||
logger.info(f"IM panel ready: {len(rows)} conversations ({result.get('panelClass', '')[:20]})")
|
||
return True
|
||
if i and i % 5 == 0:
|
||
diag = await self._page_diagnostics()
|
||
logger.info(f"Waiting for IM panel... {diag}")
|
||
await asyncio.sleep(1)
|
||
logger.warning(f"IM panel not ready after {timeout}s: {await self._page_diagnostics()}")
|
||
return False
|
||
|
||
async def _im_overlay_visible(self) -> bool:
|
||
"""检测抖音网页私信侧栏/弹层是否已打开"""
|
||
try:
|
||
return await self.page.evaluate("""() => {
|
||
const selectors = [
|
||
'[class*="im-"]', '[class*="Im"]', '[class*="message-panel"]',
|
||
'[class*="chat-list"]', '[class*="conversation"]', '[class*="Conversation"]',
|
||
];
|
||
for (const sel of selectors) {
|
||
const nodes = document.querySelectorAll(sel);
|
||
for (const el of nodes) {
|
||
if (!el.offsetParent) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width > 200 && rect.height > 200) return true;
|
||
}
|
||
}
|
||
const text = document.body?.innerText || '';
|
||
return text.includes('发消息') && (text.includes('私信') || text.includes('会话'));
|
||
}""")
|
||
except Exception:
|
||
return False
|
||
|
||
async def _page_diagnostics(self) -> str:
|
||
try:
|
||
info = await self.page.evaluate("""() => ({
|
||
url: location.href,
|
||
title: document.title,
|
||
iframes: document.querySelectorAll('iframe').length,
|
||
textLen: (document.body?.innerText || '').length,
|
||
})""")
|
||
return str(info)
|
||
except Exception as e:
|
||
return f"diag error: {e}"
|
||
|
||
async def _click_message_entry(self) -> str:
|
||
"""在页面上点击私信/消息入口"""
|
||
return await self.page.evaluate("""() => {
|
||
const keywords = ['私信', '消息', '消息中心', '我的消息'];
|
||
const nodes = [...document.querySelectorAll('a, button, span, div, li, [role="button"], [role="menuitem"]')];
|
||
for (const kw of keywords) {
|
||
for (const el of nodes) {
|
||
const t = (el.innerText || '').trim();
|
||
if (!t || t.length > 20) continue;
|
||
if (t === kw || t.startsWith(kw)) {
|
||
if (el.offsetParent) {
|
||
el.click();
|
||
return 'text:' + kw;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const a of document.querySelectorAll('a[href]')) {
|
||
const href = (a.getAttribute('href') || '').toLowerCase();
|
||
if (!a.offsetParent) continue;
|
||
if (href.includes('message') || href.includes('/im') || href.includes('inbox') || href.includes('chat')) {
|
||
a.click();
|
||
return 'href:' + href;
|
||
}
|
||
}
|
||
const icons = document.querySelectorAll('[class*="message"], [class*="Message"], [class*="im-"], [class*="IM"]');
|
||
for (const el of icons) {
|
||
if (!el.offsetParent) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width < 8 || rect.height < 8) continue;
|
||
if (el.closest('a, button, [role="button"]')) {
|
||
(el.closest('a, button, [role="button"]') || el).click();
|
||
return 'icon:' + (el.className || '').slice(0, 40);
|
||
}
|
||
}
|
||
return '';
|
||
}""")
|
||
|
||
async def _click_user_avatar_menu(self) -> bool:
|
||
"""点击头像展开菜单后再找消息入口"""
|
||
selectors = [
|
||
".header-user-avatar",
|
||
"[class*='user-avatar']",
|
||
"[class*='UserAvatar']",
|
||
"[class*='avatar-container']",
|
||
"img[class*='avatar']",
|
||
]
|
||
for sel in selectors:
|
||
el = await self.page.query_selector(sel)
|
||
if el and await el.is_visible():
|
||
await el.click(force=True)
|
||
await asyncio.sleep(1.5)
|
||
return True
|
||
return False
|
||
|
||
async def _open_message_via_direct_url(self) -> bool:
|
||
"""尝试直接打开抖音私信页"""
|
||
for url in (
|
||
"https://www.douyin.com/?recommend=1",
|
||
"https://www.douyin.com",
|
||
):
|
||
await self._safe_goto(url, wait_until="domcontentloaded")
|
||
await asyncio.sleep(3)
|
||
try:
|
||
await self.page.wait_for_load_state("networkidle", timeout=12000)
|
||
except Exception:
|
||
pass
|
||
clicked = await self._click_message_entry()
|
||
if clicked:
|
||
logger.info(f"Clicked message entry: {clicked}")
|
||
await asyncio.sleep(4)
|
||
if await self._wait_for_im_panel(timeout=20):
|
||
return True
|
||
return False
|
||
|
||
async def _open_message_via_homepage(self) -> bool:
|
||
"""从首页 UI 进入私信"""
|
||
logger.info("Opening messages via homepage UI...")
|
||
await self._safe_goto("https://www.douyin.com", wait_until="domcontentloaded")
|
||
await asyncio.sleep(4)
|
||
try:
|
||
await self.page.wait_for_load_state("networkidle", timeout=15000)
|
||
except Exception:
|
||
pass
|
||
|
||
clicked = await self._click_message_entry()
|
||
if clicked:
|
||
logger.info(f"Clicked message entry: {clicked}")
|
||
await asyncio.sleep(4)
|
||
if await self._wait_for_im_panel(timeout=20):
|
||
return True
|
||
|
||
if await self._click_user_avatar_menu():
|
||
clicked = await self._click_message_entry()
|
||
if clicked:
|
||
logger.info(f"Clicked message entry from user menu: {clicked}")
|
||
await asyncio.sleep(4)
|
||
if await self._wait_for_im_panel(timeout=20):
|
||
return True
|
||
return False
|
||
|
||
async def _navigate_to_message_center(self, force: bool = False) -> bool:
|
||
"""进入抖音私信页"""
|
||
if not self._is_browser_alive():
|
||
return False
|
||
|
||
if not force and self._message_page_ready:
|
||
result = await self._discover_conversations_merged()
|
||
if result.get("rows") or self._session_api_seen:
|
||
return True
|
||
|
||
strategies = [
|
||
self._open_message_via_direct_url,
|
||
self._open_message_via_homepage,
|
||
]
|
||
for strategy in strategies:
|
||
try:
|
||
if await strategy():
|
||
self._message_page_ready = True
|
||
logger.info(f"Message center ready at {self.page.url}")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"Message navigation failed: {format_error(e)}")
|
||
|
||
self._message_page_ready = False
|
||
logger.error(f"All message navigation strategies failed: {await self._page_diagnostics()}")
|
||
return False
|
||
|
||
async def _ensure_message_page(self, force: bool = False) -> bool:
|
||
"""确保私信页可用"""
|
||
if self._is_404_page():
|
||
force = True
|
||
if not force and self._message_page_ready:
|
||
result = await self._discover_conversations_merged()
|
||
if result.get("rows"):
|
||
return True
|
||
return await self._navigate_to_message_center(force=force)
|
||
|
||
def _make_reply_key(self, sender: str, content: str) -> str:
|
||
return f"{sender}::{content}"
|
||
|
||
def _sender_in_cooldown(self, sender: str, cooldown: int) -> bool:
|
||
if cooldown <= 0 or not sender:
|
||
return False
|
||
last = self._last_reply_at.get(sender)
|
||
if last is None:
|
||
return False
|
||
return (time.monotonic() - last) < cooldown
|
||
|
||
async def _setup_im_network_listener(self):
|
||
"""监听 IM 相关网络请求与 WebSocket,辅助捕获新消息"""
|
||
async def on_response(response):
|
||
url = response.url
|
||
lower = url.lower()
|
||
if not any(d in lower for d in ("douyin.com", "amemv.com", "snssdk.com")):
|
||
return
|
||
if not any(
|
||
k in lower
|
||
for k in ("im", "message", "chat", "session", "conversation", "private", "inbox", "stranger", "notice", "/v1/", "/v2/")
|
||
):
|
||
return
|
||
if url not in self._seen_im_urls:
|
||
self._seen_im_urls.add(url)
|
||
logger.info(f"IM API: {url[:120]}")
|
||
try:
|
||
ct = response.headers.get("content-type", "")
|
||
if response.status != 200:
|
||
return
|
||
if "imapi.douyin.com" in lower:
|
||
body = await response.body()
|
||
if body:
|
||
from rpa_engine.douyin_im.im_proto import extract_conv_meta_from_response_bytes
|
||
|
||
meta = extract_conv_meta_from_response_bytes(body)
|
||
if meta:
|
||
self._im_conv_meta.update(meta)
|
||
logger.info(f"Captured IM conv meta from API: {len(meta)} conversation(s)")
|
||
if "json" not in ct:
|
||
return
|
||
data = await response.json()
|
||
if "imapi.douyin.com" in lower:
|
||
self._parse_douyin_imapi(url, data)
|
||
self._extract_messages_from_api(data)
|
||
self._extract_conversations_from_api(data)
|
||
except Exception:
|
||
pass
|
||
|
||
self.page.on("response", on_response)
|
||
|
||
def on_websocket(ws):
|
||
lower = ws.url.lower()
|
||
if not any(k in lower for k in ("im", "message", "chat", "frontier", "ws", "imapi")):
|
||
return
|
||
if ws.url not in self._captured_ws_urls:
|
||
self._captured_ws_urls.append(ws.url)
|
||
logger.info(f"IM WebSocket: {ws.url[:120]}")
|
||
|
||
def on_frame(payload):
|
||
asyncio.create_task(self._handle_ws_frame(payload))
|
||
|
||
ws.on("framereceived", on_frame)
|
||
|
||
self.page.on("websocket", on_websocket)
|
||
|
||
async def _handle_ws_frame(self, payload):
|
||
try:
|
||
from rpa_engine.douyin_im.protocol import parse_ws_payload
|
||
from rpa_engine.douyin_im.message_content import serialize_message_content
|
||
|
||
if isinstance(payload, bytes):
|
||
items = parse_ws_payload(payload)
|
||
for item in items:
|
||
content = item.get("content") or ""
|
||
if not content:
|
||
continue
|
||
self._pending_im_messages.append({
|
||
"sender": item.get("sender_name") or item.get("sender_uid") or "未知用户",
|
||
"content": content,
|
||
"conversation_id": item.get("conversation_id") or "",
|
||
})
|
||
return
|
||
|
||
text = str(payload).strip()
|
||
if not text or text[0] not in "{[":
|
||
return
|
||
data = json.loads(text)
|
||
self._extract_messages_from_api(data)
|
||
self._extract_conversations_from_api(data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _extract_messages_from_api(self, data, depth=0, sender_hint: str = ""):
|
||
"""从 IM API / WebSocket 响应中递归提取消息(含图片/表情)。"""
|
||
from rpa_engine.douyin_im.message_content import parse_incoming_message
|
||
|
||
if depth > 8:
|
||
return
|
||
if isinstance(data, dict):
|
||
sender = (
|
||
data.get("senderName") or data.get("sender_name")
|
||
or data.get("userName") or data.get("nickname")
|
||
or data.get("fromUserName") or data.get("peerName")
|
||
or sender_hint or ""
|
||
)
|
||
from_self = data.get("fromSelf") or data.get("isSelf") or data.get("self")
|
||
content = parse_incoming_message(data)
|
||
if content and len(content) < 2000 and not from_self:
|
||
self._pending_im_messages.append({
|
||
"sender": str(sender or "未知用户"),
|
||
"content": content,
|
||
})
|
||
for v in data.values():
|
||
self._extract_messages_from_api(v, depth + 1, str(sender or sender_hint))
|
||
elif isinstance(data, list):
|
||
for item in data:
|
||
self._extract_messages_from_api(item, depth + 1, sender_hint)
|
||
|
||
def _extract_conversations_from_api(self, data, depth=0):
|
||
"""从会话列表 API 中提取预览变化"""
|
||
if depth > 8:
|
||
return
|
||
if isinstance(data, dict):
|
||
name = (
|
||
data.get("userName") or data.get("nickname") or data.get("peerName")
|
||
or data.get("sessionName") or data.get("name")
|
||
)
|
||
preview = (
|
||
data.get("lastMessage") or data.get("lastMsg") or data.get("preview")
|
||
or data.get("brief") or data.get("content")
|
||
)
|
||
if isinstance(preview, dict):
|
||
preview = preview.get("text") or preview.get("content") or preview.get("message")
|
||
unread_raw = data.get("unreadCount") or data.get("unread") or data.get("hasUnread")
|
||
unread_count = 0
|
||
if isinstance(unread_raw, bool):
|
||
has_unread = unread_raw
|
||
else:
|
||
try:
|
||
unread_count = int(unread_raw or 0)
|
||
has_unread = unread_count > 0
|
||
except (TypeError, ValueError):
|
||
has_unread = bool(unread_raw)
|
||
if isinstance(name, str) and name.strip() and isinstance(preview, str) and preview.strip():
|
||
sender = name.strip()
|
||
content = preview.strip()
|
||
prev = self._conv_previews.get(sender)
|
||
if self._should_auto_reply(prev, content, has_unread, unread_count):
|
||
self._pending_im_messages.append({
|
||
"sender": sender,
|
||
"content": content,
|
||
"_from_session_list": True,
|
||
"_unread": has_unread or unread_count > 0,
|
||
})
|
||
if prev is None:
|
||
self._conv_previews[sender] = content
|
||
self._session_api_seen = True
|
||
for v in data.values():
|
||
self._extract_conversations_from_api(v, depth + 1)
|
||
elif isinstance(data, list):
|
||
for item in data:
|
||
self._extract_conversations_from_api(item, depth + 1)
|
||
|
||
def _extract_text_content(self, value) -> str:
|
||
from rpa_engine.douyin_im.message_content import parse_incoming_message
|
||
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, dict):
|
||
parsed = parse_incoming_message(value)
|
||
if parsed:
|
||
return parsed
|
||
for key in ("text", "content", "message", "msg", "title", "desc"):
|
||
text = value.get(key)
|
||
if isinstance(text, str) and text.strip():
|
||
return text.strip()
|
||
if isinstance(value, str):
|
||
return value.strip()
|
||
return ""
|
||
|
||
def _extract_conversation_name(self, data: dict) -> str:
|
||
for key in ("nick_name", "nickname", "userName", "name", "sec_name", "remark_name"):
|
||
val = data.get(key)
|
||
if isinstance(val, str) and val.strip():
|
||
return val.strip()
|
||
for nested_key in ("core_info", "conversation_core_info", "user_info", "peer_info", "target_user"):
|
||
nested = data.get(nested_key)
|
||
if isinstance(nested, dict):
|
||
name = self._extract_conversation_name(nested)
|
||
if name:
|
||
return name
|
||
return ""
|
||
|
||
def _upsert_api_conversation(self, name: str, preview: str, unread_count: int):
|
||
name = (name or "").strip()
|
||
if not name:
|
||
return
|
||
preview = (preview or "").strip()
|
||
unread_count = max(0, int(unread_count or 0))
|
||
for idx, conv in enumerate(self._api_conversations):
|
||
if conv.get("name") == name:
|
||
self._api_conversations[idx] = {
|
||
"index": idx,
|
||
"name": name,
|
||
"preview": preview or conv.get("preview", ""),
|
||
"hasUnread": unread_count > 0,
|
||
"unreadCount": unread_count,
|
||
}
|
||
self._session_api_seen = True
|
||
return
|
||
self._api_conversations.append({
|
||
"index": len(self._api_conversations),
|
||
"name": name,
|
||
"preview": preview,
|
||
"hasUnread": unread_count > 0,
|
||
"unreadCount": unread_count,
|
||
})
|
||
self._session_api_seen = True
|
||
|
||
def _ingest_douyin_conversation_node(self, data: dict):
|
||
if not isinstance(data, dict):
|
||
return
|
||
unread_raw = (
|
||
data.get("unread_count") or data.get("unread_cnt")
|
||
or data.get("unreadCount") or data.get("badge_count") or 0
|
||
)
|
||
try:
|
||
unread_count = int(unread_raw or 0)
|
||
except (TypeError, ValueError):
|
||
unread_count = 0
|
||
|
||
name = self._extract_conversation_name(data)
|
||
preview = ""
|
||
for msg_key in ("last_message", "latest_message", "last_msg", "lastMessage", "preview", "brief"):
|
||
preview = self._extract_text_content(data.get(msg_key))
|
||
if preview:
|
||
break
|
||
|
||
if name and (preview or unread_count > 0):
|
||
self._upsert_api_conversation(name, preview, unread_count)
|
||
if unread_count > 0 or self._should_auto_reply(
|
||
self._conv_previews.get(name), preview, unread_count > 0, unread_count
|
||
):
|
||
self._pending_im_messages.append({
|
||
"sender": name,
|
||
"content": preview or "[未读消息]",
|
||
"_from_session_list": True,
|
||
"_unread": unread_count > 0,
|
||
})
|
||
|
||
def _ingest_douyin_conversations(self, data, depth: int = 0):
|
||
if depth > 12:
|
||
return
|
||
if isinstance(data, dict):
|
||
keys = set(data.keys())
|
||
if keys & {
|
||
"unread_count", "unread_cnt", "unreadCount", "conversation_id",
|
||
"conversation_short_id", "core_info", "conversation_core_info",
|
||
}:
|
||
self._ingest_douyin_conversation_node(data)
|
||
for value in data.values():
|
||
self._ingest_douyin_conversations(value, depth + 1)
|
||
elif isinstance(data, list):
|
||
for item in data:
|
||
self._ingest_douyin_conversations(item, depth + 1)
|
||
|
||
def _parse_douyin_imapi(self, url: str, data):
|
||
lower = url.lower()
|
||
if "imapi.douyin.com" not in lower:
|
||
return
|
||
if any(k in lower for k in ("conversation/list", "get_conversation_list", "stranger/get_conversation")):
|
||
before = len(self._api_conversations)
|
||
self._ingest_douyin_conversations(data)
|
||
added = len(self._api_conversations) - before
|
||
if added or self._api_conversations:
|
||
logger.info(
|
||
f"Parsed Douyin conversations from API: total={len(self._api_conversations)}, new={added}"
|
||
)
|
||
elif "unread_count" in lower and isinstance(data, dict):
|
||
total = 0
|
||
for key in ("total_unread", "unread_count", "unread_total", "count"):
|
||
try:
|
||
total = max(total, int(data.get(key) or 0))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
for key, val in data.items():
|
||
if "unread" in str(key).lower():
|
||
try:
|
||
total = max(total, int(val or 0))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if total > 0:
|
||
self._api_unread_total = total
|
||
logger.info(f"Douyin unread total from API: {total}")
|
||
elif any(k in lower for k in ("get_message", "get_user_message", "message/get")):
|
||
self._ingest_douyin_conversations(data)
|
||
self._extract_messages_from_api(data)
|
||
|
||
async def _discover_conversations_merged(self) -> dict:
|
||
"""合并 DOM 与 IM API 会话列表"""
|
||
dom_result = await self._discover_conversations()
|
||
dom_rows = dom_result.get("rows", []) if isinstance(dom_result, dict) else []
|
||
if dom_rows:
|
||
return dom_result
|
||
if self._api_conversations:
|
||
return {
|
||
"rows": list(self._api_conversations),
|
||
"panelClass": "api",
|
||
}
|
||
return dom_result
|
||
|
||
async def _discover_conversations(self) -> dict:
|
||
"""弹性探测左侧会话列表(含 iframe)"""
|
||
return await self.page.evaluate("""() => {
|
||
function collectRows(root) {
|
||
const rows = [];
|
||
const panelSelectors = [
|
||
'[class*="session-list"]', '[class*="SessionList"]',
|
||
'[class*="conversation-list"]', '[class*="ConversationList"]',
|
||
'[class*="chat-list"]', '[class*="message-list"]',
|
||
'[class*="im-list"]', '[class*="IMList"]',
|
||
'[class*="inbox"]', '[class*="Inbox"]',
|
||
'[class*="dialog-list"]', '[class*="DialogList"]',
|
||
];
|
||
let panel = null;
|
||
for (const sel of panelSelectors) {
|
||
panel = root.querySelector(sel);
|
||
if (panel) break;
|
||
}
|
||
if (!panel) {
|
||
const candidates = root.querySelectorAll('[class*="left"], [class*="side"], [class*="list"]');
|
||
for (const c of candidates) {
|
||
if (c.querySelectorAll('li, [role="listitem"]').length >= 1) {
|
||
panel = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!panel) return { rows: [], panelClass: '' };
|
||
|
||
const itemSelectors = [
|
||
'[class*="session"]', '[class*="Session"]',
|
||
'[class*="conversation"]', '[class*="Conversation"]',
|
||
'[class*="chat-item"]', '[class*="ChatItem"]',
|
||
'[class*="message-item"]', '[class*="MessageItem"]',
|
||
'[class*="dialog-item"]', '[class*="DialogItem"]',
|
||
'li', '[role="listitem"]',
|
||
];
|
||
const seen = new Set();
|
||
for (const sel of itemSelectors) {
|
||
panel.querySelectorAll(sel).forEach((el) => {
|
||
if (!el.offsetParent || seen.has(el)) return;
|
||
const text = (el.innerText || '').trim();
|
||
if (!text || text.length > 300 || text.length < 2) return;
|
||
if (/^私信$|^消息$|^登录$|^搜索/.test(text)) return;
|
||
seen.add(el);
|
||
const lines = text.split('\\n').map(s => s.trim()).filter(Boolean);
|
||
const name = lines[0] || '未知用户';
|
||
const preview = lines.length > 1 ? lines[lines.length - 1] : '';
|
||
const cls = (el.className || '') + ' ' + (el.getAttribute('class') || '');
|
||
let hasUnread = /unread|badge|dot|new/i.test(cls)
|
||
|| !!el.querySelector('[class*="unread"],[class*="badge"],[class*="dot"],[class*="new"],[class*="count"]');
|
||
let unreadCount = 0;
|
||
const badge = el.querySelector('[class*="unread"],[class*="badge"],[class*="dot"],[class*="count"],[class*="num"]');
|
||
if (badge) {
|
||
const n = parseInt((badge.innerText || '').trim(), 10);
|
||
if (!isNaN(n) && n > 0) {
|
||
unreadCount = n;
|
||
hasUnread = true;
|
||
}
|
||
}
|
||
rows.push({ index: rows.length, name, preview, hasUnread, unreadCount });
|
||
});
|
||
}
|
||
return { rows, panelClass: panel.className || '' };
|
||
}
|
||
|
||
let result = collectRows(document);
|
||
if (!result.rows.length) {
|
||
for (const frame of document.querySelectorAll('iframe')) {
|
||
try {
|
||
const doc = frame.contentDocument;
|
||
if (!doc) continue;
|
||
result = collectRows(doc);
|
||
if (result.rows.length) {
|
||
result.panelClass = 'iframe:' + (result.panelClass || '');
|
||
break;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
return result;
|
||
}""")
|
||
|
||
async def _click_conversation(self, index: int):
|
||
"""点击指定索引的会话项"""
|
||
await self.page.evaluate("""(idx) => {
|
||
const panelSelectors = [
|
||
'[class*="session-list"]', '[class*="SessionList"]',
|
||
'[class*="conversation-list"]', '[class*="chat-list"]',
|
||
'[class*="message-list"]', '[class*="im-list"]',
|
||
];
|
||
let panel = null;
|
||
for (const sel of panelSelectors) {
|
||
panel = document.querySelector(sel);
|
||
if (panel) break;
|
||
}
|
||
if (!panel) panel = document.querySelector('[class*="left"], [class*="side"], [class*="list"]');
|
||
if (!panel) return false;
|
||
|
||
const itemSelectors = [
|
||
'[class*="session"]', '[class*="conversation"]',
|
||
'[class*="chat-item"]', '[class*="message-item"]',
|
||
'li', '[role="listitem"]',
|
||
];
|
||
const items = [];
|
||
const seen = new Set();
|
||
for (const sel of itemSelectors) {
|
||
panel.querySelectorAll(sel).forEach((el) => {
|
||
if (!el.offsetParent || seen.has(el)) return;
|
||
const text = (el.innerText || '').trim();
|
||
if (!text || text.length > 300 || text.length < 2) return;
|
||
seen.add(el);
|
||
items.push(el);
|
||
});
|
||
}
|
||
if (items[idx]) { items[idx].click(); return true; }
|
||
return false;
|
||
}""", index)
|
||
|
||
async def _extract_last_incoming_message(self) -> str:
|
||
"""从当前打开的聊天窗口提取最后一条对方消息(含图片/表情 URL)。"""
|
||
return await self.page.evaluate("""() => {
|
||
function mediaFromNode(n) {
|
||
const img = n.querySelector('img');
|
||
if (img && img.src && img.src.startsWith('http')) {
|
||
const w = img.naturalWidth || img.width || null;
|
||
const h = img.naturalHeight || img.height || null;
|
||
const alt = (img.alt || '').trim();
|
||
const cls = ((n.className || '') + ' ' + (img.className || '')).toLowerCase();
|
||
const isSticker = /sticker|emoji|emot|gif|表情/.test(cls + alt);
|
||
const payload = {
|
||
type: isSticker ? 'sticker' : 'image',
|
||
text: isSticker ? '[表情包]' : '[图片]',
|
||
url: img.src
|
||
};
|
||
if (w) payload.width = w;
|
||
if (h) payload.height = h;
|
||
if (alt) payload.name = alt;
|
||
return JSON.stringify(payload);
|
||
}
|
||
const audio = n.querySelector('audio');
|
||
if (audio && audio.src && audio.src.startsWith('http')) {
|
||
return JSON.stringify({ type: 'voice', text: '[语音]', url: audio.src });
|
||
}
|
||
const video = n.querySelector('video');
|
||
if (video && video.src && video.src.startsWith('http')) {
|
||
return JSON.stringify({ type: 'video', text: '[视频]', url: video.src });
|
||
}
|
||
return '';
|
||
}
|
||
|
||
const panelSelectors = [
|
||
'[class*="message-list"]', '[class*="MessageList"]',
|
||
'[class*="chat-content"]', '[class*="ChatContent"]',
|
||
'[class*="msg-list"]', '[class*="dialog"]',
|
||
'[class*="im-chat"]', '[id*="message"]',
|
||
];
|
||
let panel = null;
|
||
for (const sel of panelSelectors) {
|
||
const els = document.querySelectorAll(sel);
|
||
for (const el of els) {
|
||
if (el.offsetParent && el.querySelector('[class*="msg"], [class*="message"], pre, img')) {
|
||
panel = el;
|
||
break;
|
||
}
|
||
}
|
||
if (panel) break;
|
||
}
|
||
if (!panel) panel = document.body;
|
||
|
||
const candidates = panel.querySelectorAll(
|
||
'[class*="message"], [class*="msg"], [class*="bubble"], pre, span, div'
|
||
);
|
||
const msgs = [];
|
||
candidates.forEach((n) => {
|
||
const media = mediaFromNode(n);
|
||
const t = media || (n.innerText || '').trim();
|
||
if (!t || t.length > 2000) return;
|
||
let cls = (n.className || '') + ' ' + (n.parentElement?.className || '');
|
||
const style = window.getComputedStyle(n);
|
||
const isSelf = /self|mine|right|send|outgoing|owner/i.test(cls)
|
||
|| style.textAlign === 'right'
|
||
|| style.justifyContent === 'flex-end'
|
||
|| n.closest('[class*="self"],[class*="mine"],[class*="right"],[class*="send"]');
|
||
msgs.push({ text: t, isSelf: !!isSelf });
|
||
});
|
||
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
if (!msgs[i].isSelf) return msgs[i].text;
|
||
}
|
||
return '';
|
||
}""")
|
||
|
||
async def _send_chat_reply(self, text: str) -> bool:
|
||
"""在聊天输入框输入并发送回复"""
|
||
focused = await self.page.evaluate("""() => {
|
||
function findInput(root) {
|
||
const inputs = [
|
||
...root.querySelectorAll('textarea'),
|
||
...root.querySelectorAll('[contenteditable="true"]'),
|
||
...root.querySelectorAll('[class*="editor"]'),
|
||
...root.querySelectorAll('[class*="input"]'),
|
||
];
|
||
for (const el of inputs) {
|
||
if (!el.offsetParent) continue;
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width < 50) continue;
|
||
el.focus();
|
||
el.click();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
if (findInput(document)) return true;
|
||
for (const frame of document.querySelectorAll('iframe')) {
|
||
try {
|
||
if (frame.contentDocument && findInput(frame.contentDocument)) return true;
|
||
} catch (e) {}
|
||
}
|
||
return false;
|
||
}""")
|
||
if not focused:
|
||
return False
|
||
|
||
await self.page.keyboard.press("Control+A")
|
||
await self.page.keyboard.press("Backspace")
|
||
await self.page.keyboard.type(text, delay=30)
|
||
await asyncio.sleep(0.4)
|
||
|
||
sent = await self.page.evaluate("""() => {
|
||
const all = [...document.querySelectorAll('button, [role="button"], span, div, a')];
|
||
for (const el of all) {
|
||
const t = (el.innerText || '').trim();
|
||
if (t === '发送' && el.offsetParent) { el.click(); return true; }
|
||
}
|
||
const sendEl = document.querySelector('[class*="send"], [class*="Send"]');
|
||
if (sendEl && sendEl.offsetParent) { sendEl.click(); return true; }
|
||
return false;
|
||
}""")
|
||
if not sent:
|
||
await self.page.keyboard.press("Enter")
|
||
await asyncio.sleep(1)
|
||
return True
|
||
|
||
async def _process_conversation_reply(self, conv: dict) -> bool:
|
||
"""打开会话并对未读/新消息自动回复"""
|
||
name = conv.get("name", "未知用户")
|
||
preview = conv.get("preview", "")
|
||
has_unread = conv.get("hasUnread", False)
|
||
unread_count = conv.get("unreadCount", 0)
|
||
|
||
logger.info(
|
||
f"Processing conversation [{name}]: preview={preview!r}, "
|
||
f"unread={has_unread}, count={unread_count}"
|
||
)
|
||
clicked = await self._click_conversation(conv["index"])
|
||
if not clicked:
|
||
clicked = await self._click_conversation_by_name(name)
|
||
if not clicked:
|
||
logger.warning(f"Failed to open conversation [{name}]")
|
||
return False
|
||
|
||
await asyncio.sleep(1.5)
|
||
last_msg = await self._extract_last_incoming_message()
|
||
if not last_msg:
|
||
last_msg = preview
|
||
if not last_msg:
|
||
logger.warning(f"No message content for [{name}]")
|
||
return False
|
||
|
||
await self._handle_incoming_message(name, last_msg)
|
||
if preview:
|
||
self._conv_previews[name] = preview
|
||
return True
|
||
|
||
async def _reply_all_unread_conversations(self, rows: list) -> int:
|
||
"""回复所有未读会话"""
|
||
unread_rows = [
|
||
r for r in rows
|
||
if r.get("hasUnread") or (r.get("unreadCount") or 0) > 0
|
||
]
|
||
if not unread_rows:
|
||
return 0
|
||
|
||
logger.info(f"Found {len(unread_rows)} unread conversations, auto-replying...")
|
||
replied = 0
|
||
for conv in unread_rows:
|
||
if await self._process_conversation_reply(conv):
|
||
replied += 1
|
||
await asyncio.sleep(0.8)
|
||
return replied
|
||
|
||
async def _handle_incoming_message(self, sender_name: str, message_content: str):
|
||
"""处理一条收到的消息:匹配规则并自动回复"""
|
||
if not message_content:
|
||
return
|
||
|
||
await self.log_received_message(
|
||
sender_name=sender_name,
|
||
raw_content=message_content,
|
||
)
|
||
|
||
reply_key = self._make_reply_key(sender_name, message_content)
|
||
if reply_key in self._replied_keys:
|
||
return
|
||
|
||
replies = await self.match_and_reply(message_content)
|
||
if not replies:
|
||
replies = await self.match_and_reply("")
|
||
if not replies:
|
||
await self.log_message(
|
||
sender_name=sender_name, sender_id=None,
|
||
message=message_content, reply=None, status="ignored",
|
||
error="未配置任何自动回复规则,请在「自动回复规则」中添加至少一条启用规则",
|
||
)
|
||
self._replied_keys.add(reply_key)
|
||
return
|
||
|
||
# 冷却窗口:同一用户在设定时间内,无论发多少条消息,只自动回复一次(账号设置优先,否则全局)
|
||
cooldown = await self.resolve_cooldown_seconds()
|
||
if self._sender_in_cooldown(sender_name, cooldown):
|
||
logger.info(
|
||
f"Auto-reply to {sender_name} skipped: within {cooldown}s cooldown window"
|
||
)
|
||
self._replied_keys.add(reply_key)
|
||
return
|
||
# 进入回复流程前先打时间戳,确保发送期间到达的消息也被抑制
|
||
if cooldown > 0:
|
||
self._last_reply_at[sender_name] = time.monotonic()
|
||
|
||
from rpa_engine.douyin_im.reply_payload import format_reply_display
|
||
|
||
reply_displays: list[str] = []
|
||
sent_any = False
|
||
last_error = ""
|
||
for index, reply_content in enumerate(replies):
|
||
if index > 0:
|
||
await asyncio.sleep(0.6)
|
||
reply_display = format_reply_display(reply_content)
|
||
reply_displays.append(reply_display)
|
||
logger.info(
|
||
f"Replying to {sender_name} ({index + 1}/{len(replies)}): "
|
||
f"{message_content!r} -> {reply_display!r}"
|
||
)
|
||
# 浏览器模式仅支持在输入框键入文本,网址/卡片降级为可读摘要发送
|
||
sent = await self._send_chat_reply(reply_display)
|
||
if sent:
|
||
sent_any = True
|
||
else:
|
||
last_error = "找不到输入框或发送按钮"
|
||
|
||
combined_display = " | ".join(reply_displays)
|
||
if sent_any:
|
||
self._replied_keys.add(reply_key)
|
||
await self.log_message(
|
||
sender_name=sender_name, sender_id=None,
|
||
message=message_content, reply=combined_display, status="replied",
|
||
)
|
||
else:
|
||
# 发送彻底失败:清除冷却时间戳,避免把没收到回复的用户锁在冷却窗口内
|
||
if cooldown > 0:
|
||
self._last_reply_at.pop(sender_name, None)
|
||
await self.log_message(
|
||
sender_name=sender_name, sender_id=None,
|
||
message=message_content, reply=combined_display,
|
||
status="failed", error=last_error or "找不到输入框或发送按钮",
|
||
)
|
||
system_logger.record(
|
||
"自动回复失败(浏览器模式)",
|
||
detail=f"回复 {sender_name} 失败:{last_error or '找不到输入框或发送按钮'}(收到:{message_content})",
|
||
level="error",
|
||
category="send",
|
||
account_id=self.account_id,
|
||
)
|
||
|
||
async def message_monitor_loop(self):
|
||
"""消息监听与自动回复主循环(DOM + 网络 + WebSocket)"""
|
||
logger.info("Entering message monitor loop...")
|
||
await self._setup_im_network_listener()
|
||
if not await self._ensure_message_page():
|
||
logger.warning("Message page not ready, will retry in monitor loop")
|
||
else:
|
||
result = await self._discover_conversations_merged()
|
||
rows = result.get("rows", []) if isinstance(result, dict) else []
|
||
if rows:
|
||
count = await self._reply_all_unread_conversations(rows)
|
||
logger.info(f"Startup unread auto-reply done: {count} conversations processed")
|
||
self._startup_unread_scan_done = True
|
||
elif self._api_unread_total > 0:
|
||
logger.info(
|
||
f"Startup: API reports {self._api_unread_total} unread, waiting for conversation list..."
|
||
)
|
||
|
||
loop_count = 0
|
||
while self.is_running:
|
||
if not self._is_browser_alive():
|
||
logger.warning(f"Browser closed for account {self.account_id}, stopping monitor")
|
||
await self.update_account_status("offline", error_msg="浏览器窗口已关闭,托管已停止")
|
||
self.is_running = False
|
||
break
|
||
try:
|
||
loop_count += 1
|
||
if loop_count % 12 == 0:
|
||
await self._persist_cookies()
|
||
|
||
if not self._message_page_ready or loop_count % 30 == 1:
|
||
await self._ensure_message_page(force=loop_count % 30 == 1)
|
||
|
||
# 处理网络 / WebSocket 捕获的消息
|
||
pending = self._pending_im_messages[:]
|
||
self._pending_im_messages.clear()
|
||
for msg in pending:
|
||
sender = msg.get("sender", "未知用户")
|
||
content = msg.get("content", "")
|
||
is_unread = msg.get("_unread", False)
|
||
prev = self._conv_previews.get(sender)
|
||
if not self._should_auto_reply(prev, content, is_unread):
|
||
if prev is None and content:
|
||
self._conv_previews[sender] = content
|
||
continue
|
||
await self._click_conversation_by_name(sender)
|
||
await asyncio.sleep(1.2)
|
||
last_msg = await self._extract_last_incoming_message() or content
|
||
await self._handle_incoming_message(sender, last_msg)
|
||
if content:
|
||
self._conv_previews[sender] = content
|
||
|
||
# DOM / API 探测会话列表
|
||
result = await self._discover_conversations_merged()
|
||
rows = result.get("rows", []) if isinstance(result, dict) else []
|
||
|
||
if loop_count % 6 == 1:
|
||
logger.info(
|
||
f"Monitor tick #{loop_count}: found {len(rows)} conversations, "
|
||
f"api={len(self._api_conversations)}, unread_api={self._api_unread_total}, "
|
||
f"page_ready={self._message_page_ready}, pending_ws={len(pending)}"
|
||
+ (f", panel={result.get('panelClass', '')[:40]}" if rows else "")
|
||
)
|
||
if not rows:
|
||
logger.info(f"Page state: {await self._page_diagnostics()}")
|
||
|
||
# 每轮优先处理所有未读会话
|
||
unread_rows = [
|
||
r for r in rows
|
||
if r.get("hasUnread") or (r.get("unreadCount") or 0) > 0
|
||
]
|
||
if unread_rows:
|
||
await self._reply_all_unread_conversations(unread_rows)
|
||
|
||
for conv in rows:
|
||
name = conv.get("name", "未知用户")
|
||
preview = conv.get("preview", "")
|
||
has_unread = conv.get("hasUnread", False)
|
||
unread_count = conv.get("unreadCount", 0)
|
||
prev_preview = self._conv_previews.get(name)
|
||
|
||
if has_unread or unread_count > 0:
|
||
continue
|
||
|
||
if not self._should_auto_reply(prev_preview, preview, False, 0):
|
||
if prev_preview is None and preview:
|
||
self._conv_previews[name] = preview
|
||
continue
|
||
|
||
await self._process_conversation_reply(conv)
|
||
|
||
await asyncio.sleep(5)
|
||
|
||
except Exception as e:
|
||
err = format_error(e)
|
||
logger.error(f"Error in message monitor loop: {err}")
|
||
if "浏览器窗口已关闭" in err or "has been closed" in str(e):
|
||
await self.update_account_status("offline", error_msg=err)
|
||
self.is_running = False
|
||
break
|
||
await asyncio.sleep(10)
|
||
|
||
async def _click_conversation_by_name(self, name: str) -> bool:
|
||
"""按名称点击会话(含 iframe)"""
|
||
try:
|
||
clicked = await self.page.evaluate("""(targetName) => {
|
||
function tryClick(root) {
|
||
const selectors = [
|
||
'li', '[role="listitem"]',
|
||
'[class*="session"]', '[class*="conversation"]',
|
||
'[class*="chat-item"]', '[class*="dialog"]',
|
||
];
|
||
for (const sel of selectors) {
|
||
for (const el of root.querySelectorAll(sel)) {
|
||
const text = (el.innerText || '').trim();
|
||
if (!text || !el.offsetParent) continue;
|
||
const firstLine = text.split('\\n')[0].trim();
|
||
if (firstLine === targetName || text.startsWith(targetName)) {
|
||
el.click();
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
if (tryClick(document)) return true;
|
||
for (const frame of document.querySelectorAll('iframe')) {
|
||
try {
|
||
if (frame.contentDocument && tryClick(frame.contentDocument)) return true;
|
||
} catch (e) {}
|
||
}
|
||
return false;
|
||
}""", name)
|
||
return bool(clicked)
|
||
except Exception as e:
|
||
logger.debug(f"Click conversation by name failed: {e}")
|
||
return False
|
||
|
||
async def cleanup(self):
|
||
"""释放资源"""
|
||
logger.info(f"Cleaning up worker {self.account_id}")
|
||
if self._im_service:
|
||
try:
|
||
await self._im_service.stop()
|
||
except Exception:
|
||
pass
|
||
self._im_service = None
|
||
try:
|
||
if self.page:
|
||
await self.page.close()
|
||
if self.context:
|
||
await self.context.close()
|
||
if self.browser:
|
||
await self.browser.close()
|
||
if self.playwright:
|
||
await self.playwright.stop()
|
||
except Exception as e:
|
||
logger.error(f"Error during cleanup: {e}")
|
||
finally:
|
||
self.page = None
|
||
self.context = None
|
||
self.browser = None
|
||
self.playwright = None
|