十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫实战:requests+BeautifulSoup采集基金会公开项目数据全流程

Python爬虫实战:requests+BeautifulSoup采集基金会公开项目数据全流程 做数据采集这行最怕的不是目标网站结构有多复杂而是你根本不知道自己到底要采什么、采回来能干什么。最近我完成了一轮基金会公开项目数据的深度采集整个过程没有上重型框架就是Python爬虫里最常用的requests加BeautifulSoup再配合一些调度和清洗技巧把过去几年这些机构对外公示的项目清单、金额、周期、区域、受益群体等核心字段全部结构化落地。这篇文章就是这次实践的完整复盘。为什么值得写出来因为这类需求其实非常典型研究公益行业资金流向的分析师、想做资助项目库的团队、甚至是只学过Python基础但想知道“爬虫在真实项目里到底怎么落地”的开发者都会遇到一模一样的场景——目标网站是公开的数据是零散分布在列表页和详情页里的你需要自己设计一套采集方案把它变成一张干净的表。所以这篇内容不会只扔代码我会把选型逻辑、解析细节、反爬节奏、排错过程全部讲透保证你看完能直接照做而且能避开我踩过的坑。1. 项目背景与采集目标拆解1.1 为什么需要采集基金会公开项目数据基金会的公开项目数据指的是各基金会在官方网站、信息公示栏目或者公开数据平台中主动向社会公开的信息通常包括项目名称、项目简介、资助金额、受益对象、实施区域、项目周期、项目状态、执行机构等。这些信息的共同特点是数量不小、分散在不同页面、格式不完全统一但每一行都有分析价值。比如你想研究某个公益领域近三年的资金分布或者想给资助方做一个“同类项目都在做什么”的摸底报告靠人工一页页复制粘贴是根本不现实的。我这次的目标也很直接把选定范围内基金会官网的公开项目列表抓下来进入每个项目的详情页补全字段最后汇总成一张结构化的数据表。这个需求在业内其实很常见只不过很多人一上来就想着“搞个大而全的爬虫系统”结果光环境配置和框架选型就折腾了好几天反而忽略了最核心的数据解析。我的经验是先用手工方式打开几个页面搞明白信息在哪、长什么样再写代码去模拟这个浏览过程效率会高得多。1.2 采集范围与字段定义开工之前先定义清楚“采什么”非常关键。我这次划定的范围是公开项目公示页中可公开访问、不涉及隐私和个人敏感信息的数据。字段层面我设计了一个主表所有内容围绕项目ID进行关联字段名称字段类型说明示例project_idstring项目唯一标识P20230001project_namestring项目名称乡村儿童阅读支持计划fieldstring项目所属领域教育amountfloat资助金额万元120.5regionstring实施区域云南省beneficiarystring受益对象乡村小学生durationstring项目周期2023.01 - 2023.12statusstring项目状态已结项summarystring项目简介为……提供阅读资源source_urlstring详情页URLexample.org/project/123updated_atstring抓取时间2024-06-01看到这个表你就明白这不是一个“随便抓抓”的活而是要从半结构化页面里提炼出相对规整的业务字段。字段设计早一点想清楚后续的数据清洗会省掉大量重复劳动。我建议你也按这个思路在项目开始前先拿Excel手工录入5到10条真实数据把字段名、格式、取值统一好这比写到一半再回头改字段要靠谱得多。1.3 项目目标与非目标这个项目我只定了三条目标第一能自动遍历列表页拿到所有项目的详情链接第二能进入每个详情页抓取核心字段并清洗入库第三支持增量更新不是一次性用完就扔。非目标也很明确不做全互联网的泛采集不做需要登录或涉及非公开信息的抓取不追求每秒几十个请求的高并发。把非目标提前列出来能防止项目做着做着就跑偏。2. 技术选型与运行环境准备2.1 为什么用 requests 而不是更容易“上头”的 Scrapy很多看了爬虫教程的朋友上来就想用Scrapy或者分布式爬虫框架但以这类基金会官网的数据量来说通常就是几千到几万条项目记录单机、串行、加上适量延时完全够用了。Scrapy的功能确实强大但它的学习曲线和调试成本并不低尤其是项目里还涉及各种动态字段、异常重试、页面结构临时变化用轻量方案反而更灵活。我个人的建议是数据量在十万条以下、目标站点不超过十个、字段以文本和数字为主直接用 requests BeautifulSoup 就非常舒服。requests负责发HTTP请求BeautifulSoup负责解析HTML两个库加一起不超过一百行核心代码就能把整个列表页和详情页的逻辑跑通。分布式爬虫和消息队列这些留到真正遇到“单机采集时间不可接受”或者“目标站点分散并且数量庞大”的情况再上不要为了技术炫技而过度设计。2.2 环境准备与依赖安装这次实践我使用Python 3.8以上的版本建议你直接装3.10或3.11语法兼容性和第三方库支持都更省心。如果电脑上还没有Python环境建议先去python.org下载对应平台的安装包安装时记得勾选“Add Python to PATH”这是新手最容易漏掉的一步漏了会导致命令行里敲python毫无反应。依赖方面只需要四个核心库执行这条命令就能装齐pip install requests beautifulsoup4 lxml pandas openpyxlrequests负责网络请求beautifulsoup4负责HTML解析lxml用C语言实现的解析器比默认的html.parser快不少pandas 和 openpyxl最后数据清洗和导出Excel用。如果你是在Linux服务器上操作可能还需要处理pip权限问题最简单的做法是加--user参数或者用一个虚拟环境。我习惯用python -m venv venv创建虚拟环境然后source venv/bin/activate激活这样不会把依赖装乱。Windows下激活命令是venv\Scripts\activate注意PowerShell有时会拦脚本改成CMD运行就没问题。2.3 合规与频率控制的基线这节我必须多说几句因为爬虫翻车绝大多数不是技术问题而是节奏问题。我给自己定的规矩有这几条第一先看目标的robots.txt路径是https://目标域名/robots.txt这里会明确告诉你哪些路径不允许抓取第二请求头里带上真实的User-Agent表明请求来源第三两个请求之间至少间隔2到5秒随机延时绝不做突发式并发第四抓下来的数据只用于个人研究或内部整理不做二次转售或公开传播。提示合规不是一句空话。很多基金会网站的公开信息本身就是为了方便公众查阅在合理频率下采集一般不会有问题但如果你把对方服务器压垮了那性质就完全变了。控制自己的请求节奏既是自我保护也是对这个行业的尊重。3. 页面结构分析与数据解析实现3.1 先手工拆解URL规律拿到一个目标网站我做的第一件事不是写代码而是打开浏览器手动把列表页、详情页都翻一遍然后在开发者工具里看URL结构。大多数基金会官网的项目展示栏目会遵循一个很典型的模式列表页https://example-foundation.org/projects?page1详情页https://example-foundation.org/project/123.html这里的规律是列表页用查询参数控制分页详情页用路径参数定位单条记录。找到这个规律后爬虫骨架就清楚了先从列表页提取所有详情页的URL再逐个请求详情页并解析字段。我建议你用浏览器“查看源代码”而不是直接看渲染后的页面来判断信息位置因为很多信息虽然能通过JavaScript动态加载但初始HTML里其实已经有完整数据。如果底层的HTML结构是接口动态返回的那就要去Network面板里找XHR请求看看是不是有个JSON接口在提供数据——那种情况反而更好解析。3.2 列表页信息的提取列表页上通常能看到项目名称、所属领域、发布时间和详情链接这些信息往往被包在类似div classproject-list的容器里。用BeautifulSoup做解析核心思路是先定位所有项目条目再对每个条目做字段提取import requests from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } def parse_list_page(page_url): resp requests.get(page_url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) items [] for card in soup.select(div.project-item): title_tag card.select_one(h2 a) if title_tag is None: continue detail_url title_tag.get(href) if detail_url.startswith(/): detail_url https://example-foundation.org detail_url items.append({ project_name: title_tag.get_text(stripTrue), detail_url: detail_url, field: card.select_one(.category).get_text(stripTrue) if card.select_one(.category) else , }) return items这段代码里有两个容易被新手忽略的点。第一是resp.encoding resp.apparent_encoding很多网站没有在响应头里声明编码或者声明得和实际内容不一致用apparent_encoding能根据页面字节内容推断真实编码避免中文乱码。第二是detail_url的拼接判断页面里的链接经常是相对路径必须以域名拼接成绝对URL否则后续请求会直接失败。提取完列表页数据后可以做一层打印或写入临时文件先确认拿到的详情链接数量是否正确。我通常会先跑第一页然后手工抽查一两条链接能不能在浏览器里打开确认没问题再进入批量阶段。3.3 详情页核心字段解析详情页是字段最丰富的地方一个典型的详情页会把项目的资助金额、实施区域、受益对象、项目周期、状态都以“标签值”的形式列在页面中部。这种情况下不要依赖页面里某个绝对位置而是要根据标签文本去定位对应的值这样即使页面上多了个推荐阅读模块也不会干扰解析逻辑。def parse_detail_page(detail_url, project_id): resp requests.get(detail_url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) field_map {} # 假设详情页用 dl/dt/dd 结构展示字段 for dt in soup.select(dl dt): label dt.get_text(stripTrue) dd dt.find_next_sibling(dd) value dd.get_text(stripTrue) if dd else field_map[label] value summary_node soup.select_one(.project-intro) return { project_id: project_id, amount: parse_amount(field_map.get(资助金额, )), region: field_map.get(实施区域, ), beneficiary: field_map.get(受益对象, ), duration: field_map.get(项目周期, ), status: field_map.get(项目状态, ), summary: summary_node.get_text(stripTrue) if summary_node else , source_url: detail_url, }很多字段并不是每次都能取到所以我在代码里统一做了缺省处理取不到就给空字符串等清洗阶段再做统一处理。这里的核心思路是“尽力而为”而不是“一次到位”尤其面对历史数据时早期项目详情页可能字段只有一两项程序不能因为缺字段就崩溃。这也是我把字段解析写成一个独立函数的价值所在——每换一个目标网站只需要改动这个函数就能适配。3.4 数据清洗与结构化输出原始解析结果基本是字符串比如“资助金额120.5万元”“项目周期2023.01-2023.12”这些内容虽然人眼看得懂但对数据分析来说并不合适。所以我在采集之后增加了一个清洗层专门做三件事把金额文本转成数值、把日期区间标准化、把区域和受益对象的空值填成“暂未公示”。import re def parse_amount(text): if not text: return None match re.search(r([\d.]), text) if match: return float(match.group(1)) return None金额字段我统一以“万元”为单位存储因为不同页面上可能出现“120.5万元”“1,205,000元”“120.5万”等不同写法直接以万元为单位再在清洗函数里做一次单位换算后续做统计时就不会出现单位不一致的问题。日期区间我用正则拆成年和月分别保存为开始日期字段和结束日期字段比原始字符串更适合做时间趋势分析。清洗完成后我用pandas把数据组装成DataFrame再导出为Excel文件。这个小流程看起来不起眼但它决定了下游分析和可视化能不能顺利进行。我建议每抓取完成一批数据就先做一次清洗和汇总不要等到全部抓完再处理因为那样一旦发现某个字段解析有误返工成本会非常高昂。4. 调度策略与反爬规避的实操细节4.1 请求头伪装与随机延时爬虫和正常浏览之间唯一的区别就是它把浏览行为自动化了。因此调度策略的核心原则就是让程序的行为尽量接近一个人工操作者的节奏。我这边使用了一个User-Agent池每次请求从池子里随机选一个避免固定UA被识别每次请求之间使用2到5秒的随机延时让访问间隔没有规律。import random import time USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) ... Firefox/121.0, ] def make_session(): session requests.Session() session.headers.update({User-Agent: random.choice(USER_AGENTS)}) return session def safe_request(session, url, retries2): for attempt in range(retries): try: resp session.get(url, timeout10) if resp.status_code 200: return resp except requests.RequestException: pass time.sleep(3 attempt * 3) return None用Session还有一个隐藏好处它能复用底层的TCP连接对于连续访问同一个域名的情况速度会比每次新建连接快不少同时对目标服务也更友好。延时方面我建议不要用固定值固定值在日志里会呈现非常规整的访问节奏反而容易触发风控随机范围在2到5秒之间数据量特别大的时候可以放宽到3到8秒。4.2 断点续采与URL去重批量采集最害怕的事情之一是跑到一半程序崩了然后你又得从头来一遍。所以我在代码里加了一个“已抓取URL集合”每次成功解析完一个详情页就把URL写进本地文件。下次启动时把这个文件加载进内存遇到已经抓过的URL就直接跳过。这个机制同时解决了两个问题一是断点续采二是避免重复请求消耗不必要的资源。import os def load_done_urls(pathdone_urls.txt): if not os.path.exists(path): return set() with open(path, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) def mark_done(url, pathdone_urls.txt): with open(path, a, encodingutf-8) as f: f.write(url \n)实际运行过程中我会先把列表页所有详情链接收集到一个待抓取队列文件里然后再启动详情页抓取这样即使程序中断我也知道还有哪些链接没处理。对这个项目来说队列文件用最简单的纯文本文件就够了每条URL一行没必要引入Redis或者消息队列。4.3 数据持久化与增量更新数据落地我用的是“每天一个独立文件”的方式文件名加上当天日期防止覆盖上一轮采集结果。每一轮抓取结束后我会把所有天文件合并成最新的总表同时保留历史文件方便回溯。增量更新的逻辑也很简单列表页越靠前的内容越新所以增量更新时只需要抓前几页然后和已有数据里的project_id做一次比对去重合并即可。import pandas as pd def merge_existing(pathfoundation_projects.xlsx, new_dfNone): if os.path.exists(path): old_df pd.read_excel(path) combined pd.concat([old_df, new_df], ignore_indexTrue) combined combined.drop_duplicates(subset[project_id], keeplast) else: combined new_df combined.to_excel(path, indexFalse)这里的去重键是project_id。我建议在采集阶段就保证project_id生成规则稳定比如从详情页URL里提取数字部分作为ID这样同一个项目无论被哪一轮抓取都能稳定对应到同一条记录。5. 常见问题与排查思路实录5.1 请求被拦截返回403或非200状态码最常见的错误场景之一就是爬到一半突然收到大量403状态码。我的排查步骤通常是这样先确认是不是目标站点已经检测到高频访问如果是就加大延时、轮换User-Agent如果仍然不行再把请求头补上Referer、Accept-Language等字段伪装得更像真实浏览器。还有一种情况是访问了robots.txt禁止的路径这种就要检查代码逻辑主动调整采集范围。如果目标站做了更严格的防护单靠requests就很难突破了。但以我这次采集的基金会网站来看它们本身是面向公众提供查询服务的只要把频率控制在人类操作范围内基本不会走到这一步。记住能用降低频率解决的问题就别急着上更复杂的工具。5.2 解析结果为空或字段丢失解析返回空结果十有八九是页面结构变了或者你的CSS选择器写得太严格。排查方法很简单把当前页面的HTML保存到本地文件然后用解析库一点点调试看选择器到底匹配到了什么。我吃过一次亏某天发现所有项目名称字段全为空最后定位到原因是运营在页面里加了一个“截止报名”的小标签把原来的h2 a结构挤到了另一个层级。注意调试解析逻辑时一定要用本地保存的HTML文件而不是每次都去请求线上环境。一方面速度快另一方面不会因为反复请求给目标站带来压力。这也是好爬虫和坏爬虫之间很明显的区别。5.3 中文乱码问题乱码问题的根源是编码判断错误。requests拿到响应后会先看响应头里的charset但很多老网站并不会正确声明。我习惯在每个请求后主动设置编码先用resp.encoding resp.apparent_encoding如果还乱就直接看页面源码里meta charset...标签手动指定。对中文字符来说最常见的两个编码是utf-8和gbk知道了目标站用哪种基本就不会再乱码。resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding text resp.text5.4 其他踩过的坑还有几个小坑值得记录。一是列表页数量和详情页数量不一致往往是列表页里包含了置顶项目或者重复项目解析时需要额外做一次URL去重。二是采集过程中出现超时异常requests默认的timeout有时候不够用尤其是详情页图片多、页面重的场景把timeout调到15秒左右更稳妥。三是大量数据写Excel时内存占用过高解决办法是每抓500条就写一次磁盘不要等全部抓完再一次性写入。还有一个容易被忽略的细节如果详情页的数据是通过JavaScript异步加载的直接解析HTML会什么都拿不到。这时候去浏览器的Network面板里找XHR请求看能不能直接命中接口。如果找到的是一个返回JSON的接口基本上就是一马平川了用resp.json()直接解析比解析HTML简单十倍。6. 项目运营经验与后续扩展方向6.1 从这次采集里沉淀出的几条经验做完这个项目我最大的体会是数据质量远比采集速度重要。很多爬虫教程喜欢强调并发多高、抓得多快但真实采集场景里你最终交付给业务方或自己分析的是一张可信的数据表而不是一堆抓下来的网页。所以我在每个环节都设置了校验点列表页解析完先看条数详情页解析完先看字段覆盖率清洗完先看金额和日期分布。任何一步发现异常就停下来人工对比页面而不是让程序不管三七二十一跑完。另一点是日志一定要留。我用最基本的logging库把每次启动、每完成一个页面、每发生一次异常都记录下来。这个习惯在数据量小的时候看不出价值但项目维护一个月后你会非常需要日志来定位“昨天怎么少抓了十个项目”这种问题。日志文件按天滚动保留最近三十天占用磁盘空间很小。6.2 后续可以继续做的方向这个项目的代码结构本身就留了扩展空间。如果你想把框架搭得更完整可以在现有基础上做几件事第一接入定时调度工具每天凌晨自动跑一次增量更新第二把输出从Excel升级成SQLite数据库查询和增量合并会更方便第三采集结束后对接大模型做自动摘要把项目简介压缩成几句可读性更强的文本方便后续生成行业月报。更远的扩展方向是把采集范围从单一基金会扩展到多个数据源这时候就可以考虑用轻量的任务队列来管理不同站点的采集任务但核心解析逻辑依然可以复用这一套。最后再分享一个小技巧别把爬虫代码写成一个几百行的脚本堆到底。我习惯把网络请求、页面解析、数据清洗、数据存储分别拆成独立函数这样每次换目标站只需要重写“页面解析”这一层其余逻辑可以原封不动地迁移。爬虫这个行当代码更新迭代特别快结构清晰一点能给未来的自己省下大量返工时间。
返回列表