Files
MediaCrawler/mac-agent-os-main/03_knowledge/99_system/matrix/douyin-login-pitfalls.md
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

354 lines
13 KiB
Markdown
Raw 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.
---
title: 抖音登录流程 — 全链路知识与踩坑记录
tags: [douyin, login, sms, verification, iframe, selector, timing]
created: 2026-06-28
updated: 2026-06-28
nature: reference
---
# 抖音登录流程 — 全链路知识与踩坑记录
> 本文包含 2026-06-28 大规模调试中积累的所有登录经验。
> 适用于 `douyin_ops.py` 的 `_ensure_logged_in()` 和 `sms_login()` 方法。
---
## 一、登录状态检测
### 1.1 判断已登录的方法(按可靠性排序)
| 方法 | 选择器 | 可靠性 | 说明 |
|:-----|:--------|:-------|:------|
| `data-e2e` 用户信息区 | `[data-e2e="user-info"]` | ⭐⭐⭐ 最高 | 抖音官方 e2e 标记,有就必已登录 |
| 头像元素 | `[class*="avatar"]` 或 `[data-e2e*="avatar"]` | ⭐⭐ 中 | 部分页面没有这个选择器 |
| 登录按钮不可见 | `button:has-text("登录")` 不可见 | ⭐ 低 | 仅作兜底,不精确 |
### 1.2 判断未登录的方法
- 右上角可见的**「登录」按钮**
- 选择器:`button:has-text("登录"), span:has-text("登录"), div:has-text("登录")`
- 注意:首页推荐流中这个按钮始终存在,要用 `is_visible()` 确认
### 1.3 关键注意事项
- **首次打开抖音首页加载慢**,至少要等 **6 秒**才能做登录检测(我们试过 2s→3s→5s→6s,6s 最稳定)
- 登录弹窗可能在上次会话的 cookie 有效时不弹出,直接显示已登录状态
- 登录成功后**必须重新导航到 `/user/self`**,因为弹窗处理改变了页面 URL
---
## 二、登录弹窗检测
### 2.1 弹窗位置:iframe
抖音的登录弹窗(passport 护照页)是在 **iframe** 中加载的,iframe URL 模式:
- `passport.douyin.com`
- `sso.douyin.com`
- 域名含 `login`
### 2.2 弹窗选择器
| 用途 | 选择器 | 说明 |
|:-----|:--------|:------|
| 弹窗容器 | `.second-verify-panel, .uc-ui-verify_sms-verify` | 覆盖新旧两版 class |
| 二维码区域 | `div[class*=qrcode], img[class*=qrcode], canvas[class*=qrcode]` | 用于二维码检测 |
### 2.3 ⚠️ iframe 查找顺序(重要!)
**必须先查 iframe,再查主页面!**
```
❌ 错误做法(我们踩过的坑):
1. 主页面查 verify_panel → 匹配到遮罩层(`.second-verify-panel`)
2. login_frame = page(主页面)
3. 在主页面搜手机输入框 → 找不到 ❌(因为输入框在 iframe 内)
✅ 正确做法(修复后):
1. 遍历所有 iframe,找 passport/login 域的 verify_panel
2. 找到 → login_frame = iframe
3. 在 iframe 内搜手机输入框 → 能找到 ✅
4. iframe 没找到 → fallback 到主页面
```
### 2.4 弹窗类型判断
弹窗可能有三种状态:
```
弹窗出现
├─ 一键登录 → 有 "一键登录" 按钮,无手机号输入框
├─ 验证码登录 → 有手机号输入框 + 获取验证码按钮
└─ 二维码登录 → 有二维码图片/画布,需切 tab
```
#### 一键登录特征
- 选择器:`button:has-text("一键登录"), span:has-text("一键登录")`
- 点击后等 5 秒
- 可能直接登录成功,也可能跳到验证码页(继续走 sms)
#### 验证码登录特征
- 手机号输入框:`input[placeholder*="手机"]`(匹配 `placeholder="请输入手机号"`)
- 验证码输入框:`input[placeholder*="验证码"]`(匹配 `placeholder="请输入验证码"`)
- 获取验证码按钮:`button:has-text("获取验证码")`
#### 二维码登录特征
- 检测:`div[class*=qrcode], img[class*=qrcode], canvas[class*=qrcode]`
- 处理:找到「验证码登录」tab 并点击切换
---
## 三、SMS 验证码流程
### 3.1 完整流程
```
1. 导航到登录页(如果不在)
2. 查找 passport iframe(最多 5 次尝试,每次间隔 2 秒)
3. 等 3 秒渲染
4. 检测二维码 → 切「验证码登录」tab
5. 找手机号输入框 → 填入手机号(3 次重试,每次间隔 1 秒)
6. 点击「获取验证码」
7. 轮询 SMS API 获取验证码(最多 6 次,共 60 秒超时)
8. 填入验证码
9. 点击「登录」
10. 判断是否登录成功
```
### 3.2 手机号输入框查找(带重试)
```python
phone_sel = "input[placeholder*='手机'], input[type='tel'], input[name='mobile'], input[id*='phone'], input[id*='mobile']"
for retry in range(3):
for sel in [phone_sel, "input:first-of-type"]:
inp = await login_frame.query_selector(sel)
if inp and await inp.is_visible():
# 填入手机号
break
if phone_filled:
break
await asyncio.sleep(1)
```
**注意**:`input:first-of-type` 会匹配到首页搜索框,所以优先用 `placeholder` 选择器。
### 3.3 验证码输入框查找
```python
code_sel = "input[placeholder*='验证码'], input[maxlength='6'], input[maxlength='4'], input[autocomplete='one-time-code']"
```
### 3.4 登录成功判断
| 判断方法 | 说明 |
|:---------|:------|
| URL 变化 | `"passport" in current_url` → 仍在登录页 / `not` → 登录成功 |
| 右上角头像 | `[data-e2e="user-info"]` 存在且可见 → 登录成功(**兜底方案**)|
**两者都用!** 如果 URL 变了但右上角还没渲染出头像,等 3 秒再查一次。
---
## 四、时序控制(重要)
### 4.1 各步骤等待时间
| 步骤 | 等待时间 | 原因 |
|:-----|:---------|:------|
| 首次打开后检测 | **6 秒** | 抖音首页加载慢,特别是第一打开,推荐流+弹窗需要完整渲染 |
| 切换 tab 后 | **3 秒** | tab 切换动画 + 新内容渲染 |
| 填手机号后 | **1 秒** | 防抖 + 输入框状态同步 |
| 点击获取验证码后 | **2 秒** | 等待 SMS 发送 |
| 填入验证码后 | **1 秒** | 防抖 |
| 点击登录后 | **3-5 秒** | 等待页面跳转 |
| 登录成功后重导航 | **3 秒** | 等待 `/user/self` 渲染 |
### 4.2 超时控制
- SMS 验证码轮询:最长 **60 秒**(6 次 × 10 秒)
- 单次 iframe 查找:最长 **10 秒**(5 次 × 2 秒)
- 页面 goto 超时:**20 秒**
---
## 五、选择器备忘(抖音 Web 版)
### 5.1 官方 `data-e2e` 选择器
这些是抖音官方的自动化测试标记,最稳定:
| 用途 | 选择器 |
|:-----|:--------|
| 用户信息区(已登录标志) | `[data-e2e="user-info"]` |
| 用户头像 | `[data-e2e="user-avatar"]`(不一定存在) |
| 搜索输入框 | `[data-e2e="searchbar-input"]` |
| 点赞按钮 | `[data-e2e="video-player-digg"]` |
| 收藏按钮 | `[data-e2e="video-player-collect"]` |
| 分享按钮 | `[data-e2e="video-player-share"]` |
| 评论列表 | `[data-e2e="comment-list"]` |
| 评论点赞 | `[data-e2e="comment-digg"]` |
| 粉丝数 | `[data-e2e="user-info-fans"]`(值可能混合中文) |
| 关注数 | `[data-e2e="user-info-follow"]` |
| 获赞数 | `[data-e2e="user-info-like"]` |
### 5.2 登录弹窗选择器(不定期变化)
这些 class 名由抖音前端动态生成,**每次发布可能变化**:
| 用途 | 6月28日已确认的 class |
|:-----|:----------------------|
| 手机号输入框 | `yKcGN1NT X5L8kib_` |
| 验证码输入框 | `tnpNAdqe _Yqor1vk` |
| 获取验证码按钮 | `gKXDWaPG KX2Mhb6E` |
| 验证码 tab | `zad3EGHJ` |
**因此,不要依赖 class 名,用 placeholder/text 代替。**
### 5.3 推荐做法:文本/属性匹配
```python
# ✅ 稳定
input[placeholder*="手机"] # 匹配 "请输入手机号"
input[placeholder*="验证码"] # 匹配 "请输入验证码"
button:has-text("获取验证码") # 匹配按钮文字
div:has-text("验证码登录") # 匹配 tab 文字
# ❌ 不稳定(class 会变)
.tnpNAdqe # 下个版本可能就改了
.yKcGN1NT # 同上
```
---
## 六、已踩过的坑(必读)
### 坑 1:logger 未定义导致 executor 闪退
**现象**:任务提交后状态一直 "running",实际已挂,slots 空但 active_tasks 有残留。
**根因**:`executor.py` 中使用了 `logger.info()` 但 `logger = logging.getLogger("guardd.executor")` 未定义,NameError 抛出,executor 闪退但任务状态来不及更新。
**修复**:添加 `logger = logging.getLogger("guardd.executor")`。
### 坑 2:主页面遮罩层匹配导致 iframe 检测失败
**现象**:弹窗出现了但手机号输入框找不到。
**根因**:`verify_panel` 选择器 `.second-verify-panel` 在主页面遮罩层上匹配成功,`login_frame` 设为主页面(不是 iframe),在主页面搜手机输入框当然搜不到。
**修复**:先查 iframe,再查主页面。
### 坑 3:登录后页面 URL 变化导致采集到首页数据
**现象**:采集任务显示完成,但 profiles.json 中昵称/粉丝数据为空或为首页标题。
**根因**:登录弹窗处理后,页面 URL 从 `/user/self` 变成了首页或其他地址,但 `goto_profile()` 继续刮取当前页面内容(首页推荐流),拿不到用户数据。
**修复**:登录成功后重新 `goto("/user/self")`。
### 坑 4:2 秒等待不够
**现象**:浏览器打开后鼠标立即动了,此时页面还没渲染完,弹窗还没出现。
**根因**:抖音首页首次加载~4-5 秒,2 秒时 `_has_avatar()` 判断为未登录(因为没渲染完),然后触发弹窗检测也找不到弹窗,最后点了"登录"按钮导致页面刷新。
**修复**:初始等待时间从 2 秒改为 6 秒。
### 坑 5:`_build_cmd` 按字段拼装导致 command_line 出错
**现象**:采集任务一直失败,executor 日志显示 `--blueprints=` 为空。
**根因**:`executor._build_cmd()` 从 `task.get("blueprint")` 读蓝图名,但 CommandBus 传的 `blueprint` 字段是空字符串(没传对),拼出了 `mc run --blueprints= --rounds=1`。
**修复**:优先使用 `task.command_line`(CommandBus 已渲染好的完整命令),`_build_cmd` 按字段拼装仅作 fallback。
### 坑 6:5kecheng 双 guardd 进程
**现象**:远程机器任务不执行,所有请求都返回空。
**根因**:SSH 部署时 `pkill -9 -f guardd` + `nohup python3 guardd.py` 启动了一个新实例,而 launchd 也管理了一个旧实例。两个 guardd 争抢端口 9090,其中一个没有 HTTP 服务。
**修复**:统一用 launchd 管理,不用 nohup。
### 坑 7:登录后 URL 不变(前端 overlay 形式)
**现象**:登录成功了但 `sms_login` 返回 False。
**根因**:有的登录弹窗是前端 overlay(不在 passport 域名下),URL 不变化。`sms_login` 只检查 URL,误判为失败。
**修复**:加 `_has_avatar()` 兜底判断。
### 坑 8:profiles.json 同步走本地文件违反联邦对等
**现象**:7kecheng 采集了数据,但 Dashboard 看不到。
**根因**:`AccountService._get_profile_for_account()` 直接读本机 `profiles.json`,不走 guardd API。
**修复**:改为统一通过 guardd HTTP API (`/accounts/profiles`) 读取,本机远程无差别。
---
## 七、录制文件分析指南
录制的 JSON 文件位于 `agent-local/tools/matrix/recordings/`。
### 7.1 关键字段含义
| 字段 | 含义 |
|:-----|:------|
| `t` | 事件类型(click/hover/key/scroll) |
| `tag` | HTML 标签 |
| `text` | 元素文本内容 |
| `cls` | 元素的 CSS class(关键!) |
| `placeholder` | input 元素的占位符 |
| `type` | input 类型 |
| `e2e` | data-e2e 属性值 |
| `vis` | 元素是否可见 |
| `before_state.selectors` | 录制前页面状态快照 |
| `before_state.text_snippet` | 页面文本片段 |
### 7.2 如何从录制中提取特征码
```bash
# 找手机号输入框
grep -B5 -A3 "请输入手机号" recording_xxx.json
# 找验证码输入框
grep -B5 -A3 "请输入验证码" recording_xxx.json
# 找登录按钮
grep -B5 -A3 "获取验证码" recording_xxx.json
# 查看页面文本快照(了解页面状态)
grep "text_snippet" recording_xxx.json | head -1 | python3 -c "import json,sys;print(json.load(sys.stdin)['text_snippet'][:500])"
```
---
## 八、蓝图登录检测覆盖范围
当前 `_ensure_logged_in()` 被以下操作调用:
| 蓝图操作 | 在流程中调用位置 |
|:---------|:----------------|
| `goto_home` | `execute_op()` 中 goto 首页后 |
| `dy_goto_profile` | `execute_op()` 中 goto 个人主页后 |
| `goto_profile` | 独立方法中被调用 |
| `_ensure_logged_in` 自身 | 无弹窗时递归调用 |
### 设计原则
所有涉及浏览器导航的操作(`goto_home`、`goto_url`、`goto_profile`)**都应该**调用 `_ensure_logged_in()`。目前 `goto_url` 还没有加,待补。
---
## 九、相关文件索引
| 文件 | 内容 |
|:-----|:------|
| `douyin_ops.py` | 抖音原子操作 + `_ensure_logged_in` + `sms_login` |
| `matrix_modules/account/sms_login.py` | SMS 登录模块 |
| `matrix_modules/account/douyin_login.py` | 抖音登录模块 |
| `matrix_modules/account/login_state_machine.py` | 登录状态机 |
| `03_knowledge/99_system/matrix/matrix-known-pitfalls.md` | 已知坑(浏览器/系统) |
| `03_knowledge/99_system/matrix/matrix-sms-verification.md` | SMS 验证码自动接收配置 |