如果你跟着这个系列走到第十篇,应该已经能用 Python 写出处理文件、操作列表字典、定义类和函数的程序了。但我想问一个很现实的问题:你手头的练习数据是从哪来的?如果每次都要打开 Excel 手动复制粘贴,那 Python 目前对你来说还只是个“高级计算器”。从这一篇开始,我们把 Python 变成真正的“数据工人”——让程序自己去互联网上获取公开数据,再整理成干净的分析表。
这篇的主题是网络数据抓取与结构化处理,面向刚学完 Python 基础、想往人工智能或数据分析方向走的人。我会用一个“某图书信息采集 Demo”把 requests、BeautifulSoup、pandas 和 SQLite 串起来走一遍,从最基础的请求开始,讲到动态页面处理、JSON 接口调用、数据清洗入库,最后整理一份实战中频繁踩坑的排查清单。学完这一篇,你应该能独立完成“从网页抓到结构化表格”的完整流程,而且知道每一步为什么要这么写。
1. 抓取方案设计:先想清楚再写代码
1.1 先确认抓取目标与合规边界
很多人一提“抓数据”就兴奋,打开终端就开始import requests。我的建议是反过来:先花十分钟回答三个问题——数据源在哪?数据是怎么渲染的?允许不允许抓?这三个问题决定了你后面是半小时收工还是折腾一整天。
先说数据源。你要抓的是静态 HTML 页面、浏览器动态渲染的页面,还是后端直接返回 JSON 的接口?这三种来源的处理方式完全不同:静态页面直接解析 HTML 就行,动态页面可能需要驱动浏览器,而 JSON 接口往往是最省事的那条路。判断方法很简单,打开浏览器开发者工具,切到 Network 面板,刷新页面,看看页面内容是不是在某个 XHR 或 Fetch 请求的响应里。如果响应里直接就是带字段名的数据,那基本可以走 JSON 路线。
再讲合规。这个容易被新手忽略,但不是小事:开始写代码前,先看一眼目标站点根目录下的 robots.txt,以及网站的使用条款。我只建议抓取公开数据、用于个人学习和研究,并且请求频率要克制,不要并发开太大,更不要抓涉及个人隐私的内容。某图书信息采集 Demo 里的示例我放在本地环境跑,你换成线上公开站点时务必先确认边界。说白了,抓取本身不是问题,问题的核心是:你有没有尊重对方的服务器和规则。
1.2 抓取与处理的主干框架
一次完整的数据抓取,无论多复杂,都可以拆成四件事:发起请求、解析内容、清洗数据、存储分析。你可以理解为去图书馆借书:先向图书馆管理员提出申请(请求),拿到书之后找到你要的内容并记下来(解析),把笔记里的错别字和冗余信息去掉(清洗),最后按主题归档(存储分析)。任何环节出了问题,后面所有步骤都会跟着出问题。
这四步的顺序是固定的,因为每一步都依赖上一步的输出。请求不到页面,解析就是空谈;解析出脏数据,清洗就得加班;清洗完没有落盘,程序一重启数据就没了,等于白干。很多新手喜欢跳跃着写,先写“解析”再补“请求”,发现报错一堆,其实问题往往出在前一步的输出根本不符合预期。
所以在动手前,我强烈建议你把这条主链写在一张纸上:请求 -> 解析 -> 清洗 -> 存储。然后在每个箭头旁边写下“怎么验证这一步成功”。请求成功的标志是状态码 200 并且响应内容里包含目标页面;解析成功的标志是能打印出至少一条你要的字段;清洗成功的标志是数据没有重复和缺失;存储成功的标志是文件或数据库里能重新查出来。这个清单就是你的验收标准,也是排查问题的索引。
1.3 工具选型:怎么组合才顺手
网上关于爬虫工具的组合说法很多,我个人的偏好是:静态页面用requests + BeautifulSoup,动态页面用Playwright,清洗和存储用pandas + SQLite。这套组合是我在大量项目里用下来最顺手的,不是因为它功能最全,而是因为它足够轻、足够直观、出了问题容易定位。
为什么静态页面不用 Selenium?因为 Selenium 要额外管理浏览器驱动,而且启动浏览器的开销远大于发一个 HTTP 请求。如果服务器直接返回 HTML,你根本不需要一个完整的浏览器。为什么动态页面推荐 Playwright?因为它的安装体验比 Selenium 好很多,pip install playwright之后一条命令就能装好浏览器内核,API 设计也更现代,用同步写法也能写得很顺手。
工具不在多,在于和场景匹配。下面这张表是我自己在项目里做选择时的依据,你直接抄就行:
| 场景 | 推荐工具 | 选择理由 |
|---|---|---|
| 静态 HTML 页面 | requests + BeautifulSoup | 轻量、快、选择器直观,适合绝大多数列表页和详情页 |
| 动态渲染页面 | Playwright | 能执行 JavaScript,安装简单,不需要额外下驱动 |
| 后端 JSON 接口 | requests | 接口直接返回结构化数据,解析成本最低 |
| 清洗与汇总 | pandas | 去重、缺失值、类型转换都有现成方法 |
| 轻量持久化 | SQLite | 单文件数据库,无需装服务端,适合个人项目 |
这套组合还有一个好处:后续接机器学习时,pandas 的 DataFrame 可以直接作为特征工程的输入,不需要中间再转换一次格式。
2. 环境准备与最小可运行示例
2.1 用虚拟环境隔离依赖
我见过不少同学图省事,直接用全局 Python 环境pip install装了一堆包,结果 A 项目要 requests 2.x、B 项目要 1.x,装来装去把环境搞乱了。从系列一开始我就反复强调虚拟环境,这一篇必须再提一次:每个项目单独建一个环境,是成本最低、收益最高的好习惯。
创建和使用虚拟环境只需要三步。在项目根目录执行:
python -m venv venvWindows 下激活:
venv\Scripts\activatemacOS 或 Linux 下激活:
source venv/bin/activate激活后终端命令行会出现(venv)前缀,这时候再装依赖就不会污染全局环境。接下来安装本篇需要的库:
pip install requests beautifulsoup4 pandas lxml playwright如果后面要操作数据库,再加一个sqlalchemy,它能让 pandas 与数据库的交互更顺滑。安装完成后可以用pip list确认版本,重点确认 Python 版本在 3.8 以上——这个系列默认环境不低于 3.8,我用的时候一般是在 3.10 或 3.11,基本没遇到过兼容性问题。
2.2 第一个网络请求:requests 入门
很多人第一次写requests.get()就直接对一个公开网站发起请求,结果要么超时,要么返回的状态码不是你预期的 200,马上就开始怀疑人生。所以我建议先在本地起一个最小环境,先把“请求 -> 响应”这条链路跑通,再上真实场景。
在项目目录下创建一个demo.html,内容就是一组图书列表,方便后面解析练习。你可以先用一段最简单的 HTML:
<ul class="book-list"> <li class="book-item">import requests resp = requests.get("http://localhost:8000/demo.html", timeout=10) print(resp.status_code) print(resp.text[:200])运行后你应该能看到 200 和 HTML 片段。这里有两个细节值得新手注意。第一是timeout参数,我建议永远不要省略,默认情况下 requests 可能等很久;给你一个 10 秒的限制,如果目标地址有问题就能快速失败,而不是卡住整个程序。第二是resp.status_code,它只表示服务器返回了状态,并不代表你拿到了想要的内容,后续还要通过响应体来做判断。
2.3 用 BeautifulSoup 完成第一次解析
拿到响应文本后,下一个任务是把 HTML 里包裹的字段提取出来。BeautifulSoup 最擅长的就是这件事。下面这段代码会选中ul.book-list里所有的li.book-item,再把每个条目的书名打印出来:
from bs4 import BeautifulSoup soup = BeautifulSoup(resp.text, "html.parser") items = soup.select("ul.book-list > li.book-item") for item in items: title = item.select_one(".title").text print(title)select()返回所有匹配的元素列表,select_one()返回第一个匹配的元素。如果你学过 CSS,上手会非常快;即使没学过,现查也够用:.表示 class,#表示 id,>表示直接子元素,空格表示后代元素。解析器我用的是html.parser,这是标准库自带的,够用且没有额外依赖。
这里说一下为什么解析器不直接选lxml。lxml解析速度更快、容错更好,但需要编译安装底层库,在部分 Windows 机器上可能遇到环境问题。先用html.parser跑通流程,之后如果遇到性能问题再切换成lxml,这属于优化手段,不是起步必需品。
3. 静态页面抓取与结构化提取实战
3.1 请求头与参数的细节
本地请求跑通之后,切换到真实站点时最容易被新手忽略的就是请求头。很多服务器会拒绝没有User-Agent的请求,或者把你识别成脚本程序。最直接的解法:打开你自己的浏览器开发者工具,在 Network 面板里随便点开一个请求,找到User-Agent的值,复制到代码里。
一个比较完整的请求头写法是这样:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", } resp = requests.get(url, headers=headers, timeout=10)User-Agent是在告诉服务器“我是个普通浏览器”,这不是欺骗,而是让服务器正常提供内容。部分页面还会校验Referer字段,表示请求来源页面,遇到这种情况可以把目标页面的地址填进去。你不需要把所有请求头都搬过来,先带着User-Agent跑,遇到拒绝再逐个加。
请求参数方面,常见的是 URL 后面跟查询参数,例如分页页码。你可以在 URL 里直接拼,也可以用 requests 自带的params:
params = { "page": 1, "size": 20, } resp = requests.get(url, params=params, headers=headers, timeout=10)用params的好处是 requests 会自动帮你处理 URL 编码,不用担心中文参数或者特殊字符把 URL 搞乱。
3.2 从 HTML 中提取结构化数据
提取数据的核心思路是:先通过元素的 class 或 id 定位到最小重复单元,再在每个单元里提取具体字段。还是以图书列表为例,定位到li.book-item之后,书名、作者、价格、评分都在这个单元内部,逐个提取即可。
items = soup.select("ul.book-list > li.book-item") books = [] for item in items: title = item.select_one(".title").text.strip() author = item.select_one(".author").text.strip() price = float(item.select_one(".price").text) rating = float(item.select_one(".rating").text) books.append({ "title": title, "author": author, "price": price, "rating": rating, }) print(books)这里我连续用了两个float()转换,是因为 HTML 里拿到的都是字符串,比如"59.0",如果不转,后面没法做平均值和排序。strip()则是去掉文本两端的空白字符和换行,真实页面的 HTML 经常带大量缩进,不处理的话数据里会混进\n和空格,清洗阶段还得返工。
实际情况中,字段内容可能更复杂,比如价格可能是"¥59.00"或"59元"。这时候你就得先按某种规则替换或拆分,再做类型转换。比较通用的做法是用正则表达式提取数字部分:
import re price_text = "¥59.00" price = float(re.search(r"[\d.]+", price_text).group())这里有个细节:正则[\d.]+会把59.00提出来,但如果价格写成1,200.50,里面有逗号,就需要先去掉逗号。所以我会建议你先把真实页面上的所有字段格式列一遍,把规则理清楚再写代码,而不是看到一个报错改一个。
3.3 多页面抓取与礼貌限速
真实项目不可能只有一个页面。大多数网站的列表页会带分页参数,比如?page=1、?page=2。我们只需要在循环里拼接不同的页码,然后重复“请求、解析、提取、保存”的流程:
import time import random base_url = "http://localhost:8000/demo.html?page={}" for page in range(1, 6): url = base_url.format(page) resp = requests.get(url, headers=headers, timeout=10) # 解析并提取... print(f"page {page} done") time.sleep(random.uniform(1, 2))循环本身没什么难度,真正的难点是“别把对方服务器打崩”。所以当你在写循环抓取时,time.sleep不应该被省略。random.uniform(1, 2)的意思是每次随机等待 1 到 2 秒,比固定等 1 秒更接近人的行为,也更不容易触发对方的频率限制。
还有一种习惯我在做长任务时一定会用:记录进度。比如我已经抓到第 40 页,程序在 41 页崩了,如果脚本没有记录,我得从第 1 页重新跑,浪费时间不说,还会给对方服务器增加负担。最简单的做法是把已抓到的数据 ID 存到一个集合里,每次请求前先判断这个 ID 是否已经抓过,或者把当前页码写进一个状态文件,恢复运行时从状态文件继续。
4. 动态页面与 API 接口抓取实战
4.1 优先寻找 JSON 接口
遇到动态渲染页面,新手最容易犯的错误是直接上浏览器自动化。其实你先花五分钟做一件事:打开开发者工具的 Network 面板,刷新页面,筛选 XHR 或 Fetch 请求,看看数据是不是已经在某个 JSON 响应里了。
很多现代网站的结构是:后端提供一个 JSON 接口,前端用 JavaScript 把 JSON 渲染成表格或卡片。这种情况下你根本不需要浏览器,直接请求那个 JSON 接口就能拿到干净的数据,效率和稳定性都远高于解析 HTML。接口地址通常在响应里能看到,比如我见过很多接口的路径像是/api/books?page=1,返回内容是一串带字段名的 JSON。
用 requests 请求 JSON 接口,解析成本极低:
resp = requests.get("https://example.com/api/books?page=1", headers=headers, timeout=10) data = resp.json() for book in data["data"]["items"]: print(book["title"], book["price"])注意我这里的https://example.com/api/books?page=1是演示用的虚拟地址,不是真的有这个接口,实际使用时以你目标站点的请求为准。resp.json()会直接把响应文本解析成 Python 的字典或列表,接下来你要做的事情就是看懂这个嵌套结构、按 key 取数据。
有个技巧值得分享:拿到 JSON 响应后,先别急着写循环。把data渲染成可读格式看一遍,比如print(json.dumps(data, ensure_ascii=False, indent=2)),搞清楚嵌套层次,再动手写提取代码。很多人写错 key、报KeyError,基本都是因为跳过了这一步。
4.2 用 Playwright 处理动态渲染页面
如果确实找不到 JSON 接口,页面内容又必须是浏览器执行完 JavaScript 之后才能出现,这时候才轮到浏览器自动化。我推荐 Playwright,因为它安装过程比 Selenium 顺滑得多,而且内置了自动等待机制,不容易出现“元素还没加载出来就去找它”的竞态问题。
安装完库之后,需要再执行一条命令安装浏览器内核:
playwright install chromium然后是最小示例,访问我们的本地页面、等待列表元素出现、提取所有书名:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("http://localhost:8000/demo.html", timeout=15000) page.wait_for_selector("ul.book-list li.book-item") titles = page.locator(".title").all_text_contents() print(titles) browser.close()wait_for_selector是 Playwright 里我非常喜欢的一个方法,它会一直等到目标选择器出现在页面上,或者达到超时时间。没有这段等待,脚本很容易因为页面还在加载而找不到元素。headless=True代表无头模式,就是浏览器在后台运行、不弹窗口,适合跑自动化任务;调试的时候可以改成False,亲眼看它怎么操作页面,能帮你更快定位问题。
4.3 请求频率、重试与异常兜底
真实网络环境很复杂,请求失败是常态。你的脚本必须把“失败重试”和“异常兜底”写进去,否则一个超时就能让整个抓取任务中断。
一个简单的重试模板是这样:
for attempt in range(3): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: break except requests.RequestException: time.sleep(2 ** attempt) else: raise SystemExit(f"请求失败:{url}")2 ** attempt是二进制的指数退避策略:第一次失败等 2 秒,第二次失败等 4 秒,第三次等 8 秒。给服务器一个喘息时间,也能提高临时抖动后的成功率。如果三次都失败,就果断放弃,记录下来去看原因,比无限循环耗下去好得多。
关于状态码,这里有几个常见信号。403表示服务器拒绝访问,429表示你请求太频繁、被限流了,418可能是对方故意拦截脚本。遇到这些情况,先检查自己的频率是不是太快、请求头是不是完整,而不是盲目加大并发去“硬刚”。
5. 数据清洗、存储与简单分析
5.1 用 pandas 完成清洗去重
抓下来的数据一定是“脏”的:可能有重复的书名、缺失的作者名、价格字段混着货币符号和空白字符。这时候就该 pandas 上场了。
先把之前循环收集的books列表变成 DataFrame:
import pandas as pd df = pd.DataFrame(books) print(df.head())然后做三件最基本的清洗操作。第一是去重,很多页面在不同位置会重复展示同一本书,按标题去重最可靠:
df.drop_duplicates(subset=["title"], inplace=True)第二是处理缺失值。真实数据里作者字段可能为空,评分可能缺失。最简单的策略是直接删除有缺失值的行,但如果缺失占比很高,就要考虑填充:
df.dropna(inplace=True)第三是类型统一。虽然上一节已经手动转成了 float,但如果有些价格解析失败,这一列还是对象类型,需要重新强制转换并处理异常:
df["price"] = pd.to_numeric(df["price"], errors="coerce") df.dropna(subset=["price"], inplace=True)errors="coerce"的意思是转换失败时把该值变成NaN,之后你再统一处理。这一招比直接float()硬转要安全得多,因为后者遇到脏字符串会直接报错中断。
5.2 数据持久化:写 CSV 和 SQLite
清洗完之后要落盘,否则程序一关,所有工作白费。最简单的存储方式是 CSV,pandas 一行搞定:
df.to_csv("books.csv", index=False, encoding="utf-8-sig")encoding="utf-8-sig"是给 Windows 用户准备的,加了 BOM 头之后用 Excel 打开 CSV 不会乱码。如果你只是给程序自己读,用utf-8也可以。
如果数据量比较大,或者你希望后续能用 SQL 查询,我更推荐存进 SQLite。SQLite 是单文件数据库,不需要装数据库服务端,很适合个人项目。用 pandas 自带的方法可以一口气把 DataFrame 写进去,但需要先安装sqlalchemy:
from sqlalchemy import create_engine engine = create_engine("sqlite:///books.db") df.to_sql("books", engine, if_exists="replace", index=False)if_exists="replace"会先删掉旧表再新建,适合重复采集的场景。如果你更希望保留历史数据,可以换成if_exists="append"。
如果想避开 SQLAlchemy 依赖,也可以直接用标准库逐行插入,代码稍长但更透明:
import sqlite3 conn = sqlite3.connect("books.db") cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS books ( title TEXT, author TEXT, price REAL, rating REAL ) """) cur.executemany( "INSERT INTO books VALUES (?, ?, ?, ?)", [(b["title"], b["author"], b["price"], b["rating"]) for b in books] ) conn.commit() conn.close()两种方案选一种就行。我个人的判断是:如果后续还要用 DataFrame 做分析,走to_sql更省事;如果只是要一个长期存储的仓库,原生sqlite3足够了,少一层依赖也少一些潜在坑。
5.3 最小可用的统计分析
数据入库不是终点,能从中提取信息才算有价值。我们来做几个最简单的统计,体验一下“数据到手”的感觉。
# 各作者的平均书价,从高到低排 author_price = df.groupby("author")["price"].mean().sort_values(ascending=False) print(author_price) # 价格的整体描述性统计 print(df["price"].describe()) # 评分高于 8.5 的书有多少本 high_rated = df[df["rating"] >= 8.5].shape[0] print(high_rated)这几个操作在人工智能项目里非常常见。先说groupby:它是“按某列分组再聚合”的意思,相当于 SQL 里的GROUP BY,是做数据探索的基础操作。describe()一行就能输出数量、均值、标准差、最值、分位数,用来快速了解数据分布很好用。条件筛选df[df["rating"] >= 8.5]本质上是布尔索引,它帮你圈定“感兴趣的子集”。
这还只是冰山一角,但已经能说明数据采集的意义:没有数据管道,后面所有特征工程、模型训练都是空中楼阁。抓取、清洗、入库这三步打通,你手里就有了可以反复使用的数据资产。
6. 实战中的常见问题与排查技巧
6.1 网络请求类问题速查
网络请求是整条链路里最容易出问题的环节。下面这个表格是我在带项目时经常贴给新人的速查表,直接对照着排查就行:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
ConnectionError | 目标地址不可达、域名解析失败 | 检查 URL 是否正确,本地环境是否已启动 |
Timeout | 服务器响应过慢 | 调大 timeout,或换个时间段再试 |
| 状态码 403 | 服务器拒绝请求 | 补充 User-Agent、Referer,检查请求频率 |
| 状态码 429 | 请求太频繁被限流 | 降低频率,增加 sleep 时间 |
| 状态码 500 | 服务器内部错误 | 多半不是你代码的问题,过段时间再用 |
遇到ConnectionError别急着改代码,先用浏览器手动访问一次目标地址,确认你自己能正常打开。很多所谓“脚本报错”,其实是网络环境、代理设置或目标站点临时故障导致的,代码本身没毛病。
6.2 解析结果为空或字段缺失
解析结果为空,是第二个高频问题。我见过最多的情况是:select返回了空列表,然后程序在遍历时报AttributeError。遇到这种情况,第一件事是打印原始响应文本,确认请求到的到底是不是你预期的页面。有时候你请求的是移动端页面,结构跟 PC 端完全不同,选择器自然匹配不到。
还有一种情况是内容在 JavaScript 渲染后才出现。requests获取的是原始 HTML,里面可能只有一个空壳,真正的内容是后续接口填充的。这时候你就得回到第 4 节的方法:去找 JSON 接口,或者上 Playwright。
最后检查你的选择器本身。浏览器开发者工具支持直接右键复制选择器,但复制的路径往往很长很脆,页面一改就失效。我更推荐手动写短选择器,比如.title、#main .item这种,定位稳定一些。
6.3 中文乱码与编码处理
如果你发现抓下来的中文变成䏿–‡或??,那基本是编码判断出了问题。requests 会根据响应头猜测编码,但很多网站响应头不写charset,或者写得不准确,导致默认按 ISO-8859-1 解码。
处理办法很直接。先手动查看正确的编码是什么:
print(resp.encoding)如果看起来不对,就用apparent_encoding让 requests 根据内容去猜:
resp.encoding = resp.apparent_encoding然后再访问resp.text,中文基本就正常了。另一种情况是存储或输出乱码:Python 3 里字符串本身是 Unicode,乱码一般发生在打印到终端或写入文件时。建议在抓取项目里全程统一使用 UTF-8,写入文件时显式指定encoding="utf-8",这样能避开绝大多数编码坑。
6.4 内存占用过高与程序中断处理
抓取规模一大,“跑着跑着内存涨上去”是很多人忽略的问题。原因通常是你在一个列表里不断append所有详情数据,几万条记录全堆在内存里,自然占用飙升。
我更推荐“抓一批、写一批”的思路:每解析完 100 条数据,就写入一次数据库或 CSV,然后把列表清空,而不是攒到最后一次性写入。配合上一个小节提到的进度记录,就算程序中途崩掉,也只是丢失最近一批数据,而不是全部。
进度记录的方式可以很简单。抓完每一页后,把当前页码写进progress.txt,下次启动时读出来从断点继续:
with open("progress.txt", "r") as f: start_page = int(f.read().strip()) # 抓完当前页后更新进度 with open("progress.txt", "w") as f: f.write(str(current_page))别小看这个操作,它能让你在大任务里节省大量重复请求的时间,也减轻对方服务器的压力。我在本地调试的时候也会用同样的思路,因为它能帮我快速定位到底崩在了哪一页。
我在实际操作中体会最深的一点是:抓取项目里写代码的时间只占三成,剩下七成时间都花在观察页面结构、复现异常、调整选择器上。越急着写代码,越容易踩坑。先打开浏览器手动访问目标页面,看请求、看响应、看数据结构,再回到代码里把流程翻译出来,效率反而更高。
这一篇用到的工具组合已经能覆盖绝大多数静态页面、JSON 接口和部分动态页面的采集任务。下一步你可以试着往管道里加入定时调度、更复杂的字段清洗规则,甚至把采集结果直接喂给后续的机器学习流程做特征工程。那部分内容,我会在系列后面单独展开。