资讯动态

Java实现巨潮A股公告爬虫:PDF解析与增量打包实践

发布时间:2026/9/13 14:09:48 来源:尧图企业网站定制
简介面向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 实现列表请求用 OkHttpJSON 用 fastjson2PDF 抽取用 PDFBox打包用 ZipOutputStream重点讲清接口参数怎么设、PDF 解析边界在哪、增量去重怎么落地。适合量化投研的数据工程师也适合被“全市场公告转文本”需求反复找上门的 Java 后端。全量抓取往往不是第一版目标稳定抓完昨天发布的一两千条公告并产出可检索文本才是这个项目真正要解决的问题。2. 公告列表抓取hisAnnouncement/query 的参数、分页与 OkHttp 并发2.1 列表接口返回结构与关键参数巨潮资讯网的公告搜索页面虽多后端基本都走同一个 POST 接口hisAnnouncement/query表单提交JSON 返回前端页面和爬虫共用这一套协议。先看请求参数里最重要的几个字段参数全市场常用值作用pageNum1页码从 1 开始递增pageSize30每页条数我一般不超过 50columnall / szse / sse / bjse交易所维度all 与 plate 搭配更稳platesz / sh / bj板块过滤留空表示全板块tabNamefulltext全文检索模式category空公告分类空为全部trade空交易状态过滤默认全量seDate2025-01-01~2025-01-31按披露日期区间过滤searchkey空关键词按标题或全文检索stock空股票代码留空则全部证券注意seDate是“披露日期”而非公告涉及的事件日期增量抓取要用自然日维度这与很多业务系统的“交易日历”存在偏差。又一关键点是column与plate的组合columnallplate通常能返回全市场但某些查询入口对空板块解析不友好保险写法是拆成szse、sse、bjse三个请求分别跑最后再合并代价只是多几次请求而已。返回 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/才能下载。分页循环的终止条件有两个判断位hasMorefalse或本页announcements数组为空两个条件同时判断避免接口偶尔把hasMore字段省略时程序陷入死循环。2.2 OkHttp 请求构造与响应解析先引入三个依赖版本号选择当前稳定线即可Maven 坐标如下dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.30/version /dependency dependency groupIdcom.alibaba.fastjson2/groupId artifactIdfastjson2/artifactId version2.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, */*; q0.01) .build();FormBody的参数顺序没有影响但Content-Type必须是application/x-www-form-urlencodedOkHttp 会自动处理。User-Agent和Referer缺失时接口偶尔会返回 200 但内容是 HTML 错误页建议固定写入。响应解析用 fastjson2 读取public static ListJSONObject 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) { ListJSONObject 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 8tabula-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并在内存里用SetLong对announcementId去重。日期窗口只是过滤条件最终的去重依据是公告 IDSetLong 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 编码写入ZipEntrypublic static void buildZip(ListParsedRecord 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里写。推荐用并发队列收集主线程在任务全部提交后等待队列元素到达即可。简要流程如下ConcurrentLinkedQueueParsedRecord queue new ConcurrentLinkedQueue(); // 每个解析线程结束后把结果放入队列 queue.offer(new ParsedRecord(code, date, title, text)); // 提交完所有任务后从队列轮询消费 ListParsedRecord 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 版本或补充字体映射后自动恢复。单独把坏件目录保留下来每次补跑时再接回解析流程是比换解析库更实在的做法。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价