Java实现巨潮A股公告爬虫:PDF解析与增量打包实践
2026/9/13 14:09:43 网站建设 项目流程

简介:面向Java开发者的A股公告数据采集实战项目,覆盖从巨潮资讯网批量抓取公告、解析PDF正文到数据入库的完整流程。压缩包共30个文件、约7.16MB,核心由4个Java类(分别处理下载请求、页面解析、JDBC存储与主流程)和15个JAR依赖库(HTTP通信、PDFBox文本抽取、JSON与数据库连接等)组成,另有7个XML工程配置、说明文档和参数配置文件,目录清晰,便于按模块对照学习。项目内容串联爬虫与PDF处理的常见技术点,包括动态加载应对、反爬策略、多线程并发、IO与异常处理,同时给出可运行的项目结构和依赖清单,能够帮助初学者从零构建完整的爬虫工程,也可供中级开发者参考其模块划分与代码组织方式。目前已有183人学习/下载。

1. 巨潮A股公告爬虫,为什么值得用 Java 重建一遍

“巨潮A股公告爬虫 pdf简单解析 Java.zip”这个标题看起来是个待下载的压缩包,其实是条完整数据链路:从巨潮资讯网抓取A股公告发布列表,把公告正文从 PDF 解析成文本,最后按批次打包成 zip 交付。真实痛点不在网络请求,而在 PDF 文本抽取质量,巨潮的公告多数由券商或上市公司上传,排版引擎五花八门,PDFBox 拿到的文本顺序偶尔会乱,甚至直接没有文本层。下面给出一套可以复用的 Java 实现:列表请求用 OkHttp,JSON 用 fastjson2,PDF 抽取用 PDFBox,打包用 ZipOutputStream,重点讲清接口参数怎么设、PDF 解析边界在哪、增量去重怎么落地。适合量化投研的数据工程师,也适合被“全市场公告转文本”需求反复找上门的 Java 后端。全量抓取往往不是第一版目标,稳定抓完昨天发布的一两千条公告并产出可检索文本,才是这个项目真正要解决的问题。

2. 公告列表抓取:hisAnnouncement/query 的参数、分页与 OkHttp 并发

2.1 列表接口返回结构与关键参数

巨潮资讯网的公告搜索页面虽多,后端基本都走同一个 POST 接口hisAnnouncement/query,表单提交,JSON 返回,前端页面和爬虫共用这一套协议。先看请求参数里最重要的几个字段:

参数全市场常用值作用
pageNum1页码,从 1 开始递增
pageSize30每页条数,我一般不超过 50
columnall / szse / sse / bjse交易所维度,all 与 plate 搭配更稳
platesz / sh / bj板块过滤,留空表示全板块
tabNamefulltext全文检索模式
category公告分类,空为全部
trade交易状态过滤,默认全量
seDate2025-01-01~2025-01-31按披露日期区间过滤
searchkey关键词,按标题或全文检索
stock股票代码,留空则全部证券

注意seDate是“披露日期”而非公告涉及的事件日期,增量抓取要用自然日维度,这与很多业务系统的“交易日历”存在偏差。又一关键点是columnplate的组合:column=all&plate=通常能返回全市场,但某些查询入口对空板块解析不友好,保险写法是拆成szsessebjse三个请求分别跑,最后再合并,代价只是多几次请求而已。

返回 JSON 的大致结构如下:

{ "totalAnnouncement": 23575, "hasMore": true, "announcements": [ { "announcementId": "1220157120", "secCode": "300750", "secName": "宁德时代", "announcementTitle": "宁德时代:2023年年度权益分派实施公告", "announcementTime": 1718560800000, "adjunctUrl": "finalpage/2024-06-19/1220157120.PDF" } ] }

announcementId是全文唯一主键,用long存储即可,后续去重、幂等都围绕它展开。adjunctUrl是 PDF 在静态资源服务器上的相对路径,需要拼接https://static.cninfo.com.cn/才能下载。分页循环的终止条件有两个判断位:hasMore=false或本页announcements数组为空,两个条件同时判断,避免接口偶尔把hasMore字段省略时程序陷入死循环。

2.2 OkHttp 请求构造与响应解析

先引入三个依赖,版本号选择当前稳定线即可,Maven 坐标如下:

<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.30</version> </dependency> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.46</version> </dependency>

组装查询请求时,默认就带上浏览器同款请求头,减少被静态防火墙拒绝的概率:

