Files
2026-08-27 18:32:03 +08:00

19 KiB
Raw Permalink Blame History

2026-08-27 工作日志

抖音私信发送 decision=KICK 修复 + 第二套发送方案

根因诊断(已完成)

  • 现象:账号1my_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=Noneim_session_data.user_agent=Chrome/120(被覆盖后写入)、cookie_data.user_agent=Chrome/148

修改(backend/rpa_engine/playwright_worker.py

  1. __init__ 新增 self._raw_user_agent = ""
  2. 新增 _load_raw_user_agent():读账号表显式配置的 UA(未配置返回空串)。
  3. _build_im_session_from_storage:账号表显式配置 UA 时用 resolve_user_agent(raw_ua);否则保留 storage_state 采集的 UA(不再覆盖),加日志。
  4. 新增 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/sendsecurity-sdk 注入 a_bogus/bd-ticket-guard)→ analyze_send_response 判定。受 KEFU_BROWSER_SEND_TIMEOUT(默认45s)控制。
  5. _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_errorDECISION=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) → dictok/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_touchcontroller.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_service run() 前 _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 hookmanager 层驱动,避免 worker 自 cancel 扭曲 + browser_slot 竞争)。
  • manager_AUTO_RELOGIN_COOLDOWN(默认 1800s=30min 防抖,env KEFU_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_OKDouyinWorker.__init__ 参数含 relogin_hook

运行时验证(待后端启动后)

  1. 托管后 6h 内观察保活日志(打开 douyin.com + 持久化 cookie)。
  2. 手动清 passport cookie 或等失效 → 观察自动重登录链路:offline→logging_in→弹码→扫码→恢复托管;确认 30min 防抖生效(扫码失败不会死循环)。
  3. 唯一绕不开的人工动作是扫码(抖音无免扫码续期通道)。

二维码截图太小/无法扫描 已修复

问题

自动重登录时前端弹窗显示的是整页截图兜底,二维码被包在整个网页截图中,尺寸过小,手机无法扫描。

诊断

  • 后端 _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

  1. 新增 from PIL import Imageimport io
  2. 扩充 _QR_SELECTORS:新增登录弹窗内 img/canvas 选择器(#login-pannel*login-guide**login-panel**account_login* 等)。
  3. 扩充 _QR_CONTAINER_SELECTORS / _LOGIN_PANEL_SELECTORS
  4. _grab_qr_in_frames:匹配元素后增加 _looks_like_qr_box() 校验(80~600px、长宽比≥0.75);对 <canvas> 优先用 canvas.toDataURL() 提取;对 <img> 优先读 data-src/http-src;返回前统一经 _upscale_qr_image() 放大。
  5. 新增 _grab_qr_generic_in_frames():在所有 frame 中泛化扫描登录容器内最大方型 img/canvas,作为精确选择器未命中时的兜底。
  6. _grab_login_panel_shot():改为遍历所有 frame(含 iframe),并调用 _ensure_panel_fits() 临时放大 viewport 保证截图清晰。
  7. 新增 _crop_center_viewport_shot():截取视口中央 700x800 区域,替代直接整页截图,避免二维码过小。
  8. 新增 _upscale_qr_image():对小于 280px 的二维码用 Pillow 最近邻放大,提高手机扫描成功率。
  9. _grab_qr_data_url() 增加分阶段日志;把「中心区域截图」放在「整页截图」之前;整页截图加 15s timeout 防字体加载卡死。
  10. _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抖音服务端安全网关返回的,常见原因:

  1. passport/sessionid 自然过期;
  2. 账号在其它设备/浏览器登录,挤掉当前会话;
  3. 设备指纹/签名不一致触发风控;
  4. 服务端主动下线。

同步更新提示文案

发现 http_client.pyservice.py 中 KICK 提示仍写着"请停止托管后用浏览器模式重新登录...",与已实施的自动重登录方案矛盾。已修改为"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码"。

修改文件

  • backend/rpa_engine/douyin_im/http_client.py
  • backend/rpa_engine/douyin_im/service.py

验证

  • py_compile 两个文件 → COMPILE_OK。

UA 全链路一致性修复(接收链路) 已实施并通过编译/导入验证

需求

用户明确要求:接收消息与发送消息使用同一 User-Agent,账号配置里改了 UA,发送、接收都要同步生效。

背景

  • 发送链路上一轮已一致:_build_im_session_from_storage 保留采集 UA → session.user_agentDouyinAuth.from_im_session(session)auth.user_agent
  • 接收链路存在 2 个硬编码漏网点(Chrome/120 DEFAULT_USER_AGENT+ 多处无参 DouyinAuth() 构造导致 self.user_agent 未设置。

修改

  1. 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
  2. douyin_im/dy_util.pygenerate_webid(auth=None, url="", user_agent="") 新增参数;内部 UA 优先级:显式参数 > auth.user_agent > DEFAULT(消除硬编码)。
  3. 调用点全部显式传 UAsession.user_agent or DEFAULT_USER_AGENT):
    • frontier.py fetch_device_idfollower_poll.pymain.py:2911conversations 兜底)、service.py send_messageuid 兜底)
    • peer_profile.py _build_authimage_upload.pyaccount_profile.py _build_auth(传 ua 变量)

