搜“Jsoup 爬虫 豆瓣 电影 Top250”这个关键词,网上能翻出几十篇相关博客,但多数只甩一段代码,不解释原理;还有一些因为页面改版早就跑不通了,贴出来的输出要么是空列表,要么直接一串异常。今天这篇我尽量写得完整且能直接复现:从为什么选 Jsoup、怎么分析页面结构、完整 Java 代码,到实测中遇到的坑和解决思路,一次讲清楚。
这个项目适合三类人:刚学完 Java 基础想找练手项目的学生;工作中需要做数据采集但不想碰 Python 的后端开发;以及单纯好奇“网页上的数据是怎么被程序读走的”的非专业读者。你不需要懂太深的前端知识,会看浏览器自带的开发者工具就够了。
1. 为什么选 Jsoup:先弄明白它解决了什么问题
很多新手第一反应是“直接发 HTTP 请求拿 HTML,再用正则表达式抠数据不就行了”。如果你只是抓一个字段,确实可以,但一旦要抓的字段多了,正则表达式会膨胀到你自己都看不懂。而且 HTML 本身不是严格的规范文本,网页里经常有不闭合的标签、大小写混用、属性值带引号不带引号之类的情况,正则处理这种脏文本非常脆弱,稍改版就崩。
1.1 这个项目到底在抓什么
豆瓣电影 Top250 是一个公开榜单,每页 25 部电影,一共 10 页。我们需要从每部电影条目里提取这些信息:
- 排名(第 1 名到第 250 名)
- 中文片名(有时候英文片名也会出现,只取第一个标题)
- 评分(如 9.7)
- 评价人数(如“约 288 万人评价”,抓取时通常是完整数字加“人评价”后缀)
- 一句话引言(电影条目下方那句评论,部分电影没有)
- 详情页链接(点击片名后跳转的 URL)
这些数据抓下来之后,可以存成 CSV,用 Excel 打开就是一张整洁的表格。后续想要做数据分析、评分排行对比、个人观影清单,全都有数据基础了。
1.2 方案对比:为什么不用 OkHttp、正则或 Selenium
先看几个常见替代方案,我直接说结论。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| HttpClient/OkHttp + 正则 | 依赖少、思路直白 | 选择器复杂时正则极难维护 | 抓单一字段、页面结构非常稳定的场景 |
| Jsoup | 自带 HTML 解析、CSS 选择器、自动容错 | 依赖第三方库 | 大多数静态页面采集 |
| Selenium/Playwright | 能跑 JS、能模拟点击、能绕过大量 JS 渲染 | 重、慢、内存占用大 | 动态渲染页面或需要强交互的采集 |
| Python + requests + BeautifulSoup | 生态好、代码短 | 不是一个技术栈,项目里要额外维护脚本 | 本来就是 Python 项目的人 |
这个项目选 Jsoup,核心原因有两个。第一,它本身就是 Java 生态里最成熟的 HTML 解析库,不需要启动浏览器,轻量且稳定。第二,它的 API 设计用起来像 jQuery,doc.select() 一行就能按 CSS 选择器找节点,代码写出来可读性非常强。
1.3 环境准备与依赖引入
你只需要 JDK 8 以上版本、一个 Maven 或 Gradle 项目。我用的是 Maven,在 pom.xml 里加这一段:
xml复制<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
</dependency>
1.15.x 是目前比较稳定的版本线,修复了老版本里大量对 HTML5 新标签的解析问题,还更新了清理器的安全策略。用 1.14 之前的版本在这个项目里也能跑,但没必要给代码埋雷。
依赖倒进去之后,写第一段验证代码,能连通页面就说明环境没问题。我习惯直接打印 doc.title() 看是否拿到了“豆瓣电影 Top 250”这个标题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分析 Top250 页面结构:写选择器之前必须做的事
这个环节最容易被人跳过,却恰恰是爬虫成败的分水岭。很多教程让你直接抄选择器,但你不知道选择器为什么长这样,页面一改版你就慌。掌握分析方法之后,面对任何网页都能靠着开发者工具一步步定位。
2.1 Jsoup 的解析模型:一棵树的三个核心角色
Jsoup 把 HTML 解析成一棵 DOM 树,类比成手办模型:
Document是整棵树,代表整个页面Element是树上的某个节点,比如一个div或一个liElements是多个 Element 的集合,相当于按条件筛出来的一把枝条
常用方法是 doc.select("CSS选择器"),返回 Elements,然后调用 .first() 取第一个,或者 .attr() 拿属性、.text() 拿纯文本。还有 getElementById()、getElementsByClass() 这些老式方法也能用,但只适合简单场景。选 select() 是因为 CSS 选择器表达力更强,一行能搞定多层定位。
Jsoup 对不规范的 HTML 会自动修正,比如自动补全缺失的闭合标签、统一小写标签名。这在真实抓取中非常重要,因为线上页面的 HTML 往往不像教科书那么干净。
2.2 用开发者工具定位关键字段
打开豆瓣电影 Top250 页面,按 F12,找到 <ol class="grid_view">。这个 ol 下面每一个 <li> 就是一部电影。所有字段都在这个 li 的子孙节点里。
我给一张字段映射表,这是整个项目最核心的部分:
| 抓取字段 | 选择器写法 | 说明 |
|---|---|---|
| 电影条目容器 | #content .grid_view > li |
每个 li 代表一部电影 |
| 片名 | .hd a span.title |
匹配第一个 span 即可,避免取到英文名 |
| 评分 | .star .rating_num |
该节点文本就是数字评分 |
| 评价人数 | .star span 最后一个 |
文本类似“2880215人评价” |
| 引言 | .bd .quote .inq |
部分电影为空,取时需要判空 |
| 详情链接 | .hd a 的 href 属性 |
跳转到电影详情页 |
以排行第一的电影为例,页面上大概是这样:
html复制<li>
<div class="item">
<div class="pic">...</div>
<div class="info">
<div class="hd">
<a href="https://movie.douban.com/subject/1292052/">
<span class="title">肖申克的救赎</span>
<span class="title"> / The Shawshank Redemption</span>
</a>
</div>
<div class="bd">
<p class="">导演: 弗兰克·德拉邦特 ...</p>
<p class="quote"><span class="inq">希望让人自由。</span></p>
<div class="star">
<span class="rating5-t"></span>
<span class="rating_num">9.7</span>
<span>2880215人评价</span>
</div>
</div>
</div>
</div>
</li>
用 .hd a span.title 会选中两个 .title,第一个是中文片名,第二个是英文片名。所以代码里不能直接 .first().text() 就完事?其实是可以的,但要理解 .first() 拿到的就是中文片名。我更喜欢稍严格一点的写法:span.title:nth-child(1),避免以后页面结构微调误伤。
2.3 写选择器最容易犯的三个错
第一个错是直接用绝对路径,比如 html body div div div ol li 这种。这种选择器脆得像玻璃,改动一个层级就失效,一定用 class 或 id 作为锚点。
第二个错是混淆空格和大于号。.grid_view > li 表示 li 必须是 grid_view 的直接子元素,而 .grid_view li 表示所有后代 li。这里用 > 更严谨,因为电影条目不可能嵌在更深层。
第三个错是看了某个节点的 class 是 rating_num,结果写 .rating_num 之后用 .text() 拿数字,拿到却带了很多空白字符。这不是选择器的问题,是 .text() 会返回节点内所有文本的合并结果。评分这种节点没有子标签,text() 没问题;但如果节点里还套着别的 span,就要用 .ownText() 只取自己的文本。
3. 完整抓取代码:从单页到十页循环
下面把完整代码拆开讲。我按照“数据模型 -> 单页抓取 -> 翻页循环 -> 数据落盘”的顺序来写,这样每一块都能独立理解和测试。
3.1 先定义一个 Movie 数据类
直接用 Map 装数据也能跑,但项目一旦复杂就会失控。定义一个简单的 POJO,后续封装、排序、输出都要方便得多:
java复制public class Movie {
private String rank;
private String title;
private double rating;
private String ratingCount;
private String quote;
private String detailUrl;
public Movie(String rank, String title, double rating,
String ratingCount, String quote, String detailUrl) {
this.rank = rank;
this.title = title;
this.rating = rating;
this.ratingCount = ratingCount;
this.quote = quote;
this.detailUrl = detailUrl;
}
// getter / setter 省略,实际使用请自行补全
}
字段类型我特意用了 String 和 double 混搭:rank 是字符串是因为它后续可能要格式化成“001”这种编号,存字符串更灵活;rating 用 double 是因为要参与排序比较。
3.2 单页抓取的完整逻辑
核心方法是 fetchPage(int start),start 是这个页面的第一条电影在整个列表中的偏移量。第一页 start=0,第二页 start=25,第三页 start=50,依此类推。
java复制public List<Movie> fetchPage(int start) throws IOException {
String url = "https://movie.douban.com/top250?start=" + start + "&filter=";
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
.timeout(5000)
.get();
List<Movie> movies = new ArrayList<>();
Elements items = doc.select("#content .grid_view > li");
for (int i = 0; i < items.size(); i++) {
Element item = items.get(i);
String rank = String.valueOf(start + i + 1);
String title = item.select(".hd a span.title").first().text();
double rating = Double.parseDouble(item.select(".star .rating_num").text());
String ratingCount = item.select(".star span").last().text();
String quote = "";
Elements quoteNode = item.select(".bd .quote .inq");
if (!quoteNode.isEmpty()) {
quote = quoteNode.first().text();
}
String detailUrl = item.select(".hd a").attr("href");
movies.add(new Movie(rank, title, rating, ratingCount, quote, detailUrl));
}
return movies;
}
这里有三处值得细说。
Jsoup.connect(url).userAgent(...) 链式调用里,userAgent() 是必须的。后面会专门讲为什么,这里先记住:不带 UA 容易被服务端拦截。
item.select(".hd a span.title").first() 取的是第一个 title span,也就是中文片名。注意 .first() 可能返回 null,不过在这个页面上每个条目至少有一个 .title,所以不会出问题。
item.select(".star span").last() 拿评价人数,这个 .last() 依赖页面结构里“评价人数”正好是 star 区块的最后一个 span。豆瓣这么多年都没改这个位置,相对可靠。
3.3 翻页循环与主流程
Top250 一共 10 页,每页 25 条,所以循环条件写死 10 就行。如果有人想扩展到别的榜单,只需要改循环次数和 URL 前缀。
java复制public static void main(String[] args) throws Exception {
DoubanTop250Crawler crawler = new DoubanTop250Crawler();
List<Movie> allMovies = new ArrayList<>();
for (int page = 0; page < 10; page++) {
int start = page * 25;
System.out.println("抓取第 " + (page + 1) + " 页,start=" + start);
allMovies.addAll(crawler.fetchPage(start));
Thread.sleep(1500);
}
System.out.println("共抓取 " + allMovies.size() + " 条");
}
Thread.sleep(1500) 是给目标服务器留喘息时间,也是爬虫的基本礼仪。我会在第 4 章专门展开这个细节,这里先埋个伏笔。
页面上那个 &filter= 参数是豆瓣自带的空参数,加上它和浏览器访问的 URL 完全一致,对结果没有影响,但保持一致性总没坏处。
3.4 把结果写入 CSV:一个小而全的落盘实现
抓到的数据不能只打在控制台里。我用 CSV 保存,这样直接用 Excel 或 WPS 打开就能看到表格。
java复制public static void writeToCsv(List<Movie> movies) throws IOException {
Path path = Paths.get("top250.csv");
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
// 写入 UTF-8 BOM,防止 Excel 打开后中文乱码
writer.write('\ufeff');
writer.write("rank,title,rating,ratingCount,quote,detailUrl\n");
for (Movie m : movies) {
writer.write(String.join(",",
m.getRank(),
m.getTitle(),
String.valueOf(m.getRating()),
'"' + m.getRatingCount() + '"',
'"' + m.getQuote() + '"',
m.getDetailUrl()) + "\n");
}
}
}
那个 \ufeff 是 UTF-8 文件带 BOM 的标记。不写的话,Excel 用系统默认编码打开中文会乱码,但在记事本或 VS Code 里看又是正常的。很多新手在这卡半天,其实加个 BOM 就行。
评价人数和引言里可能包含英文逗号,所以在 CSV 里给这两个字段加了英文双引号包裹。这是 CSV 格式的基本规则,不是可有可无的装饰。
4. 实测中的坑:请求头、编码、频率与被拒
代码写完不代表能跑通。我第一次跑这个程序的时候,第一页就返回了一堆乱码,后来又遇到 418 状态码,排查下来都是很典型的坑,逐个说。
4.1 不带 User-Agent 会得到什么结果
如果你把 .userAgent(...) 这行去掉,大概率会收到 HTTP 403 或 418。403 是拒绝访问,418 是“我是一个茶壶”的状态码,很多网站用这个幽默编码表示“识别你是爬虫,但我不想明着拒绝”。
原因是豆瓣的服务器会检查请求头里的 User-Agent,Java 默认的 UA 类似 Java/1.8.0_xxx,特征非常明显。服务器一看就知道不是普通浏览器,直接拒掉。
解决办法就是伪造一个浏览器 UA。上面代码里用的那串就是 Chrome 120 的完整 UA 字符串。你可以直接抄,也可以用自己浏览器的 UA。注意 UA 不能乱写,比如写成“iPhone”但实际不是移动端请求,就不一定有效。
4.2 字符编码:为什么你的标题变成了一堆“???”或乱码
我第一次写的时候走了弯路,用 HttpClient 先拿字节流,再手动按页面编码转字符串,结果顶部正常,中间混着乱码。后来换成 Jsoup 的 get(),乱码问题就消失了。
原因是 Jsoup 在解析时会自动读取响应头里的 Content-Type 和 HTML 里的 <meta charset>,自动选择合适的编码解码。豆瓣页面声明的是 UTF-8,Jsoup 会自动采用 UTF-8,不需要你手动干预。
如果你偏要手动处理,请记住这个原则:先拿原始字节,再按页面声明的字符集解码,不要直接用平台默认编码。平台默认编码在 Windows 上可能是 GBK,在 Linux 上是 UTF-8,同样代码两边跑结果完全不同。所以能交给 Jsoup 的,不要自己造轮子。
4.3 请求频率:快不一定是好事
我一开始做测试时,因为想快点看到结果,把循环里的休眠时间从 1500 毫秒改成了 300 毫秒,跑到第三页就开始偶发超时,第五页直接连不上。这是非常典型的“把自己 IP 送进风控名单”的操作。
爬虫不是盾构机,不需要全速前进。单线程、顺序抓取、每页之间休眠 1 到 2 秒,10 页数据大约 30 秒就抓完了。这点时间成本完全可以接受,没必要为了省十几秒去赌账号安全。
如果你要抓更多数据,建议用指数退避策略:第一次失败等 2 秒,第二次等 4 秒,第三次等 8 秒,最多等 60 秒后放弃。这比固定时间休眠要稳健得多。
4.4 页面结构变了怎么办:一个排查套路
爬虫最怕的不是写不出来,而是维护。豆瓣改版虽然不频繁,但万一哪天 .grid_view 或 rating_num 这个 class 不见了,你的程序就会静默返回空列表,或者抛出空指针异常。
这时候不要急着改代码,先按这个顺序排查:
- 用浏览器打开目标页面,按 F12 看真实 DOM,确认元素层级和 class 名是否变化
- 找一个条目,右键“检查”,对照你的选择器层层验证
- 在代码里先打印
.size(),确认选中的条目数量是不是 25 - 如果某一步拿不到节点,逐步简化选择器,比如从
.grid_view li开始,再逐步加条件
我见过很多人在排查时对着代码一行行看,却不去看页面本身。其实 90% 的问题都是页面结构变了,不是代码逻辑错了。先看页面,再看代码,这个顺序不能反。
5. 从“能跑”到“能交付”:合规意识与后续扩展
程序跑通的那一刻确实很有成就感,但这个项目真正有价值的部分,是你在跑通之后怎么对待数据、怎么扩展功能。
5.1 抓数据之前先想清楚边界
抓取公开榜单数据,在合理频率下用于学习、个人分析和研究,是完全没有问题的。但有三条红线我建议你主动遵守:
- 只抓公开页面数据,不碰需要登录才能看到的内容
- 控制请求频率,不给服务器制造压力
- 抓下来的数据不商用、不批量转售,也不在公开场合恶意传播
豆瓣的 robots.txt 里本来就允许搜索引擎抓取部分页面,我们做的只是模拟一次普通浏览行为。爬虫本身是技术工具,用得好不好完全看人。我在项目注释里会写一句“仅供学习使用”,这不仅是免责,更是提醒自己:手里的数据是借来的,不是抢来的。
5.2 两个值得继续做的扩展方向
第一个方向是存数据库。CSV 适合个人看,但如果你想做统计查询,比如算 Top250 里评分大于 9.5 的电影有多少部、评分最低的十部是哪些,换成 SQLite 或 MySQL 就灵活得多。把 writeToCsv 改成 insertIntoDb,难度很低,但工程感觉完全不同。
第二个方向是抓详情页。现在只抓到了 detailUrl,其实可以再开一个方法,对每部电影详情页做二次采集,把导演、编剧、主演、片长、类型这些字段补全。到这里,你的爬虫就从“榜单采集器”升级成“电影信息聚合器”了。
5.3 我的一点实际操作体会
这个项目我前前后后写过三个版本,第一次用正则,第二次用 Jsoup 但没控制频率,第三次才是我上面给的这版。每次重写都有新收获:正则版教会我什么叫“能跑不等于能维护”,没控制频率的版本教会我“爬虫先讲礼貌再讲效率”。
如果你也打算把它当作练手项目,我只有一个建议:跑通之后,故意把某个选择器改错,然后自己按 4.4 的流程排一遍错。这个“故意踩坑”的过程,比原地重跑十遍代码更有价值。排错时记住“先看页面,再看代码”,你就已经超过大半只在网上复制粘贴的人了。
