Douyin Messager | 抖音私信助手
通过浏览器自动化发送抖音私信、获取聊天记录;也支持打开抖音视频/图文链接、读取评论区并做简要分析,在用户明确确认后执行评论/回复等写入操作。
前置要求 | Prerequisites
| 条件 | 说明 |
|---|---|
| Browser profile | 必须使用 openclaw profile |
| 登录状态 | 抖音账号已在 openclaw profile 中保持登录态 |
⚠️ 执行前必须确认
- 用户已登录抖音账号(可目测判断)
- xdg-open 弹窗:只在 Linux 下存在
- Linux:询问用户弹窗是否关闭
- Windows/macOS:跳过
当前能力边界(阶段性总结)
本技能当前已验证可完成四类操作:
- 查看私信会话列表:进入私信悬浮面板后,读取可见的私聊/群聊列表,区分会话名称、最新消息预览、时间、未读数和置顶状态。
- 进入具体聊天窗口:从会话列表点击指定私聊或群聊,进入聊天详情页,读取当前已加载的聊天记录。
- 发送文本消息:在聊天详情底部的 Draft.js 输入框中输入文本,并通过发送按钮完成发送。
- 视频/图文评论区处理:可搜索或打开指定视频/图文链接,读取评论区,输出情绪简报;评论、回复、点赞等外部互动写入必须先获得用户明确确认。
需要注意:网页版对视频、图集、点赞、撤回、暂不支持消息等卡片类内容的 DOM 暴露不完整,读取时应保守标注,不应强行推断完整内容或发送者。
核心思路
抖音私信是一个悬浮在页面右侧的动态面板,包含"会话列表"和"聊天详情"两个视图。
会话列表布局(类 QQ/微信):左侧头像,中间分上下两行(上行:用户名/群名;下行:最新消息预览,群聊格式为「发消息的人:消息内容」),右侧时间戳。
聊天详情布局:进入某个会话后,右侧显示聊天记录。普通文本消息通常可提取时间、消息内容,并可根据气泡方向、操作项(如是否有「撤回」)辅助判断是否为本人消息;群聊中他人文本消息有时可从消息文本或引用结构中识别发送者。卡片类消息需谨慎处理。
方法论:不依赖固定 class name,而是通过元素的几何特征 + 内容特征动态查找。具体 class name 仅作参考示例。
获取私信列表流程
获取私信的第一步不是进入某个聊天,而是正确进入私信会话列表。
步骤 1:打开私信会话列表
- 打开抖音主页或个人主页。
- 在顶部导航栏右侧定位「私信」入口(可能显示未读数,如「私信11」)。
- 点击后等待右侧悬浮面板出现。
- 确认当前处于会话列表视图,而不是某个聊天详情页。
步骤 2:识别每一行会话
会话列表中每一行对应一个私聊或群聊。布局通常为:左侧头像;中间上行为会话名称(用户名/群名,可能带「置顶」标记);中间下行为最新消息预览;右侧为时间戳或未读标记。
⚠️ 会话列表本身是可滚动容器,不要只按当前可见区域判断数量。实测中滚动容器宽约 328px、高约 593px,scrollHeight > clientHeight,可通过 scrollTop 滚到底部;出现「暂时没有更多了」才表示到底。
解析时应优先按行容器处理,而不是直接读取父元素的合并文本。读取每行时分别提取:
| 字段 | 识别方法 |
|---|---|
| 会话名称 | 行内上方文本,通常是用户名或群名;若有「置顶」应作为标记处理,不并入名称 |
| 最新消息 | 行内下方文本;群聊常见格式为「发送者:消息内容」 |
| 最新发送者 | 若最新消息包含「:」,冒号前通常是群聊中最新消息发送者 |
| 时间 | 行右侧或下行末尾的时间文本,如「昨天」「04-25」「01:42」 |
| 未读数 | 行右侧 badge 数字;顶部「私信(N)」通常表示总未读数 |
| 置顶 | 名称行附近出现「置顶」标记时记录为 pinned=true |
步骤 3:区分私聊与群聊
- 私聊:上行通常是对方昵称;下行多为消息内容本身。
- 群聊:上行是群名;下行常为「发送者:消息内容」。
- 不要把上行群名和下行发送者合并为一个名称。
示例参考:
| 上行(会话名称) | 下行(最新消息) | 解析结果 |
|---|---|---|
示例群A | 成员甲:分享[视频] · 昨天 | 群名/会话名=示例群A,最新发送者=成员甲 |
项目交流群 | 成员乙:分享[图集] · 04-25 | 群名=项目交流群,最新发送者=成员乙 |
联系人A + 置顶 | 收到,谢谢 · 01:42 | 私聊=联系人A,pinned=true |
示例:提取可见会话行文本
以下代码用于辅助观察当前可见列表,不应把 class name 当成稳定依赖:
browser action=act request={"kind": "evaluate", "fn": "() => { const rows = []; const all = Array.from(document.querySelectorAll('*')); for (const el of all) { const r = el.getBoundingClientRect(); const text = (el.textContent || '').trim(); if (r.width >= 250 && r.width <= 360 && r.height >= 50 && r.height <= 90 && r.left > window.innerWidth * 0.55 && text && !text.includes('私信(')) { rows.push({top: Math.round(r.top), text}); } } return rows.sort((a,b) => a.top - b.top); }"}
完整发送流程
步骤 1:打开抖音主页
browser action=open profile=openclaw targetUrl=https://www.douyin.com/
browser action=act request={"kind": "wait", "timeMs": 3000}
步骤 2:打开私信悬浮面板
找按钮:在页面顶部导航栏中,找到文字为"私信"(可能带未读数如"私信15")且位于页面最右侧的元素,点击它。
browser action=act request={"kind": "evaluate", "fn": "() => { const all = Array.from(document.querySelectorAll('*')); for(const el of all) { const t = (el.textContent||'').trim(); const r = el.getBoundingClientRect(); if((t === '私信' || t.startsWith('私信')) && r.width > 0 && r.height > 0 && r.width < 100 && r.left > window.innerWidth - 200) { el.click(); return 'clicked: ' + t; } } return 'not found'; }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
找面板:点击后,页面右侧应出现一个约 500-600px 宽、600-700px 高的悬浮面板。
browser action=act request={"kind": "evaluate", "fn": "() => { const all = document.querySelectorAll('[class]'); for(const el of all) { const r = el.getBoundingClientRect(); if(r.width >= 450 && r.width <= 650 && r.height >= 500 && r.height <= 750 && r.left > 0 && r.top > 0 && r.left < 1500) { const text = (el.textContent||'').trim(); if(text.includes('私信')) return 'found panel: ' + r.width + 'x' + r.height + ' at ' + Math.round(r.left); } } return 'panel not found'; }"}
示例参考:实测中面板容器 class 为
vgonMAXk,宽 581px、高 649px,固定在 x:1235 位置。但这是动态的,应以几何定位为主。
步骤 3:进入具体聊天
找目标用户:在面板的会话列表中,找到目标用户名的元素并点击。
browser action=act request={"kind": "evaluate", "fn": "() => { const all = Array.from(document.querySelectorAll('*')); for(const el of all) { const t = (el.textContent||'').trim(); const r = el.getBoundingClientRect(); if(t.includes('<目标用户名>') && r.width > 0 && r.height > 0 && r.left > 0) { el.click(); return 'clicked: ' + t; } } return 'not found'; }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
成功标志:面板从会话列表切换为聊天详情,底部出现输入区域。
示例参考:会话列表区域 class 为
zEpd_aAP(左侧,500px宽);聊天详情区域 class 为w5duGc5Q(右侧)。
步骤 4:发送消息
找输入框:聊天详情底部有一个 Draft.js 富文本编辑器(contenteditable="true",class 包含 DraftEditor-content)。
browser action=act request={"kind": "evaluate", "fn": "() => { const all = document.querySelectorAll('[contenteditable=\"true\"]'); for(const el of all) { const r = el.getBoundingClientRect(); if(r.width > 200 && r.width < 500 && r.height > 10 && r.height < 100 && r.left > 1000) return 'found input at ' + Math.round(r.left) + ',' + Math.round(r.top); } return 'input not found'; }"}
发送消息:先用 type 方式写入 Draft.js 编辑器,再点击发送按钮。
// 1. 写入文本
browser action=act request={"kind": "type", "selector": "[contenteditable=\"true\"]", "text": "你好,测试消息"}
browser action=act request={"kind": "wait", "timeMs": 500}
// 2. 点击发送按钮(优先用几何位置查找,也可用实测 class 作为参考)
browser action=act request={"kind": "evaluate", "fn": "() => { const btn = Array.from(document.querySelectorAll('*')).find(el => { const r = el.getBoundingClientRect(); const cls = String(el.className || ''); return r.width >= 20 && r.width <= 50 && r.height >= 20 && r.height <= 50 && r.left > window.innerWidth * 0.7 && r.top > window.innerHeight * 0.55 && (cls.includes('send') || cls.includes('PygT7Ced')); }); if(!btn) return 'send button not found'; const r = btn.getBoundingClientRect(); btn.click(); return 'clicked send at ' + Math.round(r.left + r.width/2) + ',' + Math.round(r.top + r.height/2); }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
⚠️ 必须用
type写入 Draft.js 编辑器。直接操作 DOM 文本(textContent、execCommand、clipboard)无法触发 Draft.js 内部状态,发送按钮会保持禁用。
示例参考:实测中输入框 class 为
notranslate public-DraftEditor-content(contenteditable="true"),宽约 329px,位置 x:1346, y:651。
步骤 5:确认发送成功
browser action=act request={"kind": "evaluate", "fn": "() => { const input = document.querySelector('[contenteditable=\"true\"]'); if(!input) return 'input not found'; return input.textContent.length === 0 ? 'sent ✓' : 'not sent: ' + input.textContent; }"}
发送成功后:输入框被自动清空。
定位方法总结
| 目标 | 定位策略 | 实测示例(参考) |
|---|---|---|
| 「私信」按钮 | 文字="私信"或"私信数字",位于页面最右侧(r.left > innerWidth - 200) | 文字="私信15",viewport x:1708, y:28 |
| 私信面板 | 宽 450-650px、高 500-750px、固定在页面右侧,内容含"私信" | class=vgonMAXk,581×649px,x:1235 |
| 会话列表 | 面板左侧区域 | class=zEpd_aAP(宽~81px) |
| 聊天详情 | 面板右侧区域 | class=w5duGc5Q(宽~500px) |
| 输入框 | contenteditable="true",宽 200-500px,位于页面右侧 | class=notranslate public-DraftEditor-content,329×22px |
| 发送按钮 | 输入框右侧;写入文本后点击 | class 示例:PygT7Ced e2e-send-msg-btn |
注意:以上尺寸和位置基于典型 1920px 宽屏幕实测。class name 会随抖音版本变化,几何特征定位是更稳定的方法。
读取具体聊天记录
进入某个私聊或群聊后,可以读取聊天详情页中当前已加载的消息块。读取时建议按消息块容器处理,而不是直接读取整个聊天面板的合并文本。
可识别字段
| 字段 | 说明 |
|---|---|
| 时间 | 消息块上方或附近的时间文本,如「刚刚」「2026-04-04 19:07」 |
| 消息内容 | 普通文本消息可从气泡文本中提取 |
| 是否本人消息 | 可结合气泡位置、是否出现「撤回」操作等特征辅助判断 |
| 群聊发送者 | 普通文本或引用消息中可能出现发送者名称;若 DOM 未明确暴露,应标注为未知/推断 |
| 卡片类内容 | 视频、图集、点赞、撤回、不支持类型等可能只显示占位文本,不应过度解析 |
示例:读取当前已加载消息块
browser action=act request={"kind": "evaluate", "fn": "() => { const area = document.querySelector('.IRB0Sra6') || document.querySelector('.z1iI1SFY'); if(!area) return 'message area not found'; const blocks = Array.from(area.querySelectorAll('.mM66nPpS')); return blocks.map(block => { const time = block.querySelector('.mA74174G')?.textContent?.trim() || ''; const text = (block.querySelector('.G3hOMUUp') || block.querySelector('.J3X6BOUb') || block).textContent.trim(); const mine = block.textContent.includes('撤回'); return { time, mine, text }; }); }"}
上述 class name 是实测示例;正式逻辑应优先用消息区域的几何位置和消息块尺寸筛选,再结合文本特征解析。
获取对方回复
滚动到最新消息
browser action=act request={"kind": "evaluate", "fn": "() => { const all = Array.from(document.querySelectorAll('*')); for(const el of all) { const r = el.getBoundingClientRect(); if(r.width >= 400 && r.width <= 600 && r.height >= 300 && r.height <= 800 && r.left > 800) { el.scrollTop = el.scrollHeight; return 'scrolled'; } } return 'not found'; }"}
browser action=act request={"kind": "wait", "timeMs": 1000}
截图确认
browser action=screenshot
视频搜索与评论区操作(阶段性验证)
当前已验证可完成以下只读/可定位操作:
- 按关键词搜索视频:可直接打开
https://www.douyin.com/search/<关键词>?type=video,从搜索结果中提取/video/<id>链接、标题、作者、时间和互动数字。 - 打开指定视频/图文:可直接导航到
https://www.douyin.com/video/<id>或用户给出的分享链接。图文/笔记类内容可能会规范化跳转为/note/<id>,个人页弹窗链接中的modal_id=<id>也可作为目标内容 ID 使用。 - 读取视频信息:可从页面文本中读取标题、作者、发布时间、点赞/评论/收藏/分享等可见数字(具体字段需按页面布局解析)。
- 读取评论区:视频页评论区在页面下方,需滚动到「全部评论」区域;可读取可见评论的昵称、内容、时间/地区、点赞数、分享/回复入口,并可继续向下滚动加载更多评论。
- 定位发评论输入框:点击「留下你的精彩评论吧」后,会出现 Draft.js 输入框:
contenteditable="true"且 class 包含public-DraftEditor-content。 - 定位回复输入框:点击某条评论的「回复」后,会在该评论下方出现 Draft.js 输入框,placeholder 形如「回复@用户名」。
- 定位发送按钮:输入文字后,评论框右侧会出现图标按钮区,最右侧圆形上箭头按钮为发送/发布入口。
- 定位评论点赞入口:每条评论的操作区包含点赞数、分享、回复;点赞图标/数字区域可通过评论块几何位置定位。
- 评论区情绪简报:基于已加载评论文本,按正向/中性/负向/争议或信息不足分类,输出样本量、主要情绪、典型主题和置信度;样本少时必须注明限制。
⚠️ 发评论、回复评论、点赞、分享等都属于外部互动写入操作,必须先获得用户明确确认;测试时可以定位输入框和按钮,但不要擅自提交。
搜索并提取视频链接示例
browser action=navigate profile=openclaw targetUrl="https://www.douyin.com/search/OpenClaw?type=video"
browser action=act request={"kind":"wait","timeMs":3000}
browser action=act request={"kind":"evaluate","fn":"() => Array.from(document.querySelectorAll('a[href*=\"/video/\"]')).map(a => ({ href: a.href, text: (a.innerText || a.textContent || '').trim() })).slice(0, 20)"}
打开视频并滚动到评论区
browser action=navigate profile=openclaw targetUrl="https://www.douyin.com/video/<video_id>"
browser action=act request={"kind":"wait","timeMs":5000}
browser action=act request={"kind":"evaluate","fn":"() => { const el = Array.from(document.querySelectorAll('*')).find(el => (el.innerText || el.textContent || '').includes('全部评论') && el.getBoundingClientRect().y > 100); if (el) { el.scrollIntoView({block:'start'}); return 'scrolled to comments'; } return 'comments not found'; }"}
从个人页弹窗链接打开目标内容
用户可能发送形如 https://www.douyin.com/user/self?...&modal_id=<id>&showTab=like 的链接。此类链接可直接打开;若页面进入个人页弹窗,也可提取 modal_id 后直接尝试:
browser action=navigate profile=openclaw targetUrl="https://www.douyin.com/video/<modal_id>"
browser action=act request={"kind":"wait","timeMs":5000}
// 页面可能自动跳转为 /note/<modal_id>,属于正常情况。
读取可见评论示例
browser action=act request={"kind":"evaluate","fn":"() => Array.from(document.querySelectorAll('*')).map(el => { const r = el.getBoundingClientRect(); const text = (el.innerText || el.textContent || '').trim(); return { r, text, cls: String(el.className || '') }; }).filter(o => o.r.width > 300 && o.r.height > 60 && o.text.includes('回复') && o.text.includes('·')).slice(0, 20).map(o => o.text)"}
图文/笔记页的评论区可能在右侧栏,通过「评论(N)」标签切换;即使初始显示 评论(0),点击后也可能加载真实评论。读取时以当前 DOM 文本为准,并检查是否出现「暂时没有更多评论」。
评论区情绪简报模板
评论区情绪简报:
- 样本量:已加载 N 条评论
- 整体情绪:正向 / 中性 / 负向 / 混合 / 信息不足
- 主要主题:……
- 风险/争议点:……
- 置信度:高 / 中 / 低(说明原因)
评论/回复输入框定位
// 点击「留下你的精彩评论吧」或某条评论的「回复」后:
browser action=act request={"kind":"evaluate","fn":"() => { const input = document.querySelector('[contenteditable=\"true\"].public-DraftEditor-content'); if (!input) return 'input not found'; const r = input.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), text: input.innerText }; }"}
常见问题
Q: 点击「私信」按钮没反应?
A: 确认点击的是正确的按钮(顶栏最右侧,带"私信"文字)。也可先导航到 /user/self 页面再点击。
Q: 私信面板找不到?
A: 确认点击后有等待足够时间(wait 2000ms)。面板宽约 500px、高约 650px,固定在页面右侧。
Q: 输入框找不到? A: 必须先在会话列表中点击一个具体用户,进入聊天详情模式,输入框才会出现。
Q: 消息输入了但发送不成功?
A: 先用 kind: 'type' 写入 Draft.js 输入框,再点击发送按钮。直接操作 DOM 文本(textContent赋值、execCommand、clipboard paste)无法触发 Draft.js 状态。
Q: 怎么确认发送成功了? A: 发送成功后输入框被自动清空(textContent 变为空字符串)。
完整示例
// 1. 打开抖音
browser action=open profile=openclaw targetUrl=https://www.douyin.com/
browser action=act request={"kind": "wait", "timeMs": 3000}
// 2. 点击「私信」按钮
browser action=act request={"kind": "evaluate", "fn": "() => { const all = Array.from(document.querySelectorAll('*')); for(const el of all) { const t = (el.textContent||'').trim(); const r = el.getBoundingClientRect(); if((t === '私信' || t.startsWith('私信')) && r.width > 0 && r.height > 0 && r.width < 100 && r.left > window.innerWidth - 200) { el.click(); return 'clicked: ' + t; } } return 'not found'; }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
// 3. 点击目标用户
browser action=act request={"kind": "evaluate", "fn": "() => { const all = Array.from(document.querySelectorAll('*')); for(const el of all) { const t = (el.textContent||'').trim(); const r = el.getBoundingClientRect(); if(t.includes('<目标用户名>') && r.width > 0 && r.height > 0) { el.click(); return 'clicked: ' + t; } } return 'not found'; }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
// 4. 写入消息并点击发送
browser action=act request={"kind": "type", "selector": "[contenteditable=\"true\"]", "text": "你好,这是测试消息"}
browser action=act request={"kind": "wait", "timeMs": 500}
browser action=act request={"kind": "evaluate", "fn": "() => { const btn = Array.from(document.querySelectorAll('*')).find(el => { const r = el.getBoundingClientRect(); const cls = String(el.className || ''); return r.width >= 20 && r.width <= 50 && r.height >= 20 && r.height <= 50 && r.left > window.innerWidth * 0.7 && r.top > window.innerHeight * 0.55 && (cls.includes('send') || cls.includes('PygT7Ced')); }); if(!btn) return 'send button not found'; btn.click(); return 'clicked send'; }"}
browser action=act request={"kind": "wait", "timeMs": 2000}
// 5. 确认发送
browser action=act request={"kind": "evaluate", "fn": "() => { const input = document.querySelector('[contenteditable=\"true\"]'); return input ? (input.textContent.length === 0 ? 'sent ✓' : 'not sent: ' + input.textContent) : 'input not found'; }"}