收录诊断别再一篇篇点:用 URL Inspection API 搭一条批量核查流水线
适用读者:手里有几千到几万 URL 的课程站、内容站运维与后端开发;天天在 Google Search Console 界面里点「检查网址」点到怀疑人生的 SEO 执行者;想把收录核查做成定时任务的工程同学。
上个月我们课程站的付费课详情页涨到 21000 多条 URL,运营丢过来一句话:新上的 300 门课怎么谷歌里搜不到?我打开 Google Search Console(谷歌搜索控制台,下文简称 GSC),在网址检查里一条条贴 URL——点到第 40 条的时候手已经废了,而且这 40 条里只有 3 条真正有问题。这篇文章就是把那次排查沉淀成的东西:用 GSC 的网址检查 API(URL Inspection API)把整件事变成一条 Python 流水线,跑一次二十多分钟,全站收录状态落库出报表。
先搞清楚 API 能给你什么、不能给你什么
URL Inspection API 是 GSC 界面里「检查网址」功能的程序化版本,端点是 https://searchconsole.googleapis.com/v1/urlInspection/index:inspect。它返回的是 Google 索引里这条 URL 的实时状态,核心字段三个:

verdict:总判定,PASS/FAIL/NEUTRAL/PARTIALindexingResult:索引结果明细,比如PASSING、NEUTRAL_CANONICAL、SOFT_SLICE_BLOCKED(实际返回值以文档为准,别硬编码枚举,我吃过亏)crawlState:抓取状态,SUCCESS、BLOCKED_ROBOTS_TXT、NOT_FOUND这类
注意它的能力边界:这个 API 只反映 Google,对百度、Bing 都不适用。Bing 有自己的 IndexNow 体系,百度目前没有等价接口,所以这条流水线只覆盖 Google 侧的网站优化场景。另外它查的是「单个 URL 的当前状态」,不会告诉你排名——排名是排序环节的事,收录诊断只管抓取(Crawl)→ 索引(Index)这条链路的前半段。
配额是最先要搞清楚的:URL Inspection API 每天默认 2000 次查询、每分钟上限 600 QPS(每秒查询数,Queries Per Second)。换算成执行参数:单次请求实测约 0.4 秒(含网络往返),日配额 2000 次意味着一次最多核查 2000 条,21000 条全站要分 11 个批次、5-6 天轮完一轮。所以流水线必须支持按批次跑,别指望一天核查全站。
机制剖析:verdict 的 PASS 为什么不等于已索引
GSC 的判定是两层结构:外层 verdict 描述「这条 URL 能不能正常访问、有没有被robots 或 noindex 拦住」,内层 indexingState 才描述「这条 URL 本身是否进了索引」。当一个页面 canonical 指向另一条 URL 且 Google 采纳了它,外层判定是正常通过,内层却是 NEUTRAL_CANONICAL——Google 认为页面状态健康,只是索引给了规范版本。逐条点界面的老手通常靠肉眼读第二层,脚本不读第二层就会漏。
字段怎么读:别只看 verdict
第一版脚本我只判断 verdict == "PASS",结果报表出来全站 96% 通过,看起来皆大欢喜,运营却反馈好几门课确实搜不到。复盘才发现问题出在 canonical(规范链接)语义上:大量课程页 verdict 是 PASS,但 indexingResult 是 NEUTRAL_CANONICAL——意思是「Google 选了另一个页面作为规范版本」,这条 URL 本身并没有进索引。verdict 的 PASS 有时指的是「页面状态正常」,不等于「这条 URL 被索引了」。
后来我把判定逻辑改成三段:先看 indexStatus.verdict,再看 indexStatus.indexingState(文档里对应索引状态明细),最后看 coverageState/crawlState 组合定位原因。改造后同一批 21000 条 URL 的真实分布是这样的:
| 判定组合 | URL 数量 | 占比 | 含义 |
|---|---|---|---|
| 索引正常(Indexed, not crawled 里成功入库) | 15876 | 73.9% | 已进索引 |
| Crawled - currently not indexed | 2436 | 11.3% | 抓了但没收 |
| Discovered - not crawled | 1680 | 7.8% | 发现了没抓 |
| Alternate page with proper canonical tag | 1104 | 5.1% | 被规范链接合并 |
| Page with redirect / NotFound 等 | 404 | 1.9% | 重定向与 404 |
「Crawled - currently not indexed」这一桶最磨人:内容质量判定不够,Google 抓了但不给索引。课程站常见诱因是课程详情页模板化严重——同一套课程介绍模板换课程名,差异化内容不到 30%。分桶本身也可以画成一张判定决策图,脚本里就是按这个顺序做 if-else:
flowchart TD
A[单条 URL 的 inspectionResult] --> B{coverageState 判定}
B -->|含 Indexed| C[桶0 索引正常 不出工单]
B -->|含 Crawled / not indexed| D[桶1 内容质量不足]
B -->|含 Discovered| E[桶2 内链与发现不足]
B -->|含 Alternate page| F[桶3 canonical 合并 核对指向]
B -->|含 Redirect 或 NotFound| G[桶4 链接资产待清理]
B -->|API_ERROR| H[下一轮补查队列]
流水线设计:令牌桶 + 重试 + SQLite 落库
整体架构一张图说清:
flowchart LR
A[sitemap.xml 解析出 URL 清单] --> B[令牌桶限速队列]
B --> C[并发核查 Worker]
C --> D{verdict 判定}
D -->|异常| E[指数退避重试 最多 3 次]
E --> C
D -->|正常| F[(SQLite 结果库)]
F --> G[分桶报表生成器]
F --> H[与上次结果对比 回捞监控]
依赖环境:Python 3.10+,requests、google-auth(拿服务账号凭据)、SQLite 标准库不用装。GSC API 要求服务账号必须在 Search Console 里被添加为站点用户,权限至少「受限」级别,这一步漏了会拿到 403。
限速用令牌桶(Token Bucket),别用简单的 time.sleep 轮询——600 QPS 的分钟级上限意味着瞬时突发没问题,但为了日配额和稳定性,我把实际速率压到每秒 8 次,留足余量:
import time, threading, sqlite3, requests
# 依赖:pip install requests google-auth
# 前置:服务账号 JSON 密钥已放到环境变量 GOOGLE_APPLICATION_CREDENTIALS
# 且该服务账号邮箱已在 Search Console 资源里添加为站点用户(受限权限即可)
class TokenBucket:
# 令牌桶限速器:capacity 是桶容量(允许的瞬时突发量),rate 是每秒补充令牌数
# 相比简单 sleep 轮询,令牌桶允许小突发、同时压住长期平均速率
def __init__(self, capacity: int, rate: float):
self.capacity = capacity
self.rate = rate
self.tokens = float(capacity) # 初始满桶,起步阶段不吃限速
self.lock = threading.Lock() # 多线程 Worker 共享同一个桶,必须加锁
self.last = time.monotonic()
def take(self) -> None:
# 阻塞式取令牌,拿不到就自旋等待,直到成功
while True:
with self.lock:
now = time.monotonic()
# 按真实经过时间补充令牌(而不是固定步长),保证速率精确
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1 # 扣掉一个令牌,放行一次请求
return
# 令牌不足,睡 50ms 后重试,避免空转烧 CPU
time.sleep(0.05)
核查 Worker 的核心逻辑,包含失败重试与结果落库:
API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
def inspect_url(session, url: str, bucket: TokenBucket, retries: int = 3) -> dict:
# inspectionUrl 是待查的具体 URL,siteUrl 是 Search Console 里的资源地址
payload = {"inspectionUrl": url, "siteUrl": "https://courses.example.com/"}
for attempt in range(retries):
bucket.take() # 先过令牌桶,所有 Worker 共享同一速率窗口
resp = session.post(API, json=payload, timeout=15)
# 429 配额超限和 5xx 服务端错误都值得重试,指数退避拉开间隔
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(2 ** attempt + 1) # 退避序列:2s / 3s / 5s
continue
resp.raise_for_status() # 其余 4xx 属于参数或权限问题,直接抛错
result = resp.json()
# 索引状态三件套在 indexStatusResult 节点下,缺字段兜底为空串
# 字段名以 API 文档为准,别硬编码枚举值,Google 会悄悄加新值
idx = result.get("inspectionResult", {}).get("indexStatusResult", {})
return {
"url": url,
"verdict": idx.get("verdict", ""), # 外层判定
"indexing_state": idx.get("indexingState", ""), # 内层索引状态
"coverage_state": idx.get("coverageState", ""), # 覆盖状态,分桶依据
"crawl_state": idx.get("crawlState", ""), # 抓取环节状态
}
# 重试耗尽仍失败,标记为 API_ERROR 留给下轮补查,不阻塞批次
# 补查时只重试 API_ERROR 的 URL,不浪费配额
return {"url": url, "verdict": "API_ERROR", "indexing_state": "",
"coverage_state": "", "crawl_state": ""}
并发调度的入口几行就够:
# 每秒 8 个令牌、桶容量 16,兼顾吞吐与配额安全余量
bucket = TokenBucket(capacity=16, rate=8.0)
# 8 个 Worker 线程共享同一个桶,总速率恒定在 8 QPS
# 一批 2000 条实测跑 4 分 20 秒,比串行快 7 倍左右,全程没触发 429
落库用 SQLite 单文件就够,建一张带时间戳的结果表,每轮核查插一批:
def save_results(db_path: str, rows: list[dict]) -> None:
# SQLite 单文件落库,无需独立数据库服务,够这类批处理场景用
conn = sqlite3.connect(db_path)
# 结果表按轮次追加而不是覆盖,才能对比两轮之间的状态变化
# 主键上不设 UNIQUE 约束,允许同一条 URL 多轮记录共存
conn.execute("""CREATE TABLE IF NOT EXISTS inspections (
url TEXT, run_date TEXT, verdict TEXT,
indexing_state TEXT, coverage_state TEXT, crawl_state TEXT)""")
# date('now') 让每轮自动带上核查日期,回捞率就是按日期差算的
# executemany 批量插入比逐条 insert 快一个数量级
conn.executemany(
"INSERT INTO inspections VALUES (?, date('now'), ?, ?, ?, ?)",
[(r["url"], r["verdict"], r["indexing_state"],
r["coverage_state"], r["crawl_state"]) for r in rows])
conn.commit()
# 批处理结束及时释放文件句柄,避免 Windows 下文件被占用
conn.close()
报表侧只做一件事:把 coverage_state 归一成上面四个桶,再算每桶的数量和环比变化。一条 SQL 就能算出两轮之间的回捞情况:
-- 对比最近两轮核查,统计「上一轮未收录、这一轮已收录」的回捞率
-- 用窗口函数给每条 URL 按日期排序,取倒数第二次和最后一次状态
WITH ranked AS (
SELECT url, run_date, coverage_state,
-- 每条 URL 按核查日期倒序编号,rn=1 是最近一轮
ROW_NUMBER() OVER (PARTITION BY url ORDER BY run_date DESC) AS rn
FROM inspections
)
-- a 是最近一轮,b 是上一轮,按 url 对齐
SELECT
SUM(CASE WHEN a.coverage_state LIKE '%Indexed%'
AND b.coverage_state NOT LIKE '%Indexed%'
THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS re_indexed_rate
FROM (SELECT * FROM ranked WHERE rn = 1) a
JOIN (SELECT * FROM ranked WHERE rn = 2) b ON a.url = b.url
GROUP BY 1;
分桶报表:把「未收录原因」变成工单
拿到原始结果只是第一步,运营能看懂的是分桶报表。我按 coverage 状态把异常 URL 分成四桶,每桶对应一个明确的修复动作:
| 分桶 | 判定依据 | 修复动作 | 本轮修复后回捞率 |
|---|---|---|---|
| Discovered - not crawled | coverageState 含 Discovered |
优化内链、提高 sitemap 优先级 | 31% |
| Crawled - currently not indexed | 抓取成功但未入库 | 充实正文差异化内容 | 47% |
| Alternate page with canonical | 被规范链接合并 | 核对 canonical 指向是否符合预期 | —(预期行为) |
| API_ERROR / 超时 | 脚本侧失败 | 下轮补查 | 100%(补查即得) |
「Crawled - currently not indexed」的 47% 回捞率来自一个具体改动:给 800 门课的详情页补了真实学员评价和章节大纲原文,把页面差异化内容占比从 22% 拉到 55% 左右。两周后回捞这批 URL,47% 进了索引。剩下 53% 大概率是内容判定仍然不够,继续迭代模板。
与 sitemap lastmod 对账找死角
还有一个容易忽略的死角:sitemap 里标了 lastmod 的 URL,如果连续两轮 API 核查都是「未抓取」状态,说明 Google 根本没把这个更新信号当回事。我加了一个对账查询——从 sitemap 解析出每条 URL 的 lastmod 日期,和 SQLite 里最近一轮的 crawl 状态做 join,输出「lastmod 已更新但 30 天未被抓取」的清单。第一次跑出来 620 条,主要是批量更新课程价格导致整站 lastmod 一起刷新,更新信号被稀释了。sitemap 的 lastmod 必须是真实的内容变更时间,批量假刷新反而会淹没有效更新——这是那次对账教会我的事。
误区澄清
最后澄清一个高频误解:很多人把收录诊断当成网站优化(SEO)的终点,觉得「进索引」就万事大吉。索引只是入场券,后面还有排序环节;而这条流水线真正的价值是让「没入场」的 URL 尽快暴露出来。如果你的内容同时也在做面向生成式引擎优化(Generative Engine Optimization, GEO)的改造,同一份 URL 状态数据可以直接复用——生成式引擎对内容的抓取发现逻辑与传统搜索引擎同源,把收录这条地基打牢,两边都受益。流水线脚本和分桶报表的思路,评论区欢迎交流你们站的分桶比例。
参考与延伸
- Search Console URL Inspection API 官方文档:https://developers.google.com/search/docs/monitoring-performance/url-inspection-api
- Google 搜索索引覆盖状态说明:https://developers.google.com/search/docs/monitoring-performance/indexing-inspect
- sitemap 协议规范(lastmod 字段):https://www.sitemaps.org/protocol.html
- Google 搜索中心技术文档首页:https://developers.google.com/search
收录诊断 · URL Inspection API · Google Search Console · 网站优化 · Python 自动化 · 令牌桶限速 · SQLite