所属专题:SEO 专题导航:从基础到技术实践

SEO 发布后监控实战:用 Python 检查抓取、收录与主动提交结果

SEO 发布后监控实战:用 Python 检查抓取、收录与主动提交结果 - 暂无配图,技术文章默认封面

文章发布成功,只能说明数据库里有了一条记录。对搜索引擎来说,还要经过发现 URL、请求页面、理解内容和决定是否收录几个独立阶段。百度主动推送或 IndexNow 的成功回执,也只代表搜索引擎收到了一个发现提示,不能直接当作抓取成功,更不能当作已经收录。

本文把发布后的检查拆成一套可以重复执行的流程:先从 sitemap.xml 得到页面清单,再用访问日志确认搜索爬虫是否真正请求了页面,最后把百度提交、IndexNow 回执和页面状态放进同一份报告。这样可以回答“页面有没有被提交”“有没有被抓取”“是否出现异常”三个不同问题。

先区分四种状态

一张监控表至少要区分下面四种状态:

  1. 已发布:文章在站点上返回 200,拥有明确的 canonical 和 lastmod。
  2. 已提交:百度接口或 IndexNow 接收了 URL,回执只说明请求格式和认证基本通过。
  3. 已抓取:访问日志出现搜索爬虫请求,且页面返回 200 或预期的永久重定向。
  4. 已收录:搜索平台或站点搜索结果能够验证页面已经进入索引。

这四个状态不能互相替代。例如,百度接口返回成功但日志中没有 Baiduspider,说明暂时只能确认“提交已接收”;日志出现爬虫也不代表页面一定会收录。报告里把字段分开,才能避免把主动提交的数量误当成收录数量。

上一篇Django SEO 发布前检查:用 Python 自动验证 canonical、robots、sitemap 与站内链接解决的是发布前问题;本文接着处理发布后的观察周期。

为每个 URL 建立监控记录

不要只保存一个“已提交”布尔值。建议为每个规范 URL 保留下面这些字段:

字段 含义
url 规范的 HTTPS 地址,去掉追踪参数和片段
published_at 文章实际发布时间
updated_at 最近一次实质内容更新时间
sitemap_lastmod Sitemap 中声明的更新时间
baidu_status 百度提交状态、回执时间和错误信息
indexnow_status IndexNow 的 HTTP 状态和回执时间
first_crawl_at 第一次发现搜索爬虫请求的时间
last_crawl_at 最近一次搜索爬虫请求的时间
last_crawl_status 爬虫最近一次请求的 HTTP 状态
review_day 发布后第 1、3、7 天的复核结果

URL 应先做规范化再入队。https://blog.zenleak.cn/post/300/、带 utm_source 的版本和带 #section 的版本,都不应被当成三篇文章。提交队列可以使用规范 URL 的哈希值做唯一键,避免重试时重复消耗百度每日额度。

第一步:检查 Sitemap 与页面状态

下面的脚本只依赖 Python 标准库和 requests。它会检查 Sitemap 中的 URL、lastmod、页面最终地址、canonical 和响应状态,并输出一份 CSV。生产环境运行时应设置超时、请求间隔和最大页面数,不要把它改造成接收任意目标地址的公共接口。

from __future__ import annotations

import csv
import sys
import time
from datetime import datetime, timezone
from urllib.parse import urljoin, urldefrag, urlparse
from xml.etree import ElementTree

import requests

SITEMAP = "https://blog.zenleak.cn/sitemap.xml"
OUTPUT = "seo-monitor.csv"
SITE_HOST = "blog.zenleak.cn"


def normalize(url: str) -> str:
    url, _fragment = urldefrag(url.strip())
    parsed = urlparse(url)
    path = parsed.path or "/"
    return parsed._replace(
        scheme=parsed.scheme.lower(),
        netloc=parsed.netloc.lower(),
        path=path,
        params="",
        query="",
        fragment="",
    ).geturl()


def read_sitemap(session: requests.Session) -> list[dict[str, str]]:
    response = session.get(SITEMAP, timeout=15)
    response.raise_for_status()
    root = ElementTree.fromstring(response.content)
    namespace = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
    rows = []
    for item in root.findall("sm:url", namespace):
        loc = item.findtext("sm:loc", default="", namespaces=namespace)
        lastmod = item.findtext("sm:lastmod", default="", namespaces=namespace)
        url = normalize(loc)
        if urlparse(url).netloc == SITE_HOST:
            rows.append({"url": url, "sitemap_lastmod": lastmod})
    return rows


