㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:
-
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
- [5️⃣ 环境准备与依赖安装](#5️⃣ 环境准备与依赖安装)
- [5️⃣ 环境准备与依赖安装](#5️⃣ 环境准备与依赖安装)
- [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
- [7️⃣ 核心实现:数据库层(Cache Storage)](#7️⃣ 核心实现:数据库层(Cache Storage))
- [8️⃣ 解析层(Parser - 简化版)](#8️⃣ 解析层(Parser - 简化版))
- [9️⃣ 运行方式与结果展示(必写)](#9️⃣ 运行方式与结果展示(必写))
- [🔟 常见问题与排错](#🔟 常见问题与排错)
- [1️⃣1️⃣ 进阶优化(可选但加分)](#1️⃣1️⃣ 进阶优化(可选但加分))
- [1️⃣2️⃣ 总结与延伸阅读](#1️⃣2️⃣ 总结与延伸阅读)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
-
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
1️⃣ 摘要(Abstract)
标题:拒绝做无用功:利用 ETag 与 Last-Modified 构建 HTTP 304 增量爬虫系统
摘要:
本文将深入 HTTP 协议底层,使用 Python 构建一个能够"记忆"网页状态的智能爬虫。通过管理本地指纹库(SQLite),自动发送 If-None-Match 和 If-Modified-Since 请求头,让服务器主动告诉我们"别下了,内容没变"。
读完你将获得:
- 彻底理解 HTTP 304 状态码在爬虫中的实战应用。
- 一套完整的 SQLite 缓存表设计方案,用于记录 URL 及其指纹。
- 大幅降低代理带宽成本(流量节省可达 90% 以上)的生产级代码实现。
2️⃣ 背景与需求(Why)
Image of bandwidth usage comparison chart: Full Crawl vs Incremental Crawl
为什么要搞增量?
- 省钱(Cost Saving): 高质量的住宅代理是按流量计费的($/GB)。如果一个 500KB 的 HTML 页面没变,你却每天重复抓 100 次,那就是在烧钱。
- 提速(Speed): 下载一个完整的 HTML body 可能需要 200ms-1s,但接收一个仅含 Header 的
304 Not Modified响应只需要 20-50ms。 - 友好(Politeness): 减少对方服务器压力,降低被 WAF(防火墙)判定为攻击的概率。
目标场景:
- 新闻资讯列表页(监控是否有新文章)。
- 博客/CMS 站点(文章经常微调修正)。
- 电商详情页(监控价格变动,但要在页面结构未大改的前提下)。
3️⃣ 合规与注意事项(必写)
- Robots.txt: 依然是第一准则。如果对方禁止抓取,增量也不行。
- 服务器支持度: 并不是所有网站都配置了
ETag或Last-Modified。如果服务器不支持(总是返回 200),我们需要有"备用方案"(如计算内容 Hash)。 - 指纹隐私: 我们只存储 URL 和校验特征值,不存储页面内含的敏感用户数据。
- 频率控制: 即使是 304 请求,依然是一次 HTTP 交互。请保持合理的
Request Interval(请求间隔),不要因为流量小就发起 DDoS 级别的并发。
4️⃣ 技术选型与整体流程(What/How)
架构设计:
Image of Incremental Crawler Workflow: Request -\> Check DB -\> Send Conditional Headers -\> Server Response (200/304) -\> Action
流程说明:
-
查库: 爬虫启动,先从本地 SQLite 查出该 URL 上次抓取留下的
ETag和Last-Modified。 -
请求: 构造 Header,带上
If-None-Match(对应 ETag) 和If-Modified-Since(对应 Last-Modified)。 -
判断:
- 304 Not Modified: 既然没变,直接跳过解析,更新最后访问时间。(省流核心)
- 200 OK: 内容变了(或第一次抓),下载 Body,解析数据,并将新的
ETag/Last-Modified存回数据库。
技术栈:
requests:底层 HTTP 控制最灵活。sqlite3:Python 自带,轻量级,足够存几十万条指纹数据。email.utils:解析 HTTP 里的那些奇怪的时间格式(如 RFC 1123)。
5️⃣ 环境准备与依赖安装
我们这次主要用 Python 原生23)。
5️⃣ 环境准备与依赖安装
我们这次主要用 Python 原生库,为了处理更方便,可以选装 loguru 做日志。
bash
# 核心库都是内置的,只需安装日志库
pip install loguru requests
项目目录结构:
text
incremental_spider/
├── db/
│ └── cache.db # 我们的指纹数据库
├── core/
│ ├── fetcher.py # 请求发送器(核心)
│ └── storage.py # 数据库操作封装
├── main.py # 启动入口
└── output_data.csv # 最终爬取的新数据
6️⃣ 核心实现:请求层(Fetcher)
这是全篇最关键的部分。我们需要封装一个能够处理"条件请求"的 Fetcher。
代码详解:
python
import requests
from loguru import logger
class IncrementalFetcher:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
})
def fetch(self, url, last_etag=None, last_modified=None):
"""
发送条件请求
:param last_etag: 上次存的 ETag
:param last_modified: 上次存的 Last-Modified
"""
headers = {}
# --- 核心逻辑解析 A ---
# 如果数据库里有 ETag,告诉服务器:"如果指纹还是这个,就别给我数据"
if last_etag:
headers['If-None-Match'] = last_etag
# 如果数据库里有时间,告诉服务器:"如果这个时间之后没改过,就别给我数据"
if last_modified:
headers['If-Modified-Since'] = last_modified
try:
# 发送请求时合并 headers
response = self.session.get(url, headers=headers, timeout=10)
# --- 核心逻辑解析 B ---
# 状态码 304:服务器说"没变"。此时 response.text 是空的,不需要解析。
if response.status_code == 304:
logger.info(f"♻️ [304] 页面未修改,跳过: {url}")
return {
'status': 304,
'data': None,
'new_etag': last_etag, # 保持旧值
'new_modified': last_modified # 保持旧值
}
# 状态码 200:页面变了,或者第一次抓。
elif response.status_code == 200:
logger.success(f"⬇️ [200] 发现新内容,下载中: {url}")
return {
'status': 200,
'data': response.text,
# 获取新的指纹,用于下次对比
'new_etag': response.headers.get('ETag'),
'new_modified': response.headers.get('Last-Modified')
}
else:
logger.warning(f"⚠️ 异常状态码 {response.status_code}: {url}")
return None
except Exception as e:
logger.error(f"❌ 请求错误: {e}")
return None
专家点拨:
- 优先级: 通常
ETag的优先级高于Last-Modified,因为时间可能不准(比如文件回滚),但 ETag 是基于内容 Hash 生成的。 - 更新: 注意看
status_code == 200时,我们必须从 `response.headers 里拿新的值,准备存库。
7️⃣ 核心实现:数据库层(Cache Storage)
我们需要一个地方来"记住"每个 URL 的状态。SQLite 是最完美的选择。
python
import sqlite3
import os
class CacheManager:
def __init__(self, db_path="db/cache.db"):
# 确保目录存在
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self.conn = sqlite3.connect(db_path)
self.cursor = self.conn.cursor()
self._init_table()
def _init_table(self):
"""初始化缓存表"""
# url 是主键,避免重复
# etag 和 last_modified 可以为空
sql = """
CREATE TABLE IF NOT EXISTS page_cache (
url TEXT PRIMARY KEY,
etag TEXT,
last_modified TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
self.cursor.execute(sql)
self.conn.commit()
def get_cache(self, url):
"""查询 URL 的历史指纹"""
self.cursor.execute("SELECT etag, last_modified FROM page_cache WHERE url=?", (url,))
row = self.cursor.fetchone()
if row:
return row[0], row[1] # (etag, last_modified)
return None, None
def update_cache(self, url, etag, last_modified):
"""更新指纹(使用 REPLACE 语法,存在则更新,不存在则插入)"""
self.cursor.execute("""
REPLACE INTO page_cache (url, etag, last_modified, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
""", (url, etag, last_modified))
self.conn.commit()
def close(self):
self.conn.close()
8️⃣ 解析层(Parser - 简化版)
为了演示,我们假设抓取的是文章标题。
python
from parsel import Selector
def parse_html(html):
"""
简单的解析函数
"""
if not html:
return None
sel = Selector(text=html)
title = sel.css('title::text').get()
# 还可以提取正文、时间等...
return {
'title': title or "No Title",
'length': len(html)
}
9️⃣ 运行方式与结果展示(必写)
将上述模块组装到 main.py 中。
python
from core.fetcher import IncrementalFetcher
from core.storage import CacheManager
from core.parser import parse_html # 假设单独放了文件,这里直接用上面的函数
import time
def main():
# 目标 URL 列表(找几个支持缓存头的知名站点测试,如 GitHub API 或 维基百科)
urls = [
"https://www.python.org/", # Python 官网通常支持 Last-Modified
"https://httpbin.org/etag/test_etag_123", # 这是一个专门测试 ETag 的工具站
"https://github.com/timeline" # GitHub API 支持很好的缓存控制
]
fetcher = IncrementalFetcher()
cache = CacheManager()
print("--- 🚀 第 1 轮运行 (初次抓取) ---")
for url in urls:
# 1. 读缓存(第一次肯定是 None)
etag, lm = cache.get_cache(url)
# 2. 发起请求
result = fetcher.fetch(url, etag, lm)
# 3. 处理结果
if result and result['status'] == 200:
# 解析并保存数据(业务逻辑)
data = parse_html(result['data'])
print(f"✅ 保存数据: {data['title']}")
# 更新缓存(关键步骤!)
cache.update_cache(url, result['new_etag'], result['new_modified'])
print("\n--- ⏳ 休眠 3 秒模拟下一次轮询 ---\n")
time.sleep(3)
print("--- 🚀 第 2 轮运行 (增量抓取) ---")
for url in urls:
# 1. 读缓存(这次应该有值了)
etag, lm = cache.get_cache(url)
# 2. 发起请求
result = fetcher.fetch(url, etag, lm)
# 3. 处理结果
if result and result['status'] == 304:
print("🎉 成功跳过解析,节省流量!")
elif result and result['status'] == 200:
print("⚠️ 页面竟然变了?重新下载。")
cache.close()
if __name__ == "__main__":
main()
运行结果示例(Console Output):
text
--- 🚀 第 1 轮运行 (初次抓取) ---
⬇️ [200] 发现新内容,下载中: https://www.python.org/
✅ 保存数据: Welcome to Python.org
⬇️ [200] 发现新内容,下载中: https://httpbin.org/etag/test_etag_123
✅ 保存数据: No Title
--- ⏳ 休眠 3 秒模拟下一次轮询 ---
--- 🚀 第 2 轮运行 (增量抓取) ---
♻️ [304] 页面未修改,跳过: https://www.python.org/
🎉 成功跳过解析,节省流量!
♻️ [304] 页面未修改,跳过: https://httpbin.org/etag/test_etag_123
🎉 成功跳过解析,节省流量!
🔟 常见问题与排错
-
服务器不讲武德(不支持 304
- 现象: 你明明发了
If-None-Match,服务器还是每次都回200 OK,且ETag每次都在变(有些动态服务器会生成随机 ETag)。 - 对策: 在本地计算
Body的 MD5 哈希值存入数据库。请求回来后(既然无法避免下载),先算 Hash,跟库里比对。如果 Hash 一样,说明内容没变,停止后续繁重的解析/入库步骤。这也叫"应用层增量*"。
- 现象: 你明明发了
-
WAF 拦截:
- 现象: 带了
If-NoneMatch头反而报错 403。 - 对策: 有些防火墙认为这是一种扫描行为。尝试去掉该头,或者增加 Headers 的丰富度(Referer, Accept 等)。
- 现象: 带了
-
时间格式陷阱:
Last-Modified的格式极其严格(如Wed, 21 Oct 2015 07:28:00 GMT)。如果你存数据库时把它转成了2015-10-21,再发回去时服务器可能不认。- 建议: 数据库直接存服务器返回的原始字符串,不要自作聪明去转换格式。
1️⃣1️⃣ 进阶优化(可选但加分)
-
分布式缓存(Redis):
如果是多台机器跑爬虫,SQLite 就不行了。换成 Redis,使用
SET url_hash etag EX 86400。 -
Scrapy 中间件化:
Scrapy 其实自带
HttpCacheMiddleware,但它主要用于调试(把响应存文件)。建议自己写一个 Middleware,在process_request里查 Redis 注入 Header,在process_response里根据 304 抛出IgnoreRequest异常。 -
HEAD 请求预检:
对于超大文件(如 PDF/视频),可以先发
HEAD 请求。HEAD 只拿头部不拿 Body。如果Content-Length和Last-Modified没变,就不发GET` 请求了。这比 304 机制更主动。
1️⃣2️⃣ 总结与延伸阅读
复盘:
今天我们不仅写了代码,还深入了 HTTP 协议的交互逻辑。通过ETag(实体标签)和Last-Modified(最后修改时间) ,我们成功让爬虫学会了"偷懒"。在实际工程中,这能为你节省 70%-% 的带宽成本,是迈向高级爬虫工程师的必经之路!🛣️
延伸:
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!