更新
This commit is contained in:
@@ -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'}"
|
||||
)
|
||||
# 活跃访问后 cookie(msToken 等)可能更新,重新落库
|
||||
# 活跃访问后 cookie(msToken 等)可能更新。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:
|
||||
|
||||
Reference in New Issue
Block a user