1. 项目概述与需求拆解
1.1 这个项目解决什么问题
先说结论:这个java爬虫项目,目标就是把银行理财产品的数据从公开页面抓下来,存成结构化数据,方便后续做收益对比、产品筛选或者监控提醒。
银行理财数据有个特点——更新频繁、字段复杂、分散在各大银行官网和第三方理财平台。手动去查不仅慢,而且容易漏。写个爬虫定时跑一遍,把产品名称、发行银行、预期收益率、风险等级、起购金额、期限、募集期这些关键字段自动抓下来,属于典型的"用程序代替重复人工"场景。
我在接到这个需求的时候,第一反应就是:这活儿没那么简单。银行网站的反爬策略、数据渲染方式、字段命名差异,每一样都是坑。所以这篇博文会把整个项目的设计思路、核心代码、踩坑实录完整拆开讲,适合有一定Java基础、想入门爬虫或者正在做类似数据采集项目的朋友参考。
1.2 为什么用Java而不是Python
我知道现在一提爬虫,大家第一反应就是Python。确实,requests加BeautifulSoup写起来快,Scrapy框架也成熟。但我这个项目最终选了Java,有几个实际考量:
- 公司技术栈就是Java:项目要接入现有的Spring Boot服务体系,用Java写爬虫可以直接复用现有的HttpClient、JSON解析、数据库访问层,不需要额外维护一套Python服务。
- 并发控制更顺手:银行理财数据源不止一个,几个平台同时抓取是很常见的需求。Java的线程池、Future、CompletableFuture用起来非常成熟,性能和可控性都更好。
- 部署运维省心:打成一个Jar包丢到服务器上就能跑,跟现有的定时任务、监控告警系统无缝对接。
当然,如果你是个人学习、就抓一两个小网站,Python确实上手更快。但涉及银行这种数据量大、反爬严格、需要长期维护的场景,Java的工程化优势会越来越明显。
1.3 核心技术点一览
这个项目涉及的技术点,我列了一张清单,后面会逐个展开:
| 技术点 | 用途 | 难度 |
|---|---|---|
| HttpClient | 发送HTTP请求,获取页面数据 | 低 |
| Jsoup | 解析HTML,提取DOM节点数据 | 低 |
| Fastjson / Jackson | 解析JSON格式的接口返回 | 低 |
| 线程池 | 多数据源并发抓取 | 中 |
| 代理IP池 | 绕过IP频率限制 | 中 |
| Spring Boot + Quartz | 定时任务调度 | 中 |
| MyBatis / JPA | 数据持久化存储 | 低 |
2. 数据源分析与抓取策略设计
2.1 数据源选择的门道
银行理财数据,表面上看就是"产品名称+收益率"这么简单,实际抓起来会发现数据源分好几类,每一类的抓取难度都不一样。
第一类是银行官网直销页面。这类页面通常是最权威的,但问题在于——有的银行是服务端渲染,HTML里直接有完整数据,这种最好抓;有的银行是前端Vue/React渲染,数据通过Ajax接口异步加载,这种就需要找到背后的JSON接口;还有的银行页面用了大量JS混淆,连接口都藏得深。我实际调研下来,国有大行的官网反爬相对宽松,股份制银行次之,城商行反而偶尔会有一些奇怪的验证逻辑。
第二类是第三方理财平台。比如各类基金销售平台、理财超市,它们把多家银行的产品聚合在一起,数据格式统一、字段齐全,抓取效率最高。但这类平台的反爬通常比银行官网严格,因为它们靠数据吃饭,对爬虫的容忍度很低。UA校验、IP频率限制、滑块验证,样样都有。
第三类是数据服务商的开放接口。有些平台提供官方API,注册后就能按接口规范拉数据。这是最省事的方案,但需要商务合作或者有账号权限,不是所有场景都能用。
我最后采用的策略是:以银行官网为主,以第三方平台为辅,优先找JSON接口,找不到再解析HTML。这样能最大程度平衡数据准确性和抓取可行性。
2.2 抓取频率与调度策略
爬虫的频率设计,直接决定了你能不能长期稳定跑下去。我见过太多人一上来就高频抓取,结果第二天IP就被封了,然后跑来问怎么办。
我的经验是:对银行数据,单源请求间隔不低于3秒,单日单源抓取次数控制在合理范围。银行理财产品的更新频率本身就不高,一天抓一次完全足够,没必要没事就去骚扰人家服务器。如果你是做日内收益率波动的监控,那可以上午一次、下午一次,间隔拉长到几个小时。
具体到定时调度,我用的是Spring Boot集成Quartz。配置一个Cron表达式,比如每天上午9点跑一次,跑完自动入库,然后发一条通知到企业微信或者钉钉群,告诉我"今日采集完成,新增X条,更新Y条"。这套下来,基本就属于无人值守的状态了。
调度的Cron表达式长这样:
// 每天上午9点执行一次 @Scheduled(cron = "0 0 9 * * ?") public void dailyCollectTask() { collectService.executeAllSources(); }2.3 抓取策略的取舍
做爬虫最忌讳的就是"一个方案走到黑"。我的习惯是给每个数据源都设计a、b两套方案。
a方案是直接请求JSON接口。浏览器F12打开开发者工具,Network面板里找XHR请求,翻翻接口返回的数据结构。如果接口返回的是标准的JSON格式,且没有加密参数,那恭喜你,这是最省力的路径。用HttpClient请求一下,Fastjson解析一下,数据就拿到了。
b方案是解析HTML。如果接口加密了,或者数据混在页面里没有独立接口,那就退而求其次,用Jsoup直接解析HTML页面。虽然效率低一点,但胜在通用。
实操中还有c方案,就是用Selenium或Playwright模拟浏览器操作,专门对付那种JS渲染严重、接口加密复杂的页面。这个方案最重,资源消耗大,速度慢,但确实是最后一道保险。我一般只有在a、b方案都行不通的情况下才上它。
3. 核心代码实现与踩坑实录
3.1 HTTP请求模块的实现
请求模块是整个爬虫的地基。别小看这一步,请求头的伪装、超时时间的设置、重试机制的实现,每一项都影响最终的抓取成功率。
我用自己的代码为例,先展示一个最基础的请求封装:
public class HttpUtil { private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); public static String get(String url) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(15)) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .header("Accept", "text/html,application/json") .header("Accept-Language", "zh-CN,zh;q=0.9") .GET() .build(); HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { return response.body(); } throw new RuntimeException("请求失败,状态码:" + response.statusCode()); } }这里有几个细节值得说明:
UA一定要伪装成真实浏览器。有些网站对非浏览器UA直接拒绝服务,返回403。我用的这个UA串是Chrome浏览器的标准UA,大部分情况下都能过。如果遇到更严格的校验,还需要补上Cookie、Referer等Header信息。
超时时间一定要设置。银行网站的响应速度经常不稳定,有时候一个页面能卡好几十秒。如果没有超时设置,爬虫线程可能会被一个坏请求拖死,连带整个任务队列都卡住。10秒连接超时、15秒读取超时,是我多次测试后觉得比较合适的值。
重试机制很有必要。网络抖动是常态,遇到超时或者504,不要立刻放弃,重试2-3次往往就有惊喜。但要注意,重试间隔要有退避策略,比如第一次等2秒,第二次等5秒,第三次等10秒,而不是死循环立刻重试。
3.2 JSON解析与字段提取
银行理财产品的JSON接口,数据结构通常长这样:
{ "code": "000000", "message": "success", "data": { "total": 156, "list": [ { "prdCode": "ZZ120241001", "prdName": "某某银行2024年第100期理财产品", "yieldRate": 3.25, "riskLevel": "R2", "startAmount": 10000, "termDays": 180, "saleStart": "2024-06-01", "saleEnd": "2024-06-07" } ] } }用Fastjson解析这样的结构非常简单:
public List<FinancialProduct> parseProductList(String jsonStr) { JSONObject root = JSON.parseObject(jsonStr); if (!"000000".equals(root.getString("code"))) { throw new RuntimeException("接口返回异常:" + root.getString("message")); } JSONObject data = root.getJSONObject("data"); JSONArray list = data.getJSONArray("list"); return list.toJavaList(FinancialProduct.class); }有一点要注意:银行接口的字段命名五花八门,同一个"预期收益率",有的叫yieldRate,有的叫expReturn,有的叫yearIncomeRate。建议写一个字段映射工具类,把不同来源的字段统一映射成我们自己的标准命名。
还有,字段值的格式也需要统一处理。比如收益率有的平台给的是百分比数值3.25,有的给的是小数0.0325,还有的给的是字符串"3.25%"。这些都需要在解析的时候做清洗统一。
3.3 HTML解析兜底方案
JSON接口行不通的时候,就要靠Jsoup解析HTML了。我举一个例子,假设银行页面的产品列表长这样:
<div class="product-item"> <span class="name">某某理财产品</span> <span class="rate">3.10%</span> <span class="risk">中低风险</span> <span class="amount">1万元起购</span> </div>Jsoup解析代码:
public List<FinancialProduct> parseHtml(String html) { Document doc = Jsoup.parse(html); Elements items = doc.select(".product-item"); List<FinancialProduct> list = new ArrayList<>(); for (Element item : items) { FinancialProduct p = new FinancialProduct(); p.setPrdName(item.selectFirst(".name").text().trim()); String rate = item.selectFirst(".rate").text().replace("%", "").trim(); p.setYieldRate(new BigDecimal(rate)); p.setRiskLevel(parseRisk(item.selectFirst(".risk").text())); p.setStartAmount(parseAmount(item.selectFirst(".amount").text())); list.add(p); } return list; }经验之谈:HTML解析最大的坑不是选择器写不出来,而是网站的改版。银行页面经常优化调整,有时候改一个class名,你整个解析逻辑就废了。所以我的做法是:解析逻辑单独封装,页面结构发生变化时,只需要改一个方法,不需要动整个爬虫框架。
另外一个坑是,有的页面数据是分页加载的。你需要先解析出总页数,然后循环请求每一页。这里要注意:循环请求的间隔别太短,每页之间至少间隔1-2秒,否则很容易触发反爬。
3.4 并发抓取设计
多个数据源同时抓取,用串行方式一个个跑太慢了。以我的经验,3个以上数据源就应该考虑并发。
Java实现并发抓取,我推荐用线程池加Future,代码写起来干净,可控性好:
public List<FinancialProduct> concurrentCollect() throws Exception { ExecutorService pool = Executors.newFixedThreadPool(4); List<Callable<List<FinancialProduct>>> tasks = new ArrayList<>(); tasks.add(() -> collectFromBankA()); tasks.add(() -> collectFromBankB()); tasks.add(() -> collectFromPlatformC()); List<Future<List<FinancialProduct>>> futures = pool.invokeAll(tasks); List<FinancialProduct> result = new ArrayList<>(); for (Future<List<FinancialProduct>> future : futures) { try { result.addAll(future.get()); } catch (ExecutionException e) { log.error("某个数据源采集失败", e.getCause()); } } pool.shutdown(); return result; }这里有几个容易踩的坑:
线程池核心线程数不要贪多。有些新人一看有5个数据源,就开10个线程,结果把对方网站请求压力搞得很大,IP很快被限制。我的经验是,线程数不要超过数据源数量,一般4到6个就够了。爬虫这种IO密集型的任务,线程太多反而会让自己机器的CPU上下文切换变高,而且对目标站点的压力也不好。
线程池参数需要结合Xxl-Job或Spring Task指定线程大小。如果是用Spring的@Async,记得单独定义一个线程池Bean,不要用默认的SimpleAsyncTaskExecutor,那个是来一个任务new一个线程,完全不可控。
Future.get会阻塞等待,如果某一个数据源响应特别慢,会拖慢整个流程。解决方案有两个:一个是Future.get设置超时时间,比如10秒;另一个是用CompletableFuture配合allOf,实现"等待所有任务完成,但有一个失败也不影响其他任务的结果收集"。
3.5 代理IP池的使用
银行理财数据抓取,IP被封的概率说大不大、说小不小。如果你就抓一两个数据源、频率也不高,基本用不到代理。但如果你的爬虫要跑很多数据源、一天抓好几次,那还是建议准备一个代理IP池。
代理IP池的核心逻辑很简单:
public class ProxyManager { private Queue<Proxy> proxyPool = new ConcurrentLinkedQueue<>(); /** * 从代理服务商接口拉取可用IP,放入本地队列 */ public void refreshProxies() { String apiUrl = "http://代理服务商提供的API地址"; String result = HttpUtil.get(apiUrl); List<Proxy> proxies = parseProxyList(result); proxyPool.clear(); proxyPool.addAll(proxies); } public Proxy getProxy() { // 轮询取用,用完放回 Proxy proxy = proxyPool.poll(); if (proxy != null) { proxyPool.offer(proxy); } return proxy; } }这里要提醒一句:代理IP的质量比数量重要。有些免费代理列表看着一大堆,实际用起来全是废的——要么连不上,要么速度极慢,用了反而拖垮整个抓取效率。我的建议是找稳定的付费代理服务商,价格不贵,省心很多。
另外,代理IP不是每请求一个页面就换一个,那样反而容易触发异常检测。我的策略是:同一个数据源,整个抓取过程使用同一个代理IP,除非被拒绝才切换。这样模拟的是一个固定IP的正常访问行为,更不容易被识别。
4. 数据入库与更新策略
4.1 表结构设计
抓到数据之后,存什么、怎么存,直接决定了后续的数据分析和报表展示是否方便。我用的表结构供大家参考:
CREATE TABLE `t_financial_product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `prd_code` varchar(64) DEFAULT NULL COMMENT '产品代码', `prd_name` varchar(256) DEFAULT NULL COMMENT '产品名称', `bank_name` varchar(128) DEFAULT NULL COMMENT '发行银行', `yield_rate` decimal(8,4) DEFAULT NULL COMMENT '预期年化收益率/%', `risk_level` varchar(16) DEFAULT NULL COMMENT '风险等级', `start_amount` decimal(12,2) DEFAULT NULL COMMENT '起购金额/元', `term_days` int(11) DEFAULT NULL COMMENT '期限天数', `sale_start` date DEFAULT NULL COMMENT '募集开始日', `sale_end` date DEFAULT NULL COMMENT '募集结束日', `source_url` varchar(512) DEFAULT NULL COMMENT '来源页面', `collect_time` datetime DEFAULT NULL COMMENT '采集时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_prd_code` (`prd_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里要注意一个关键点:产品代码的唯一索引设计。同一个理财产品,每次抓取的收益率、募集期是变化的,但产品代码是不变的。所以用prd_code作为唯一键,可以方便后续做更新操作——如果prd_code已经存在,就更新收益率和募集期;如果不存在,就插入新记录。
4.2 增量更新与去重逻辑
爬虫最忌讳的就是每次全量插入,跑几天下来数据全重复了。我的更新策略是"先查后插或直接更新":
public void saveOrUpdateProduct(FinancialProduct product) { FinancialProduct exist = mapper.selectByPrdCode(product.getPrdCode()); if (exist == null) { mapper.insert(product); } else { exist.setYieldRate(product.getYieldRate()); exist.setRiskLevel(product.getRiskLevel()); exist.setSaleStart(product.getSaleStart()); exist.setSaleEnd(product.getSaleEnd()); exist.setUpdateTime(new Date()); mapper.update(exist); } }这个逻辑看起来简单,但有一个潜在问题:并发环境下,两个线程同时查到不存在,然后同时插入,就会触发唯一键冲突。解决方式有两种:
一种是给数据库操作用ON DUPLICATE KEY UPDATE语法,直接交给数据库处理冲突:
INSERT INTO t_financial_product (prd_code, prd_name, bank_name, yield_rate, ...) VALUES (#{prdCode}, #{prdName}, #{bankName}, #{yieldRate}, ...) ON DUPLICATE KEY UPDATE yield_rate = VALUES(yield_rate), risk_level = VALUES(risk_level), update_time = NOW()另一种是在Java代码里加分布式锁或者同步锁,但考虑到爬虫这种场景,数据库级别的冲突处理已经足够了。
4.3 数据校验与清洗
银行数据虽然是公开的,但抓下来的数据也要做好校验,避免脏数据污染整个数据库。我总结了几个常见的脏数据场景和处理方式:
收益率为空或为0。有些产品是浮动收益,页面显示"以实际收益为准",没有具体数值。这种情况我建议不直接丢弃,而是存一个特殊标记,比如-1表示"未知",省得影响后续的均值计算。
产品名称重复但代码不同。有的银行不同期次产品名称非常相似,只有代码不同。这种情况下需要人工核对,是名称真的重复,还是代码规则不一致。我一般会用prd_code作为唯一标识,名称为辅,这样即使名称重复也不会影响数据质量。
募集期已经结束的产品。抓到的产品可能已经过了募集期,理论上不应该再展示给用户。我在入库前会做一个过滤:sale_end小于当前日期的,标记为"已过期",后续查询时默认不展示。
5. 常见问题与反爬应对
5.1 遇到403 Forbidden怎么办
403是爬虫最常见的响应状态码之一,通常意味着服务器拒绝了你的请求。我排查403的顺序是:
第一步,检查UA有没有设置。很多初级爬虫失败就是忘了设置UA,服务器一看请求头里没有浏览器标识,直接拒绝。把UA改成真实浏览器的UA,通常就能解决。
第二步,检查是否需要Cookie。有些网站首次访问需要在响应里种Cookie,后续请求要带着Cookie才能访问。这种情况需要用HttpClient的CookieManager来管理Cookie,或者手动从浏览器复制一份Cookie,放在请求头里携带。
第三步,检查请求频率。如果之前一段时间请求太频繁,IP可能已经被限制了。这时候暂停几分钟再试,或者换代理IP。
第四步,如果以上都没用,那大概率是需要Referer校验。有些网站会检查请求的来源地址,如果Referer不是它自己的域名,就会拒绝服务。把Referer设置成目标网站的首页地址,很多问题就迎刃而解了。
5.2 页面数据是JS动态渲染的怎么处理
现在很多银行网站都是前后端分离架构,页面数据靠JS异步加载。你直接请求URL,拿到的HTML里面是一个空壳,啥数据都没有。
这种场景,最关键的是找到它背后的数据接口。怎么找?
按F12打开开发者工具,切到Network面板,刷新页面,然后筛选XHR请求。一个个看返回内容,找到返回JSON数据的那个请求,记录下它的URL、请求方式、参数列表。通常这个接口就是数据的真实来源。
找到了接口之后,如果请求参数很简单,直接用HttpClient模拟就行。但如果参数里有加密字段,比如sign、token之类的,那就比较麻烦了。这时候有两条路:
一是通过代码模拟加密逻辑。但前提是你得能看懂它的加密算法——通常是MD5、SHA、Base64加盐之类。这个工作量说大不大说小不小,看具体加密复杂度。
二是直接上Selenium。Selenium完整驱动一个浏览器去访问页面,等JS执行完再抓取数据,从逻辑上规避了加密参数的问题。代价就是性能差、资源占用高,适合数据量不大的场景。
对于银行理财产品这种页面,我个人的经验是:大概一半的网站能找到公开无加密的JSON接口,另外一半需要Selenium或者模拟加密。具体碰到什么情况,看运气也看耐心。
5.3 采集频率过高被限制IP
这是我踩过最实在的坑了。刚开始做爬虫的时候,心气高,想着快点把数据抓完,设置了很短的间隔,1秒一次甚至更快。结果没跑几分钟,IP就被封了,搞得整个项目进度都延期。
被限制之后怎么判断?两个信号:一是请求突然开始大量返回403或者验证码;二是有些页面开始跳转到安全验证页面。如果出现这两种情况,基本就是被盯上了。
解决办法除了降低频率、换代理IP之外,还有一个务实的思路:和对方网站"做朋友"。有些银行的理财页面,如果请求频率控制得当,其实是允许数据访问的。我的经验是,单源单IP的请求间隔保持在3-5秒,每天抓取频率控制在几次以内,长期运行基本不会出问题。
5.4 编码乱码问题
很多银行网站还是用的GBK或者GB2312编码。你用UTF-8解析,中文全变乱码。
解决方案很简单,用Jsoup的话,指定编码解析:
Document doc = Jsoup.parse(html, "GBK");如果是HttpClient拿到的字符串,可以在获取响应的时候指定解码字符集:
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));但这里有个坑:服务器告诉你的charset可能和实际内容编码不一致。我的习惯是,先用正则解析HTML中的charset声明,如果解析不到,再尝试GBK解析,还不行就用UTF-8。做一个自动检测的编码处理工具,算是比较稳妥的方案。
6. 项目扩展与深度思考
6.1 从单机爬虫到分布式爬虫
当抓取数据源数量增加、单个数据源的页面数量增多时,单机爬虫的性能瓶颈就显现出来了。分布式爬虫并不是一个新东西,核心思路就是"任务分发、并行执行、结果汇总"。
Java生态里,可以用Redis做任务队列,用一个Scheduler服务管理任务分发,多台机器各自跑Worker节点执行抓取任务。每个Worker抓到的数据统一写入中心数据库或者消息队列。
但这个方案对银行理财数据来说,我认为是"杀鸡用牛刀"了。银行理财产品的数据源总共就那么多,单机完全能扛住。分布式爬虫更适合那种几百万级URL的大规模数据采集场景,比如电商全站商品抓取、社交媒体数据抓取。如果你只是爬银行理财数据,单机加合理调度足够了。
6.2 AI技术与爬虫的结合
最近很多人讨论AI和爬虫的关系。我的看法是:AI不是替代爬虫,而是让爬虫变得更加智能。
传统的爬虫是"规则驱动"的——你告诉他怎么解析,他就怎么解析。但网页结构一变,规则就失效。而基于大模型或机器学习的方式,可以从大量页面样本中学习到数据的规律,即使页面结构变化,依然能准确抽取目标字段。这在处理银行理财这种字段命名不统一、页面结构多变的场景,价值非常明显。
我后续打算尝试的方案是:用大模型做字段抽取的后备方案。当正则和JSON解析失效时,把页面文本交给大模型,让它把关键字段提取出来。虽然有一定成本,但相比人工去适配网站改版,效率提升是数量级的。不过这条路径还没完全跑通,目前还处于实验阶段,这里就不展开讲了。
6.3 合规使用爬虫数据
最后必须聊一聊合规问题。爬虫抓取数据,有条件限制,个别场景甚至可能踩法律红线。我的原则很简单:
第一,只抓公开数据,不绕过登录验证、不破解加密算法去获取非公开数据。理财产品信息本身是公开的,抓取这类数据风险相对较低。
第二,抓取频率保持克制,不给目标网站造成压力。把对方服务器搞挂了,既对别人不负责,对自己也没好处。
第三,抓到的数据自己分析使用可以,不要把原始数据打包出售给做数据生意的人,这是明确的高风险行为。
关于银行理财数据,还有一个值得注意的高级话题:通过爬虫把数据积攒起来后,可以做产品对比分析、历史收益率走势预测等增值服务。有投资分析需求的朋友,会发现长期积累的理财数据非常珍贵,这里面的价值远比"抓一次看看"要大得多。我自己的做法是,把数据存到ES里,配合Kibana做可视化分析,一眼就能看出不同银行、不同期限产品的收益率分布情况,这是手动去官网对比完全做不到的效率。
也算是一点私心:这个项目做到后面,与其说是爬虫技术练习,不如说是给自己搭建了一个理财信息聚合工具,一举两得的事。