ARTICLE DETAIL

资讯详情

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

Java Web信息采集系统实战:基于Spring Boot+HttpClient+Jsoup搭建

Java Web信息采集系统实战:基于Spring Boot+HttpClient+Jsoup搭建 Java Web信息采集系统这名字听起来有点学院派但说白了就是写一个能自己跑着抓网页、抽数据、存起来、再拿Web页面展示和查询的程序。我从零开始做了好几版从最早的纯ServletJDBC硬编码到后面Spring Boot HttpClient Jsoup的清爽组合中间踩过不少坑。这篇就把我自己实践下来的一套完整方案拆开讲目标读者是那种已经会写Java增删改查、但还没完整做过采集系统的朋友看完你能直接复刻一套能用的系统而不是停留在概念层面。先说清楚这套系统能干什么定时去目标网站抓取公开信息比如资讯列表、商品价格、行业数据把非结构化的HTML页面按照你定义的规则提取成结构化数据清洗后存入MySQL再通过一个Web管理后台去看数据、手动触发采集、查日志。整条链路从调度、抓取、解析、去重、入库到展示全都能跑通。1. 整体设计与技术选型1.1 系统定位它不是爬虫是一套带管理后台的采集服务很多人一上来就把信息采集等同于写个循环加正则抓到多少算多少跑完一次扔一边。这种思路应付一两个页面没问题但一旦目标站点有几十个频道、每天更新上千条数据你很快就会遇到三个问题重复数据怎么跳过、采集频率怎么控制、挂掉之后怎么恢复。所以我建议一开始就按服务而不是脚本来设计哪怕需求很小也值得多搭两层结构。我的定位是做一个内部使用的Java Web信息采集系统既要解决采集端的抓取和解析问题又要提供Web端的任务管理和数据检索能力。它不是一个公开发布的搜索引擎而是面向固定业务场景的采集服务平台。核心指标不是抓得有多快而是稳定、可追溯、不重复。采集到的每一条数据都有来源URL、采集时间、任务编号出问题能反查到是哪一次任务跑的。1.2 技术栈为什么是这三件套Spring Boot HttpClient Jsoup是我试下来最省心的一组搭配这也是我重新选型后确定的组合。Spring Boot负责把整个系统串起来定时任务、接口暴露、配置管理、数据库访问都靠它。Apache HttpClient负责发HTTP请求它比HttpURLConnection强在处理Cookie、连接池、重定向、超时控制这些细节上这些恰恰是采集最容易翻车的地方。Jsoup负责解析HTML它支持CSS Selector选择器写解析规则的时候非常直观语法跟jQuery类似比如document.select(div.news-list li a)就能拿一堆链接理解成本极低。存储我用的是MySQL配合Spring Data JPA或MyBatis都行。如果采集量级到日均百万条再考虑上Elasticsearch或者ClickHouse初期完全没必要。Redis我这里用来做URL去重和待抓取队列这是它的强项代替数据库去重能省下大量索引开销。提示HttpClient和Jsoup都是Apache生态里的老牌库稳定性经过大量项目验证选它们不是最酷的方案但肯定是最不会给你添麻烦的方案。1.3 分层架构采集逻辑和Web逻辑必须分开我的项目结构是这样的src/main/java ├── config # 配置类注册Bean、初始化连接池 ├── controller # Web接口层给前端页面调用 ├── service # 业务层采集任务管理与数据查询 ├── crawler # 采集核心抓取、解析、调度都在这里 │ ├── fetch # HTTP请求组件 │ ├── parse # 页面解析器 │ ├── pipeline # 数据持久化管道 │ └── scheduler # 任务调度逻辑 ├── repository # 数据访问层 ├── entity # 实体类 └── task # 定时任务入口这个分层的核心原则是crawler包内部不依赖Spring MVC的任何东西controller也不直接碰HttpClient。这样做的最大好处是采集逻辑可以单独用main方法或单元测试来跑调试的时候不用起Web容器而且以后如果要把采集模块拆成独立服务分界线是现成的。别小看这一层划分。我第一版就是把抓网页的代码写在Controller里结果每次调接口都要重新初始化连接池测试的时候就特别痛苦运行一段时间内存还老涨。后来一拆干净问题全没了。2. 核心模块设计与实现原理2.1 抓取层一场和反爬机制的攻防战抓取层是整个系统的门面也是一开始最容易失败的地方。用HttpClient发一个GET请求拿HTML听起来很简单实际操作中你会陆续撞上各种拦截策略。我把自己常用的配置整理成了下面这套Bean public CloseableHttpClient httpClient() { RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(10000) .setConnectionRequestTimeout(3000) .setRedirectsEnabled(true) .build(); return HttpClients.custom() .setDefaultRequestConfig(config) .setConnectionManager(poolingConnectionManager()) .setDefaultHeaders(defaultHeaders()) .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true)) .build(); }连接池我设置了最大200个连接每个路由最大50个这样采集多频道时不会因为连接不够而排队等待。但我实际踩过一个坑连接池给得再大目标网站服务器每秒能接受的连接数也只有那么多。如果你用20个线程疯狂抓同一个站点哪怕本机网络再好服务器还是会给你返回503。所以我在抓取层加了一个关键组件——请求限速器。它的实现思想是令牌桶每个目标域名维护一个独立的令牌桶每秒补充N个令牌每次请求需要先获取令牌才能执行。这样同一个域名的请求频率就被锁死了而不同域名之间互不影响。实测下来把频率控制在每秒1-3个请求大部分正常站点都不会跟你翻脸。2.2 请求头与Cookie策略你有没有遇到过这种情况浏览器里打开好好的页面用Java代码请求却返回403或一个验证页面十有八九是请求头没伪装好。目标服务器会通过User-Agent判断你是浏览器还是脚本通过Referer判断请求是不是从站内发起的通过Accept-Language判断语言环境。我的默认请求头是这样组织的private ListBasicHeader defaultHeaders() { ListBasicHeader headers new ArrayList(); headers.add(new BasicHeader(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36)); headers.add(new BasicHeader(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8)); headers.add(new BasicHeader(Accept-Language, zh-CN,zh;q0.9,en;q0.8)); headers.add(new BasicHeader(Connection, keep-alive)); return headers; }这里有个细节值得注意User-Agent不要写死而是准备一组浏览器UA列表每次请求随机取一个。原因很简单如果你一直用同一个UA高强度访问UA本身就可能变成一个特征。我维护了一个包含Firefox、Chrome、Safari不同版本UA的列表按随机数取实测比固定UA稳定不少。Cookie策略也要分级处理。有些站点只要求首次访问种一个会话Cookie后续请求带不带无所谓。有些站点则需要你先访问首页拿到Cookie再去访问目标页面否则直接拦截。所以我的抓取层里有一个CookieStore第一次访问首页时会自动存下Set-Cookie后续请求自动带上。如果遇到登录后才能看的数据就需要在代码里模拟登录接口把登录后的Cookie存起来这属于进阶话题这里先提个思路。2.3 解析层Jsoup的规则引擎解析层是决定系统灵活性的地方。同一个系统可能要同时采集几个完全不同风格的站点如果每个站点的解析逻辑都写死在一个方法里又臭又长不说改一个站点还要重新编译部署。我更推荐给Parser设计一个配置化的结构每种站点类型对应一个Parser实现通过Strategy模式注入。其中关键的工具就是Jsoup的选择器。举个例子一个典型的新闻列表页div classnews-list ul lia href/news/123.html标题一/aspan2024-03-15/span/li lia href/news/124.html标题二/aspan2024-03-14/span/li /ul /div对应的提取代码就三行Document doc Jsoup.parse(html, baseUrl); Elements items doc.select(.news-list li); for (Element item : items) { String title item.select(a).text(); String url item.select(a).attr(abs:href); String date item.select(span).text(); }注意这里我用的是attr(abs:href)而不是attr(href)Jsoup会自动把相对路径拼成绝对URL省去了自己处理相对路径的麻烦。这是很多新手会忽略的细节看起来小却经常导致入库的URL是残缺的。解析规则抽象出来后我通常会在每个Parser里定义一个isMatch(String url)方法调度器通过URL自动选择合适的Parser这样新增一个采集源时只需要新写一个Parser类并注册进去不用动现有代码。2.4 去重与队列不抓重复数据的关键采集系统的数据质量一半取决于解析对没对一半取决于去重做没做好。我第一版用MySQL的UNIQUE索引来去重数据量小的时候没问题但索引一多插入效率明显下降而且对数据库压力很大。后来改成了Redis Set做URL去重思路很直接每次抓到一个新URL执行SADD visited:urls {url}如果返回1说明是新的可以继续处理返回0说明已经抓过了直接丢弃。对于千万级的URL量级Redis的Set照样能扛得住。待抓取队列我用Redis List实现左侧入队右侧出队// 入队 redisTemplate.opsForList().leftPush(crawler:todo, url); // 出队 String url redisTemplate.opsForList().rightPop(crawler:todo, 10, TimeUnit.SECONDS);rightPop带阻塞超时时间是关键队列为空时线程不会死等而是等待10秒后返回null配合定时任务调度就很平滑。这套方案还有个额外的好处系统重启后队列数据还在Redis里不会因为进程退出而丢失进度。2.5 遇到动态渲染页面怎么办这个可以说是采集系统最头疼的挑战。现在很多网站的内容不是直接写在HTML里的而是页面加载后通过JavaScript异步请求接口再渲染出来的。用Jsoup直接抓拿到的是一个空壳里面什么都没有。我总结出两条路。第一条是抓它的Json接口。在浏览器开发者工具里的Network面板选择XHR选项卡刷新页面就能看到实际的JSON接口地址。这种接口往往直接返回结构化数据解析成本比HTML还低。但缺点是接口可能带签名参数需要逆向。第二条路是引入无头浏览器。用PlayDev或Selenium启动一个无头Chrome等页面渲染完成后再去取DOM内容。这个方案基本能解决所有动态渲染问题但代价是性能和资源消耗都大很多单机并发数也上不去。我的建议是优先找JSON接口实在找不到才用无头浏览器并且把无头浏览器单独做成一个代理服务不要让主采集进程去启动浏览器实例。3. 实操过程与核心代码实现3.1 环境准备与项目初始化我假设你已经在本机配好了JDK 17、Maven 3.8和MySQL 8.0。环境变量配置这里不多讲了只提醒一句JAVA_HOME的路径里不要带空格某些Windows机器上这个坑能让你怀疑人生。用Spring Initializr创建项目依赖选择Spring Web、Spring Data JPA、MySQL Driver、Validation然后在pom.xml里额外加上Jsoup和HttpClientdependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdorg.apache.httpcomponents.client5/groupId artifactIdhttpclient5/artifactId version5.3.1/version /dependency注意这里用的是HttpClient 5.x版本API和4.x有一些出入网上很多老教程是4.x的写法包里多了一个classic不要直接抄错。数据库我建了四张表采集源表crawl_source、任务表crawl_task、数据表crawl_data、日志表crawl_log。数据表是核心字段包括标题、内容、URL、来源站点、发布时间、入库时间。其中URL建了唯一索引给Redis去重做兜底万一Redis数据被清了数据库这一层还能挡住重复插入。3.2 编写可运行的抓取调度器调度是采集系统的发动机。我采用的是经典的单机多线程任务队列模式。一个调度线程负责从Redis队列取URL分发给Worker线程池去执行抓取和解析。Component public class CrawlScheduler { private static final int WORKER_COUNT 5; private final ExecutorService workerPool Executors.newFixedThreadPool(WORKER_COUNT); Scheduled(fixedDelay 1000) public void schedule() { int activeCount ((ThreadPoolExecutor) workerPool).getActiveCount(); if (activeCount WORKER_COUNT) { return; } String url redisTemplate.opsForList().rightPop(crawler:todo, 1, TimeUnit.SECONDS); if (url ! null) { workerPool.submit(new CrawlTask(url)); } } }这个实现的妙处在于用getActiveCount()做背压判断线程池满了就跳过这一轮调度不会无限往队列塞任务。5个线程听起来不多但配合限速器足够稳定地采集中小型站点。如果你的机器配置高、目标站点带宽大可以适当调大但不要超过10个否则很容易触发目标站点的风控。3.3 页面抓取和解析入库一个完整的工作线程内部执行链路是这样的发送请求拿到HTML解析出目标字段查重然后入库。代码骨架如下public class CrawlTask implements Runnable { private final String url; Override public void run() { try { String html fetchService.get(url); if (html null || html.isEmpty()) { return; } OptionalDataItem itemOpt parseService.parse(url, html); if (itemOpt.isPresent()) { DataItem item itemOpt.get(); if (deduplicateService.isDuplicate(item.getUrl())) { return; } dataRepository.save(item); deduplicateService.markVisited(item.getUrl()); logService.logSuccess(url, item.getTitle()); } } catch (Exception e) { logService.logError(url, e.getMessage()); // 加入重试队列 retryService.retryLater(url, 3); } } }这里有一个容易忽略的顺序问题一定要先查重再保存而不是先保存再查重。如果先保存你用唯一索引兜底也行但会白白产生一次无效的数据库写入如果先查重再保存Redis的SADD返回的布尔值本身就可以直接当作查重结果还能顺手做标记性能会好很多。入库时还有一个值得处理的细节内容清洗。从HTML提取出来的文本里经常混着nbsp;、换行、广告链接尾巴我在保存前过一遍正则清理String cleanText rawText .replace(\u00A0, ) .replaceAll([^], ) .replaceAll(\\s{2,}, ) .trim();这段处理看起来很简单但能救回一半以上的脏数据。很多采集系统的数据拿出去没法用就是少了这一步。3.4 Web查询接口与后台页面采集端再强悍如果没有一个能查数据和操作任务的界面这个系统实际使用价值就要打对折。我用Spring MVC暴露了一组REST接口配合一个简单的Bootstrap单页来做管理和查看。接口设计遵循这样一个原则数据查询和任务触发分开。数据查询接口直接查MySQL支持关键词模糊搜索、来源筛选、时间范围筛选。任务触发接口用于手动开启采集RestController RequestMapping(/api/task) public class CrawlTaskController { PostMapping(/start) public Result startTask(RequestBody StartTaskRequest request) { // 校验数据源是否存在 CrawlSource source sourceService.getById(request.getSourceId()); if (source null) { return Result.error(采集源不存在); } // 入队起始URL crawlQueueService.push(source.getEntryUrl()); taskService.createTask(source.getId(), 手动触发); return Result.success(); } GetMapping(/list) public Result taskList(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 20) int size) { return Result.success(taskService.pageQuery(page, size)); } }页面上我最关心两个信息块今天采集了多少条、失败了多少条、最近十条失败的日志是什么。这两个就能快速判断系统有没有在正常工作。如果再配一个按小时统计采集量的折线图基本上运营人员每天只要扫一眼图表就能发现问题。4. 常见问题与排查技巧实录4.1 抓回来是乱码怎么办乱码问题十有八九是字符集判断错了。早期很多站点用GBK编码新站点绝大多数是UTF-8。死板的做法是写死一个字符集但那样换个站点就挂了。正确的做法是先看HTTP响应头里的Content-Type声明的是什么字符集没有的话再解析HTML的meta charset标签最后才是默认UTF-8。Jsoup的parse(InputStream in, String charsetName, String baseUri)方法可以指定编码配合字节流使用更可靠。// 先把响应字节读取到内存再判断编码 byte[] bytes EntityUtils.toByteArray(entity); String charset utf-8; // 从Content-Type头中解析charset ContentType contentType ContentType.get(entity); if (contentType ! null contentType.getCharset() ! null) { charset contentType.getCharset().name(); } String html new String(bytes, charset);注意如果先EntityUtils.toString(entity)再交给Jsoup解析实际上已经把字节转成字符串了再用Jsoup的parse(String)就不会重新识别编码此时如果编码推断错了乱码就产生了。所以正确顺序是先拿字节流再确定编码最后转字符串。4.2 SSL握手失败和HTTP状态码排查采集HTTPS站点时经常遇到SSLHandshakeException大部分原因是站点的SSL证书不被JDK信任比如自签名证书、证书链不完整。有两个选择一是把证书导入JDK的cacerts信任库适合公司内部系统二是用代码绕过证书校验适合快速开发但生产环境不推荐。如果目标网站频繁返回429或503恭喜你被限流了。这时候不要硬刚首要任务是降低抓取频率而不是换IP。我见过太多人一遇到429就疯狂上代理IP结果把免费代理的地址用一遍反而把自己的出口IP标记得更严重。正确做法是先观察响应头里的Retry-After字段按服务器要求的时间退避同时把并发降下来。如果业务确实需要高频抓取再考虑接入代理IP池但这是一个长期优化过程。4.3 数据重复和漏数据的矛盾处理这个矛盾有点意思为了去重你可能会遗漏一些本身就会重复出现的页面。比如有些网站的文章列表页首页和分页第二页都挂了同一篇文章如果你单一地按URL去重没问题第二次出现的直接跳过。但如果这个页面本身内容会更新比如最新热门排行榜页面URL始终是同一个内容每次不同按URL去重就会丢弃更新。应对方案是区分列表页和详情页。列表页不允许去重每次都抓但从列表页提取到的详情页URL要加入待抓队列时详情页才走URL去重。实现上就是在解析结果里打一个pageType标记调度器根据标记决定走哪条处理链路。4.4 常见错误速查表现象常见原因处理建议返回403 Forbidden请求头不够完整被识别为脚本补全UA、Referer、Accept请求头返回302循环或跳到登录页缺少Cookie或会话失效检查CookieStore必要时重置会话返回200但HTML为空壳数据是JS动态渲染的改抓JSON接口或引入无头浏览器Jsoup解析结果为空页面结构变动或选择器写错先用浏览器检查实际DOM再更新选择器采集一段时间后变慢线程池或连接池沾满检查是否有连接未释放、任务队列堆积数据库插入死锁并发写同一张表且事务过长批量入库、缩短事务边界还有一类问题很隐蔽日志里出现Connection reset代码看着哪里都正常但请求就是失败。这种情况倾向于目标站点做了连接复用限制或你的连接池里保留了过期的长连接。我的解法是定期关闭空闲连接检测到异常时主动evict掉对应连接而不是无限重试。5. 关于稳定性与合规性的几点实操建议最后再多说几句这套系统跑起来只是第一步跑得稳才是关键。我个人在维护过程中一个很深的体会是采集系统最怕的不是目标站反爬而是自己把自己搞崩。JVM内存溢出、数据库连接被占满、任务队列无限堆积这些问题比反爬更频繁。建议给采集线程池加任务拒绝对策队列满了就把新任务放到Redis里而不是丢弃数据库层面一定要加连接池最大等待时间宁可抓取慢一点也别让定时任务把数据库打垮。合规性这里也要多说一句。采集公开信息本身不是问题但要注意几个边界遵守目标站点的robots协议约束控制请求频率避免影响对方服务器正常服务不采集个人隐私信息、不涉及版权的完整内容转载。这套系统设计成按需、低频率、定点采集本身也是一种对目标站点的尊重。我自己的习惯是采集前会看一眼目标网站的服务条款不越界使用数据。在Java环境、Spring版本升级这件事上也有个经验采集系统依赖的HttpClient、Jsoup这类库版本升级比较激进新版本API变化不小升级前务必在测试环境把老任务全部跑一遍。我吃过一次亏升级HttpClient 4.x到5.x后一堆设置连接超时的代码直接编译不过连带Cookie策略也变了排查了半天。所以生产环境不要追新稳定版本用半年以上再考虑升级。这套Java Web信息采集系统目前的形态已经满足了我日常90%的采集需求。如果你打算自己动手做一个建议先把抓取和解析这两条链路跑通再补任务调度和Web展示不要一上来就设计分布式采集、断点续爬这种大而全的功能。先把一个站点采明白、存干净、能查询后面扩展其他站点就是做加法的事。最后再分享一个小技巧给每一个采集源单独建一个配置文件或者数据库记录不要把所有采集规则写在代码常量里改为配置化之后新增采集源的成本会从改代码发版直接降到填一行配置这个改动带来的爽感谁用谁知道。
返回列表