1. 开局:这堂实训课到底要解决什么问题
先说一个真实的场景。上一期实训班有位学员,拿着网上的爬虫教程苦练了半个月,代码能跑了,结果启动没两天就被目标站点封了 IP,数据只抓到几百条。他跑来问我:老师,我明明照着教程写的,到底哪里出了问题?
这个问题很有代表性。市面上大部分数据采集教程,前几章都是教你装库、调 requests、正则提取,看起来很简单,但真正到了实战环节,你会发现采集从来不是"请求一个 URL 再解析 HTML"那么简单。你面对的是动态加载、接口鉴权、字段混淆、频率限制、数据清洗,甚至还有对方服务端的风控策略。与其说数据采集是写代码的技术活,不如说它是一个围绕数据的分析、工程化落地和长期维护的系统工程。
这篇博文把"数据采集基础知识"这堂实训课的完整思路拆给你看。内容包括:如何从零梳理采集目标、怎么理解网页数据的来源与传输逻辑、如何用 Python 搭一套轻量采集工具链、怎么抓包定位真实接口、怎么清洗和落地存储,以及我在实际带项目过程中踩过的坑和排查经验。我会用雪球个股数据采集作为贯穿案例,把一套从分析到落地的完整流程走一遍。
不管你是刚入行的运维、转行做数据分析的新手,还是想给自己业务补充数据源的独立开发者,这篇内容都可以作为你的第一份"抄作业"底稿。看完你至少能回答三个问题:数据采集到底在采什么?一个稳定的采集脚本长什么样?遇到封禁和解析失败,应该按什么顺序排查?
2. 需求拆解:先想清楚要采什么,再动手
2.1 明确采集目标和数据边界
很多人一上来就问"写个爬虫要多少钱""能不能把整个网站数据都爬下来"。这种想法第一步就错了。数据采集的第一个动作,不是写代码,而是把需求说清楚。
以雪球数据采集为例。雪球是一个投资交流平台,上面有大量个股行情、讨论帖子、用户观点、财务数据等内容。如果只说"我要采集雪球数据",那范围大到无法落地。你至少要确认三个问题:
- 采哪些数据?是行情快照、历史K线,还是某个股票下的热门帖子、评论内容?
- 时间范围是什么?是实时增量,还是历史全量?历史要从哪一天开始?
- 数据用途是什么?是个人做策略回测,还是做舆情分析,还是做教学示例?
这三个问题直接决定你的技术路线。比如只采行情快照,最轻量的方案是调公开接口,不需要模拟浏览器;如果要采历史K线,就得考虑分页参数和频率控制;如果要做舆情分析,那解析的是动态渲染数据,采集逻辑完全不一样。
我在实训课上反复强调一句话:数据采集项目的失败,大部分发生在写代码之前。需求不明确,后面每一步都在返工。
2.2 从协议视角理解网页数据来源
确定了采集目标,下一步是理解数据从哪来。这里有个常见的认知差:新手以为网页上的数据就是 HTML 文件里的内容,右键查看源代码就能看到。但今天的主流网站早就不是这个逻辑了。
现代网页有两种主要的数据呈现方式:
第一种是服务端渲染。用户发起请求后,服务器直接把带数据的完整 HTML 返回,浏览器解析后直接展示。这种页面写起来简单,搜索引擎也好收录,但数据直接躺在 HTML 里,正则是够用的。
第二种是客户端渲染。服务器先返回一个空壳 HTML,浏览器加载 JavaScript 脚本,脚本再向后台接口发请求,拿到 JSON 数据后在浏览器端渲染成页面。这种模式用户体验好,交互复杂,但对采集者来说多了几步工作。你看到的帖子、K线图、评论,很多时候都来自一个独立的 API 接口。
判断方式是打开浏览器开发者工具,切到 Network 面板,刷新页面,看有没有 XHR/Fetch 类型的请求返回 JSON。如果主要数据都在这类请求里,那这个站点是接口驱动的,采集对象应该是接口,而不是 HTML。
这一层理解很重要。它决定了你后续的采集方案是"请求 HTML 页面再解析"还是"直接调接口拿 JSON"。接口路径直接且结构化程度高,解析成本低,是首选的采集目标。
2.3 技术选型:轻量工具链,还是重型框架
搞清楚数据源形态之后,才轮到工具选型。网上有各种框架,Scrapy、Playwright、Puppeteer、requests + BeautifulSoup 等。很多新手在第一步就陷入框架选择困难症。我给实训班的建议很直接:能从基础库起步,就不用大框架;能用静态请求解决,就不要上浏览器渲染。
对于大部分中小规模采集任务,我个人推荐的工具链是:
- 请求发起:Python 的 requests 库,简单直接,适合绝大多数静态接口调用
- 页面解析:BeautifulSoup4 + lxml,处理不规范 HTML 比正则稳定得多
- 动态渲染处理:Playwright 或 Selenium,只在必须执行 JavaScript 才能拿到数据时使用
- 数据清洗:Pandas,尤其是 DataFrame 的列处理、去重、类型转换,比手写循环高效得多
- 任务调度:APScheduler 或直接写个 while + sleep 的循环,前期够用
- 存储:数据量小于 100 万行,SQLite 完全够用;更大量级再考虑 MySQL 或 PostgreSQL
为什么不用 Scrapy?Scrapy 很强,但它的学习曲线对初学者不太友好,异步框架的调试、中间件配置、Pipeline 管理,每一项都需要额外理解成本。我见过太多人为了跑个课堂作业去啃 Scrapy,结果光配置就被搞崩溃了。不是说它不好,而是入门期选择一个"心智负担最小"的方案,能让你把注意力放在理解数据本身的逻辑上,而不是框架代沟上。
这套轻量工具链有自己的最佳适用范围:单机部署、目标站点不超过几十个、每天采集量在几万到几十万条级别。如果你的任务已经到千万级、分布式采集、定时增量同步这些规模,再考虑引入 Scrapy 和分布式调度系统不迟。
3. 核心细节:HTTP 请求、页面解析与字段清洗
3.1 HTTP 请求的构成:Header、参数与频率
数据采集基础设施的核心,是熟练掌握一个 HTTP 请求的完整构成。写代码之前,你必须知道下面几个要素各自的角色:
- URL:你要访问的资源路径,包括路径参数和查询字符串
- Method:GET 用于获取数据,POST 用于提交数据。采集场景绝大多数是 GET,少数需要构造 POST body
- Headers:请求头,携带 UA(User-Agent)、Cookie、Referer、Accept 等信息。服务器通过它们判断请求方的身份
- Params / Body:查询参数或请求体,定义你要的数据范围、分页位置等
- Response:服务器返回的内容,可能是 HTML、JSON、图片二进制等
写采集脚本时,最容易被忽略的就是 Headers。UA 是服务器识别"这是浏览器还是脚本"的第一道关卡。默认的 Python-requests UA 一眼就能看出是程序在访问,正规一点的站点都会拦截。所以无论你请求什么页面,都要设置一个看起来正常的浏览器 UA。
Cookie 则承担了身份识别的作用。登录态、浏览偏好、滑块验证通过后的凭证都可能存在 Cookie 里。很多接口直接校验 Cookie 里的特定字段,没有它返回 403 或返回空数据。所以抓包时随手把完整请求头复制下来,不要只复制 URL。
再就是频率控制。服务器不会关心你是否需要这批数据,它只关心你是不是"正常用户"。人工浏览一小时几十次请求很正常,但脚本可能一秒钟打几十次,这种频率本身就是一种风险信号。无论目标站点是否有风控,请求间隔都不能为 0。我在实训项目中习惯设定 1 到 3 秒的随机间隔,即使是并发采集,单线程间隔也不会低于 0.5 秒。这个原则不是为了躲避什么,是基本的网络礼貌和稳定性的保障。
3.2 页面结构分析与字段定位
拿到一个静态页面,真正动手写解析脚本前,先做结构分析。这一步可以用开发者工具的 Elements 面板,也可以用浏览器右键直接查看源代码来确认数据是否在 HTML 里。
定位字段时,新手喜欢一上来就写正则,这是最脆弱的方式。页面结构稍微调整,正则就会失效。我建议优先用 CSS 选择器或 XPath 定位节点,通过标签结构和 class 属性来确定位置。BeautifulSoup 的 select 方法直接支持 CSS 选择器语法,简单直观:soup.select('div.article-item .title')这种写法,可读性和可维护性都远高于一堆正则。
实际操作中,你会发现真实站点的 HTML 结构往往嵌套很深、class 里有大量动态生成的字符串。这时不要死磕页面 HTML 里的某一段,优先考虑两个替代路径:
- 接口 JSON 里是否直接有目标字段?如果页面是客户端渲染的,数据源头本身就结构化了,解析成本最低
- 页面里有没有嵌入式的 JSON 数据(比如
window.__INITIAL_STATE__这样的全局变量)?有的话用正则提取整块 JSON 再解析,比在 HTML 里找字段稳定
这个思路我管它叫"越靠近数据源头越好"。HTML 是给人眼看的,JSON 是给程序用的。能从 JSON 里拿到的字段,绝不在 HTML 里抠。
3.3 字段清洗:脏数据才是常态
采集到的原始数据,和"可以分析的数据"之间,差的不是一个列名,而是一整套清洗流程。我用雪球的个股行情数据举个例子。接口返回的 JSON 里可能包含:
- 数值字段混入单位:比如
"成交量": "1234万",分析时得统一转成数字 - 字符串和数字类型混乱:百分数字段有的返回 0.153,有的返回 "15.3%"
- 缺失值:停牌的股票,部分行情字段直接为空,得填充业务上有意义的默认值
- 重复数据:分页边界处理不好,下一页第一行和上一页最后一行是重复的
这些问题的解决思路,归纳起来就三步:
- 先定好目标表结构,把每个字段的类型和格式写清楚,这是基准
- 写统一的归一化函数处理单位、百分号、空值等常见问题
- 入库前用 Pandas 做一次批量清洗,而不是边采边清
很多新手以为"采完就行了",实际上采集工作只占整个项目的三成精力,清洗和存储标准化至少占四成,剩下的三成在维护和排查。如果你没有这样的预期,后面很容易被脏数据搞得心态崩溃。
4. 实操记录:用雪球个股行情数据完成一次完整采集
4.1 第一步:抓包定位真实数据接口
以雪球个股页面为例,假设我要采集某只股票最近一段时间的历史行情数据。
做法是先打开浏览器开发者工具,切到 Network 面板,勾选 Fetch/XHR 过滤条件,然后手动刷新页面。很快就能看到大量接口请求。目标是找一个返回 JSON 且包含行情字段的接口,名称里通常包含 quote、chart、kline 这类词。
我自己常用的路径是:雪球行情数据接口大致在https://stock.xueqiu.com/v5/stock/chart/kline.json这个类型下,通过symbol参数指定股票代码,通过begin和period参数指定起始时间和周期。真实路径以你抓包为准,但思路是通用的:先过滤出 XHR 请求,看响应预览,找到包含目标数据的那个接口,然后右键复制为 cURL 命令,后续在脚本里把请求头原样带过去。
这一步的关键是不要用页面 URL 猜接口地址。很多接口的路径经过改动,参数格式也五花八门,最可靠的信息源始终是浏览器发出的真实请求。
4.2 第二步:编写基础采集脚本
参数和请求头明确了,写代码只是时间问题。下面是一个简化版请求示例,它能正常工作,但我建议你在自己的项目里再补上重试逻辑和异常捕获:
import requests import time import json HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://xueqiu.com/", "Cookie": "你的cookie字符串", "Accept": "application/json, text/plain, */*", } def fetch_kline(symbol: str, begin: int, period: str = "day", count: int = 100): url = "https://stock.xueqiu.com/v5/stock/chart/kline.json" params = { "symbol": symbol, "begin": begin, "period": period, "type": "before", "count": count, "indicator": "kline", } resp = requests.get(url, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.json() # 示例:拉取最近100天的日K数据 if __name__ == "__main__": data = fetch_kline("SH600000", int(time.time() * 1000)) print(json.dumps(data, ensure_ascii=False, indent=2)[:2000])注意几个细节。begin用的是毫秒时间戳,这是接口返回 JSON 里自带的字段类型,直接换算即可。period控制K线粒度,day 对应日K,week 对应周K。type=before表示取起点之前的数据,配合下一页的 begin 值完成翻页。
跑通脚本后,第一件事是检查返回 JSON 里有没有error_code之类的字段。如果接口返回了非 0 的错误码,大概率是请求头有问题,优先检查 Cookie 是否过期。这个排查顺序在后面我会再强调。
4.3 第三步:翻页逻辑与数据拼接
一次请求只返回一页数据,完整的历史行情需要循环翻页。翻页的关键是理解分页参数。这类行情接口的分页逻辑比较典型:下一页的begin值取上一页最后一条数据的时间戳,再加一个最小间隔。
写翻页循环时,有几类典型的坑:
- 死循环:如果服务端对超出范围的时间返回空列表,而你没有判断空返回就继续请求,会一直打下去。每一页都必须判断"返回条数为 0 则停止"
- 重复行:如果下一页的 begin 取的是上一页最后一条的时间戳,服务端翻页逻辑可能包含这一条,导致拼接时重复。解决方式是按某个唯一字段去重
- 翻页较慢:历史数据几年甚至十几年,一天天拉可能要几小时。合理做法是把起始时间切成多个区间并发拉取,但初学阶段还是建议先线性翻页,跑通再优化
翻页拼接后的数据,我会先转成 Pandas DataFrame 做一次重复检查和时间排序,确认连续性和完整性再入库:
import pandas as pd def kline_to_dataframe(raw): rows = raw.get("data", {}).get("item", []) columns = ["timestamp", "open", "high", "low", "close", "volume", ...] df = pd.DataFrame(rows, columns=columns) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") df = df.drop_duplicates(subset=["timestamp"]) df = df.sort_values("timestamp").reset_index(drop=True) return df4.4 第四步:落地存储
数据清理完,下一步是存储。初学阶段首选 SQLite,原因有三个:零配置、单文件、Python 内置支持。它足够应付个人项目到百万行级别的数据量。
建表语句按字段设计走:
CREATE TABLE IF NOT EXISTS stock_kline_daily ( symbol TEXT NOT NULL, trade_date TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, volume INTEGER, PRIMARY KEY (symbol, trade_date) );主键用 (symbol, trade_date),天然去重。写入时采用INSERT OR REPLACE或INSERT OR IGNORE,这样断点续采时不会产生重复数据。后续如果要从 SQLite 迁移到 MySQL,Pandas 的to_sql方法可以直接帮你转换,迁移成本很低。
存储设计的背后,其实是让你的采集数据具有"可追溯性"和"可重复使用性"。没有规范存储,数据就是一次性垃圾。
5. 常见问题与排查技巧实录
5.1 问题一:请求返回 403 / 请求被拒绝
这是采集过程中最常碰到的错误,没有之一。403 表示服务器理解请求但拒绝执行。按我排查的经验,优先级从高到低是:
- 检查请求头是否完整。尤其是 UA、Referer、Cookie,缺了哪个都不行
- Cookie 是否过期。很多站点的 Cookie 有效期只有几小时到几天,采集间隔久了,重新打开浏览器登录一次、更新 Cookie 即可
- 是否请求过于频繁。服务器对单一 IP 的请求频率有统计,短期密集请求很容易触发临时封禁。解决方式是拉长间隔,或者退避式重试
- 是否缺动态签名参数。少数站点除了固定 Cookie,还会在请求里带上由 JavaScript 计算出的动态参数。这种情况用纯静态请求不好解决,需要分析加密逻辑,或者切到 Playwright 方案
实测中最隐蔽的是第四种。接口返回 200 但不带数据,和接口返回 403,处理方式完全不同。所以排查时务必先看响应的具体内容,不要只看状态码。
5.2 问题二:页面结构变更导致解析失败
静态页面解析的天然脆弱点在于:目标站点的前端工程师每次改版,你的脚本就可能当场失效。这没法根治,但可以把损失降到最低。
我自己有两条防护经验。一是解析时优先依赖"语义特征"而不是"视觉无关的临时类名"。比如帖子正文的容器 class 是动态字符串,但article-content这种带语义的标识相对稳定,优先用后者。二是给解析函数加一层"结果校验"。无非是检查解析后的 DataFrame 是否为空、行数是否为 0、关键列是否缺失。如果结果不符合预期,脚本主动报警或退出,而不是继续把脏数据写进数据库。这样即使页面变了,你也第一时间知道,而不是过了一个月才发现库里全是空值。
5.3 问题三:数据质量差,字段不齐或类型混乱
接口返回的 JSON 结构看起来规整,但落到真实数据里,脏数据的概率远超你想象。我踩过最典型的一次:某接口的成交量字段在停牌日返回的是字符串"--",正常日返回的是数字。因为在 Python 里按数字类型直接处理,跑到停牌日就报错。
解决这类问题的标准化手段是做一个"字段兼容层"。每次采集后,统一对 DataFrame 做类型强制转换,遇到无法转换的值就填充约定好的空值标记。转换逻辑专门摘到一个函数里,方便批量复用:
def normalize_numeric(series): if pd.api.types.is_numeric_dtype(series): return series return pd.to_numeric(series.astype(str).str.replace("--", "").str.replace("%", ""), errors="coerce")这个函数看起来短,但它在实训项目里帮我省下的排查时间远远超过写它的时间。
5.4 应对策略:把采集做成一个小型稳定工程
整体来看,遇到反爬或者限流,核心应对思路不是"破解",而是"降噪"。具体来说:
- 控制单日请求总量,尽量模拟正常用户的行为节奏
- 收集过程中随时检查返回内容的合法性,发现异常立即停止而不是硬冲
- 对关键数据做增量备份和断点记录,即使中途挂了,也能从上次位置继续
- 绝不绕过登录、验证码或刻意对抗服务端的安全机制
我在实训课上对学生说得很直接:数据采集的价值在于稳定持续地拿到高质量数据,而不是一次性搬空某个平台。在这个前提下,任何让你脚本"跑得快但活不久"的做法,都是舍本逐末。
6. 实训总结与一点个人体会
回到最开始那个学员的问题:为什么教程都照着写了,脚本还是活不过两天?看完前面的内容,答案已经有了——他缺少的不是写代码的能力,而是对一个完整采集系统的拆解能力。需求边界、协议理解、网络请求细节、解析定位、清洗规范、存储设计、异常排查,每一个环节都是独立的知识块,拼在一起才是一个能长期运行的采集工程。
我的个人建议是:第一次做数据采集项目,不要贪多求大,选择一个小而明确的数据集,认真走完从抓包到存储的全流程,然后想办法让它稳定运行一周。你会在一周里遇到各种意料之外的问题,而解决这些问题的过程,才是这门技能真正上手的开始。把第一个小项目做扎实,后面再面对复杂的反爬、分布式调度、增量同步,都是在这个基础上的自然延伸。