算起来这应该是每个做数据分析、爬虫开发或者哪怕是写自动化脚本的人都会碰到的第一课。但它也是被误解最多的一门基础课——数据采集并不是简单地把网页源码下载下来,或者对着某个接口发几个请求。我在带实训小组的时候经常遇到这样的情况:大家装好了环境、敲得动代码,但面对"采什么数据、去哪采、以什么形式采、采完怎么处理"这几个基本问题还是两眼一抹黑。这篇就是想把数据采集的基础知识用一套完整的实训思路讲清楚,从数据类型、请求原理到解析方式、合规边界,最后用一个可运行的采集小项目把这些点串起来。内容适合刚接触数据采集的开发者,也适合已经能跑通简单脚本但想更系统地理解采集链路的同学。
1. 数据采集到底在采什么:先分清三种数据形态
很多教程一上来就教怎么写Python代码,结果学生复制下来能跑,换个网站就完全不会改。根子在于没有先建立一个认知:数据采集的第一步不是写代码,而是判断目标数据属于哪种形态。数据分析类项目里,90%的采集目标可以归为三类:HTML页面、结构化接口、以及流式数据。理解这三者的区别,你才知道该用什么工具去处理。
1.1 HTML页面数据:最直观,但也是最容易误解的
HTML页面数据指的是浏览器最终渲染出来的那一堆标签结构。比如你想采一个商品的价格信息,那么需求就是:从某个商品详情页的HTML里,找到标着"价格"的那个DOM节点,再把它里面的文本提取出来。这类数据的特点是非结构化的——信息藏在标签嵌套里,需要一层层定位。
举个例子,一个常见的商品页面结构大致是:
<div class="product-info"> <span class="title">某某型号液晶显示器</span> <span class="price">1299.00</span> <span class="sales">已售300件</span> </div>你需要做的就是用选择器定位到.price这个class,再取出1299.00。但这里有个大坑:网站在改版时经常更换class名,比如把.price换成.sale-price,你的代码就会立刻失效。所以采集HTML数据时一定要为每个字段写一个"定位策略"而不是只写死一个class名,这个后面我详细说。
1.2 结构化接口数据:真正的主流数据源
再说结构化接口数据。现在大多数网站的前端都是前后端分离架构,页面上的数据其实是JavaScript从后端接口拿到JSON之后渲染出来的。这类接口通常返回规范的JSON格式,比如:
{ "code": 0, "data": { "list": [ { "title": "某公司年报解读", "publish_time": "2025-01-15 09:30:00", "url": "https://example.com/news/12345", "read_count": 2300 } ] } }这类数据的采集难度比HTML低不少,因为字段结构清晰,直接用json.loads()就能解析。真正难的是"找接口"这一步:打开浏览器开发者工具,切到Network面板,刷新页面,筛选XHR或Fetch类型,你就能看到网页背后请求了哪些接口。找到能返回完整数据的那一个,再分析它的请求参数和加密签名逻辑。我的建议是,凡是目标网站有接口,优先采接口而不是解析HTML——接口数据干净、字段全、更新及时,解析成本低一个量级。
1.3 流式数据与增量数据:进阶场景的必修课
除了HTML和JSON接口,还有一类数据叫流式数据。它指的不是一次性返回完整数据集,而是通过长连接、WebSocket或Server-Sent Events持续推送的数据。股票行情、实时比分、社交平台的信息流都属于这一类。这类数据的采集方式和前两类完全不一样,通常需要建立长连接、维护心跳、处理断线重连。
不过作为基础实训,我不建议一上来就碰流式数据。先把前两类吃透就够了,知道有第三种形态存在,以后遇到的时候不会慌。还有一类"增量数据"值得提一下:比如你昨天采集了1000条新闻,今天网站又更新了20条,你要做的不是重新采1000条,而是只采新增的20条。这时候"上次采集的最后一条的时间戳或ID"就是关键,通常叫增量标记字段。这个思路在实训项目里我会重点演示。
2. 从URL到数据落地的完整请求链路:一次HTTP请求到底发生了什么
理解了采集对象之后,下一步是搞清楚一次请求的生命周期。数据采集的核心操作本质上就是HTTP客户端向服务器发起请求、接收响应的过程。但"发请求"这三个字背后有太多可以优化的细节,尤其是新手常常在编码、请求头、状态码这些地方栽跟头。
2.1 HTTP请求的组成部分拆解
一次标准的GET请求由几部分组成:URL、请求头(Headers)、请求参数(Params)和请求体(Body)。GET请求的参数通常拼在URL后面,用?分隔,多个参数之间用&连接。比如:
https://example.com/api/list?page=1&page_size=20&type=news这里page、page_size、type是查询参数,服务端依靠它们返回对应的数据。采集接口数据时,改参数是常态——改page翻页、改page_size调整每页数量、改时间范围筛数据。用Python的requests库写起来大概是:
import requests url = "https://example.com/api/list" params = { "page": 2, "page_size": 20, "type": "news" } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/", "Accept": "application/json" } resp = requests.get(url, params=params, headers=headers, timeout=10) print(resp.status_code) print(resp.json())如果你要采集的接口需要登录态,那就要在请求头里带上Cookie,或者用Authorization请求头带Token。我的实训经验是:直接填Cookie最简单、见效最快,缺点是Cookie有有效期;带Token更规范,但需要处理Token过期刷新。具体用哪种,取决于采集任务是一次性的还是长期定时跑。
2.2 请求头里的关键字段:User-Agent、Referer与Cookie
很多同学在采集时收到403 Forbidden,第一反应是"IP被封了",其实大部分情况下是请求头没带全,服务器识别出这不是正常浏览器行为。
最重要的三个字段:
| 字段 | 作用 | 不带的后果 |
|---|---|---|
| User-Agent | 告知服务器客户端的设备和浏览器类型 | 服务器可能拒绝响应,返回403 |
| Referer | 告知服务器请求来源页面,很多接口校验此字段 | 接口返回"非法请求" |
| Cookie | 携带会话信息,用于身份识别 | 需要登录的接口无法访问 |
有些服务器还会校验Sec-Fetch-*系列字段或者Origin,这种情况下直接用浏览器的Network面板里复制的请求头最省事。但我不建议无脑复制全部请求头,过多的字段反而可能暴露你的自动化特征,带上必备的几个就够了。
2.3 状态码与重定向:读不懂响应就谈不上采集
HTTP响应状态码是服务器和你对话的语言。采集过程中最常见的几个:
- 200:请求成功,数据正常返回
- 301/302:重定向,说明请求的URL有变化
- 403:禁止访问,通常是请求头或权限问题
- 404:请求的资源不存在
- 429:请求太频繁,被限流
- 500/502/503:服务器内部错误或过载
重定向这个细节在采集时极其容易踩坑。比如你请求http://example.com/data,服务器返回302,Location指向https://example.com/data。requests库默认会自动跟随重定向,这会带来两个问题:一是最终拿到的URL可能变了,导致翻页逻辑出错;二是某些采集目标会在重定向过程中种Cookie,如果你禁用了重定向跟踪,身份状态就不完整。我建议调试阶段把allow_redirects=False设上,手动观察重定向链条,确认无误后再打开自动跟随。
3. 拿到响应之后:网页解析与JSON清洗的实用套路
数据拿到手了,真正的工作才刚开始。这段是实训里同学们最容易卡壳的部分,因为解析代码写起来不难,难的是写的解析逻辑"扛不住"真实的脏数据。
3.1 HTML解析的两套方案:XPath与CSS选择器
解析HTML的主流工具是lxml和BeautifulSoup。它们的底层都在做同一件事:把HTML字符串解析成可查询的节点树,然后通过路径或选择器提取内容。
以lxml的XPath为例,我想提取上面那个商品例子的价格:
from lxml import html page = html.fromstring(resp.text) price = page.xpath('//span[contains(@class, "price")]/text()') print(price[0].strip())XPath的定位逻辑是"从根节点按路径找",适合字段多、结构复杂的页面。CSS选择器则更贴近前端工程师的思路,.price、#product-detail这样的写法更简洁。BeautifulSoup的select方法用的就是CSS选择器:
from bs4 import BeautifulSoup soup = BeautifulSoup(resp.text, "html.parser") price = soup.select_one(".price").get_text(strip=True)两套方案用哪个?我的看法是:页面结构清晰简单时用CSS选择器,速度快;页面结构复杂、需要按层级条件过滤时用XPath,表达能力强。比如"找到所有class以item-开头的div里的第二个span",这种条件XPath一行搞定,CSS选择器就要绕半天。
另外强烈建议给所有用到class名的代码都加一层容错。真实场景中网页经常有小改动,多写一行判断就能避免整个任务全崩:
price_elem = page.xpath('//span[contains(@class, "price")]/text()') if price_elem: price = price_elem[0].strip() else: price = "" # 记为缺失值,而不是直接报错3.2 JSON接口数据:解析简单,清洗才是重点
JSON解析本身没什么好讲的:
data = resp.json() items = data["data"]["list"]把数据取出来之后,清洗才是重头戏。我归纳了清洗时最常见的四种脏数据形态,实训中可以直接对照处理:
- 空值和None:有的字段在列表中不存在,补齐为
None或者约定好的占位符。 - 格式不统一的日期:
2025/01/15和2025-01-15混在一起,统一转成同一种格式。 - HTML实体与转义字符:从页面采集的文本里经常有
&、\u3000这类东西,要统一转换和去空白。 - 字段类型漂移:同一个字段,有时是字符串,有时是数字,需要做类型归一。
我习惯写一个简单的清洗函数,把所有采集数据统一过一遍再入库:
import re from datetime import datetime def clean_date(value): if not value: return None value = re.sub(r"[/.]", "-", str(value).strip()) try: return datetime.strptime(value, "%Y-%m-%d").date() except ValueError: return None def clean_text(value): return re.sub(r"\s+", " ", str(value or "")).strip()3.3 解析失败时的排查思路
数据解析失败时,第一反应不应该"检查代码",而是"检查响应内容"。把resp.text的前500个字符打印出来,看看服务器到底返回了什么。常见情况是:
- 返回了登录页面的HTML,说明请求状态丢了
- 返回了一段JSON错误提示,说明参数不对
- 返回了空数组,说明筛选条件没有匹配到数据
- 返回了压缩乱码,说明没有正确处理
Content-Encoding
这个排查思路看起来简单,但能帮你节省至少一半的调试时间。我自己实训中强调过无数遍:先打印响应体,再谈修代码。
4. 采集不是越快越好:频率控制、超时设置与重试策略
写了不少采集代码之后,我发现很多人的问题不是"采不到数据",而是"把目标网站采崩了"。这就要谈一个实训里几乎不讲、但实际工作中必考的话题:采集纪律。
4.1 请求频率控制的底层逻辑
服务器怎么判断你是真人还是脚本?其中一条就是请求频率。正常人浏览网站,两秒一次已经算很快了;脚本则可以一秒发几十个请求。目标服务器一旦检测到异常频率,轻则限流,重则封IP。
控制频率最朴素的方法就是睡眠:
import time import random for page in range(1, 11): fetch_page(page) time.sleep(random.uniform(2, 4)) # 随机间隔,模拟人类操作这里我特意用了random.uniform(2, 4)而不是固定的time.sleep(3)。固定间隔在服务器看来也是一种机器特征,随机化才是模拟真人。要不要上代理池、加并发,那是进阶话题。基础实训只要记住一个原则:采集单个网站时,默认以自己的浏览器速度作为参考上限。
关于频率控制,我还看到过用自适应策略的做法——每请求一次,观察响应时间,如果响应时间变长就自动放慢频率;连续收到429就彻底停下来等一段时间恢复。这个思路非常适合长时间运行的采集任务,相当于给采集器装了一个"油门控制器"。
4.2 超时设置:不让一个坏请求拖垮整个任务
requests库如果不设timeout,可能因为服务器无响应而一直挂起,整个采集任务卡死。设超时是写采集代码的门槛动作:
resp = requests.get(url, headers=headers, timeout=(3.05, 10))这里的timeout参数传的是元组,前一个是连接超时,后一个是读取超时。连接超时代表"连不连得上",读取超时代表"连上了但拿不到数据"。建议连接超时设短一点,比如3秒;读取超时按单条数据的大小来,一般10秒足够。
4.3 重试策略:指数退避比死磕有效
请求失败了,要不要重试?答案是要,但要有策略。无脑重试会加重服务器的负担,也让自己的错误率居高不下。我常用的策略是"指数退避",每次重试间隔翻倍,并设置最大重试次数:
import time def fetch_with_retry(url, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, timeout=(3.05, 10)) if resp.status_code == 200: return resp elif resp.status_code in (429, 500, 502, 503): # 服务器忙或限流,等待时间递增 wait_time = 2 ** attempt + 1 time.sleep(wait_time) else: # 4xx错误一般重试无意义,直接返回 return resp except requests.RequestException as e: time.sleep(2 ** attempt + 1) return None重试里还有一个细节值得注意:重试要区分错误类型。403、404这种重试一百次还是同样的结果,白白浪费时间和服务器资源;只有429、5xx和网络超时这类临时性错误才值得重试。这个判断逻辑放进代码里,采集器的健壮性会明显上一个台阶。
5. 合规采集是底线:什么能采、什么不能采
数据采集的技术问题都好解决,真正需要反复强调的是合规问题。我带的每个实训项目,开课第一件事就是讲清楚采集的红线。
5.1 公开数据与个人信息的分界线
技术上你能采到的数据,法律上未必允许你采集和使用。两者的分界线不完全在于"这个页面要不要登录",而在于数据本身的性质。一个比较稳妥的判断标准是:涉及可识别到特定自然人的个人信息,比如姓名、手机号、身份证号、住址、社交账号等,如果没有明确的合法依据和必要的授权,就不应该采集。
公开的企业资讯、行业报告、市场行情这类数据,采集和使用的风险相对小很多,但仍然要注意网站的用户协议。很多网站在服务条款里明确写了"未经许可不得对本站内容进行批量抓取",这种情况下即便数据本身是公开的,批量采集也可能构成违约。
我给学生定的实训规矩是:
- 只采集公开的、非个人的、用于学习研究的数据
- 不使用采集工具突破登录限制、绕过反爬验证、破解加密参数
- 不把采集到的数据用于商业用途或公开传播
- 采集前先看目标网站的
robots.txt和用户协议
5.2 robots.txt的含义与局限
robots.txt是网站根目录下的一个文本文件,用来告诉爬虫哪些路径可以采集、哪些不可以。比如某个网站的robots.txt可能长这样:
User-agent: * Allow: /news/ Disallow: /user/ Disallow: /private/这表示普通爬虫可以访问/news/目录,但不应访问/user/和/private/。需要说明的是,robots.txt更多是一种君子协定,它表达的是网站方的访问意愿。遵守它是采集者的基本素养,也是实训里必须养成的习惯。查robots.txt的方式很简单:
robots_url = "https://example.com/robots.txt" resp = requests.get(robots_url, timeout=5) print(resp.text)5.3 合规采集的自查清单
我在每个实训项目验收的时候都会检查一份清单,这里直接分享出来:
| 检查项 | 说明 |
|---|---|
| 数据是否公开 | 确认目标数据属于公开信息,而非账号私域数据 |
| 是否涉及个人信息 | 涉及则必须有明确的合法理由和授权依据 |
| 是否遵守robots.txt | 目标路径是否被Disallow |
| 是否遵守用户协议 | 用户协议是否禁止批量抓取 |
| 请求频率是否克制 | 是否设置了合理的请求间隔和限速 |
| 数据用途是否合法 | 是否仅用于学习研究,不做商业运营 |
| 是否存在绕过行为 | 是否使用自动破解验证码、绕过频率限制等手段 |
不要觉得这些是"套话"。我见过太多数据采集项目因为合规问题翻车,小则数据被清空,大则招惹法律纠纷。基础实训阶段就把合规意识植入习惯,比任何技术点都重要。
6. 实训演示:一个财经资讯页面的数据采集小项目
理论部分讲完了,最后用一个具体的实训项目把整个流程串起来。这个项目模拟真实场景,对当前财经公告环境进行数据获取,虽然代码不复杂,但它完整覆盖了"分析目标→构造请求→解析清洗→存储落地"的全流程,实操性很强。
6.1 目标分析与字段设计
假设我要采集某个财经资讯网站的公告列表页,页面结构大概是每页20条公告,每条包含标题、发布时间和详情链接。先别急着写代码,第一步是明确需求:
- 采集范围:公告列表前5页
- 目标字段:标题、发布时间、链接
- 存储方式:标准CSV文件
- 采集频率:单次任务,不做定时轮询
字段设计上,我建议在存储前加一个"采集时间"字段,用来记录这条数据是什么时候采到的。这在后续做数据去重和增量更新时非常有用。
6.2 代码实现:请求与解析的完整串联
下面是完整的实训代码,采用requests发送请求、lxml做解析:
import csv import random import time import requests from lxml import html from datetime import datetime BASE_URL = "https://example.com/announcement" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example.com/", "Accept": "text/html,application/xhtml+xml" } def fetch_page(page): url = f"{BASE_URL}?page={page}" resp = requests.get(url, headers=HEADERS, timeout=(3.05, 10)) resp.raise_for_status() return resp.text def parse_announcements(html_text): tree = html.fromstring(html_text) items = [] # 每一行公告的xpath路径,实际使用时需要按目标页面调整 rows = tree.xpath('//div[contains(@class, "notice-list")]/div[contains(@class, "item")]') for row in rows: title_ele = row.xpath('.//span[contains(@class, "title")]/a') time_ele = row.xpath('.//span[contains(@class, "date")]/text()') if not title_ele: continue items.append({ "title": title_ele[0].text_content().strip(), "publish_time": time_ele[0].strip() if time_ele else "", "link": title_ele[0].get("href", ""), }) return items def main(): all_data = [] for page in range(1, 6): print(f"正在采集第 {page} 页") try: page_html = fetch_page(page) page_data = parse_announcements(page_html) all_data.extend(page_data) except requests.RequestException as e: print(f"第 {page} 页采集失败: {e}, 跳过") time.sleep(random.uniform(1.5, 3.5)) timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open("announcements.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "publish_time", "link", "collect_time"]) writer.writeheader() for item in all_data: item["collect_time"] = timestamp writer.writerow(item) print(f"采集完成,共获取 {len(all_data)} 条数据,已保存至 announcements.csv") if __name__ == "__main__": main()这里有几个设计值得解释一下。encoding="utf-8-sig"是CSV的中文保险方案,带BOM的UTF-8编码能让Excel直接打开不乱码,这是实战中特别容易忽略的一环。publish_time字段如果为空就存空字符串而不是丢掉整条数据,保证数据量完整。time.sleep(random.uniform(...))则是我们已经聊过的礼貌爬虫策略。
6.3 实训过程中最常踩的三个坑
第一个坑是XPath路径写死。网站改版或者结构微调,路径就失效。我见过最多的情况是title_ele为空,但页面其实有数据,原因是class名多了个空格或者大小写变了。解决办法是在解析前先打印几行HTML确认结构:print(tree.xpath('//div[contains(@class, "notice")]')[0].text_content()),看一眼再写路径,能少走很多弯路。
第二个坑是翻页逻辑。有些网站第一页URL是announcement而不是announcement?page=1,直接用循环拼接出来的URL会导致第一页重复采集。处理方式是把第一页当成特例,或者确认翻页参数的起始值再写循环。
第三个坑是数据编码。接口返回的JSON一般是UTF-8,但某些老站的HTML是GBK编码,requests直接用resp.text会解码成乱码。遇到这种情况,要显式指定编码:resp.encoding = "gbk"。判断编码的一个小技巧是看响应头里的Content-Type字段,比如text/html; charset=gbk。
6.4 项目完成后的自我复盘清单
实训做完不是交差就完了,我一般让同学按这份清单复盘一遍:
- 是否所有目标字段都成功采集,缺失率是多少
- 是否有重复数据,是否需要按标题或链接去重
- 请求失败的页是否做了重试,还是直接跳过了
- CSV文件是否能被Excel或其他工具正常打开
- 采集过程中是否有触碰到合规边界的行为
- 如果页面结构变了,当前代码需要改哪些地方
按这套思路走下来,一个小资产的数据采集任务基本就能稳定落地。我也建议你在此基础上尝试扩展:把存储从CSV换成SQLite数据库,把单次采集改成增量采集,把固定页面改成从入口页动态发现详情页链接。这些都是数据采集基础知识之上很自然的进阶方向,也是把基本功转化成生产力的路径。