19 KiB
2026-08-27 工作日志
抖音私信发送 decision=KICK 修复 + 第二套发送方案
根因诊断(已完成)
- 现象:账号1(my_uid=2609567359568155)收消息正常(UA=Chrome/148),发消息被安全网关 decision=KICK。
- 根因:UA 全链路不一致——凭证采集时写入 storage_state/im_session_data 的 UA 是 Chrome/148,但 worker 构建 IM 会话时
_load_user_agent()用resolve_user_agent回退默认 Chrome/120 覆盖了采集 UA。a_bogus 签名、IM 请求头、Protobuf body 用 Chrome/120,浏览器上下文/凭证却是 Chrome/148,设备指纹不一致触发 KICK。 - 数据库证据:
account.user_agent=None、im_session_data.user_agent=Chrome/120(被覆盖后写入)、cookie_data.user_agent=Chrome/148。
修改(backend/rpa_engine/playwright_worker.py)
__init__新增self._raw_user_agent = ""。- 新增
_load_raw_user_agent():读账号表显式配置的 UA(未配置返回空串)。 _build_im_session_from_storage:账号表显式配置 UA 时用resolve_user_agent(raw_ua);否则保留 storage_state 采集的 UA(不再覆盖),加日志。- 新增
send_im_via_browser_page(conversation_id, content):第二套发送方案——构建 IM session →DouyinImHttpClient.resolve_conversation_meta解析 ticket/short_id →ProtoBuilder.build_send_message_request构造 protobuf → 打开抖音 /message 页面(非 headless 最小化)→ 页面 JS fetch/v1/message/send(security-sdk 注入 a_bogus/bd-ticket-guard)→analyze_send_response判定。受KEFU_BROWSER_SEND_TIMEOUT(默认45s)控制。 _run_im_direct_service注入send_fallback=self.send_im_via_browser_page。
修改(backend/rpa_engine/douyin_im/service.py)
__init__新增send_fallback参数。_send_text失败分支:last_error含DECISION=KICK/STATUS_CODE=7911/INVALID_REQUEST且存在send_fallback时调用兜底;成功则重置_session_invalid_strikes/_fired并返回 True;失败继续走_note_session_invalid自动下线。
方法签名验证(全部通过,py_compile OK)
resolve_conversation_meta(auth, conv_id, my_uid, peer_uid) → (conv_id, short_id, ticket)ProtoBuilder.build_send_message_request(auth, conv_id, short_id, ticket, msg_content, message_type=7)静态方法;build_msg_payload(spec) → (msg_content, message_type)展开正确parse_reply_content(raw) → dict(含type键,"image"时兜底拒绝)analyze_send_response(bytes) → dict(ok/decision/status_code/summary/server_message_id)DouyinAuth.from_im_session/ImSession.can_direct_im/cookies/my_uid/user_agent/DouyinImHttpClient(session, account_id)async with 均匹配
待办/注意
- 后端当前未运行;下次启动自动生效(无需重启)。
- 浏览器兜底暂不支持图片回复(返回"请改用纯文字")。
- 运行时验证未执行:需要账号重新托管后实际触发发送观察;KICK 后兜底页面需等待 security-sdk 加载(15s 轮询)。
登录态常驻方案(A 保活 + B 自动重登录)✅ 已实施并通过编译/导入验证
背景:sessionid/passport 无 refresh token 可自动换新;IM 通道活跃不刷新 passport 登录态,托管约 30 天失效 → 抖音返回「用户未登录」。
A 保活(backend/rpa_engine/playwright_worker.py)
- 配置:
KEFU_KEEPALIVE_INTERVAL(默认 21600s=6h,最小 300s)、KEFU_KEEPALIVE_DISABLED(设为 1 关闭)。 _keepalive_loop:每间隔调用_keepalive_touch。_keepalive_touch:controller.browser_slot(account_id,"keepalive")串行 → 非 headless 最小化 Chromium → storage_state +self._user_agent建 context → 临时挂载(self.playwright/browser/context/page)复用_has_visible_login_prompt/_persist_cookies→ goto douyin.com → sleep 6s → 检测登录提示(有则返回失败"将触发自动重登录")→_persist_cookies()→ finally 恢复引用并关浏览器。- 挂载点:
_run_im_direct_servicerun() 前_start_keepalive(),finally 后_stop_keepalive();stop()开头也_stop_keepalive()(防悬挂)。
B 失效自动重登录(backend/main.py + worker 侧)
- worker:
__init__新增relogin_hook: Optional[Callable[[int], Awaitable[None]]];on_im_session_invalid末尾 await hook(manager 层驱动,避免 worker 自 cancel 扭曲 + browser_slot 竞争)。 - manager:
_AUTO_RELOGIN_COOLDOWN(默认 1800s=30min 防抖,envKEFU_AUTO_RELOGIN_COOLDOWN);_schedule_auto_relogin快速返回(防抖→查重→create_task);_auto_relogin_account:轮询等旧 worker 退出(100×0.2s)→ DB 置logging_in、清 qr/error →start_worker(login_mode="browser", wait_until_ready=False)→ 浏览器弹码 → 前端账号卡片轮询展示 qr_code_base64 → 扫码自动恢复托管;异常置 offline+error_message。 - 前端无需改(Accounts.vue 已支持 logging_in + 二维码)。
验证记录
py_compile playwright_worker.py main.py→ COMPILE_OK。- AST 检查修正版(须同时匹配
ast.AsyncFunctionDef!)确认:DouyinWorker 含_keepalive_interval(sync)/_keepalive_disabled(sync)/_start_keepalive/_stop_keepalive/_keepalive_loop/_keepalive_touch/on_im_session_invalid(均 async);WorkerManager 含_schedule_auto_relogin/_auto_relogin_account(均 async)。 - venv 导入冒烟(
.venv/Scripts/python.exe)→ IMPORT_SMOKE_OK;DouyinWorker.__init__参数含relogin_hook。
运行时验证(待后端启动后)
- 托管后 6h 内观察保活日志(打开 douyin.com + 持久化 cookie)。
- 手动清 passport cookie 或等失效 → 观察自动重登录链路:offline→logging_in→弹码→扫码→恢复托管;确认 30min 防抖生效(扫码失败不会死循环)。
- 唯一绕不开的人工动作是扫码(抖音无免扫码续期通道)。
二维码截图太小/无法扫描 ✅ 已修复
问题
自动重登录时前端弹窗显示的是整页截图兜底,二维码被包在整个网页截图中,尺寸过小,手机无法扫描。
诊断
- 后端
_grab_qr_data_url()的精确选择器未命中当前 Douyin 二维码元素,导致一直回退到page.screenshot()。 - 使用 Playwright 诊断脚本
backend/debug_qr_inspect.py观察:headless 环境触发的是验证码 iframe(lf-rc1.yhgfb-cn-static.com/.../rmc-nocaptcha),与真实有头浏览器弹出的扫码登录弹窗不同,因此重点改为增强二维码抓取鲁棒性。
修改(backend/rpa_engine/playwright_worker.py)
- 新增
from PIL import Image、import io。 - 扩充
_QR_SELECTORS:新增登录弹窗内img/canvas选择器(#login-pannel、*login-guide*、*login-panel*、*account_login*等)。 - 扩充
_QR_CONTAINER_SELECTORS/_LOGIN_PANEL_SELECTORS。 _grab_qr_in_frames:匹配元素后增加_looks_like_qr_box()校验(80~600px、长宽比≥0.75);对<canvas>优先用canvas.toDataURL()提取;对<img>优先读 data-src/http-src;返回前统一经_upscale_qr_image()放大。- 新增
_grab_qr_generic_in_frames():在所有 frame 中泛化扫描登录容器内最大方型img/canvas,作为精确选择器未命中时的兜底。 _grab_login_panel_shot():改为遍历所有 frame(含 iframe),并调用_ensure_panel_fits()临时放大 viewport 保证截图清晰。- 新增
_crop_center_viewport_shot():截取视口中央 700x800 区域,替代直接整页截图,避免二维码过小。 - 新增
_upscale_qr_image():对小于 280px 的二维码用 Pillow 最近邻放大,提高手机扫描成功率。 _grab_qr_data_url()增加分阶段日志;把「中心区域截图」放在「整页截图」之前;整页截图加 15s timeout 防字体加载卡死。_check_and_grab_captcha()的整页兜底改为先_crop_center_viewport_shot()。
验证
py_compile rpa_engine/playwright_worker.py→ COMPILE_OK。- venv 导入冒烟 → IMPORT_OK;新增方法列表:
_looks_like_qr_box、_grab_qr_generic_in_frames、_ensure_panel_fits、_crop_center_viewport_shot、_upscale_qr_image。
待验证
- 重启后端后触发自动重登录/首次托管,观察前端二维码是否清晰可扫;查看日志应出现
Captured QR via precise selector/generic scan/login panel screenshot等字样,而不是fell back to full-viewport screenshot。
排查:KICK 是否由代码主动退出登录触发 ✅ 结论:否
用户疑问
收到 decision=KICK 错误,怀疑代码里有主动退出登录的逻辑。
排查结果
全库搜索 logout / 退出登录 / clear_cookie / sessionid.*None / passport.*logout 等,没有发现任何主动调用抖音退出登录接口或自动清空 session cookie 的代码。
clear_cookie_file()仅在 3 个手动 API 中被调用:DELETE /api/accounts/{id}(删除账号)POST /api/accounts/{id}/clear-cookie(手动清除 Cookie)_reset_account_credentials(重置账号凭证接口)
on_im_session_invalid()只停止 worker、更新 DB 状态为 offline、触发relogin_hook自动重登录,不会删除 cookie / session。_keepalive_touch()仅访问https://www.douyin.com/并持久化 cookie,不会登出。
decision=KICK 是抖音服务端安全网关返回的,常见原因:
- passport/sessionid 自然过期;
- 账号在其它设备/浏览器登录,挤掉当前会话;
- 设备指纹/签名不一致触发风控;
- 服务端主动下线。
同步更新提示文案
发现 http_client.py 和 service.py 中 KICK 提示仍写着"请停止托管后用浏览器模式重新登录...",与已实施的自动重登录方案矛盾。已修改为"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码"。
修改文件
backend/rpa_engine/douyin_im/http_client.pybackend/rpa_engine/douyin_im/service.py
验证
py_compile两个文件 → COMPILE_OK。
UA 全链路一致性修复(接收链路)✅ 已实施并通过编译/导入验证
需求
用户明确要求:接收消息与发送消息使用同一 User-Agent,账号配置里改了 UA,发送、接收都要同步生效。
背景
- 发送链路上一轮已一致:
_build_im_session_from_storage保留采集 UA →session.user_agent→DouyinAuth.from_im_session(session)设auth.user_agent。 - 接收链路存在 2 个硬编码漏网点(Chrome/120 DEFAULT_USER_AGENT)+ 多处无参
DouyinAuth()构造导致self.user_agent未设置。
修改
douyin_im/auth.py:__init__增加self.user_agent = None。perepare_auth增加user_agent: str = ""参数,非空时保存self.user_agent(避免覆盖 from_im_session 已设值)。query_my_uid()的请求头与generate_a_bogus改用ua = self.user_agent or DEFAULT_USER_AGENT(消除硬编码)。from_im_session在 perepare_auth 时直接传user_agent=session.user_agent or DEFAULT_USER_AGENT。
douyin_im/dy_util.py:generate_webid(auth=None, url="", user_agent="")新增参数;内部 UA 优先级:显式参数 >auth.user_agent> DEFAULT(消除硬编码)。- 调用点全部显式传 UA(
session.user_agent or DEFAULT_USER_AGENT):frontier.py fetch_device_id、follower_poll.py、main.py:2911(conversations 兜底)、service.py send_message(uid 兜底)peer_profile.py _build_auth、image_upload.py、account_profile.py _build_auth(传ua变量)
保留原样(非风险点)
image_upload.py:496/599的DEFAULT_USER_AGENT:VOD 存储上传(腾讯云 VOD,AWS SigV4/JWT 独立鉴权,非抖音 web API、无 a_bogus),不会触发 7911。proto_builder.py _ua_headers、http_client.py:740:已走auth.user_agent,自动生效。device_profiles.pyDEFAULT_USER_AGENT:仅作为无显式配置时的默认 profile。
验证
py_compile9 个文件 → PY_COMPILE_OK。- venv 导入冒烟:perepare_auth 签名含 user_agent、设置/不设置路径正确、generate_webid 签名与缓存命中正确 → IMPORT_SMOKE_OK。
- Grep 确认接收链路(douyin_im)无
user_agent=DEFAULT_USER_AGENT/"User-Agent": DEFAULT_USER_AGENT残留。
运行时验证(待重启后端)
配置账号 UA(account.user_agent)→ 重启后端 → 观察日志确认 a_bogus 签名与请求头 UA 一致(Chrome/148 或其他配置值,而非 Chrome/120)。
凭证登录头(user_agent)自动回填账号 ✅ 已实施并通过编译/冒烟测试
需求
用户问:「获取登录凭证里有没有登录头,如果有的话保存账户的时候直接填上去」。确认采集器导出的 storage_state 顶层带 user_agent(如 Chrome/148),保存 Cookie 时之前只存进 cookie_data,account.user_agent 列不回填。
修改
backend/utils/cookie_store.py:新增extract_user_agent_from_cookie_data(cookie_data) -> str——解析 JSON 取顶层user_agent(兼容 DYCRED 的ua键),无效/缺失返回空串。backend/main.py:- 新增
_backfill_user_agent_from_cookie(account, standard_json_str):仅当account.user_agent为空且凭证含 UA 时回填;已有自定义 UA 保持不变(用户配置优先)。 create_account(POST /api/accounts)与update_account_cookie(PUT /api/accounts/{id}/cookie)保存 Cookie 后调用回填。AccountCookieResponse增加user_agent/user_agent_label字段,_build_cookie_response填充,前端保存凭证后可确认回填结果。
- 新增
验证
py_compile main.py utils/cookie_store.py→ PY_COMPILE_OK。- 冒烟测试:storage_state(顶层 user_agent)→ validate_cookie_json 保留 → 提取 Chrome/148 → 空列回填成功;已有自定义 UA 不被覆盖;DYCRED 输入(ua 键)也能提取 → ALL_SMOKE_OK。
行为说明
- 保存/更新凭证时自动回填,发送+接收链路经
_build_account_im_session统一走该 UA。 - 若用户在配置页显式改过 UA,凭证里的登录头不会覆盖它。
- 清空凭证(DELETE cookie)不会清
account.user_agent列(保留设备指纹配置,可在配置页手动改)。
待验证
重启后端 → 保存一份带 user_agent 的凭证 → 观察账号响应中 user_agent 已回填(非空)且 user_agent_label 正确。
咨询:能否用抖音 APP 凭证登录 → 结论:不能,已改为优化保活 ✅
用户提问
能否搞到抖音 APP 登录凭证(APP sessionid)用来登录/托管。
结论(已给用户,含对比图)
- APP sessionid 与 Web sessionid 分属不同端,当前架构(frontier WS aid=2906 + web API + a_bogus)只认 Web 登录态 + security-sdk 密钥,APP 凭证直接喂入 → 设备指纹/签名不匹配 → 风控/限号/封号。
- 获取 APP 凭证本身要 root + 抓包 + frida(ssl pinning 反抓包),且绑定设备指纹,跨环境使用高风控;等于重写整套 APP 协议层,不推荐。
- 用户动机 = 摆脱频繁扫码/登录失效 → 方向改为优化 Web 保活。
保活增强(backend/rpa_engine/playwright_worker.py)
_keepalive_touch访问目标从首页改为https://www.douyin.com/im(私信页,更贴近真实活跃、触发 IM 域请求),可用KEFU_KEEPALIVE_URL覆盖;/im 异常时回退首页。- 随机节奏:停留 4~8s + 一次
mouse.wheel滚动,避免固定机械行为。 - 新增
_cookie_expires_map/_fmt_expires_map静态方法:保活前后读取 sid_guard/sessionid/sid_tt 等 passport cookie 的 expires 并打日志(before/after/renewed),用于实测抖音 Web 是否对持续活跃账号滑动续期。
验证
- py_compile → PY_COMPILE_OK。
- AST + venv 冒烟:辅助方法存在且为 static;expires 提取(含 session cookie -1→0、空值跳过、非 passport 键过滤)、格式化、renewed 判定逻辑全部通过 → KEEPALIVE_SMOKE_OK。
待观察(重启后端后)
- 保活日志出现
keepalive passport expires before[...] after[...] renewed=...:若 renewed 有值 → 保活确实续期,可继续调优频率;若持续 renewed=none → 抖音 Web passport 不滑动续期,30 天到期仍需扫码一次(自动重登录已保证只扫一次)。
KICK 循环修复(发送被踢下线 → 重登 → 又被踢)
根因(实锤)
DouyinImSession.from_storage_state把新版__tea_cache_tokens_6383.user_unique_id(实际是 web_id,如 7678646545793812008)误当账号 UID → my_uid=web_id,而 device_id 取web_runtime_security_uid(真实 UID)→ device_id != my_uid → 安全网关 decision=KICK → 自动重登 → 新旧登录态混合 → 循环。- 重登后 cookie 落库仍混合(normalize 只修顶层 my_uid 与 web_runtime_security_uid,不清理 localStorage 混合 tea)。
- worker 的
profile_matches_cookie要求 profile_updated_at >= cookie_updated_at:资料同步滞后时错误 UID 带病运行。
修复(4 个文件)
session.py from_storage_state:改两遍扫描收集(ls_sec_uid/ls_web_id/ls_tea_pairs),my_uid 优先级:extra/顶层 > web_runtime_security_uid > tea(user_unique_id!=web_id 才可信);device_id:extra/cookies > ls_sec_uid > my_uid;末尾加一致性收敛(my_uid 与 device_id 不一致时以 my_uid 为准)。tea 解析对非 dict 值(如 int 1)类型保护。cookie_store.py _parse_tea_from_ls:if not isinstance(parsed, dict): continue,修复__tea_cache_first_*值为 1 时的 AttributeError 崩溃(保存凭证 500)。playwright_worker.py _build_im_session_from_storage:权威 UID 覆盖改为accounts.douyin_uid无条件优先(拿 cookie 拉的,最可靠),profile.uid仅在资料不早于 cookie 更新时可信(防串号,账号 9 的 profile 表就存了账号 8 的 UID);覆盖 my_uid 时同步 device_id=verified_uid。credential.py build_im_session_from_storage:saved.uid_verified 覆盖 my_uid 时同步 device_id(之前只改 my_uid 不改 device_id,凭证残留设备号导致不一致)。
数据修复
- 备份:backend/.bak/kefu*.db.before_uid_fix_20260827_*
- 账号 1:im_session_data.my_uid 7678646545793812008(web_id) → 2609567359568155,uid_verified=True
- 账号 8/9:仅补 uid_verified=True
- 验证:3 账号均 device_id==my_uid==douyin_uid,ALL_CONSISTENT
测试
- test_sec_user_id_guard + test_im_receive_path 共 52 个用例全过(更新 3 处断言:device_id 同步为新行为;3 处 mock 从 _load_user_agent 改为 _load_raw_user_agent)。
- 全套 228 个用例剩 6 个失败为预先存在的测试 mock 签名过期(get_owned_account 新增 write_permission 参数,测试 mock 未更新),与本次改动无关。
待观察(重启后端后)
- 账号 1 再发送是否还 KICK(预期不再循环);日志出现 "replaced collected IM uid ..." / "synced device_id" 即覆盖生效。
- 账号 9 的 localStorage 仍混合(www 域残留 7657072144060941834),等重新登录后由 normalize + 新解析逻辑自然清洗。