SpringBoot+爬虫高考志愿推荐系统:数据抓取与位次法冲稳保实现

发布时间:2026/10/30 16:54:19
SpringBoot+爬虫高考志愿推荐系统:数据抓取与位次法冲稳保实现

简介:这份资源是面向高校计算机专业学生与Java开发学习者的高考志愿智能推荐系统完整源码,基于SpringBoot框架搭建后端服务,并融合爬虫数据采集与智能推荐算法,帮助解决志愿填报中信息分散、决策困难的问题,适合作为课程设计、毕业设计或全栈练手项目。压缩包共672个文件,约34.6MB,以135个Java源文件与99个Vue前端组件为主体,辅以63个JavaScript脚本、17个XML配置、15个CSS样式及大量svg、jpg、png等界面素材,另含Python爬虫脚本、SQL建库文件与项目说明文档,前后端与数据采集链路相对完整。目前已有83人学习下载。读者可从中获取可运行的SpringBoot工程结构、爬虫采集与反爬处理思路、推荐算法落地代码以及MySQL数据表设计参考,便于快速理解系统整体架构并在此基础上二次开发或撰写技术文档。

1. 高考志愿推荐系统:把 SpringBoot 和爬虫拼成一条能跑通的数据链

每年六月,后台总有人问同一个问题:手里有一堆院校投档线、专业录取位次,怎么把它变成一个能按分数推荐志愿的系统?这个标题里的 SpringBoot 加爬虫高考志愿智能推荐系统,本质就是回答这件事——用爬虫把散落在各省考试院、院校招生网的录取数据抓下来,用 SpringBoot 做后端服务,再按位次和线差算出一份冲稳保的志愿表。它适合两类人:一类是计算机专业做毕设的学生,需要一套结构完整、能讲清楚数据来源和推荐逻辑的项目;另一类是真正想给自己或亲戚孩子做志愿参考的开发者,愿意花几天把数据链路搭起来。核心难点从来不是推荐算法有多玄学,而是数据从哪来、怎么清洗、位次怎么对齐,这三件事决定了系统是能用的工具还是只能演示的壳子。

2. 数据从哪来:爬虫抓取高考录取数据的三种落地路径

2.1 先想清楚抓什么:院校、专业、投档线、位次四张表

动手写爬虫之前,必须先把数据模型定下来,否则抓回来的东西是一团乱麻。高考志愿推荐真正需要的是四类数据:院校基本信息(院校名称、所在省份、办学层次、院校代码)、专业信息(专业名称、专业组、选科要求、学制)、历年投档线(年份、批次、科类、最低分、最低位次)、以及一分一段表(分数对应的累计人数)。这四张表里,一分一段表是最容易被忽略但最关键的一张,因为不同年份试卷难度不同,绝对分数没有可比性,只有位次才能跨年对齐。

我一般会先在数据库里把表结构建好,再反推爬虫要抓哪些字段。这样爬虫的解析逻辑就是围绕字段走的,不会抓一堆用不上的东西。下面是一个精简的表结构,用 MySQL 建:

-- 院校表 CREATE TABLE college ( id INT PRIMARY KEY AUTO_INCREMENT, college_code VARCHAR(20) COMMENT '院校代码', college_name VARCHAR(100) COMMENT '院校名称', province VARCHAR(20) COMMENT '所在省份', level VARCHAR(20) COMMENT '办学层次:本科/专科', tags VARCHAR(200) COMMENT '985/211/双一流等标签' ); -- 投档线表,核心是位次字段 CREATE TABLE admission_score ( id INT PRIMARY KEY AUTO_INCREMENT, college_id INT, year INT COMMENT '年份', batch VARCHAR(20) COMMENT '批次', subject_type VARCHAR(10) COMMENT '科类:物理/历史', min_score INT COMMENT '最低分', min_rank INT COMMENT '最低位次', INDEX idx_college_year (college_id, year) ); -- 一分一段表 CREATE TABLE score_rank ( id INT PRIMARY KEY AUTO_INCREMENT, year INT, subject_type VARCHAR(10), score INT, cumulative_count INT COMMENT '该分数及以上累计人数' );

