抖音私信自动化回复-多版本 DOM 适配

同一个”私信”按钮,换了账号就找不到了。这不是 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 更清晰,优先用最常见的版本
3if (!result) 做二级兜底用于历史遗留版本,与常用版本分离
4URL 前置拦截不在无关页面上做 DOM 查询,减少开销
5了解目标平台的架构wujie / shadow DOM / iframe,直接影响脚本能碰到哪些元素
6弹性选择器不是万能的页面底层大改版时仍需手动更新,但它把维护成本从”崩了就修”降到了”加一条”
程序员老狼

程序员老狼

新浪前高级开发工程师,Golang、PHP 全栈开发者,十余年后端架构实战经验。自研唯一客服系统及配套浏览器自动化插件,专注企业客服生态与 RPA 自动化技术。

了解更多 → 企业备案域名 · 聊城变量网络科技有限公司