def inspect_page(session: requests.Session, row: dict[str, str]) -> dict[str, str]:
    try:
        response = session.get(
            row["url"],
            timeout=15,
            allow_redirects=True,
            headers={"User-Agent": "seo-monitor/1.0"},
        )
        canonical = ""
        marker = 'rel="canonical"'
        head = response.text[:200000]
        if marker in head:
            start = head.find(marker)
            href_start = head.rfind('href="', 0, start)
            if href_start >= 0:
                href_start += len('href="')
                href_end = head.find('"', href_start)
                canonical = normalize(urljoin(response.url, head[href_start:href_end]))
        row.update(
            status=response.status_code,
            final_url=normalize(response.url),
            canonical=canonical,
            checked_at=datetime.now(timezone.utc).isoformat(),
            error="",
        )
    except requests.RequestException as exc:
        row.update(status="", final_url="", canonical="", error=str(exc))
    return row


def main() -> None:
    session = requests.Session()
    rows = read_sitemap(session)
    with open(OUTPUT, "w", newline="", encoding="utf-8") as handle:
        fieldnames = [
            "url", "sitemap_lastmod", "status", "final_url",
            "canonical", "checked_at", "error",
        ]
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            writer.writerow(inspect_page(session, row))
            time.sleep(0.3)
    print(f"checked={len(rows)} output={OUTPUT}")


if __name__ == "__main__":
    sys.exit(main())

实际项目中可以把 HTML 解析替换为 BeautifulSoup 或 lxml,并补充 robots、H1、JSON-LD 和图片 alt 检查。脚本的目的不是模拟搜索引擎,而是尽早发现页面状态、canonical 和 Sitemap 声明之间的不一致。

建议优先处理这些结果:

  • status >= 400:先修复页面或确认旧地址是否应该 301 到新地址;
  • final_url != url:检查是否发生了多次重定向、HTTP 降级或错误的 host 跳转;
  • canonical != url:判断是否确实是重复页面,而不是误把规范 URL 指向了旧文章;
  • sitemap_lastmod 早于最近一次实质更新:修正生成 Sitemap 的逻辑,避免更新时间长期不变。

第二步:从访问日志确认是否真的抓取

页面返回 200 仍然不能说明搜索爬虫访问过。Nginx 或应用访问日志至少应保留时间、请求路径、状态码、响应耗时和 User-Agent。解析日志时,先把路径规范化,再将请求按客户端类型分类。常见分类可以包括:

  • 搜索引擎:Googlebot、Baiduspider、bingbot;
  • AI 检索器:GPTBot、OAI-SearchBot、ClaudeBot 等;
  • 普通用户:Chrome、Safari、Edge 等浏览器;
  • 自动化脚本:curl、python-requests、httpx、Go-http-client;
  • 安全扫描:zgrab、CensysInspect、Nmap 等;
  • 未知客户端:User-Agent 为空或格式异常。

User-Agent 只能提供线索,不能单独证明请求来自某个官方爬虫。对需要严格归类的请求,还要结合官方公布的 IP 校验方式、反向 DNS 或访问频率进行复核。详细的 IP、GeoIP 和 User-Agent 识别方法可以参考SEO访问日志分析实战:识别人类用户、搜索引擎与AI爬虫。

可以用下面的命令为每个文章 URL 生成一个简短的抓取摘要:

awk '$7 ~ /^\/post\// && $9 == 200 {print $4, $7, $12}' access.log \
  | grep -Ei 'Baiduspider|Googlebot|bingbot|GPTBot|OAI-SearchBot' \
  | sort > crawler-posts.log

不同 Nginx 日志格式的字段位置可能不同,正式接入前要先用一条已知日志核对 $7、$9 和 User-Agent 的位置。监控表中建议记录“首次抓取时间”和“最近一次抓取时间”,而不是只统计总请求数。总请求数很高,可能只是某个爬虫反复请求同一篇旧文章。

第三步:记录百度与 IndexNow 的回执

百度主动推送和 IndexNow 解决的是 URL 发现通知,不是收录保证。队列至少要保存:规范 URL、提交渠道、提交时间、HTTP 状态、返回正文摘要、重试次数、下次重试时间和最终状态。

