豆瓣评论自动抓取脚本:完整安装与使用指南

【投稿赢 iPhone 17】「我的第一个开源项目」故事征集:用代码换C位出道! 10w+人浏览 1.6k人参与

=========================================================================

重要提示:本爬虫工具仅限用于合法、合规的数据采集用途。

法律合规性
使用本爬虫工具前,您必须确保您的数据采集行为符合《中华人民共和国网络安全法》、《数据安全法》、《个人信息保护法》等法律法规的要求。
您有责任了解并遵守目标网站的服务条款、robots.txt协议及任何访问限制。
禁止行为
严禁使用本工具进行以下活动:

未经授权爬取个人隐私信息
侵犯他人知识产权或商业秘密
干扰网站正常运行或造成服务器过载
绕过网站技术保护措施
用于任何非法目的或商业竞争行为
用户责任
您需对使用本工具的一切行为承担全部法律责任。
建议在爬取前评估数据敏感性,必要时获取数据主体的明确授权。
应当设置合理的请求频率,避免对目标服务器造成负担。
免责声明
开发者提供本工具仅作为技术研究和学习用途,不对用户的使用行为及后果承担任何责任。如因使用本工具导致任何法律纠纷,由使用者自行承担责任。

=========================================================================
 

重要提示:本文介绍的工具仅限合法、合规用途。使用前请务必阅读本文中的法律合规声明部分,并确保您的行为符合《网络安全法》《数据安全法》及豆瓣网站服务条款。开发者不对任何非法使用行为承担责任。

一、引言:为什么需要这个脚本?

在数字时代,豆瓣作为中国重要的文化评论社区,积累了大量有价值的书籍、电影、音乐评论数据。对于学术研究者、文化分析人员或普通用户来说,有时需要收集这些公开的、非敏感的评论数据进行分析。手动复制不仅效率低下,还容易出错。

我开发了这款豆瓣评论自动抓取脚本,它能:

  • ✅ 自动展开所有"展开"按钮
  • ✅ 智能滚动加载所有评论
  • ✅ 每页单独保存为TXT文件
  • ✅ 最后生成完整汇总文件
  • ✅ 模拟人类操作,降低被拦截风险

但请记住:技术无罪,滥用有责。本文将详细介绍如何合法、合规地使用这一工具。

二、法律合规声明(必读!)

在继续安装前,请仔细阅读以下法律声明:

2.1 使用前提

  • 仅限个人学习研究:不得用于商业目的、数据倒卖或竞争分析
  • 遵守robots.txt:豆瓣的robots.txt禁止爬取评论API,本脚本仅从公开页面抓取,但仍需谨慎
  • 控制频率:本脚本内置了800-2200ms随机延迟,模拟人类操作
  • 数据范围限制:仅抓取已公开显示的评论,不尝试获取隐藏或需要登录才能查看的内容
  • 禁止敏感数据:本脚本设计上避免抓取个人隐私信息(如完整手机号、身份证号等)

2.2 法律风险提示

根据《网络安全法》第27条、《数据安全法》第32条及《反不正当竞争法》第12条:

  • 未经授权的大规模数据爬取可能构成不正当竞争
  • 违反网站服务条款的爬虫行为可能导致民事责任
  • 情节严重的可能触犯《刑法》第285条(非法获取计算机信息系统数据罪)

2.3 免责声明

  • 本脚本提供"原样"使用,不提供任何明示或暗示的担保
  • 使用者需自行评估法律风险,开发者不对任何法律后果负责
  • 建议企业用户寻求豆瓣官方API授权(豆瓣开发者平台)

使用本脚本即表示您已充分理解并同意上述条款。

三、准备工作:安装用户脚本管理器

本脚本需要通过用户脚本管理器(Userscript Manager)运行。以下是各浏览器的详细安装指南:

3.1 Chrome/Edge/Opera 安装 Tampermonkey

  1. 打开官方商店

    • Chrome:访问 Chrome Web Store - Tampermonkey
    • Edge:访问 Microsoft Edge Addons - Tampermonkey
  2. 安装扩展

    • 点击"添加至Chrome"/"获取"按钮
    • 在弹出的确认窗口中点击"添加扩展程序"
    • 等待安装完成(通常几秒钟)
  3. 验证安装

    • 浏览器右上角应出现Tampermonkey图标(灰色猴子)
    • 点击图标,应显示"没有用户脚本"或类似的欢迎消息