保留原样(非风险点)

  • image_upload.py:496/599DEFAULT_USER_AGENT:VOD 存储上传(腾讯云 VODAWS SigV4/JWT 独立鉴权,非抖音 web API、无 a_bogus),不会触发 7911。
  • proto_builder.py _ua_headershttp_client.py:740:已走 auth.user_agent,自动生效。
  • device_profiles.py DEFAULT_USER_AGENT:仅作为无显式配置时的默认 profile。

验证

  • py_compile 9 个文件 → 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 残留。

运行时验证(待重启后端)

配置账号 UAaccount.user_agent)→ 重启后端 → 观察日志确认 a_bogus 签名与请求头 UA 一致(Chrome/148 或其他配置值,而非 Chrome/120)。

凭证登录头(user_agent)自动回填账号 已实施并通过编译/冒烟测试

需求

用户问:「获取登录凭证里有没有登录头,如果有的话保存账户的时候直接填上去」。确认采集器导出的 storage_state 顶层带 user_agent(如 Chrome/148),保存 Cookie 时之前只存进 cookie_dataaccount.user_agent 列不回填。

修改

  1. backend/utils/cookie_store.py:新增 extract_user_agent_from_cookie_data(cookie_data) -> str——解析 JSON 取顶层 user_agent(兼容 DYCRED 的 ua 键),无效/缺失返回空串。
  2. backend/main.py
    • 新增 _backfill_user_agent_from_cookie(account, standard_json_str):仅当 account.user_agent 为空且凭证含 UA 时回填;已有自定义 UA 保持不变(用户配置优先)。
    • create_accountPOST /api/accounts)与 update_account_cookiePUT /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 + 抓包 + fridassl pinning 反抓包),且绑定设备指纹,跨环境使用高风控;等于重写整套 APP 协议层,不推荐。
  • 用户动机 = 摆脱频繁扫码/登录失效 → 方向改为优化 Web 保活。

保活增强(backend/rpa_engine/playwright_worker.py

  1. _keepalive_touch 访问目标从首页改为 https://www.douyin.com/im(私信页,更贴近真实活跃、触发 IM 域请求),可用 KEFU_KEEPALIVE_URL 覆盖;/im 异常时回退首页。
  2. 随机节奏:停留 4~8s + 一次 mouse.wheel 滚动,避免固定机械行为。
  3. 新增 _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 冒烟:辅助方法存在且为 staticexpires 提取(含 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 个文件)

  1. 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_idextra/cookies > ls_sec_uid > my_uid;末尾加一致性收敛(my_uid 与 device_id 不一致时以 my_uid 为准)。tea 解析对非 dict 值(如 int 1)类型保护。
  2. cookie_store.py _parse_tea_from_lsif not isinstance(parsed, dict): continue,修复 __tea_cache_first_* 值为 1 时的 AttributeError 崩溃(保存凭证 500)。
  3. 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。
  4. credential.py build_im_session_from_storagesaved.uid_verified 覆盖 my_uid 时同步 device_id(之前只改 my_uid 不改 device_id,凭证残留设备号导致不一致)。

数据修复

  • 备份:backend/.bak/kefu*.db.before_uid_fix_20260827_*
  • 账号 1im_session_data.my_uid 7678646545793812008(web_id) → 2609567359568155uid_verified=True
  • 账号 8/9:仅补 uid_verified=True
  • 验证:3 账号均 device_id==my_uid==douyin_uidALL_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 + 新解析逻辑自然清洗。