字段设计里min_rank和cumulative_count是整套推荐逻辑的地基。很多毕设项目只存了分数,结果跨年推荐时误差大到没法看,就是因为丢了位次。建表时把索引加上,后面按院校和年份查询会快很多。

2.2 用 requests 加 xpath 抓考试院页面:一个可复用的解析模板

各省考试院的数据发布形式差别很大,有的是静态 HTML 表格,有的是 JS 渲染后才有内容,还有的干脆只给 PDF。最常见也最好处理的是静态表格页,用 requests 拿 HTML,再用 lxml 的 xpath 提取。下面这个模板我用了很多次,改改 xpath 就能套到不同省份的页面上:

import requests from lxml import etree import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" } def fetch_page(url, retry=3): """带重试的页面抓取,失败返回 None""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding # 防止中文乱码 if resp.status_code == 200: return resp.text except requests.RequestException as e: print(f"第{i+1}次请求失败: {e}") time.sleep(random.uniform(1, 3)) # 随机间隔,降低被封风险 return None def parse_score_table(html): """解析投档线表格,返回字典列表""" tree = etree.HTML(html) rows = tree.xpath('//table[@class="score-table"]//tr[position()>1]') result = [] for row in rows: cells = row.xpath('./td/text()') if len(cells) < 5: continue result.append({ "college_code": cells[0].strip(), "college_name": cells[1].strip(), "min_score": int(cells[3].strip()) if cells[3].strip().isdigit() else 0, "min_rank": int(cells[4].strip()) if cells[4].strip().isdigit() else 0, }) return result if __name__ == "__main__": html = fetch_page("https://example-exam-edu.cn/score/2024") if html: data = parse_score_table(html) print(f"抓到 {len(data)} 条记录")

这段代码有三个关键点。apparent_encoding用来解决中文页面常见的乱码问题,比写死 utf-8 稳。time.sleep(random.uniform(1, 3))是随机间隔,固定间隔的请求节奏太规律,容易被识别成爬虫。xpath 里的position()>1是跳过表头行,这个写法比在循环里判断更干净。参数上,timeout=10别设太大,否则一个卡住的请求会拖垮整批任务;重试次数三次足够,再多说明目标站点本身有问题,该换数据源了。

2.3 抓取节奏与合规边界:别把系统做成一次性消耗品

爬虫这块最容易翻车的地方不是解析写错,而是请求太猛把人家站点打挂,或者触发风控导致 IP 被封。血泪经验是:单站点并发不要超过 2,请求间隔至少 1 秒,整批任务控制在几百个页面以内。如果数据量大,优先找官方开放的 Excel 或 PDF 汇总文件,比逐页抓 HTML 省事得多,也更稳定。

提示:抓取前先看目标站点的 robots.txt,只抓公开的招生录取数据,不要碰任何需要登录才能访问的内容。数据仅用于个人参考或学术研究,不要二次分发或商用。

另一个现实问题是数据更新。高考数据每年六月到八月集中发布,平时基本不变。所以爬虫不需要做成常驻服务,写成一个手动触发的批处理脚本就够了,跑完把数据入库,系统只读数据库。这样既降低了维护成本,也避免了定时任务在数据没更新时空跑。

3. SpringBoot 后端怎么搭:从数据入库到推荐接口

3.1 项目分层与依赖:controller、service、mapper 各管什么

SpringBoot 这套东西搭起来快,但分层乱了后面改起来很痛苦。我的习惯是严格按 controller 接请求、service 写业务、mapper 管数据库来分。推荐逻辑全部放在 service 层,controller 只做参数校验和结果包装。依赖上,除了 spring-boot-starter-web 和 mybatis-spring-boot-starter,再加一个 hutool 处理日期和集合操作,能省不少工具代码。

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency> </dependencies>

