AI 说它会按格式输出,但它不会:我给考研分析套了一层正则
这篇的重点不是爬虫,也不是"怎么画报告"(那两件事别的文章写过了)。
它只讲一件事:当你把一个程序交给一个概率模型,你怎么保证它不把程序搞崩。
成本先说清(别白折腾):
- 爬虫部分 🟢 零成本,只要 requests + pandas
- AI 部分 🔴 要一个讯飞星火 API Key(实名注册,有免费额度),要花钱的我一律不放
- 没有 Key 也能跑:把 ai_fenxi() 换成返回假数据的桩函数,爬虫、分档、HTML 报告整条链路照常走通,
只是报告里"AI 预测"那一段显示"暂无数据"。核心逻辑你一样能学到,不用先卡在申请 Key 上。
我决定考研是在 2026 年 9 月,那时候我手上什么数据都没有。目标院校是听说的,分数线是感觉的,专业课考什么是猜的。
后来我把这件事拆成了三步:先把数据爬下来,再让 AI 分析,最后生成一页能发给爸妈看的报告。整条链路一共四个文件、1200 多行代码。
这篇不讲"怎么写爬虫",网上一搜一大把。我想讲的是后半段——数据爬回来之后,怎么让它变成一句人话,再变成一个能看的东西。
第一步:87 行,两段接口
考研网站的数据分两层:一层是学校列表,一层是每个学校每个专业的分数线。所以是两段接口串起来的。
import requests, time, random, pandas as pd
send_url = "https://api.kaoyan.cn/pc/school/schoolList"
send_h = {
"user-agent": "Mozilla/5.0 … Chrome/149.0.0.0 Safari/537.36",
"referer": "https://www.kaoyan.cn/",
}
send_data = {"page": 1, "limit": 20, "province_id": "11", "type": "", "feature": "", "school_name": ""}
res = requests.post(url=send_url, headers=send_h, data=send_data)
xxlist = res.json()["data"]["data"]
all_data = []
for xx in xxlist:
xname, xid = xx["school_name"], xx["school_id"]
for year in range(2023, 2027):
url = "https://api.kaoyan.cn/pc/school/schoolScore"
data = {"degree_type": "", "school_id": xid, "year": year}
resdata = requests.post(url=url, headers=send_h, data=data).json()
for x in resdata["data"]:
all_data.append({
"学校": xname, "专业": x["name"], "代码": x["code"],
"招生院系": x["depart_name"], "总分": x["total"],
"政治": x["english"], "外语": x["politics"],
"专业课一": x["special_one"], "专业课二": x["special_two"],
"录取年份": x["year"],
})
time.sleep(random.randint(1, 3))
pd.DataFrame(all_data).to_csv("各院校历年数据采集.csv", index=False)
87 行里,真正值得说的只有三件事。
第一,学校 id 是链条的关节。 第一个接口给你 school_id,第二个接口要你把这个 id 喂回去。所有"列表页 → 详情页"的爬虫都是这个结构,我以前写过音乐下载、B 站视频,一模一样。
第二,random.randint(1, 3) 不是可选项。 一个学校 4 个年份 = 4 个请求,20 个学校就是 80 个请求。不加 sleep,一分钟内 IP 就凉了。
第三,注意 政治 和 外语 这两个字段。 代码里写的是 "政治": x["english"], "外语": x["politics"]——字段名是反的。这不是我写错了,是接口本身就这么返回的(至少我抓的时候是)。所以我加了一层别名映射,把接口的脏数据挡在 CSV 外面。
数据脏是常态。 我后来抽了 20 行出来看,还发现总分有空的、专业课二有 "-" 的。所以读取的时候必须 try: float(…) except: continue,不能假设字段一定是数字。
第二步:把"择校"这件事翻译成代码
这一步是我做这个项目时最爽的地方——"冲刺 / 稳妥 / 保底"这三个词,是可以写成 if 的。
for r in rows:
if zhuanye not in r['专业']:
continue
try:
xian = float(r['总分'])
except:
continue
info = r['学校'] + ' | ' + r['专业'] + ' | ' + str(int(xian)) + '分 | ' + r['录取年份'] + '年'
if xian > fenshu + 10:
chongci += ' ' + info + '\\n'
elif xian >= fenshu – 10:
wentuo += ' ' + info + '\\n'
else:
baodi += ' ' + info + '\\n'
fenshu 是我的估分,±10 是一个我自己定的带宽:
- 比我高 10 分以上 → 冲
- 在 ±10 分之内 → 稳
- 比我低 10 分以上 → 保底
这十行代码的价值,不在于技术,在于它把一个模糊的焦虑变成了一条条能排序的记录。
写之前我脑子里是"我大概能考上哪儿",写之后我看到的是一张表:哪些学校的金融专业在 370 分附近,分别是哪一年、哪个院系。焦虑变成了清单,清单是可以处理的。
第三步:让 AI 分析,然后不信它
这一步是我踩坑最多的地方。
我的提示词很朴素:把历史分数线、以及按 ±10 分筛出来的三档学校喂给它,要求 300 字以内、按固定格式输出。
prompt += '请用以下格式输出(每项写简洁,不要展开):\\n'
prompt += '—\\n'
prompt += '预测明年线:XX分(理由:一句话)\\n'
prompt += '\\n'
prompt += '冲刺推荐:2个(学校-专业,分数线,理由一句话)\\n'
prompt += '稳妥推荐:2个(同上)\\n'
prompt += '保底推荐:2个(同上)\\n'
prompt += '\\n'
prompt += '建议:1条\\n'
第一版跑出来,AI 确实给了答案,但格式每次都不一样:有时是 冲刺推荐:,有时是 **冲刺推荐**:,有时干脆换成小标题。我的程序按 冲刺推荐: 去切,一半的时候切不到。
我当时的错误想法是:“提示词再写严一点它就会守规矩。”
不会的。LLM 是概率模型,不是编译器。 你把格式写进提示词,只是让它"倾向于"这么输出,不是保证。
所以第二版我改了思路:解析端不要求格式正确,只要能在噪声里把需要的东西捞出来。
patterns = {
'prediction': r'预测\\s*(?:明年|\\d{4})\\s*线\\s*[::]\\s*(.+?)(?=\\n\\s*(?:冲刺|稳妥|保底|建议|\\Z))',
'chongci': r'冲刺推荐\\s*[::]\\s*(.+?)(?=\\n\\s*(?:稳妥|保底|建议|\\Z))',
'wentuo': r'稳妥推荐\\s*[::]\\s*(.+?)(?=\\n\\s*(?:保底|建议|\\Z))',
'baodi': r'保底推荐\\s*[::]\\s*(.+?)(?=\\n\\s*(?:建议|\\Z))',
'advice': r'建议\\s*[::]\\s*(.+?)$',
}
for key, pattern in patterns.items():
m = re.search(pattern, clean, re.DOTALL | re.MULTILINE)
if m:
sections[key] = m.group(1).strip()
三个关键点,全在这一小段里:
- [::] 全角半角都收,因为 AI 经常混着输出。
- \\s* 到处都是,容忍多余空格和换行。
- (?=…) 前瞻,用来"切到下一个标题为止"。这是 .+? 非贪婪做不到的——非贪婪只保证最短,不保证停在正确的边界上。
如果我还是解析不出来怎么办? 每一项默认是空字符串,build_xxx_html() 里判空就给"暂无数据"。整个程序不会崩,只会降级。
这就是我对"AI 集成"的全部理解:它的输出永远当作不可靠的外部输入处理,跟处理一个第三方 API 一样。
第四步:把结果变成一个能看的东西
数据干净了,怎么展示?有三个选择:命令行打印、生成 PDF、生成 HTML。
我选 HTML,理由是单文件、双击就开、能发微信。
html = HTML_TEMPLATE
html = html.replace('{school}', school)
html = html.replace('{major}', major)
html = html.replace('{score}', str(int(score)))
html = html.replace('{prediction_html}', build_prediction_html(sections))
html = html.replace('{history_html}', build_history_html(data['linian']))
为什么不用 jinja2? 因为要装依赖,而且模板文件得单独存一份,分发的时候容易漏。
str.replace 是土办法,但它有一个巨大的好处:整个报告就是一个 .py 文件里的一个字符串常量,复制走就能跑。我给同学看过一次,他要做的就是改三个变量。
代价是模板里不能用 { }(会被误替换)。所以我 CSS 里全用 –var: value; 这种写法,避开花括号冲突。这个坑我在做可视化大屏的时候又踩了一次,后面那篇会专门写。
第五步:最后一行代码,决定用户会不会用
html_path = os.path.abspath('./考研数据分析报告.html')
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html_content)
webbrowser.open('file://' + html_path)
webbrowser.open() 只有一行,但它是整个项目里性价比最高的一行。
没有它:用户跑完程序,看到"报告已生成:C:\\Users…\\考研数据分析报告.html",然后手动去文件夹里找。
有了它:跑完,浏览器自己弹出来。
技术项目好不好用,常常不取决于核心逻辑,取决于这最后一步有没有替用户点一下。
写在最后
回头看这个项目,1200 行代码里,我觉得有价值的东西按顺序是:
至于爬虫那 87 行——说实话,那部分谁都能写。
下一步我打算做三件事:把 CSV 换成 SQLite(数据量到几万行时 pd.read_csv 明显变慢)、把提示词和格式解析抽成独立的 llm_parse.py(现在混在业务里太脏)、让报告支持多校对比(现在一次只能看一所)。
这套"爬数据 → 算指标 → 问 AI → 出报告"的流水线,换个领域就能复用。 我下一步想用它做"实习岗位薪资地图",逻辑几乎不用改,只换数据源和指标。
免责声明(请务必读完)
- 数据仅供个人参考,不构成任何报考建议。 分数线受招生计划、报考人数、试题难度影响,历史数据不能线性外推。最终以目标院校研究生院官网发布的招生简章和复试线为准。
- 请控制抓取频率。 本文代码里的 sleep(random.randint(1, 3)) 不是装饰。高频请求会给对方服务器造成压力,也可能导致你的 IP 被封。《网络安全法》对"干扰他人网络正常功能"有明确约束。
- 不要把爬来的数据商业化。 二次分发、售卖、用于商业产品,可能侵犯数据权益。
- AI 输出的内容可能是错的。 本文里 AI 的"预测明年线"只是基于历史数字的模式外推,它不懂招生政策,也不知道今年会不会扩招。请把它当成一个帮你整理信息的助手,不是决策者。
- 凭据不要写进代码。 本文示例里的 API Key 通过 get_credential() 从环境变量读取,任何公开仓库、博客、截图里都不要贴真实 Key。
- 本文代码可能随时失效。 接口地址、字段名、页面结构都会变。以你手上的实际情况为准,运行后果自负。
择校这件事,数据和 AI 都只能帮到"把选项列清楚"这一步。真正决定的那一下,还是得你自己下。
网硕互联帮助中心





评论前必须登录!
注册