This commit is contained in:
Your Name
2026-09-01 15:31:05 +08:00
parent 1f3addcf79
commit 2fc864cc00
20 changed files with 1381 additions and 45 deletions
+110 -17
View File
@@ -28,7 +28,11 @@ from rpa_engine.douyin_im.session import DouyinImSession
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
from rpa_engine.credential import validate_im_session, build_im_session_from_storage
from rpa_engine.credential import (
CREDENTIAL_EGRESS_PUBLIC_IP_KEY,
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,
@@ -36,6 +40,7 @@ from rpa_engine.runtime_config import (
playwright_proxy,
)
from rpa_engine.egress_channels import clamp_attempts, resolve_fixed_channel
from rpa_engine.source_bound_proxy import playwright_proxy_for_source
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("rpa_engine")
@@ -55,7 +60,12 @@ async def _start_playwright_for_browser(headless: Optional[bool] = None):
return await async_playwright().start(), headless
async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None):
async def _launch_chromium(
pw,
args: list[str],
headless: Optional[bool] = None,
source_ip: str = "",
):
"""统一的 Chromium 启动入口:自动处理无头/有头、虚拟显示与住宅代理。
- headless 默认由 KEFU_BROWSER_HEADLESS 决定(缺省有头,避免抖音安全 SDK 判定)。
@@ -66,10 +76,21 @@ async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None)
headless = resolve_headless(default=False)
await ensure_browser_display(headless)
launch_kwargs: dict = {"headless": headless, "args": args}
proxy = playwright_proxy()
proxy = (
await playwright_proxy_for_source(source_ip)
if source_ip
else playwright_proxy()
)
if proxy:
launch_kwargs["proxy"] = proxy
logger.info("浏览器将通过代理启动:%s", proxy.get("server"))
if source_ip:
logger.info(
"浏览器将固定使用本机源地址 %s(本地代理 %s",
source_ip,
proxy.get("server"),
)
else:
logger.info("浏览器将通过代理启动:%s", proxy.get("server"))
return await pw.chromium.launch(**launch_kwargs)
@@ -223,6 +244,44 @@ class DouyinWorker:
async def get_db(self):
return AsyncSessionLocal()
async def _resolve_browser_source_ip(self) -> str:
"""Resolve the account's fixed browser egress source address.
Browser login, keepalive and credential refresh must use the same
public channel as IM HTTP/WS. Silently falling back to the default
route for a selected-but-unavailable channel would create a mixed-IP
security session, so that case intentionally fails closed.
"""
selected = await self._load_selected_egress_public_ip()
if not selected:
return ""
route = await resolve_fixed_channel(selected)
source_ip = str(route.source_ip or "").strip()
if not source_ip:
raise RuntimeError(
f"账号指定公网通道 {selected} 无法绑定到本机网卡,已停止浏览器登录/刷新,"
"避免登录出口与发送出口不一致"
)
return source_ip
async def _load_selected_egress_public_ip(self) -> str:
db = await self.get_db()
try:
result = await db.execute(
select(Account.egress_public_ip).where(Account.id == self.account_id)
)
selected = str(result.scalar_one_or_none() or "").strip()
except Exception as exc:
logger.debug(
"Account %s browser egress config unavailable: %s",
self.account_id,
exc,
)
return ""
finally:
await db.close()
return selected
def _mark_startup_ready(self) -> None:
self._startup_error = ""
self._startup_ready.set()
@@ -640,6 +699,7 @@ class DouyinWorker:
"--no-sandbox",
"--disable-setuid-sandbox",
],
source_ip=await self._resolve_browser_source_ip(),
)
if storage_state:
self.context = await self.browser.new_context(
@@ -679,10 +739,13 @@ class DouyinWorker:
async def _persist_cookies(self):
"""登录成功或运行中将 Cookie 同步到文件和数据库(合并 HttpOnly sessionid"""
if not self.context:
return
return None
storage = await self.context.storage_state()
live_cookies = await self.context.cookies()
storage = merge_playwright_cookies(storage, live_cookies)
storage[CREDENTIAL_EGRESS_PUBLIC_IP_KEY] = (
await self._load_selected_egress_public_ip()
)
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)
@@ -704,6 +767,7 @@ class DouyinWorker:
await db.rollback()
finally:
await db.close()
return storage
async def _build_im_session_from_storage(
self,
@@ -1172,8 +1236,8 @@ class DouyinWorker:
# 不在发送链路上自动开浏览器刷新:实测重载页面并不会重生 web_protect
# 反而每次失败阻塞 ~22s("反应特别慢"),且无法解决 7911 风控。
refresh_credentials=None,
# 第二套发送方案:HTTP 签名发送被 KICK/7911/INVALID_REQUEST 拒绝时,
# 用浏览器页面上下文重发(真实 JS 签名,可自愈被踢会话)
# 第二套发送方案:仅在非终态 7911 时用同出口浏览器页面重试;
# KICK/INVALID_REQUEST 必须停发并下线,不能继续重放
send_fallback=self.send_im_via_browser_page,
)
self._im_service = im_service
@@ -1332,7 +1396,10 @@ class DouyinWorker:
if sys.platform == "win32":
args.append("--start-minimized")
browser = await _launch_chromium(
pw, args, headless=browser_headless
pw,
args,
headless=browser_headless,
source_ip=await self._resolve_browser_source_ip(),
)
ua = self._user_agent or resolve_user_agent(None)
context = await browser.new_context(
@@ -1397,13 +1464,26 @@ class DouyinWorker:
f"after[{self._fmt_expires_map(after_exp)}] "
f"renewed={','.join(renewed) or 'none'}"
)
# 活跃访问后 cookiemsToken 等)可能更新,重新落库
# 活跃访问后 cookiemsToken 等)可能更新。Cookie、ticket、
# ts_sign、private key 是一套安全会话,不能只更新数据库里的
# cookie 而让正在发送的内存会话继续使用旧值;否则 WS 仍能收,
# 下一次写请求却会因新旧凭证混用被安全网关 KICK。
try:
await self._persist_cookies()
service = self._im_service
if service is not None:
fresh_session = await self._build_im_session()
await service.replace_session(fresh_session)
await self._persist_im_session(service.session)
logger.info(
"Account %s: keepalive credentials synchronized "
"to active IM session",
self.account_id,
)
except Exception as exc:
logger.warning(
f"Account {self.account_id}: keepalive persist cookies "
f"failed: {exc}"
f"Account {self.account_id}: keepalive credential sync "
f"failed; active session left unchanged: {exc}"
)
return True, f"已访问 {target_url} 并刷新登录态"
except asyncio.CancelledError:
@@ -1445,16 +1525,19 @@ class DouyinWorker:
) -> tuple[bool, str]:
"""第二套发送方案:浏览器页面上下文内重发私信。
HTTP 签名发送被抖音安全网关拒绝(decision=KICK / 7911 / INVALID_REQUEST
时的兜底:用已保存的登录态打开抖音页面,由页面自带 security-sdk 在真实
浏览器环境里生成 a_bogus / bd-ticket-guard 并完成发送——绕开 Node execjs
的签名模拟;浏览器重新加载页面也会重建安全会话,可自愈被服务端踢掉的
登录态。仅文本/表情/卡片内容可用,图片需先走 HTTP 上传链路。
仅供非终态 7911 签名错误使用:用已保存的登录态打开抖音页面,在与账号
相同的固定出口中完成一次页面内发送。KICK/INVALID_REQUEST 不会调用此
方法,避免对已经失效的登录态继续重放。仅文本/表情/卡片内容可用,图片
需先走 HTTP 上传链路。
返回 (是否成功, 详情)。失败不会抛异常,只记录日志。
"""
from rpa_engine.douyin_im.auth import DouyinAuth
from rpa_engine.douyin_im.conv_util import normalize_conversation_id, resolve_peer_uid
from rpa_engine.douyin_im.conv_util import (
conversation_belongs_to,
normalize_conversation_id,
resolve_peer_uid,
)
from rpa_engine.douyin_im.pb_decode import analyze_send_response
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
from rpa_engine.douyin_im.reply_payload import build_msg_payload, parse_reply_content
@@ -1476,6 +1559,13 @@ class DouyinWorker:
if not my_uid:
return False, "无法获取 my_uid"
# 与 HTTP 发送同一道跨账号闸门:不是本账号的会话绝不改写后重发。
if not conversation_belongs_to(conversation_id, my_uid):
return False, (
f"会话 {conversation_id} 的参与方都不是本账号(uid={my_uid}),"
"拒绝发送:这条会话属于另一个账号"
)
conv_id = normalize_conversation_id(conversation_id, my_uid)
peer_uid = resolve_peer_uid(conv_id, my_uid)
if not peer_uid:
@@ -1545,6 +1635,7 @@ class DouyinWorker:
pw,
token_args,
headless=browser_headless,
source_ip=await self._resolve_browser_source_ip(),
)
storage_state = await self._load_storage_state()
context = await browser.new_context(
@@ -1780,6 +1871,7 @@ class DouyinWorker:
pw,
token_args,
headless=browser_headless,
source_ip=await self._resolve_browser_source_ip(),
)
context = await browser.new_context(
storage_state=storage_state,
@@ -2382,6 +2474,7 @@ class DouyinWorker:
self.playwright,
args,
headless=browser_headless,
source_ip=await self._resolve_browser_source_ip(),
)
logger.info(f"Account {self.account_id}: opening browser for IM setup (minimized)")
except RuntimeError: