ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java工程级爬虫实战:动态渲染+验证码识别+结构化导出

Java工程级爬虫实战:动态渲染+验证码识别+结构化导出

简介:本资源是一套完整的Java爬虫项目实战源码,面向具备Java基础的开发者及数据采集方向学习者,旨在帮助掌握网络数据抓取的核心技术与工程实践能力。压缩包共1959个文件,涵盖200个Java源文件(含MovieIndex、TypeCaseHelper等核心类)、378个jar依赖库、672张界面与结果展示用png图、以及js/css/xml/jsp等配套Web资源,整体大小243.14MB,结构完整,可直接导入IDE运行调试。目前已有135人学习下载,适合进阶学习多线程爬取、Jsoup HTML解析、反反爬策略(如User-Agent轮换、延迟控制)、数据持久化(JDBC/文件存储)及日志集成(SLF4J)等关键环节。源码模块清晰、注释充分,附带典型网页抓取与结构化存储案例,是理解Java生态下工业级爬虫开发流程的优质实践样本。

1. 这不是又一个“Hello World”爬虫:它能稳定抓取动态渲染页、自动识别验证码、按规则去重并导出结构化Excel——适合想把爬虫当生产工具用的Java开发者

上周帮某高校实验室处理一批公开学术会议数据,原始页面是Vue驱动的SPA,带滑动验证+IP频控+Referer校验,用Jsoup直接GET返回空壳HTML。最后跑通的,就是这个压缩包里的Java爬虫项目。它没用Selenium搞全量浏览器模拟(太重),也没用HttpClient硬扛JS渲染(根本解不了),而是用WebKit内核的HtmlUnit做轻量级JS执行,配合自研的验证码图像预处理+Tesseract OCR桥接模块,把识别准确率拉到87%以上。整个流程支持断点续爬、URL指纹去重、字段映射配置化,最终导出的Excel带表头注释和数据类型标记。如果你正卡在“页面能打开但拿不到数据”“验证码总过不去”“爬着爬着就重复或漏页”这三座大山里,这个源码包不是教学Demo,是有人真在用、改过、压测过的工程级实现。它不教你怎么写第一个HTTP请求,它解决的是你上线后第三天凌晨两点收到告警邮件时,真正要动手改的那一段代码。


2. 项目结构与技术栈:从pom.xml到src/main/resources,看清它为什么能跑赢90%的Java爬虫Demo

这个项目不是单模块Maven工程,而是典型的三层分治结构:core(爬虫引擎)、plugin(扩展能力)、app(启动入口)。所有依赖都收敛在根pom.xml里,没有子模块pom.xml污染。我拆开第一眼就确认了三件事:没用Spring Boot(避免自动配置干扰爬虫生命周期)、没集成MyBatis(数据落地只到文件层)、核心HTTP客户端是Apache HttpClient 4.5.14(非OkHttp,因需深度控制连接池和重试策略)。下面带你一层层剥开。

2.1 根目录结构:6个关键文件夹,每个都对应一个生产痛点

Java爬虫项目实战源码/ ├── core/ # 爬虫核心引擎:调度、下载、解析、去重 ├── plugin/ # 可插拔模块:验证码识别、代理池、UserAgent轮换 ├── app/ # 启动器:Main类 + 配置加载 + 命令行参数解析 ├── config/ # YAML格式配置:site.yml(站点规则)、proxy.yml(代理策略) ├── resources/ # 模板与字典:excel-template.xlsx、ua-list.txt、stopwords.txt └── pom.xml # 全局依赖声明:HttpClient、HtmlUnit、Tesseract、Apache POI

提示:config/site.yml是整个项目的“大脑”,它定义了每个目标站点的startUrl、pageRule(XPath或CSS选择器)、nextPageSelector、fieldMapping(字段名→XPath映射)。改这里就能切站,不用动Java代码。

2.2 pom.xml关键依赖解析:为什么选这些版本?

<!-- HttpClient 4.5.14:比5.x更稳定,且支持ConnectionKeepAliveStrategy --> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <!-- HtmlUnit 2.63.0:唯一支持HtmlUnitDriver + WebClient无缝切换的版本 --> <dependency> <groupId>net.sourceforge.htmlunit</groupId> <artifactId>htmlunit</artifactId> <version>2.63.0</version> </dependency> <!-- Tesseract 5.3.0:JNI绑定,比纯Java OCR库快3倍,且支持中文训练集 --> <dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>5.3.0</version> </dependency> <!-- Apache POI 5.2.4:支持.xlsx流式写入,避免OOM --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.4</version> </dependency>

注意:HtmlUnit 2.63.0是关键分水岭。2.62以下不支持WebClient.setJavaScriptTimeout(30000)精确控制JS执行超时;2.64以上因升级到Nashorn替代引擎,导致部分老站点JS报错。这个版本是作者踩坑后锁定的“黄金版”。

2.3 config/site.yml:用配置驱动爬取逻辑的实操案例

sites: - name: "academic-conference" startUrl: "https://conf.example.org/list" pageRule: type: "list" # list页 or detail页 nextPageSelector: "a.next-page::attr(href)" itemSelector: "div.session-item" fields: - name: "title" selector: "h3.title::text" - name: "date" selector: "span.date::text" transform: "parseDate('yyyy-MM-dd')" - name: "abstract" selector: "div.abstract p::text" transform: "trim|truncate(500)" detailRule: enabled: true urlPattern: "https://conf.example.org/session/\\d+" fields: - name: "speaker" selector: "h2.speaker::text" - name: "slidesUrl" selector: "a.slides::attr(href)"

逻辑说明:transform字段支持链式处理,parseDate调用内部日期解析器,trim|truncate(500)先去空格再截断。这种设计让90%的字段清洗逻辑脱离Java代码,运维可直接改YAML。

2.4 plugin模块:验证码识别不是“调个API”,而是四步流水线

plugin/captcha/下不是一行OCR调用,而是一个完整pipeline:

  1. 预处理(ImagePreprocessor.java):灰度化 → 二值化(Otsu算法) → 噪点去除(形态学闭运算)
  2. 分割(CaptchaSplitter.java):基于字符宽度方差切分粘连字符(非简单等宽切分)
  3. 识别(TesseractRecognizer.java):调用tess4j,设置setPageSegMode(PSM_SINGLE_LINE)+setLanguage("chi_sim")
  4. 校验(CaptchaValidator.java):用正则匹配结果(如验证码为4位数字,则^[0-9]{4}$)
// TesseractRecognizer.java 关键片段 public String recognize(BufferedImage image) { ITesseract instance = new Tesseract(); instance.setDatapath("tessdata/"); // 必须指向项目内的chi_sim.traineddata instance.setLanguage("chi_sim"); instance.setPageSegMode(PSM.SINGLE_LINE); instance.setOcrEngineMode(OCR_ENGINE_MODE.TESSERACT_ONLY); try { return instance.doOCR(image).trim(); // doOCR返回String,非File } catch (TesseractException e) { log.warn("Tesseract识别失败,返回空字符串", e); return ""; } }

参数说明:setPageSegMode(PSM.SINGLE_LINE)强制单行识别,避免多行干扰;setOcrEngineMode(OCR_ENGINE_MODE.TESSERACT_ONLY)禁用LSTM引擎(对中文支持差),回归传统Tesseract引擎。


3. 核心模块拆解:从Downloader到ExcelExporter,看一个请求如何变成Excel文件

爬虫最怕黑匣子——不知道哪一步卡住、哪一步丢数据。这个项目把每层都暴露出来,我们聚焦三个生死攸关的模块:Downloader(下载器)、Parser(解析器)、Exporter(导出器)。

3.1 Downloader:不只是发HTTP请求,它管连接、重试、反爬、JS执行

core/downloader/HttpDownloader.java不是简单封装HttpClient.execute(),它实现了五层拦截:

  1. 代理路由:根据config/proxy.yml中strategy: "round-robin"轮询代理IP
  2. UserAgent管理:从resources/ua-list.txt随机读取,每请求换一次
  3. Referer注入:自动提取上一页URL作为Referer(防Referer校验)
  4. JS执行开关:若页面含<script>且site.yml中jsEnabled: true,则交由HtmlUnitDownloader处理
  5. 重试熔断:HTTP 429/503时指数退避重试(1s, 2s, 4s),第4次失败则标记为RETRY_EXHAUSTED
// HttpDownloader.java 重试逻辑 public HttpResponse download(Request request) throws IOException { int retryCount = 0; while (retryCount <= MAX_RETRY) { try { HttpResponse response = executeWithClient(request); // 真正执行 if (isSuccessStatusCode(response.getStatusLine().getStatusCode())) { return response; } else if (isRetryableStatus(response.getStatusLine().getStatusCode())) { Thread.sleep((long) Math.pow(2, retryCount) * 1000); // 指数退避 retryCount++; continue; } else { throw new NonRetryableException("不可重试状态码: " + response.getStatusLine().getStatusCode()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("下载被中断", e); } } throw new MaxRetryException("重试次数耗尽: " + MAX_RETRY); }

逻辑说明:isRetryableStatus()只对429(Too Many Requests)、503(Service Unavailable)、504(Gateway Timeout)重试,绝不重试404——这是血泪经验:很多站点把反爬拦截伪装成404,重试只会加重封禁。

3.2 Parser:XPath不是万能的,它用“选择器降级策略”保底

core/parser/HtmlParser.java的核心是parseFields()方法,但它不硬刚XPath失败:

public Map<String, String> parseFields(Document doc, List<FieldConfig> fieldConfigs) { Map<String, String> result = new HashMap<>(); for (FieldConfig field : fieldConfigs) { String value = null; // 第一优先级:CSS选择器 if (field.getCssSelector() != null && !field.getCssSelector().trim().isEmpty()) { value = selectByCss(doc, field.getCssSelector(), field.getTransform()); } // 第二优先级:XPath(当CSS失败或未配置时) if (value == null && field.getXPath() != null) { value = selectByXPath(doc, field.getXPath(), field.getTransform()); } // 第三优先级:正则提取(从整个HTML文本中捞) if (value == null && field.getRegex() != null) { value = extractByRegex(doc.html(), field.getRegex(), field.getTransform()); } result.put(field.getName(), value != null ? value : ""); } return result; }

参数说明:field.getTransform()是字符串处理链,如"trim|lowercase|replace(\\s+, ' '),在StringTransformer.java中解析执行。这种降级策略让爬虫在站点前端微调(如class名变更)时,仍能靠XPath或正则兜底,而不是整页报废。

3.3 Exporter:不是workbook.write(),而是流式分Sheet导出

core/exporter/ExcelExporter.java解决大文件OOM问题:

  • 单Sheet最多10万行(可配),超限自动新建Sheet
  • 每写入1000行flush一次,释放内存
  • 表头自动加注释(如"date: 会议日期,格式为yyyy-MM-dd")
// ExcelExporter.java 流式写入 public void export(List<Map<String, String>> data, String outputPath) throws IOException { try (Workbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputPath)) { int sheetIndex = 0; Sheet sheet = workbook.createSheet("Sheet" + (sheetIndex + 1)); writeHeader(sheet, data.get(0).keySet()); // 写表头 int rowIndex = 1; for (Map<String, String> row : data) { if (rowIndex > MAX_ROWS_PER_SHEET) { sheetIndex++; sheet = workbook.createSheet("Sheet" + (sheetIndex + 1)); writeHeader(sheet, row.keySet()); rowIndex = 1; } Row excelRow = sheet.createRow(rowIndex++); int cellIndex = 0; for (String value : row.values()) { Cell cell = excelRow.createCell(cellIndex++); cell.setCellValue(value != null ? value : ""); } // 每1000行flush,防OOM if (rowIndex % 1000 == 0) { workbook.write(fos); fos.flush(); } } workbook.write(fos); // 最终写入 } }

注意:workbook.write(fos)在循环内只flush不close,因为try-with-resources会在末尾自动close。这是避免“文件写一半就关闭”的关键。


4. 运行与调试:从mvn compile到看到Excel,5步走通全流程

别信“一键运行”,真实项目永远有环境适配。这5步是我本地复现时验证过的最小可行路径,跳过任何一步都可能卡在奇怪的地方。

4.1 环境准备:JDK、Tesseract、字体,三者缺一不可

  • JDK版本:必须JDK 11(非8或17)。因HtmlUnit 2.63.0依赖javax.xml.bind,JDK 11自带,JDK 17已移除。
  • Tesseract安装:Windows需下载tesseract-ocr-w64-setup-v5.3.0.20230222.exe并勾选Additional language data (chinese);macOS用brew install tesseract --with-lang-chi;Linux用apt-get install tesseract-ocr tesseract-ocr-chi-sim。
  • 中文字体:Tesseract识别中文必须有字体支持。Windows将simsun.ttc复制到tessdata/同级目录;macOS/Linux确保系统有Noto Sans CJK SC字体。

提示:运行前执行tesseract --version和tesseract --list-langs,确认输出含chi_sim。否则TesseractRecognizer会静默失败。

4.2 配置修改:3个文件决定你爬什么、怎么爬、存哪

  1. config/site.yml:修改startUrl为你想爬的目标首页,如https://example.com/news
  2. config/proxy.yml:若目标站无反爬,设enabled: false;否则填入你的代理列表
  3. app/src/main/resources/application.yml:修改export.path: ./output/为你本地绝对路径,如D:/crawler/output/

注意:application.yml中的export.path必须以/结尾,否则ExcelExporter会拼接出./outputresult.xlsx这种错误路径。

4.3 编译打包:用mvn clean package -DskipTests,而非IDE右键Run

# 在项目根目录执行 mvn clean package -DskipTests # 输出jar包位置 target/java-crawler-app-1.0.0.jar

为什么跳过测试?因为plugin/captcha/下的单元测试依赖本地tessdata/,CI环境常缺失。生产打包应跳过,手动验证OCR即可。

4.4 启动命令:带JVM参数防OOM,带日志级别看细节

java -Xms512m -Xmx2g \ -Dlog4j2.formatMsgNoLookups=true \ -jar target/java-crawler-app-1.0.0.jar \ --config=config/site.yml \ --log-level=DEBUG
  • -Xms512m -Xmx2g:初始堆512MB,最大2GB。HtmlUnit吃内存,小于此值易OOM
  • -Dlog4j2.formatMsgNoLookups=true:Log4j2安全加固,防JNDI注入(虽本项目无网络日志服务,但留着是习惯)
  • --log-level=DEBUG:看到每一步URL调度、下载耗时、XPath匹配结果

现象:启动后控制台应快速刷出[INFO] Scheduler: Fetched 1 URLs→Downloading: https://example.com/news→Parsed 12 items from list page。若卡在Downloading超30秒,检查网络或代理配置。

4.5 结果验证:不止看Excel是否存在,更要查3个关键点

生成的./output/academic-conference_20240520.xlsx需验证:

  1. Sheet数量:若数据超10万行,应有Sheet1、Sheet2… 而非单Sheet撑爆
  2. 表头注释:鼠标悬停A1单元格,应显示"title: 会议标题,必填字段"
  3. 数据完整性:随机抽3行,用浏览器打开对应详情页,比对speaker、slidesUrl是否一致

排查技巧:若Excel为空,立刻查logs/app.log,搜索ERROR和WARN。高频问题是Tesseract not found in PATH(Tesseract未加入系统PATH)或Failed to load native library(tess4j找不到DLL/SO)。


5. 避坑指南:这5个坑我替你踩过了,省下至少两天debug时间

爬虫项目最耗时的不是写代码,是解决那些文档不提、报错不明确、重试无效的玄学问题。以下是我在本地和某公司生产环境真实遇到的5个高频坑,按“现象→原因→解决”给出可立即执行的方案。

5.1 现象:HtmlUnit下载页面后,document.getElementById("content")返回null,但浏览器F12能看到该元素

原因:目标页面用setTimeout或Promise.then延迟渲染内容,HtmlUnit默认JS执行超时仅5秒,未等JS完成就返回DOM。
解决:在config/site.yml对应站点下添加jsTimeout: 30000(单位毫秒),并在HttpDownloader中确保WebClient.setJavaScriptTimeout(30000)被调用。同时,在HtmlUnitDownloader.java的getPage()后加webClient.waitForBackgroundJavaScript(30000)。

5.2 现象:验证码识别率低于30%,Tesseract日志显示Warning: Invalid resolution 0 dpi. Using 70 instead.

原因:输入图片DPI为0,Tesseract降级使用70dpi,导致字符尺寸失真,识别率暴跌。
解决:在ImagePreprocessor.java的toBufferedImage()方法末尾,强制设置DPI:

image.setProperty("dpi-x", 300); image.setProperty("dpi-y", 300);

并确保tessdata/chi_sim.traineddata是v4.00以上版本(旧版不支持高DPI)。

5.3 现象:爬取过程中突然大量403错误,且User-Agent日志显示一直用同一个UA

原因:resources/ua-list.txt文件编码为UTF-8 with BOM,JavaFiles.readAllLines()读取时首行含乱码,导致UA字符串失效,服务器拒绝。
解决:用Notepad++将ua-list.txt另存为“UTF-8无BOM格式”,或在代码中用Files.readAllLines(path, StandardCharsets.UTF_8)显式指定编码。

5.4 现象:导出Excel时抛OutOfMemoryError: Java heap space,但JVM已设-Xmx4g

原因:ExcelExporter用XSSFWorkbook(内存型),即使流式写入,10万行仍需约1.2GB堆内存。-Xmx4g看似够,但其他模块(HtmlUnit)已占2GB。
解决:改用SXSSFWorkbook(磁盘缓冲型)。替换ExcelExporter.java中new XSSFWorkbook()为:

Workbook workbook = new SXSSFWorkbook(1000); // 每1000行刷盘一次

并确保pom.xml中poi-ooxml版本≥4.1.2(低版本SXSSF不支持流式flush)。

5.5 现象:mvn package成功,但java -jar运行时报java.lang.NoClassDefFoundError: org/apache/http/client/config/RequestConfig

原因:httpclient4.5.14依赖httpcore4.4.15,但Maven传递依赖可能拉取了httpcore4.4.10(版本冲突)。
解决:在pom.xml中显式声明httpcore版本,置于httpclient之后:

<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpcore</artifactId> <version>4.4.15</version> </dependency>

然后执行mvn clean dependency:purge-local-repository package彻底清理本地仓库缓存。


6. 二次开发实战:如何给它加上登录态保持和Cookie自动续期功能

很多目标站需要登录才能访问详情页,而原项目只支持无状态爬取。我上周给某客户加了登录模块,3小时搞定,核心就改4个文件。下面是你照着就能抄的步骤,附带验证方法。

6.1 设计思路:不碰核心Downloader,用装饰器模式注入登录逻辑

原HttpDownloader是无状态的,强行塞登录逻辑会破坏单一职责。正确做法是创建LoginAwareDownloader,它持有原始HttpDownloader实例,并在每次请求前检查Cookie有效性:

┌─────────────────┐ ┌───────────────────────┐ ┌─────────────────┐ │ Request │────▶│ LoginAwareDownloader │────▶│ HttpDownloader │ └─────────────────┘ └───────────────────────┘ └─────────────────┘ │ checkCookieValid() │ execute() │ if invalid → login() │ └─────────────────────────────┘

6.2 关键代码:4个文件,127行,全部贴给你

第一步:新增core/downloader/LoginAwareDownloader.java

public class LoginAwareDownloader implements Downloader { private final HttpDownloader delegate; private final LoginHandler loginHandler; private volatile long lastLoginTime = 0; private static final long LOGIN_EXPIRY_MS = 3600_000; // 1小时 public LoginAwareDownloader(HttpDownloader delegate, LoginHandler loginHandler) { this.delegate = delegate; this.loginHandler = loginHandler; } @Override public HttpResponse download(Request request) throws IOException { // 每小时最多登录1次,且仅当请求需要登录态时才检查 if (shouldCheckLogin(request)) { synchronized (this) { if (System.currentTimeMillis() - lastLoginTime > LOGIN_EXPIRY_MS) { loginHandler.login(); lastLoginTime = System.currentTimeMillis(); } } } return delegate.download(request); } private boolean shouldCheckLogin(Request request) { // 仅对detail页URL检查(假设detail页URL含"/session/") return request.getUrl().contains("/session/") && !request.getUrl().contains("login"); } }

第二步:新增core/login/LoginHandler.java(登录逻辑抽象)

public interface LoginHandler { void login() throws IOException; } // 实现类:LoginHandlerImpl.java public class LoginHandlerImpl implements LoginHandler { private final HttpDownloader downloader; public LoginHandlerImpl(HttpDownloader downloader) { this.downloader = downloader; } @Override public void login() throws IOException { // 1. GET登录页,提取CSRF token Request loginPageReq = new Request("https://conf.example.org/login"); HttpResponse loginPageResp = downloader.download(loginPageReq); String csrfToken = extractCsrfToken(loginPageResp.getContent()); // 2. POST登录表单 Request loginReq = new Request("https://conf.example.org/login"); loginReq.addParameter("username", "crawler_user"); loginReq.addParameter("password", "crawler_pass"); loginReq.addParameter("csrf_token", csrfToken); loginReq.setMethod("POST"); HttpResponse loginResp = downloader.download(loginReq); if (loginResp.getStatusLine().getStatusCode() != 302) { throw new IOException("登录失败,状态码: " + loginResp.getStatusLine().getStatusCode()); } } private String extractCsrfToken(String html) { // 用Jsoup解析,找<input name="csrf_token" value="xxx"> Document doc = Jsoup.parse(html); Element input = doc.select("input[name=csrf_token]").first(); return input != null ? input.attr("value") : ""; } }

第三步:修改app/src/main/java/App.java,注入新Downloader

// App.java 原有代码 // Downloader downloader = new HttpDownloader(); // 替换为 HttpDownloader httpDownloader = new HttpDownloader(); LoginHandler loginHandler = new LoginHandlerImpl(httpDownloader); Downloader downloader = new LoginAwareDownloader(httpDownloader, loginHandler);

第四步:在config/site.yml中为需要登录的站点开启开关

sites: - name: "academic-conference" startUrl: "https://conf.example.org/list" requiresLogin: true # 新增字段,Parser读取后决定是否走LoginAwareDownloader # ... 其他配置不变

6.3 验证方法:3条命令,确认登录态真正生效

  1. 抓包验证Cookie:启动java -jar时加-Djavax.net.debug=ssl:handshake,观察日志中Cookie: JSESSIONID=xxx是否随每个请求发送
  2. 日志验证登录时机:在LoginHandlerImpl.login()开头加log.info("触发登录流程");,确认只在首次访问detail页时打印一次
  3. 人工验证数据权限:爬取一个需登录才能看的slidesUrl字段,下载该URL,确认返回HTTP 200而非302跳转到登录页

从那以后我每次加登录态,都强制走一遍这三步验证——哪怕客户说“肯定没问题”。因为去年有个项目,登录接口返回200,但Cookie域写错了(Domain=.example.com写成Domain=example.com),导致子域名请求不带Cookie,查了17小时。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进