简介:基于Java语言的比价网爬虫开源项目源码包,面向需要构建比价网站数据抓取系统的开发者,提供完整的爬虫架构与数据处理方案,适合有Java基础、希望学习网络爬虫或电商数据采集的初中级开发者。压缩包共2000个文件,大小约122.75MB,核心代码涵盖303个Java源文件,配合698个JavaScript文件、430个HTML页面、199个CSS样式及122个JSP动态页面,另有135个Markdown文档和XML、JSON等配置数据文件,覆盖前端交互、后端逻辑、页面解析与数据存储等环节。项目内置readme与pom.xml等工程说明,src与db目录结构清晰,可直接导入IDE分析。已有81人学习,通过阅读源码可快速掌握爬虫请求、页面解析、数据持久化等实现思路,为自研比价或信息采集工具提供基础。
1. 比价 Spider 源码怎么拆:从 6073 个文件里找到 Java 的主线
解压这套基于 Java 编写的比价网 Spider 开源源码包,第一眼看到的不是类文件,而是一个容量惊人的资源池:6073 个文件里,Java 源文件只有 303 个,JavaScript、HTML、图片和样式文件加起来超过 4000 个。这个比例直接暴露了比价爬虫的真实工作量——抓请求、存数据只是起点,解析异构网页、归一化商品信息、组织前端展示才是投入的大头。
这套源码适合两类人:一类是想用 Java 搭商品数据采集链路的工程师,另一类是准备 Java 相关岗位面试时需要一个完整项目的学习者。它覆盖了从 HTTP 请求、HTML 解析、JSON 中间态到数据库落地的完整闭环,也保留了真实爬虫项目常见的前端资源目录和运维文档。后面的拆解会按“文件结构 → 抓取链路 → 持久化 → 上线验证”四条线展开,核心代码段可以直接改到自己的工程里。
2. Maven 工程与前端资源层:文件构成反向推导模块边界
2.1 文件类型统计:哪个文件才是项目的真主体
先看全貌。按文件类型统计,这套源码的构成如下:
| 文件类型 | 数量 | 大致角色 |
|---|---|---|
| JavaScript | 1640 | 页面交互、图表渲染、前端数据处理 |
| HTML | 796 | 目标页面样本、解析对象、管理后台视图 |
| PNG | 754 | 商品图、界面切图、校验码样本 |
| GIF | 672 | 动图资源、价格走势动画 |
| JSON | 408 | 抓取结果、前端配置、接口测试数据 |
| LESS | 356 | 未编译的样式源文件 |
| CSS | 306 | 编译后的样式文件 |
| Java | 303 | 抓取调度、解析、存储核心逻辑 |
| Markdown | 165 | 项目文档、开发笔记、变更记录 |
| JPG | 140 | 商品图等位图资源 |
JSON 文件有 408 个,这个数量说明项目把结构化了的数据当作一等公民来对待:爬虫跑完一轮,结果先落到 JSON,再由后续任务把 JSON 写入数据库或推给前端。LESS 和 CSS 并存,说明项目里含一套完整的前端编译流程,比如用 Less 源码编译出 CSS 再发布到静态资源目录。此外项目里还出现了 Ruby 和 PHP 文件,这类文件在 Java 主导的仓库里通常承担辅助角色:PHP 文件可能是数据导出工具,Ruby 脚本可能是运维或日志处理工具,出现频率不高,不必把它们当作主链路。
2.2 pom.xml 与 src/main/java:Maven 模块怎么定位
pom.xml 是整个工程的装配说明,它定义了三件事:依赖哪些第三方库(比如 HTTP 客户端、HTML 解析器、JSON 序列化库)、用 Maven 的哪个生命周期完成编译打包、子模块之间怎么组织依赖。拿到这套源码,第一步先执行依赖树命令,可以直接看出解析层和序列化层的选型:
mvn dependency:tree -Dincludes=org.jsoup,com.fasterxml.jackson.core这条命令会过滤出 HTML 解析器和 JSON 处理相关的依赖树。如果 Jsoup 出现在结果里,说明页面解析用了 DOM 选择器方案;如果 Jackson 的依赖被排除掉了,则可能换成 Gson 或 Fastjson,后续看代码时对ObjectMapper的搜索方向也要跟着调整。依赖树为空则说明解析逻辑用正则或自研解析器实现,这时要重点看 Java 代码里Pattern和Matcher的密度。
src/main/java 下的 303 个 Java 文件是项目主逻辑,拆这类项目有个经验:先把类按包名归类,controller包一般是任务入口,service包负责业务编排,dao或mapper包负责读写,entity包对应数据模型。包名往往直接反映模块边界,比逐行读注释更快。
2.3 db 目录与 readme.txt:持久化与运行约定
db 目录通常会放建库建表脚本,比价场景至少会有商品表、价格历史表、抓取任务表三类,第四章会给出参考表结构。readme.txt 是运行的第一落点,它会写明 JDK 要求、Maven 配置、数据库初始化方式。由于项目里有 165 个 Markdown 文件,说明维护者习惯把设计文档也放进仓库,这对二次开发是重要帮助——很多开源项目源码好懂、文档却缺,这个项目把文档密度拉得较高,属于适合直接接手研究的那一类。
在本地跑起来之前,先确认 Java 环境变量已配置好,java -version和mvn -v能正常输出。常见做法是在 IDEA 里以 Maven 项目导入根目录,等依赖下载完成后,按 readme.txt 里的顺序执行 SQL 脚本,最后启动一个带main方法的入口类观察日志输出。如果启动时报找不到驱动类,优先检查 db 目录下 SQL 脚本是否完整执行,以及 pom.xml 里数据库驱动的 scope 是否被误标成了provided。
3. 抓取链路实现:线程池调度、Jsoup 解析与价格归一化
3.1 调度层:有界队列与线程池的参数选择
比价爬虫的调度层需要同时面对两个约束:目标网站响应慢,单线程抓取太慢;并发过高又容易被限流。常见做法是维护一个抓取 URL 队列,用ThreadPoolExecutor控制并发度,队列用有界实现防止任务积压撑爆内存。
// 抓取任务调度:有界队列 + 丢弃最旧任务策略 BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(5000); ThreadPoolExecutor pool = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程回收时间 queue, new ThreadPoolExecutor.DiscardOldestPolicy() ); // 提交商品详情页抓取任务 for (ProductSeed seed : seedList) { pool.execute(new FetchTask(seed, parser, sink)); }这里几个参数值得细看。核心线程数 8 对应目标站点响应耗时与单机带宽的折中,如果目标页面平均响应在 200ms 以内,可以提高到 16;最大线程数不建议超过 32,否则触发限流的概率明显增加。ArrayBlockingQueue容量设成 5000,是为了防止种子 URL 膨胀时任务堆积过深;DiscardOldestPolicy的语义是队列满了就丢弃最老的任务,在爬虫场景里比AbortPolicy实用,因为抓取有新鲜度要求,老任务价值低。
| 线程池参数 | 推荐取值 | 设置依据 |
|---|---|---|
| corePoolSize | 8 | 单页平均响应耗时 × 期望并发数 |
| maximumPoolSize | 16 | 峰值流量下的上界,超过容易触发限流 |
| queue capacity | 5000 | 内存容量与任务新鲜度的折中 |
| keepAliveTime | 60s | 空闲线程回收,降低消耗 |
线程数与队列容量的关系也是 Java 面试里常被追问的点:任务积压时,线程池是先创建线程到最大线程数,还是先往队列里塞?这套代码的默认行为是先用满核心线程,再进队列,队列满了才扩张到最大线程数。能把这个顺序讲清楚,再结合本项目为什么选DiscardOldestPolicy而不选抛异常,比单纯背八股文更有说服力。
3.2 解析层:Jsoup 抽取商品字段的写法
拿到响应后,页面解析是 Bug 最容易出现的位置。用 Jsoup 操作时,要先抓页面结构特征,再写选择器。比价站页面通常存在重复的商品块,div.product-item这类选择器是常见入口。
// 解析商品列表页:抽取名称、价格、链接、销量 Document doc = Jsoup.connect(url) .userAgent(UA_POOL[index]) .timeout(8000) .followRedirects(true) .get(); Elements items = doc.select("div.product-item"); for (Element item : items) { String title = item.selectFirst("a.title").text(); String rawPrice = item.selectFirst("span.price").text(); String detailUrl = item.selectFirst("a.title").absUrl("href"); String sales = item.selectFirst("span.sales-num") == null ? "0" : item.selectFirst("span.sales-num").text(); Product p = new Product(); p.setTitle(title); p.setPrice(normalizePrice(rawPrice)); p.setDetailUrl(detailUrl); p.setSales(Integer.parseInt(sales.replaceAll("[^0-9]", ""))); sink.write(p); }UA_POOL[index]是提前准备的一组 User-Agent,运行期间轮换,具体策略放第五章。selectFirst没有命中元素时返回 null,所以销量字段先做空判断;absUrl("href")会把相对路径拼成完整链接,省掉自己拼接域名这一步。注意价格文本不能直接转数字,必须经过normalizePrice处理,否则¥1,299.00这类字符串会在解析阶段直接抛出异常。
3.3 数据层:从 HTML 到 JSON 的标准化过程
价格归一化是比价项目里最容易被新手忽略的一环。不同页面给出的价格格式差异很大:¥1,299.00、1299元、1 299、降价至 1299都会出现。统一做法是剥离货币符号和千分位,再按小数点转成以分(或毫)为单位的整数,避免浮点误差。
// 价格归一化:各种字符串统一成 Long 型分值 private static long normalizePrice(String raw) { if (raw == null || raw.length() == 0) { return -1L; } String cleaned = raw .replaceAll("[^0-9.]", "") // 去货币符、空格、中文 .replaceAll("^0+", ""); // 去首部多余的0 if (cleaned.isEmpty()) { return -1L; } try { BigDecimal decimal = new BigDecimal(cleaned); return decimal.movePointRight(2).longValue(); // 元转分 } catch (NumberFormatException e) { return -1L; } }replaceAll("[^0-9.]", "")把数字和小数点之外的所有字符都清掉,这一步对¥1,299.00和1299元都有效;movePointRight(2)把元转成分,保证后续比较、求差价时不丢精度。返回-1L表示解析失败,在写入时直接过滤掉这类脏数据。
标准化之后的Product对象会序列化成 JSON 存到本地文件或消息队列。408 个 JSON 文件的来源就在这里:一台机器跑完一轮,按日期分目录输出products-20240101.json,再由独立任务去读取入库。这种中间态设计让抓取和存储解耦,即使数据库暂时不可用,抓取任务也不会失败。
4. 持久化设计:JSON 缓存、MySQL 建表与全量增量切换
4.1 为什么 JSON 文件还要再落 MySQL
JSON 文件适合做中间缓存和跨系统交换,但不适合做聚合查询。要回答“某商品最近 30 天价格中位数是多少”这类问题,解析 JSON 比执行一条 SQL 慢几个数量级。所以这套项目的典型落地方式是:抓取模块只管写 JSON,入库模块定期把 JSON 分批导入 MySQL。抓取与入库通过文件目录衔接,比在抓取线程里直接写库更稳健,数据库慢查询或锁等待也不会反向拖垮采集任务。
全量与增量切换也需要想清楚。首次上线跑全量,把历史种子 URL 全部抓一遍;之后每天跑增量,只抓crawled_at大于上次最大时间的商品。增量起点的获取用一条 SQL 就能完成:
mysql -h localhost -u price_user -p price_db \ -e "SELECT MAX(crawled_at) FROM product_price;"把这个时间点传到抓取任务的参数里,种子 URL 只保留该时间之后有变化的商品。注意全量跑完不要直接删表,保留一个月以上的历史价格,后续才能画价格走势曲线。
4.2 product_price 表设计与去重键
比价库里的核心表是商品价格历史表,它的设计决定数据质量。价格记录需要支持三个维度的查询:按商品维度看价格走势、按时间维度看全网均价、按来源维度对比各平台差价。
CREATE TABLE product_price ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_hash CHAR(32) NOT NULL COMMENT '商品去重指纹', source VARCHAR(64) NOT NULL COMMENT '来源站点标识', product_title VARCHAR(255) NOT NULL, detail_url VARCHAR(512) NOT NULL, price_cents BIGINT NOT NULL COMMENT '价格,单位:分', sales_count INT DEFAULT 0, crawled_at DATETIME NOT NULL COMMENT '抓取时间', UNIQUE KEY uk_source_hash_time (source, product_hash, crawled_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;product_hash由商品标题加来源站点标识生成,哈希算法直接决定去重效果。这里唯一键是“来源站点 + 商品指纹 + 抓取时间”,它避免了同一分钟重复入库同一商品,又不阻拦不同时段的价格快照——价格历史需要保留每次变化。price_cents用 BIGINT 存分值,避免浮点数带来的比较误差。注意detail_url长度要留够,部分电商平台商品链接带长参数,255 不够时要扩到 512 或 768。
配合这张表的索引,常见查询先走source + product_hash找到某商品全部历史价格,再按crawled_at排序取时间序列。如果数据量级上到千万行,再考虑按季度分表,或者把一年前的数据归档到冷表。
4.3 Maven 依赖清单与批量写入
pom.xml 里除了 Jsoup,还需要数据库连接与 JSON 处理相关的依赖。参考依赖如下:
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <!-- 版本按构建环境匹配,JDK 8 及以上选择可用版本 --> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency>引入 HikariCP 后,批量写入建议打开 JDBC 的重写开关,它能把多条 INSERT 合并成一条多值语句,写入效率提升明显。连接串示例:
jdbc:mysql://localhost:3306/price_db?rewriteBatchedStatements=true&useServerPrepStmts=truerewriteBatchedStatements=true是 MySQL 驱动在批量插入时的关键开关,不开的话addBatch实际是逐条发送,性能不会提升。useServerPrepStmts=true让预编译在服务端进行,配合 HikariCP 连接池,长任务跑批时内存占用更平稳。
入库模块读取 JSON 时,逐行解析比整文件加载更安全:
// 逐行读取 JSON 文件,跳过解析失败的记录,攒批写入 try (BufferedReader reader = Files.newBufferedReader(jsonPath)) { String line; while ((line = reader.readLine()) != null) { try { Product p = objectMapper.readValue(line, Product.class); insertBatch.add(p); } catch (JsonProcessingException e) { log.warn("skip bad line: {}", line); } if (insertBatch.size() >= 1000) { jdbcTemplate.batchUpdate(INSERT_SQL, insertBatch); insertBatch.clear(); } } }这里用readLine逐行读而不是整文件readValue,因为抓取结果文件可能很大,一次性加载入内存容易触发 OOM;单条解析失败只记日志并跳过,不影响整批任务。攒到 1000 条执行一次batchUpdate,是写性能和内存占用之间的常用折中点。实际跑批时还可以把INSERT_SQL改成INSERT INTO ... ON DUPLICATE KEY UPDATE,配合唯一键让价格更新自动完成。
5. 上线前的验证与限速:抓取质量检查和 User-Agent 策略
5.1 用日志统计抓取成功率
一套爬虫上线前,先看三组数字:请求总数、解析成功数、入库去重数。比较快的做法是在FetchTask的结束位置输出结构化日志,之后用 grep 统计:
grep -c "fetch_success" app.log grep -c "fetch_fail" app.log成功率的健康线一般要求在 95% 以上。低于这个值,先排查超时时间和 User-Agent 是否被目标站点识别;解析成功但字段为空的情况,则需要单独统计price_cents < 0的记录数,这类脏数据往往是页面结构改版导致的。建议在日志里把解析失败的 HTML 片段截断输出,方便直接定位是选择器失效还是页面跳转到了验证码页。
5.2 User-Agent 轮换与限速参数
UA 轮换是低成本反识别手段。准备一个覆盖桌面和移动端的 UA 池,每次请求前随机取一个,同时请求间隔加上随机抖动,避免定时器被对方抓到规律:
// 请求前随机延迟,降低请求特征的规律性 Thread.sleep(800 + ThreadLocalRandom.current().nextLong(1200));nextLong(1200)让每次等待时间在 800ms 到 2000ms 之间波动,比固定延时 1 秒更隐蔽。注意限速参数要和线程数联动:核心线程数 × 平均延迟就是这台机器的平均 QPS,按目标站点的容忍度反推参数。如果对方 1 秒内允许 5 个请求,核心线程数就得控制在 10 以内,平均延迟 2 秒时总 QPS 才 5。
5.3 断点续抓的校验技巧
长时间跑批最怕中途挂掉后全部重来。常见做法是给每个详情页 URL 算一个 MD5 指纹,运行时用内存集合过滤,入库时依赖唯一键去重:
// 已抓取集合:启动时从数据库加载,运行时内存判断 if (seenHashes.contains(hash)) { return; } seenHashes.add(hash);注意seenHashes的规模受内存限制,几百万量级用HashSet可以支撑,再往上涨就要换成 Redis 的 SET 结构。重启后从数据库把最近 24 小时的product_hash重新加载进内存即可继续,缺失的时段会在下轮增量任务里自动补齐。跑批过程中如果频繁出现超时,优先降低线程数而不是调大超时——线程数翻倍带来的收益在目标站点限流后会直线下降,反而让超时异常堆积在队列里。
本文还有配套的精品资源,点击获取