做数据整理和批量查询的时候,基于多进程爬虫去抓研招网上的公开信息,基本是这个圈子里绕不开的入门实战项目。招生专业目录、专业学位点、院系代码这类公开数据,一页页复制粘帖会让人崩溃,手动查又太低效,写脚本去抓是最合理的选择。这篇文章就把我用多进程方案重写这套爬虫的全过程记录一遍:从目标站点规律分析、进程池设计,到请求头伪装、断点续爬,再到实际踩过的坑,最后整理成一份可以直接抄改的代码和排查清单。适合已经会用 Requests 写简单爬虫、但不清楚多进程怎么落地的读者,也适合想系统整理一套“能长期稳定跑”的抓取脚本的人参考。
1. 项目背景与需求拆解
1.1 这个项目解决的是什么问题
先说需求本身。研招网这类平台会集中发布各院校的研究生招生专业目录、学科门类、学习方式、拟招生人数等公开数据。如果只是查某一个学校某个专业,人工点击完全够用;但一旦要对比几十个学校的同类专业,或者把某一学科门类下所有招生单位的数据汇总成一张表,手动操作的时间和出错率都会指数级上升。
我做这个项目的出发点很朴素:把某一学科门类下的院校、专业、研究方向、学习方式、指导教师等公开字段抓下来,存进结构化数据表,后续不管是做筛选还是做统计分析,都直接在本地数据上操作。这种需求在考研辅导、志愿填报参考、教育数据整理等场景里非常普遍,本质上属于“公开数据整理”,不涉及任何个人隐私,也不涉及非公开内容。
但这里有个很现实的矛盾:页面数量并不大,核心查询接口的响应速度也很快,问题出在“请求多、页面杂、爱限流”。目标站点虽然有查询页面,但数据渲染路径复杂,直接解析 HTML 很容易因为页面改版而失效。更稳妥的做法是找到它背后真正的数据接口,然后对接口做批量请求。
单进程逐条请求当然也能跑通,但响应时间累积起来非常可观。假设需要拉取 300 个专业目录分页数据,每页平均 1.5 秒,单进程大约需要 7 到 10 分钟。看起来还能接受,但一旦加上超时重试、异常处理、数据清洗,实际耗时经常会翻倍。我跑第一版脚本的时候就是单进程,中间某几个请求被卡住,整个任务就卡了十几分钟。把并发加上去之后,同样的数据量压缩到两分钟以内,这才是“多进程”在这个项目里真正的价值。
1.2 技术栈选型:为什么是多进程
Python 里做并发抓取,通常有几个选择:协程、多线程、多进程。先说结论:我最终选了 multiprocessing 进程池,而不是 asyncio 或 threading。
原因不复杂。这个项目有大量的阻塞式网络 IO,协程确实擅长处理这种场景,asyncio + aiohttp 的表现很好;但协程对代码结构的侵入性比较大,整个请求层都要改成异步风格,调试起来不够直观。多线程看似简单,可 CPython 的 GIL 导致 CPU 密集型的解析和 JSON 处理无法真正并行,线程多了一旦碰到解析耗时较重的场景,提速有瓶颈。多进程每个子进程都有独立的 Python 解释器和独立的 GIL,虽然进程开销更大,但在抓取、解析、落库这条链路里,并行收益最干净。
再补充一点个人体会:进程池管理起来比手动 Process + Queue 舒服得多。我第一版用的是 multiprocessing.Process 手动启动 8 个进程,还要自己处理任务分配和结果回收,代码冗余不说,子进程异常退出时非常难定位。后来换成 Pool 和 imap_unordered,主进程负责构造任务列表,进程池负责调度和回收,异常处理也集中在一处,代码量直接少了一半。
Requests 配 multiprocessing 的组合,虽然看起来没有 Scrapy 那么“高级”,但胜在简单可控、依赖少、好排查。Scrapy 的下载中间件、Item Pipeline 确实强大,但为了这个小体量项目引入整套框架,学习成本和配置成本都偏高。我的建议是:先拿 Requests + multiprocessing 跑通,项目规模大了再迁移框架也不迟。
2. 目标站点分析与反爬体检
2.1 页面结构与接口规律分析
拿到需求先别急着写代码,第一步永远是打开浏览器开发者工具,把目标站点的数据流摸清楚。
我在分析“研招网”这类站点时,观察到的页面结构是这样的:查询页是一个搜索表单,里面包含学科门类、专业名称、院校省份、学习方式等筛选条件。当你在页面上调整条件并点击查询时,浏览器发起的并不是一个简单的 GET 请求,而是一个 POST 请求,Form Data 里带着筛选参数,返回的响应体里存在一段编码后的页面片段或 JSON 数据。
普通开发者第一次看到这段返回内容容易蒙:试图正则解析 HTML 里残缺不全的结构,或者干脆用 Selenium 去模拟浏览器点击,结果给整个项目埋下性能巨坑。实际上,这些“查询接口”返回的数据虽然外层包裹一些兼容性代码,但内核往往是规整的 JSON 或类 JSON 数组,字段名在 JS 文件里能找到对应定义。
我的排查习惯是:
- 打开开发者工具“网络”面板,勾选“保留日志”,正常操作一次查询。
- 从请求列表里找到那个返回数据量明显大于其他请求的 XHR 或 Fetch 请求。
- 查看 Payload,确定请求参数名和编码规律。
- 查看 Response,猜测数据结构,找关键字段名。
- 换一个筛选条件再操作一次,对比两个请求之间的参数变化。
一旦确认接口规律,后面所有抓取都围绕接口展开,而不是围绕页面 HTML。这一步是整个项目最关键的地方,接口找对了,后面的工作基本就顺了;接口找错了,后续所有解析代码都要重写。
2.2 请求头与反爬机制的应对思路
目标站点虽然不算是反爬强度特别高的平台,但也不是裸奔状态。我实际观察到的限制主要有三个:
- 请求头校验:缺失 User-Agent 或 User-Agent 过于明显的请求,会被返回异常页面。
- Referer 校验:部分接口要求 Referer 必须是站内查询页地址。
- 访问频率限制:单 IP 在短时间内高频请求时,会触发临时限流。
针对这些特征,我的应对策略非常常规:构造拟真请求头、设置随机 User-Agent、控制请求频率、做到失败重试。
这不是鼓励绕过什么安全机制,而是确保一个正常用户脚本能够稳定访问公开数据。合理频率的抓取、不涉及非公开数据、不干扰站点正常服务,这是底线。
我实际使用的请求头一般是这样:
import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.4 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", ] def build_headers(): return { "User-Agent": random.choice(UA_POOL), "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://example.edu.cn/", "Origin": "https://example.edu.cn", "Connection": "keep-alive", }这里的 Referer 和 Origin 我建议直接设置成站内查询页地址,减少被识别为非浏览器请求的概率。UA 池里的版本号不必追新,但不能用太老的 Chrome 版本,否则部分 JS 校验会不通过。
还有一个细节:请求头里的 Accept 字段不要乱填。有的接口返回 JSON,但如果你请求头 Accept 写着 text/html,服务端有可能根据响应协商返回一段 HTML 结构,导致解析失败。我一般直接写application/json, text/plain, */*,最省事。
3. 多进程架构设计与核心代码实现
3.1 整体流程与进程模型
理清站点规律之后,我设计的整体流程是这样的:
- 主进程根据目标学科门类或专业代码,构造所有待请求的 URL 或参数列表。
- 进程池中的每个 Worker 从任务队列中领取一个任务。
- Worker 构造请求头,发起请求,拿到 JSON 数据。
- Worker 对 JSON 做字段抽取、清洗,转换成统一结构。
- Worker 将结构化数据写入本地数据库,同时维护一个已抓取 ID 集合用于去重。
这个流程里,任务分发和结果回收是关键。我选择Pool.imap_unordered而不是Pool.map,原因是:Pool.map会严格按照输入列表的顺序返回结果,但每个任务的响应时间有快有慢,顺序等待会拖慢整体速度;imap_unordered则谁先完成谁先返回,吞吐量更高。
进程模型的选择上,我用的是固定大小进程池,进程数一般取 CPU 核心数的 1 到 2 倍。我这台机器是 8 核 16 线程,实测 8 个进程表现最均衡,进程数提到 16 之后,速度提升有限,反而本地 CPU 占用率会打满,影响调试。
3.2 关键代码实现
先看完整的核心代码。这个版本是我调整过多次之后觉得最清晰、最不容易出错的形态:
import os import random import sqlite3 import time from multiprocessing import Pool import requests BASE_URL = "https://example.edu.cn/api/query" UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.4 Safari/605.1.15", ] def build_headers(): return { "User-Agent": random.choice(UA_POOL), "Accept": "application/json, text/plain, */*", "Referer": "https://example.edu.cn/", } def fetch_json(params): """带重试的单次请求""" headers = build_headers() for attempt in range(3): try: resp = requests.post( BASE_URL, data=params, headers=headers, timeout=15, ) if resp.status_code == 200: return resp.json() if resp.status_code in (403, 429): time.sleep(2 + attempt * 2) continue except requests.RequestException: time.sleep(1 + attempt) return None def save_row(db_path, row): """写入单条数据,使用 INSERT OR IGNORE 去重""" conn = sqlite3.connect(db_path) try: conn.execute( """ INSERT OR IGNORE INTO program_info (school_code, school_name, major_code, major_name, study_mode, plan_count) VALUES (?, ?, ?, ?, ?, ?) """, (row["school_code"], row["school_name"], row["major_code"], row["major_name"], row["study_mode"], row["plan_count"]), ) conn.commit() finally: conn.close() def worker(task): """进程池中的单个工作单元""" params, db_path = task["params"], task["db_path"] data = fetch_json(params) if not data or data.get("code") != 0: return None rows = data.get("data", {}).get("list", []) saved_count = 0 for item in rows: row = { "school_code": item.get("school_code", ""), "school_name": item.get("school_name", ""), "major_code": item.get("major_code", ""), "major_name": item.get("major_name", ""), "study_mode": item.get("study_mode", ""), "plan_count": item.get("plan_count", ""), } save_row(db_path, row) saved_count += 1 # 随机延时,避免短时间集中请求 time.sleep(random.uniform(0.3, 1.0)) return saved_count def build_tasks(): """根据查询条件构造任务列表""" tasks = [] major_codes = ["0101", "0201", "0301", "0401", "0501", "0601", "0701", "0801"] provinces = ["11", "12", "13", "14", "15"] for major_code in major_codes: for province in provinces: params = { "ssdm": province, # 省市代码 "yjxkdm": major_code, # 专业代码 "pageno": "1", "pagesize": "50", } tasks.append({"params": params, "db_path": "program_info.db"}) return tasks if __name__ == "__main__": # Windows 下必须放在入口保护中,否则递归创建子进程 pool_size = min(8, os.cpu_count() or 4) tasks = build_tasks() # 初始化数据库表 conn = sqlite3.connect("program_info.db") conn.execute( """ CREATE TABLE IF NOT EXISTS program_info ( school_code TEXT, school_name TEXT, major_code TEXT, major_name TEXT, study_mode TEXT, plan_count TEXT, UNIQUE(school_code, major_code, study_mode) ) """ ) conn.commit() conn.close() with Pool(processes=pool_size) as pool: results = pool.imap_unordered(worker, tasks) for result in results: print("saved:", result)这段代码里有一个容易被忽略的细节:字段统一用字符串存储。招生计划人数这种字段,不同学校有的填数字,有的填“不限制”,有的填“详见招生简章”,强行转 int 必然报错。统一存字符串,后续分析时再按规则清洗,是最稳妥的做法。
3.3 数据解析与落库细节
抓到的数据不是拿来就能用的,解析阶段有几个常见的脏数据问题。
第一个是空值。某些院校专业目录里,研究方向、指导教师字段可能为空,解析时要用.get("field", "")兜底,避免 KeyError 导致整个 Worker 崩溃。
第二个是换行和空白字符。页面上看起来正常的文本,接口返回的原始数据往往带着\r\n和多余空格。入库前统一做一次清洗:re.sub(r"\s+", " ", text).strip()。别小看这一步,后续做筛选统计时,脏数据会直接导致分组错乱。
第三个是重复字段。同一所学校的同一个专业,可能因为学习方式不同(全日制、非全日制)分成两条记录。去重时不能只按学校代码或专业代码,必须联合多个字段。我在表设计里用的唯一键是(school_code, major_code, study_mode),覆盖了主要重复场景。
数据库选择上,我用的是 SQLite,单文件、零运维、Python 内置支持,适合这个体量。如果数据量超过十万条且需要多端共享,再考虑迁移到 MySQL。SQLite 写入频繁时要注意事务批量提交,代码里每行单独 commit 在数据量大时会变慢,可以改成每 50 行 commit 一次。
3.4 限频、重试与断点续爬
多进程爬虫最大的敌人不是代码逻辑混乱,而是短时间内把目标服务器打得太猛。我的经验是:进程池开到 8,每完成一个任务随机睡 0.3 到 1 秒,这个节奏在速度和友好度之间比较平衡。如果你的目标数据量更大,优先增加任务列表的批量数,而不是盲目提高并发数。
重试逻辑我写在fetch_json里:每个任务最多重试 3 次,遇到 403 或 429 时等待时间递增,指数退避;遇到网络超时也做重试。这里踩过一个坑:requests 默认的 timeout 行为不是拦截所有等待场景,connect_timeout和read_timeout都可能因为网络抖动无限拉长,所以必须把timeout=(5, 15)或直接timeout=15写在每次请求里。没有超时的请求在真实环境里就是定时炸弹。
断点续爬是另一个不能省略的环节。抓取脚本经常因为网络波动、电脑休眠、进程崩溃而中断。如果每次重跑都全量重复请求,既浪费时间又增加目标服务器压力。我的做法是:启动时先从本地数据库里读一遍已有记录,把school_code + major_code + study_mode组成的标识集合加载到内存里,构造任务时直接跳过已抓取过的组合,实现差集抓取。
简单说,就是“抓过的就不再抓”。这个设计在第一次全量抓取后增量更新的场景里尤其有用。
4. 常见问题与排查技巧实录
4.1 问题速查表
做多进程抓取过程中,我整理过一份高频问题排查表,基本上照着查就能解决大部分异常。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 爬到一半全部超时 | 请求频率过高,被服务端临时限流 | 降低进程池大小,加大随机延时,等待几分钟再重跑 |
| 返回数据是登录页或验证码页 | 请求头缺 Cookie 或 UA 不完整 | 补齐请求头,确认是否真的需要登录态 |
| 数据大量重复 | 没有维护唯一键,断点续爬失效 | 建联合唯一键,启动时先做差集去重 |
| 某个进程卡死不退出 | 请求阻塞在 TCP 层,没设置超时 | 给 requests 加 timeout,并适当缩小并发数 |
| 本地 CPU 打满但耗时没下降 | 解析和落库逻辑太重,进程间争抢资源 | 把解析和写库拆开,用批量事务减少锁等待 |
| 断点重启后重复抓取 | 不知道哪些数据已入库 | 启动时 SELECT 已抓取集合,构造待抓差集 |
这张表里最后一条是我在实际项目中踩得最深的坑。第一版脚本没有断点续爬功能,某次凌晨跑到一半断了,第二天重新跑,不仅重新抓了全部数据,还把同一批数据重复写了好几遍,后续清洗时浪费了大量时间。
4.2 几个值得展开的坑
第一个坑是Windows 下的多进程启动问题。如果你在 Windows 上运行这段代码,没有if __name__ == "__main__":保护,程序会因为递归创建子进程而直接崩溃。这是因为 Windows 没有 fork,每创建一个子进程都要重新导入主模块。这个问题在 Linux 上不明显,但团队协作时换台 Windows 电脑跑,立刻现形。
第二个坑是在进程池内部维护 Session。requests.Session 复用连接池能显著提升效率,但 multiprocessing 的子进程是独立内存空间,在子进程里初始化的 Session 不能在父进程里复用。我吃过一次亏:把 Session 放在父进程,传给子进程用,结果父进程里创建的连接对象在 fork 之后处于不可用状态,实弹请求直接抛错。正确做法是在子进程内部创建 Session,每个 Worker 自己持有。
第三个坑是日志分散导致无法定位任务进度。多进程下每个子进程打印的日志是交叉混在一起的,print 输出量一大,根本看不出哪个任务失败了。我后来统一用一个日志队列:multiprocessing.Queue把日志事件送回主进程,由主进程统一打印到文件。这样既能定位失败任务,又不会被交叉输出刷屏。
第四个坑是输出结果乱序造成“假死”错觉。用imap_unordered拿到结果是乱序的,打印 saved 数字会跳来跳去,比如先输出 50 再输出 0 再输出 40。第一次跑的时候我一度以为逻辑错了,其实只是执行顺序不同而已。理解进程池的调度机制后就知道这是正常现象。
5. 优化路线与经验沉淀
5.1 从多进程到分布式
很多人跑通多进程之后会马上问:能不能更猛一点?多机分布式、消息队列、任务调度框架都听过,是不是直接上一个更大的架构?
我的建议是:先别急着上分布式。数据量在几十万条以内,多进程完全够用;把抓取、解析、落库这条链路优化好了,性能瓶颈往往不在并发数,而在数据结构设计和请求策略上。我见过有人用小数据集硬套 Celery + Redis,最终维护成本远大于收益。
什么时候才需要迁移到分布式?数据源分散、单机 IP 被限速、任务量达到百万级、需要定时周期性更新,这些条件下再考虑把任务队列外置,让多台机器多进程协同。迁移时可以保留现有的核心抓取逻辑,只把参数列表放入消息队列,Worker 从队列取任务,结果写入共享数据库。这样迁移成本最低,原有效率也能完整保留。
5.2 文明抓取与合规意识
这里有必要展开聊几句。做数据采集的人最容易忽略的一点是:能用不代表可以随便用。抓取目标网站的公开数据本身没有问题,但要注意几个原则:
- 只抓公开数据,不碰任何需要登录后才能看到的非公开信息。
- 控制请求频率,不给目标站点造成压力。
- 避开站点高峰时段,比如报考季的晚上 8 点到 10 点。
- 抓到的数据只用于个人研究或正当用途,不传播原始数据、不贩卖数据。
- 尊重网站的访问条款,如果站点明确声明禁止批量抓取,就主动降低抓取量或停止。
这套规则不是社会学要求,而是实际操作中的保命原则。频率控制做得好,脚本能稳定跑几天不中断;做得不好,最快可能 10 分钟内就被封禁,后续只能换策略重启。我建议任何抓取项目都默认遵守这些约束,这不只是为了合规,更是让脚本长期可用的技术手段。
5.3 批量操作与数据结构设计的经验
数据处理流程中,最容易踩的一个隐性坑是“先抓后想”。很多初学者习惯先把数据抓到本地文件里,再考虑怎么清洗入库,结果数据格式乱七八糟,后面分析时要花几倍时间返工。我的建议是:抓取前先设计好目标表结构,字段类型统一,能字符串就字符串,能省判断就省判断。
举个例子,招生计划字段的取值可能是“5”、“10+”、“不限”、“若干”、“详见简章”。如果表结构定义成整数类型,入库时直接报错;定义成字符串类型,则可无损保留,后续分析时再用规则清洗。这种设计上的小选择,直接影响整个项目后半段的工作量。
另外一个经验是批量事务提交。SQLite 在默认情况下每执行一条 INSERT 都会触发一次磁盘写入,数据量大时写入时间占比会明显上升。我在项目里改成每 50 条或 100 条统一 commit 一次,写入速度快了将近三分之一。代码上只需把save_row封装成批量版本,把所有 Worker 返回的行先攒在内存列表里,达到阈值再统一落库。
最后一条关于断点续爬的经验值得一提。数据爬取类的项目,几乎都会经历“第一次全量、后续增量”的过程。全量阶段可以允许慢一点,但增量阶段一定要把“待抓取集合差集”的逻辑做好。我在实现时直接读数据库里的唯一键,和任务列表做差集,这样脚本无论重启多少次,都不会做无用功,也不会对目标服务器造成多余压力。
从单进程跑到多进程,从手动管理进程到进程池调度,从单纯抓取到断点续爬、批量落库,整个过程里花的最大力气其实不在代码本身,而在“理解目标站点规律”和“设计方案取舍”上。技术选型总有热门和冷门之分,但适合自己的场景、稳定可维护的方案才是好方案。如果你手头也有类似规模的公开数据整理需求,不妨直接用这套思路先跑一遍,代码模型可以照搬,参数按你的实际站点调整即可。