版本选择上有个坑:SpringBoot 3.x 要求 JDK 17 起步,如果你本地还是 JDK 8,要么升 JDK,要么把 SpringBoot 降到 2.7.x。很多人卡在启动报错上,查半天发现是版本不匹配。mybatis-spring-boot-starter 的版本要和 SpringBoot 大版本对应,3.x 的 starter 配 SpringBoot 3.x,别混用。

3.2 位次法推荐的核心算法:冲稳保三档怎么算出来

推荐逻辑说白了就一句话:拿考生的位次,去比对往年各院校专业的最低录取位次,按差距分档。差距在考生位次之上一定范围的算冲,接近的算稳,低于考生位次的算保。具体阈值我一般这样设:考生位次乘以 0.8 到 1.0 是冲,1.0 到 1.3 是稳,1.3 到 1.8 是保。这个系数不是拍脑袋,是根据多数省份录取位次的波动区间调的,你可以按自己省份的情况微调。

@Service public class RecommendService { @Autowired private AdmissionScoreMapper scoreMapper; /** * @param rank 考生位次 * @param year 参考年份 * @param subject 科类 * @return 冲稳保三档推荐结果 */ public Map<String, List<RecommendVO>> recommend(int rank, int year, String subject) { // 取该年份该科类下所有院校专业的最低录取位次 List<AdmissionScore> scores = scoreMapper.selectByYearAndSubject(year, subject); List<RecommendVO> chong = new ArrayList<>(); List<RecommendVO> wen = new ArrayList<>(); List<RecommendVO> bao = new ArrayList<>(); for (AdmissionScore s : scores) { double ratio = (double) s.getMinRank() / rank; RecommendVO vo = new RecommendVO(s.getCollegeName(), s.getMinRank(), ratio); if (ratio >= 0.8 && ratio < 1.0) { chong.add(vo); } else if (ratio >= 1.0 && ratio < 1.3) { wen.add(vo); } else if (ratio >= 1.3 && ratio <= 1.8) { bao.add(vo); } } // 每档按位次升序,越靠前越优先 chong.sort(Comparator.comparingInt(RecommendVO::getMinRank)); wen.sort(Comparator.comparingInt(RecommendVO::getMinRank)); bao.sort(Comparator.comparingInt(RecommendVO::getMinRank)); Map<String, List<RecommendVO>> result = new HashMap<>(); result.put("chong", chong); result.put("wen", wen); result.put("bao", bao); return result; } }

ratio是院校最低位次除以考生位次,比值小于 1 说明院校录取位次比考生更靠前,属于需要冲的。这个算法的好处是只用位次一个维度,跨年可比,不受试卷难度影响。参数上,0.8 和 1.8 这两个边界可以根据你所在省份的录取波动调整,波动大的省份可以把冲的范围放宽到 0.75。返回结果按位次排序,前端展示时冲的档位排前面,符合考生心理预期。

3.3 接口联调与前端对接:一个推荐请求的完整链路

后端接口写好后,前端传考生位次、科类、参考年份三个参数,后端返回三档列表。controller 层保持轻薄:

@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @GetMapping("/list") public Result<Map<String, List<RecommendVO>>> list( @RequestParam int rank, @RequestParam(defaultValue = "2024") int year, @RequestParam(defaultValue = "物理") String subject) { if (rank <= 0) { return Result.fail("位次必须大于0"); } return Result.ok(recommendService.recommend(rank, year, subject)); } }

参数校验放在 controller 里做,业务逻辑不掺和。Result是统一返回包装类,包含 code、msg、data 三个字段,前端好处理。联调时最常见的翻车是跨域,前端本地起服务调后端接口会报 CORS 错误,加一个全局跨域配置就行,别在每个 controller 上单独加注解,那样重复且容易漏。

4. 避坑与排查:这套系统最容易翻车的五个地方

4.1 位次对不上:跨年数据错位的排查

现象是推荐结果明显不合理,比如考生位次五万名,系统推荐了一堆往年录取位次两万名的院校当保底。原因通常是数据入库时把不同科类的位次混在一起了,物理类和历史类的位次是分开排的,混用会导致比值完全错乱。解决方法是入库时严格按年份加科类加批次做唯一约束,查询时三个条件都要带上,缺一个都会串数据。

4.2 爬虫被封:请求特征太明显的三个信号

现象是跑了几十个请求后开始返回 403 或者验证码页面。原因一般是请求头里缺 User-Agent、请求间隔固定、或者单 IP 并发太高。解决方法是补齐常见请求头、加随机间隔、控制并发数,必要时把任务拆成多天跑。别想着用多线程猛冲,数据量本来就不大,稳比快重要。

4.3 中文乱码:编码问题的定位顺序

现象是抓回来的院校名称全是问号或乱码。原因是页面编码和代码里指定的编码不一致。解决顺序是先看页面 meta 标签里声明的编码,再用apparent_encoding自动识别,最后才考虑手动指定。数据库连接串里也要加characterEncoding=utf8,否则入库那一步还会再乱一次。

4.4 推荐结果为空:数据没入库还是查询条件写错

现象是接口返回三个空列表。先查数据库里对应年份和科类有没有数据,如果没有就是爬虫入库环节断了;如果有数据但查不出来,多半是 mapper 的 SQL 条件写错,比如年份字段类型不匹配或者科类字符串带了空格。排查时把 SQL 打印出来直接在数据库里跑一遍,比在代码里猜快得多。

4.5 启动报错:SpringBoot 版本和 JDK 不匹配

现象是项目启动直接抛UnsupportedClassVersionError。原因是 SpringBoot 3.x 编译出的字节码版本高于本地 JDK。解决方法是确认 JDK 版本,17 以上用 SpringBoot 3.x,8 就用 2.7.x。这个坑在换电脑或者换环境时特别容易踩,建议在 pom 里显式声明 java.version,别依赖默认值。

5. 让推荐更准:位次波动修正与数据校验的两个技巧

系统能跑通只是第一步,推荐准不准才是决定它有没有价值的关键。这里分享两个我实际用过的技巧。第一个是位次波动修正。同一所院校同一专业,不同年份的录取位次会有波动,直接用单年数据容易偏。我的做法是取近三年位次的加权平均,越近的年份权重越高,比如 2024 年权重 0.5、2023 年 0.3、2022 年 0.2。这样算出来的参考位次比单年更稳,能过滤掉某一年的异常波动。

// 近三年加权位次计算 public double weightedRank(int rank2024, int rank2023, int rank2022) { return rank2024 * 0.5 + rank2023 * 0.3 + rank2022 * 0.2; }

权重不是固定的,如果你所在省份录取波动大,可以把最近一年的权重提到 0.6。这个修正放在 service 层,查询时把三年数据都取出来算,比只查一年再推荐要靠谱得多。

第二个技巧是数据校验。爬回来的数据难免有脏值,比如位次为 0、分数超出合理范围、院校名称带多余空格。入库前加一层校验,把明显异常的数据拦下来记日志,别让它们污染推荐结果。我一般会校验位次必须大于 0 且小于当年考生总数,分数在 0 到 750 之间,院校名称去空格后长度大于 2。校验不通过的数据单独存一张异常表,方便回头人工核对。

校验项合理范围异常处理
最低位次1 到当年考生总数丢弃并记日志
最低分0 到 750丢弃并记日志
院校名称去空格后长度大于 2丢弃并记日志
年份近五年内丢弃并记日志

这两个技巧做完,系统的推荐结果会明显更贴近实际。我自己的习惯是每次更新数据后,拿几个已知位次的考生做回归测试,看推荐结果和实际录取情况差多少,差得离谱就回头查数据。这套东西没有一劳永逸的调参,数据质量决定了推荐质量的上限,算法只是在这个上限内做优化。希望帮到你。

本文还有配套的精品资源,点击获取