=========================================================================
重要提示:本爬虫工具仅限用于合法、合规的数据采集用途。
法律合规性
使用本爬虫工具前,您必须确保您的数据采集行为符合《中华人民共和国网络安全法》、《数据安全法》、《个人信息保护法》等法律法规的要求。
您有责任了解并遵守目标网站的服务条款、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
-
打开官方商店:
- Chrome:访问 Chrome Web Store - Tampermonkey
- Edge:访问 Microsoft Edge Addons - Tampermonkey
-
安装扩展:
- 点击"添加至Chrome"/"获取"按钮
- 在弹出的确认窗口中点击"添加扩展程序"
- 等待安装完成(通常几秒钟)
-
验证安装:
- 浏览器右上角应出现Tampermonkey图标(灰色猴子)
- 点击图标,应显示"没有用户脚本"或类似的欢迎消息

(示意图:安装完成后浏览器右上角显示Tampermonkey图标)
3.2 Firefox 安装 Violentmonkey(推荐)或 Greasemonkey
Violentmonkey 安装步骤:
- 访问 Firefox Add-ons - Violentmonkey
- 点击"添加到 Firefox"按钮
- 在确认对话框中点击"添加"
- 等待安装完成,Firefox右上角会出现Violentmonkey图标
Greasemonkey 旧版安装(仅Firefox旧版本):
- 访问 Firefox Add-ons - Greasemonkey
- 按照上述类似步骤安装
推荐使用Violentmonkey:它对现代用户脚本支持更好,更新更活跃。
3.3 其他浏览器支持情况
| 浏览器 | 推荐管理器 | 兼容性 | 备注 |
|---|---|---|---|
| Safari | Tampermonkey | ⭐⭐⭐ | 需从Mac App Store安装 |
| Brave | Tampermonkey | ⭐⭐⭐⭐ | 与Chrome扩展兼容 |
| 手机Chrome | Tampermonkey | ⭐⭐ | 需启用"桌面版网站" |
| Firefox安卓 | Tampermonkey | ⭐⭐⭐ | 需通过addons.mozilla.org安装 |
四、脚本安装详细步骤
4.1 通过脚本库一键安装(推荐)
-
访问脚本库页面:
- 打开 GreasyFork - 豆瓣自动评论抓取(假设已发布)
- 或访问 OpenUserJS 相关页面
-
点击安装按钮:
- 页面会显示绿色的"安装此脚本"按钮
- 点击后,Tampermonkey/Violentmonkey会弹出安装确认窗口
-
确认安装:
- 检查脚本来源是否可信(应显示来自greasyfork.org等可信域名)
- 点击"安装"按钮
- 安装成功后,管理器图标会显示已安装脚本数量

(示意图:Tampermonkey安装确认窗口)
4.2 手动安装方法(无网络环境)
-
获取脚本源码: 在本博客下方"附录:完整脚本代码"部分复制全部代码
-
创建新脚本:
- 点击浏览器工具栏中的Tampermonkey/Violentmonkey图标
- 选择"创建新脚本"或"新建用户脚本"
- 重要:删除编辑器中所有默认内容
-
粘贴脚本:
- 将复制的完整脚本代码粘贴到编辑器中
- 仔细检查确保没有缺失任何部分(特别是头部元数据)
- 按
Ctrl+S(Windows) 或Cmd+S(Mac) 保存
-
验证安装:
- 关闭脚本编辑窗口
- 点击Tampermonkey图标,应能在列表中看到"豆瓣自动评论抓取"
- 脚本状态应为"已启用"
4.3 安装后验证
-
访问豆瓣测试页面:
- 打开 豆瓣电影评论页面(以《肖申克的救赎》为例)
- 或打开任意书籍/音乐的评论页面
-
检查脚本激活:
- 页面加载完成后,脚本会自动运行(约1-2秒延迟)
- 您应看到页面自动展开所有"展开"按钮
- 然后页面会平滑滚动到底部
- 最后浏览器会开始下载TXT文件
-
开发者工具验证(高级):
- 按
F12打开开发者工具 - 切换到"控制台(Console)"标签
- 您应看到类似以下的日志: Text
编辑
1已初始化评论存储。如果需要重置,运行: localStorage.removeItem("allComments") 2=== 处理第 1 页(URL: https://movie.douban.com/subject/1292052/reviews)=== 3开始展开页面中所有可展开内容... 4...
- 按
五、脚本使用指南
5.1 基本工作流程
脚本执行四步自动化操作:
- 展开所有:自动点击所有"展开全文"、"显示全部"按钮
- 滚动加载:平滑滚动到页面底部,确保加载所有评论
- 保存当前页:将当前页面评论保存为单独TXT文件(格式:
douban_reviews_page1.txt) - 自动翻页:找到并点击"下一页",重复上述过程
默认设置:最多抓取50页,每页之间随机延迟800-2200毫秒
5.2 参数自定义
如需修改默认参数,可通过浏览器控制台调用:
Javascript
编辑
1// 仅抓取10页,延迟1500-3000毫秒
2runDoubanScraper({
3 maxPages: 10,
4 minDelay: 1500,
5 maxDelay: 3000
6});
参数说明:
| 参数 | 默认值 | 说明 | 合理范围 |
|---|---|---|---|
| maxPages | 50 | 最大抓取页数 | 1-100(建议) |
| minDelay | 800 | 页间最小延迟(毫秒) | 500-5000 |
| maxDelay | 2200 | 页间最大延迟(毫秒) | 1000-10000 |
法律建议:为降低法律风险,建议将
maxPages设为10以内,minDelay设为1500以上
5.3 数据导出与管理
脚本生成两类文件:
-
单页文件:
douban_reviews_page1.txt,douban_reviews_page2.txt...- 仅包含当页抓取的评论
- 适合分批次分析
-
汇总文件:
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保存已抓取数据,即使关闭页面也能恢复:
-
继续上次抓取:
- 重新打开豆瓣评论页面
- 脚本会自动检测已保存数据
- 继续从上次中断的页码开始
-
强制重新开始: 在浏览器控制台执行:
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 伦理准则
-
最小必要原则:
- 仅抓取分析所需的最少数据量
- 例如研究电影评分趋势,只需100-200条评论样本
-
匿名化处理:
- 导出后移除用户名等个人标识
- 聚合分析代替个体分析
-
贡献回馈:
- 将研究成果以适当方式回馈豆瓣社区
- 避免单向数据提取
8.2 技术最佳实践
-
遵守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/ -
请求频率控制:
- 个人研究:每分钟不超过3页
- 学术项目:联系豆瓣获取官方API
-
数据留存期限:
- 研究完成后及时删除原始数据
- 仅保留匿名化后的统计结果
九、附录:完整脚本代码
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})();
十、结语与社区贡献
技术的力量在于为善。这个脚本的开发初衷是帮助研究者合法获取公开数据,促进文化分析与学术研究。在使用过程中,我们应当:
- 保持克制:仅获取必要的数据量
- 尊重规则:遵守网站的robots.txt和服务条款
- 贡献价值:将研究成果以适当方式回馈社区
如果您发现脚本存在问题,或有改进建议,欢迎在GitHub Issues提交(假设已创建仓库)。对于法律合规性问题,建议咨询专业法律人士。
最后强调:本文所述工具必须在合法、合规的前提下使用。技术没有善恶,使用方式决定其价值。

822

被折叠的 条评论
为什么被折叠?



