资讯详情

资讯详情

SpringBoot+爬虫高考志愿推荐系统:位次匹配与冲稳保策略解析

简介这是一套基于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. 工程结构与数据链路先看包结构再看数据怎么落 MySQL2.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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: trueserverTimezoneAsia/Shanghai几乎决定了你能不能第一次就连上 MySQL——本地 MySQL 8 默认时区不是中国时区不带这个参数会直接报时区识别失败而且报错信息本身还是乱码非常劝退。characterEncodingutf8要和表结构里的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-starttrue这种自动运行参数第一次跑建议先关掉改成手动触发日志看得清楚出了问题也知道是启动阶段还是抓取阶段。数据库里没有数据时推荐接口返回空列表最容易让人误判成算法 bug这种问题十次有八次是数据源没通。3. 爬虫模块实现解析规则、去重策略和反爬预案3.1 技术选型Jsoup 解析 HTMLHttpClient 控制请求这个模块最常见的组合是 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); ListString 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 ListScoreLine parseScoreLine(String html) { Document doc Jsoup.parse(html); ListScoreLine 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 里的nbsp;字符不处理的话一个520 也会让Integer.parseInt翻车。3.3 反爬预判先看 robots 再控制频率脱敏数据要兜底爬虫这个模块最容易翻车的地方不是解析而是站点反爬。列表页和详情页混在一起抓频率一旦高了就会出现超时和 403。常见做法是开工前先看目标站点的robots.txt确认哪些路径允许抓取这既是基本习惯也能帮你避开不必要的麻烦。这套源码里内置了三个兜底策略请求间隔可配置、失败重试、以及本地 mock 数据开关。爬虫跑出来的数据量太少时推荐模块等于没有输入。我的判断标准是score_line表至少要超过 500 行推荐接口的结果才有意义如果因为站点改版抓取失败就打开mock-data: true配置用 SQL 脚本里的示例数据先把业务链路跑通。数据脱敏也要注意不要把真实考生信息塞进表里做演示课程设计里造几百条假档案就够字段包括分数、位次、省份、科类、选科组合完全能支撑答辩。参数推荐值说明crawler.interval-ms1500~3000详情页请求间隔防封crawler.max-retry3失败重试次数超过就跳过crawler.timeout-ms10000连接和读取超时mock-datatrue/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 ListRecommendItem recommend(StudentProfile student) { int standardScore positionMatcher.convertRankToScore( student.getRank(), student.getYear() - 1, rankTableRepo); ListRecommendItem 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 ListRecommendItem 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 idfindByScoreRange resultTypeScoreLine 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.port8081长期改就在application.yml里设置server.port。习惯上我会把课程设计固定端口改成 8081避免和本机其它项目冲突。这个坑不大但卡住的时候特别烦日志刷一堆初始化信息然后戛然而止第一眼根本看不出是端口问题。坑三MySQL 连不上报时区或乱码。现象The server time zone value й׼ʱ is unrecognized。原因MySQL 8 驱动的时区校验和本地时区不匹配而且报错信息本身编码错乱看着像字符集问题。解决JDBC URL 加serverTimezoneAsia/Shanghai数据库和表的字符集统一用utf8mb4连接参数带characterEncodingutf8。这三项缺一个后续爬虫写入中文数据都可能出现问号或乱码而且这类问题在推荐接口返回中文院校名时才会暴露排查成本很高。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?score582rank30000province广东category物理类year2024 | jq .data[] | {school: .schoolName, level: .level, minScore: .minScore}jq过滤出院校名、档位和投档线重点看返回里三档各有没有数据。第三步看日志确认数据来源如果走的是 mock 数据控制台会打印 mock 模式提示如果走爬虫数据能看到每个学校的入库日志。这三步走完一份带爬虫的 SpringBoot 源码基本就算在你机器上活了。我从那以后每次拿到这种带爬虫的 SpringBoot 项目都会先造 5 条边界数据把推荐链路逼出一次验证通过再谈爬虫优化顺序反了很容易在“爬虫抓不到数据”和“推荐算法空指针”之间来回翻车。这份项目源码的拆解先说这些希望帮到你少走我踩过的几个坑。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →