1. 为什么“项目书爬数据”值得单独写一篇
先把这个场景讲清楚。项目书这个事情,在招投标、科研申报、投资尽调、政府补贴申请这些领域里太常见了。很多人手里积压了一堆PDF、Word、网页端公示的项目申报书、中标通知书、立项名单、结题报告,格式五花八门,来源七零八落。真要用的时候——比如做行业分析、竞争对手监控、政策复盘、数据报表——全靠人工一份份打开、复制、粘贴,几十份还能忍,几百份就是灾难。
这个需求跟普通爬虫有个明显区别:不是“爬一个网站”,而是“围绕项目清单批量抓取结构化信息”。它往往涉及多个站点、多种文件格式、混合页面结构,而且数据字段高度定制——项目名称、申报单位、金额、时间、负责人、区域、行业分类,每个字段都对应你的业务口径。通用的爬虫教程很少会把这些讲透,网上能搜到的大多是“怎么爬Boss直聘”或者“怎么爬美团商家”,用到项目书这种场景总觉得差半口气。
我最早接触这个需求是做区域产业政策梳理。领导丢给我一个名单,说“这里面五百家企业,每家近三年的省市级项目都查出来”,我当时如果手动去点,估计一个月就没了。后来把流程拆成三块——清单整理、网页采集、字段抽取——每块用最简单的工具解决,最后整个链路跑下来不到半天。这篇文章就是把那套流程完整拆开,把我踩过的坑、试过的方案、最后稳定的配置全部写清楚,给正在做同类事情的人一条能直接走的路。
文章会从工具选型讲起,然后是单页采集和字段抽取的完整实操,再到并发提速、反爬应对,最后是常见的异常场景和排查思路。整体偏工程落地,中间每一段核心代码都可以直接搬走改改用。
2. 动手前,先把思路和工具链理清楚
2.1 拿到项目书数据前要想清楚的五件事
很多人一上来就写代码,结果写到一半发现数据源不对、字段要重来、网站结构变了。我个人的习惯是,任何事情开始前先花二十分钟回答五个问题:
第一个,数据从哪来。项目书的来源通常有三类:公开的政府项目公示页面、招标采购平台的项目公告、行业侧的信息聚合站点。每一类的页面结构差异非常大,公示页一般是表格或列表,公告页可能是长文章,聚合站点则常常有列表页加详情页两层结构。明确来源后,才能判断要写几套解析逻辑。
第二个,要抓到什么粒度。同样一份项目书数据,有的人只需要项目名称和金额,有的人还要申报时间、所属领域、承担单位地址、负责人联系方式。这个粒度直接决定了解析字段的数量和难度,也决定了后续清洗的工作量。我的建议是宁可一开始多定两个字段,也不要后面返工再爬一次。
第三个,更新频率是多少。是只做一次性打包抓取,还是每周定时更新?如果是一次性的,脚本写直白点,跑完就算;如果要持续监控某个网站的政策更新,就得加上增量抓取、去重、状态管理,复杂度和前者完全不是一个量级。
第四个,规模和频率约束。目标是一百家单位的五百条项目,还是全行业几万条明细?目标越小,越可以用串行加礼貌延时,尽量不给目标网站压力;目标越大,越要在流程设计上考虑并发和容错。
第五个,数据保存成什么格式。大多数业务场景Excel就够了,但如果你后续要做分析或者对接BI,CSV、SQLite、数据库都可能成为选项。我自己的习惯是统一落CSV作为中间态,方便预览和检查,最后再转成业务需要的格式。
这五个问题不需要全部精确回答,但必须在动手前有大致答案。否则很容易出现“爬了两天发现字段太粗或太细”的尴尬境地。
2.2 最小可用工具组合:Anaconda加requests加pandas
工具选型这件事,我见过太多人一上来就上Scrapy、上Selenium,东西是能跑,但调试周期长、依赖复杂,最后改起来也麻烦。对于“项目书爬数据”这个场景,我的建议是直接用最小可用组合:Anaconda管理Python环境,requests负责拿页面,BeautifulSoup或者lxml负责解析HTML,pandas负责整理数据和输出。
为什么要Anaconda?因为爬虫任务往往涉及解析库、HTTP库、数据处理库的混合安装,Anaconda把Python环境、包管理、常用科学计算库一次性装好,省去了很多环境层面的折腾。尤其是新手,用Anaconda开一个独立环境,就算装坏了重来也就几分钟的事。
用Anaconda建环境的命令很简单:
conda create -n spider python=3.10 -y conda activate spider接下来把需要的库装上:
pip install requests beautifulsoup4 lxml pandasrequests没什么好说的,Python生态里最成熟的HTTP客户端。BeautifulSoup4加lxml做解析,前者写起来直观、适合快速开发,后者解析速度快、适合跑大批量。pandas则是数据清洗和导出的利器,DataFrame一行就能生成CSV。
为什么不推荐直接上Scrapy?原因有两个:一是项目书类任务通常要适配多个不同站点,Scrapy的Spider抽象在跨站场景下有优势,但配置成本和学习成本都高;二是如果需求是“简单直接拿一批数据”,Scrapy的异步框架和中间件机制属于过度设计。等哪天真遇到了需要大规模、多层级、长时间跑的项目,再迁移到Scrapy也不迟。
这个组合的好处是:每块都能单独替换,出错时能快速定位到具体模块。比如requests被反爬拦截了,可以直接换成curl或者httpx,不影响后面的解析和导出逻辑。
2.3 值得关注的几个“爬数据”姿势和误区
网上关于爬数据的热搜词,比如“boss直聘python数据爬取”“如何使用anaconda爬取网络数据”“如何py爬取美团数据”,其实方向都很正,但有几个坑大家容易踩。
第一个误区是把别人网站的焦点都放在“反爬”上。实际上,很多项目书类网站根本没有严格的反爬,它们真正难搞的是页面结构不统一、历史数据年份久远导致链接失效、公告附件是扫描版PDF这些“低级问题”。反爬策略不是不重要,但把百分之八十的精力放在应对反爬上是典型的资源错置。
第二个误区是看到网页就用Selenium模拟浏览器。Selenium适合的是页面动态渲染、内容由JavaScript异步加载的场景。但如果你目标站的表格内容是直接渲染在HTML里的,用requests拿下来解析就行,性能快好几个量级。判断标准很简单:浏览器里右键“查看网页源代码”,如果数据直接在里面,就用requests;如果源代码里看不到数据、只有一堆JS脚本,再考虑Selenium或者直接找接口。
第三个误区是不做数据校验。爬下来的数据里经常出现空值、乱码、重复项,直接进Excel往往要到很晚才发现问题。养成爬完就做校验的习惯,能省下很多返工时间。
3. 工具链装配完成后:核心实操与完整流程
3.1 第一步,从单页请求开始:构建稳定请求头
很多爬虫教程忽视了请求头,但对于项目书类网站,这是能不能拿到数据的分水岭。目标网站一般会检查User-Agent和Referer,如果裸奔访问,轻则返回错误页,重则IP被临时封禁。
一个平衡了隐蔽性和稳定性的请求头配置长这样:
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive", }这里有几个细节值得注意。User-Agent尽量保持和主流浏览器一致,不要用那种很长很奇怪的UA,容易被识别为脚本。Accept-Language指定成中文,避免部分网站在中英文之间做重定向。Connection用keep-alive,在批量请求时能复用TCP连接,速度会明显提升。
请求头写好后,先不要写解析逻辑,单独跑一次请求测试,看返回的HTML是否包含目标数据。我一般会打印状态码和页面前几百个字符,用这两项判断是正常页面、登录跳转还是拦截页。这一步确认通过,才继续往下走。
url = "https://example.com/project/notice/list?page=1" resp = requests.get(url, headers=headers, timeout=10) print(resp.status_code) print(resp.text[:500])另外建议在请求函数里加上超时控制。很多列表页系统偶尔会慢,不加timeout的话,请求会一直挂在那里,批量跑的时候等于白白浪费线程。
3.2 第二步,解析列表页:拿全每一行的核心字段
列表页是所有项目书数据的入口,里面通常直接展示着项目名称、发布时间、状态等基础字段。用BeautifulSoup解析列表页的表格是老本行,但有几个常见的页面套路要提前识别。
第一种是HTML表格,标签规整,每个<tr>对应一条项目记录。这种直接用find_all定位<tr>,再逐行提取<td>就可以了,最简单。
第二种是列表加详情页结构,列表页只有标题和链接,详细字段藏在详情页里。这种情况必须先抓列表页拿到链接,然后每个链接再发一次请求。很多新手栽在这里,列表页解析完了就直接存数据,结果字段全是残缺的。
第三种是JavaScript渲染的列表,源码中没有列表数据,只有一长串JS逻辑。遇到这种页面,优先开F12看Network面板,在XHR请求里找到返回JSON数据的接口。通常这个接口可以直接用requests访问,返回的数据比HTML还好解析。
下面是一个典型的表格解析示例:
from bs4 import BeautifulSoup soup = BeautifulSoup(resp.text, "lxml") rows = soup.select("table tbody tr") for row in rows: cells = row.find_all("td") if len(cells) < 3: continue project_name = cells[0].get_text(strip=True) publish_date = cells[1].get_text(strip=True) link = cells[0].find("a")["href"] if cells[0].find("a") else "" print(project_name, publish_date, link)这里要注意,get_text()默认会把子标签的文本全部拼进来,加上strip=True可以去掉首尾空格和换行,避免后面清洗数据的时候被隐形字符坑到。if len(cells) < 3这个判断不是多余的,很多表格的底部有合并行、提示行,不加判断会把噪音数据混进来。
3.3 第三步,详情页采集:把完整信息抽成结构化字段
列表页拿到只是开头,大部分业务需求需要用详情页的完整字段。详情页的解析逻辑不复杂,难在每个页面结构可能略有不同——有的用表格展示字段,有的用“字段名:字段值”的方式平铺。
一个通用性较强的做法是:先按字段名定位,再取相邻节点。比如:
def parse_detail(text): soup = BeautifulSoup(text, "lxml") result = {} # 方式一:表格结构 for tr in soup.select("table tr"): tds = tr.find_all("td") if len(tds) >= 2: key = tds[0].get_text(strip=True) value = tds[1].get_text(strip=True) result[key] = value # 方式二:平铺结构 for item in soup.select(".info-item"): key = item.select_one(".label") value = item.select_one(".value") if key and value: result[key.get_text(strip=True)] = value.get_text(strip=True) return result解析出来的字段最好立刻统一键名。比如详情页里“申报单位”“申请单位”“承担单位”可能表达同一个概念,在输出前就要统一成unit。这一步叫字段归一化,直接决定最后的数据能不能用来横向对比。
还经常遇到金额字段,页面上可能是“500万元”“500万”“5,000,000元”三种写法,保险起见用正则提取数字部分再统一单位:
import re def parse_amount(text): if not text: return None match = re.search(r"([\d,,.]+)\s*万元", text) if match: return float(match.group(1).replace(",", "").replace(",", "")) match = re.search(r"([\d,,.]+)\s*元", text) if match: return float(match.group(1).replace(",", "").replace(",", "")) / 10000 return None这种细节如果不在解析阶段处理,后面Excel里就全是“500万元”和“5,000,000元”混着,做汇总统计的时候非常头疼。
3.4 第四步,把数据用pandas整理输出
当所有页面跑完,拿到一批字典列表记录后,用pandas一次性转成结构化表格:
import pandas as pd data = [] # data是每个详情页解析后得到的字段字典列表 df = pd.DataFrame(data) df = df.drop_duplicates() # 去重 df = df.fillna("") # 空值填充 df.to_csv("projects.csv", index=False, encoding="utf-8-sig")这里要特别提醒encoding="utf-8-sig"。直接用utf-8存CSV,Excel打开大概率乱码。utf-8-sig会在文件头加BOM,Excel对带BOM的UTF-8识别率最高,这个细节我当年栽过一次之后再也没有忘过。
去重的时候,建议先用关键字段组合判断重复。比如“项目名称+单位名称+发布时间”三个字段完全一致,几乎可以断定是同一条记录;如果只有项目名称一致,可能是不同年度的同名项目,不要误删。
字段导出前,我还习惯顺手打印一行df.info(),看看有没有字段变成全NaN,有没有类型不对。这一步检查花费两分钟,能帮你少说十句“怎么跑完数据不对”的烦恼。
3.5 第五步,用并发把速度提上去
单线程跑几十条需求还能忍,跑到几百条、上千条的时候,速度就是硬伤。项目书网站的响应时间通常在0.3到1秒之间,单线程抓一千条要十到十五分钟,并发后能压缩到两三分钟。但并发也会放大被封的风险,所以必须控制节奏。
最稳妥的做法是用ThreadPoolExecutor加自定义限速:
from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_detail(url): time.sleep(0.3) # 控制整体请求频率 resp = requests.get(url, headers=headers, timeout=10) return parse_detail(resp.text) urls = [...] # 需要抓取的详情页链接列表 results = [] with ThreadPoolExecutor(max_workers=5) as executor: future_map = {executor.submit(fetch_detail, url): url for url in urls} for future in as_completed(future_map): try: results.append(future.result()) except Exception as e: print(f"抓取失败: {future_map[future]},错误: {e}")max_workers=5是我个人比较常用的值,对大多数中小网站都算礼貌。如果目标站响应速度很快、且你确认没有严格的反爬,可以适当加大到8或10;如果遇到429或者503,立刻降回3,并且每次请求的sleep时间加到0.5秒以上。
这里想说一句经验之谈:快速失败和重试机制有时候比并发数更重要。一个稳定的爬虫,不应该是“尽量多抓”,而是“抓到了的部分保证不丢,没抓到的部分能记录重试”。所以在合适的位置加异常捕获和日志输出,能让整个任务的可维护性上一个台阶。
3.6 第六步,断点续跑与增量更新
跑了几百条之后遇到网络波动或者代码异常,是家常便饭。如果脚本设计成“从头跑起”,已经抓到的数据就白费了。所以从第一天起就要考虑断点续跑。
最简单的实现是:每成功解析一条记录,就把链接写入一个已完成的清单文件;下次启动时,先加载这个清单,跳过已经完成的链接。这样即使中断了,重复执行脚本就能从断点处继续。
done_urls = set() if os.path.exists("done.txt"): with open("done.txt", "r") as f: done_urls = set(f.read().splitlines()) for url in urls: if url in done_urls: continue # 抓取、解析、保存 ... with open("done.txt", "a") as f: f.write(url + "\n")增量更新的思路也一样,只不过“已完成的链接”变成“已经入库的项目的唯一标识”。每次跑之前查一下库,把新的项目链接追加到任务列表里。这个办法虽然土,但在没有引入数据库和调度框架的情况下,已经足够管理大多数项目书采集任务。
4. 常见问题与坑位排查实录
4.1 案例一:页面返回200,但解析出来是空数据
这个问题排在所有爬虫问题的最前面。你的请求明明成功了,状态码200也拿到了,但用BeautifulSoup一找,目标表格是空的。排查步骤其实有规律可循。
第一看页面是否加载在iframe里。很多项目公示网站使用iframe嵌入内容,外层HTML里只有一个<iframe src="...">标签,数据在子页面里。遇到这种,先用resp.text搜一下iframe关键字,拿到子页面的地址后,再发一次请求获取真正的内容页。
第二看页面是不是用了JS动态渲染。判断方法前面说过,打开网页源码看目标数据在不在里面。如果源码里找不到数据,说明是异步接口,去Network面板找XHR请求。有些接口返回的是JSON,解析起来比HTML简单;有些返回的是JS代码片段,需要用正则提取。
第三看是不是登录后才可见。这类情况在招投标网站特别多,列表页可以匿名访问,详情页却必须登录。遇到这种,最常见的方法是携带Cookie访问。先用浏览器登录一次,在DevTools里复制Cookie字符串,放到请求头里。注意Cookie有有效期,过期了就需要重新获取。
4.2 案例二:数据量一大,耗时指数级上升
一百条数据跑得挺快,一千条就开始像蜗牛。这时候先检查是不是每个请求都重新建立了TCP连接。如果没有用Session,requests默认会在每个请求上新建连接,耗时和资源开销都比较可惜。
用Session统一管理:
session = requests.Session() session.headers.update(headers) def fetch(url): resp = session.get(url, timeout=10) return resp.text同一个Session会复用底层的TCP连接,速度提升非常明显。再配合连接池参数:
session = requests.Session() adapter = requests.adapters.HTTPAdapter(pool_connections=10, pool_maxsize=10) session.mount("http://", adapter) session.mount("https://", adapter)这两个配置改完之后,一千条数据的耗时经常能缩短三分之一到一半。当然,并发也能省时间,但并发要谨慎,Session复用是无风险优化。
4.3 案例三:被目标网站拦了IP或封了账号
小规模抓取一般不会遇到封禁,但如果你目标网站比较敏感,或者你的请求频率确实高,就可能遇到403、429、验证码这些情况。应对思路从轻到重排列:
第一步,降低频率。把请求之间的sleep调大,比如从0.3秒调到1秒,多数情况下问题就解决了。很多“被封”其实是请求频率超过了对方服务器的合理阈值。
第二步,设置重试。临时性的429或503,等几秒再重试往往就过了。注意不要无限重试,一般三次就够了。
第三步,更换请求头。除了User-Agent,还可以补充一些浏览器常用的Header,比如Accept-Encoding、Sec-Fetch-Site、Sec-Fetch-Mode。虽然部分网站在真实性校验上做得不严格,但多几项总比裸奔强。
第四步,检查是不是走了登录态。有些站点的反爬策略是“未登录用户请求频率超过N次就封IP”,但登录后频率阈值会高很多。这种情况,挂上Cookie往往比换代理更有效。
我自己遇到最麻烦的一次,是对方设置了滑动验证码,而项目数据又必须登录后才能拿到全字段。最后我的方案是把这个网站的采集频率降到“每分钟不超过10次”,用几个不同账号轮流登录,加上每次请求之间随机延时3到5秒,跑了整整一天,慢是慢了点,但数据完整拿下来了。我的体会是:不要太相信工具的“妖艳技巧”,合规和节奏才是发不出来最大的保障。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 状态码200但无数据 | iframe嵌套或JS动态加载 | 查找iframe子页面,或找XHR接口 |
| 返回403 | 请求头缺少关键字段 | 补充Referer、User-Agent、Accept-Language |
| 返回429 | 请求频率过高 | 增大延时,降低并发数,加重试机制 |
| 中文乱码 | 编码识别错误 | 先resp.encoding = resp.apparent_encoding再取文本 |
| Excel打开乱码 | 编码不是utf-8-sig | to_csv时改encoding="utf-8-sig" |
| 字段混入换行符 | 未使用strip | 统一get_text(strip=True) |
| 金额格式不统一 | 页面单位不同 | 解析阶段用正则统一换算单位 |
| 数据重复 | 多次运行未去重 | 用关键字段组合做去重 |
| 链接在下一页 | 分页加载 | 解析下一页链接或page参数迭代 |
这个表是我实际排查中归纳出来的高发问题,几乎每个新项目都会撞上其中两三条。
4.5 两个容易被忽略的稳健性习惯
第一个习惯是把不合法的HTML修正好再解析。某些老旧的政务网站,HTML标签不够规范,BeautifulSoup对畸形标签的容忍度比lxml高,但lxml速度快。稳妥的搭配是BeautifulSoup加lxml解析器,遇到明显异常可以把HTML片段用html5lib再处理一次。代价是html5lib速度很慢,只在异常时用。
第二个习惯是给请求加上随机的User-Agent或延时。不要每次都一模一样,稍微加一点随机性,不仅减少被封概率,也让日志看起来更接近真人操作。比如:
import random time.sleep(random.uniform(0.5, 1.5))注意这里随机延时不是要你伪装成什么,只是模拟人的操作节奏。合规采集本来就是公开数据,节奏合理,大家相安无事。
5. 合规边界与工程化进阶思考
5.1 爬数据要守住的基本准则
讲完技术,必须聊几句合规。项目书数据属于公开信息,抓取公开数据本身没有问题,但有几个基本原则要守住:
第一,只抓公开可见的数据,不突破登录授权和验证码机制。网站要求登录才能看的信息,最好走正规接口或者人工确认,不要在技术上强行绕过。
第二,控制请求频率,不给目标网站造成压力。公开数据也是别人运营出来的,爬取的时候保持基本的礼貌,尽量模拟正常访问节奏。
第三,获取的数据不用于侵权、不正当竞争、泄露他人隐私等目的。项目书数据中有时会包含联系人、联系电话、地址,使用时要特别注意范围。
技术本身是中性的,但使用技术的人要有边界感。我见过一些人把爬虫写得很野气,一天抓几十万条,最后对方直接上了验证码,大家都没得玩。与其那样,不如一开始就温和一点。
5.2 从一次性脚本到半自动小工具
当脚本稳定后,可以往“小工具”的方向迭代。我的做法是增加一个参数配置文件,把目标URL、请求间隔、字段映射关系、输出路径都放到配置里,这样别人用或者自己换个站点时,只需要改配置,不用改代码。
import json with open("config.json", "r") as f: config = json.load(f) url = config["list_url"] interval = config["interval"] fields = config["fields"]这算半个工程化,但收益很明显。尤其是“字段映射”这个设计,把同一个网站上不同页面的字段名统一到一个标准上,后续清洗数据的成本会再次下降。
再往前走一步,就是用定时任务让脚本自动跑。Windows的任务计划程序或者Linux的crontab都能做,设定每月或者每周定点运行一次,输出文件按日期命名,基本就是一个可用的小型数据服务了。
5.3 如果数据量继续膨胀,怎么升级
如果项目书数据量涨到几万条、几十万条,CSV文件读写开始变慢,Excel直接打不开,那时候就要考虑升级到数据库。SQLite是最轻量的过渡方案,无需安装服务,一个文件就是一个库,pandas可以无缝读写,非常适合中期过渡。
import sqlite3 conn = sqlite3.connect("projects.db") df.to_sql("projects", conn, if_exists="replace", index=False)再往后,数据量更大、需要多人协作或者需要对外提供查询接口时,再引入MySQL或PostgreSQL也不迟。原则是不为将来的规模过度设计,每一步都用当下够用的方案。
6. 关于“工作量和工具选择”的一些个人建议
最后再分享一点实操中的体会。
项目书爬数据这件事,真正的工作量大头不是“写爬虫”,而是“清洗数据”和“字段对齐”。代码可能三个小时就写完了,但不同来源的项目数据格式差异非常大:有的叫“项目名称”,有的叫“课题名称”;有的金额写在标题里,有的金额在附件里;有的日期格式是“2024-03-01”,有的是“2024.3.1”。这些数据不统一,拿到业务手里就是一堆废数据。
所以我建议所有做同类事情的人,在写代码之前先问一句:拿到数据之后,谁来用,用来做什么,字段口径是什么。把这个问题想清楚,再动笔写代码,效率会高很多。
工具方面,再强调一点:requests加pandas这套组合适合绝大多数情况,但如果你要抓的页面大量依赖JavaScript渲染,或者目标网站有严格的登录和会话管理,不用硬撑着用requests,Selenium或者Playwright都是更合理的选择。工具没有高下,只有合适与否。
按这套流程,我帮不同行业的朋友处理过招投标文件清单、科研项目立项数据、政府补贴公示记录,基本上从拿到需求到交付数据,小规模一两天,大规模一周内都能搞定。希望这篇文章能让你少走弯路,把时间花在真正有价值的数据分析上。