同一个”私信”按钮,换了账号就找不到了。这不是 bug,是抖音的灰度发布。我是如何用弹性选择器让自动化脚本在多个页面版本中都活下来的。
1. 当你以为定位对了,换个账号就崩了
事情的起点很简单——我写了一个浏览器扩展,用来监听抖音私信并自动回复。
最先定位到昵称的 XPath 是这样的:
let nickname = getTextNodeContent(document,
'//div[@data-mask="conversaton-detail-content"]/div[1]//span'
);
在 A 账号上跑得稳稳当当。换了 B 账号,直接返回 null。
加调试日志一看,B 账号的私信页面 DOM 结构完全不一样——没有 data-mask 属性,用的是一个叫 RightPanelHeadertitle 的 class。
同一个网址,同一个功能,不同的 DOM。
2. 罪魁祸首:A/B 实验下的多版本页面
这不是 bug,是抖音在做 A/B 实验(也叫灰度发布)。
| 策略 | 表现 |
|---|---|
| 灰度发布 | 新版本只推给部分用户,逐步放量 |
| A/B 测试 | 多个版本同时跑,对比数据后决定用哪个 |
| 用户分桶 | 按 user_id hash 分配到不同实验组 |
| 地域 / 设备差异 | 某些版本仅特定地区或设备可见 |
换句话说,你在浏览器里看到的抖音私信页面,和隔壁同事看到的可能源码都不相同。对于普通用户这是无感的,对于要做 DOM 自动化的我们来说,这就是噩梦。
这套私信页面的消息列表区域,我最终发现了 至少 4 种不同的 DOM 结构:
| 版本 | 条件 | XPath |
|---|---|---|
| 新版聊天列表 | messageMessageListlist | //div[@class="messageMessageListlist"]//div[@data-index="0"]//div[@data-e2e="msg-item-content"] |
| 当前会话高亮 | conversationConversationItemcurConversation | //div[contains(@class,"conversationConversationItemcurConversation")]//pre |
| 旧版消息面板 | messageContent + flex | //div[@id="messageContent"]/div[1]/div[3]/div[contains(@style, "justify-content: space-between;")]//pre |
| 单 class 会话条目 | 仅一个 class 名 | //div[@data-e2e="conversation-item" and contains(@class, " ") and not(contains(substring-after(@class, " "), " "))]//pre |
四种结构,指向同一个数据——最新的那条私信内容。
3. 解决方案:弹性选择器
我采用的核心策略叫 弹性选择器(Resilient Selectors),核心思想就一句话:
不指望一个选择器能通吃所有版本,用优先级降级的方式逐个尝试。
3.1 昵称提取:两级降级
let nickname = getTextNodeContent(document,
'//div[@data-mask="conversaton-detail-content"]/div[1]//span'
);
if (!nickname) {
nickname = getTextNodeContent(document,
'//div[@class="RightPanelHeadertitle"]'
);
}
先用最可靠的结构属性定位,失败后降级到 class 属性的备选。
3.2 消息内容提取:双级联降级
let content =
getTextNodeContent(document,
'//div[@class="messageMessageListlist"]//div[@data-index="0"]//div[@data-e2e="msg-item-content"]'
)
|| getTextNodeContent(document,
'//div[contains(@class,"conversationConversationItemcurConversation")]//pre'
);
if (!content) {
content =
getTextNodeContent(document,
'//div[@id="messageContent"]/div[1]/div[3]/div[contains(@style, "justify-content: space-between;")]//pre'
)
|| getTextNodeContent(document,
'//div[@data-e2e="conversation-item" and contains(@class, " ") and not(contains(substring-after(@class, " "), " "))]//pre'
);
}
第一层用 || 短路尝试两个常用版本;第二层用 if (!content) 兜底两个历史版本。四个选择器覆盖了所有已知的页面变体。
3.3 输入框:两路径适配
let textarea = getNode(document, '//div[@contenteditable="true"]/div/div');
if (textarea) {
simulateEditableInput('//div[@contenteditable="true"]/div/div', replyContent);
} else {
simulateEditableInput('//div[@contenteditable="true"]/div/span', replyContent);
}
抖音的 contenteditable 输入框在不同版本下结尾元素分别是 <div> 和 <span>,按顺序先试一个。
3.4 发送按钮:两套 class 体系
let btn = getNode(document, '//span[contains(@class,"e2e-send-msg-btn")]');
if (!btn) {
btn = getNode(document, '//div[contains(@class,"messageMsgInputinputAction")]/*[3]');
}
一个用 e2e- 前缀的测试定位属性,一个用业务 class 名——两种命名体系,只能同时兼容。
6. 完整架构图
┌─────────────────────────────────────────────────────────┐
│ URL Gating Layer │
│ location.href.startsWith("douyin/user/self") │
│ ↓ true │
├─────────────────────────────────────────────────────────┤
│ Resilient Selector Layer │
│ │
│ Nickname: Selector A → Selector B (fallback) │
│ Content: Selector A || Selector B → C || D │
│ Textarea: Selector A → Selector B │
│ Send Btn: Selector A → Selector B │
│ │
│ ↑ 4 components × N versions │
├─────────────────────────────────────────────────────────┤
│ DOM Access Layer │
│ document.evaluate (XPath, main doc) │
│ shadowRoot.firstElementChild (wujie) │
├─────────────────────────────────────────────────────────┤
│ Action Layer │
│ simulateClick2 / simulateEditableInput │
└─────────────────────────────────────────────────────────┘
7. 总结:几个值得带走的原则
| # | 原则 | 说明 |
|---|---|---|
| 1 | 别相信单一选择器 | 同一条 XPath 在不同账号下可能返回 null |
| 2 | 用 || 做短路降级 | 比层层 if 更清晰,优先用最常见的版本 |
| 3 | if (!result) 做二级兜底 | 用于历史遗留版本,与常用版本分离 |
| 4 | URL 前置拦截 | 不在无关页面上做 DOM 查询,减少开销 |
| 5 | 了解目标平台的架构 | wujie / shadow DOM / iframe,直接影响脚本能碰到哪些元素 |
| 6 | 弹性选择器不是万能的 | 页面底层大改版时仍需手动更新,但它把维护成本从”崩了就修”降到了”加一条” |