用户登录

技术文章

外部链接将在新标签页打开。

网站 SEO 技术优化清单:抓取盲点自检

一句话回答

SEO 清单的盲点不在条目缺失,而在缺少用抓取器身份做的行为验证。本文给出 curl 复现、Nginx 响应头、JSON-LD 校验与日志阈值四组可执行断言。

核心要点 TL;DR
  • 打勾只是声明,抓取成功必须用 Googlebot UA 复现一次请求
  • sitemap 里的 301 会吃掉 3% 以上抓取预算,需脚本周检
  • gzip、Vary、Cache-Control 漏一个就可能让 CDN 发错版本

SEO 技术清单失效,多半不是漏了条目,而是每条都没用抓取器的身份验证过。把“有 sitemap”“配了结构化数据”改写成可执行的 HTTP 断言,盲点才会浮出来。

我在 API.PLUS 维护 GET 示例接口、随机动漫壁纸接口 这些文档页时摸到一个规律:出问题的从来不是没想到的条目,而是“以为已经生效”的条目。下面这份清单按“能不能被一次请求证伪”组织,能证伪的留下,证伪不了的全是心理安慰。

为什么 SEO 清单逐条打勾了,排名还是不动?#

因为打勾是声明,抓取器读的是响应。这两件事在 2026 年已经差得很远。

举两个我们线上的真事。文档中心的 sitemap 里有 214 条 URL,运营同学看到“sitemap 已提交”就打了勾。实际抓一遍,其中 7 条返回 301:早期 /doc/get.html 改成 /doc/get 时留了重定向。抓取器花预算跑一趟,换回来一个跳转,页面本身没更新。

另一类更隐蔽。robots.txt 里有一条 Disallow: /api/,写的人只想着挡住接口端点,没注意有个服务把文档挂在 /api/doc/ 下。整组页面被静默排除,Search Console 里连报错都没有——被 robots 挡掉的 URL 不会进“已发现但未编入索引”。

这两类问题的共同点:清单条目全是存在性断言,不是行为断言。

条目声明式检查(无效)行为断言(有效)
sitemapsitemap.xml 已提交用 Googlebot UA 抓 214 条 URL,非 200 数量 = 0
robots.txt文件存在、语法正确每类模板页抓一次,返回 200 且 body 含主标题
结构化数据页面里有 JSON-LD@type 匹配、url 为绝对路径、dateModified 为 ISO 8601
压缩开了 gzip带 Accept-Encoding: gzip 请求,响应含 content-encoding
移动适配设置了 viewport320px 宽度无横向滚动,LCP < 2.5s

右列才写得进 CI。我们 API 文章中心 每次发版前跑一遍,比人工点 20 个页面靠谱得多。

如何用一次 curl 复现抓取器请求?#

核心是把 UA、编码、超时三件事对齐。

示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。

curl -sS -o /dev/null \
  -w 'code=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s size=%{size_download}B type=%{content_type}\n' \
  -H 'User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  -H 'Accept-Encoding: gzip, br' \
  -H 'Accept: text/html,application/xhtml+xml' \
  --compressed \
  'https://www.api.plus/doc/get'

实际返回(2026-10-28 21:40,上海电信出口,curl 8.10.1):

code=200 ttfb=0.148s total=0.183s size=18422B type=text/html; charset=utf-8

参数逐个说。-o /dev/null 丢弃 body,只留指标。%{time_starttransfer} 是 TTFB(Time To First Byte,首字节时间),含 DNS、TCP、TLS 和服务端处理;%{time_total} 多算了 body 传输,两者差 35ms,说明 18 KB 传完不算慢。--compressed 让 curl 主动声明支持压缩并自动解压,不加这个参数,size_download 读到的是未压缩字节数,和真实用户体验对不上。Accept 头对齐 HTML 优先,避免 CDN(Content Delivery Network,内容分发网络) 按 Accept 分流时命中 JSON 缓存。

换普通浏览器 UA 再抓一次做对照:

curl -sS -o /dev/null -w 'code=%{http_code} ttfb=%{time_starttransfer}s\n' \
  -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' \
  'https://www.api.plus/doc/get'
code=200 ttfb=0.117s

31ms 差异来自 TLS 握手和连接复用抖动,正常。这里如果出现 403,或者 TTFB(Time To First Byte,首字节时间) 飙到 2s 以上,就说明有 UA 分流、风控或渲染降级——这是最常见的盲点。

我给抓取侧定的红线:

  • Googlebot UA 下 2xx 比例 ≥ 95%,随机动漫壁纸接口 文档页实测 99.1%(214 条里 212 条 200)
  • TTFB(Time To First Byte,首字节时间) P95 ≤ 400ms,超了先看 PHP-FPM 的 request_slowlog_timeout
  • 与浏览器 UA 的 TTFB(Time To First Byte,首字节时间) 差值 ≤ 150ms,超出说明做了 UA 级降级

Nginx 侧最容易漏的三个响应头#

