ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

jsoup实战指南:从HTML解析到数据提取与清洗的Java完全攻略

jsoup实战指南:从HTML解析到数据提取与清洗的Java完全攻略 相信不少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 groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.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 pHello, bjsoup/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本质上就是一个ArrayListElement的封装支持for循环、get()、size()、isEmpty()这些操作。处理大列表时我提醒你注意两个容易踩坑的“隐性条件”。第一个是懒加载很多页面首屏只渲染前10条数据剩下的等滚动或点击“加载更多”才出现这种直接用jsoup抓静态HTML是拿不全的需要配合无头浏览器。第二个是分页逻辑有些列表页的下一页按钮不是常规的a[href]而是JavaScript绑定的click事件这种也不行得看URL是否支持参数化翻页比如?page2这种能直接改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[propertyarticle:published_time]).attr(content); String author doc.select(meta[nameauthor]).attr(content); Element content doc.selectFirst(div.article-content); String pureText content.text(); ListString 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?id5如果你直接拿这个字符串存库后续用的时候必然是死链。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 MapString, String headers; public HtmlCrawler(String url, String charset, int timeoutMillis, MapString, 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 FieldExtractorT { T extract(Document doc); } public GenericFieldExtractor implements FieldExtractorMapString, String { private final String titleSelector; private final String contentSelector; private final String timeSelector; Override public MapString, String extract(Document doc) { MapString, 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; } }这里我统一用MapString, String做返回结构好处是入库前可以统一做空值判断和类型转换坏处是失去了类型安全。业务简单时这种写法能少写很多实体类但如果字段特别多还是建议定义具体的POJO维护起来更清晰。到落库环节核心思路是每次解析完一条记录就立即写入不积攒批量数据避免系统异常时丢数据。抓取开始前先记录起始时间结束后打印耗时和成功条数方便定位是网络瓶颈还是解析瓶颈。8. 写在最后的工程经验jsoup这个工具再基础用好了也能省掉很多工作量。我给你的建议是凡是遇到“从HTML里取数据”这个动作第一反应就选选择器而不是正则凡是遇到要输出用户提交的富文本第一反应就过一层Jsoup.clean白名单凡是遇到抓完数据要拼URL直接absUrl别手动拼。我在实际项目中体会最深的一点是jsoup的大部分问题不是出在工具本身而是出在生产环境的“脏HTML”上。写代码的时候多打印几次解析后的树结构多做几个空指针防御多考虑一下目标页面改版的情况整个抓取任务的稳定性就能提升一个档次。如果后续你要做大面积的网站数据采集建议在jsoup之上再封装一层调度模块管理好URL队列、去重、失败重试、限速这些横切逻辑。工具永远只是螺丝刀工程能力才决定你能把这颗螺丝拧得多稳。这个工具还有很多其他好玩的玩法比如通过NodeVisitor遍历全节点、用Document.OutputSettings控制序列化格式等用到的时候翻一翻官方文档或者源码收获会非常大。
返回列表