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()
三个经验:
url做主键(归一化后的),重复抓自动覆盖,不会插两份。- 如果要"内容没变就不更新",加一个
content_hash字段,先比哈希再决定是否写。 - 开
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 和唯一键,重跑不产生重复。
- 失败告警:连续失败要能通知你,而不是默默跑空。
- 随机化启动时间:别所有任务都在整点同时开跑。
监控:怎么知道它坏了
长期爬虫有两种"静默失败",必须监控:
- 选择器失效:页面改版后,抓到的条数骤降甚至为 0——程序却"成功"了。
- 被拦截: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,示例均使用公开练习站点。