← 返回 Skill 列表
extension
分类: 数据与分析无需 API Key

豆瓣图书长短评采集

采集豆瓣图书的短评和长评,做口碑分析,最终生成一个 docx 文档(口碑分析 + 长评全文 + 短评纯文本)。当用户要求采集豆瓣某本书的短评/长评/书评/评论、做口碑分析、舆情监测、读者反馈整理、营销素材提取时触发。触发词:豆瓣评论、豆瓣短评、豆瓣长评、豆瓣书评、采集评论、口碑分析、图书口碑、评论合集。

person作者: user_329961aahubcommunity

豆瓣短长评采集 → 口碑分析 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 安装)。

流程总览

  1. 定位 subject id
  2. 探测登录态
  3. 采集短评(热度 + 最新双通道)
  4. 采集长评 top50 全文
  5. 合并去重 + 分析(词频 / 高赞摘录 / 口碑结论)
  6. 生成 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 的目录。

关键坑(务必遵守)

  1. 短评 js() 超时:不要在单个 js() 里写翻页循环,会触发 Runtime.evaluate 超时。改为 Node 层 for 循环,每次 js() 只抓一页。
  2. 长评页不能 fetch:reviews 列表 fetch 返回 0 条,必须 gotoAndWait 导航逐页抓。
  3. 翻页折叠上限:短评热度通道约 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 人点赞」,不省略。
  • 条目间空一行,不画分隔线。