简介:这是一套基于SpringBoot框架与爬虫技术实现的高考志愿智能推荐系统源码,面向Java开发者、计算机专业学生及课程设计/毕业设计人群。系统利用爬虫采集高校招生政策、录取分数线等公开数据,结合协同过滤等推荐算法,为考生提供个性化志愿填报建议,涵盖后端服务、RESTful API接口、数据存储与前端页面等完整环节。资源共672个文件,以Java与Vue源码为主,另含JS脚本、SVG图标、PNG/JPG图片、XML/JSON配置、Python爬虫脚本(py/pyc)及SQL数据库文件等,整体压缩包约34.6MB。同时内置可直接执行的Windows启动和安装脚本,便于本地运行调试。当前已有83人学习下载。解压后可按目录快速理清前后端模块、爬虫采集逻辑与数据库表结构,便于二次开发或直接用于课程设计答辩,也可作为学习SpringBoot、爬虫技术与推荐算法落地的参考案例。
1. SpringBoot+爬虫的高考志愿推荐系统:它不只是课程设计
这套springboot-springboot+爬虫高考志愿智能推荐系统.zip解压之后是一个完整的 SpringBoot 工程。常见做法是把爬虫模块和数据推荐引擎打包在一个项目里:爬虫抓各省考试院往年公布的投档线和位次数据,落到 MySQL,再由推荐模块按考生的分数、位次、省份和科类算出一份“冲、稳、保”志愿表。对正在做课程设计的人,它是一个能讲清楚数据从哪来、怎么清洗、怎么落库、怎么被业务接口消费的闭环;对想把 SpringBoot 和爬虫串起来的初学者,它又是一份能直接跑通全流程的源码。核心价值在于:它让“志愿填报”这个黑匣子变成了可以验算的结果。
2. 工程结构与数据链路:先看包结构,再看数据怎么落 MySQL
2.1 包结构:这个工程的分层比课程设计要求多一层
第一个建议是不要急着启动,先花十分钟把包结构看一遍。SpringBoot 课程设计通常只有 controller/service/mapper 三层,但带爬虫的工程会多出crawler和recommend两个独立的包,这是判断这个源码质量的关键。常见的结构如下:
com.example.college ├── config # 全局配置、线程池、爬虫参数 ├── controller # 推荐接口、爬虫触发接口 │ ├── RecommendController.java │ └── CrawlerController.java ├── service # 业务层 │ ├── RecommendService.java │ └── CrawlerService.java ├── mapper # MyBatis 数据访问层 ├── entity # School、ScoreLine、StudentProfile 等实体 ├── crawler # 爬虫专用包 │ ├── HttpClientFactory.java │ ├── SchoolCrawler.java │ └── parser # 不同站点的 HTML 解析器 ├── recommend # 推荐算法包 │ ├── PositionMatcher.java │ └── RecommendStrategy.java └── SpringBootApplication.javacrawler单独成包而不是塞在 service 里,是因为爬虫的生命周期和业务生命周期不同:业务代码要稳定,爬虫代码要随时跟着目标站点改版而调整。recommend单独成包则是因为推荐逻辑可以完全和 HTTP 层解耦,单元测试里直接构造一个StudentProfile就能调用,不必把整个 Web 容器拉起来。这个设计虽然比课程设计常规要求多了一层,但后期维护和答辩演示都省事。如果源码里这两个包是散在 service 里的,我一般会顺手拆出来再讲逻辑。
2.2 数据表设计:五张表的核心是 score_line
这种系统表不会太多,核心是成绩与位次的对应关系。常见设计是五张表:school存院校基础信息,major存专业,score_line存每年每个院校的投档最低分和最低位次,student_profile存考生档案,recommend_result存生成出来的志愿表。其中score_line是整个系统的数据命脉,它的字段直接决定推荐算法能做什么计算:
CREATE TABLE score_line ( id INT PRIMARY KEY AUTO_INCREMENT, school_id INT NOT NULL COMMENT '院校ID,关联school表', year INT NOT NULL COMMENT '数据年份,至少要存3年', province VARCHAR(32) NOT NULL COMMENT '省份', batch VARCHAR(32) NOT NULL COMMENT '批次:本科一批/本科二批', category VARCHAR(16) NOT NULL COMMENT '科类:物理/历史/理科/文科', min_score INT NOT NULL COMMENT '最低投档分', min_rank INT NOT NULL COMMENT '最低投档位次', plan_count INT DEFAULT 0 COMMENT '招生计划数', UNIQUE KEY uk_school_year_province_batch_category (school_id, year, province, batch, category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='历年投档线';这里两个细节值得注意。第一,min_rank必须存,很多初版设计只存分数,这会让推荐精度上不去。第二,唯一索引uk_school_year_province_batch_category是去重兜底——爬虫重复运行时,同一院校同一年份的数据不满足唯一约束会被 MySQL 直接拒绝,比在 Java 代码里做contains判断可靠得多。school表和score_line表用school_id做逻辑关联就可以,课程设计里物理外键可加可不加,省得删数据时还要处理外键约束。
2.3 application.yml 里容易忽略的三个参数
启动配置里真正的坑不在端口,而在连接 MySQL 的编码和时区。常见配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/college_rec?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserverTimezone=Asia/Shanghai几乎决定了你能不能第一次就连上 MySQL——本地 MySQL 8 默认时区不是中国时区,不带这个参数会直接报时区识别失败,而且报错信息本身还是乱码,非常劝退。characterEncoding=utf8要和表结构里的utf8mb4成对出现,否则爬虫抓到的生僻字院校名在写入时会出问题。map-underscore-to-camel-case: true让score_line自动映射成scoreLine,少写一堆resultMap。我一般还会给爬虫单独预留crawler.interval-ms: 2000和crawler.max-retry: 3这类自定义配置,调反爬频率的时候不用重新编译。
2.4 首次启动顺序:先确认数据链路再点 Run
第一次启动不建议立即触发爬虫,而是先查school表有没有基础数据,没有就先执行sql/init.sql,把院校列表建好,否则爬虫解析出来的学校名无法关联到school_id。我习惯的顺序是:先建库执行 SQL,再启动 SpringBoot 验证基础接口,然后打开CrawlerController手动触发一次爬虫,最后查score_line的数量。很多课程设计源码会在配置里写--crawler.run-on-start=true这种自动运行参数,第一次跑建议先关掉,改成手动触发,日志看得清楚,出了问题也知道是启动阶段还是抓取阶段。数据库里没有数据时推荐接口返回空列表,最容易让人误判成算法 bug,这种问题十次有八次是数据源没通。
3. 爬虫模块实现:解析规则、去重策略和反爬预案
3.1 技术选型:Jsoup 解析 HTML,HttpClient 控制请求
这个模块最常见的组合是 Apache HttpClient + Jsoup,而不是直接上 Selenium。原因很简单:志愿数据大多是静态 HTML 页面,用 HttpClient 拿 HTML,再用 Jsoup 按 CSS Selector 抽目标节点,速度和资源占用都比渲染浏览器低一个数量级。只有遇到数据是 JS 动态加载的页面才需要换思路,这套源码里静态页面为主。
抓取流程我一般会拆三步:第一步构造带超时和重试的 HttpClient;第二步按年份和省份拼接列表页 URL,解析出学校列表;第三步进详情页抓投档线和位次。核心代码如下:
public class SchoolCrawler { private final CloseableHttpClient httpClient; private final ScoreLineParser parser; private final ScoreLineMapper scoreLineMapper; public void crawlYear(String province, int year) throws IOException { String listUrl = String.format( "https://api.example-college.com/%d/%s/list.html", year, province); HttpGet request = new HttpGet(listUrl); request.setHeader("User-Agent", randomUserAgent()); try (CloseableHttpResponse response = httpClient.execute(request)) { String html = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); List<String> detailUrls = parser.parseDetailUrls(html); for (String detailUrl : detailUrls) { crawlDetail(detailUrl, province, year); Thread.sleep(config.getCrawlerIntervalMs()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void crawlDetail(String detailUrl, String province, int year) { // 抓详情页、解析分数行、按唯一索引去重后写入 score_line } }这段代码里值得抄的参数有三个:year和province拼进 URL,保证数据是可回溯的;Thread.sleep(config.getCrawlerIntervalMs())放在每个详情页之后,单位是毫秒,常见做法是 1500 到 3000,太快容易被封 IP,太慢几万条数据要跑一天;randomUserAgent()从预置列表里随机取 UA,虽然对反爬强的站点没用,但能避开默认 UA 被日志系统标记的尴尬。注意try-with-resources包住CloseableHttpResponse,不然抓几千页之后文件描述符会被耗尽,Windows 上表现为 TCP 连接数异常上涨。
3.2 解析规则:用 Jsoup 的 select 而不是正则
解析 HTML 有两个流派:正则硬抠和 Jsoup 选择器。这个场景下正则只应该出现在取页面里某个数字时,结构化的表格行一律用select。志愿数据的页面结构通常是<table>里每个<tr>对应一行院校记录,解析代码长这样:
public List<ScoreLine> parseScoreLine(String html) { Document doc = Jsoup.parse(html); List<ScoreLine> result = new ArrayList<>(); Elements rows = doc.select("table#scoreTable tr"); for (Element row : rows) { Elements cells = row.select("td"); if (cells.size() < 6) { continue; } ScoreLine line = new ScoreLine(); line.setSchoolName(cells.get(0).text().trim()); line.setBatch(cells.get(1).text().trim()); line.setMinScore(Integer.parseInt(cells.get(2).text().trim())); line.setMinRank(Integer.parseInt(cells.get(3).text().trim())); result.add(line); } return result; }table#scoreTable tr这个选择器比table tr多一个 id 定位,原因是页面里可能有多个表格,比如顶部有个“历年录取人数”表,如果不锁定 id,第一行表头会被当成数据解析,Integer.parseInt("最低分")直接抛异常。cells.size() < 6的判空逻辑同样是在防表头行和空行,这类脏数据在招生网站里非常常见。解析阶段我建议把所有字段都先当字符串处理后 trim 再转类型,HTML 里的 字符不处理的话,一个"520 "也会让Integer.parseInt翻车。
3.3 反爬预判:先看 robots 再控制频率,脱敏数据要兜底
爬虫这个模块最容易翻车的地方不是解析,而是站点反爬。列表页和详情页混在一起抓,频率一旦高了就会出现超时和 403。常见做法是开工前先看目标站点的robots.txt,确认哪些路径允许抓取,这既是基本习惯,也能帮你避开不必要的麻烦。
这套源码里内置了三个兜底策略:请求间隔可配置、失败重试、以及本地 mock 数据开关。爬虫跑出来的数据量太少时,推荐模块等于没有输入。我的判断标准是score_line表至少要超过 500 行,推荐接口的结果才有意义;如果因为站点改版抓取失败,就打开mock-data: true配置,用 SQL 脚本里的示例数据先把业务链路跑通。数据脱敏也要注意,不要把真实考生信息塞进表里做演示,课程设计里造几百条假档案就够,字段包括分数、位次、省份、科类、选科组合,完全能支撑答辩。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| crawler.interval-ms | 1500~3000 | 详情页请求间隔,防封 |
| crawler.max-retry | 3 | 失败重试次数,超过就跳过 |
| crawler.timeout-ms | 10000 | 连接和读取超时 |
| mock-data | true/false | 站点不可用时用示例数据兜底 |
重试还有一个细节:不要三秒内连续重试三次,失败后按 1 秒、3 秒、10 秒递增等待,效果比固定间隔好很多。这个参数在课程设计里可以不加,但加了之后被问“怎么处理反爬”时能多讲两句。爬虫跑完看一眼日志里的成功率和耗时,成功率低于八成,就要怀疑目标站结构变了,而不是继续调频率。
4. 推荐逻辑:为什么用位次而不是分数,冲稳保梯度怎么算
4.1 核心思路:位次比分数可靠,等效分是中间桥梁
高考志愿填报的共识是:每年的分数线会因试卷难度浮动,但全省位次是稳定的。同一所院校,去年投档线 560 分对应全省 20000 位,今年试卷难,全省第 20000 名只考了 540 分,如果还拿 560 去匹配,就会误判成“够不着”。所以这套系统的推荐引擎第一步不是查分数,而是把考生今年的位次换算成往年的“等效分”。
换算过程是这样:比如考生今年位次 30000,查今年的一分一段表,30000 位对应 532 分;再查去年的一分一段表,30000 位对应 545 分,那么 545 分就是考生在去年的等效分。后面所有院校匹配都用 545 分去比去年的投档线。这个逻辑放在PositionMatcher里,代码量不大但它是整个推荐结果的稳定器。
public int convertRankToScore(int rank, int year, RankTableRepository repo) { YearRankRow row = repo.findByYearAndRankRange(year, rank); // 如果位次正好落在两个分数段的边界,取高分段 return row == null ? 0 : row.getScore(); }4.2 冲稳保计算:三档分数区间和排序策略
拿到了等效分,推荐就变成一个区间查询问题。常见做法是把院校分成三档:冲刺院校是等效分比院校投档线低 3 到 10 分的;稳妥院校是差值在 -3 到 +5 分之间的;保底院校是差值在 +5 到 +15 分之间的。这里的正负号要特别说清楚:等效分 545,院校去年投档线 550,差值是 -5,属于冲刺;投档线 540,差值 +5,属于稳妥垫底。代码如下:
public List<RecommendItem> recommend(StudentProfile student) { int standardScore = positionMatcher.convertRankToScore( student.getRank(), student.getYear() - 1, rankTableRepo); List<RecommendItem> result = new ArrayList<>(); result.addAll(matchRecommend(student, standardScore, -10, -3, "冲")); result.addAll(matchRecommend(student, standardScore, -3, 5, "稳")); result.addAll(matchRecommend(student, standardScore, 5, 15, "保")); return result; } private List<RecommendItem> matchRecommend( StudentProfile student, int score, int minOffset, int maxOffset, String level) { return scoreLineMapper.findByScoreRange( student.getProvince(), student.getCategory(), student.getYear() - 1, score + minOffset, score + maxOffset ).stream() .map(line -> new RecommendItem(line.getSchoolName(), level, line.getMinScore(), line.getMinRank())) .collect(Collectors.toList()); }参数表对应关系如下:
| 档位 | 分数区间(等效分 - 投档线) | 排序方式 | 数量建议 |
|---|---|---|---|
| 冲 | -10 ~ -3 | 投档线降序 | 3 |
| 稳 | -3 ~ +5 | 按差值升序 | 4 |
| 保 | +5 ~ +15 | 投档线升序 | 3 |
拿到结果后排序也有讲究:冲档按投档线降序排,让学生看到最想够但最难够的;稳档按差值从低到高排,越接近的排越前;保档按投档线升序排,保证最后一个志愿绝对安全。三档数量 3:4:3,对应大多数省份十个志愿的表结构。mapper 里的查询也按照这个区间去写:
<select id="findByScoreRange" resultType="ScoreLine"> SELECT school_id, school_name, year, min_score, min_rank FROM score_line WHERE province = #{province} AND category = #{category} AND year = #{year} AND min_score BETWEEN #{minScore} AND #{maxScore} ORDER BY min_score DESC </select>这里BETWEEN #{minScore} AND #{maxScore}直接使用传入的等效分加减后的边界值,注意排序方向不同档位要单独处理,冲档和保档在 Java 层重新排序更灵活,不要在 SQL 里写死。
4.3 推荐参数怎么调:不同省份科类的差异
这里的参数不能写死,因为新高考省份和老高考省份逻辑差别很大。老高考看文科/理科排名,新高考看物理类/历史类排名,还有“院校专业组”模式要求把专业组代码也纳入匹配条件。源码里把category字段作为硬过滤条件,SQL 查询时必须带category = #{category},否则会出现物理类考生被推荐纯历史类专业组的错误。
另外一个容易被忽视的参数是数据年份。常见做法是取最近三年数据做平均,而不是只看去年一年。冲稳保边界可以用三年投档线的中位数来定,单年数据波动大,大小年效应明显,拿一年做基准推荐出来的学校特别容易踩坑。我给这套源码的调参建议是:先跑 50 个考生的批量测试,看三档比例是否合理,如果保底档经常返回空,就把保底上限从 15 调大到 20;如果冲档数量过多,说明等效分换算偏低了,先检查一分一段表的取数范围是不是太粗。
5. 部署避坑:从 ZIP 解压到推荐接口跑通的五个坑
5.1 启动期的三个坑
坑一:SpringBoot 版本和 JDK 版本不匹配。
现象:导入源码后启动器直接报UnsupportedClassVersionError,或者 Maven 编译到一半就失败。原因:源码如果在pom.xml里用了 SpringBoot 3.x,它要求 JDK 17,而很多课程设计环境还是 JDK 8。ZIP 解压完第一件事不是看代码,先mvn -v确认环境。解决:本机是 JDK 8 时,把spring-boot-starter-parent版本降到 2.7.x,同时把<java.version>改成 8,重新刷新 Maven 依赖。这里要特别注意,降版本不是只改一处,依赖里如果引用了高版本 Jsoup 或旧版 MySQL 驱动,都要对应调整。
坑二:8080 端口被占用。
现象:启动日志最后卡在Web server failed to start. Port 8080 was already in use。原因:本地同时跑了多个 SpringBoot 服务,或者别的开发服务器占了 8080。解决:临时改端口用启动参数--server.port=8081,长期改就在application.yml里设置server.port。习惯上我会把课程设计固定端口改成 8081,避免和本机其它项目冲突。这个坑不大,但卡住的时候特别烦,日志刷一堆初始化信息然后戛然而止,第一眼根本看不出是端口问题。
坑三:MySQL 连不上,报时区或乱码。
现象:The server time zone value '�й���ʱ��' is unrecognized。原因:MySQL 8 驱动的时区校验和本地时区不匹配,而且报错信息本身编码错乱,看着像字符集问题。解决:JDBC URL 加serverTimezone=Asia/Shanghai,数据库和表的字符集统一用utf8mb4,连接参数带characterEncoding=utf8。这三项缺一个,后续爬虫写入中文数据都可能出现问号或乱码,而且这类问题在推荐接口返回中文院校名时才会暴露,排查成本很高。
5.2 数据链路两个坑
坑四:爬虫跑完,推荐接口返回空列表。
现象:日志显示爬虫执行成功,但打开推荐接口查不到数据。原因:爬虫解析出的学校和school表里的school_id对不上,score_line里只写了学校名没有关联上school_id,或者写入时被唯一索引全部挡住。解决:打印日志对比school.name和score_line.school_name,优先用school_id做主键,不要依赖学校名字符串匹配,一个全角空格就能让 JOIN 查不到数据。排查时可以先用SELECT COUNT(*) FROM score_line确认数据量,再查SELECT * FROM score_line WHERE school_id IS NULL看有没有孤儿数据。
坑五:爬虫数据只有几行,明显不全。
现象:跑完脚本,SELECT COUNT(*) FROM score_line只有个位数。原因:目标站点列表页是异步加载,或者需要登录后才能看到完整表格,HttpClient 拿到的 HTML 里根本没有数据行。解决:先用浏览器开发者工具看请求返回的是不是完整 HTML;如果接口返回 JSON,就不要用 Jsoup,改成请求 JSON 接口后用 Jackson 解析;实在拿不到就开 mock 数据开关,用初始化 SQL 里的示例数据继续开发推荐逻辑。这不算偷懒,爬虫本来就是整个链路最脆弱的一环,先让它不影响主流程才是正确取舍。
6. 复现后的验证方法:造数、跑接口、抓日志一次走完
拿到的源码能不能用,我一般不会先跑完整爬虫,而是先做端到端验证。第一件事是检查初始化 SQL 是否带示例数据,如果没有,先手动造几条边界数据:
INSERT INTO school (id, name, province) VALUES (1, '示例大学', '广东'), (2, '示例学院', '广东'); INSERT INTO score_line (school_id, year, province, batch, category, min_score, min_rank) VALUES (1, 2023, '广东', '本科批', '物理类', 615, 18000), (2, 2023, '广东', '本科批', '物理类', 480, 95000);这里要故意构造一条“冲得上去”和一条“绝对保底”的数据,比如考生位次 30000,等效分 580 左右,示例大学 615 分对应冲档,示例学院 480 分对应保档,这样一眼就能看出推荐分类对不对。第二步启动项目后用 curl 打接口:
curl "http://localhost:8080/api/recommend?score=582&rank=30000&province=广东&category=物理类&year=2024" | jq '.data[] | {school: .schoolName, level: .level, minScore: .minScore}'jq过滤出院校名、档位和投档线,重点看返回里三档各有没有数据。第三步看日志确认数据来源:如果走的是 mock 数据,控制台会打印 mock 模式提示;如果走爬虫数据,能看到每个学校的入库日志。这三步走完,一份带爬虫的 SpringBoot 源码基本就算在你机器上活了。
我从那以后每次拿到这种带爬虫的 SpringBoot 项目,都会先造 5 条边界数据把推荐链路逼出一次,验证通过再谈爬虫优化,顺序反了很容易在“爬虫抓不到数据”和“推荐算法空指针”之间来回翻车。这份项目源码的拆解先说这些,希望帮到你少走我踩过的几个坑。
本文还有配套的精品资源,点击获取