Python 爬虫(六):数据落地、Scrapy 取舍与长期运维

Posted on Sun 18 October 2026 in 技术 • Sucre Liu

先想清楚:抓来干什么

存储方案不是技术偏好问题,而是由用途倒推的:

  • 一次性分析 → JSONL 就够了,简单、可追加、pandas 直接读。
  • 要去重、要增量、要查询 → SQLite。
  • 多人并发写、要对外服务 → PostgreSQL。
  • 要保留原始 HTML / 附件 → 文件系统或对象存储,数据库只存路径和元数据。

新手最常见的过度设计,是"抓几个页面就上 Postgres + 消息队列"。从 JSONL/SQLite 起步,不够了再迁。

JSONL:最简单也最被低估

每行一条 JSON,天然适合追加、流式处理、出错只影响一行:

import json

def save_jsonl(path: str, item: dict):
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(item, ensure_ascii=False) + "\n")

改成 CSV 通常是错的——嵌套字段(标签、作者)在 CSV 里很难表达,而且编码/逗号转义问题一堆。除非要拿去给人用 Excel 打开,否则 JSONL 更省心。

SQLite:单文件里的"增量与去重"

要"抓到就没重复""重跑不会插两份",就得有个唯一键。SQLite 的 ON CONFLICT(upsert)正好干这个:

import sqlite3
from datetime import datetime

conn = sqlite3.connect("data.db")
conn.execute("PRAGMA journal_mode=WAL")     # 允许读写并发,减少锁

conn.execute("""
CREATE TABLE IF NOT EXISTS books (
    url        TEXT PRIMARY KEY,            -- 唯一键:天然去重
    title      TEXT,
    price      TEXT,
    rating     TEXT,
    fetched_at TEXT
)
""")

def upsert(conn, item: dict):
    conn.execute("""
        INSERT INTO books (url, title, price, rating, fetched_at)
        VALUES (:url, :title, :price, :rating, :fetched_at)
        ON CONFLICT(url) DO UPDATE SET
            title      = excluded.title,
            price      = excluded.price,
            rating     = excluded.rating,
            fetched_at = excluded.fetched_at
    """, {**item, "fetched_at": datetime.utcnow().isoformat()})
    conn.commit()

三个经验:

  1. url 做主键(归一化后的),重复抓自动覆盖,不会插两份。
  2. 如果要"内容没变就不更新",加一个 content_hash 字段,先比哈希再决定是否写。
  3. 开 WAL 模式,写入时读不会被阻塞,适合"一边抓一边查"。

原始数据与结构化数据分开存

一个容易被忽略的设计:把"原始响应"和"解析后的字段"分开。 解析规则一定会变;如果只存解析结果,改一次选择器就得重新抓全站。存了原始 HTML,就能离线重新解析,不用再打扰目标站。

data/
├── raw/2026-10-18/books-page-1.html      # 原始 HTML,按日期归档
└── data.db                                # 结构化结果

这样选择器坏了,你只要重跑解析,而不是重抓。

Scrapy:什么时候值得上

前五篇都是"手写请求 + 解析"。当规模到一定程度,你会开始重复实现:调度、并发、重试、去重、限流、pipeline、导出。这些 Scrapy 都内置了。

一个最小 Spider(基于 Scrapy 2.19):

import scrapy

class BooksSpider(scrapy.Spider):
    name = "books"
    start_urls = ["https://books.toscrape.com/"]

    def parse(self, response):
        for card in response.css("article.product_pod"):
            yield {
                "title": card.css("h3 a::attr(title)").get(),
                "price": card.css("p.price_color::text").get(),
                "url": response.urljoin(card.css("h3 a::attr(href)").get()),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, self.parse)
scrapy runspider books_spider.py -o books.jsonl

注意 -o 是追加,-O 是覆盖——用错会把上次的结果清掉。

Scrapy 的取舍:

用它 别用它
大规模、多页面、要调度与管道 抓几个页面的小脚本
需要中间件、去重、限流、导出 只打官方 JSON API(httpx 反而更直接)
团队协作、要长期维护 只是临时验证一下想法

还有一点:Scrapy 是 Twisted 异步模型,和 asyncio 混用有坑;抓 JS 渲染页要额外装 scrapy-playwright。如果你已经有一套 httpx 的异步代码,不一定非要换。

调度:让它在该跑的时候跑

别用 while True: sleep(...)。用系统调度:

# crontab:每天 3 点跑,flock 防止上次没跑完又启动
0 3 * * * /usr/bin/flock -n /tmp/spider.lock \
  /home/me/venv/bin/python /home/me/spider/main.py >> /home/me/spider/log.txt 2>&1

或用 systemd timer(更适合服务化、能看状态)。要点:

  • 加锁防重入(flock),否则任务堆积会瞬间把目标站打爆。
  • 幂等:靠 upsert 和唯一键,重跑不产生重复。
  • 失败告警:连续失败要能通知你,而不是默默跑空。
  • 随机化启动时间:别所有任务都在整点同时开跑。

监控:怎么知道它坏了

长期爬虫有两种"静默失败",必须监控:

  1. 选择器失效:页面改版后,抓到的条数骤降甚至为 0——程序却"成功"了。
  2. 被拦截:403/429 比例飙升,或大量响应是验证页。

最简单的监控是对"每轮抓取条数"设阈值:条数突然掉到历史均值的 20% 以下就告警。这比监控 HTTP 状态码更能提前发现问题。

import statistics
counts = load_recent_counts()          # 最近几轮的成功条数
if counts and len(counts) >= 3 and latest < 0.2 * statistics.median(counts):
    alert(f"抓取条数异常: {latest} vs 中位数 {statistics.median(counts)}")

一套最小可用的运维方案

如果你不想上太重的东西,这套够用很久:

  • 抓取:httpx/requests + 选择器常量表;
  • 存储:SQLite(WAL)+ raw/ 归档原始 HTML;
  • 调度:cron + flock;
  • 日志:logging 输出到文件,记录每轮条数与失败数;
  • 监控:条数阈值告警;
  • 重解析:选择器变更时,从 raw/ 离线重跑,不重抓。

等它真的不够用了(并发写入冲突、数据量上百 GB、多人协作),再迁移到 Postgres + Scrapy 或任务队列。不要在第一天就上最终形态。

最后,回到边界

技术收尾,规矩不能丢。长期爬虫尤其要记住:

  • 尊重 robots 与服务条款;频率克制是长期能跑的前提;
  • 不绕过验证码、不破解登录;能走官方 API 就走 API;
  • 个人数据受法律保护,"公开"不等于"可批量收集";
  • 抓到别人的内容,再发布是另一回事,注意版权。

系列收束

这六篇下来,一条主线其实很清楚:

爬虫的难点从不在"发请求",而在 HTTP 认知、解析的抗变化、会话与动态页的取舍、工程化的失败处理,以及合规边界。

把这几样做好,你写出来的就不是"能抓一次的脚本",而是"能跑一年的系统"。而其中最重要的一条,也许是最不起眼的:慢一点,守规矩,才能跑得久。


本系列基于 requests 2.33 / httpx 0.28 / BeautifulSoup 4.14 / lxml 6.1 / Playwright 1.61 / Scrapy 2.19 / Python 3.12,示例均使用公开练习站点。