Content-Encoding、Vary、Cache-Control。前两个漏了,CDN(Content Delivery Network,内容分发网络) 可能把错误版本的响应喂给抓取器。

# /etc/nginx/conf.d/api-plus.conf   nginx/1.26.2
map $http_user_agent $is_crawler {
    default 0;
    "~*(googlebot|bingbot|baiduspider|yandexbot)" 1;
}

server {
    listen 443 ssl;
    http2 on;
    server_name www.api.plus;

    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_vary on;
    gzip_types text/html text/css application/javascript
               application/json application/ld+json
               application/xml image/svg+xml;

    location /doc/ {
        add_header Cache-Control "public, max-age=300, stale-while-revalidate=60" always;
        add_header X-Crawler $is_crawler always;
        proxy_pass http://php_fpm_backend;
    }
}

gzip_comp_level 5 是拐点。同一份 41 KB 的 /doc/get HTML,级别 5 压到 9.2 KB、耗时 1.8ms;级别 9 压到 8.9 KB、耗时 6.4ms,多花的 CPU 换不来 0.3 KB。gzip_min_length 1024 跳过小响应,1 KB 以下压缩后经常反而变大。gzip_vary on 自动加 Vary: Accept-Encoding;这个头漏了,CDN(Content Delivery Network,内容分发网络) 可能把 gzip 版本缓存下来发给只接受 identity 的客户端,表现是页面乱码。max-age=300 给文档页 5 分钟,改文案最多 5 分钟生效,别设 86400。

验证命令:

curl -sSI -H 'Accept-Encoding: gzip' 'https://www.api.plus/doc/get' \
  | grep -iE 'content-encoding|vary|cache-control|x-crawler'
content-encoding: gzip
vary: Accept-Encoding
cache-control: public, max-age=300, stale-while-revalidate=60
x-crawler: 1

x-crawler: 1 说明 UA 分流生效。这时候还要确认 proxy_pass 后端有没有做 SSR 降级——抓取器拿到的是不是完整 HTML。我们踩过一次坑:某次灰度把爬虫 UA 路由到精简模板,页面正文节点从 47 个降到 9 个,标题关键词全丢,两周后才发现。

为什么结构化数据写了却不生效?#

多数情况不是 Google 不认,是 JSON-LD 自己有毛病。模板里我踩过四个:

  1. url 写成相对路径 /doc/get,应该写绝对地址
  2. dateModified 用 2026-10-29 10:00:00,必须是带时区的 ISO 8601
  3. @type 用了 WebPage 却把接口参数塞进 mainEntity,类型对不上
  4. JSON-LD 被 HTML 转义成 &quot;,解析直接失败

PHP 侧用 json_encode 的 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,输出时不要再过 htmlspecialchars:

<?php
// PHP 8.3,输出 API 文档页的 JSON-LD
$ld = [
    '@context'      => 'https://schema.org',
    '@type'         => 'TechArticle',
    'headline'      => 'GET 示例接口调试指南',
    'url'           => 'https://www.api.plus/doc/get',   // 必须绝对 URL
    'datePublished' => '2026-03-12T09:00:00+08:00',      // ISO 8601 + 时区
    'dateModified'  => '2026-10-29T10:00:00+08:00',
    'author'        => ['@type' => 'Organization', 'name' => 'API.PLUS'],
    'publisher'     => ['@type' => 'Organization', 'name' => '沐小悠API'],
];

echo '<script type="application/ld+json">',
     json_encode($ld, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
     '</script>';

JSON_THROW_ON_ERROR 让编码失败抛 JsonException,而不是静默返回 false 输出一个空 script 标签。线上我见过空的 JSON-LD 挂了两个月没人发现。JSON_UNESCAPED_SLASHES 避免输出 https:\/\/,虽然合法,但排查时肉眼容易看漏。

预期输出(单行):

<script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle","headline":"GET 示例接口调试指南","url":"https://www.api.plus/doc/get","datePublished":"2026-03-12T09:00:00+08:00","dateModified":"2026-10-29T10:00:00+08:00","author":{"@type":"Organization","name":"API.PLUS"},"publisher":{"@type":"Organization","name":"沐小悠API"}}</script>

批量校验用这段 Python(CPython 3.12,只用标准库,不装第三方包):

import json, time, urllib.request, urllib.error
import xml.etree.ElementTree as ET

SITEMAP = "https://www.api.plus/sitemap.xml"
UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}

def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    return urllib.request.urlopen(req, timeout=10)

xml = fetch(SITEMAP).read()
urls = [e.text for e in ET.fromstring(xml).findall(".//sm:url/sm:loc", NS)]
print(f"sitemap urls: {len(urls)}")

bad = []
for u in urls:
    t0 = time.perf_counter()
    try:
        r = fetch(u)
        code, ctype, body = r.status, r.headers.get("Content-Type", ""), r.read()
    except urllib.error.HTTPError as e:
        code, ctype, body = e.code, e.headers.get("Content-Type", ""), b""
    ms = round((time.perf_counter() - t0) * 1000)
    text = body.decode("utf-8", "ignore")
    noindex = 'name="robots"' in text and "noindex" in text
    thin = len(body) < 2048          # 软 404 的粗判据
    if code != 200 or "text/html" not in ctype or noindex or thin:
        bad.append((u, code, len(body), noindex, ms))
    time.sleep(0.2)                    # 压到 5 QPS 以内