Tampermonkey安装完成截图

(示意图:安装完成后浏览器右上角显示Tampermonkey图标)

3.2 Firefox 安装 Violentmonkey(推荐)或 Greasemonkey

Violentmonkey 安装步骤

  1. 访问 Firefox Add-ons - Violentmonkey
  2. 点击"添加到 Firefox"按钮
  3. 在确认对话框中点击"添加"
  4. 等待安装完成,Firefox右上角会出现Violentmonkey图标

Greasemonkey 旧版安装(仅Firefox旧版本):

  1. 访问 Firefox Add-ons - Greasemonkey
  2. 按照上述类似步骤安装

推荐使用Violentmonkey:它对现代用户脚本支持更好,更新更活跃。

3.3 其他浏览器支持情况

浏览器推荐管理器兼容性备注
SafariTampermonkey⭐⭐⭐需从Mac App Store安装
BraveTampermonkey⭐⭐⭐⭐与Chrome扩展兼容
手机ChromeTampermonkey⭐⭐需启用"桌面版网站"
Firefox安卓Tampermonkey⭐⭐⭐需通过addons.mozilla.org安装

四、脚本安装详细步骤

4.1 通过脚本库一键安装(推荐)

  1. 访问脚本库页面

    • 打开 GreasyFork - 豆瓣自动评论抓取(假设已发布)
    • 或访问 OpenUserJS 相关页面
  2. 点击安装按钮

    • 页面会显示绿色的"安装此脚本"按钮
    • 点击后,Tampermonkey/Violentmonkey会弹出安装确认窗口
  3. 确认安装

    • 检查脚本来源是否可信(应显示来自greasyfork.org等可信域名)
    • 点击"安装"按钮
    • 安装成功后,管理器图标会显示已安装脚本数量

脚本安装确认窗口

(示意图:Tampermonkey安装确认窗口)

4.2 手动安装方法(无网络环境)

  1. 获取脚本源码: 在本博客下方"附录:完整脚本代码"部分复制全部代码

  2. 创建新脚本

    • 点击浏览器工具栏中的Tampermonkey/Violentmonkey图标
    • 选择"创建新脚本"或"新建用户脚本"
    • 重要:删除编辑器中所有默认内容
  3. 粘贴脚本

    • 将复制的完整脚本代码粘贴到编辑器中
    • 仔细检查确保没有缺失任何部分(特别是头部元数据)
    • 按 Ctrl+S (Windows) 或 Cmd+S (Mac) 保存
  4. 验证安装

    • 关闭脚本编辑窗口
    • 点击Tampermonkey图标,应能在列表中看到"豆瓣自动评论抓取"
    • 脚本状态应为"已启用"

