尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java网络爬虫实战:HttpClient+Jsoup构建高效采集系统

Java网络爬虫实战:HttpClient+Jsoup构建高效采集系统 最近接到一个需求要用Java写一套网络爬虫去采集几个行业网站的数据。本来想图省事用Python但项目组现有的技术栈是Java数据要直接落到MySQL和ES里后续还要接公司内部的消息队列用Java反而能少走弯路。折腾了几天把HttpClient、Jsoup、线程池、代理IP这些组件串起来总算是跑通了。这篇文章就记录一下我在这个Java网络爬虫项目里的完整思路和踩坑过程。不会讲太多虚的全是实际能落地的代码和方案适合刚接触爬虫、或者准备在Java技术栈里做数据采集的同学参考。如果你已经在用Python写爬虫也可以看看Java这边的工程化思路两种语言在解决同一类问题时侧重点真的不太一样。1. 为什么要用Java写爬虫而不是Python网上聊爬虫十篇有八篇是Python的requests加BeautifulSoup一套组合拳下来几十行代码就能跑。但真到了企业级的数据采集场景Java的优势才会体现出来。先说语言本身。Java的强类型特性在写复杂解析逻辑时其实是个保护字段定义清晰IDE提示到位不像Python写久了容易忘了某个字段到底是字符串还是字典。另外Java的异常处理机制也更适合爬虫这种高IO、高失败率的场景你可以准确地捕获连接超时、解析失败、反爬拦截等不同类型的异常分别做降级处理。再聊生态和集成。做爬虫从来不只是“把网页抓下来”这么简单抓完的数据要清洗、去重、入库要定时调度要监控运行状态。这些在Java生态里都有非常成熟的方案Spring Boot负责定时任务和依赖管理MyBatis或者JPA负责数据持久化XXL-Job或Quartz负责分布式调度监控报警直接接Prometheus或者公司自带的监控系统。你用Python写爬虫抓完数据最后还是要写一堆胶水代码去把数据转发给Java服务多一层网络开销不说出了问题排查起来还麻烦。最后是性能和资源控制。Java的线程池机制配合HttpClient的异步特性可以在单机上轻松维持几百个并发连接而且每个连接的内存占用是可控的。Python因为GIL的存在多线程爬虫在CPU密集型的HTML解析场景下会被锁住只能用多进程或者协程来弥补部署和运维成本反而更高。这不是说Python不好Python的requests库写起来确实爽Scrapy框架的插件生态也丰富。但如果你的项目本身就是Java技术栈数据要直接进ES、MQ、各种Java中间件那用Java写爬虫反而是一条更顺的路。我这次就是把采集、解析、清洗、入库全部在一个Spring Boot服务里搞定没有额外的数据传输环节整体链路短出问题也好定位。2. 整体架构设计思路写爬虫和写CRUD接口不一样不能用“一次性请求”的思路来写。数据采集是一个持续的、动态的过程你面对的网站随时可能改版、升级反爬策略、调整接口参数所以架构设计上要留出足够的缓冲和容错空间。我这次采用的架构分为五层调度层、采集层、解析层、清洗层、存储层。调度层负责控制“什么时候抓、抓哪些URL”我用Spring Schedule做定时触发再用一个自定义的URL队列管理器维护待抓取的任务。采集层是核心用HttpClient发送HTTP请求带着配置好的请求头、Cookie、代理IP池等参数去获取响应结果。解析层负责把拿到的HTML或者JSON转换为结构化的Java对象根据页面类型的不同有时候用Jsoup解析HTML有时候用Jackson解析JSON接口。清洗层做的工作就是去重、过滤无效数据、处理缺失字段、统一数据格式。存储层就是把最终干净的数据落库我这边是写了双写逻辑一份进MySQL用于业务查询一份进ES用于全文检索。关键的设计决策是“采集”和“解析”解耦。页面请求逻辑和页面解析逻辑写在一起初期看起来省事但网站一旦改版你就得在一堆代码里找哪里是抓取的、哪里是解析的非常痛苦。我用了生产者-消费者模式采集线程只负责把响应结果放进一个阻塞队列解析线程从队列里取数据做解析。队列有界防止内存被打爆采集速度和解析速度不一致时队列天然起到了削峰填谷的作用。URL管理也是容易被忽视的一环。爬虫最容易犯的错就是重复抓取同一个页面既浪费带宽又给目标网站增加压力。我在内存里维护了一个去重集合同时把已抓取的URL指纹写进Redis用Set类型做去重这么设计的好处是即使服务重启了运行过程中已抓取的URL还能被Redis记住不会从头再抓一遍。关于爬取频率的控制我是用了最简单的令牌桶算法。一个定时任务每秒钟往桶里放一定数量的令牌采集线程每次抓取之前先申请一个令牌拿到令牌才允许发HTTP请求拿不到就阻塞等待。这个方法虽然简单但能把抓取速率控制得死死的比直接在代码里sleep更灵活。比如目标网站白天访问量大我就可以动态调低令牌桶的速率晚上再放开完全不用重启服务。3. 用Jsoup解析HTML的核心细节Jsoup是Java领域解析HTML的标配工具它的哲学和jQuery很像用选择器语法就能定位页面元素。我一直觉得如果你会用jQuery那上手Jsoup基本没有成本。用Jsoup解析页面的第一步是把HTML字符串加载成一个Document对象。这里有几种方式最常见的是Jsoup.parse(html)传入一个字符串就可以。需要注意的是网页的字符编码问题很容易踩坑如果HTTP响应头里没有明确的charset而HTML的meta标签里声明的是UTF-8Jsoup默认的解析行为可能会导致中文乱码。我遇到这种情况后在解析之前先做了一次编码探测用了一个叫做CharsetDetector的工具类先根据Content-Type头部的charset参数判断没有的话就去HTML的head标签里找meta声明实在找不到才用UTF-8兜底。定位元素是解析的核心操作。doc.select(div.product-list)、doc.select(.price)、doc.select(#main ul li)这些CSS选择器的用法和前端一模一样。还有一个很常用的方法是doc.getElementsByClass(item)它等价于.item这个类选择器返回一个Elements集合可以遍历每个元素再做二次定位。我在解析中经常用到的一个模式是先定位“列表项的外层容器”再在容器内部查找目标字段。比如一个商品列表页每个商品在一个div.goods-item里里面的span.goods-name是商品名span.goods-price是价格。我先Elements items doc.select(div.goods-item)选出所有商品再遍历items分别调用item.select(span.goods-name).text()和item.select(span.goods-price).text()取出字段值。这种方式比直接用一大串复杂的选择器一次定位更稳健页面结构稍微调整时只需要改内层选择器就行。Jsoup处理相对路径也是一把好手。页面里的链接经常是/product/123.html这种相对地址直接用会出问题。用absUrl方法可以自动补全成绝对地址比如element.absUrl(href)前提是Document对象加载时设置了baseUri参数。HttpClient请求时我已经知道了最终的URL所以Jsoup.parse(html, url)这种方式就能把页面链接全部转为绝对路径非常省事。如果页面里有些数据不在HTML里而是通过AJAX异步加载的那用Jsoup直接解析是拿不到的。这种情况下必须用浏览器开发者工具找到那个异步接口直接用HttpClient请求接口拿JSON数据。这个思路很重要不能死磕HTML解析学会分析网络请求是爬虫进阶的必修课。4. HttpClient请求库的实战用法项目里我用的是Apache HttpClient 4.5版本虽然现在有更新的HttpClient 5.x但4.5因为稳定性和兼容性在企业项目里还是主流。我封装了一个基于连接池的HttpClient工具类把连接池配置、超时设置、请求头管理、重试机制都封装好外部调用只需传一个URL就能拿到响应状态码和响应内容。连接池是HttpClient的核心配置。爬虫通常需要同时请求几十个甚至上百个页面如果每个请求都新建一个HTTP连接TCP握手的时间开销会非常可观。我的做法是设置一个最大连接数200、每个路由的最大连接数100的连接池并开启Keep-Alive策略让TCP连接可以复用。实际压测下来开启了连接池后吞吐量提升了将近三倍而且目标服务器的连接压力也变小了。超时设置是三段式配置联系超时、连接超时、Socket读取超时。最初的版本是把超时时间全设在10秒结果经常因为某个页面响应慢队列里堆积了大量超时异常。后来我把Socket读取超时调到了15秒连接超时保持5秒配合HttpClient的连接管理策略问题就缓解了很多。请求头也是需要精心构造的。很多网站的防护策略首先看User-Agent如果发现是爬虫常用的UA直接拒绝服务或者返回验证码页面。我用了一个UA池里面放了Chrome、Firefox、Safari、Edge几种主流浏览器的UA字符串每次请求随机取一个。更完整一点的方案还要带上Referer、Accept、Accept-Language等字段模拟真实浏览器的请求特征。处理重定向也是HttpClient的重要功能。HttpClient默认会跟随302重定向但问题是重定向后你可能丢失了自定义的请求头。我遇到了一个网站登录后的Cookie是加在初始请求上的结果重定向后HttpClient默认策略没有把Cookie带过去导致跳转后的页面显示未登录。解决办法是关闭自动重定向手动处理Location头再发一次请求这样Cookie和header都能自己控制。Cookie的存储管理同样关键。我实现了一个基于持久化存储的CookieStore把Cookie保存到本地文件里这样程序重启后还能沿用之前的登录态不用每次启动都重新登录。这个方案在需要登录的网站上非常实用省去了一堆登录验证的流程。5. 代理IP池与反爬应对策略反爬是每个爬虫工程师都躲不开的话题。正规的数据采集一定要遵守网站的robots协议合理控制频率不恶意攻击站点。但即便如此很多网站还是会验证User-Agent、检测访问频率、校验Cookie来拦截看起来像“非人类”的请求。应对反爬最有效的办法就是代理IP。我用了一个管理代理IP的组件从代理服务商那边获取IP列表定期验证可用性然后供采集线程轮换使用。每个IP有自身的权重和失败次数失败次数超过阈值就会被暂时移出池子定期重新探测。代理分为透明代理、匿名代理和高匿代理采集敏感数据时一定要用高匿代理。高匿代理不会在HTTP头里暴露你的真实IP目标网站看到的就是代理IP本身。如果用了透明代理服务器可以从X-Forwarded-For等字段看出你的真实地址那代理就形同虚设了。除了IP层面的应对请求频率控制也是关键。我给每个代理IP设置了每秒最大请求数超过阈值就换下一个IP。配合前面说的令牌桶算法整个爬虫的请求压力被均匀地分散到各个IP上单IP的负载很小自然不容易触发反爬规则。验证码一直是爬虫的难点能绕开就绕开。我发现很多网站虽然会弹验证码但如果你的请求头、Cookie和访问行为足够像真人触发验证码的概率并不高。比如我会在两次请求之间随机暂停2到5秒并且让请求的次序不完全按照URL列表的顺序而是随机打乱这样看起来更像是用户在手动浏览。如果实在绕不开验证码那就只能上打码服务了。市面上有很多在线的验证码识别服务通过API提交图片返回结果集成起来不算复杂。不过这也意味着成本上升和延迟增加我的选择是优先调整行为模式尽量减少验证码的出现频率只有极少数情况下才调用打码服务。6. 多线程采集的并发控制实践单线程爬虫的效率实在是太低了特别是面对几千个URL的任务队列时用单线程可能要跑几个小时。我在采集层引入了线程池并发度控制在合理范围内既能显著提升速度又不会对目标站点造成过大压力。线程池我用的是ThreadPoolExecutor核心线程数设为10最大线程数设为20队列容量为500。选这个参数不是什么玄学是综合考虑了本机CPU核数、内存大小以及目标网站的承受能力。队列满的时候会执行CallerRunsPolicy拒绝策略让提交任务的线程自己执行任务这样就不会出现任务丢失的情况。相比直接使用Executors.newFixedThreadPool()手动创建线程池的好处是参数完全可控出现异常时也更容易定位问题。线程安全是并发采集的重中之重。我们在多个线程里共享URL队列、代理IP池、统计数据等资源如果不对这些共享资源做同步控制会出现重复抓取、IP选择冲突、数据统计不准确等各种问题。我选的方案是ConcurrentLinkedQueue来管理URL队列它基于CAS实现并发性能好不会锁住整个队列。代理IP池用了ConcurrentHashMap加AtomicInteger来管理每个IP的权重和状态。统计数据则是用LongAdder在高并发更新计数时比AtomicLong的性能更优秀。还有一个小细节是线程上下文的传递。我在设计时给每个爬虫任务加了一个自定义的上下文对象里面存了任务ID、来源URL、重试次数等信息。解析线程处理数据时可以通过这个上下文追溯到数据来源后续排查问题时能省很多力气。多线程采集虽然爽但问题也容易出现在并发上。最常见的问题就是某个共享数据结构没有做好同步导致数据错乱或者抛出ConcurrentModificationException。我的建议是在代码审查时重点检查所有在多线程环境下会被修改的集合对象看是否使用了线程安全的数据结构或者是否有加锁保护。这个环节偷懒后面排查bug会痛苦百倍。7. 数据清洗与持久化的完整流程采集和解析只是前半场数据清洗和持久化才是爬虫价值的体现。抓下来的原始数据质量参差不齐有HTML标签残留、有空字段、有编码混乱、有重复数据直接入库会严重污染下游的业务分析。在清洗层我先做的是字段级校验。商品价格必须大于0发布时间不能晚于当前时间标题长度不能超过数据库字段长度限制这些校验规则在一个ValidationUtil里统一维护。校验不通过的数据不会直接丢弃而是写入一个日志表中备案方便后续追溯和分析。接着是格式统一。日期字段统一转成yyyy-MM-dd HH:mm:ss格式数字字段统一去除千分位逗号金额字段统一保留两位小数。这里务必记得处理两个常见的坑一是网页里经常出现nbsp;这种HTML实体字符需要转成空格二是数字字段里偶尔会有全角数字或中文数字需要做转换。清洗这一步看起来不起眼但真的是数据质量的生命线。持久化我用的是MyBatis-Plus主要是图它简便的CRUD和分页插件。写入策略上MySQL用INSERT ... ON DUPLICATE KEY UPDATE做幂等写入靠唯一索引去重。ES的话是先查出文档ID是否存在有则更新无则新增。批量写入的性能优化也是值得注意的。刚开始我是一条一条地往MySQL里insert一万条数据要跑快一分钟。后来改成了每攒满500条执行一次批量插入瞬间提速到原来的五倍左右。批量操作时要注意控制每条SQL的参数数量单次批量太大反而会因为SQL语句过长或者事务过大导致性能下降甚至触发MySQL的max_allowed_packet限制。8. 常见问题排查与避坑指南爬虫项目实施过程中我记录了十几个典型问题和对应的排查思路这里挑几个有代表性的分享出来希望你能少踩点坑。第一个是响应内容乱码。分析思路是先确认目标站点返回的字符编码再看程序里是否正确处理了这个编码。我用了一套三级探测逻辑先看HTTP响应头里的Content-Type再看HTML的meta标签最后用第三方库做内容编码猜测。绝大多数乱码问题都是编码探测不健全导致的这个解决思路基本可以覆盖99%的网站在编码方面的坑。第二个是SSL证书校验失败。用HttpClient请求HTTPS站点时经常会遇到证书链不完整或者证书过期的情况。解决办法是构造一个信任所有证书的SSLContext绕过证书验证但要注意这会产生中间人攻击风险建议只在可控环境下使用。更稳妥的方案是把目标站点的证书下载下来导入到本地信任库中。第三个是请求超时和重试。网络环境不稳定时某个请求偶尔会超时如果直接放弃会丢失数据如果无限重试又可能卡死线程。我用的方案是支持重试机制最多重试3次每次重试的等待时间按指数退避递增。重试超过了3次就丢弃该URL并写入失败日志由定时任务统一进行补偿抓取。第四个是内存溢出问题。爬虫程序跑的时间长了内存情况总是需要特别关注。如果响应内容很大且解析后对象没有及时释放加上队列积压过多很容易触发OutOfMemoryError。建议用可观测性工具实时关注堆内存使用情况配合有界队列来控制生产速度。另外我建议把大页面请求的结果先落盘到临时文件再异步解析避免同时有太多大对象驻留在堆内存。第五个是动态数据加载问题。很多时候目标页面的数据是通过AJAX异步加载的直接用HttpClient请求HTML页面拿到的是空壳。这种情况我建议使用浏览器开发者工具找到数据对应的XHR请求直接请求这个接口。接口返回的JSON数据不仅结构稳定解析起来也更方便。当然如果是纯JavaScript渲染的页面就只能上Selenium或者Playwright这类浏览器自动化工具了但那是另一个复杂度级别的事情通常作为最后的备选方案。9. 分布式爬虫的扩展思路当你的采集量级从日级十万涨到千万级别时单机爬虫就跑不动了。这时候需要考虑横向扩展也就是分布式爬虫。分布式爬虫的核心思想是将任务队列从内存中搬出来放到外部的消息中间件中。我用Kafka作为任务分发中心每台采集节点从Kafka中拉取URL任务处理完成后把新的URL再生产回Kafka形成一个闭环。数据解析和入库的结果统一发到另一个Topic由专用的写入组消费入库。这个架构比单机的线程池方案复杂不少但好处也很明显可以对任意节点单独扩缩容而无须停止整个服务。某个节点被目标网站封了IP直接下线该节点换一批新的代理IP再把新节点加回集群就行。调度一致性是分布式场景下面临的最大挑战。比如所有节点都在跑定时任务到整点就同时发起采集会产生巨大的流量峰值并且极容易触发目标网站的反爬策略。我引入了集中式的调度服务由调度中心统一分配任务片每个采集节点只处理分配给自己的那一份任务互不竞争。分布式采集还有一个数据一致性的问题。同一个URL有可能被两个节点同时抢到并抓取所以我的去重逻辑从Redis单机版本改成了Redis集群的SETNEX操作确保同一个URL在全集群只会被一个节点成功消费。这个改造是很必要的不然后台的脏数据会大量增加。不过说实话如果你只是做中小规模的数据采集单机方案完全够用。引入分布式组件会显著提升系统的复杂度如果不能驾驭Kafka、Redis集群这些基础设施反而会把自己搞得很累。我的态度是按需演进先用单机方案跑通业务明确瓶颈之后再上分布式架构。10. 聊聊学习路径和踩坑心得写完这套Java网络爬虫我对“爬虫工程师”这个角色的理解又深了一层。爬虫不是简单地写代码调接口它要求你有网络协议基础、有HTML/CSS/JavaScript功底、有并发编程经验、有数据分析思维还得懂一些系统运维的知识。对于想入门Java爬虫的新手我建议从最基础的内容学起不要一上来就想搞分布式。先用HttpClientJsoup写一个能跑的单机爬虫采集几个静态页面做数据清洗和入库把整个链路跑通。然后再考虑加入多线程、代理池、布隆过滤器去重、异常处理框架这些进阶方案。每一层都不难但叠加起来就构成一个完整的爬虫系统。踩过的坑多了以后我的体会是爬虫系统出问题十次有八次不是代码逻辑错而是对目标站点的行为理解不到位。写爬虫之前一定要花时间分析目标网站的技术栈和页面结构先手动用浏览器打开页面按下F12看网络请求能请求接口就直接请求接口实在不行再解析HTML这会省去大量无效编码时间。最终实际运行下来这套Java爬虫系统每天能稳定采集约50万条数据抓取成功率达到99.2%这还是在目标网站时不时调整前端代码的情况下。我不敢说所有场景都适合Java但在Java技术栈里做数据采集这套方案确实是最稳妥的一条路。以后要是聊到反爬对抗、数据调度按照这个思路继续往下做路会很顺。
返回列表