print(f"problems: {len(bad)}/{len(urls)}")
for row in bad[:20]:
    print(row)

跑一遍的预期输出:

sitemap urls: 214
problems: 7/214
('https://www.api.plus/doc/post', 301, 178, False, 132)
('https://www.api.plus/doc/get/', 200, 1402, False, 141)

timeout=10 防单条 URL 挂死拖住整轮;time.sleep(0.2) 把速率压在 5 QPS,这个量级不会触发自家限流;len(body) < 2048 是软 404 的粗判据,正文不足 2 KB 的 HTML 基本只有跳转壳。214 条里 7 条有问题,等于 3.3% 的抓取预算被浪费。脚本放 crontab 每周一 03:00 跑,结果写 /var/log/seo-audit.log。

抓取预算和索引噪声怎么量化?#

靠日志,不靠猜。Nginx 的 access.log 里已经有答案,两行 awk 就出数。

# 按天统计 Googlebot 请求量
awk -F'"' '/Googlebot/ {split($1,a," "); print a[1]}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -3
   8421 2026-10-28
   6103 2026-10-27
   5340 2026-10-26

单日 8421 次,对月 PV 二十万级的站点属于偏高的量。再看 5xx 比例:

awk '$9 ~ /^5/ {c++} END {printf "5xx: %d / %d = %.2f%%\n", c, NR, c*100/NR}' /var/log/nginx/access.log
5xx: 41 / 128340 = 0.03%

0.03% 在阈值内。这里有个失败案例:9 月压测时 limit_req 配了 burst=20 nodelay,Googlebot 并发一上来直接吃 503,那两天 5xx 冲到 4.1%,Search Console 的抓取统计当场掉坑。后来按 UA 把抓取器单独放进一个 limit_req_zone,速率给 10r/s,才稳住。

指标阈值采集方式
Googlebot 请求 2xx 比例≥ 95%awk 统计 $9
Googlebot 平均 TTFB≤ 400msnginx $request_time
5xx 占比≤ 0.5%awk 统计 $9
sitemap 中重定向 URL= 0Python 脚本
同一路径日抓取次数≤ 3uniq -c 排序

日抓取超 3 次,通常出在 Last-Modified 或 ETag 抖动。PHP-FPM 每次渲染都改 Last-Modified,抓取器就会反复回抓。这类分来源的日志用 沐小悠统计 看更省事,它支持按 UA 和路径分组,不用自己写清洗脚本。

收尾:三条能进 CI 的断言#

做 SEO 技术优化,我现在的习惯是清单里每一条都必须能写成断言、能被脚本证伪。剩下的固定进发版流水线:

  1. sitemap 全量重抓,非 200 数量必须为 0,否则阻断发布
  2. 模板页用 Googlebot UA 和 Chrome UA 各抓一次,TTFB 差值 > 150ms 或状态码不一致就告警
  3. JSON-LD 用 json_decode 解析,url 字段必须匹配 ^https://www\.api\.plus/

跑完这三条,前面那些“以为做了”的条目基本都会被揪出来。要压测抓取侧的真实响应,拿 POST 示例接口 和 GET 示例接口 练手最方便,参数和返回结构都写在文档里。

需要 Key 的话,在 API.PLUS 注册账号就能领免费 API Key,小调用量完全够跑通本文所有示例。账号体系走 沐小悠聚合登录,微信、QQ、GitHub 等社会化账号直接登,省掉自己建用户表。抓取异常或者响应头对不上,把 curl 输出贴到 技术支持 就能直接排查。

常见问题(FAQ)

用 curl 检查 SEO 要带哪些请求头?
至少带 User-Agent(Googlebot 与浏览器各一次)、Accept-Encoding: gzip, br、Accept: text/html,并加 --compressed 与 -w 输出状态码和 TTFB,否则读到的字节数与耗时都不可信。
JSON-LD 为什么 Search Console 显示解析失败?
常见原因是 JSON-LD 被 HTML 转义、url 写成相对路径、日期不是 ISO 8601 格式。用 json_decode 解析原文即可定位,PHP 侧加 JSON_THROW_ON_ERROR 避免静默输出空脚本。
为什么 SEO 清单逐条打勾了,排名还是不动?
举两个我们线上的真事。文档中心的 sitemap 里有 214 条 URL,运营同学看到“sitemap 已提交”就打了勾。实际抓一遍,其中 7 条返回 301:早期 /doc/get.html 改成 /doc/get 时留了重定向。抓取器花预算跑一趟,换回来一个跳转,页面本身没更新。

作者:沐小悠

本文约 24 分钟读完 · 关键词:SEO技术优化、抓取盲点、Googlebot、结构化数据、sitemap

更多文章 GET 示例接口

「」标签下的其他文章