移植自 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 带网关原话 / 正常返回)。
354 lines
13 KiB
Markdown
354 lines
13 KiB
Markdown
---
|
||
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 验证码自动接收配置 |
|