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

222 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
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/send`(security-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_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_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 hook(manager 层驱动,避免 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_OK;`DouyinWorker.__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 Image`、`import 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.py` 和 `service.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_agent` → `DouyinAuth.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.py`**:`generate_webid(auth=None, url="", user_agent="")` 新增参数;内部 UA 优先级:显式参数 > `auth.user_agent` > DEFAULT(消除硬编码)。
3. **调用点全部显式传 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.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` 残留。
### 运行时验证(待重启后端)
配置账号 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` 列不回填。
### 修改
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_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)
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 冒烟:辅助方法存在且为 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 个文件)
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_id:extra/cookies > ls_sec_uid > my_uid;末尾加一致性收敛(my_uid 与 device_id 不一致时以 my_uid 为准)。tea 解析对非 dict 值(如 int 1)类型保护。
2. `cookie_store.py _parse_tea_from_ls`:`if 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_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 + 新解析逻辑自然清洗。