String queryUrl = "https://www.cninfo.com.cn/new/hisAnnouncement/query"; OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); FormBody formBody = new FormBody.Builder() .add("pageNum", "1") .add("pageSize", "30") .add("column", "all") .add("tabName", "fulltext") .add("plate", "") .add("category", "") .add("trade", "") .add("seDate", "2025-03-01~2025-03-10") .add("searchkey", "") .add("secid", "") .add("sortName", "") .add("sortType", "") .add("isHLtitle", "true") .build(); Request request = new Request.Builder() .url(queryUrl) .post(formBody) .addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .addHeader("Referer", "https://www.cninfo.com.cn/new/fulltextSearch") .addHeader("Origin", "https://www.cninfo.com.cn") .addHeader("Accept", "application/json, text/javascript, */*; q=0.01") .build();

FormBody的参数顺序没有影响,但Content-Type必须是application/x-www-form-urlencoded,OkHttp 会自动处理。User-AgentReferer缺失时,接口偶尔会返回 200 但内容是 HTML 错误页,建议固定写入。响应解析用 fastjson2 读取:

public static List<JSONObject> queryAnnouncements(OkHttpClient client, int pageNum, String seDate) throws IOException { FormBody body = new FormBody.Builder() .add("pageNum", String.valueOf(pageNum)) .add("pageSize", "30") .add("column", "all") .add("tabName", "fulltext") .add("plate", "") .add("seDate", seDate) .build(); Request request = new Request.Builder() .url(queryUrl) .post(body) .addHeader("User-Agent", UA) .addHeader("Referer", "https://www.cninfo.com.cn/new/fulltextSearch") .build(); try (Response response = client.newCall(request).execute()) { String text = response.body() != null ? response.body().string() : ""; JSONObject root = JSON.parseObject(text); JSONArray list = root == null ? null : root.getJSONArray("announcements"); if (list == null || list.isEmpty()) { return Collections.emptyList(); } return list.stream().map(item -> (JSONObject) item).toList(); } }

toList()需要 JDK 16 以上,如果团队还是 JDK 8,改成循环add即可。这一步已经把公告元数据中的代码、标题、时间戳、PDF 路径全部拿到,后续下载 PDF 不需要再访问页面。

2.3 并发线程数与退避重试

并发设计到底哪个好,在这个项目里没有太多悬念:巨潮接口对单 IP 有频控,开 10 个线程以上短时间抓取,大概率触发验证码或者直接断连。我的经验是固定 4 到 6 个线程,单线程跑一遍耗时太久,6 线程通常能压到单线程的 3 倍速度,再往上收益递减。分页串行、下载 PDF 并行,是更稳的组合。

ExecutorService pool = Executors.newFixedThreadPool(6); AtomicInteger inFlight = new AtomicInteger(0); for (int page = 1; ; page++) { List<JSONObject> batch = queryAnnouncements(client, page, seDate); if (batch.isEmpty()) break; batch.forEach(item -> { pool.submit(() -> downloadAndParse(item)); }); if (inFlight.get() > 30) { Thread.sleep(2000); } }

注意Thread.sleep放在循环体内能起到类似令牌桶的作用,限制瞬间积压的下载任务。真正的重试应该针对 HTTP 层,403、429 状态码或 JSON 体里出现error字段时,用指数退避重试:

