← 返回专栏
· 2 分钟阅读

把 2,500 次免费 SERP 额度撑出 10,000 次的效果:Python 缓存层

免费 SERP API 额度很慷慨——直到管线里每个工具都开始重复问同一个问题。本文搭一个纯标准库的缓存层:sqlite 存储、钉死市场的 key、按用途的 TTL、single-flight 合并——附真实基准数据:0.005 毫秒缓存命中对 2.0-3.1 秒 API 调用。真实命令、真实输出、真实取舍。

TL;DR

大多数 SERP API 消耗不是新问题,是不同工具在重复问同一个问题。一个约 40 行的纯标准库缓存(sqlite + sha1 key + TTL + single-flight)能把一份 Serper.dev 免费额度当几份用——缓存命中实测 0.005 毫秒,对 API 真实的 2.0-3.1 秒,零 credit。本文讲重复调用藏在哪、为什么缓存 key 必须包含 gl/hl、各用途该配什么 TTL,以及唯一不该缓存的东西:你想测的那个「新鲜度」本身。

免费额度是善意里的鱼钩。Serper.dev 每月给 2,500 次搜索,听起来巨大——直到你的管线长出第二个工具,而第二个工具开始问 API 第一个工具昨天刚问过的问题。

这篇文章讲的是「你的」和「你需要问的」之间的差距。终点是一个约 40 行、零依赖的缓存层,以及跑出来的真实数字。

重复调用藏在哪

没人故意浪费 credit。重复是结构性的:

  • 难度评分serp scraping python 的 SERP。
  • 意图分类跑同一份关键词列表,再拉一次同样的 SERP——它要的 top-10 和难度评分器刚看过的完全一样。
  • 竞品差距扫你的追踪关键词——其中很多上周刚评分过。
  • 每周排名追踪全部重查,因为追踪必须新鲜。

一个关键词、四个消费方、四笔不同日期的相同请求。乘上 500 个关键词的列表,一个月 2,000 次 API 调用里,可能只有 700 次带着你不知道的信息。

朴素的修法是手工去重关键词列表。正确的修法是让请求层成为唯一和 API 说话的东西——然后给它记忆。

缓存 key 是全部胜负手

先于一切代码:两个 SERP 请求什么时候等价?不是 query 字符串。我们直接量过——同一个关键词、同一个 API、只改市场参数,top-10 就洗牌了:

keyword: 'python seo tools'  — 各市场 (gl/hl) 下的域名位置
domain                              us    gb    de    in
adver.tools/python/seo               5     6     4     8
searchengineland.com/python-...      -     9     -     4
searchwilderness.com/free-seo-tools  8     7     7     9

美国响应不是德国响应。完整方法论见本地化排名差异测试——这里的要点是:不带 gl/hl 的缓存 key 是一个长得像性能优化的数据污染 bug。

规范化 key:

import hashlib, json

def cache_key(endpoint, q, gl, hl, num):
    raw = json.dumps(
        {"e": endpoint, "q": q, "gl": gl, "hl": hl, "n": num},
        sort_keys=True, ensure_ascii=False,
    )
    return hashlib.sha1(raw.encode()).hexdigest()

排序的 key、显式的字段、确定性的序列化。哈希是键名,JSON 是排查「缓存错了」时的日志。

完整的一层

纯标准库——直接放进 zens_ink 工具链或任何脚本:

import hashlib, json, os, sqlite3, time, urllib.request

DB = sqlite3.connect("serp_cache.db")
DB.execute("""CREATE TABLE IF NOT EXISTS serp_cache (
    k TEXT PRIMARY KEY, endpoint TEXT, q TEXT, gl TEXT, hl TEXT,
    payload TEXT, fetched_at REAL)""")

