Java比价爬虫实战指南:从模块拆分到长期稳定采集
发布时间:2026/9/28 22:34:45来源:尧图网络
简介一份基于Java实现的比价网爬虫开源设计源码面向网络爬虫开发初学者与Java技术爱好者可用于构建比价网站的数据抓取与分析功能。压缩包共收录2000个文件约122.75MB既包含698个JavaScript、430个HTML、303个Java源文件等核心代码也配有CSS样式、Markdown文档、JSP页面及XML配置覆盖前端展示、后端逻辑、依赖管理与项目文档等多个层面。目前已有81人学习/下载。项目提供了完整的Maven构建配置、数据库相关脚本以及使用说明从源码结构到运行配置都清晰可查便于理解爬虫调度、页面解析与数据存储的完整链路。对于希望深入爬虫实战、研究开源项目组织方式的开发者这份代码具有一定的参考与复用价值。1. 用Java写比价Spider到底图什么比价网Spider的核心任务是把多个电商平台上的商品价格、促销状态、库存信息持续抓回来形成可对比、可追踪的历史价格数据。市面上Python爬虫教程不少但真正能支撑“长期稳定采集”的比价系统用Java写的反而更可靠。原因很直接比价爬虫不是跑一次就完事而是要按小时、按天反复调度抓取、解析、入库、告警一条链路都要能在生产环境里长期扛住。Java在并发控制、线程管理、异常隔离上的工程能力比脚本语言更适合这种“长跑型”任务。这篇文章不是科普爬虫概念而是把一套比价网Spider从模块拆分、代码落地到避坑调试的完整路径讲清楚适合准备自研比价系统、或者想用Java做采集方向的开发者。2. 比价Spider的整体设计先拆模块再写代码2.1 模块全景五层职责分清楚比价爬虫最忌讳把所有逻辑堆在一个类里。我曾经见过一个“能跑”的爬虫抓取、解析、存库全写在main方法里结果加一个平台就要改一段代码最后谁都不敢碰。后来我重构时把系统拆成五层每层只干一件事。采集层只负责把URL对应的页面字节拿回来不关心页面里是什么。解析层把HTML或JSON变成统一的商品对象不关心数据从哪来。去重层缓存URL和商品指纹避免同一个商品被重复采集。存储层维护商品表、价格快照表、采集日志表。调度层决定“什么时间采哪些URL、间隔多少、并发多大”。这五层边界一旦划清楚后续加平台、改规则、调频率都不会牵连一片。从源码组织结构上看一个可维护的比价Spider工程应该按模块分包而不是按页面分包com.demo.pricecrawler ├── fetcher // 采集层HTTP请求、重试、响应处理 ├── parser // 解析层选择器提取、JSON解析、价格清洗 ├── dedup // 去重层URL指纹、SKU指纹、布隆过滤器 ├── scheduler // 调度层定时任务、线程池、频率控制 ├── storage // 存储层实体类、Mapper、事务 └── alert // 告警层降价通知、异常通知这里有个容易被新手忽略的点解析层不要依赖采集层的具体实现。无论页面是用HttpClient抓的、用OkHttp抓的还是从本地文件读回来的解析器只接收HTML字符串。这样写的好处是单测时可以直接把抓到的页面存成文件喂给解析器调规则不需要每次跑网络请求。2.2 Java技术栈选型不是最新是最稳比价系统落地的技术选型我一般遵循“稳”字优先。HTTP客户端用Apache HttpClient 5.x或Java 11以上的原生HttpClient。前者胜在拦截器机制丰富后者胜在零依赖JDK自带就能跑。解析库基本锁死JSoupCSS选择器语法成熟处理不规范的电商页面比正则表达式可靠得多。定时任务用Spring的Scheduled就够了任务量上来再上Quartz别一上来就上分布式调度中心。数据库层面MySQL加MyBatis Plus是比价系统的常见组合。价格快照表会随着采集次数增长得非常快一个月几百万条很正常所以要提前按goods_id加captured_at建联合索引。缓存用Redis存指纹和最新的价格结果不能让爬虫每次重复查询数据库来判断“这个商品见过没有”。这里要特意说一句不建议用Selenium做比价爬虫的主体抓取方案。Selenium是为了解决浏览器渲染问题但代价是每个页面要启动浏览器实例内存和CPU开销都很大。很多电商页面的价格数据其实藏在HTML里的JSON变量或者接口返回里直接用HTTP请求访问即可先抓一次看响应里有没有数据确定没有再考虑浏览器级方案。2.3 数据模型设计价格不只是字段是快照比价系统最重要的表只有两张商品表和价格快照表。商品表保存商品的静态信息平台、SKU、标题、品牌、分类、URL、首次发现时间。价格快照表保存每次采集到的价格和状态。千万别把当前价格当作商品表里的一个字段那是把历史数据丢掉了比价网的核心价值正好是历史价格曲线。CREATE TABLE t_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, platform VARCHAR(32) NOT NULL, sku_id VARCHAR(128) NOT NULL, title VARCHAR(255) NOT NULL, brand VARCHAR(64), category VARCHAR(64), url TEXT NOT NULL, image_url VARCHAR(512), first_seen_at DATETIME NOT NULL, last_crawled_at DATETIME NOT NULL, UNIQUE INDEX uk_platform_sku (platform, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_price_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, current_price DECIMAL(10,2) NOT NULL, original_price DECIMAL(10,2), promotion_type VARCHAR(32), stock_status TINYINT NOT NULL DEFAULT 1, captured_at DATETIME NOT NULL, KEY idx_goods_time (goods_id, captured_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计时注意两个细节。第一promotion_type字段要单独存标记“秒杀价”“满减值”“会员价”否则后面做降价告警时会把临时促销误判成真实降价。第二original_price和current_price要分开电商页面经常同时展示“划线价”和“实付价”不清这两个字段的差别价格分析会失真。商品维度的唯一键用(platform, sku_id)不要用URL。同一个商品在不同活动页URL可能不同SKU ID才是平台内唯一标识。SKU可以从详情页的URL参数、页面JSON、或商品数据属性里提取解析层要做的事之一就是把SKU稳定提取出来。3. 采集层落地用原生HttpClient和JSoup把价格捞回来3.1 先跑通最小抓取再搭框架很多人一上来就搭建完整工程结果连一个页面都还没抓到。正确顺序是先写一个二十行的抓取类确认目标站点的响应结构再逐步加框架。用Java 11原生HttpClient可以零依赖跑通这一步。import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class SimpleFetcher { public static void main(String[] args) throws Exception { HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://example-mall.com/product/10001)) .timeout(Duration.ofSeconds(10)) .header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64)) .header(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8) .header(Accept-Language, zh-CN,zh;q0.9) .GET() .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(status: response.statusCode()); System.out.println(html length: response.body().length()); } }这里的关键是请求头。浏览器能正常打开页面是因为它带上了完整的UA、Accept、Accept-Language等头。爬虫如果只带一个User-Agent很多站点会直接返回4xx。.followRedirects(Redirect.NORMAL)是Java 11 HttpClient的重定向策略会把301/302跳转自动处理好避免自己手动拼Location。第一次抓取时建议把response.body()保存到本地文件再打开分析别直接在控制台打印整个HTML。控制台输出会截断而且HTML文件可以直接作为解析器的测试输入。3.2 用JSoup解析商品列表与价格拿到HTML之后落地第一步是写一个“临时解析”先把页面里的商品标题、价格、链接提取出来。JSoup的选择器语法和CSS一致用过前端的人都熟悉。import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; Document doc Jsoup.parse(html); Elements items doc.select(div.product-list div.item); System.out.println(商品数量: items.size()); for (Element item : items) { String title item.select(a.title).text(); String priceText item.select(span.price).text(); String url item.select(a.title).attr(abs:href); System.out.println(title); System.out.println(priceText); System.out.println(url); System.out.println(----); }JSoup有几个容易踩的点。.text()方法只取文本不取子节点里的HTML适合取标题.attr(abs:href)会把相对路径拼成绝对URLJSOUP自动用文档的baseUri来拼.select()如果匹配不到元素会返回空集合不会抛空指针但要小心后续从空集合里取第一个元素时返回null。上面的代码能跑通但还只是临时方案。真正工程化时列表页的解析结果不应该直接打印而是组装成一个商品对象丢给后续处理先查重再存库然后根据商品详情页URL生成下一批采集任务。价格清洗是解析层最容易翻车的点。电商页面的价格文本千奇百怪“1,299.00”“ 1.29万 ”“券后1499元”。写一个稳定的价格清洗函数至少要考虑货币符号、千分位逗号、空格、汉字单位。import java.math.BigDecimal; public static BigDecimal parsePriceText(String rawPrice) { if (rawPrice null || rawPrice.isBlank()) { return null; } String cleaned rawPrice .replaceAll([^0-9.], ) .replaceAll(^\\., ); if (cleaned.isEmpty()) { return null; } try { return new BigDecimal(cleaned); } catch (NumberFormatException e) { return null; } }这段代码的逻辑是先把所有非数字和小数点的字符全部干掉然后处理“开头就是小数点”的脏数据最后用BigDecimal而不是double来存价格避免浮点误差。注意replaceAll的写法regex里的“.”要转义成“\.”否则会匹配到任意字符。对于“1.29万”这类带单位的数据上面的朴素清洗会得到1.29和真实价格12900差了100倍。处理这种数据时需要单独判断是否包含“万”或“千”再做乘法换算。3.3 把解析规则配置化页面改版不用重新编译比价网站的日常就是“被改版”。今天价格显示在span.price明天可能在div.sale-info里的data-price属性上。如果你把CSS选择器写死在Java代码里每次改版都要改代码、重新编译、重新部署。尤其是同时采集多个平台时规则混在代码里会让工程变得极其难维护。我的处理方式是把每个平台的解析规则外置成YAML配置Java代码只写“通用解析引擎”。platform: demo_mall list: container: div.product-list div.item title: a.title price: span.price url: a.title url_attr: abs:href detail: title: h1.product-name price: span.sale-price original_price: del.price-origin sku: div.product-code sku_attr: data-sku-id然后解析器从配置中心读取规则把选择器注入到通用方法里MapString, String rule ruleService.getListRule(demo_mall); Elements items doc.select(rule.get(list.container)); for (Element item : items) { String title item.select(rule.get(list.title)).text(); String priceText item.select(rule.get(list.price)).text(); String url item.select(rule.get(list.url)).attr(rule.get(list.url_attr)); Product product new Product(); product.setTitle(title); product.setUrl(url); product.setPrice(parsePriceText(priceText)); product.setPlatform(demo_mall); }这样做的好处很明显新增平台时只需要在配置里加一段选择器规则代码完全不动。页面改版时运维或开发人员改配置就能恢复采集。甚至可以把配置放在数据库里通过后台管理页面动态调整连重启都不用。很多开源Java采集框架也是这个思路把“采集规则”和“采集逻辑”解耦。配置化也有个边界只适合“结构稳定、选择器能精确定位”的页面。如果某个平台的前端是重度JS渲染HTML里根本没有价格数据那配置CSS选择器没有意义得先去分析页面的接口请求再决定是直接请求接口还是用无头浏览器。4. 调度、去重与价格波动让爬虫进入长跑模式4.1 定时任务怎么设fixedDelay比fixedRate安全比价爬虫的调度配置直接影响能不能长期稳定运行。很多新手用fixedRate设定固定频率结果任务还没执行完下一次就开始了线程越堆越多最后整个应用卡死。Spring的Scheduled有两个核心参数要区分。fixedDelay是“上一次执行完成后隔多久再执行下一次”fixedRate是“从开始时间算起固定周期执行”。对爬虫来说前一种才是对节奏友好的。网络抖动时某次采集慢了fixedRate不会等会造成任务堆积fixedDelay则让节奏自动变慢。Component public class PriceCrawlScheduler { private final CrawlTaskService taskService; public PriceCrawlScheduler(CrawlTaskService taskService) { this.taskService taskService; } // 固定延迟上一次跑完再等30分钟 Scheduled(fixedDelay 30 * 60 * 1000, initialDelay 10_000) public void runPriceCrawl() { ListCrawlTask tasks taskService.fetchDueTasks(500); tasks.forEach(task - crawlExecutor.execute(() - { try { CrawlResult result crawler.crawlAndParse(task.getUrl()); pipeline.process(task, result); } catch (Exception e) { logger.error(task failed: {}, task.getUrl(), e); } })); } }initialDelay是启动后延迟10秒再执行第一次给应用一个缓冲时间。如果系统里同时有多个定时任务尽量让它们错开启动时间避免同一时刻触发大量HTTP请求。线程池参数也要克制。比价爬虫的并发不是越大越好单机4到8个采集线程是比较稳妥的起点。配合CallerRunsPolicy拒绝策略线程池满了就让提交任务的线程自己去执行相当于天然限流。Bean(name crawlExecutor) public ThreadPoolTaskExecutor crawlExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(price-crawl-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这套配置的思路是系统忙时自动降速系统闲时保持基本并发而不是小区里闯红灯等门口冲一片然后被封。实践中“慢就是快”是真话。4.2 URL和商品指纹去重两种指纹承载不同使命采集链路里有两个地方会产生重复。一是在列表页重复看到同一个链接二是详情页URL变了但商品的SKU没变。所以去重要分两层URL指纹和商品指纹。URL指纹用于采集队列判断“这个页面已经采过了”。由于电商URL经常带一堆无用的统计参数先做一次归一化再取MD5。商品指纹用于数据库判断“这个商品已经入库了”。public class Fingerprints { public static String urlFingerprint(String url) { String normalized url.replaceAll(\\?.*, ) .replaceAll(#.*, ) .trim(); return DigestUtils.md5Hex(normalized); } public static String skuFingerprint(String platform, String skuId) { return DigestUtils.sha256Hex(platform | skuId); } }URL归一化要小心有些电商URL的query参数里确实带SKU信息全部去掉会导致不同商品被误判为同一个。稳妥的做法是保留“可以识别的关键参数”比如去掉utm_source、spm等跟踪参数但保留id、sku、productId这类业务标识。Redis里的SetNX操作是判断“第一次见”的高效手段Boolean firstSeen stringRedisTemplate .opsForValue() .setIfAbsent(sku: fingerprint, 1, Duration.ofDays(7)); if (Boolean.TRUE.equals(firstSeen)) { goodsService.insertIfAbsent(goods); } snapshotService.save(goodsId, snapshot);这里设置7天过期是平衡方案。比价场景中一个商品7天内一般会经过多轮采集不需要让指纹永久留存过期后自然会让商品进入一轮“重新检查”。如果是百万级商品量建议用布隆过滤器代替Redis存储能节省大量内存。4.3 采集节奏控制用RateLimiter而不是乱sleep随机延迟是不少人用的方案但不要全链路到处sleep。比较稳的做法是把限流放在调度层用一个令牌桶控制全局QPS。import com.google.common.util.concurrent.RateLimiter; Component public class CrawlThrottle { private final RateLimiter limiter RateLimiter.create(5.0); public void acquire() { limiter.acquire(); } }RateLimiter.create(5.0)表示每秒发放5个令牌也就是全局限速5QPS。每次发起HTTP请求前调用throttle.acquire()请求自然排队不会瞬间冲爆。这个参数的设定原则先设一个保守值跑到一天观察目标站点是否出现请求失败、验证码、响应变慢再逐步微调。QPS不是越高越好比价系统更重要的是“持续性”。宁可每天采到的数据量少一点也不要把目标站点的风控系统触发。真实场景里一个平台单IP 3到10QPS是比较常见的上限区间具体要看站点规模和反爬强度。控制节奏要控制住了上限。4.4 价格波动判定低价告警不能只看单次快照价格快照入库后下一步就是要不要通知用户“降价了”。直接拿当前价格和上一次快照比较误报率会高得离谱秒杀价、无门槛券、临时活动都会触发降价通知。用户被忽悠两次就不再相信你的数据了。更稳的做法是引入近7日均价作为基准线SELECT AVG(current_price) AS avg_price FROM t_price_snapshot WHERE goods_id #{goodsId} AND captured_at DATE_SUB(NOW(), INTERVAL 7 DAY) AND promotion_type NORMAL;在代码里比较当前价与均价BigDecimal avgPrice snapshotService.getAvgPrice(goodsId); if (avgPrice null) { return; } BigDecimal ratio avgPrice.subtract(current) .divide(avgPrice, 4, RoundingMode.HALF_UP); if (ratio.compareTo(new BigDecimal(0.05)) 0) { alertService.push(降价提醒, goods, avgPrice, current); }这里用“低于7日均价超过5%”做触发阈值。5%是一个经验值正常的价格波动幅度小于2%左右超过5%基本意味着真实调价。促销价在判断时单独打标签不混进均价计算。这样才能做到“比价”而不是“比促销”。5. 比价爬虫避坑五条踩出来的实战经验5.1 价格解析到0元或天文数字现象数据库里出现0.00的价格或者把“限时抢购12:00”解析成了价格1200。原因价格选择器匹配到的文本不是纯价格数字可能包含倒计时、促销文案清洗函数也没做合理性校验只要文本里有数字就取。解决在价格进入入库前加一道范围校验低于0.01或高于999999直接丢。同时解析时要先观察页面结构价格区域如果用多个span拼出来的优先取靠近“”符号的节点。5.2 详情页字段缺失整个任务线程崩溃现象日志里一堆NullPointerException后续商品全部没采。原因详情页偶尔缺评价数、缺库存状态代码却没做空值判断取到的Element为null直接调用.text()。解决详情页处理单独抽方法异常捕获后只记日志不影响采集线程。采集链路任何节点都不能因为单条数据挂掉。日志里记录失败商品ID后续人工或补采任务介入。5.3 反爬识别请求变慢、出现验证页现象采集前半小时一切正常之后突然响应变慢页面内容变成验证码页或“访问异常”。原因单IP请求频率超过站点阈值或者请求头特征与真实浏览器差异太大比如Referer为空、Accept头缺失。解决调度层限速保持稳定节奏补齐请求头。大促期间电商平台会提高反爬等级比价系统也需要提前降低频率。更彻底的做法是部署到多节点让不同节点分时段、分平台采集把压力分散到多个出口IP上。单机单IP硬扛早晚被封。5.4 商品信息和价格快照入库不一致现象价格快照已经写进数据库但商品标题、图片没更新页面展示时快照对应的商品信息对不上。原因两个表分两步写入中间发生异常没有事务保护。解决把“更新商品信息 插入价格快照”放进同一个事务方法里。Transactional(rollbackFor Exception.class) public void saveCrawlResult(CrawlResult result) { goodsMapper.updateGoods(result.getGoods()); snapshotMapper.insert(result.getSnapshot()); }这里要注意事务方法不要跨越网络请求。采集动作应该在事务外面完成事务里只做数据库操作否则一个长事务会锁表锁很久。5.5 低价误报先涨后降、满减叠加现象系统推送“降价20%”用户点进去一看只是从划线价变成日常价根本没降价。原因前面推送用当前快照对比商品表里的“参考价”而这个参考价来自页面上的划线价本身就虚高。解决用时间窗口均价作为参照并强制过滤促销类型。还有一个细节对“满299减30”这类优惠必须确认块钱是否默认自动参与结算如果用户要手动领券才能享受不要把券后价当成真实价格展示。比价系统最怕的就是诱导用户点击然后实际价格对不上信任一崩就很难回来了。6. 验证与进阶让比价数据可靠到可以对外爬虫做得再好数据不可验证等于没用。每轮采集任务跑完后至少要验证两件事抓取成功率、价格异常率。做一张简单的每日质量表会让问题清晰很多。SELECT DATE(captured_at) AS day, COUNT(*) AS snapshot_cnt, SUM(CASE WHEN current_price 0 THEN 1 ELSE 0 END) AS zero_cnt, SUM(CASE WHEN current_price IS NULL THEN 1 ELSE 0 END) AS null_cnt FROM t_price_snapshot GROUP BY DATE(captured_at) ORDER BY day DESC LIMIT 30;如果zero_cnt超过snapshot_cnt的1%基本可以确定解析规则出了问题。这时候不是急着改代码而是先翻出当天采集到的原始HTML看页面是不是改版了。进阶方向是把告警从“降价通知”升级为“价格异常波动”。比如某个商品在一小时内价格变化超过15%触发人工复核某平台连续3次返回异常状态码自动暂停该平台的采集任务。这些规则让系统有了自保护能力而不是傻傻地反复撞墙。我个人习惯是把每次采集任务的元数据也存下来任务开始时间、结束时间、成功数、失败数、平均响应时间。这些数据积累一个月后你能看到平台的“脾气”什么时候响应慢、什么时候容易触发验证码、哪些品类的页面结构不稳定。再回头调调度参数时就不是玄学而是基于数据的决策。比价爬虫做到最后真正的壁垒不是代码而是数据治理采集覆盖率、价格准确率、历史数据完整性。你每天积累下来的快照才是比价网最有价值的东西。希望这篇笔记能帮你绕过那些我踩过的坑把时间花在更有价值的数据分析上。本文还有配套的精品资源点击获取
网站建设高端定制企业官网