ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot爬虫高考志愿推荐系统:架构、算法与实战

Spring Boot爬虫高考志愿推荐系统:架构、算法与实战 简介基于SpringBoot的爬虫高考志愿智能推荐系统源码是一套面向Java学习者、毕业设计及课程设计学生的完整项目。系统以爬虫采集高校招生与录取数据结合推荐算法为学生提供志愿填报参考覆盖数据抓取、存储、分析到前端展示的完整链路并包含Vue构建的前端页面和SpringBoot后端服务技术栈清晰。压缩包共669个文件核心含135个Java源码、99个Vue组件、63个JS脚本以及SQL数据库脚本、bat启动脚本、YML配置、SVG/PNG图标等类型包体约26.46MB目录结构完整便于检索。已有75人学习适合需要参考SpringBoot、爬虫或推荐系统实际项目的开发者。资源附带运行脚本与数据库说明可快速导入Eclipse/IDEA运行方便对照源码理解爬虫调度、数据处理和志愿匹配逻辑也便于二次开发与课程设计答辩。1. 爬虫高考志愿智能推荐系统毕业设计选型的起点和边界每年六月前后找我聊毕业设计的朋友里总有一批人想做“爬虫 推荐”组合又要抓数据、又要写算法、还要有 web 界面给评委演示。这套基于 Spring Boot 的爬虫高考志愿智能推荐系统正好卡在这个交集上——它用爬虫抓公开的院校录取数据再按考生分数、位次、选科和地域偏好生成“冲稳保”三档志愿表。你把它跑通之后答辩时既有数据采集的实拍过程又有推荐结果的生成链路技术点很密集。它适合三类人想靠一个项目同时覆盖 Spring Boot、爬虫、算法三个方向的学生课程设计被要求“必须有真实数据来源”的人以及想复刻一套志愿填报参考工具、不愿意只写 CRUD 的开发者。先说清楚边界它解决的是“数据从哪来、推荐怎么算、页面怎么出结果”不等同于商业化报考产品也不替你做人工咨询决策。2. 架构与数据模型八张表撑起推荐闭环2.1 分层结构爬虫采集、业务服务、推荐引擎如何分工拿到源码先把包结构扫一遍别急着点 Run。这类系统通常分成三层采集层负责对接考试院或高校公开页面把分数线、招生计划、专业目录解析成结构化数据服务层负责用户管理、志愿方案 CRUD、数据查询推荐层独立在 service 包下输入用户位次和偏好输出三档志愿列表。我一般会先看datacollect和recommender两个包是否物理隔离。如果采集代码直接散落在 controller 里说明原作者写着写着偷懒了。分层的意义在于你换数据源、调推荐阈值、改页面三者互不影响。下面是常见的模块划分参考。模块典型包名职责爬虫采集collector/parser抓页面、解析表格、清洗入库数据服务service/mapper分数线、院校、专业查询接口推荐引擎recommender/strategy位次换算、院校打分、梯度生成表现层controller/viewREST API 与 Thymeleaf 页面渲染这一条值得写进论文的“系统设计”章节爬虫层不直接暴露给前端所有数据先落 MySQL前端永远走 service 接口避免把抓取过程拖进请求链路。2.2 核心表设计分数线、位次、选科口径落库数据模型是整个系统的地基。我看到过不少翻车项目把“年份”“省份”“文理科”揉成一个备注字段推荐逻辑写起来全是字符串 if-else。这套系统里我的建议是拆八张核心表其中四张是刚需CREATE TABLE school ( id BIGINT PRIMARY KEY AUTO_INCREMENT, school_name VARCHAR(100) NOT NULL, province VARCHAR(50), city VARCHAR(50), level_type VARCHAR(20), -- 985/211/普通本科 school_type VARCHAR(20) -- 综合/理工/师范 ); CREATE TABLE admission_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, school_id BIGINT NOT NULL, year INT NOT NULL, province VARCHAR(20) NOT NULL, subject_type VARCHAR(10), -- 物理类/历史类/理科/文科 batch VARCHAR(20), -- 本科批/专科批 min_score INT, min_rank INT, -- 最低录取位次核心字段 score_line INT -- 当年省控线 ); CREATE TABLE major ( id BIGINT PRIMARY KEY AUTO_INCREMENT, school_id BIGINT NOT NULL, major_name VARCHAR(100), category VARCHAR(50), -- 专业大类 require_subject VARCHAR(50) -- 选科要求新高考用 );字段里最容易被忽略的是min_rank和subject_type。很多现成数据源只给分数不给位次但推荐逻辑恰恰依赖位次因为不同年份的分数线没有直接可比性。subject_type必须单独成列否则同一所学校物理类和历史类的录取线会被互相污染。省控线score_line是线差法的输入参数后面算法章节会用到。2.3 为什么是位次而不是裸分一段换算逻辑志愿填报的基本常识是分数会随试卷难度浮动位次才是稳定的相对位置。假设你今年考了 620 分在全省排第 8000 名那你要找的不是“去年录取分数 620 的学校”而是“去年录取位次在 8000 名附近的学校”。min_rank解决的是这个对齐问题。推荐引擎先查用户所属省份、年份的一分一段表把分数换算成位次再用位次去匹配学校往年录取位次区间。这个逻辑的正确性决定了答辩时能不能扛住追问。你可以在论文里写清楚本位次法避免了直接比较跨年分数的失真问题线差法作为辅助策略处理部分未公布位次的老数据。宁可在论文里把这个点写透也不要堆一堆 Lambda 表达式讲技术细节。3. 数据采集模块用 Spring Boot 写爬虫的工程化做法3.1 采集前调研编码、反爬与更新频率动代码之前先把目标网站打开 F12 看三件事页面编码、数据容器、更新周期。考试院类网站最常见的是 GBK 或 GB2312 编码如果用 UTF-8 硬解析出来的全是乱码分数线数据大多在table里少量用 JavaScript 异步渲染后者要额外找接口地址。还有一个关键点是更新频率——分数线每年只出一次没必要每分钟抓一次定时任务按天跑就够。了解这三件事之后再决定用 HttpClient 还是 Jsoup。老牌的 HttpClient 负责发请求拿 HTMLJsoup 负责把 HTML 解析成 DOM 对象。只要不是动态渲染页面这套组合已经够用。如果页面是 JSON 接口返回数据连 Jsoup 都不用直接解析 JSON。README 里crawler-config下的配置文件通常在描述这部分的边界先改配置再改代码。3.2 HttpClient Jsoup 抓取分数线页并解析入库这里给一段可以直接抄进项目的解析代码模板目标是从表格页抓取院校名称、年份、最低分、最低位次、省控线。Service public class ScorePageCrawler { private static final String UA Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36; private static final int TIMEOUT 10000; public ListAdmissionScore crawl(String url, String province, String subjectType) { ListAdmissionScore list new ArrayList(); HttpGet request new HttpGet(url); request.setHeader(User-Agent, UA); request.setHeader(Referer, url); // 部分站点校验来源 try (CloseableHttpClient client HttpClients.createDefault(); CloseableHttpResponse response client.execute(request)) { String html EntityUtils.toString(response.getEntity(), gbk); Document doc Jsoup.parse(html); Elements rows doc.select(table tr); for (Element row : rows) { Elements tds row.select(td); if (tds.size() 4) continue; AdmissionScore s new AdmissionScore(); s.setSchoolName(tds.get(0).text()); s.setYear(Integer.parseInt(tds.get(1).text())); s.setMinScore(Integer.parseInt(tds.get(2).text())); s.setMinRank(parseRank(tds.get(3).text())); s.setProvince(province); s.setSubjectType(subjectType); list.add(s); } } catch (Exception e) { log.error(抓取失败: {}, url, e); } return list; } }解析逻辑说明doc.select(table tr)是 Jsoup 的 CSS 选择器写法选中页面里所有表格行tds.get(0).text()取每行第一列文本即院校名称。这里有两个关键参数——gbk是目标页面编码取决于你第一步调研结果UA请求头用桌面浏览器版本能过滤掉一部分简单反爬。parseRank是自己写的清洗函数把“8000名以内”“—”“暂无”这类脏文本归一成整数具体实现里用正则提取数字即可。入库这一步用 MyBatis-Plus 的批量插入即可注意主键策略用IdType.ASSIGN_ID别在爬虫里自增主键方便后面重复跑数据时做幂等。3.3 定时任务与增量更新数据刷新与防重复分数线每年更新一次定时策略用 Spring 自带Scheduled就够不需要引入 xxl-job 这种重武器。cron 表达式设定为每天凌晨两点跑一次避开目标站点白天的高峰流量。Component public class ScoreRefreshTask { Autowired private ScorePageCrawler crawler; Autowired private AdmissionScoreMapper scoreMapper; Scheduled(cron 0 0 2 * * ?) public void refresh() { ListAdmissionScore fresh crawler.crawl(CrawlerConfig.getScoreUrl(), 河南, 理科); for (AdmissionScore s : fresh) { if (!exists(s)) { scoreMapper.insert(s); } } } private boolean exists(AdmissionScore s) { QueryWrapperAdmissionScore qw new QueryWrapper(); qw.eq(school_id, s.getSchoolId()) .eq(year, s.getYear()) .eq(province, s.getProvince()) .eq(subject_type, s.getSubjectType()) .eq(min_score, s.getMinScore()); return scoreMapper.selectCount(qw) 0; } }防重复的要点是把“业务唯一键”加在exists判断上学校、年份、省份、选科、分数五个字段联合去重。如果依赖数据库自增主键判断第一次跑成功后第二次会全量重复。增量更新同理新数据入库前先查一次查到就跳过。另外Scheduled默认单线程串行执行采集任务的耗时通常超过 30 秒建议手动加一个EnableScheduling的事务前缀避免数据写到一半中断——后台用的血泪经验定时任务永远要做成“可中断恢复”否则第二次跑还要先清表。提示如果目标站点有验证码或强风控策略优先寻找公开的 JSON 接口或历史数据下载页不要硬怼。4. 推荐算法实现位次法、线差法与冲稳保梯度4.1 位次核算法把当年分数换算成等位分推荐引擎的第一步是把用户今年的分数换算成目标年份的等位分。做法是查用户当年一分一段表拿到位次再用这个位次查目标年份的一分一段表得到对应分数。这个换算逻辑写在RankConverter里Component public class RankConverter { Autowired private OneSegmentMapper segmentMapper; public Integer scoreToYearRank(Integer score, String province, String subjectType, Integer year) { OneSegment current segmentMapper.findByScore(province, subjectType, year, score); if (current null) return null; return current.getRank(); } public Integer rankToYearScore(Integer rank, String province, String subjectType, Integer targetYear) { ListOneSegment list segmentMapper.findByRankRange(province, subjectType, targetYear, rank); if (list.isEmpty()) return null; return list.get(0).getScore(); } }参数说明findByScore返回该分数段对应的累计人数也就是位次findByRankRange是反向操作按位次落在哪个分数段回推等位分。这里要注意一分一段表的口径——每年考生总数和扩招规模都在变位次法在边缘段如接近本科线误差较大算法里需要给一个浮动区间后面梯度策略会用到。4.2 院校匹配打分专业、地域与选科约束拿到目标年份的等位分之后筛选逻辑包含三类约束选科硬约束、分数硬过滤、偏好软评分。选科硬约束是“不符即排除”比如目标专业要求物理必选用户没选物理就直接跳过分数过滤则是按等位分上下浮动一个区间选出候选院校池软评分项包括地域偏好、院校层次985/211/双一流、专业大类匹配度。这一步的实现我建议把打分器拆成独立的ScoringStrategy接口每种偏好对应一个实现类主流程里按权重累加public interface ScoringStrategy { double score(RecommendContext ctx, SchoolScore ss); } Component public class RegionScoring implements ScoringStrategy { Override public double score(RecommendContext ctx, SchoolScore ss) { if (ctx.getPreferredCities() null || ctx.getPreferredCities().isEmpty()) return 0; if (ctx.getPreferredCities().contains(ss.getCity())) return 10; if (ctx.getPreferSameProvince() ctx.getProvince().equals(ss.getProvince())) return 5; return 0; } }打分逻辑不做魔法数字权重统一收敛到配置类里院校层次权重 0.3地域偏好权重 0.2专业匹配权重 0.3历史认可度 0.2。所有权重相加等于 1学生在答辩时能直接解释“为什么这所学校排在前面”——一句话说清楚“因为它四项得分高”比玄学排序强得多。4.3 三档志愿生成阈值参数与排序策略冲稳保三档的本质是位次区间映射。冲的学校录取位次比用户完全高一大截把用户位次除以 0.85 到 0.98 的系数作为候选区间稳档是用户位次的 0.98 到 1.05 倍保档是 1.05 到 1.20 倍。位次数字越小越靠前所以冲档系数小于 1这个容易写反调参数时留意。public ListSchoolScore generateTiers(Integer userRank, ListSchoolScore candidates, Tier tier) { double lowRatio, highRatio; switch (tier) { case CHONG: lowRatio 0.85; highRatio 0.98; break; case WEN: lowRatio 0.98; highRatio 1.05; break; case BAO: lowRatio 1.05; highRatio 1.20; break; default: throw new IllegalArgumentException(未知梯度类型); } int lowRank (int) (userRank * lowRatio); int highRank (int) (userRank * highRatio); return candidates.stream() .filter(s - s.getMinRank() lowRank s.getMinRank() highRank) .sorted(Comparator.comparingDouble(SchoolScore::finalScore).reversed()) .limit(6) .collect(Collectors.toList()); }这段代码注释里藏着一个关键点候选学校的minRank必须落在区间内。如果用户位次是 10000冲档低限10000*0.858500代表“录取位次 8500 名之前的学校”这类学校往年录取位次比用户更靠前所以叫冲同理保档低限10500录取位次比用户更靠后的学校才是稳保。每档数量上限建议设为 6正好对应当前多数省份志愿填报的每批院校数量上限贴近实际流程评委也会认可这个设计动机。5. 避坑记录从爬虫到推荐结果最容易翻车的七个点5.1 网络的坑反爬、乱码、限速三连先说我踩过三次的那个坑爬虫一跑就返回 403页面跳到一个安全验证页。现象是 Java 端报HttpResponseException: 403 Forbidden请求根本拿到表格。原因是只设置了 URL没带User-Agent和Referer目标站点把 Java 默认客户端当成了脚本。解决方式也很机械——补上桌面浏览器 UA并把Referer设为当前页面自身再在循环里加 2 到 3 秒的休眠。后来我把休眠时间从固定改成了随机区间误伤率明显下降代价是采集一个省的数据要跑一个多小时但对于定时任务完全可以接受。乱码是第二个高频问题。拿到 HTML 后直接Jsoup.parse(html)默认按 UTF-8 解码而考试院老页面用 GBK解析出来的表格全是“”。解决方式是在读实体时显式传入字符集EntityUtils.toString(response.getEntity(), gbk)。我现在的习惯是先在浏览器里看响应头Content-Type里的charset再决定写进代码不再靠肉眼猜。第三个坑是限速。把抓取频率调到 200 毫秒以下很多老系统的接口会直接断开。后端日志上表现为大量连接重置但代码没崩溃数据却缺了三分之一这在排查时很有迷惑性。后来我在采集循环里加了日志计数器每 500 条打印一次立刻发现问题。5.2 数据的坑时区、口径、Spring Boot 版本时区这个坑是“症状后置”的。MySQL 连接串没加serverTimezoneAsia/Shanghai时项目能启动采集和推荐都能跑但切换数据源或修改日期字段后某些字段凭空多了 8 小时推荐结果里日期排序乱掉。解决方式spring.datasource.urljdbc:mysql://localhost:3306/gaokao?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai选科口径问题更隐蔽。同一个学校物理类和历史类的录取位次可能相差一倍。如果你在admission_score表里没有严格区分subject_type推荐引擎会把物理类的位次去匹配历史类考生的输入生成结果完全乱套。解决方式是在写入表时候严格校验枚举值并且把枚举写在配置文件里枚举值含义适用省份理科老高考理科河南、山西等文科老高考文科河南、山西等物理类新高考 312 首选物理广东、湖南等历史类新高考 312 首选历史广东、湖南等最后一个是 Spring Boot 版本过高的问题。很多三年往上的老源码用的 Spring Boot 2.x你本地装了最新版 3.x跑起来直接报ClassNotFoundException: javax.servlet.*。这不是项目代码错了是 Spring Boot 3.x 把javax包迁移成了jakarta。解决方案是把 Spring Boot 版本固定回 2.7.x并同步用 MyBatis-Plus 3.5.3 及以下版本这一组合在毕业设计场景里最省心。6. 用真实录取结果回测推荐质量一段可以照抄的验证代码推荐算法写完了能跑但推荐得准不准才是答辩会被追问的核心。我的做法是构造回测把某省去年已经公布的录取结果拆分出训练集和验证集——用前年数据跑推荐反推去年用户的志愿表再对比真实录取结果计算命中率。public double evaluate(String province, String subjectType, int year) { ListUserRecord users userRecordMapper.findByProvinceAndYear(province, subjectType, year); int hit 0, total 0; for (UserRecord u : users) { ListSchoolScore suggested recommendService.recommend(u, 12); SetLong suggestedIds suggested.stream() .map(SchoolScore::getSchoolId) .collect(Collectors.toSet()); if (suggestedIds.contains(u.getAdmittedSchoolId())) { hit; } total; } return total 0 ? 0 : hit * 1.0 / total; }这段代码里recommend(u, 12)的第二个参数是候选集大小取 12 对应“6 冲 4 稳 2 保”的常见结构。evaluate的返回值反映的是系统推荐列表里是否包含用户最终被录取的学校。作为一个下限指标它衡量的是“是否覆盖”而不是精确排序。回测的目的不是追求 100% 命中而是验证你的数据清洗和梯度阈值没有系统性偏斜——比如保档普遍推得太低导致命中率集中在底部一档那说明算法在浪费用户的分数。我一般跑完一轮回测如果综合命中率低于 60%会先检查min_rank字段是否有大量空值再检查位次换算里一分一段表的年份是不是拿错了。从那以后我每次调整推荐阈值或换数据源都强制走一遍回测脚本再上线这条习惯救过我很多次。这套源码包拿到手后先按 README 把 Spring Boot 版本对齐再把这个回测脚本接上本地数据库跑一轮你就能直观看到算法参数和命中率之间的因果关系希望帮到你。本文还有配套的精品资源点击获取
返回列表