public static String postWithRetry(OkHttpClient client, Request request, int maxRetry) throws IOException { for (int i = 0; i < maxRetry; i++) { try (Response resp = client.newCall(request).execute()) { if (resp.code() == 200 && resp.body() != null) { return resp.body().string(); } } if (i < maxRetry - 1) { long waitMillis = 1000L * (i + 1); try { Thread.sleep(waitMillis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } throw new IOException("请求超过最大重试次数"); }

退避时间从 1 秒翻倍到 2 秒、4 秒,最大可以设到 16 秒。重试时不要改 URL 和表单参数,保持幂等。

3. 公告 PDF 的下载与正文解析:PDFBox 的中文与扫描件边界

3.1 从 adjunctUrl 拼出直链并落盘

公告列表返回的adjunctUrl形如finalpage/2024-06-19/1220157120.PDF,这是静态资源服务器上的相对路径。拼接下载地址:

public static Path downloadPdf(OkHttpClient client, JSONObject item, Path outputDir) throws IOException { String relativeUrl = item.getString("adjunctUrl"); String downloadUrl = "https://static.cninfo.com.cn/" + relativeUrl; String secCode = item.getString("secCode"); String fileName = secCode + "_" + item.getLong("announcementId") + ".pdf"; Path target = outputDir.resolve(fileName); Request request = new Request.Builder().url(downloadUrl).build(); try (Response resp = client.newCall(request).execute()) { if (resp.code() != 200 || resp.body() == null) { throw new IOException("PDF 下载失败, code=" + resp.code()); } try (InputStream in = resp.body().byteStream()) { Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); } } return target; }

文件名不要拼接公告标题,巨潮的标题经常包含空格、斜杠、冒号,直接当文件名会触发InvalidPathException。用secCode + announcementId最稳妥,announcementId本身保证了唯一性。下载完成后顺手检查文件大小,低于 1KB 的 PDF 大概率是占位文件,可以直接标记为失败。

3.2 用 PDFTextStripper 抽取正文文本

PDFBox 是 Java 生态里处理 PDF 的基础库,解析公告正文的核心类是PDFTextStripper,它能把带文本层的 PDF 页面内容按阅读顺序提取出来。代码很简单,但有几个参数值得说明:

public static String extractText(File pdf) { try (PDDocument doc = Loader.loadPDF(pdf)) { PDFTextStripper stripper = new PDFTextStripper(); // 按坐标排序文本,尽量保留从上到下、从左到右的顺序 stripper.setSortByPosition(true); stripper.setStartPage(1); stripper.setEndPage(doc.getNumberOfPages()); String text = stripper.getText(doc); return cleanText(text); } catch (IOException e) { return ""; } } private static String cleanText(String raw) { if (raw == null || raw.isBlank()) { return ""; } return raw.replaceAll("\\u0000", "") .replaceAll("[\\t ]+", " ") .replaceAll("\\r?\\n{3,}", "\n\n") .trim(); }

setSortByPosition(true)对巨潮的公告很重要,很多公告是双栏或多栏排版,不排序会出现左右两栏文本交错。cleanText里的规则是基于公告 PDF 的常见脏数据写的:空字符是 PDFBox 解析某些 CID 字体时产生的,连续空行是表格单元格解析后残留的。关于库选型可以看下面这个对比:

方案适合场景本项目用法
PDFBox抽取纯文本、处理书签、页面级操作主解析,兼顾 JDK 8
tabula-java抽取表格数据,依赖 PDFBoxPDFBox 抽不出表格时再做二次处理
iText 7生成 PDF,许可证对商用有要求不建议在爬虫项目引入

PDFBox 的局限是它对没有文本层的扫描件无能为力,getText返回空字符串,这种情况在巨潮的年度报告 PDF 中有一定比例,需要做降级处理。

3.3 图片型 PDF 与乱码文本的兜底策略

如果extractText返回空字符串,说明 PDF 是纯图片,没有文本层。此时“简单解析”的正确策略不是硬上 OCR,而是把文件移动到独立目录,记入解析失败日志,等后续需要时再单独识别。这样能保证主流程不被某个大体积扫描件拖死。检测乱码则更隐蔽,PDFBox 抽取中文偶尔会把字体编码映射错,生成一堆特殊字符,需要从文本特征判断:

public static boolean looksGarbled(String text) { if (text.isEmpty()) { return false; } int badCount = 0; for (char c : text.toCharArray()) { if (c == '\uFFFD' || c == '\u001A' || c == '\ufffe') { badCount++; } } return (double) badCount / text.length() > 0.03; }

阈值 3% 比较保守,正常中文文本几乎不会出现替换字符。这个函数在文本抽取后立刻执行,凡是判定为乱码的 PDF 同样进入“待人工复核”目录,而不是直接写进 zip 产物,避免下游检索系统被脏数据污染。

4. 增量抓取与 zip 打包:状态文件、去重与中文文件名编码

4.1 增量窗口与公告 ID 去重

全量重跑不是好方案,巨潮的公告量逐年变大,重复下载浪费带宽也容易触发频控。增量抓取的常见做法是维护一个状态文件,存上次成功抓取的最大披露日期。每次启动时读取该日期,将抓取区间设为lastDate ~ today

Path stateFile = Paths.get("last_sync_date.txt"); String lastDate = Files.exists(stateFile) ? Files.readString(stateFile).trim() : "2025-01-01"; String today = LocalDate.now().toString(); String seDate = lastDate + "~" + today;

这里有个细节:lastDate当天抓过一次,但巨潮可能当天盘后继续补发公告,因此下次运行时seDate起点要回退到lastDate,并在内存里用Set<Long>announcementId去重。日期窗口只是过滤条件,最终的去重依据是公告 ID:

Set<Long> seenIds = new HashSet<>(); // 每个公告进入处理队列前先判重 if (!seenIds.add(item.getLong("announcementId"))) { return; }

Set.add返回 false 即代表集合中已存在,这一条就把重复下载挡掉了。状态文件的更新时机也应放在整个批次的末尾,且只在默默环节全部成功后写入,不能抓到一半就改状态,否则中途进程被杀后会有窗口遗漏。

4.2 ZipOutputStream 打包与文件名清洗

解析结果最终要按批次打成 zip 包。Java 标准库的ZipOutputStream足够用,不需要引入额外依赖。中文文件名在 zip 里是一个容易踩坑的点,Windows 资源管理器默认按本地编码读取入口名,如果直接用 UTF-8 写入,部分版本会显示乱码。为兼容 Windows,常见做法是使用 GBK 编码写入ZipEntry

public static void buildZip(List<ParsedRecord> records, Path zipPath) throws IOException { try (ZipOutputStream zos = new ZipOutputStream( Files.newOutputStream(zipPath), Charset.forName("GBK"))) { zos.setLevel(6); for (ParsedRecord r : records) { String entryName = r.secCode() + "_" + r.tradeDate() + "_" + sanitizeFileName(r.title()) + ".txt"; zos.putNextEntry(new ZipEntry(entryName)); zos.write(r.content().getBytes(StandardCharsets.UTF_8)); zos.closeEntry(); } } } private static String sanitizeFileName(String title) { String cleaned = title.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); if (cleaned.length() > 40) { cleaned = cleaned.substring(0, 40); } return cleaned; }

Compression level 6 是默认值,0表示不压缩,9压缩率最高但明显消耗 CPU,公告文本按纯文本存储压缩率可达 80%,选 6 足够。文本内容本身用 UTF-8 写入,因为 txt 文件内部编码与 zip 入口名编码是两回事,正文用 UTF-8 能保证各种系统打开 txt 时都正确。sanitizeFileName把标题里的非法字符统一替换成下划线,并截断至 40 字符,避免极端长标题导致文件系统路径超限。

4.3 多线程结果收敛的线程安全

并行下载解析时,各线程产出的结果不能直接往同一个ArrayList里写。推荐用并发队列收集,主线程在任务全部提交后等待队列元素到达即可。简要流程如下:

ConcurrentLinkedQueue<ParsedRecord> queue = new ConcurrentLinkedQueue<>(); // 每个解析线程结束后把结果放入队列 queue.offer(new ParsedRecord(code, date, title, text)); // 提交完所有任务后,从队列轮询消费 List<ParsedRecord> records = new ArrayList<>(); ParsedRecord rec; while ((rec = queue.poll()) != null) { records.add(rec); }

ConcurrentLinkedQueue无界但安全,poll拿空时返回 null,实际使用时需要统计已提交任务数,所有任务完成后才能开始打包,否则会有漏包风险。

5. 验证与排错:抓取量、解析率、坏件目录三个技巧

5.1 两个可量化指标:解析成功率与文本产出比

抓取结束不能直接宣布完工,先看两个指标。解析成功率等于“成功抽取文本的 PDF 数 / 下载成功的 PDF 总数”,巨潮公告正常情况应高于 90%,低于这个值说明大量扫描件混入,或者 PDFBox 对某类字体解析异常。文本产出比等于“解析出的字符数 / PDF 文件字节数”,用于甄别解析质量:

产出比范围可能原因处理方式
低于 1%扫描图片型 PDF移入 bad 目录
1% - 8%正常公告文本进入 zip 产物
高于 15%文本重复或含隐藏水印抽样检查后决定

这个比例是经验值,不同证券类公告有一定波动,但作为第一道自动校验已经足够。

5.2 zip 内容校验

打包完成后,用ZipFile反向打开 zip 文件,核对条数、单条内容大小。这一步能捕获打包过程中常见的 entry 未 close、文件名重复覆盖等问题:

try (ZipFile zf = new ZipFile("output.zip", Charset.forName("GBK"))) { long actualSize = zf.stream().count(); if (actualSize != expectedCount) { System.err.println("条目数不一致, expected=" + expectedCount + ", actual=" + actualSize); } }

ZipFile构造时指定与写入时一致的字符集,否则读取中文入口名会得到乱码,无法正确与历史记录比对。

5.3 异常 PDF 隔离到坏件目录

解析失败、乱码率超标的 PDF,统一移动到bad/目录,并在文件名前加上失败原因编号。具体做法如下:

Path badDir = Paths.get("bad"); Files.createDirectories(badDir); String reason = "no_text_layer"; Files.move(pdfPath, badDir.resolve(pdfPath.getFileName() + "." + reason));

移动而不是删除,保留原始公告文件。每次增量运行结束后,把 bad 目录中的 PDF 重新喂回解析线程做一次補跑,很多扫描件可以在追加 OCR 后处理成功,而乱码文件通常在升级 PDFBox 版本或补充字体映射后自动恢复。单独把坏件目录保留下来,每次补跑时再接回解析流程,是比换解析库更实在的做法。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询