相信不少Java后端同行都遇到过这种场景:手头没有现成的接口文档,但又要从一个老旧的HTML页面里捞数据,或者需要把自己网站上的内容做一次结构化的抓取清洗。正则硬写吧,遇到嵌套标签和动态结构会写到怀疑人生;用Selenium或者Playwright吧,又显得杀鸡用牛刀,还得额外维护一个浏览器环境。这时候jsoup就该登场了。
jsoup是一个Java世界里的HTML解析库,它干的事情说白了就三件:解析HTML、提取数据、操作DOM。它把HTML当成一段可以随心所欲查询和修改的文档,让你能用类似jQuery的选择器语法飞快地找到想要的节点,也能用一套直白的API把页面里的链接、图片、表格甚至整个正文内容整整齐齐地抠出来。这篇文章不追求面面俱到,而是从实际干活的角度出发,把jsoup最常用的功能、最容易踩的坑、以及我实际项目中沉淀下来的技巧从头到尾捋一遍。不管你是刚接触Java的新人,还是写了好几年业务代码但一直没细看jsoup的老手,这篇都值得你花十几分钟过一遍。
1. 为什么是jsoup:它到底解决了什么问题
先说一个我印象很深的实际需求。早年间做电商数据迁移,对方给了一个导出工具,导出来的不是Excel也不是JSON,而是一整个嵌套了十几层的HTML页面。页面里有几百个商品卡片,每个卡片里除了名称、价格、库存,还有一堆装饰性的结构和推荐位。用正则去匹配的话,稍微换个样式或者多一个空格,规则就全废了。我当时第一反应就是用jsoup,因为它的整个设计理念就是“把HTML当作一棵可遍历、可查询的树”,而不是一串需要硬抠的文本。
1.1 从文本匹配到结构化解析的思路转变
传统的字符串处理思路,是把HTML当作线性文本,用正则或者indexOf去搜索特定模式。这种方式在页面结构非常稳定、内容非常简单时还能凑合,但一旦遇到标签嵌套、属性顺序变化、注释节点、脚本内容混入等情况,正则写起来就像在雷区里跳芭蕾。比如你想匹配所有<a>标签里的href,正则可能需要处理引号单双、属性顺序、标签内换行、甚至自闭合标签,写出来的表达式既难读又难维护。
jsoup的思路完全不一样。它先把整个HTML字符串解析成一棵文档对象模型树,树的每个节点对应标签、属性、文本或者注释。解析完之后,你就可以像操作Java对象一样,通过选择器去查询这棵树。selector语法基本沿袭了CSS和jQuery的习惯,div.product、a[href]、ul > li:first-child,会写CSS就能用。这种方式天然规避了正则的所有痛点,因为结构化的东西就该用结构化的方式来处理。
1.2 相比正则和浏览器自动化,jsoup的优势和边界在哪里
拿正则来对比,jsoup在绝大多数HTML提取场景里都更清晰、更稳定,而且没有正则的灾难性回溯问题。但正则并非一无是处,如果只是简单判断一个字符串里是否包含某段文本,或者做纯文本层面的清洗,正则还是更轻量。所以实际项目里,我的习惯是“能用选择器就不用正则,纯文本清洗才考虑正则”。
拿浏览器自动化来对比,Selenium或Playwright会真正启动一个浏览器内核去渲染页面,能执行JavaScript,能得到动态加载后的内容,但代价是资源消耗大、启动慢、需要安装浏览器驱动。jsoup不执行JavaScript,它只解析静态HTML响应体。这个特性既是优势也是边界:静态页面里抓数据,jsoup快得飞起;遇到纯前端渲染的SPA或者依赖Ajax异步加载的页面,jsoup拿到手的只是一堆空壳骨架,这时候就得考虑先用无头浏览器抓取渲染后的HTML,再把结果交给jsoup做解析。我实际项目里就有这种组合:Playwright负责渲染和等待,jsoup负责后续所有解析逻辑,各干各的绝活。
2. 入门准备:依赖引入和基础概念
2.1 三分钟把jsoup装进项目
如果你用的是Maven,在pom.xml里加一段依赖就完事:
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>目前1.17.2是个比较稳定的版本,如果你希望用更新的特性,可以去Maven中央仓库看看最新版本号。Gradle用户则是在build.gradle里写:
implementation 'org.jsoup:jsoup:1.17.2'依赖体积不大,没有任何传递依赖,不会给你的项目引入一堆用不上的库,这一点在微服务拆得很碎的时候特别讨喜。
装好之后,你会接触到三个最核心的类:org.jsoup.Jsoup是门面入口,负责解析和连接;org.jsoup.nodes.Document代表一整份HTML文档;org.jsoup.nodes.Element代表文档里的某个标签节点,一个节点可以嵌套其他节点。你日常90%的操作,都是在这三个类之间打转。
2.2 解析HTML的几种姿势,以及它们分别用在什么场景
jsoup解析HTML的方式很灵活,我从项目里总结下来,主要的入口有三种:
第一种是处理字符串形式的HTML片段,常用于从接口返回、数据库字段或者消息队列里读到的半结构化内容。写法很简单:
String html = "<p>Hello, <b>jsoup</b>!</p>"; Document doc = Jsoup.parse(html);这招尤其适合解析富文本编辑器吐出来的内容,比如后台管理系统里编辑器提交的文章正文,你需要从这段HTML里提取纯文本、抽取图片地址、或者做一些标签白名单清洗。
第二种是直接从一个URL发起HTTP请求获取页面内容。这个要特别说明一下,jsoup不只是个解析器,它内部还内置了一个轻量级的HTTP客户端:
Document doc = Jsoup.connect("https://example.com").get();你可以配置超时时间、请求头、Cookie、请求体,甚至模拟表单提交。我在实际项目中常用它抓一些不需要执行JavaScript的静态页面,比如技术文档站、政府公开数据页。虽然功能比不了成熟的HTTP客户端如OkHttp,但对于“抓一个页面然后立刻解析”这种场景来说,少引入一个依赖就是优势。
第三种是处理本地HTML文件。比如你需要批量清洗历史导出文件,或者做离线分析:
Document doc = Jsoup.parse(new File("/path/to/page.html"), "UTF-8");解析本地文件时第二个参数指定字符集非常关键。很多老系统导出HTML时不会在header里声明编码,如果文件其实是GBK编码却用默认UTF-8去解析,中文就会乱成一锅粥。我踩过这个坑,后来一律显式传入字符集,而且实在判断不了的时候,会先用工具探测编码再交给jsoup。
2.3 必须先搞清的DOM树和节点关系
掌握了三种解析姿势之后,强烈建议你先花一点时间搞懂jsoup的DOM树模型,否则后面所有选择器操作都像是在黑屋子里找开关。简单来说,HTML文档被解析后是一个树形结构,树的根是Document,下面是<html>、<head>、<body>这样的层级节点,再往下是各种标签和文本。
jsoup里每个Element都有一系列方法获取父节点、子节点、兄弟节点:parent()拿到上一层元素,children()拿到所有子元素,siblingElements()拿到同级兄弟元素。这些方法在处理列表、表格这类重复结构时特别好用。比如你拿到了一个商品卡片,想找整个卡片区域,可以card.parent();想跳过某个装饰节点直接找下一个兄弟,可以card.nextElementSibling()。这些导航操作配合后面要讲的选择器,基本能覆盖绝大多数取数逻辑。
3. 核心必备技能:选择器与DOM遍历实战
3.1 选择器语法,实际写代码时最常用的有哪些
选择器是jsoup的灵魂,它让你能用极短的代码定位到页面里任何想操作的元素。我从真实项目里统计了一下,最常用的就下面这么几类:
- 按标签选:
div、a、p。适合先框定一个大范围。 - 按类名选:
.product。注意那个英文句点别漏了。 - 按ID选:
#header。 - 按属性选:
a[href]、img[src$=.png]。这里支持CSS属性选择器的通配规则,$=表示以某个字符串结尾,^=表示以某个字符串开头,*=表示包含。 - 层级组合:
div.product .price(后代)、div.product > .price(直接子级)、h3 + p(相邻兄弟)。这在爬取列表页时几乎天天用。 - 伪类选择器:
:first-child、:last-child、:eq(0)、:contains(关键词)。前两个用于列表首尾,:contains我用得也很多,比如在一堆标签里找包含“缺货”字样的节点去做库存状态判断。
实际写代码时,我更倾向于“从大到小逐步收缩”的策略。先选中一个较大的容器,比如整个商品列表区域,然后在容器内部继续用select深挖具体字段。这样有两个好处:一是选择器路径短,不容易因为页面局部结构调整而碎掉;二是定位准确,避免两个不同区域撞了同一个类名导致误选。
3.2 提取文本、属性和HTML,别把API搞混
找到节点之后,接下来的活就是取内容。这里有几个API长得像但返回值完全不同,新手特别容易混:
element.text():拿节点内部所有可见文本,嵌套标签里的文字会被拼接后返回。这个要注意,如果有多个子标签且你只想取某一层级的文本,用这个会把你不需要的碎片也拼进来,更精确的做法是先定位到子元素再取文本。element.attr("href"):拿某个属性的值。取不到时返回空字符串,不会抛异常。element.html():拿节点内部的原始HTML片段,保留标签结构,适合做内容备份或再加工。element.outerHtml():连当前节点自身的标签一起输出。element.val():专门用来取表单控件值的,比如<input>的value、<textarea>的内容、<select>当前选中的option。
在提取网页表格数据时,我一般这么组合使用:
Elements rows = doc.select("table#data tr"); for (Element row : rows) { String code = row.select("td").get(0).text(); String name = row.select("td").get(1).text(); String amount = row.select("td").get(2).text(); System.out.println(code + " - " + name + " - " + amount); }如果表格里有的单元格带colspan属性,上面的简单按位置取值会错位。更稳的方式是逐行遍历td时,同时检查colspan值来动态决定偏移量。这个问题我在后面的排查章节还会详细展开。
3.3 遍历与大列表处理:注意懒加载和分页
页面里最常见的场景就是列表:商品列表、新闻列表、评论区。jsoup返回的Elements本质上就是一个ArrayList<Element>的封装,支持for循环、get()、size()、isEmpty()这些操作。
处理大列表时,我提醒你注意两个容易踩坑的“隐性条件”。第一个是懒加载,很多页面首屏只渲染前10条数据,剩下的等滚动或点击“加载更多”才出现,这种直接用jsoup抓静态HTML是拿不全的,需要配合无头浏览器。第二个是分页逻辑,有些列表页的下一页按钮不是常规的a[href],而是JavaScript绑定的click事件,这种也不行,得看URL是否支持参数化翻页,比如?page=2这种,能直接改URL的话就循环抓。
遍历列表时还有一个性能细节。如果你对页面上成千上万个节点都要分别做复杂的选择器查询,性能会有影响,但jsoup本身是内存解析,一页几百个节点基本毫无压力。真正需要小心的是不要在一个循环里反复调用Jsoup.connect()去抓详情页,做好延时和重试,不然很容易被对方网站的限流策略盯上。
4. 深入实战:从文章抓取到数据清洗的一站式流程
4.1 抓取一篇文章的标题、正文和图片
用一个非常典型的场景展开:抓取一篇新闻文章,拿到标题、发布时间、正文内容、和里面的所有图片地址。
Document doc = Jsoup.connect("https://example.com/news/123") .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .timeout(5000) .get(); String title = doc.title(); String publishTime = doc.select("meta[property=article:published_time]").attr("content"); String author = doc.select("meta[name=author]").attr("content"); Element content = doc.selectFirst("div.article-content"); String pureText = content.text(); List<String> imageUrls = content.select("img").eachAttr("src");这段代码里有几个值得品一品的细节。
一是标题我用了doc.title()而不是去HTML里找<h1>。因为多数新闻页的<title>标签已经包含了完整标题,而页面里的<h1>可能因为样式原因被拆成几个span,取出来还要做拼接清洗。
二是发布时间和作者我选择从<meta>标签里取。这是很多页面为SEO和社交媒体分享准备好的结构化信息,比在正文里找时间字符串靠谱得多。不同网站的meta标签写法不统一,有的用article:published_time,有的用weibo: article:create_at,有的甚至用og:release_date,实际处理时需要写一个可配置的适配层。
三是eachAttr这个方法,它是批量提取某个属性的快捷方式,content.select("img").eachAttr("src")会直接返回一个List,省去手动遍历再add的功夫。
4.2 相对路径转绝对路径,一个非常实用但不常被提到的API
抓链接和图片时,URL相对路径是很大的坑。页面里经常写src="/uploads/2024/01/01/pic.jpg"或者href="../detail?id=5",如果你直接拿这个字符串存库,后续用的时候必然是死链。jsoup提供了一个极其便捷的API来解决这个问题,我每次给团队分享时都要特意强调:
String absUrl = element.absUrl("src");absUrl方法会根据文档的base URI自动把相对路径拼成完整的带域名地址。但要注意一个前提,使用这个API前,你必须保证Document对象在解析时带了正确的base URI。如果你是用Jsoup.parse(String html)这种方式解析的,文档的base URI是空的,absUrl算出来的还是原字符串。正确做法是从URL连接解析,或者显式指定:
Document doc = Jsoup.parse(html, "https://example.com/base/path");第二种方式第二个参数就是baseUri。第一次不知道这个细节时,我排查了很久为什么absUrl返回的还是相对路径,后来翻源码才明白原来是baseUri为空。这个经验值得重点记一下。
4.3 用白名单机制清洗不可信的HTML内容
做内容型产品时,经常需要接收用户提交的富文本,或者从外部来源抓取文章后重新展示。这时候直接原样输出HTML,等于把XSS漏洞的大门敞开了。jsoup真正打动我的一个功能,就是它内置了一个HTML清洗器,基于白名单机制,只保留你允许的标签和属性。
最典型的用法是,用户提交了一篇带格式的正文,你只允许保留基本的排版标签:
String cleanHtml = Jsoup.clean(userInput, Safelist.relaxed() .addTags("h2", "h3") .addAttributes("a", "href", "title"));Safelist.relaxed()是内置策略,允许的标签比basic()多一些,通常包含p、br、img、a、ul、ol、li、blockquote、表格相关标签等。如果你需要更严格的约束,可以用Safelist.basic(),它直接过滤掉大部分格式标签,只保留纯文本结构。这个清洗机制在底层会把HTML重新解析成树,再按白名单逐节点过滤拼接,输出出来的HTML是干净、安全、格式规范的。
我在内容管理系统里给所有输入出口都加了这个清洗步骤,从源头把投毒标签挡在外面。实际效果是,后来安全扫描工具扫描时,XSS风险列表几乎全空。这里也分享一个细节:如果你要保留class属性来做样式定制,不能只加标签,还得额外加属性白名单,比如addAttributes("p", "class"),否则清洗器会把class丢掉。
5. 高频踩坑与排查技巧:这里都是真金白银的教训
5.1 编码问题:乱码、空内容,多半是字符集没配对
网页编码是解析HTML最常见的坑,没有之一。老网站用GBK、GB2312的多得是,新网站基本UTF-8,但偶尔也有不标识编码或者标识错误的情况。jsoup的Jsoup.connect()在大多数情况下能根据响应头里的Content-Type或者HTML里的<meta charset>自动判断编码,但总有翻车的时候。
我建议的做法是,对于你自己明确知道编码的站点,强制指定:
Document doc = Jsoup.parse(new URL("https://example.com").openStream(), "GBK", "https://example.com");三个参数分别是输入流、字符集、baseUri。如果你不确定对方编码,一个土办法是先用工具检测字节流,比如juniversalchardet这个库,检测出编码后再交给jsoup解析,实测准确率很高。
5.2 动态加载内容解析为空:先判断是静态还是动态页面
经常有人来问我:“我明明用Jsoup.connect(...).get()抓到了页面,为什么我的选择器就是选不到数据?”这八成是因为页面内容是JavaScript动态渲染的。最简单验证方法是用浏览器的“查看源代码”功能去看原始HTML响应,如果源代码里根本没有你预期的数据节点,那jsoup注定无能为力。
遇到这种页面你有几个选择:一是找找页面是否有提供JSON数据接口,很多站点的列表数据其实藏在https://example.com/api/list下面,返回纯JSON,反而比解析HTML更香;二是用无头浏览器渲染之后再交给jsoup;三是找页面源码里是否有window.__INITIAL_STATE__这类全局变量,里面经常直接内嵌了一份完整数据,用jsoup拿到<script>标签内容再配合JSON解析,能绕开整个渲染过程。
5.3 选择器匹配不到或错位:学会调试和验证
选择器写完发现匹配不到元素,先留意下面这几种情况:类名里带空格其实是多个类,选择器里应该写作.class1.class2;标签大小写不敏感,但属性值大小写敏感;HTML里div没闭合导致结构变形,这种可以先用doc.toString()打印解析后的树,肉眼检查层级是否正常。我之前排查过一个大坑,页面里写的是<img src>没闭合,后面的段落全被并进了某个父标签,选择器路径全都失效,打印解析树才看到端倪。
text()提取文本时匹配不到,也别急着改选择器,先检查是不是有元素被隐藏了。display:none和visibility:hidden的元素在页面里看不见,但它们的文本依然会被text()完整提取出来。如果你想要过滤掉这些隐藏节点,就得自己判断style属性,没有现成的内置API,需要根据element.attr("style")或者element.hasAttr("hidden")手动排除。
另外,如果你拿到的是繁体或全角空格混排的文本,匹配不上关键词是正常的。清洗文本的时候,记得统一做一下replaceAll("\\u00A0", " "),把不换行空格替换成普通空格,再用trim()去掉首尾空白。
5.4 表格数据错位:colspan和rowspan的处理策略
处理HTML表格时,colspan和rowspan这两个属性会让所有按单元格位置取数的逻辑崩盘。我做过一个抓取多行多列统计报表的活,表格里频繁出现合并单元格,简单用tr td顺序取值,取出来的数据跟表头完全对不上。解决办法是先对表格做一个“展开”处理:把每个带colspan的单元格拆成多个虚拟单元格,遇到rowspan时记录它占用的后续行数,在后续行里补上这个重复值。听完是不是有点像在做Excel表格的合并单元格处理?思路是共通的。如果你处理的表格形式固定,可以直接在代码里写死偏移量;但若格式多变,还是建一个二维数组去模拟展开更靠谱。
6. 性能优化和上游配合:让jsoup在真实项目中跑得更顺
6.1 连接复用、超时控制和重试策略
虽然jsoup内置了HTTP客户端,但如果你的爬取任务量大,还是建议把“抓取”和“解析”分离:用OkHttp这类更成熟的HTTP客户端负责拿页面字节流,然后交给jsoup解析。道理很简单,OkHttp对连接池、自动重定向、请求重试、代理支持都做得更完善,而jsoup的HTTP模块更多是为了单次抓取方便设计的。
无论用哪个发请求,超时和重试都得自己控制好。网络环境很复杂,服务器5秒没响应很正常,如果设置了过短的超时时间,反而会频繁误判。我的经验是连接超时设5秒,读取超时设10秒,重试最多3次,每次间隔指数退避,比如1秒、2秒、4秒。用jsoup连接时,超时时间用.timeout()配置,参数单位是毫秒。
6.2 解析超大HTML:选择器层级如何影响性能
单页HTML超过几兆字节时,解析性能就开始值得关注了。这种超大页面常见于导出型后台系统,一个文件里塞了上千行表格,每行还有几十个字段。jsoup的处理方式是把整个文档树全部建在内存里,如果页面巨大,内存占用会跟着涨。我在实际测试里,一个10MB的HTML文件,解析成Document大约会占用几十MB堆内存,如果系统的堆内存本来就紧,很有必要做限制。
性能优化上,一个很有效的习惯是缩小查询范围。比如你只需要表格里的数据,可以先selectFirst("table#data")把范围缩到一个节点,然后在这个节点内部再去做字段查询,而不是每次都从doc全局查找。另一个技巧是优先使用selectFirst()替代select()。如果你只需要第一个匹配项,selectFirst()会在找到目标后提前终止遍历,减少不必要的扫描。
6.3 尊重robots、限速和反爬:专业做法值得一看
虽然本文讲的是技术工具,但作为从业者,我希望你明白,任何爬取行为都在法律和道德的边界内运行。对目标网站,你应当先检查robots.txt,设置合理的抓取间隔,不要对同一个域名发起高并发请求。一个朴素的原则是:你发给别人的流量,是你自己网站被高并发打爆时也不希望收到的。
遇到对方有反爬策略,比如请求头校验、频率限制、滑块验证时,更合理的应对是研究对方的公开API、找合作渠道,而不是在绕过策略上死磕。jsoup本身不解决反爬问题,它只负责解析,如果你想折腾高强度爬取,该上无头浏览器或代理池时就得换工具,那已经是另外一个话题了。
7. 一个完整示例:从配置读取到落库的抓取小框架
我兜里这套代码,是早期在公司做信息聚合系统时沉淀下来的骨架,压箱底也七八年了。每次有新站点要接,我都是复制这套代码改改选择器和字段映射就能上线,今天把核心逻辑分享给你。
整体思路是:三条核心链路并行,读取配置决定“抓什么、去哪抓、怎么存”,抓取器只负责把HTML变成Document,字段提取器只负责从Document里抠数据,落库环节通过回调把结果交出去。这样做的好处是职责清晰,新接一个站点往往只需要改配置和少量提取逻辑。
public class HtmlCrawler { private final String url; private final String charset; private final int timeoutMillis; private final Map<String, String> headers; public HtmlCrawler(String url, String charset, int timeoutMillis, Map<String, String> headers) { this.url = url; this.charset = charset; this.timeoutMillis = timeoutMillis; this.headers = headers; } public Document fetch() throws IOException { // 方法一:直接使用jsoup自带的连接器 // 方法二:使用OkHttp请求,拿到InputStream后交给jsoup解析,灵活性更高 Connection conn = Jsoup.connect(url) .timeout(timeoutMillis); headers.forEach(conn::header); if (charset != null && !charset.isEmpty()) { conn = conn.parser(Parser.htmlParser().setTrackErrors(500)); } return conn.get(); } }Parser.htmlParser().setTrackErrors(500)这个写法值得说道一下。它能打开解析器的错误收集功能,最多记录500条解析错误,用于排查HTML不规范导致的解析异常。调用parser.getErrors()可以拿到所有错误信息,在debug模式下打印出来,能帮你快速定位是哪个标签把DOM结构带歪了。
字段提取部分的抽象可以这样设计:
public interface FieldExtractor<T> { T extract(Document doc); } public GenericFieldExtractor implements FieldExtractor<Map<String, String>> { private final String titleSelector; private final String contentSelector; private final String timeSelector; @Override public Map<String, String> extract(Document doc) { Map<String, String> data = new HashMap<>(); data.put("title", doc.selectFirst(titleSelector) == null ? "" : doc.selectFirst(titleSelector).text()); data.put("content", doc.selectFirst(contentSelector) == null ? "" : doc.selectFirst(contentSelector).html()); data.put("time", doc.selectFirst(timeSelector) == null ? "" : doc.selectFirst(timeSelector).text()); return data; } }这里我统一用Map<String, String>做返回结构,好处是入库前可以统一做空值判断和类型转换,坏处是失去了类型安全。业务简单时这种写法能少写很多实体类,但如果字段特别多,还是建议定义具体的POJO,维护起来更清晰。
到落库环节,核心思路是每次解析完一条记录就立即写入,不积攒批量数据,避免系统异常时丢数据。抓取开始前先记录起始时间,结束后打印耗时和成功条数,方便定位是网络瓶颈还是解析瓶颈。
8. 写在最后的工程经验
jsoup这个工具再基础,用好了也能省掉很多工作量。我给你的建议是:凡是遇到“从HTML里取数据”这个动作,第一反应就选选择器而不是正则;凡是遇到要输出用户提交的富文本,第一反应就过一层Jsoup.clean白名单;凡是遇到抓完数据要拼URL,直接absUrl别手动拼。
我在实际项目中体会最深的一点是,jsoup的大部分问题不是出在工具本身,而是出在生产环境的“脏HTML”上。写代码的时候多打印几次解析后的树结构,多做几个空指针防御,多考虑一下目标页面改版的情况,整个抓取任务的稳定性就能提升一个档次。
如果后续你要做大面积的网站数据采集,建议在jsoup之上再封装一层调度模块,管理好URL队列、去重、失败重试、限速这些横切逻辑。工具永远只是螺丝刀,工程能力才决定你能把这颗螺丝拧得多稳。这个工具还有很多其他好玩的玩法,比如通过NodeVisitor遍历全节点、用Document.OutputSettings控制序列化格式等,用到的时候翻一翻官方文档或者源码,收获会非常大。