我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Java+HTML实现招标信息爬虫:抓取、落库、展示一站式方案

Java+HTML实现招标信息爬虫:抓取、落库、展示一站式方案 简介面向需要批量获取招标/采购信息的Java开发者这套爬虫示例工程围绕网页招标信息抓取展开可提取项目名称、招标单位、投标截止时间等关键字段适合入门爬虫或作为中小业务原型的参考。压缩包仅7KB共5个文件3个Java源码分别承担HTTP请求、HTML解析与业务服务逻辑2个HTML文件提供列表页和详情页展示便于对照理解前端页面与后端爬取代码的衔接。已有434人学习下载。实现过程中覆盖Jsoup选择器定位目标数据、HttpClient发送请求、正则与日期清洗等典型环节并体现了基础的反爬应对、异常处理和日志记录思路若结合Spring Boot可进一步改造为定时后台任务搭配线程池提升抓取效率。代码量不大但结构清晰Controller、Service接口与实现类的分层方式有助于Java初学者快速掌握一个最小可用的爬虫框架也可作为后续扩展分布式爬虫和可视化展示的起点。1. 招标信息爬虫Javahtml一套代码把抓取和展示都跑通做招标信息爬虫的工程师最常被反问一句Java 爬虫不都是 Python 吗其实 Java HTML 这条路能解决一个很现实的业务问题——招标公告散落在各地公共资源交易平台人工盯网页费时往往差半天就错过报名截止。这套方案不需要微服务和分布式爬虫一台 2G 内存的服务器就能定时把公告抓回来、落库再生成一个 HTML 页面把标题、预算、截止时间摊开让业务同事按地区和时间筛选。适合三类人被安排搭投标信息聚合系统的 Java 工程师、想低成本自建投标情报库的中小企业以及把采集加展示当练手的转行学习者。目标只有一个在截止前两天把该看到的标都看见。2. 技术选型与最小架构为什么是 Java 而不是 Python2.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批量插入要配 rewriteBatchedStatementsSpring Boot定时任务 HTTP 接口 静态页面生态齐全默认线程池很小并发抓取要调组合方式一般是先用 HttpClient 直接抓抓回来的 HTML 交给 Jsoup碰到列表正常但正文白屏的页面再用 HtmlUnit 渲染一次。注意不是所有页面都要上 HtmlUnit它只用在“JS 渲染”这一类少数派组件上。老手还会把上面这些组件包成自己的“爬虫工具箱”统一的 UA、代理池接口、重试策略写进公司的 common 模块里新接入一个站点只是在配置中心加几行配置。2.3 最小架构抓取、解析、落库、展示两小时跑通最常见的落地方式是单工程四层包crawler抓取和解析、service去重落库、controller查询接口、web静态 HTML。不用微服务不用分布式爬虫分布式只有在站点超过二十个、单机被限流时才值得考虑。先建一个 Spring Boot 工程把依赖加进pom.xmldependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencyJsoup 的版本建议别太老旧版本对 CSS 伪类和 HTML5 标签的支持不够MyBatis-Plus 3.5.x 可以用实体类自动生成建表 SQL后面表结构那节会用到。定时任务用 Spring 自带的注解就能起步Scheduled(cron 0 0 8 * * ?) public void grabDailyBids() { ListBidSource 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(); HttpResponsebyte[] 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 CHARSETutf8mb4 COLLATEutf8mb4_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 IPageBidInfo 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 IPageBidInfo search(long page, long size, String keyword, String region) { LambdaQueryWrapperBidInfo 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); }默认size10是列表页常见的设置内网页面我一般调到 20一次一屏正好。keyword 做SELECT *之后的内存过滤是大忌必须走 SQL like否则记录数过万后就卡死。4.3 HTML 页面一屏看全今天所有截止的标前端我坚持用纯 HTML 原生 JS不引 Vue也不引 React。因为这种内部工具页面就是给人快速看的Spring Boot 的src/main/resources/static/目录里放一个index.html启动后直接访问根路径就能用零构建步骤!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 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 idkw placeholder按标题搜 button onclickload(1)搜索/button table idbidTable theadtrth标题/thth地区/thth截止时间/thth状态/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}size20keyword${encodeURIComponent(kw)}); const data await resp.json(); const tbody document.querySelector(#bidTable tbody); tbody.innerHTML data.records.map(r tr tda 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 /htmllangzh-CN让浏览器按简体中文解析页面meta charsetutf-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; charsetutf-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 体系里跑通完整业务闭环。我的习惯是第一版先不加并发和代理池单线程稳稳跑两周把站点结构变化、反爬触发、字符集异常都处理干净再逐步提速和扩展。希望帮到你。本文还有配套的精品资源点击获取
返回列表