每年考研报名开始前后,我身边总有一堆人陷在"择校"的泥潭里:这个专业的报录比到底是多少?目标院校近三年分数线波动大不大?某地区的双非院校是不是真的比热门 985 好考?这些问题靠官网一篇篇翻公告,既慢又不全,而且不同年份、不同院系的数据格式完全不统一,连对比都无从下手。所以我去年花了大概三周业余时间,用 Python 技术栈完整做了一套基于 Scrapy 的考研院校报名数据分析系统,从数据采集、清洗、入库到多维分析和可视化,跑通了一条可以复用的链路。这套系统最终能直接回答"哪个院校的哪个专业竞争更激烈""过去五年报名热度有什么变化趋势"这类具体问题。这篇文章我会把整套系统的设计思路、核心实现、踩坑过程和扩展方向完整记录下来,特别适合正在做爬虫实战、数据分析项目,或者准备相关毕业设计的同学参考。
1. 考研报考数据为什么值得专门做一套系统来分析
1.1 信息不对称是择校最大的隐性成本
考研和高考最大的区别在于:高考是出分后按排名填志愿,而考研是报名时就要确定目标院校和专业,一旦报错,分数再高也可能落榜。这里面的核心矛盾是信息不对称——招生简章、推免名单、历年复试线、报考人数这些关键数据分散在各个院校研究生院官网、研招网公告和第三方信息平台里,没有一个统一的维度去对比。
我整理了一圈发现,很多网站只提供当年数据,历史数据要自己翻翻不完的归档页面;有些院校研究生院官网公布了报录统计表,但用的是 PDF 或者图片格式,复制都费劲。更麻烦的是数据口径不统一:有的写"报考人数",有的写"确认人数",还有的干脆只给一个"报录比"的结果,至于怎么算出来的完全没有说明。这种分散且口径混乱的现状,恰好是爬虫和数据分析最能发挥价值的地方——把零散的公开数据采集下来,清洗成统一的结构化数据,再做多维分析,就能极大降低择校时的信息获取成本。
1.2 一套系统能回答哪些实际问题
我在设计系统之前,先把"考研择校"这个场景拆成了几个能落地的具体问题:
- 历年报名人数变化:某个院校或专业近五年的报考热度是升是降,是否存在"大小年"现象。
- 报录比与竞争程度:报名人数和录取人数之间的差距,判断上岸难度。
- 推免比例:招生计划里有多少名额留给了推免生,统考生的实际名额还剩多少。
- 分数线趋势:复试分数线近几年的波动幅度,判断是否存在"分数线虚高"或"价值洼地"。
- 专业冷热分布:同一学校不同专业、不同地区之间的热度差异。
比如有用户想找一个"竞争相对缓和但学校层次不错"的目标,那系统就能通过分析推免率、报录比和分数线的组合,筛出统考名额充足、热度稳定的院校,这比纯靠论坛经验帖靠谱得多。这些分析结果本身,就是这套系统最核心的交付物。
1.3 系统的边界:不预测,只呈现
我必须强调一点:这套系统不是用来"预测"某个学校今年会不会爆的玄学工具。它的定位是一个数据分析平台,负责把历史公开数据采集、清洗、计算、可视化,让决策者基于事实做判断。至于今年某院校报名人数是否会大涨,那是实时数据采集和更复杂的预测模型才能回答的问题,不在本系统第一版的目标范围内。明确边界非常重要,否则需求会无限膨胀,项目根本收不了尾。
2. 系统整体架构与关键技术选型
2.1 数据从采集到展示的完整链路
这套系统的整体链路分四层:采集层、存储层、分析层、展示层。采集层用 Scrapy 完成多站点数据的定时抓取,抓到的原始数据先落到 Redis 做 URL 去重和请求队列管理,再经过 Item Pipeline 清洗后写入 MySQL。分析层通过 Pandas 和 NumPy 从 MySQL 读取结构化数据,做聚合、透视、排名等分析计算,也可以在这里接 Spark 做更大规模的数据处理。展示层用 Flask 提供 JSON 查询接口,前端用 ECharts 渲染图表,最终用户通过浏览器访问分析结果。
链路里最容易被忽略的是中间的数据质量控制环节:Scrapy 抓到的数据不是直接能用,必须经过字段映射、类型转换、去重合并、缺失值处理这一整套清洗流程,才能进入分析层。所以我把这套系统理解成一个数据加工厂:Scrapy 是原料采购员,Pipeline 是质检员,MySQL 是仓库,Pandas 是加工车间,ECharts 是包装展示。
2.2 为什么采集层选 Scrapy 而不是 requests + BeautifulSoup
很多初学者入门爬虫时都是先用 requests 拿 HTML,再用 BeautifulSoup 解析。这套组合在小规模、一次性抓取场景下完全够用,但一旦面对多个学校站点、上百个详情页、需要断点续爬和异常重试的场景,就会暴露出问题:requests 是同步请求,逐个请求效率很低;请求失败要自己写重试逻辑;URL 去重要自己维护;并发控制要自己用线程池或 asyncio 拼装。
Scrapy 把这些常用的爬虫基建都内置了,这也是我选它最核心的原因:
| 能力 | requests + BS4 | Scrapy |
|---|---|---|
| 并发请求 | 需要自己实现 | 内置异步引擎,默认支持并发 |
| URL 去重 | 需要自己维护集合 | 内置基于指纹的去重 |
| 失败重试 | 手写循环 | 中间件配置即可 |
| 数据管道 | 手动代码处理 | Item Pipeline 分阶段处理 |
| 扩展机制 | 几乎没有 | Middleware、Extension 体系完整 |
Scrapy 的引擎、调度器、下载器、爬虫、管道这五个核心组件,打个比方就像一家餐厅的后厨:调度器是排单员,负责安排请求的优先级;下载器是采购员,负责把网页资源拿回来;爬虫是大厨,告诉系统该取哪些数据;管道是摆盘和出菜的环节。每个环节都能单独替换和扩展,遇到反爬升级或者站点改版,只需要改对应的中间件或解析函数,不会牵一发动全身。
2.3 存储与分析的选型逻辑
存储方面我最终选了 MySQL 为主、Redis 为辅的组合。考研数据是典型的结构化数据:院校、专业、年份、人数、分数线,天然适合关系型数据库的二维表。MySQL 对各种聚合查询支持非常成熟,分析层直接用 SQL 做 group by、join 很方便。Redis 在这里主要不是存数据,而是承担 URL 去重指纹、请求队列和 Pipeline 处理时的消息缓存。
关于大数据量的问题,说实话,一个考研院校报名数据分析系统,即使覆盖全国几百所院校近十年的数据,总量也就是几十万条到几百万条的量级,这个量级用 Pandas 处理绰绰有余,完全没有必要一上来就上 Hadoop。但如果数据源扩展到包含每一条报名记录、每一条调剂记录,或者要做海量历史数据对比,那时候再引入 Spark 也不迟。我在这套系统里预留了从 Pandas 平滑过渡到 Spark 的接口,分析计算部分尽量用 DataFrame 风格的代码,这样后续切换成本很低。
3. Scrapy 采集层的工程化实现
3.1 目标数据源与核心字段设计
我把数据源分成三类:第一类是研招网这类官方信息汇总平台,能拿到硕士专业目录、招生计划和部分录取数据;第二类是目标院校的研究生院官网,重点抓历年的复试分数线、录取名单、报考统计等公告;第三类是第三方考研信息站点,用于补充一些官方没有发布的汇总数据。我在采集时遵守的基本原则是只爬公开页面,控制请求频率,并对页面内容做必要的缓存,避免给目标站点造成压力。
核心字段我按主题域拆成了院校维度、专业维度、招生年份维度和报考数据维度:
- 院校名称、院校代码、所在省市、院校层次(985/211/双一流等)
- 专业代码、专业名称、所属学院、专业门类
- 年份、计划招生人数、推免人数、统考录取人数
- 报考人数或确认人数、复试分数线(总分、单科线)、报录比
其中"报考人数"是最容易出现缺失或口径混乱的字段,有的院校不会公开,有的只公布实考人数,清洗阶段需要做特殊处理。
3.2 定义 Item 与字段校验
Scrapy 中定义数据结构的标准方式是写 Item 类。我习惯把字段校验也放在 Item 里,尝试把问题拦截在早期。
import scrapy class ApplyInfoItem(scrapy.Item): # 院校维度 school_name = scrapy.Field() school_code = scrapy.Field() province = scrapy.Field() school_level = scrapy.Field() # 985/211/双一流/普通 # 专业维度 major_code = scrapy.Field() major_name = scrapy.Field() college_name = scrapy.Field() # 报考数据维度 year = scrapy.Field() plan_num = scrapy.Field() # 计划招生 recommend_num = scrapy.Field() # 推免人数 apply_num = scrapy.Field() # 报名人数 admit_num = scrapy.Field() # 统考录取人数 interview_score = scrapy.Field()# 复试分数线Field 的作用不只是容器,还能通过 meta 参数传递校验信息。比如我可以给 year 字段绑定一个处理器函数,检查值是不是四位数字;给 apply_num 绑定类型转换函数,把字符串里的"约""人"等杂质去掉。这样 Item 就不仅仅是数据容器,还在入口处做了一层基础质检。
3.3 Spider 核心逻辑:列表页与详情页的配合
整个爬虫的逻辑核心是"先列表后详情"的两级解析模式。列表页拿到的是一批院校+专业的入口链接和摘要信息,详情页补齐具体的报名人数、分数线等完整指标。
import scrapy from ..items import ApplyInfoItem class ApplySpider(scrapy.Spider): name = "apply_info" allowed_domains = ["example.edu.cn"] start_urls = [ "https://yjs.example.edu.cn/zsxx/statistics", ] def parse(self, response): # 解析列表页中的详情链接 detail_links = response.css("table tbody tr td a::attr(href)").getall() for link in detail_links: yield scrapy.Request( url=response.urljoin(link), callback=self.parse_detail, meta={"source_page": response.url}, # 记录来源,便于后期排查 ) # 处理翻页 next_page = response.css("a.next::attr(href)").get() if next_page: yield scrapy.Request(url=response.urljoin(next_page), callback=self.parse) def parse_detail(self, response): item = ApplyInfoItem() item["school_name"] = response.css("h1.title::text").get() # 具体选择器根据实际页面调整,这里只是示例 item["apply_num"] = response.xpath("//td[contains(text(),'报名人数')]/following-sibling::td[1]/text()").get() item["admit_num"] = response.xpath("//td[contains(text(),'录取人数')]/following-sibling::td[1]/text()").get() ... return item这里有一个很实用的细节:用 meta 把来源页面地址传下去。后期如果清洗阶段发现某个数据特别异常,可以顺着 source_page 快速定位是哪个页面采集来的,排查效率会高很多。
3.4 动态加载页面与 iframe 的处理方案
现在很多院校官网已经不是纯静态 HTML,数据往往通过 Ajax 异步加载,甚至嵌在 iframe 里。针对这类页面,我的优先方案是抓接口而不是抓渲染后的页面。用浏览器开发者工具分析页面请求,找到背后的 JSON 数据接口,直接请求接口拿结构化数据,省事又稳定。
如果站点做了比较严格的访问控制,难以直接请求接口,或者数据完全靠 JavaScript 渲染且没有独立的数据接口,那我就会让 Scrapy 集成 Playwright,用浏览器渲染之后再提取。核心思路是用 Scrapy 的 Downloader Middleware 拦截请求,当发现页面是动态渲染的,就通过 Playwright 加载并返回渲染后的 HTML。
# middleware 关键配置示例 class PlaywrightMiddleware: async def process_request(self, request, spider): if request.meta.get("render_js"): from playwright.async_api import async_playwright async with async_playwright() as p: browser = await p.chromium.launch() page = await browser.new_page() await page.goto(request.url) await page.wait_for_selector("table") content = await page.content() await browser.close() return scrapy.http.HtmlResponse( url=request.url, body=content, encoding="utf-8" )iframe 的处理原理类似,如果目标数据在 iframe 子文档里,直接用页面默认的 response 是拿不到的。我踩过的坑是只看了顶层页面的 HTML,结果字段全是空的。正确的做法是先定位 iframe 元素,获取它的 src 地址,把请求发到 iframe 对应的 URL,这样才能拿到真正的数据。这个点看似简单,但排序在坑里排得上号。
3.5 请求频率控制与断点续爬
爬取多个院校站点时,最怕的是未经控制的高频请求把对方站点打挂,也怕自己 IP 被临时限制。Scrapy 的 AUTOTHROTTLE 扩展会自动调节请求速度,同时我也在 settings 里设置了 DOWNLOAD_DELAY 兜底:
# settings.py 片段 DOWNLOAD_DELAY = 1.5 # 请求间隔兜底值,单位秒 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1.0 AUTOTHROTTLE_MAX_DELAY = 10.0 AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0 CONCURRENT_REQUESTS_PER_DOMAIN = 4 ITEM_PIPELINES = { "apply_system.pipelines.CleanPipeline": 300, "apply_system.pipelines.MySQLPipeline": 500, }断点续爬这块,我用的是 Scrapy 自带的 JOBDIR 功能。只需要在启动命令里加一个参数,爬虫意外中断后再次启动,就能从上次的位置继续,不至于前功尽弃:
scrapy crawl apply_info -s JOBDIR=crawls/apply_info_job这个功能实测中帮了大忙。有一次抓了几万个详情页,写到一半服务器重启,没有 JOBDIR 的话整个队列都要重来。
4. 数据清洗与存储:决定分析结果下限的部分
4.1 实际数据里最常见的脏数据形态
很多做爬虫项目的同学把重心放在"能不能抓到",却严重低估了清洗的工程量。我在这套系统里面临的脏数据可以说是五花八门,归纳起来主要有这么几类:
第一类是格式不统一。同样是报名人数,有的页面写"350",有的写"约350人",还有的用"350+"这种表示。第二类是单位不一致,部分院校用"万"做单位,比如某专业报考人数超过 1 万,页面可能写成"1.2万人"。第三类是缺失值,很多字段在部分年份根本没有公布,要么为空,要么显示"—"。第四类是重复值,同一个数据既出现在院校官网,也出现在汇总平台,如果不做去重,分析阶段就会重复计数。
4.2 Pipeline 清洗策略的落地
我在 Item Pipeline 里做三层处理:第一层是标准化,把所有字段转成统一格式;第二层是补全和修正,比如根据招生计划和推免人数推导统考名额;第三层是过滤,把明显异常的数据剔除或者标记。
class CleanPipeline: def process_item(self, item, spider): # 标准化报名人数,去掉中文和符号,保留数字 if item.get("apply_num"): cleaned = re.sub(r"[约人万人\+]", "", str(item["apply_num"])) if "万" in str(item["apply_num"]): cleaned = str(float(cleaned) * 10000) item["apply_num"] = int(float(cleaned)) # 缺失值处理:报录比缺失时用录取人数推算 if item.get("admit_num") and item.get("apply_num") and not item.get("rate"): item["apply_rate"] = round(item["apply_num"] / item["admit_num"], 2) return item清洗规则看起来很简单,但真正的难点在于不同站点有不同的"脏法"。我会写一个独立的 validation 脚本,定期对库里数据做质量检查,比如统计 apply_num 为空的比例、检查某一年份某院校的数据是否重复。数据质量检查应该像体检一样固定频率去做,而不是上线一次就完事。
4.3 表结构设计与增量更新策略
MySQL 建表时我用了联合唯一索引来防止重复数据。核心标识是"院校代码+专业代码+年份"这三个字段,理论上一个院校的某个专业在某一年只有一条报名统计数据。
CREATE TABLE apply_stat ( id INT PRIMARY KEY AUTO_INCREMENT, school_code VARCHAR(16) NOT NULL, school_name VARCHAR(64), province VARCHAR(32), school_level VARCHAR(16), major_code VARCHAR(16), major_name VARCHAR(64), college_name VARCHAR(64), year INT NOT NULL, plan_num INT, recommend_num INT, apply_num INT, admit_num INT, interview_score DECIMAL(5,1), apply_rate DECIMAL(6,2), UNIQUE KEY uk_school_major_year (school_code, major_code, year) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;数据入库时用"查重,有则更新,无则插入"的 upsert 策略。MySQL 里直接用 INSERT ... ON DUPLICATE KEY UPDATE 就能实现。这样每次重新采集,旧数据会被覆盖,新数据会插入,不会产生重复记录。增量更新的核心思路就是让采集任务具备"幂等性"——不管跑多少次,结果都是一致的。
5. 分析建模:把数据变成可直接参考的结论
5.1 竞争指数:不只是简单的报录比
做数据分析最早级也最不推荐的做法是直接拿原始字段堆图表,因为那张表根本没法直接用。比如报录比这个指标,不同院校的推免比例差异很大,有的学校招生计划里一半名额是推免,剩下给统考生的名额本来就少;如果只看报录比不看推免率,很容易得出"这个学校竞争怎么这么激烈"的错误结论。
所以我在建模时设计了一个"统考竞争指数":
[ 统考竞争指数 = \frac{报考人数}{(计划招生人数 - 推免人数)} ]
分子是实际参与竞争的统考生,分母是统考生实际能争取的名额,这样的指标比单一报录比更能反映真实难度。如果院校没有公布推免人数,就退回到用报录比,但会在可视化里做降权标注。指标的设计一定要围绕"能帮助用户做决策"这个目标,而不是为了花哨。
5.2 历年趋势与性价比筛选:Pandas 的典型应用
数据分析部分我主要用 Pandas 完成。核心思路是:从 MySQL 读数据,在 DataFrame 里做清洗和聚合,输出分析结果给到后端接口。下面这段代码实现了"近五年某专业全国各院校竞争指数排名"的计算:
import pandas as pd import pymysql conn = pymysql.connect(host="localhost", user="root", password="******", database="kaoyan") df = pd.read_sql("SELECT * FROM apply_stat WHERE year >= 2019", conn) # 计算统考名额和竞争指数 df["tongkao_num"] = df["plan_num"] - df["recommend_num"].fillna(0) df["competition"] = df["apply_num"] / df["tongkao_num"].replace(0, pd.NA) # 筛选近三年平均竞争指数适中的院校和专业 recent_years = df[df["year"] >= 2022] pivot = recent_years.groupby(["school_name", "major_name"]).agg( avg_competition=("competition", "mean"), avg_score=("interview_score", "mean"), year_count=("year", "nunique") ).reset_index() # 找出竞争指数在 3-6 之间、分数线稳定的"性价比"目标 candidates = pivot[(pivot["avg_competition"] >= 3) & (pivot["avg_competition"] <= 6)] candidates = candidates.sort_values("avg_competition")这类分析的价值在于把"感觉哪个学校难考"变成"哪个学校的竞争指数是多少、趋势是什么"。分析层的所有代码都尽量写成函数,方便后续在 Flask 接口里反复调用。
5.3 趋势分析与异常波动的识别方法
除了静态的排名分析,历年趋势分析也很关键。我用 Pandas 的 shift 函数计算每年相对上一年的报名人数变化率,定义大于 30% 的年份为"热度跳升年",小于负 20% 的年份为"热度回落年",从而识别出每个专业是否存在明显的"大小年"现象。
识别异常波动的工作量不小,但产出很有价值。比如某个双一流院校的计算机专业,前三年报名人数稳定在 600 人左右,去年突然跳到 900 人,系统就能在专业详情页自动标注"近一年热度显著上升",这种一手发现比在网上看经验帖靠谱得多。
6. 可视化输出与系统前端
6.1 哪些图表最能说明问题
可视化的目标是让非技术用户也能快速理解分析结果。我实际用下来,最有效的是这几种:
- 折线图:展示某院校或专业近五年的报名人数、复试线趋势,一眼看出变化。
- 柱状图:横向对比同一地区不同院校的热度,或同一院校不同专业的热度。
- 热力图:用矩阵方式展现"地区×门类"的竞争程度,颜色越深代表竞争越激烈。
- 饼图/环形图:展示推免与统考的名额占比,让人直观理解统考生的实际竞争空间。
图表库我选的是 Pyecharts,因为它基于 ECharts,图表交互效果好,而且可以直接在 Python 代码里生成 HTML,不需要单独写复杂的前端代码。对这套系统来说,Pyecharts 是把分析结果落到界面上最快的路径。
6.2 用 Flask 提供查询接口
为了让浏览器端可以按条件筛选,我写了一个轻量级的 Flask 后端,提供两类接口:一类返回聚合图表数据,另一类返回院校和专业详情。比如按"省市+专业门类"筛选竞争指数排名:
from flask import Flask, request, jsonify import pandas as pd app = Flask(__name__) @app.route("/api/rank", methods=["GET"]) def rank(): province = request.args.get("province") major_category = request.args.get("major_category") # 调用分析层函数,返回排名数据 result_df = analyze.rank_by_condition(province=province, major_category=major_category) return jsonify(result_df.to_dict(orient="records"))前端这一层没必要做太重,一个单页面应用,通过下拉框和按钮调用接口,再用 Pyecharts 渲染图表就够了。整个前端开发工作量大概只占项目的一成,重心还是放在数据部分。
6.3 分析结果如何推动真实决策
举一个实际跑出来的分析案例:在某中部省份的院校数据里,计算机科学与技术专业连续三年处于高竞争区间,但同校的软件工程专业竞争指数却只有前者的一半左右,两个专业的复试线差距也不大。对于"想进入这个城市,又想学泛 IT 专业"的考生,系统就会给出一个参考结论:软件工程是更稳妥的选择。分析系统最有价值的地方,就是能把这种容易被忽略的差异化信息挖掘出来。
7. 项目跑通后,我踩过的坑和下一步思路
7.1 采集阶段三个印象最深的坑
第一个坑是动态 token 问题。有的官网在请求详情页时会校验 Cookie 或 token,直接通过详情页 URL 带参访问会返回 403。我的解决方案是先从首页走一遍完整交互流程,用 Playwright 拿到有效的 Cookie 和 token 之后,把这些值放到请求头里,后续请求才能正常返回。
第二个坑就是前面提到的 iframe。某个院校的历年报录表被嵌在二级页面里的 iframe 中,iframe 的 URL 看不出来跟原始页面有任何关联,我花了半天时间排查,最后才发现数据根本没在当前 response 里。
第三个坑是数据单位不一致导致的分析偏差。有个院校某专业报考人数写的是"1.2千人",我一开始按"1200 人"入库,后来翻原始页面才发现它表格里藏了一个单位行。从那以后,我在清洗项目里加入了单位检查和人工抽检双保险。
7.2 数据分析阶段的两个关键教训
教训一是必须统一数据口径。有的院校公布的是"报名人数",即网上确认前的报名数;有的公布的是"实考人数",即实际走进考场的人数;两者可能差百分之二三十。如果把两种口径混在一起对比,分析结论会失真。我在系统里增加了 source_type 字段,区分数据来自官方统计还是第三方平台汇总,并优先信任官方来源。
教训二是年份的起止边界不能含糊。某院校的"2025 年招生数据"实际上是 2024 年 12 月考试的数据,不仔细看就会把年份搞错一年。这类时间边界问题在爬虫字段设计时必须明确,我最后在代码里强制要求年份字段必须从页面上下文里显式获取,不允许默认填充当前日历年。
7.3 从单机分析往大数据方向扩展
目前这套系统用 Pandas 处理几十万条数据完全没有压力。如果后续要把数据规模往上推,比如接入全量调剂记录、每天抓取实时报名人数变化,或者加入非结构化数据(论坛讨论、社交媒体的院校热度指数),那就可以按这样的路径演进:
- 存储层把 MySQL 扩展到 HDFS 或云数据仓库,把历史冷数据和实时热数据分层存放。
- 计算层用 Spark 替换 Pandas,处理百亿级以内的数据分析任务。
- 采集层引入消息队列 Kafka,让各院校的定时采集任务解耦,数据实时流入数据分析管道。
- 调度层用 Airflow 编排定时任务,不再依赖手写 cron。
这套演进路线我虽然没有全部落地,但架构上已经留好了接口。做这种项目最忌讳的是一开始就追求大而全,先把核心链路跑通、把数据质量管好,等真正有数据量增长的需求,再逐层升级,这样每一步都走得踏实。
做完这套系统,我最大的体会是:爬虫和可视化这些看似炫酷的部分,其实只占整个项目三成的工作量;真正决定系统价值的,是数据治理和口径统一这类看不见的脏活累活。再分享一个小技巧给后面做类似项目的人:从第一天就给每条数据记录加上来源 URL 和采集时间,这两个字段在后期排查数据异常时价值巨大,能帮你在数据质量问题上省下一半的调试时间。