def serp(q, gl="us", hl="en", num=10, ttl=86400):
    endpoint = "search"
    key = cache_key(endpoint, q, gl, hl, num)
    row = DB.execute(
        "SELECT payload, fetched_at FROM serp_cache WHERE k=?", (key,)
    ).fetchone()

    if row and time.time() - row[1] < ttl:
        return json.loads(row[0])          # 缓存命中:零 credit、零网络

    body = json.dumps({"q": q, "gl": gl, "hl": hl, "num": num}).encode()
    req = urllib.request.Request(
        f"https://google.serper.dev/{endpoint}",
        data=body,
        headers={"X-API-KEY": os.environ["SERPER_API_KEY"],
                 "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=20) as r:
        payload = json.load(r)

    DB.execute(
        "INSERT OR REPLACE INTO serp_cache VALUES (?,?,?,?,?,?,?)",
        (key, endpoint, q, gl, hl, json.dumps(payload), time.time()),
    )
    DB.commit()
    return payload

所有工具——难度评分、意图分类竞品差距——都调 serp(),永不直连 API。这一层间接就是全部架构。

真实数字

连续三次实时调用,计时(与抓取失败日志同一会话):

call 1: 200, 2.043979s
call 2: 200, 2.342513s
call 3: 200, 3.060339s

以及 sqlite 点查,1,000 次命中的平均值:

sqlite cache hit avg: 0.005 ms

大约 2,500 毫秒 对 0.005 毫秒——约 500,000 倍——但更重要的:一条路 credits: 1,另一条零。速度是副产物,credit 预算才是重点。

TTL 是用途决策,不是常量

多旧算太旧,取决于你为什么问:

用途TTL理由
关键词发现 / 难度评分7 天SERP 结构变化慢,评分容忍
意图分类7 天top-10 的页面类型稳定
活页面内容审计24 小时对编辑决策够新
排名追踪不缓存你在测变化,缓存会取消测量

最后一行是让缓存保持诚实的纪律。排名追踪必须每次排期新鲜打 API——预算紧就砍追踪的关键词数,永远别缓存排名。追踪上游的一切尽管缓存。

Single-flight:最后一个重复杀手

还剩一个竞态:同一进程里两个工具在同一秒问同一个冷关键词。双双 miss 缓存、双双打 API。管线规模下,一个 in-flight 字典就能关掉它:

_inflight = {}

def serp_singleflight(*args, **kwargs):
    key = cache_key(kwargs.get("endpoint","search"), kwargs.get("q",""),
                    kwargs.get("gl","us"), kwargs.get("hl","en"), kwargs.get("num",10))
    if key in _inflight:
        return _inflight[key].result()   # 加入正在飞的调用
    fut = THREAD_POOL.submit(serp, *args, **kwargs)
    _inflight[key] = fut
    try:
        return fut.result()
    finally:
        _inflight.pop(key, None)

和 CDN 的请求合并一个思路:一次真实抓取,N 个调用方。

这套做法哪里不完美

  1. **缓存命中率是猜的,直到你测它。**3-5 倍是我们管线看到的数字;你的取决于列表之间重叠多少。给它埋点——存 fetched_at,数一周命中和 miss,再信任何倍数。

  2. **sqlite 是单写者。**单机单进程完美。并行 worker 那天,要么每 worker 一个库每晚合并,要么接受写竞争。别伸手够 Redis——那是为你还没有的问题上一台服务器。

  3. **TTL 是观点。**难度 SERP 用 7 天 TTL,意味着你可能对着周中已变的 SERP 打分。对评分这是噪音;对一次性的不可逆决策可能要紧。任何 feeding 独木桥决策的查询,把 TTL 降下来。

  4. **API 响应本身就是压缩过的真相。**你缓存的是 API 的归一化 SERP,不是活的 SERP——该缓存的正是这个,但记住本地化测试的结论:再新鲜的 API 响应也只是钉死市场的样本,不是普适真相。

算一笔账

具体场景,因为不带算术的倍数是营销话术:

  • 500 个追踪关键词
  • 每周排名追踪,4 周:2,000 次调用——不缓存,永远(这是规则)
  • 每月对同一批 500 个重评难度:500 次 → 7 天 TTL 下 0(每周重叠)
  • 1,500 个候选关键词的意图扫描,40% 已见过:1,500 → ~900
  • 300 个关键词的竞品差距,70% 与追踪集重叠:300 → ~90

朴素:4,300 credit(免费额度爆了)。加缓存层:2,090——还在 2,500 以内,$0。这就是「免费额度够用」和「免费额度够用到周二」的区别。

放进工作流

  1. 获取 SERP 走一个带缓存的入口——本文的 serp()
  2. 评分你抓到的东西:难度评分SERP 结构分析
  3. 发现新关键词喂机器:Autocomplete 挖掘(免费、不计额——API 预算留给 SERP)
  4. 追踪每周、新鲜、永不缓存
pip install git+https://github.com/ZensInk/zens-ink-seo-package.git
export SERPER_API_KEY="your_key"

要点

免费额度不是预算,是信息速率。多数管线把它花在重新学习昨天已经知道的东西上。一层间接——所有工具问缓存,缓存独自问 API——同一份额度就悄悄覆盖几倍的活。

FAQ

怎么让免费 SERP API 额度用得更久?

在本地按完整请求做缓存——query、gl、hl、num 加端点——存 sqlite,让所有工具都经过同一个包装函数。实操中,难度评分、意图检查、排名追踪所用的关键词列表通常有 3-5 倍重叠。配合 24 小时 TTL,一份 2,500 次/月的免费额度通常能覆盖 8,000-12,000 次的有效查询需求。

SERP 响应的缓存 key 应该包含什么?

至少:端点、query、gl、hl、num。漏掉 gl/hl 是经典 bug——同一个关键词在不同市场返回不同排名(我们用同一个 API 实测过四个市场的位置差异),一个英语-美国响应被缓存到裸 query 名下,会直接污染德语市场的查询。把规范化的 key 字符串做 sha1,同时把市场参数存在旁边便于排查。

排名追踪的 SERP 响应应该缓存吗?

不——至少追踪路径上不缓存。排名追踪的目的就是测量变化,缓存测量本身就取消了测量。上游(关键词研究、难度评分、意图分类)尽管缓存,每周的排名检查要新鲜打 API,且每次跑钉死同一套 gl/hl。预算紧就砍追踪的关键词数,永远别缓存排名。

sqlite 做 SERP 缓存够快吗?

够,快大约五个数量级。实测主键点查平均 0.005 毫秒一次命中,对 Serper.dev 真实往返的 2,000-3,000 毫秒。就算算上 JSON 反序列化,缓存路径也比网络路径快约 500,000 倍——而且不花 API credit。

想给自己的站点做同样的分析?

ZensInk Pro 把这个流程自动化了。一条命令,从种子词到内容计划。

查看 Pro →