爬虫与推荐算法融合的高考志愿智能推荐系统构建方案
简介基于爬虫的高考志愿智能推荐系统是一份完整的Java毕业设计项目包面向计算机相关专业毕业生及Spring Boot/Vue学习者可用于毕业设计、课程设计或全栈项目实践。系统采用前后端分离架构后端Spring Boot前端VueMySQL存储数据涵盖用户端和管理员端完整业务支持考生登录注册、院校专业查询、位次与历年录取数据检索、MBTI职业性格测试、院校识别、模拟志愿填报、志愿管理以及后台的专业、院校、招生计划、咨询、位次和用户管理同时利用Scrapy爬虫采集中国教育在线高校录取数据结合知识图谱与智能算法提供个性化志愿推荐。压缩包约34.56MB共671个文件包含Java源码、Vue前端、SQL脚本、Python爬虫代码、SVG图标、PPT及说明文档等目录按springboot后端、admin管理端、front用户端、spider爬虫模块划分并附带多个install/run/build批处理脚本便于快速启动和二次开发。目前已有87人学习下载适合毕业设计选题、功能扩展或深入理解爬虫与推荐系统整合思路。1. 爬虫高考志愿智能推荐系统一个毕设的完整解法每年志愿填报的时间窗口只有几天考生和家长要面对的是各省考试院、高校招生网、第三方数据平台里分散且格式各异的录取数据。人工把这些数据整理成表格再逐个比对效率低且容易错过位次接近的院校。这个项目标题要解决的就是一件事用爬虫把分散的公开招生信息聚合成结构化数据用推荐算法把分数—位次—院校—专业的匹配过程从人工查表变成算法打分最后用 Spring Boot MySQL 做成一个可查询、可交互的 Web 系统。对正在选毕设题目的 Java 方向学生来说这个题目覆盖了爬虫、数据清洗、MySQL 表设计、推荐算法和 Web 开发全链路是一个能写文档、能画架构图、也能现场演示的完整项目。下文按数据采集、存储建模、推荐计算、功能闭环的顺序给出可直接复现的实现方案。2. 架构与数据建模先把数据链路立住2.1 Spring Boot MySQL MyBatis 的选型理由毕设系统不需要微服务和分布式中间件常见做法是 Spring Boot 单体应用内嵌 Tomcat前端用 Thymeleaf 或 Vue 静态页数据层用 MyBatis 操作 MySQL。这套组合的好处是启动成本低、报错链路短答辩时讲得清请求怎么进来、数据怎么出去。为什么爬虫不用 Python 而用 Java因为这是 Java 毕设保持技术栈统一最稳妥。Jsoup 解析 HTML 的能力足够应付招生网站常见的表格结构HttpClient 负责发起请求重试和限流用 Java 自带并发工具就能实现。MySQL 用 InnoDB 引擎默认事务隔离级别可读已提交对爬虫批量写入和推荐查询都够用。2.2 核心表结构与字段设计2.2.1 院校表、专业表和录取分数表至少设计四张表院校表、专业表、录取分数表、用户收藏表。院校表放学校标识码、院校名称、所在地、办学层次本科/专科、院校类型综合/理工/师范专业表放专业代码、专业名称、学制、学费录取分数表是核心表存每个院校专业在某省某年的录取数据。录取分数表是最容易踩坑的地方。有些学生会把所有字段塞进一张大表结果院校信息冗余、专业数据没法单独维护。拆表之后分数表只保留外键和数值字段查询时用 JOIN 关联院校和专业名称。2.2.2 索引与分区设计录取分数表的查询模式固定按省份、年份、位次范围查或按院校代码查。所以联合索引必须建在查询条件上CREATE TABLE admission_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, school_code VARCHAR(10) NOT NULL COMMENT 院校标识码, major_code VARCHAR(10) NOT NULL COMMENT 专业代码, province_code VARCHAR(6) NOT NULL COMMENT 省份代码, year INT NOT NULL COMMENT 录取年份, batch VARCHAR(10) DEFAULT 本科批 COMMENT 批次, min_score INT NOT NULL COMMENT 最低分, avg_score INT DEFAULT NULL COMMENT 平均分, min_rank INT NOT NULL COMMENT 最低位次, plan_count INT DEFAULT NULL COMMENT 计划招生人数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_query (province_code, year, min_rank), INDEX idx_school (school_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT录取分数表;min_rank字段比min_score更重要。高考每年试题难度不同分数波动大位次是更稳定的参考维度推荐算法会同时用到这两个字段。索引建立在查询频率最高的(province_code, year, min_rank)上分数范围查询走联合索引能有效控制回表次数。2.3 从爬虫到推荐的模块划分把单体项目按职责切成四个模块爬虫采集模块、数据清洗模块、推荐引擎模块、Web 展示模块。爬虫采集只负责把 HTML 解析成AdmissionScore对象数据清洗做去重、空值处理、异常分数剔除推荐引擎读 MySQL 数据计算推荐列表Web 模块提供 REST 接口和页面渲染。模块之间通过 Service 层调用爬虫模块采集完成后触发清洗接口清洗完直接写库推荐引擎只读库。这样做的好处是答辩时能清楚画出数据流转图而且每一部分可以单独写测试。比如爬虫模块本地跑 100 条数据验证解析正确性推荐模块用上一年的数据回测推荐命中率每一层都能拿数据说话。3. 爬虫采集模块抓取、清洗与落库的实现细节3.1 合规前提只抓公开接口与静态页面爬虫模块最容易在答辩时被问数据哪来的、合不合法。常见做法是只抓省教育考试院公开的招生录取统计页面、高校招生网公开发布的历年分数线静态页面这些信息本身是向社会公开的。抓取前检查目标站点 robots 协议控制请求频率不抓登录后才能看的数据不保存个人隐私信息。提示数据仅供学习研究使用。生产环境如需商用应使用官方批准的授权接口或三方数据服务。3.2 Jsoup 解析录取信息表格以省考试院常见的院校专业组投档线表格为例HTML 结构通常是table tbody tr每行放院校代码、院校名称、专业名称、计划数、最低分、最低位次。用 Jsoup 解析的核心代码public ListAdmissionScore parseHtml(String html, String provinceCode, int year) { ListAdmissionScore list new ArrayList(); Document doc Jsoup.parse(html); Elements rows doc.select(table tr); // 从第1行开始第0行通常是表头 for (int i 1; i rows.size(); i) { Elements tds rows.get(i).select(td); if (tds.size() 6) continue; // 跳过合并单元格或缺数据的行 AdmissionScore score new AdmissionScore(); score.setSchoolCode(tds.get(0).text().trim()); score.setSchoolName(tds.get(1).text().trim()); score.setMajorName(tds.get(2).text().trim()); score.setPlanCount(Integer.parseInt(tds.get(3).text().trim())); score.setMinScore(Integer.parseInt(tds.get(4).text().trim().replaceAll([^0-9], ))); score.setMinRank(Integer.parseInt(tds.get(5).text().trim().replaceAll([^0-9], ))); score.setProvinceCode(provinceCode); score.setYear(year); list.add(score); } return list; }代码逻辑分三步用Jsoup.parse把 HTML 转成 Document 对象用select(table tr)选中所有行遍历每一行按列索引取值。取分数字段时用replaceAll([^0-9], )过滤掉最低分 585中外合作办学这类括号说明避免parseInt抛异常。一个常见的坑是有些表格第一行不是表头而是数据或者前几行带合并单元格。判断是否跳过的逻辑不能只看tds.size()还要确认第一列不是院校代码等表头文字否则表头会被当成数据写进库。3.3 抓取队列、URL去重与请求限速单线程逐页抓取速度慢而且容易被封常见做法是用线程池并发抓取同时控制并发数和请求间隔。这个项目里不引入 Redis 做去重用 JVM 内存里的ConcurrentHashMap或HashSet就够public class CrawlScheduler { private final ThreadPoolExecutor pool new ThreadPoolExecutor( 3, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy()); private final SetString visitedUrls ConcurrentHashMap.newKeySet(); public void start(ListString seedUrls) { for (String url : seedUrls) { submitUrl(url); } } private void submitUrl(String url) { if (!visitedUrls.add(url)) return; // 已抓过则跳过 pool.execute(() - { try { String html HttpUtil.get(url); ListAdmissionScore scores parseHtml(html, 33, 2024); saveBatch(scores); // 从页面解析下一页链接并递归提交 ListString nextUrls parsePagination(html); nextUrls.forEach(this::submitUrl); Thread.sleep(ThreadLocalRandom.current().nextLong(1000, 3000)); } catch (Exception e) { log.error(crawl failed: {}, url, e); } }); } }去重逻辑在visitedUrls.add(url)这行ConcurrentHashMap.newKeySet()是线程安全的 Set能保证同一个 URL 不会被重复提交。线程池配置 3~5 个线程队列容量 100超过容量时让调用方线程执行任务避免任务丢弃。Thread.sleep在 1 到 3 秒之间随机休眠目的是打散请求时间模拟人工访问节奏。3.4 数据清洗与增量更新策略爬下来的原始数据不能直接进库。院校名称可能带原XX学院的后缀专业代码可能重复同一个院校在不同年份的名称可能略有变化这些都需要清洗规则处理。清洗三步走第一步去空格和全角字符统一转为半角第二步按院校代码去重同代码取最新年份数据第三步校验分数范围最低分在 0 到 750 之间各省满分不同位次必须为正整数。清洗逻辑放在独立 Service 里爬虫解析完先调清洗再批量插入。增量更新不用每天全量重爬。录取分数表按(school_code, major_code, year, province_code)建唯一索引插入时用INSERT ... ON DUPLICATE KEY UPDATE已存在的数据更新分数和位次不存在的插入新记录。这样第二年分数线公布后只需要重跑相同 URL 就能完成增量更新。4. 智能推荐模块基于分数与偏好的综合推荐4.1 为什么毕设推荐系统不用深度学习看到智能推荐先别急着上 BERT 或者 Graph Neural Network。高考志愿场景的数据量只有几万条录取记录用户行为数据几乎为零深度学习模型在这种规模下既没有训练优势答辩时也很难解释清楚。毕设项目里最常见的可靠方案是协同过滤加规则约束先用分数和位次做硬过滤再用相似度算法对候选集排序。这个思路借鉴的是推荐系统里召回加排序的两阶段架构召回阶段把位次上下浮动一定比例的院校专业捞出来排除绝对够不着的和绝对浪费分数的排序阶段计算候选集与考生画像的相似度输出最终推荐列表。两阶段串起来就是完整的推荐服务。4.2 核心算法位次分档加余弦相似度加权协同过滤部分用基于物品的 ItemCF把每个院校专业当作一个物品用录取数据的特征向量计算物品间相似度。特征向量由最低分、最低位次、学费、省份、院校类型组成做归一化后计算余弦相似度。public double cosineSimilarity(double[] vectorA, double[] vectorB) { if (vectorA.length ! vectorB.length) { throw new IllegalArgumentException(向量维度不一致); } double dotProduct 0.0; double normA 0.0; double normB 0.0; for (int i 0; i vectorA.length; i) { dotProduct vectorA[i] * vectorB[i]; normA Math.pow(vectorA[i], 2); normB Math.pow(vectorB[i], 2); } if (normA 0.0 || normB 0.0) return 0.0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }特征向量构造直接决定推荐质量。最低分和位次量纲不同不能直接放进向量里。min_score除以 750、min_rank除以当年全省考生总数、学费除以 10000把三个连续值压缩到 0~1 区间省份和院校类型用 one-hot 编码扩展为多个 0/1 维度。归一化这一步做不做推荐结果差别很大。召回条件用位次而非分数因为不同年份的分数不可比。考生位次为candidateRank召回范围是candidateRank * 0.9到candidateRank * 1.3这个比例来自历年录取数据的统计经验位次下探 10% 是接近冲的边界上浮 30% 是接近保的边界。4.3 冷启动与边缘情况的处理新用户没有任何选校行为数据协同过滤算不了。常见的冷启动方案是走规则推荐按考生分数、位次、省份、文理科进行配置化匹配等用户收藏了几个院校后再切换到协同过滤加权排序。边缘情况也要提前想好。分数高于某校历年最高分时直接标记为稳分数低于该校历年最低分 20 分以上就不出现在推荐里某些专业在不同省份的录取分数差异很大跨省比对没有意义所以特征向量里必须包含省份维度。推荐接口里把这些判断写在过滤阶段避免脏数据影响排序结果。4.4 推荐结果的可解释性输出推荐系统被质疑最多的是凭什么推荐这个学校。可解释性在毕设里最容易拿分把推荐理由拆成三条硬指标显示出来。给一段示例代码public ListRecommendation recommend(StudentProfile profile, int limit) { ListAdmissionScore candidates filterByRank(profile.getRank()); MapLong, Double scoreMap new HashMap(); for (AdmissionScore candidate : candidates) { double[] candidateVector buildVector(candidate); double[] userVector buildUserVector(profile, candidate); double similarity cosineSimilarity(userVector, candidateVector); double safetyScore calculateSafety(candidate, profile); scoreMap.put(candidate.getId(), 0.7 * similarity 0.3 * safetyScore); } return scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(limit) .map(entry - buildRecommendation(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); }综合得分中相似度占 70%安全系数占 30%。安全系数是考生位次相对院校录取位次的位置考生位次比录取位次高越多安全系数越高。输出时把三个核心参数显示在推荐卡片上你的位次、该校近三年最低位次、你的分数超过最低分多少分。用户看到具体数值比对信任度会明显提升。5. 功能闭环搜索、收藏到模拟志愿单5.1 院校专业查询接口设计推荐系统只有推荐页不够用户需要主动搜索某所院校或某个专业的录取信息。查询接口的核心是动态条件拼装GetMapping(/api/scores) public PageResultAdmissionScoreVO queryScores( RequestParam(required false) String schoolName, RequestParam(required false) String majorName, RequestParam(required false) String provinceCode, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { // 按名称模糊查询院校名称和专业名称支持关键字搜索 // 按省份筛选默认查全部省份 // 按年份倒序最新数据排在前面 }查询接口只用 MySQL 的 LIKE 和等值条件单表查询加上联合索引响应时间在几十毫秒级别不需要引入搜索引擎。返回的 VO 里除了分数数据还要带上院校类型、所在地、学费等信息避免前端页面再发二次请求。5.2 收藏池与志愿单的实现收藏是推荐系统的核心交互收藏行为也是推荐算法的重要输入。用户表结构里保存省份和年份收藏表保存用户对院校专业的操作记录CREATE TABLE user_favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, score_id BIGINT NOT NULL COMMENT 录取分数记录ID, favorite_type TINYINT DEFAULT 1 COMMENT 1收藏 2弃选, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_score (user_id, score_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户收藏表;favorite_type字段存用户把某个院校标记为感兴趣还是不感兴趣推荐算法在召回后可以直接过滤掉用户弃选过的项目。收藏表的数据还是推荐排序的重要特征两个位次相近的院校用户收藏过 A 但没有收藏 B推荐时会提高 A 的权重。5.3 收藏排序与打印导出模拟志愿单功能参照各省志愿填报规则本科批允许填 80 个院校专业组按冲稳保三档排列。导出功能用前端点击生成 CSV 文件后端返回 JSON 由前端处理比后端用 POI 生成 Excel 更简单。收藏排序规则是按位次分档位次低于考生位次的按冲、接近的按稳、高于的按保每组按综合得分降序排列。这一步做完整个系统从数据采集到最终输出就闭环了。6. 进阶优化与踩坑记录把系统调到可演示状态6.1 爬虫性能与反爬应对的三个参数演示时爬虫跑太慢会很尴尬。三个参数值得调线程池核心线程数从 3 调到 6单页解析时间能压到 1 秒左右Jsoup.connect的超时时间从默认的 3 秒调到 8 秒应对校园网慢速环境每次请求间隔从 1~3 秒缩短到 800~1500 毫秒配合随机 User-Agent 轮换在公开页面场景下足够稳定。如果目标站点返回 403优先检查 User-Agent 和 Accept 请求头浏览器直接复制过来的请求头基本能解决。自动重试机制要加指数退避每次失败等待2^n秒最多重试 3 次避免服务器拒绝后仍反复请求。6.2 推荐效果自测方法推荐效果不能用看起来还行来验收。用上一年的数据做回测取出 2023 年的录取数据作为测试集假设考生位次是 2022 年的某条记录看推荐算法给出的前十位院校里是否包含考生实际被录取的学校。统计命中率50% 以上说明算法有效低于 30% 就检查特征向量归一化是否有问题。6.3 答辩演示前要检查的五个细节演示前把下面五项过一遍能避免绝大多数现场事故检查项操作说明数据库连接mysql -u root -p手动查询确认 MySQL 服务已启动字符集为 utf8mb4爬虫数据量SELECT COUNT(*) FROM admission_score最好有 1000 条以上数据否则推荐结果太空接口超时时间修改application.yml的server.tomcat.connection-timeout建议设为 10 秒防止演示时白屏关闭拦截器查看拦截器配置是否放行/api/**保障静态接口在演示机上可直接访问幂等性连续点击两次推荐按钮确认页面不会重复插入收藏记录最后补充一个具体排错技巧推荐结果为空时不要先看代码先查数据库里province_code和year是否和用户表里的字段一致。实际排过的一个问题是爬虫解析时把省份代码写成了全角数字查询参数是半角导致 JOIN 结果为空。这类问题用SELECT DISTINCT province_code FROM admission_score一条 SQL 就能发现排查成本远低于在代码里打断点。本文还有配套的精品资源点击获取