豆瓣短长评采集 → 口碑分析 docx
交付规格(硬约束)
- 最终交付物 = 单个 docx:结构固定为「口碑分析 → 长评全文 → 短评」三部分,纯文本排版。
- 不交付 CSV / md / pptx。中间产物(
tmp_*.json)仅用于处理,处理完可删。 - 长评、短评不用表格,用纯文本条目呈现。
前置环境
- ego-browser(外部运行时依赖):本 skill 的采集命令全部跑在
ego-browser nodejs <<'EOF' ... EOF上,依赖它注入的useOrCreateTaskSpace/js/openOrReuseTab/gotoAndWait/cliLog/wait/completeTaskSpace等 helper。ego-browser(也叫 ego lite)是基于 Chromium 的浏览器工具,ego-browser命令需在 PATH 中可用;未安装见 ego-browser skill 的references/install.md。 - 豆瓣登录态(首次手动,后续失效需重登):首次使用需在 ego-browser 里打开豆瓣并手动登录一次(扫码/账密都行),agent 通过
document.cookie中的ck字段判断登录态(handOffTaskSpace让用户登录,登录完takeOverTaskSpace接回)。登录态会失效——cookie 过期、被风控、清缓存、退出登录等场景都需在 ego-browser 里重新登录一次。探测方式见 Step 1 的loginState。 - Python venv:
/Users/xuchenyoung/.workbuddy/binaries/python/envs/default/bin/python3 - 依赖:
python-docx、jieba(缺失时用该 venv 的 pip 安装)。
流程总览
- 定位 subject id
- 探测登录态
- 采集短评(热度 + 最新双通道)
- 采集长评 top50 全文
- 合并去重 + 分析(词频 / 高赞摘录 / 口碑结论)
- 生成 docx(
scripts/build_docx.py)
采集脚本一律用 ego-browser nodejs <<'EOF' ... EOF heredoc 运行,不写 .js 文件。所有 heredoc 首行 useOrCreateTaskSpace 复用同一空间。
输入格式
最佳:直接提供豆瓣图书页面链接(如 https://book.douban.com/subject/36593622/),agent 从 URL 中提 subject id,跳过搜索定位。
次优:只给书名——走 Step 1 的搜索页定位,结果可能命中多个版本(不同封面/译者),需选最匹配的一个。
Step 1 — 定位 subject id + 探测登录态
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('douban book comments')
cliLog('task space id: ' + task.id)
// 1) 搜索书名定位 subject id
const url = 'https://book.douban.com/subject_search?search_text=' + encodeURIComponent('《书名》')
await openOrReuseTab(url, { wait: true, timeout: 20 })
const results = await js(String.raw`(() => {
const out = []
document.querySelectorAll('a').forEach(a => {
const m = a.href.match(/book\.douban\.com\/subject\/(\d+)/)
if (m && a.innerText.trim()) out.push({ id: m[1], text: a.innerText.trim().slice(0, 40) })
})
const seen = new Set()
return out.filter(x => { if (seen.has(x.id + x.text)) return false; seen.add(x.id + x.text); return true }).slice(0, 20)
})()`)
cliLog('results: ' + JSON.stringify(results, null, 2))
// 2) 探测登录态
const loginState = await js(String.raw`(() => ({
hasDbcl2: /dbcl2=/.test(document.cookie),
hasCk: /(?:^|;\s*)ck=/.test(document.cookie)
}))()`)
cliLog('loginState: ' + JSON.stringify(loginState))
EOF
hasCk为false→ 未登录,handOffTaskSpace让用户扫码,扫完takeOverTaskSpace接回。- 拿到的 subject id(如
36593622)后续全程复用。
顺带打开图书主页抓实时评分/评价数:
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('douban book comments')
await openOrReuseTab('https://book.douban.com/subject/{ID}/', { wait: true, timeout: 20 })
const info = await js(String.raw`(() => ({
title: (document.querySelector('h1 span') || document.querySelector('#wrapper h1') || {}).innerText || '',
rating: (document.querySelector('.rating_num') || {}).innerText || '',
ratingCount: (document.querySelector('.rating_people span') || document.querySelector('a.rating_people') || {}).innerText || ''
}))()`)
cliLog('bookInfo: ' + JSON.stringify(info, null, 2))
EOF
Step 2 — 采集短评(热度 + 最新)
接口:book.douban.com/subject/{ID}/comments/?limit=20&status=P&sort={sort}&start={n}
sort=score(热度)+sort=time(最新)双通道。- 必须 Node 层循环,每次
js()只抓一页(单次长循环会触发 Runtime.evaluate 超时)。 - 每页间隔
wait(2)秒。 - 热度通道取 250,最新通道取 250(实际有折叠上限,见「数据边界」)。
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('douban book comments')
const fs = await import('node:fs')
const outDir = '/Users/xuchenyoung/WorkBuddy/2026-08-31-11-03-15/.workbuddy' // 改成本项目目录
const grabOne = (sort, start) => js(String.raw`(async () => {
const url = 'https://book.douban.com/subject/{ID}/comments/?limit=20&status=P&sort=${sort}&start=${start}'
const resp = await fetch(url, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
return [...doc.querySelectorAll('.comment-item')].map(it => {
const user = it.querySelector('.comment-info a')
const ratingEl = it.querySelector('.comment-info .rating')
const timeEl = it.querySelector('.comment-time')
const contentEl = it.querySelector('.comment-content .short') || it.querySelector('.comment-content')
const voteEl = it.querySelector('.vote-count')
let stars = 0
if (ratingEl) { const m = ratingEl.className.match(/allstar(\d+)/); stars = m ? parseInt(m[1]) / 10 : 0 }
return {
user: user ? user.innerText.trim() : '',
userHref: user ? user.href : '',
stars,
time: timeEl ? (timeEl.getAttribute('title') || timeEl.innerText.trim()) : '',
content: contentEl ? contentEl.innerText.trim() : '',
votes: voteEl ? voteEl.innerText.trim() : ''
}
})
})()`)
async function collect(sort, max) {
const all = []
for (let start = 0; start < max; start += 20) {
const items = await grabOne(sort, start)
if (!items.length) break
all.push(...items)
if (all.length >= max) break
await wait(2)
}
return all.slice(0, max)
}
const score = await collect('score', 250)
fs.writeFileSync(outDir + '/tmp_short_score.json', JSON.stringify(score))
cliLog('score saved: ' + score.length)
const timeItems = await collect('time', 250)
fs.writeFileSync(outDir + '/tmp_short_time.json', JSON.stringify(timeItems))
cliLog('time saved: ' + timeItems.length)
EOF
Step 3 — 采集长评 top50 全文
关键坑:长评列表页 reviews 不支持 fetch(返回 0 条),必须用导航方式(openOrReuseTab / gotoAndWait)。
- 列表页默认即按热度排序,前 50 篇就是 top50。
- 逐篇进详情页抓
.review-content全文。 - 列表 3 页(start=0/20/40)+ 50 个详情页,每步
wait(2)秒。
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('douban book comments')
const fs = await import('node:fs')
const outDir = '/Users/xuchenyoung/WorkBuddy/2026-08-31-11-03-15/.workbuddy' // 改成本项目目录
const parseList = () => js(String.raw`(() => {
return [...document.querySelectorAll('.review-item')].map(it => {
const titleEl = it.querySelector('h2 a')
const name = it.querySelector('.name')
const ratingEl = it.querySelector('.main-title-rating')
const timeEl = it.querySelector('.main-meta')
const upEl = it.querySelector('.action-btn.up')
let stars = 0
if (ratingEl) { const m = ratingEl.className.match(/allstar(\d+)/); stars = m ? parseInt(m[1]) / 10 : 0 }
return {
title: titleEl ? titleEl.innerText.trim() : '',
href: titleEl ? titleEl.href : '',
author: name ? name.innerText.trim() : '',
stars,
time: timeEl ? timeEl.innerText.trim() : '',
useful: upEl ? upEl.innerText.trim() : ''
}
})
})()`)
const parseDetail = () => js(String.raw`(() => {
const contentEl = document.querySelector('.review-content')
const usefulEl = document.querySelector('.useful, [class*="useful"]')
return {
content: contentEl ? contentEl.innerText.trim() : '',
usefulText: usefulEl ? usefulEl.innerText.trim() : ''
}
})()`)
const list = []
for (let start = 0; start < 60; start += 20) {
const url = 'https://book.douban.com/subject/{ID}/reviews?start=' + start
if (start === 0) await openOrReuseTab(url, { wait: true, timeout: 20 })
else await gotoAndWait(url, { timeout: 20 })
const items = await parseList()
if (!items.length) break
list.push(...items)
await wait(2)
}
cliLog('list total: ' + list.length)
const top50 = list.slice(0, 50)
for (let i = 0; i < top50.length; i++) {
await gotoAndWait(top50[i].href, { timeout: 20 })
const d = await parseDetail()
top50[i].content = d.content
top50[i].usefulText = d.usefulText
await wait(2)
}
fs.writeFileSync(outDir + '/tmp_reviews.json', JSON.stringify(top50))
cliLog('reviews saved: ' + top50.length)
EOF
完成后收尾(关闭任务空间):
ego-browser nodejs <<'EOF'
const task = await useOrCreateTaskSpace('douban book comments')
const r = await completeTaskSpace('douban book comments', { keep: false })
cliLog(JSON.stringify(r))
EOF
Step 4 — 合并去重 + 分析
用 venv python 读三个 json,做:合并去重(user+content 作 key)、评分分布、词频(jieba)、高赞好评/差评摘录。口碑结论由 AI 基于这些数据撰写(重点挖「好评为何引发共鸣」,营销场景下放大优点而非营销差评)。
Step 5 — 生成 docx
运行 scripts/build_docx.py:
/Users/xuchenyoung/.workbuddy/binaries/python/envs/default/bin/python3 \
/Users/xuchenyoung/.workbuddy/skills/douban-comments-collect/scripts/build_docx.py \
--book "书名" \
--out "/abs/path/书名_豆瓣口碑与评论全集.docx" \
--analysis "/abs/path/口碑分析.md" \
--data "/abs/path/.workbuddy"
--analysis可选;不传则 docx 只含长评 + 短评。--data指向存放tmp_short_score.json/tmp_short_time.json/tmp_reviews.json的目录。
关键坑(务必遵守)
- 短评
js()超时:不要在单个js()里写翻页循环,会触发 Runtime.evaluate 超时。改为 Node 层for循环,每次js()只抓一页。 - 长评页不能 fetch:
reviews列表 fetch 返回 0 条,必须gotoAndWait导航逐页抓。 - 翻页折叠上限:短评热度通道约 260 条、最新约 108 条封顶(豆瓣折叠机制,非 bug)。别硬凑目标条数,到顶即停。
推荐条数(最佳值)
- 短评:热度通道取 250 + 最新通道取 250(受豆瓣折叠限制,热度约 260、最新约 108 封顶,去重后约 350+ 条),评分全谱覆盖。
- 长评:top50 全文(按热度=有用数排序)。
依据:豆瓣折叠机制把超过 ~260/108 的评论折叠,未登录用户更是只看得到前 220 条;硬凑"全量"会触发验证码 / 封号 / 封 IP。这两个通道 + top50 长评既能覆盖口碑全谱,又把反爬风险压在低水平,是性价比最高的样本。
短评「热门」按有用数排、不按星级 → 差评犀利反而易被顶到前排,样本均分会低于豆瓣综合分换算值。这不是 bug,说明真实口碑更偏好评。
字段结构
| 短评字段 | 长评字段 | |---|---| | user / userHref / stars / time / votes / content | title / author / stars / time / useful / usefulText / href / content |
stars:1–5(整数),0 = 未评分。长评 useful 为「有用」数,usefulText 为详情页补充文本。
docx 排版规则(用户已确认)
- 星级写分数(如「4分」),未评分写「未评分」。
- 时间只留日期(
2024-01-25),去时分秒。 - 短评按点赞数降序;长评按「有用」数降序。
- 每条:一行头部(
序号. 作者|分数|日期|点赞/有用数)+ 正文段(首行缩进)。 - 长评额外加标题行(加粗)+ 原文链接行。
- 0 赞写「0 人点赞」,不省略。
- 条目间空一行,不画分隔线。
微信扫一扫