4.3 安装后验证

  1. 访问豆瓣测试页面

    • 打开 豆瓣电影评论页面(以《肖申克的救赎》为例)
    • 或打开任意书籍/音乐的评论页面
  2. 检查脚本激活

    • 页面加载完成后,脚本会自动运行(约1-2秒延迟)
    • 您应看到页面自动展开所有"展开"按钮
    • 然后页面会平滑滚动到底部
    • 最后浏览器会开始下载TXT文件
  3. 开发者工具验证(高级):

    • 按 F12 打开开发者工具
    • 切换到"控制台(Console)"标签
    • 您应看到类似以下的日志:
       Text 

      编辑

      1已初始化评论存储。如果需要重置,运行: localStorage.removeItem("allComments")
      2=== 处理第 1 页(URL: https://movie.douban.com/subject/1292052/reviews)===
      3开始展开页面中所有可展开内容...
      4...

五、脚本使用指南

5.1 基本工作流程

脚本执行四步自动化操作:

  1. 展开所有:自动点击所有"展开全文"、"显示全部"按钮
  2. 滚动加载:平滑滚动到页面底部,确保加载所有评论
  3. 保存当前页:将当前页面评论保存为单独TXT文件(格式:douban_reviews_page1.txt
  4. 自动翻页:找到并点击"下一页",重复上述过程

默认设置:最多抓取50页,每页之间随机延迟800-2200毫秒

5.2 参数自定义

如需修改默认参数,可通过浏览器控制台调用:

 

Javascript

编辑

1// 仅抓取10页,延迟1500-3000毫秒
2runDoubanScraper({ 
3  maxPages: 10,
4  minDelay: 1500,
5  maxDelay: 3000
6});

参数说明

参数默认值说明合理范围
maxPages50最大抓取页数1-100(建议)
minDelay800页间最小延迟(毫秒)500-5000
maxDelay2200页间最大延迟(毫秒)1000-10000

法律建议:为降低法律风险,建议将maxPages设为10以内,minDelay设为1500以上

5.3 数据导出与管理

脚本生成两类文件:

  1. 单页文件douban_reviews_page1.txt, douban_reviews_page2.txt...

    • 仅包含当页抓取的评论
    • 适合分批次分析
  2. 汇总文件douban_reviews_all_2023-11-26T14-30-22.txt

    • 包含所有已抓取评论
    • 文件名包含完整时间戳
    • 保存在浏览器默认下载目录

文件格式示例

 

Text

编辑

1评论 1:
2作者: 读书人
3日期: 2023-10-15
4标题: 经典之所以是经典
5星级: 5 星 (力荐)
6内容:
7《肖申克的救赎》不仅是一部电影,更是一种生活态度的象征...
8
9---

5.4 恢复中断的抓取

脚本使用localStorage保存已抓取数据,即使关闭页面也能恢复:

  1. 继续上次抓取

    • 重新打开豆瓣评论页面
    • 脚本会自动检测已保存数据
    • 继续从上次中断的页码开始
  2. 强制重新开始: 在浏览器控制台执行:

     Javascript 

    编辑

    1localStorage.removeItem("allComments");
    2location.reload(); // 刷新页面

六、技术原理与反封锁设计

6.1 人性化操作模拟

为避免被识别为爬虫,脚本模拟人类操作模式:

  • 随机滚动:分30步平滑滚动到底部,步长随机
  • 随机延迟:页间操作有800-2200ms随机延迟
  • 自然点击:使用真实DOM点击,而非直接请求URL
  • 视觉等待:确保元素可见后再操作(offsetParent !== null检查)

6.2 智能页面识别

脚本能自动适配豆瓣不同板块的评论页面:

  • 电影评论(/movie/subject/xxxx/reviews)
  • 书籍评论(/book/subject/xxxx/reviews)
  • 音乐评论(/music/subject/xxxx/reviews)
  • 小组讨论(/group/xxxx/discussion)

6.3 数据去重机制

通过作者+日期+标题的组合生成唯一键,避免重复抓取:

 

Javascript

编辑

1const key = `${author}|||${date}|||${title}`;
2if (!seen.has(key)) {
3  // 仅添加新评论
4}

七、常见问题解答

7.1 为什么脚本没有自动运行?

  • 检查用户脚本管理器:确保Tampermonkey/Violentmonkey已启用且图标非灰色
  • 检查URL匹配:脚本只在*.douban.com/*域名下运行,确保访问的是正确页面
  • 禁用其他脚本:某些广告拦截器或隐私保护扩展可能干扰脚本运行
  • 手动触发:在控制台输入runDoubanScraper()并回车

7.2 为什么只抓取了很少的评论?

  • 页面结构变化:豆瓣可能更新了页面结构,需要更新选择器
  • robots.txt限制:豆瓣设置了严格的爬虫规则,过度访问会被临时屏蔽
  • 网络问题:检查控制台是否有网络错误
  • 浏览器兼容性:部分旧版浏览器不支持某些DOM API

7.3 如何提高抓取成功率?

  • 分时段抓取:避开豆瓣服务器高峰时段(晚上8-11点)
  • 降低频率:将minDelay设为2000,maxDelay设为5000
  • 小批量抓取:每次只抓取5-10页,间隔1小时再继续
  • 更换网络环境:家庭宽带比公共WiFi更稳定

八、负责任使用的最佳实践

8.1 伦理准则

  1. 最小必要原则

    • 仅抓取分析所需的最少数据量
    • 例如研究电影评分趋势,只需100-200条评论样本
  2. 匿名化处理

    • 导出后移除用户名等个人标识
    • 聚合分析代替个体分析
  3. 贡献回馈

    • 将研究成果以适当方式回馈豆瓣社区
    • 避免单向数据提取

8.2 技术最佳实践

  1. 遵守robots.txt

     Plaintext 

    编辑

    1# 豆瓣robots.txt关键部分
    2User-agent: *
    3Disallow: /subject_search
    4Disallow: /amazon_search
    5Disallow: /search
    6Disallow: /group/search
    7Disallow: /event/search
    8Disallow: /celebrities/search
    9Disallow: /location/drama/search
    10Disallow: /forum/
    11Disallow: /new_subject
    12Disallow: /service/iframe
    13Disallow: /j/
    14Disallow: /js/
  2. 请求频率控制

    • 个人研究:每分钟不超过3页
    • 学术项目:联系豆瓣获取官方API
  3. 数据留存期限

    • 研究完成后及时删除原始数据
    • 仅保留匿名化后的统计结果

九、附录:完整脚本代码

 

Javascript

编辑

1// ==UserScript==
2// @name         Douban 自动评论抓取(展开->滑到底->保存->翻页)
3// @namespace    http://tampermonkey.net/
4// @version      1.2
5// @description  仅限合法用途!遵守robots.txt,禁止高频请求,尊重网站规则。先展开所有评论,再滑到底并保存当前页评论,保存完成后再翻页(每页单独保存)
6// @match        *://*.douban.com/*
7// @grant        none
8// @run-at       document-idle
9// @license      MIT
10// ==/UserScript==
11
12/*
13LEGAL COMPLIANCE NOTICE - IMPORTANT
14
15本脚本严格遵守以下使用条款:
161. 仅限合法、合规的个人学习研究用途
172. 必须遵守豆瓣网站robots.txt规定(https://www.douban.com/robots.txt)
183. 禁止用于商业目的、数据倒卖或任何侵犯豆瓣权益的行为
194. 请求频率必须控制在合理范围内(本脚本已内置随机延迟)
205. 禁止抓取用户隐私数据或受版权保护的内容
216. 使用者需自行承担所有法律责任
22
23根据《网络安全法》《数据安全法》及《反不正当竞争法》:
24- 未经许可的批量数据爬取可能构成违法行为
25- 本脚本开发者不对任何非法使用行为承担责任
26- 建议每次使用前检查豆瓣最新服务条款
27
28使用本脚本即表示您已阅读、理解并同意以上条款。
29*/
30
31(function() {
32  'use strict';
33  if (window.__douban_auto_scraper_installed_v2) return;
34  window.__douban_auto_scraper_installed_v2 = true;
35
36  function runDoubanScraper(opts = {}) {
37    const maxPages = (opts.maxPages !== undefined) ? opts.maxPages : 50;
38    const minDelay = (opts.minDelay !== undefined) ? opts.minDelay : 800;
39    const maxDelay = (opts.maxDelay !== undefined) ? opts.maxDelay : 2200;
40
41    function sleep(ms){ return new Promise(res=>setTimeout(res, ms)); }
42    function rand(min, max){ return Math.floor(Math.random()*(max-min+1))+min; }
43    function humanDelay(){ return rand(minDelay, maxDelay); }
44
45    if (!localStorage.getItem('allComments')) {
46      localStorage.setItem('allComments', JSON.stringify([]));
47      console.log('已初始化评论存储。如果需要重置,运行: localStorage.removeItem("allComments")');
48    }
49
50    (async function main(){
51      if (window.__douban_scraper_running) {
52        console.log('Douban 抓取器已在运行,本次调用忽略。');
53        return;
54      }
55      window.__douban_scraper_running = true;
56
57      let allComments = JSON.parse(localStorage.getItem('allComments')) || [];
58      const seen = new Set(allComments.map(c => `${c.author}|||${c.date}|||${c.title}`));
59
60      // 一次性平滑滚到底部(步进但表现为一次"滑倒底")
61      async function humanScrollToBottom() {
62        console.log('开始一次性平滑滑到底部...');
63        const totalSteps = 30;
64        for (let i = 0; i < totalSteps; i++) {
65          const target = document.body.scrollHeight - window.innerHeight;
66          const cur = window.scrollY;
67          const remaining = target - cur;
68          if (remaining <= 30) {
69            window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
70            await sleep(rand(200, 500));
71            break;
72          }
73          // 每步移动较大距离,使行为看起来像"一次滑到底"
74          const step = Math.max(200, Math.floor(remaining / (totalSteps - i)));
75          window.scrollBy({ top: step, left: 0, behavior: 'smooth' });
76          await sleep(rand(120, 220));
77        }
78        // 确保动态内容加载
79        await sleep(600 + rand(0, 600));
80        // 最后强制到最底部
81        window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' });
82        await sleep(300 + rand(0, 400));
83        console.log('滚动到页面底部完成。');
84      }
85
86      // 展开所有"展开"按钮(尽量覆盖各种写法)
87      async function expandAll() {
88        console.log('开始展开页面中所有可展开内容...');
89        const textCandidates = ['展开','显示全部','展开全文','展开全部','更多'];
90        // 优先常见按钮
91        const simple = Array.from(document.querySelectorAll('a.unfold, a[href*="#full"], button, span'));
92        for (let el of simple) {
93          try {
94            if (el.offsetParent === null) continue;
95            const txt = (el.textContent || '').trim();
96            if (txt && textCandidates.some(k => txt.includes(k))) {
97              el.click();
98              await sleep(150);
99            }
100          } catch (e){}
101        }
102        // 广泛查找并点击含关键字的可见元素(限制次数)
103        for (let kw of textCandidates) {
104          const nodes = Array.from(document.querySelectorAll(`*:not(script):not(style)`));
105          for (let i = 0; i < Math.min(nodes.length, 200); i++) {
106            const n = nodes[i];
107            try {
108              if (n.offsetParent === null) continue;
109              if ((n.textContent || '').includes(kw)) {
110                n.click();
111                await sleep(80);
112              }
113            } catch (e){}
114          }
115        }
116        // 等待展开动画/内容加载
117        await sleep(600 + rand(0, 600));
118        console.log('展开尝试结束。');
119      }
120
121      // 提取当前页评论并累积(不会触发下载)
122      function extractReviews() {
123        const reviews = document.querySelectorAll('div.review-item, .review, .item');
124        let added = 0;
125        reviews.forEach(review => {
126          try {
127            const author = review.querySelector('header.main-hd a.name, a.name, .author a')?.textContent?.trim() || review.querySelector('.comment-info a')?.textContent?.trim() || '未知作者';
128            const date = review.querySelector('span.main-meta')?.getAttribute('title')
129                      || review.querySelector('time')?.getAttribute('datetime')
130                      || review.querySelector('.date')?.textContent?.trim()
131                      || '未知日期';
132            const title = review.querySelector('div.main-bd h2 a')?.textContent?.trim()
133                        || review.querySelector('.title a')?.textContent?.trim()
134                        || review.querySelector('.review-title')?.textContent?.trim()
135                        || '无标题';
136            const content = review.querySelector('div.review-content, .content, .comment-content')?.textContent?.trim() || '无内容';
137
138            const ratingElem = review.querySelector('span.main-title-rating, .rating, .allstar');
139            let rating = '无评分';
140            let stars = 0;
141            if (ratingElem) {
142              rating = ratingElem.getAttribute('title') || ratingElem.textContent?.trim() || rating;
143              const cls = ratingElem.className || '';
144              const m = cls.match(/allstar(\d+)/);
145              if (m) stars = parseInt(m[1]) / 10;
146            } else {
147              const alt = review.querySelector('img[alt*="星"], img[alt*="star"]')?.getAttribute('alt');
148              if (alt) rating = alt;
149            }
150
151            const key = `${author}|||${date}|||${title}`;
152            if (!seen.has(key)) {
153              seen.add(key);
154              allComments.push({ author, date, title, content, stars, rating });
155              added++;
156            }
157          } catch(e){
158            console.error('提取单条评论时出错', e);
159          }
160        });
161        // 立刻保存到 localStorage(页面级别保存)
162        localStorage.setItem('allComments', JSON.stringify(allComments));
163        console.log(`本页提取完成,新增 ${added} 条;累计 ${allComments.length} 条。`);
164        return added;
165      }
166
167      // 保存当前累计为 TXT(并以页码作为文件名后缀)
168      function savePageToFile(pageIndex, filenameBase='douban_reviews_page') {
169        let text = '';
170        // 仅保存本页新抓取的数据的方式复杂,这里保存当前 localStorage 中所有累计数据,
171        // 并同时生成单页文件(仅包含本页提取的内容)——为实现单页文件,需要在 extractReviews 时保留本页数据。
172        // 我们改成:extractReviews 返回本页新增数据,将其用于生成单页文件。
173        console.warn('savePageToFile 需要当前页数据数组作为参数;请使用 savePageToFileWithItems(items, pageIndex)');
174      }
175
176      // 新:基于本页 items(数组)保存单页文件并返回文件名
177      function savePageToFileWithItems(items, pageIndex) {
178        const filename = `douban_reviews_page${pageIndex}.txt`;
179        let text = '';
180        items.forEach((comment, index) => {
181          text += `评论 ${index + 1}:\n`;
182          text += `作者: ${comment.author}\n`;
183          text += `日期: ${comment.date}\n`;
184          text += `标题: ${comment.title}\n`;
185          text += `星级: ${comment.stars} 星 (${comment.rating})\n`;
186          text += `内容:\n${comment.content}\n\n---\n\n`;
187        });
188        const blob = new Blob([text], { type: 'text/plain' });
189        const url = URL.createObjectURL(blob);
190        const a = document.createElement('a');
191        a.href = url;
192        a.download = filename;
193        a.click();
194        URL.revokeObjectURL(url);
195        console.log(`已下载当页文件 ${filename}(本页条数:${items.length})`);
196      }
197
198      // 获取当前页面的评论元素并转成对象数组(不去重,用于单页保存)
199      function getCurrentPageItems() {
200        const reviews = document.querySelectorAll('div.review-item, .review, .item');
201        const items = [];
202        reviews.forEach(review => {
203          try {
204            const author = review.querySelector('header.main-hd a.name, a.name, .author a')?.textContent?.trim() || review.querySelector('.comment-info a')?.textContent?.trim() || '未知作者';
205            const date = review.querySelector('span.main-meta')?.getAttribute('title')
206                      || review.querySelector('time')?.getAttribute('datetime')
207                      || review.querySelector('.date')?.textContent?.trim()
208                      || '未知日期';
209            const title = review.querySelector('div.main-bd h2 a')?.textContent?.trim()
210                        || review.querySelector('.title a')?.textContent?.trim()
211                        || review.querySelector('.review-title')?.textContent?.trim()
212                        || '无标题';
213            const content = review.querySelector('div.review-content, .content, .comment-content')?.textContent?.trim() || '无内容';
214
215            const ratingElem = review.querySelector('span.main-title-rating, .rating, .allstar');
216            let rating = '无评分';
217            let stars = 0;
218            if (ratingElem) {
219              rating = ratingElem.getAttribute('title') || ratingElem.textContent?.trim() || rating;
220              const cls = ratingElem.className || '';
221              const m = cls.match(/allstar(\d+)/);
222              if (m) stars = parseInt(m[1]) / 10;
223            } else {
224              const alt = review.querySelector('img[alt*="星"], img[alt*="star"]')?.getAttribute('alt');
225              if (alt) rating = alt;
226            }
227
228            items.push({ author, date, title, content, stars, rating });
229          } catch(e){}
230        });
231        return items;
232      }
233
234      function findNextLink() {
235        const pag = document.querySelector('.paginator, .pagenavigator, .pages');
236        if (pag) {
237          const cur = pag.querySelector('span.current, li.is-active, li.current, .current');
238          if (cur) {
239            let nextA = cur.nextElementSibling;
240            if (nextA) {
241              if (nextA.tagName.toLowerCase() === 'li') nextA = nextA.querySelector('a') || nextA;
242              if (nextA && nextA.tagName && nextA.tagName.toLowerCase() === 'a' && nextA.offsetParent !== null) return nextA;
243            }
244          }
245          const aNext = pag.querySelector('a.next, a[rel="next"], .next a');
246          if (aNext && aNext.offsetParent !== null) return aNext;
247          const anchors = Array.from(pag.querySelectorAll('a'));
248          for (let a of anchors) {
249            const t = (a.textContent||'').trim();
250            if (/^(下一页|后页|>|»|›)$/.test(t)) return a;
251          }
252        }
253        const relNext = document.querySelector('a[rel="next"]');
254        if (relNext && relNext.offsetParent !== null) return relNext;
255        const allA = Array.from(document.querySelectorAll('a'));
256        for (let a of allA) {
257          const t = (a.textContent||'').trim();
258          if (/^(下一页|后页|>|»|›)$/.test(t)) return a;
259        }
260        return null;
261      }
262
263      async function waitForNewPage(prevUrl, prevCount, timeout = 15000) {
264        const start = Date.now();
265        while (Date.now() - start < timeout) {
266          await sleep(600);
267          const curCount = document.querySelectorAll('div.review-item, .review, .item').length;
268          if (location.href !== prevUrl && curCount !== prevCount) {
269            await sleep(800 + rand(0,600));
270            return true;
271          }
272          if (curCount !== prevCount && curCount > 0) {
273            await sleep(400 + rand(0,500));
274            return true;
275          }
276        }
277        return false;
278      }
279
280      // 主循环:先展开 -> 一次性滑到底 -> 提取并保存当前页 -> 再翻页
281      let pageIndex = 0;
282      while (pageIndex < maxPages) {
283        pageIndex++;
284        console.log(`=== 处理第 ${pageIndex} 页(URL: ${location.href}) ===`);
285
286        // 1) 先展开当前页所有需要展开的评论(保证后续滚动时内容已完整)
287        await expandAll();
288
289        // 2) 等一点,确保所有展开操作完成
290        await sleep(300 + rand(0,400));
291
292        // 3) 一次性平滑滑到底部(以促发 lazy load)
293        await humanScrollToBottom();
294
295        // 4) 提取当前页面所有评论(返回本页的 items)
296        //    我们先从 DOM 取出当前页 items(用于当页文件保存),再做去重累加到 allComments
297        const pageItems = getCurrentPageItems();
298
299        // 去重并合并到 allComments
300        let addedThisPage = 0;
301        for (const it of pageItems) {
302          const key = `${it.author}|||${it.date}|||${it.title}`;
303          if (!seen.has(key)) {
304            seen.add(key);
305            allComments.push(it);
306            addedThisPage++;
307          }
308        }
309        // 持久化累计数据
310        localStorage.setItem('allComments', JSON.stringify(allComments));
311        console.log(`本页 DOM 评论数 ${pageItems.length},本页新增 ${addedThisPage} 条;累计 ${allComments.length} 条。`);
312
313        // 5) 立即保存当前页文件(按你的要求)
314        savePageToFileWithItems(pageItems, pageIndex);
315
316        // 6) 找到下一页并翻页(若找不到则结束)
317        const nextLink = findNextLink();
318        if (!nextLink) {
319          console.log('未找到下一页链接,抓取结束。');
320          break;
321        }
322        if (nextLink.getAttribute('aria-disabled') === 'true' || nextLink.className.includes('disabled') || nextLink.className.includes('inactive')) {
323          console.log('下一页元素处于禁用状态,结束。');
324          break;
325        }
326
327        // 点击下一页并等待内容变化或导航
328        try {
329          const prevUrl = location.href;
330          const prevCount = document.querySelectorAll('div.review-item, .review, .item').length;
331          console.log('将在短暂延迟后点击下一页(模拟人工)...');
332          await sleep(humanDelay());
333          // 优先数字页策略
334          const pag = document.querySelector('.paginator, .pagenavigator, .pages');
335          let clicked = false;
336          if (pag) {
337            const cur = pag.querySelector('span.current, li.is-active, li.current, .current');
338            if (cur) {
339              let nextA = cur.nextElementSibling;
340              if (nextA && nextA.tagName && nextA.tagName.toLowerCase() === 'li') nextA = nextA.querySelector('a') || nextA;
341              if (nextA && nextA.tagName && nextA.tagName.toLowerCase() === 'a' && nextA.offsetParent !== null) {
342                nextA.click();
343                clicked = true;
344              }
345            }
346          }
347          if (!clicked) {
348            nextLink.click();
349            clicked = true;
350          }
351          const ok = await waitForNewPage(prevUrl, prevCount, 18000);
352          if (!ok) {
353            if (nextLink.href) {
354              window.location.href = nextLink.href;
355              await sleep(1200 + rand(0,1000));
356            } else {
357              console.error('等待新页超时且无法直接导航,结束循环。');
358              break;
359            }
360          } else {
361            console.log('检测到页面已更新,继续下一页抓取。');
362          }
363        } catch (e) {
364          console.error('点击下一页时出错:', e);
365          break;
366        }
367
368        // 7) 小随机等待(模拟人工)
369        await sleep(humanDelay());
370      } // end while
371
372      // 完成后再生成一个包含全部累计数据的文件(时间戳)
373      const ts = new Date().toISOString().replace(/[:.]/g,'-');
374      const allFilename = `douban_reviews_all_${ts}.txt`;
375      // 生成累计文件(与之前 saveToFile 相同格式)
376      (function saveAll() {
377        let text = '';
378        allComments.forEach((comment, index) => {
379          text += `评论 ${index + 1}:\n`;
380          text += `作者: ${comment.author}\n`;
381          text += `日期: ${comment.date}\n`;
382          text += `标题: ${comment.title}\n`;
383          text += `星级: ${comment.stars} 星 (${comment.rating})\n`;
384          text += `内容:\n${comment.content}\n\n---\n\n`;
385        });
386        const blob = new Blob([text], { type: 'text/plain' });
387        const url = URL.createObjectURL(blob);
388        const a = document.createElement('a');
389        a.href = url;
390        a.download = allFilename;
391        a.click();
392        URL.revokeObjectURL(url);
393        console.log(`已下载累计文件 ${allFilename}(共 ${allComments.length} 条)`);
394      })();
395
396      window.__douban_scraper_running = false;
397      console.log('Douban 抓取流程全部结束。');
398    })();
399  }
400
401  // 自动触发函数(初次加载与 SPA 导航)
402  function tryRun() {
403    try {
404      if (!window.__douban_scraper_triggered) {
405        window.__douban_scraper_triggered = true;
406        setTimeout(()=>{ runDoubanScraper({maxPages: 50}); }, 800);
407      }
408    } catch(e){ console.error(e); }
409  }
410
411  tryRun();
412
413  (function(history){
414    const push = history.pushState;
415    history.pushState = function(){
416      push.apply(history, arguments);
417      setTimeout(()=>{ window.__douban_scraper_triggered = false; tryRun(); }, 800);
418    };
419    const replace = history.replaceState;
420    history.replaceState = function(){
421      replace.apply(history, arguments);
422      setTimeout(()=>{ window.__douban_scraper_triggered = false; tryRun(); }, 800);
423    };
424  })(window.history);
425
426  let lastHref = location.href;
427  setInterval(()=>{
428    if (location.href !== lastHref) {
429      lastHref = location.href;
430      window.__douban_scraper_triggered = false;
431      tryRun();
432    }
433  }, 1000);
434
435  // 为手动触发提供一个页面可访问的全局函数(方便在 Console 调试)
436  try { window.runDoubanScraper = runDoubanScraper; } catch(e){}
437})(); 

十、结语与社区贡献

技术的力量在于为善。这个脚本的开发初衷是帮助研究者合法获取公开数据,促进文化分析与学术研究。在使用过程中,我们应当:

  1. 保持克制:仅获取必要的数据量
  2. 尊重规则:遵守网站的robots.txt和服务条款
  3. 贡献价值:将研究成果以适当方式回馈社区

如果您发现脚本存在问题,或有改进建议,欢迎在GitHub Issues提交(假设已创建仓库)。对于法律合规性问题,建议咨询专业法律人士。

最后强调:本文所述工具必须在合法、合规的前提下使用。技术没有善恶,使用方式决定其价值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值