资讯动态

Python多进程爬虫实战:抓取研招网招生目录的全流程解析

发布时间:2026/10/21 10:22:40 来源:尧图企业网站定制
做数据整理和批量查询的时候基于多进程爬虫去抓研招网上的公开信息基本是这个圈子里绕不开的入门实战项目。招生专业目录、专业学位点、院系代码这类公开数据一页页复制粘帖会让人崩溃手动查又太低效写脚本去抓是最合理的选择。这篇文章就把我用多进程方案重写这套爬虫的全过程记录一遍从目标站点规律分析、进程池设计到请求头伪装、断点续爬再到实际踩过的坑最后整理成一份可以直接抄改的代码和排查清单。适合已经会用 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;q0.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, dataparams, headersheaders, timeout15, ) 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(processespool_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)或直接timeout15写在每次请求里。没有超时的请求在真实环境里就是定时炸弹。断点续爬是另一个不能省略的环节。抓取脚本经常因为网络波动、电脑休眠、进程崩溃而中断。如果每次重跑都全量重复请求既浪费时间又增加目标服务器压力。我的做法是启动时先从本地数据库里读一遍已有记录把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 返回的行先攒在内存列表里达到阈值再统一落库。最后一条关于断点续爬的经验值得一提。数据爬取类的项目几乎都会经历“第一次全量、后续增量”的过程。全量阶段可以允许慢一点但增量阶段一定要把“待抓取集合差集”的逻辑做好。我在实现时直接读数据库里的唯一键和任务列表做差集这样脚本无论重启多少次都不会做无用功也不会对目标服务器造成多余压力。从单进程跑到多进程从手动管理进程到进程池调度从单纯抓取到断点续爬、批量落库整个过程里花的最大力气其实不在代码本身而在“理解目标站点规律”和“设计方案取舍”上。技术选型总有热门和冷门之分但适合自己的场景、稳定可维护的方案才是好方案。如果你手头也有类似规模的公开数据整理需求不妨直接用这套思路先跑一遍代码模型可以照搬参数按你的实际站点调整即可。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