Python爬虫实战:增量爬虫实战 - 利用 HTTP 缓存机制实现“极致减负”(附CSV导出 + SQLite持久化存储)!

㊗️本期内容已收录至专栏《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-MatchIf-Modified-Since 请求头,让服务器主动告诉我们"别下了,内容没变"。
读完你将获得:

  1. 彻底理解 HTTP 304 状态码在爬虫中的实战应用。
  2. 一套完整的 SQLite 缓存表设计方案,用于记录 URL 及其指纹。
  3. 大幅降低代理带宽成本(流量节省可达 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: 依然是第一准则。如果对方禁止抓取,增量也不行。
  • 服务器支持度: 并不是所有网站都配置了 ETagLast-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

流程说明:

  1. 查库: 爬虫启动,先从本地 SQLite 查出该 URL 上次抓取留下的 ETagLast-Modified

  2. 请求: 构造 Header,带上 If-None-Match (对应 ETag) 和 If-Modified-Since (对应 Last-Modified)。

  3. 判断:

    • 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

专家点拨:

  1. 优先级: 通常 ETag 的优先级高于 Last-Modified,因为时间可能不准(比如文件回滚),但 ETag 是基于内容 Hash 生成的。
  2. 更新: 注意看 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
🎉 成功跳过解析,节省流量!

🔟 常见问题与排错

  1. 服务器不讲武德(不支持 304

    • 现象: 你明明发了 If-None-Match,服务器还是每次都回 200 OK,且 ETag 每次都在变(有些动态服务器会生成随机 ETag)。
    • 对策: 在本地计算 Body 的 MD5 哈希值存入数据库。请求回来后(既然无法避免下载),先算 Hash,跟库里比对。如果 Hash 一样,说明内容没变,停止后续繁重的解析/入库步骤。这也叫"应用层增量*"。
  2. WAF 拦截:

    • 现象: 带了 If-NoneMatch 头反而报错 403。
    • 对策: 有些防火墙认为这是一种扫描行为。尝试去掉该头,或者增加 Headers 的丰富度(Referer, Accept 等)。
  3. 时间格式陷阱:

    • 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-LengthLast-Modified没变,就不发GET` 请求了。这比 304 机制更主动。

1️⃣2️⃣ 总结与延伸阅读

复盘:

今天我们不仅写了代码,还深入了 HTTP 协议的交互逻辑。通过ETag(实体标签)和Last-Modified(最后修改时间) ,我们成功让爬虫学会了"偷懒"。在实际工程中,这能为你节省 70%-% 的带宽成本,是迈向高级爬虫工程师的必经之路!🛣️

延伸:

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


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

相关推荐
一个处女座的程序猿O(∩_∩)O2 小时前
Python异常处理完全指南:KeyError、TypeError、ValueError深度解析
开发语言·python
was1722 小时前
使用 Python 脚本一键上传图片到兰空图床并自动复制链接
python·api上传·自建图床·一键脚本
好学且牛逼的马2 小时前
从“Oak”到“虚拟线程”:JDK 1.0到25演进全记录与核心知识点详解a
java·开发语言·python
shangjian0072 小时前
Python基础-环境安装-Anaconda配置虚拟环境
开发语言·python
codeJinger2 小时前
【Python】函数
开发语言·python
geovindu3 小时前
python: Command Pattern
开发语言·python·命令模式
曲幽3 小时前
FastAPI实战:WebSocket长连接保持与心跳机制,从入门到填坑
javascript·python·websocket·keep-alive·fastapi·heartbeat·connection
好学且牛逼的马5 小时前
从“混沌初开”到“有序统一”:Java集合框架发展历程与核心知识点详解
前端·数据库·python
a1117765 小时前
快速制作 虚拟形象项目 MotionPNGTuber
python·live2d