网站 SEO 技术优化清单:抓取盲点自检
SEO 清单的盲点不在条目缺失,而在缺少用抓取器身份做的行为验证。本文给出 curl 复现、Nginx 响应头、JSON-LD 校验与日志阈值四组可执行断言。
- 打勾只是声明,抓取成功必须用 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 不会进“已发现但未编入索引”。
这两类问题的共同点:清单条目全是存在性断言,不是行为断言。
| 条目 | 声明式检查(无效) | 行为断言(有效) |
|---|---|---|
| sitemap | sitemap.xml 已提交 | 用 Googlebot UA 抓 214 条 URL,非 200 数量 = 0 |
| robots.txt | 文件存在、语法正确 | 每类模板页抓一次,返回 200 且 body 含主标题 |
| 结构化数据 | 页面里有 JSON-LD | @type 匹配、url 为绝对路径、dateModified 为 ISO 8601 |
| 压缩 | 开了 gzip | 带 Accept-Encoding: gzip 请求,响应含 content-encoding |
| 移动适配 | 设置了 viewport | 320px 宽度无横向滚动,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 自己有毛病。模板里我踩过四个:
url写成相对路径/doc/get,应该写绝对地址dateModified用2026-10-29 10:00:00,必须是带时区的 ISO 8601@type用了WebPage却把接口参数塞进mainEntity,类型对不上- JSON-LD 被 HTML 转义成
",解析直接失败
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 | ≤ 400ms | nginx $request_time |
| 5xx 占比 | ≤ 0.5% | awk 统计 $9 |
| sitemap 中重定向 URL | = 0 | Python 脚本 |
| 同一路径日抓取次数 | ≤ 3 | uniq -c 排序 |
日抓取超 3 次,通常出在 Last-Modified 或 ETag 抖动。PHP-FPM 每次渲染都改 Last-Modified,抓取器就会反复回抓。这类分来源的日志用 沐小悠统计 看更省事,它支持按 UA 和路径分组,不用自己写清洗脚本。
收尾:三条能进 CI 的断言#
做 SEO 技术优化,我现在的习惯是清单里每一条都必须能写成断言、能被脚本证伪。剩下的固定进发版流水线:
- sitemap 全量重抓,非 200 数量必须为 0,否则阻断发布
- 模板页用 Googlebot UA 和 Chrome UA 各抓一次,TTFB 差值 > 150ms 或状态码不一致就告警
- JSON-LD 用
json_decode解析,url字段必须匹配^https://www\.api\.plus/
跑完这三条,前面那些“以为做了”的条目基本都会被揪出来。要压测抓取侧的真实响应,拿 POST 示例接口 和 GET 示例接口 练手最方便,参数和返回结构都写在文档里。
需要 Key 的话,在 API.PLUS 注册账号就能领免费 API Key,小调用量完全够跑通本文所有示例。账号体系走 沐小悠聚合登录,微信、QQ、GitHub 等社会化账号直接登,省掉自己建用户表。抓取异常或者响应头对不上,把 curl 输出贴到 技术支持 就能直接排查。