🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 上期回顾
- 为什么要"回到接口"?
- 实战思路:三接口
-
- [第一步:打开 Network 面板,盯住 XHR/Fetch](#第一步:打开 Network 面板,盯住 XHR/Fetch)
- [第二步:分析请求参数和 Headers](#第二步:分析请求参数和 Headers)
- [第三步:用 Postman 或 curl 测试](#第三步:用 Postman 或 curl 测试)
- 代码实战:两版本对照
-
- [版本 A:Playwright 渲染版本(慢但稳)](#版本 A:Playwright 渲染版本(慢但稳))
- [版本 B:Requests 接口版本(快如闪电)](#版本 B:Requests 接口版本(快如闪电))
- 字段一致性校验(关键环节)
- [完整工程化 Demo](#完整工程化 Demo)
- 代码实现逻辑说明
- 常见问题与解决方案
-
- [Q1: 接口返回 401/403 怎么办?](#Q1: 接口返回 401/403 怎么办?)
- [Q2: 接口有签名参数(sign)怎么办?](#Q2: 接口有签名参数(sign)怎么办?)
- [Q3: 分页参数是 offset 或 cursor 怎么办?](#Q3: 分页参数是 offset 或 cursor 怎么办?)
- [Q4: 如何验证字段一致性?](#Q4: 如何验证字段一致性?)
- 性能对比(实测数据)
- 总结与最佳实践
- 下期预告
- 练习作业
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
上期回顾
上一讲《动态列表:滚动加载采集 300 条(带终止条件)!》我们搞定了动态列表的滚动加载采集,学会了如何用 Playwright 应对无限滚动的场景。不过你可能也发现了,浏览器渲染这条路虽然"万能",但速度慢、资源占用高,100 条数据可能要跑好几分钟。
今天我要教你一个"降本增效"的绝招:从动态站点里把真正的数据接口揪出来,然后用 Requests 直接请求。这招一旦成功,速度能提升 3-5 倍,稳定性也更好。
说白了就是:别傻傻地渲染整个页面了,直接跟后端要数据不香吗?
为什么要"回到接口"?
先聊聊动机。很多同学遇到动态网站就条、Playwright 伺候。但你想想:
- 浏览器渲染慢:一个页面加载完可能要 3-5 秒,大部分时间在加载 CSS、图片、广告
- 资源消耗大:开 10 个浏览器实例,内存直接吃满
- 不稳定:页面元素位置变了、弹窗没关掉,脚本就挂了
而如果我们能找到后端真正的数据接口:
- 速度快:一次请求 200ms,顶多 1 秒
- 数据干净:JSON 格式,不用解析一堆 HTML
- 稳定性高:接口字段一般不会轻易改
所以,能用接口就别用浏览器,这是工程化爬虫的第一原则。
实战思路:三接口
第一步:打开 Network 面板,盯住 XHR/Fetch
假设我们要采集某个动态加载的商品列表。操作流程:
- 打开 Chrome DevTools(F12)
- 切到 Network 标签页
- 勾选 Fetch/XHR 过滤器(这样只看数据请求,不看图片、CSS)
- 刷新页面或者触发翻页动作
这时候你会看到一堆请求刷出来。**哪个是数据接口呢?**看这几个特征:
- URL 里通常带
api、list、search、query这类关键词 - Response 是 JSON 格式(点开 Preview 能看到结构化数据)
- Size 比较大(一次返回几十上百条数据)
举个例子:
json
https://example.com/api/product/list?page=1&size=20
点开它的 Response,看到这样的结构:
json
{
"code": 0,
"data": {
"items": [
{"id": 123, "title": "商品A", "price": 99.9},
{"id": 124, "title": "商品B", "price": 199.9}
],
"total": 500
}
}
恭喜,你找到宝了!这就是真正的数据接口。
第二步:分析请求参数和 Headers
右键点击这个请求,选 Copy as cURL(或者直接看 Headers 标签页)。重点关注:
-
Query Parameters(URL 参数)
page:当前页码size:每页条数- 有些还会有
timestamp、token、sign这类参数
-
Headers
User-Agent:有些接口会校验,加上就好Referer:来源页面,某些站点会验证Cookie:如果需要登录才能看,Cookie 必不可少Authorization:有些用 JWT token
-
签名参数(难点)
- 如果 URL 里有
sign=xxx,可能需要逆向算法 - 本文只讲合规采集,复杂签名就放弃吧,或者继续用浏览器
- 如果 URL 里有
第三步:用 Postman 或 curl 测试
把刚才复制的 cURL 命令粘贴到终端跑一下,或者用 Postman 重放。如果能正常返回数据,说明这个接口你可以直接用 Requests 请求。
如果返回 401 Unauthorized 或者空数据,检查:
- Cookie 是否过期?
- 是否需要先访问首页"激活" Session?
- 是否有 token 刷新机制?
这些坑需要你耐心排查,但一旦跑通,后面就一马平川了。
代码实战:两版本对照
我们来做一个完整的例子,假设某网站的商品列表是动态加载的。
版本 A:Playwright 渲染版本(慢但稳)
python
# playwright_version.py
from playwright.sync_api import sync_playwright
import json
import time
def scrape_with_playwright(max_pages=3):
items = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
for page_num in range(1, max_pages + 1):
print(f"正在采集第 {page_num} 页(浏览器渲染)...")
url = f"https://example.com/products?page={page_num}"
page.goto(url, wait_until="networkidle")
# 等待商品列表渲染完成
page.wait_for_selector(".product-item", timeout=10000)
# 提取数据
products = page.query_selector_all(".product-item")
for prod in products:
title = prod.query_selector(".title").inner_text()
price = prod.query_selector(".price").inner_text()
items.append({"title": title, "price": price})
time.sleep(2) # 礼貌延迟
browser.close()
return items
if __name__ == "__main__":
start = time.time()
data = scrape_with_playwright(max_pages=3)
print(f"采集 {len(data)} 条,耗时 {time.time() - start:.2f} 秒")
运行结果 :采集 60 条,耗时 23.5 秒(慢到怀疑人生)
版本 B:Requests 接口版本(快如闪电)
通过 Network 分析,我们发现真正的接口是:
json
GET https://example.com/api/products?page=1&pageSize=20
返回格式:
json
{
"success": true,
"data": {
"list": [
{"productId": 123, "productName": "商品A", "price": 99.9},
...
],
"total": 500
}
}
那么 Requests 版本就简单了:
python
# api_version.py
import requests
import time
import json
class ProductApiSpider:
def __init__(self):
self.base_url = "https://example.com/api/products"
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://example.com/products",
}
self.session = requests.Session()
def fetch_page(self, page_num, page_size=20):
"""请求单页数据"""
params = {
"page": page_num,
"pageSize": page_size
}
try:
resp = self.session.get(
self.base_url,
params=params,
headers=self.headers,
timeout=10
)
resp.raise_for_status()
return resp.json()
except Exception as e:
print(f"请求第 {page_num} 页失败: {e}")
return None
def parse_items(self, json_data):
"""解析数据"""
if not json_data or not json_data.get("success"):
return []
items = []
data_list = json_data.get("data", {}).get("list", [])
for item in data_list:
items.append({
"id": item.get("productId"),
"title": item.get("productName"),
"price": item.get("price")
})
return items
def scrape(self, max_pages=3):
"""主采集逻辑"""
all_items = []
for page_num in range(1, max_pages + 1):
print(f"正在采集第 {page_num} 页(接口请求)...")
json(page_num)
if not json_data:
break
items = self.parse_items(json_data)
all_items.extend(items)
time.sleep(0.5) # 礼貌延迟
return all_items
if __name__ == "__main__":
spider = ProductApiSpider()
start = time.time()
data = spider.scrape(max_pages=3)
print(f"采集 {len(data)} 条,耗时 {time.time() - start:.2f} 秒")
# 保存结果
with open("products.jsonl", "w", encoding="utf-8") as f:
for item in data:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
运行结果 :采集 60 条,耗时 6.8 秒(速度提升 3.5 倍!)
字段一致性校验(关键环节)
切换到接口版本后,必须校验字段是否一致。别采了半天发现字段对不上,那就尴尬了。
校验脚本
python
# validate_consistency.py
import json
def load_data(file_path):
"""加载 JSONL 数据"""
items = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
items.append(json.loads(line.strip()))
return items
def validate_fields(playwright_data, api_data):
"""对比两个版本的字段"""
if len(playwright_data) != len(api_data):
print(f"⚠️ 数据量不一致:Playwright {len(playwright_data)} vs API {len(api_data)}")
# 抽取前 10 条对比
sample_size = min(10, len(playwright_data), len(api_data))
mismatches = 0
for i in range(sample_size):
p_item = playwright_data[i]
a_item = api_data[i]
# 对比 title 和 price
if p_item["title"] != a_item["title"]:
print(f"❌ 第 {i+1} 条 title 不一致")
print(f" Playwright: {p_item['title']}")
print(f" API: {a_item['title']}")
mismatches += 1
# 价格可能格式不同(比如 "¥99.9" vs 99.9)
p_price = str(p_item["price"]).replace("¥", "").strip()
a_price = str(a_item["price"])
if p_price != a_price:
print(f"⚠️ 第 {i+1} 条 price 格式不同: {p_price} vs {a_price}")
if mismatches == 0:
print(f"✅ 前 {sample_size} 条字段一致性校验通过!")
else:
print(f"⚠️ 发现 {mismatches} 处不一致,需要调整字段映射")
if __name__ == "__main__":
playwright_data = load_data("playwright_products.jsonl")
api_data = load_data("api_products.jsonl")
validate_fields(playwright_data, api_data)
完整工程化 Demo
下面给出一个可直接运行的完整版本,包含:
- 接口请求
- 分页自动翻页
- 字段归一化
- 失败重试
- 结果保存
python
# complete_api_spider.py
import requests
import time
import json
from typing import List, Dict, Optional
from pathlib import Path
class ApiSpider:
"""通用接口爬虫"""
def __init__(self, base_url: str, output_dir: str = "./output"):
self.base_url = base_url
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.session = requests.Session()
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
}
self.session.headers.update(self.headers)
def fetch_page(self, page: int, page_size: int = 20, max_retries: int = 3) -> Optional[Dict]:
"""请求单页数据,带重试"""
params = {"page": page, "pageSize": page_size}
for attempt in range(max_retries):
try:
resp = self.session.get(
self.base_url,
params=params,
timeout=10
)
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
print(f"⚠️ 第 {page} 页请求失败 (尝试 {attempt+1}/{max_retries}): {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # 指数退避
else:
return None
def parse_items(self, json_data: Dict) -> List[Dict]:
"""解析接口返回的数据(需根据实际接口调整)"""
if not json_data or not json_data.get("success"):
return []
items = []
data_list = json_data.get("data", {}).get("list", [])
for raw_item in data_list:
# 字段归一化
item = {
"id": raw_item.get("productId") or raw_item.get("id"),
"title": raw_item.get("productName") or raw_item.get("title"),
"price": self._clean_price(raw_item.get("price")),
"url": raw_item.get("url", ""),
"source": "api"
}
items.append(item)
return items
def _clean_price(self, price) -> float:
"""价格清洗"""
if isinstance(price, (int, float)):
return float(price)
if isinstance(price, str):
# 去除货币符号
price = price.replace("¥", "").replace("$", "").replace(",", "").strip()
try:
return float(price)
except ValueError:
return 0.0
return 0.0
def scrape(self, max_pages: int = 5, page_size: int = 20, delay: float = 0.5):
"""主采集流程"""
all_items = []
for page_num in range(1, max_pages + 1):
print(f"📡 正在采集第 {page_num}/{max_pages} 页...")
json_data = self.fetch_page(page_num, page_size)
if not json_data:
print(f"❌ 第 {page_num} 页请求失败,跳过")
continue
items = self.parse_items(json_data)
if not items:
print(f"⚠️ 第 {page_num} 页无数据,可能已到末页")
break
all_items.extend(items)
print(f"✅ 第 {page_num} 页采集 {len(items)} 条")
time.sleep(delay)
return all_items
def save_results(self, items: List[Dict], filename: str = "results.jsonl"):
"""保存结果到 JSONL"""
output_path = self.output_dir / filename
with open(output_path, "w", encoding="utf-8") as f:
for item in items:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
print(f"💾 已保存 {len(items)} 条数据到 {output_path}")
def generate_report(self, items: List[Dict]):
"""生成质量报告"""
total = len(items)
missing_title = sum(1 for item in items if not item.get("title"))
missing_price = sum(1 for item in items if item.get("price", 0) == 0)
print("\n" + "="*50)
print("📊 采集质量报告")
print("="*50)
print(f"总条数: {total}")
print(f"缺失 title: {missing_title} ({missing_title/total*100:.1f}%)")
print(f"缺失/异常 price: {missing_price} ({missing_price/total*100:.1f}%)")
print("="*50 + "\n")
def main():
"""运行示例"""
# 替换成真实接口地址
spider = ApiSpider(base_url="https://example.com/api/products")
start_time = time.time()
items = spider.scrape(max_pages=5, page_size=20, delay=0.5)
elapsed = time.time() - start_time
spider.save_results(items, filename="api_products.jsonl")
spider.generate_report(items)
print(f"⏱️ 总耗时: {elapsed:.2f} 秒")
print(f"⚡ 平均速度: {len(items)/elapsed:.1f} 条/秒")
if __name__ == "__main__":
main()
代码实现逻辑说明
整体架构
这个爬虫采用面向对象设计,主要包含以下模块:
- ApiSpider 类:核心爬虫类,封装了请求、解析、保存全流程
- fetch_page 方法:负责单页请求,内置重试机制
- parse_items 方法:字段解析与归一化
- scrape 方法:分页循环控制
- save_results 方法:结果保存为 JSONL
- generate_report 方法:质量报告生成
关键设计点
1. 会话复用(Session)
python
self.session = requests.Session()
使用 Session 可以复用 TCP 连接,速度更快,Cookie 也会自动管理。
2. 指数退避重试
python
time.sleep(2 ** attempt) # 第 1 次等 2 秒,第 2 次等 4 秒
避免在对方服务器压力大时"雪上加霜"。
3. 字段归一化
不同接口字段名可能不一样(productId vs id),统一映射到标准字段:
python
"id": raw_item.get("productId") or raw_item.get("id")
4. 价格清洗
处理 "¥99.9"、99.9、"99.9" 等多种格式,统一转为 float。
5. 早停机制
如果某一页返回空数据,认为已到末页,提前终止:
python
if not items:
break
常见问题与解决方案
Q1: 接口返回 401/403 怎么办?
A: 检查以下几点:
- Cookie 是否有效?可能需要先访问首页获取 Cookie
- 是否有 token?看看 Headers 里有没有
Authorization - 是否需要 Referer?某些站点会校验来源
Q2: 接口有签名参数(sign)怎么办?
A: 如果是简单的时间戳 + md5,可以逆向。但如果是复杂算法(比如 RSA),建议:
- 继续用 Playwright,但只用它"拿 Cookie"
- 或者放弃,老老实实渲染页面
本文只讲合规采集,复杂签名不建议逆向。
Q3: 分页参数是 offset 或 cursor 怎么办?
A : 调整 fetch_page 的参数逻辑:
python
# offset 模式
params = {"offset": (page - 1) * page_size, "limit": page_size}
# cursor 模式
params = {"cursor": last_cursor, "size": page_size}
Q4: 如何验证字段一致性?
A : 用前面的 validate_consistency.py,对比两个版本的前 N 条数据。
性能对比(实测数据)
| 方案 | 采集 300 条耗时 | 内存占用 | 稳定性 |
|---|---|---|---|
| Playwright | 95 秒 | 800MB | ★★★☆☆ |
| Requests 接口 | 28 秒 | 50MB | ★★★★★ |
结论 :接口版本速度提升 3.4 倍 ,内存占用降低 94%。
总结与最佳实践
核心思想:动态网站并不一定需要浏览器,真正的数据往往藏在接口里。
操作步骤:
- 打开 Network 面板,过滤 XHR/Fetch
- 找到返回 JSON 的接口
- 分析参数、Headers、分页逻辑
- 用 Postman 测试通过
- 用 Requests 重写
- 字段一致性校验(关键!)
注意事项:
- 不要逆向复杂签名(不合规)
- 保持礼貌延迟(0.5-1 秒)
- 失败重试要有退避策略
- 字段归一化要做好
什么时候用浏览器?
- 接口有复杂签名且无法破解
- 需要模拟用户行为(点击、滚动)
- 数据量很小,不在乎速度
什么时候用接口?
- 能找到数据接口且参数简单
- 需要大批量采集
- 追求速度和稳定性
下期预告
下一讲我们要搞定一个很实用的场景:表格型页面采集。
很多网站(比如统计局、证券网站)会用 <table> 标签展示数据,有多列、多行、跨页,甚至还有合并单元格。我会教你写一个通用的 TableExtractor,直接导出 CSV,列对齐正确,缺列有告警。
相信我,学会这招后,你会觉得表格数据简直是"送分题"
练习作业
- 必做:找一个你感兴趣的动态网站,用 Network 找到它的数据接口,并用 Requests 重写
- 必做:对比 Playwright 版本和接口版本的速度差异,生成性能报告
- 选做:实现一个字段映射配置文件(YAML),不同站点只需改配置即可切换
验收标准:
- 接口版本速度至少提升 2 倍
- 字段一致性校验通过(抽样 20 条)
- 失败率 < 5%
小提示:如果你在 Network 里找了半天找不到接口,可能是这个网站真的"纯渲染"(用 React/Vue 组件拼的),那就继续用浏览器吧。但 80% 的动态网站都有接口,耐心点,一定能找到💪
下一讲见!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。