百度接口通常有每日额度限制。可以按下面的优先级排队:

  1. 今天首次发布并且已经通过发布前检查的文章;
  2. 今天发生实质更新的旧文章;
  3. 最近 7 天内修复了 canonical、断链或 5xx 的页面;
  4. 其他尚未成功提交的页面。

同一规范 URL 在同一天只进入一次百度队列;接口返回网络错误时可以重试,但不能因为超时就无条件再次消耗额度。每次真正发送前,再次检查数据库中的幂等键和当天已发送数量。

IndexNow 的 key 文件应放在站点域名对应的公开路径下,密钥本身不要写进文章、模板或前端 JavaScript。提交成功后只记录 HTTP 回执和 URL 数量,后续仍要用访问日志确认爬虫是否到达页面。两套提交机制的认证、接收方和返回结果不同,具体配置可以参考百度主动推送与 IndexNow:差异、配置与每日提交规划。

用 7 天复核表代替“发完就不管”

发布后的检查不需要每小时人工查看。可以把同一 URL 在第 0、1、3、7 天分别记录一次:

时间 重点检查 通过标准
发布当天 页面、canonical、Sitemap、队列 页面 200,规范 URL 唯一,提交记录有回执
第 1 天 搜索爬虫访问 至少能看到目标爬虫或明确记录尚未访问
第 3 天 抓取错误和链接关系 没有新的 4xx/5xx,文章从专题或相关文章入口可达
第 7 天 搜索平台状态和内容表现 记录是否收录、展示、点击或仍需调整,不把未知当成成功

第 1 天没有抓取记录时,先检查 robots、服务器状态、提交队列和内链入口;不要立即重复提交几十次。第 7 天仍没有收录时,再检查内容是否与已有页面重复、标题是否准确回答搜索意图、页面是否存在大量模板化段落,并结合搜索平台的实际反馈决定修改、补链或合并。

站内链接审计实战:用 Python 找出孤立文章、断链与过深页面可以帮助检查文章是否从首页、分类页或专题页可达。发布后监控应该把审计结果作为一个输入:如果页面没有入链,即使提交和爬虫日志都正常,也应该优先补充自然的上下文链接。

报告中要明确“未知”

搜索平台通常不会为每个 URL 提供即时、完整的收录状态。监控报告应保留 unknown 或“待验证”,不要为了让图表好看而把缺少数据的页面标成“未收录”或“已收录”。建议把结果分成:

  • 可访问:页面状态、canonical 和 Sitemap 一致;
  • 已提交:至少一个提交渠道有可验证回执;
  • 已抓取:日志中出现目标爬虫访问;
  • 待确认:缺少搜索平台的索引证据;
  • 需处理:出现 4xx、5xx、错误重定向、robots 阻断或孤立页面。

这样的分类更适合长期比较,也能避免把一次接口返回误读成排名或收录承诺。对于耗时、状态码和错误堆栈的排查,可以继续使用Django 部署后的日志排查与性能监控:从请求链路到故障定位中的关联 ID 和日志分层方法。

隐私、密钥与保留期限

访问日志可能包含 IP、User-Agent、来源页和请求路径。应在隐私政策中说明用途、保存期限和访问权限,并只保留分析所需字段。访问记录及服务器本地数据库备份中的访问记录副本均按 30 天期限清理;数据库备份文件本身按单独的运维策略保留。

百度 token、IndexNow key 和服务器日志都不应出现在公开文章的真实示例中。文章中的域名、数量、响应正文和 CSV 内容应使用脱敏数据;部署时从环境变量或只读配置文件加载密钥,并限制管理后台只有必要的工作人员可以查看。

结语

SEO 发布流程可以拆成四个可验证环节:发布前检查页面信号,发布时建立规范 URL,发布后提交发现通知,再用日志和搜索平台数据复核抓取与索引。每个环节都有自己的证据,不能用 Sitemap、接口回执或访问量替代另一个环节。

把这些结果按 URL、渠道和日期保存下来,站点就能从“今天提交了多少篇”升级到“哪些页面已经可访问、哪些页面被抓取、哪些问题需要处理”。这份小型监控报告也可以作为每周 SEO 维护的固定输入,帮助优先修复真正影响发现和抓取的问题。

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!