北航培养方案数据流水线:爬虫、PDF解析与课程查重实战 📅 发布时间:2026/9/16 3:26:49 👁 浏览次数: 简介北航全校培养方案数据整理与归档项目是一份面向北京航空航天大学各学院师生与教务管理人员的培养方案资源集主要解决课程设置查询不便、学分要求分散以及教学数据人工整理效率低等问题。资源共817个文件压缩包大小约452KB主体为808份各学院专业培养方案PDF另含Python自动化爬取脚本、Markdown说明文档、JSON配置及使用说明等分别服务于数据抓取、程序配置、系统说明与日常查阅。包内支持多年度培养方案版本对比与查重可快速识别课程调整、重复设置与学分学时变化帮助学生更高效地规划学习路径教师可据此优化教学内容教务部门也能借助自动化方式提升数据准确性、时效性与管理效率同时附赠操作说明与配置示例便于快速掌握系统使用。目前已有48人学习/下载适合需要系统查阅北航专业培养方案、跟进版本动态或开展教务数据处理的高校师生及管理人员。1. 把北航培养方案从爬取到查重做成可复用的数据流水线每年秋季学期过后各学院的教学秘书都会收到一批“新版培养方案”往往以 PDF 形式挂在教务系统深处。老师想查某专业 2022 级和 2020 级的课程差异得打开两个文件逐行对教务想排查两门课是否内容重叠只能靠人工浏览课程清单。这个项目把北航各学院专业的培养方案 PDF 从教务系统批量拉下来按“学院序号-学院-专业-年级”命名归档再用 PDF 解析把课程表抽成结构化的 JSON/SQLite 数据最后支持同专业多年度版本对比和跨专业课程查重。对教学管理人员它能输出差异清单对工程师它是一条“爬取→归档→解析→对比→校验”的完整数据流水线对学生它本身就值得当作爬虫和数据清洗的练手案例。下面按这条链路逐步拆开。2. 教务系统数据爬取与“学院-专业-年级”分类存储的实现2.1 教务系统爬取的常用链路北航教务系统是典型的 JSP 后端页面数据分两类一类是直接在 HTML 里渲染的静态链接另一类是通过 XHR 异步拉取 JSON。打开浏览器开发者工具切到 Network 面板刷新培养方案列表页如果看到pyfaQuery之类的接口返回数组那就说明可以用 requests 模拟登录流程。登录时很多系统会用 JS 对密码做 RSA 加密需要在控制台打断点拿到公钥或加密函数或者干脆用 Playwright 驱动真实浏览器完成登录并复用 Cookies。import requests from time import sleep s requests.Session() # 教务系统登录前先 GET 一次登录页拿到必要的 Cookie s.get(https://jwxt.buaa.edu.cn/login, timeout10) # 实际登录接口名称和参数以页面源码为准 login_data { username: your_student_id, password: your_password, encode: true # 部分系统要求密码经过 JS 编码后传入 } resp s.post( https://jwxt.buaa.edu.cn/jwglxt/xtgl/login_slogin.html, datalogin_data, timeout10 ) print(resp.status_code, s.cookies.get_dict()) # 查询培养方案列表返回 JSON 数组 plan_resp s.get( https://jwxt.buaa.edu.cn/jwglxt/pyfa/pyfaQuery, params{gnmkdm: N309, queryModel.showMore: true}, timeout10 ) plans plan_resp.json() for plan in plans: print(plan.get(xmmc), plan.get(yjxz)) # 方案名称、培养年份 sleep(0.5) # 每个请求间隔 0.5 秒防止触发反爬这段代码的逻辑是用同一个requests.Session复用登录后的 Cookie先访问登录页拿到初始状态再提交账号密码。登录接口往往带gnmkdm这样的功能模块代码需要从前端请求的 payload 里抄过来。拿到xmmc字段后可以从里面拆出“学院-专业-年级”信息也可以继续请求详情接口下载 PDF。参数sleep(0.5)是按普通教务系统承受能力设定的如果网络里有几十个并发任务建议把间隔提到 1 秒以上反过来如果你在学校内网且目标接口做了 IP 白名单可以把间隔调到 0.2 秒。2.2 文件名解析与目录归档规则项目原始文件里的命名非常有代表性例如05-1-机械工程及自动化学院-智能制造工程-2022级.pdf。从中可以看到归档规则是“学院序号-方案序号-学院名称-专业名称-年级.pdf”。学院序号用于保持学院在全校列表里的排序稳定方案序号解决同一个学院同时存在多个培养方案的情况比如机械工程及自动化学院同时有智能制造工程和机器人工程两个专业。import re import os import json import shutil # 文件名形如05-1-机械工程及自动化学院-智能制造工程-2022级.pdf pattern re.compile( r^(?Pschool_code\d)-(?Pplan_code\d)- r(?Pschool.?)-(?Pmajor.?)-(?Pgrade\d{4})级\.pdf$ ) def archive_pdf(pdf_path: str, root: str) - dict: fname os.path.basename(pdf_path) m pattern.match(fname) if not m: raise ValueError(f文件名不符合归档规则: {fname}) fields m.groupdict() # 学院目录下再分专业与年级避免所有文件平铺在根目录里 dest_dir os.path.join( root, f{fields[school_code]}-{fields[school]}, fields[major], f{fields[grade]}级 ) os.makedirs(dest_dir, exist_okTrue) new_path shutil.move(pdf_path, os.path.join(dest_dir, fname)) meta {**fields, file: new_path} # 追加写 JSONL单行一条记录方便断点续跑 with open(index.jsonl, a, encodingutf-8) as f: f.write(json.dumps(meta, ensure_asciiFalse) \n) return meta正则里最关键的是学院和专业之间用非贪婪的.?匹配。因为有些方案名称里带“”或括号比如项目文件里的机械工程及自动化学院自动化科学与电气工程学院-机器人工程如果直接按连字符split会把学院名称拆断。这个正则只在两个固定位置断开第一个连字符前是学院序号最后一个-(\d{4})级前是专业名称。归档后生成index.jsonl它是全量文件的索引后续解析和查询都从这份索引里读取。2.3 文件系统 索引 SQLite 的三级存储培养方案总量一般不超过几百份 PDF清洗后课程明细也只有几万条这类规模不需要上数据库服务。文件系统负责物理存储JSONL 负责记录归属关系SQLite 负责课程级检索。下面是在 SQLite 里建两张表的示例CREATE TABLE IF NOT EXISTS plans ( plan_id TEXT PRIMARY KEY, plan_code TEXT, school TEXT, major TEXT, grade INTEGER, file_path TEXT, parsed_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS curriculum ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL, course_code TEXT NOT NULL, course_name TEXT NOT NULL, credit REAL NOT NULL, semester INTEGER, property TEXT, UNIQUE(plan_id, course_code) );选择 SQLite 而不是 MySQL 的原因很直接单文件、零运维、支持UNIQUE约束保证同一年级的课程代码不重复。plan_id建议直接用“学院序号-方案序号-年级”拼接例如05-1-2022。这张宽表是后续做版本对比和查重的数据底座课程代码、学分、学期都进了字段后自然能用 SQL 做聚合。归档这一步到这儿就结束了下一节进入 PDF 解析。3. 培养方案PDF解析用 pdfplumber 把课程表抽成结构化数据3.1 为什么不用 PyPDF2 而是 pdfplumber培养方案 PDF 大多是网页导出的带边框表格PyPDF2 只提供文本流拿到手的是按阅读顺序拼出的整页字符串表格行列关系基本丢失。pdfplumber 基于 PDF 内部的坐标信息重建表格extract_tables()能返回二维列表每一行对应表格中的一行。项目包内附的 PDF 如果是从教学管理系统里导出一般带文字层不需要 OCR如果碰上扫描版就需要先用ocrmypdf预处理或者接 PaddleOCR 识别但那种方案解析结果会更多噪点下文会专门讲校验方法。3.2 抽取课程的代码实现import re import pdfplumber def extract_courses(pdf_path: str) - list[dict]: courses [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: tables page.extract_tables() for table in tables: for row in table: if not row or len(row) 5: continue # 典型的表格列顺序会被终端打印出来确认 # 这里按常见顺序序号、课程代码、课程名称、学分、学时、学期、课程性质 code (row[1] or ).strip() name (row[2] or ).strip() credit 0.0 try: credit float((row[3] or ).strip()) except ValueError: continue # 过滤表头、分组名、说明行课程代码通常以字母开头后面跟数字 if code and name and re.match(r^[A-Z]{1,5}\d{3,}$, code): courses.append({ course_code: code, name: name, credit: credit, semester: (row[5] or ).strip() if len(row) 5 else , property: (row[6] or ).strip() if len(row) 6 else , }) return courses这段代码的容错点集中在三处合并单元格会导致row中某些元素为None所以每个字段取值都要or 跨页表格在每页都会重复表头表头的row[1]是“课程代码”这几个中文字无法通过正则校验会被自动跳过credit转换成float失败时直接跳过该行避免把“毕业要求160”这类汇总行误当成课程。若你的 PDF 表格有合并单元格pdfplumber的extract_tables()会返回重复内容或None这时改用extract_tables({vertical_strategy: lines, horizontal_strategy: lines})能强制按边框线切分但对内嵌横线复杂的表头也会敏感。3.3 课程名称清洗与表格数据校准PDF 里的文本经常带有全角括号、全角空格和制表符课程名称中也可能混入课程组名称。统一清洗逻辑如下import unicodedata import re def clean_course_text(text: str) - str: if text is None: return # NFKC 会把全角字符转半角比如 转成 ( normalized unicodedata.normalize(NFKC, text) # 去掉全角空格、制表符再把连续空格折叠成单个空格 normalized normalized.replace( , ).replace(\u3000, ) normalized re.sub(r\s, , normalized) return normalized.strip()清洗规则里最需要注意的一点是不要直接过滤所有空格。课程名称“电路 分析基础”中间的空格是有意义的如果去掉会改变名称所以这里只折叠连续空格。清洗后的数据如果能落成 CSV后续在 Excel 里做初步筛查也方便。清洗规则整理成下表原始数据示例清洗后规则B1C1000090 基础化学实验1B1C1000090/基础化学实验(1)全角空格删除、全角括号转半角C语言程序设计(B)C语言程序设计(B)首尾空格去除相同字符折叠★专业核心课程★工程力学工程力学去掉评注前缀或单独存为 property 字段解析完成后的质量校验重点看每个专业的总学分。以北航多数工科培养方案为例毕业学分通常在 150170 之间。用一条 SQL 就能算出每个方案的总学分SELECT plan_id, SUM(credit) AS total_credit FROM curriculum GROUP BY plan_id HAVING ABS(total_credit - 160) 5;如果出现大量plan_id与 160 的差距超过 5 学分基本上是解析漏行而不是方案本身有问题。此时回查原始 PDF重点看存在跨页合并行的表格区域。4. 多年度版本对比与跨专业查重的算法与工程落点4.1 以课程代码为主键做版本差异对比版本对比最忌讳直接对 PDF 抽出的文本做diff因为同一门课的课程名称多了一个“英文班”后缀就会被打成一大段差异。正确做法是把每门课当成一条结构化记录以course_code为主键分别构建两个字典再输出新增、移除和变更三类结果。from collections import defaultdict def compare_versions(prev_courses: list[dict], curr_courses: list[dict]) - defaultdict: prev_map {c[course_code]: c for c in prev_courses} curr_map {c[course_code]: c for c in curr_courses} diff defaultdict(list) for code, curr in curr_map.items(): if code not in prev_map: diff[added].append(curr) else: prev prev_map[code] # 学分变化或学期变化都算“变更”名称变化单独归入 renamed if curr[credit] ! prev[credit] or curr[semester] ! prev[semester]: diff[changed].append({ course_code: code, old: prev, new: curr }) elif curr[name] ! prev[name]: diff[renamed].append({ course_code: code, old: prev, new: curr }) for code in set(prev_map) - set(curr_map): diff[removed].append(prev_map[code]) return diff # 假设提取函数已实现 diff_result compare_versions( extract_courses(2020级-飞行器设计与工程.pdf), extract_courses(2022级-飞行器设计与工程.pdf) ) print(f新增 {len(diff_result[added])} 门移除 {len(diff_result[removed])} 门变更 {len(diff_result[changed])} 门)这里把“名称变化”和“学分变化”分开处理因为很多课程在修订时只改了个别汉字学分没变教学层面不需要额外关注。若两版方案使用了不同的课程代码例如学院把课程代码从B1C1000090换成B1C1001001这种精确匹配会漏报。处理办法是先按课程名称做一轮模糊匹配把相似度高于 0.9 的课程对挑出来再确认是否属于同一门课的改码。实际上这个项目里支持多年度版本对比意味着上游数据具备“方案号 年级”两个维度对比时不要跨学院比较否则同一个课程代码可能含义完全不同。4.2 跨专业课程查重从精确匹配到模糊相似度查重场景通常来自两个方向一是全校各专业是否存在同一名称课程重复开设二是某个学院内部名称不同但内容明显重叠的课程。前者用 SQL 直接按course_name分组就能查出同名课后者必须做模糊匹配否则“工程伦理”和“工程伦理与实践”这两门极易重叠的课会被漏掉。这里我用difflib.SequenceMatcher做两两比较数据量在万级之内都能在几秒内跑完。import difflib from itertools import combinations # 每条记录是 (plan_id, course_code, course_name) courses [ (05-1-2022, B1C100001, 工程伦理), (17-3-2022, MX002031, 工程伦理与实践), (05-1-2022, B1C100002, 流体力学), ] def normalize(s): return re.sub(r[\s()], , s) similar_records [] threshold 0.8 for (pa, ca, na), (pb, cb, nb) in combinations(courses, 2): ratio difflib.SequenceMatcher(None, normalize(na), normalize(nb)).ratio() if ratio threshold: similar_records.append({ a: f{pa}:{na}, b: f{pb}:{nb}, similarity: round(ratio, 3) }) print(similar_records)SequenceMatcher对字面顺序敏感工程伦理与伦理学的相似度就不高此时可以换成计算 Jaccard 相似度把课程名拆成单字或双字集合J |A ∩ B| / |A ∪ B|。阈值参数要根据实际试跑调整设到 0.9 会只抓出微小改动设到 0.6 会产生大量误报需要人工筛选。参考“Excel 两列查重”只能做逐字相等判断这里用模糊匹配能发现“查重网站”常用的那种同义变形辅导老师拿到的候选结果更接近真实重复项。最后把疑似重复输出成表格由教务处人工决定是否合并方案 A课程 A方案 B课程 B相似度建议05-1-2022工程伦理17-3-2022工程伦理与实践0.857人工复核03-3-2022机器人控制07-5-2022机器人控制基础0.929建议合并4.3 把差异结果写成变更日志版本对比不是跑完就结束每次发现新增、移除、变更都要落到changelog表里才能追溯“2021 到 2022 级到底改了哪些课程”。 我用如下结构记录每次对比{ plan_id: 05-1-2022, base_plan_id: 05-1-2020, change_type: added, course_code: B1C1002010, course_name: 智能制造基础, credit: 3.0, semester: 5 }这样维护了每个专业的“方案进化史”未来要做毕业要求达成度分析时可以直接从changelog表里捞某门课的演变记录。注意change_type建议直接用英文小写数据库查询时不需要大小写转换。5. 收尾回填校验脚本、API 入口和最容易踩的三个坑5.1 用十字校验法保证解析结果可靠解析结果是否靠谱可以写一页脚本做交叉验证。核心规则有三条课程代码在单个方案内不能重复总学分应与方案里标注的最低毕业学分接近方案文件名里的专业必须和 PDF 内页表头一致。下面是最小可用的校验函数def validate_plan(courses: list[dict], expected_min_credit: float 160.0) - bool: codes [c[course_code] for c in courses] assert len(codes) len(set(codes)), f重复课程代码: {[c for c in codes if codes.count(c) 1]} total sum(c[credit] for c in courses) assert abs(total - expected_min_credit) 5, f总学分 {total} 与预期 {expected_min_credit} 不符 return True5.2 项目内 API.md 对应的查询接口思路资源包里带API.md说明作者已经把解析结果做成了接口。最常见的做法是用 FastAPI 包一个只读查询服务前端按学院、专业、年级三个参数过滤。启动命令类似uvicorn api:app --host 0.0.0.0 --port 8000然后用curl验证 JSON 返回curl http://127.0.0.1:8000/api/plan?school%E8%88%AA%E7%A9%BA%E7%A7%91%E5%AD%A6%E4%B8%8E%E5%B7%A5%E7%A8%8B%E5%AD%A6%E9%99%A2major%E9%A3%9E%E8%A1%8C%E5%99%A8%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%B7%A5%E7%A8%8B接口返回里至少应包含课程代码、课程名称、学分、学期前端才能直接渲染成带汇总的课程表。5.3 最容易被忽略的三个坑第一个坑是 PDF 的跨页合并单元格。同一行课程从第 3 页底部断到第 4 页顶部时extract_tables()会把一行拆成两行学分出现在后半段。处理办法是记录上一页最后一行的top坐标在下一页首行尝试拼接或者直接按course_code为空的行做合并。第二个坑是课程代码规则并不统一有的学院用“字母数字”有的学院使用“学号式”的 9 位纯数字编号正则必须兼容这两种形式建议写两条规则先精确后宽松。第三个坑是爬虫请求间隔太短容易在登录后第一次批量请求时就触顶并发限制返回 302 跳转回登录页。遇到这个问题先检查会话是否过期再检查Referer头是否与教务系统域名一致。最后提醒版本对比和查重脚本都建议用命令行参数传入阈值不要硬编码在代码里方便后续按不同学院分别调参。本文还有配套的精品资源点击获取