简介:面向需要批量获取招标/采购信息的Java开发者,这套爬虫示例工程围绕网页招标信息抓取展开,可提取项目名称、招标单位、投标截止时间等关键字段,适合入门爬虫或作为中小业务原型的参考。压缩包仅7KB,共5个文件,3个Java源码分别承担HTTP请求、HTML解析与业务服务逻辑,2个HTML文件提供列表页和详情页展示,便于对照理解前端页面与后端爬取代码的衔接。已有434人学习下载。实现过程中覆盖Jsoup选择器定位目标数据、HttpClient发送请求、正则与日期清洗等典型环节,并体现了基础的反爬应对、异常处理和日志记录思路;若结合Spring Boot可进一步改造为定时后台任务,搭配线程池提升抓取效率。代码量不大但结构清晰,Controller、Service接口与实现类的分层方式有助于Java初学者快速掌握一个最小可用的爬虫框架,也可作为后续扩展分布式爬虫和可视化展示的起点。
1. 招标信息爬虫Java+html:一套代码把抓取和展示都跑通
做招标信息爬虫的工程师,最常被反问一句:Java 爬虫?不都是 Python 吗?其实 Java + HTML 这条路能解决一个很现实的业务问题——招标公告散落在各地公共资源交易平台,人工盯网页费时,往往差半天就错过报名截止。这套方案不需要微服务和分布式爬虫,一台 2G 内存的服务器就能定时把公告抓回来、落库,再生成一个 HTML 页面,把标题、预算、截止时间摊开,让业务同事按地区和时间筛选。适合三类人:被安排搭投标信息聚合系统的 Java 工程师、想低成本自建投标情报库的中小企业,以及把采集加展示当练手的转行学习者。目标只有一个:在截止前两天,把该看到的标都看见。
2. 技术选型与最小架构:为什么是 Java 而不是 Python?
2.1 现成工具的诱惑,和 Java 不可替代的三个点
先说大实话:python爬虫生态确实最省事,requests + BeautifulSoup 半小时就能抓一个列表页,免费开源的爬虫软件里 Scrapy 更是把去重、并发、日志都做好了。我最早也这么玩,直到要面对招标这个具体业务场景才发现,对方要的不是一个能跑的爬虫脚本,而是把公告抓回来之后还要能查、能统计、能导出、能提醒。
Java 在这类落地里不可替代的地方有三个。第一,招标信息往往要进企业的业务系统,OA、合同、供应商库大多跑在 Java 体系里,爬虫用 Python 写完还得靠 Shell 定时任务和 HTTP API 去对接,不如直接做成 Spring Boot 的一个定时模块。第二,Java 自带成熟的连接池、重试框架和任务调度,Quartz、Spring 的 @Scheduled 都能表达“每天早上 8 点抓 12 个站点”这种周期,维护成本低,跑三个月不重启是常态。第三,接手成本:给中型企业搭这类系统,后面维护的往往是 Java 工程师。你用 Python 交付,人家改一个网页选择器还要先学 Python,用 Java 交付,会议室里当场就能改。
2.2 组件选型:HttpClient、Jsoup、HtmlUnit 各管哪一段
老手做 Java 爬虫不会只用一个库,而是按“请求、解析、渲染”三层分工。下面这张表是我在新项目里默认的组合方式,按场景剪裁。
| 组件 | 负责环节 | 选型理由 | 常见坑 |
|---|---|---|---|
| java.net.http.HttpClient | 发起 HTTP 请求、连接管理与重试 | JDK 自带,省依赖,支持 HTTP/2 和异步 | 要自己处理 gzip 解压和重定向 |
| Jsoup | 解析 HTML、CSS 选择器抽取 | 语法接近 jQuery,容错性好 | 只能拿静态 HTML,拿不到 JS 渲染内容 |
| HtmlUnit | 无头浏览器渲染 JS 动态页面 | 能真实执行脚本、模拟点击 | 慢、内存消耗大,超时参数要调 |
| MyBatis-Plus | 数据落库与分页查询 | 实体类映射简单,自动生成基础 SQL | 批量插入要配 rewriteBatchedStatements |
| Spring Boot | 定时任务 + HTTP 接口 + 静态页面 | 生态齐全 | 默认线程池很小,并发抓取要调 |
组合方式一般是:先用 HttpClient 直接抓,抓回来的 HTML 交给 Jsoup;碰到列表正常但正文白屏的页面,再用 HtmlUnit 渲染一次。注意不是所有页面都要上 HtmlUnit,它只用在“JS 渲染”这一类少数派组件上。老手还会把上面这些组件包成自己的“爬虫工具箱”:统一的 UA、代理池接口、重试策略,写进公司的 common 模块里,新接入一个站点只是在配置中心加几行配置。
2.3 最小架构:抓取、解析、落库、展示两小时跑通
最常见的落地方式是单工程四层包:crawler(抓取和解析)、service(去重落库)、controller(查询接口)、web(静态 HTML)。不用微服务,不用分布式爬虫;分布式只有在站点超过二十个、单机被限流时才值得考虑。先建一个 Spring Boot 工程,把依赖加进pom.xml:
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>Jsoup 的版本建议别太老,旧版本对 CSS 伪类和 HTML5 标签的支持不够;MyBatis-Plus 3.5.x 可以用实体类自动生成建表 SQL,后面表结构那节会用到。定时任务用 Spring 自带的注解就能起步:
@Scheduled(cron = "0 0 8 * * ?") public void grabDailyBids() { List<BidSource> sources = sourceService.listEnabledSources(); for (BidSource source : sources) { bidCrawler.crawlList(source); } }0 0 8 * * ?表示每天早上 8 点整触发,?用于日和周字段的互斥占位。定时任务是整个系统的节拍器,跑起来之后第一件事就是把抓取日志打出来,后面验证“有没有漏标”全靠它。
3. 抓取与解析:写一个能跑到招标公告正文的爬虫核心
3.1 从公告列表页开始:超时、重试、压缩与编码
大多数交易平台的列表页是静态 HTML,可以直接用 JDK 自带的 HttpClient。这里有个习惯:用byte[]接响应体,而不是直接用String。因为很多服务器不写响应头的 charset,提前按 UTF-8 转会在解析时把中文搞乱。
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) // 建连超过 10 秒放弃 .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(listUrl)) .timeout(Duration.ofSeconds(15)) // 整个请求最多等 15 秒 .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") .header("Accept-Encoding", "gzip") .build(); HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); byte[] body = response.body();连接超时 10 秒、读超时 15 秒是给国内交易平台的稳妥值;如果目标站在异地机房,我一般调成 20 秒。UA 不一定要复制浏览器里的完整值,但至少要带上 Mozilla 前缀,空 UA 会被不少站的 WAF 直接拦。服务端如果返回 gzip,还要手动解一层:
byte[] decoded = body; if ("gzip".equalsIgnoreCase(response.headers().firstValue("Content-Encoding").orElse(""))) { try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(body))) { decoded = gis.readAllBytes(); } }很多人在这一步翻车:明明页面是 UTF-8,解码出来却是乱码,就是因为没解 gzip 直接转 String。重试策略我习惯用一个 1 秒、2 秒、4 秒的指数退避循环,最多三次,连续失败就放弃当天该站点,避免把对方服务器打挂也避免 IP 被封。这些点看着基础,其实是 java 基础 和 java面试题 里经常被追问的细节,真实项目里坑也都在这里。
3.2 Jsoup 选择器:抽取标题、链接、发布时间,补齐相对地址
拿到干净的 HTML 字节流之后,用 Jsoup 解析成 DOM。招标公告列表的结构高度相似:一个ul.list包着一堆li,每个li里有链接、标题和发布时间。抽取的代码可以写得很短:
Document doc = Jsoup.parse(new String(decoded, StandardCharsets.UTF_8), listUrl); Elements items = doc.select("ul.list li"); // 按实际页面结构调整 for (Element item : items) { String title = item.select("a.tit").text().trim(); String href = item.select("a.tit").attr("href"); String pubTime = item.select("span.time").text().trim(); String absUrl = item.absUrl("href"); // 自动把相对路径补成绝对地址 if (title.isEmpty() || absUrl.isEmpty()) continue; BidDTO dto = new BidDTO(); dto.setTitle(title); dto.setUrl(absUrl); dto.setPublishTime(pubTime); // dto 交给去重和落库 }item.absUrl("href")会在内部用 document 的 baseUri 拼接相对路径,比手动URI.resolve稳得多,列表页是./info/123.htm这类地址时尤其有用。选择器ul.list li是大多数站点的通用写法;如果站点用的是 table 布局,改成table.dat tr就行。发布时间不建议用正则硬抠,中文日期2025-03-12 14:30这种格式先 trim,再交给LocalDateTime的 pattern 解析,保证入库的都是统一格式,为后面的 MD5 去重省事。
这里有一个排查技巧:抓不到数据时先打开浏览器开发者工具,Elements 面板里直接看列表项的真实 class 名,很多站点的列表 class 每次改版都会换,比如ul.list可能改成ul.news_list。我一般会写一个临时 main 方法,把当前页面的 HTML 落盘,然后肉眼扫一眼结构,比反复改代码重跑效率高得多。
3.3 有的招标站用 JS 渲染正文:HtmlUnit 的等待与超时
不少平台在详情页做了“前端防爬”,正文不是直接写在 HTML 里,而是用 JS 异步请求接口再填进div.content_area。Jsoup 直接拿详情页会得到空正文,这不是编码问题,而是页面还没渲染完。这时再用 HtmlUnit 做无头渲染,但只对“正文过短”的页面触发:
WebClient webClient = new WebClient(BrowserVersion.CHROME); webClient.getOptions().setJavaScriptEnabled(true); webClient.getOptions().setCssEnabled(false); // 不加载 CSS,速度差好几倍 webClient.getOptions().setThrowExceptionOnScriptError(false); // 脚本报错不打断渲染 webClient.getOptions().setTimeout(15000); HtmlPage page = webClient.getPage(detailUrl); webClient.waitForBackgroundJavaScript(5000); // 等 5 秒,让异步请求返回 String text = page.querySelector("div.content_area").getTextContent(); webClient.close(); // 不关会内存泄漏waitForBackgroundJavaScript(5000)里的 5000 毫秒是经验值,不是每个站都够用。更稳的写法是循环等待:每 1 秒检查一次正文长度,超过 30 个字就认为渲染完成,最多等 20 秒。setCssEnabled(false)必须开,否则 HtmlUnit 会去加载一堆样式文件,单页耗时能翻三四倍。记住 HtmlUnit 是重武器,只对 Jsoup 拿不到正文的少数页面用;我在代码里会先判断doc.select("div.content").text().length() < 30再切换渲染器,全站无脑上 HtmlUnit 会把定时任务拖到超时。
3.4 增量去重与幂等:怎么保证不重复入库
招标公告天然适合用公告编号去重;很多省级平台在详情页会显示“招标编号”或“项目编号”,列表页没有就先解析详情页拿编号。没有编号的站点,只能用“标题 + 发布时间”拼起来做 MD5。注意标题末尾的空格、时间格式不一致,都会让 MD5 对不上,所以入库前先统一 trim 和格式化。
ALTER TABLE t_bid_info ADD UNIQUE KEY uk_bid_no (bid_no);有了唯一索引,插入时可以放心用 MySQL 的幂等写法,重复的编号不会报错,只刷新抓取时间:
int rows = jdbcTemplate.update( "INSERT INTO t_bid_info(bid_no, title, source_url, publish_time, content_md5) " + "VALUES (?, ?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE grab_time = NOW()", dto.getBidNo(), dto.getTitle(), dto.getUrl(), dto.getPublishTime(), dto.getContentMd5());幂等是这个方案里最重要的一件事。定时任务每天跑,同一批公告今天抓到、明天还会抓到,没有唯一索引的话,跑一周数据库里就是几千条重复数据,前端页面会出现同一标题三行并列,业务同事会直接怀疑系统坏了。先把唯一索引建好,再谈抓取效率。
4. 数据落库与 HTML 前端展示:把抓到的公告变成可搜索页面
4.1 表结构设计:字段够用,索引不能少
招标信息表不需要堆太多字段,核心就是标题、编号、时间、链接、正文。下面这张表是从我实际项目里精简出来的,足够支撑日常筛选:
CREATE TABLE t_bid_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, bid_no VARCHAR(64) DEFAULT NULL COMMENT '公告编号', title VARCHAR(512) NOT NULL COMMENT '标题', region VARCHAR(32) DEFAULT NULL COMMENT '地区', budget DECIMAL(14,2) DEFAULT NULL COMMENT '预算金额(万元)', publish_time DATETIME DEFAULT NULL COMMENT '发布时间', deadline DATETIME DEFAULT NULL COMMENT '报名截止时间', source_url VARCHAR(1024) NOT NULL COMMENT '原文链接', content TEXT COMMENT '公告正文', content_md5 CHAR(32) NOT NULL COMMENT '去重指纹', status TINYINT DEFAULT 0 COMMENT '0未看 1跟进 2放弃', grab_time DATETIME NOT NULL COMMENT '抓取时间', UNIQUE KEY uk_bid_no (bid_no), KEY idx_deadline (deadline), KEY idx_publish (publish_time) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;deadline和publish_time必须建索引,业务每天看得最多的就是“今天有什么要截止”;bid_no唯一索引是去重的地基。MyBatis-Plus 的实体类直接对应这张表,类上写@TableName("t_bid_info"),主键用@TableId(type = IdType.AUTO),之后分页查询、条件构造器都能少写很多样板代码。用 MyBatis-Plus 的代码生成器可以从实体类自动生成建表 SQL,省去手写字段对齐的麻烦。
4.2 后端查询接口:分页和筛选一次给够
接口不用复杂,一个分页列表接口就能覆盖九成需求。Spring Boot 加 MyBatis-Plus 写起来很短:
@RestController @RequestMapping("/api/bid") public class BidController { @Autowired private BidService bidService; @GetMapping("/list") public IPage<BidInfo> list(@RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "10") long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) String region) { return bidService.search(page, size, keyword, region); } }Service 里的查询逻辑也直白,关键词走标题模糊匹配,地区走精确匹配,排序按截止时间倒序。这里特意不按抓取时间排序,因为业务人员关注的是“哪个先截止”,不是“哪个先抓回来”:
public IPage<BidInfo> search(long page, long size, String keyword, String region) { LambdaQueryWrapper<BidInfo> qw = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { qw.like(BidInfo::getTitle, keyword); } if (StringUtils.hasText(region)) { qw.eq(BidInfo::getRegion, region); } qw.orderByDesc(BidInfo::getDeadline); return baseMapper.selectPage(new Page<>(page, size), qw); }默认size=10是列表页常见的设置,内网页面我一般调到 20,一次一屏正好。keyword 做SELECT *之后的内存过滤是大忌,必须走 SQL like,否则记录数过万后就卡死。
4.3 HTML 页面:一屏看全今天所有截止的标
前端我坚持用纯 HTML + 原生 JS,不引 Vue,也不引 React。因为这种内部工具页面就是给人快速看的,Spring Boot 的src/main/resources/static/目录里放一个index.html,启动后直接访问根路径就能用,零构建步骤:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>招标公告台</title> <style> table { border-collapse: collapse; width: 100%; } th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; } .expired { color: #c00; font-weight: 600; } </style> </head> <body> <input id="kw" placeholder="按标题搜"> <button onclick="load(1)">搜索</button> <table id="bidTable"> <thead><tr><th>标题</th><th>地区</th><th>截止时间</th><th>状态</th></tr></thead> <tbody></tbody> </table> <script> async function load(page) { const kw = document.getElementById('kw').value; const resp = await fetch(`/api/bid/list?page=${page}&size=20&keyword=${encodeURIComponent(kw)}`); const data = await resp.json(); const tbody = document.querySelector('#bidTable tbody'); tbody.innerHTML = data.records.map(r => ` <tr> <td><a href="${r.sourceUrl}" target="_blank">${r.title}</a></td> <td>${r.region || '-'}</td> <td class="${new Date(r.deadline) < new Date() ? 'expired' : ''}">${r.deadline}</td> <td>${r.status === 0 ? '未看' : r.status === 1 ? '跟进' : '放弃'}</td> </tr>`).join(''); } load(1); </script> </body> </html>lang="zh-CN"让浏览器按简体中文解析页面,meta charset="utf-8"是中文页面的底线配置。JS 里把已过期的截止时间加上红色高亮,这是每天一打开页面就能判断“先处理哪个标”的关键。sourceUrl永远放在可点击的链接上,业务同事习惯点进原文核对,这比我们自己打印的正文更可信。
5. 避坑与常见问题:招标网站反爬、乱码与重复入库的排错
下面的排错清单来自真实站点踩坑记录,每条都按“现象 -> 原因 -> 解决”写,直接对照排查。
5.1 列表能抓到,正文始终为空
现象:列表页正常入库,详情页的 title 有值,content 字段却是空字符串。原因:正文不是写在静态 HTML 里,而是页面加载后由 JS 异步请求接口填进去的,Jsoup 拿不到动态填充的内容。解决:先判断详情页正文长度小于 30 个字,再改用 HtmlUnit 渲染。还有一种情况是正文放在 iframe 里,需要先解析详情页拿 iframe 的 src,再对那个嵌套地址发一次请求。大多数情况下的“前端防爬”只是异步加载,不是真要拦住你。
5.2 页面明明是 UTF-8,程序里却是一串问号
现象:浏览器打开正常,Java 打印出来全是????。原因有两个:一是响应头没写Content-Type: text/html; charset=utf-8,程序提前用默认字符集转了 String;二是服务端开了 gzip,程序没解压直接解码。解决:响应体用byte[]接收,先按Content-Encoding解 gzip,再用 Jsoup 的parse方法时让它自己去读 HTML 头部的 meta charset。如果页面 meta 也没写,就用CharsetDetector检测,比盲目指定 UTF-8 稳得多。
5.3 爬了两轮就被返回 403,或者 IP 被封
现象:第一轮全成功,第二轮开始大量 403,重试越重试越惨。原因:请求频率太高,没有带 Referer,部分平台还会校验 Cookie 里的 session 是否来自真实浏览器。解决:每个请求加Referer指向该站的列表页;两次请求之间Thread.sleep(2000 + new Random().nextInt(3000)),随机间隔比固定间隔更不容易触发风控;一个 UA 只用来访问一个站点。被 403 时直接放弃当天该站点的剩余页面,不要继续硬抓,否则会把 IP 拖进黑名单。这套策略跑一个月后,才考虑加代理池和并发。
5.4 唯一索引建了,日志里却出现 duplicate key 异常
现象:表里有uk_bid_no,插入时仍然报重复键错误。原因:很多公告没有 bid_no,我们去重用title + publish_time拼 MD5,但时间格式不统一,比如有的解析结果是2025-03-12,有的是2025年03月12日,MD5 对不上。解决:入库前统一格式,时间用LocalDateTime的DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")归一化,标题 trim 之后再拼字符串。再给content_md5也建一个唯一索引,双保险,重复记录就彻底进不来了。
5.5 服务启动报错:Failed to configure a DataSource
现象:Spring Boot 工程启动直接失败,报Failed to configure a DataSource: 'url' attribute is not specified。原因:引入了 mybatis-plus-boot-starter 但application.yml里没配数据源。解决:如果只是想先调试抓取代码,加上@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})临时跳过;如果是要跑完整系统,把spring.datasource.url、username、password配好即可。这个问题纯属启动配置粗心,但几乎每个新人都要踩一次,列在这里省得你查半天。
6. 验证与压测:怎么证明你的爬虫没漏标,还能怎么扩展
爬虫系统上线前,最该回答的问题是“抓全了吗”。我常用的验证方法是拿目标站点的列表页总数和库内记录数对账。大多数交易平台列表页底部都会显示“共 132 条”,把当天抓到的记录数抓出来对比就能发现明显漏标。更稳的脚本是把验证写进定时任务,每周一早上跑一次差异检查:
SELECT COUNT(*) FROM t_bid_info WHERE publish_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY);然后把结果和对应平台列表页的公告总数比对,差异超过 5% 就告警到邮件。我第一次交付时只对比了总数,忽略了有些平台在详情页里通过 JS 翻页加载更多公告,结果连续漏标了一周没人发现。后来我把“抓全率 = 平台总数 / 本地总数 × 100%”写进抓取日志,每天打一条,再也没有被业务追问过。
功能稳定之后,扩展方向也清晰:一是把单机版改成分布式爬虫,按站点做分片,多个节点各抓一部分,注意这时候要引入 Redis 做全局去重,MySQL 唯一索引在跨节点时会拖慢吞吐;二是导出功能,将查询结果生成 CSV,业务同事用 WPS 也能直接打开,文件名带上日期,这就是最简单的日报表。再往后可以做截止时间倒计时和报价提醒,把“哪几个标今天下午截止”推给对应业务员。
这套 Java + HTML 的招标信息爬虫,最强的优势不是技术新,而是能在一个团队已经熟悉的 Spring Boot 体系里跑通完整业务闭环。我的习惯是第一版先不加并发和代理池,单线程稳稳跑两周,把站点结构变化、反爬触发、字符集异常都处理干净,再逐步提速和扩展。希望帮到你。
本文还有配套的精品资源,点击获取