惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
Netflix TechBlog - Medium
J
Java Code Geeks
爱范儿
爱范儿
雷峰网
雷峰网
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
博客园 - 三生石上(FineUI控件)
H
Hackread – Cybersecurity News, Data Breaches, AI and More
B
Blog RSS Feed
Google DeepMind News
Google DeepMind News
Jina AI
Jina AI
The GitHub Blog
The GitHub Blog
I
InfoQ
月光博客
月光博客
博客园 - 聂微东
博客园 - Franky
The Cloudflare Blog
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
G
Google Developers Blog
Blog — PlanetScale
Blog — PlanetScale
L
LangChain Blog
罗磊的独立博客
Apple Machine Learning Research
Apple Machine Learning Research

博客园 - 唯一客服系统开发笔记

十几年后端开发,承接网站/小程序开发接单 worbuddy上线静态页面实现在线客服咨询 Deepseek Harness 安装和使用 拆解当下 AI 智能体真实水平:能力很强,但落地短板卡死商业化 基于扣子旧版智能体搭建专属 AI 客服,打通抖音私信、电商直播全渠道对接 唯一客服系统聊天链接传参教程:精准识别访客、展示会员信息与商品卡片 唯一客服系统已适配达梦数据库 抖音开放平台企业号私信新规解读|7 月 6 日起私信手机号强制脱敏,服务商该如何适配? MySQL 迁移达梦数据库 达梦数据库 Windows 安装 + 基础操作 企业微信怎么设置投诉页面 汽修行业AI知识库应用到抖店智能咨询 快手直播间弹幕评论抓取 知识库 AI Excel表格数据上传处理 扣子知识库 AI 智能体完整创建、对接实操教程 客服私有化部署 + 抖音私信接入说明 抖店飞鸽客服自动回复机器人插件使用 网站客服系统与电商客服自动化工具 唯一客服浏览器插件自动回复抖音评论私信 拼多多客服如何实现24小时全自动值守、大幅降本增效?这款自动化插件帮你轻松破局! 扣子 AI 智能体对接企业微信客服 抖音私信和评论网页端浏览器自动化插件 功能详解 批量扒完抖音评论还能发私信 | 本地开源工具 唯一客服企业微信侧边栏客户画像:让客服在聊天时直接查看客户详情、进群记录和跟进轨迹 自定义微信卡片链接生成工具:自由设置标题、描述与封面图 唯一客服系统接入自定义大模型:一行代码都不用改,写个 HTTP 接口就够了 唯一客服系统多智能体架构深度解析:一个后台,四种 AI 引擎随心切换 GoLiveChat:Golang独立部署海外英文在线客服系统全解析 抖音无痕引流神器!评论自动回复藏微表情,规避系统检测,自动引私域 抖音评论区全自动表情回复插件-引流推广小技巧,有人评论自动化回表情
抖音私信自动化回复-多版本 DOM 适配-抖音对不同账号推了不同...
唯一客服系统开发笔记 · 2026-06-03 · via 博客园 - 唯一客服系统开发笔记

同一个”私信”按钮,换了账号就找不到了。这不是 bug,是抖音的灰度发布。我是如何用弹性选择器让自动化脚本在多个页面版本中都活下来的。

联系微信:llike620

引用原文:抖音私信自动化回复-多版本 DOM 适配

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 弹性选择器不是万能的 页面底层大改版时仍需手动更新,但它把维护成本从”崩了就修”降到了”加一条”