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

Django SEO 发布前检查:用 Python 自动验证 canonical、robots、sitemap 与站内链接

Django SEO 发布前检查:用 Python 自动验证 canonical、robots、sitemap 与站内链接 - 暂无配图,技术文章默认封面

先把“发布成功”和“可以被发现”分开

文章在后台点击发布,只代表数据库里有了一条内容记录。对搜索引擎来说,还需要继续确认几个独立信号:页面能否稳定返回、抓取规则是否允许访问、规范 URL 是否明确、站点地图是否能发现页面,以及站内链接是否把页面接入内容结构。

这几个信号可以在发布前用一个小型检查器完成。它适合放在本地开发机、部署脚本或 CI 中运行,先给出一份可读报告,再决定是否进入百度主动推送和 IndexNow 队列。本文的检查器只验证页面信号,不承诺收录、展现或排名;收录仍要结合搜索平台状态和服务器日志观察。已有的网站页面不被收录排查清单适合处理异常页面,SEO 进阶测量与验证则适合持续记录检查结果。

预检应该检查哪些内容

可以把发布前检查分为五组:

  1. 可访问性:文章 URL 返回成功状态,最终地址没有跳到错误页面,响应类型是 HTML。
  2. 抓取入口:robots.txt 能打开,且没有用全局 Disallow: / 阻断站点;sitemap.xml 能打开,并包含文章的规范 URL。
  3. 页面规范:title、description、H1 和 canonical 存在,canonical 指向当前页面的 HTTPS 地址,没有把权重指向旧 URL。
  4. 机器可读性:页面包含 JSON-LD 或其他结构化数据,图片有替代文本,页面不是意外的 noindex。
  5. 内容关系:页面至少有若干条指向站内页面的链接,且链接使用稳定的 canonical 路径,而不是带跟踪参数的临时地址。

这些规则不是搜索引擎的全部质量标准。它们的价值在于把明显的发布错误提前暴露出来,避免刚发布就出现 404、重复 canonical、孤立页面或图片无 alt 等问题。

一个可运行的 Python 检查器

下面的脚本依赖 requests,只对你明确传入的站点执行 GET 请求。它适合在发布者自己的机器上运行,不要把它直接做成任何人都能调用的公共 HTTP 接口,以免被利用去请求内网地址。

from __future__ import annotations

import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from urllib.parse import urldefrag, urljoin, urlparse, urlunparse

import requests


class PageParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.title = ""
        self.description = ""
        self.canonical_links = []
        self.robots = ""
        self.h1_count = 0
        self.internal_links = []
        self.missing_alt = 0
        self._in_title = False

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        tag = tag.lower()
        if tag == "title":
            self._in_title = True
        elif tag == "h1":
            self.h1_count += 1
        elif tag == "link" and "canonical" in {
            item.strip().lower() for item in attrs.get("rel", "").split()
        }:
            self.canonical_links.append(attrs.get("href", ""))
        elif tag == "meta":
            name = attrs.get("name", "").lower()
            if name == "description":
                self.description = attrs.get("content", "")
            elif name == "robots":
                self.robots = attrs.get("content", "")
        elif tag == "img" and not attrs.get("alt", "").strip():
            self.missing_alt += 1
        elif tag == "a" and attrs.get("href"):
            self.internal_links.append(attrs["href"])

    def handle_endtag(self, tag):
        if tag.lower() == "title":
            self._in_title = False

    def handle_data(self, data):
        if self._in_title:
            self.title += data.strip()


def normalize(url):
    url, _ = urldefrag(url)
    parsed = urlparse(url)
    path = parsed.path or "/"
    return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), path,
                       "", parsed.query, ""))


def result(name, ok, detail, level="error"):
    return {"name": name, "ok": ok, "detail": detail, "level": level}


def fetch(session, url):
    response = session.get(url, timeout=(3.05, 10), allow_redirects=True)
    response.raise_for_status()
    return response


def check_jsonld(html):
    blocks = re.findall(
        r"<script[^>]+type=[\"']application/ld\+json[\"'][^>]*>(.*?)</script>",
        html,
        flags=re.I | re.S,
    )
    types = []
    for block in blocks:
        try:
            value = json.loads(block.strip())
        except json.JSONDecodeError:
            continue
        values = value if isinstance(value, list) else [value]
        expanded = []
        for item in values:
            if isinstance(item, dict) and isinstance(item.get("@graph"), list):
                expanded.extend(item["@graph"])
            else:
                expanded.append(item)
        for item in expanded:
            if isinstance(item, dict):
                item_type = item.get("@type")
                if isinstance(item_type, list):
                    types.extend(str(x) for x in item_type)
                elif item_type:
                    types.append(str(item_type))
    return types


def audit(base_url, article_url):
    base_url = normalize(base_url).rstrip("/")
    article_url = normalize(article_url)
    base_host = urlparse(base_url).netloc
    session = requests.Session()
    session.headers["User-Agent"] = "BlogSeoPreflight/1.0 (+manual-audit)"
    checks = []

    try:
        response = fetch(session, article_url)
    except requests.RequestException as exc:
        return {"url": article_url, "ok": False,
                "checks": [result("文章响应", False, str(exc))]}

    content_type = response.headers.get("content-type", "").lower()
    checks.append(result("文章响应", response.status_code == 200,
                         f"HTTP {response.status_code},最终地址 {response.url}"))
    checks.append(result("HTML 类型", "text/html" in content_type,
                         content_type or "缺少 Content-Type"))

    parser = PageParser()
    parser.feed(response.text)
    canonical_links = [item for item in parser.canonical_links if item.strip()]
    canonical = normalize(urljoin(response.url, canonical_links[0])) if len(canonical_links) == 1 else ""
    canonical_ok = len(canonical_links) == 1 and canonical == normalize(article_url)
    canonical_detail = canonical or (
        f"发现 {len(canonical_links)} 个 canonical,应该恰好有 1 个"
    )
    checks.append(result("canonical", canonical_ok, canonical_detail))
    checks.append(result("title", bool(parser.title.strip()),
                         parser.title.strip() or "缺少 title"))
    description_ok = 50 <= len(parser.description.strip()) <= 170
    checks.append(result("description", description_ok,
                         f"{len(parser.description.strip())} 个字符",
                         level="warning" if parser.description else "error"))
    checks.append(result("H1", parser.h1_count == 1,
                         f"发现 {parser.h1_count} 个 H1",
                         level="warning" if parser.h1_count != 1 else "error"))
    checks.append(result("robots meta", "noindex" not in parser.robots.lower(),
                         parser.robots or "未设置 robots meta"))

    types = check_jsonld(response.text)
    checks.append(result("JSON-LD", bool(types),
                         ", ".join(types) if types else "没有可解析的 JSON-LD"))
    checks.append(result("图片 alt", parser.missing_alt == 0,
                         f"{parser.missing_alt} 张图片缺少 alt"))

    internal = []
    for href in parser.internal_links:
        target = urlparse(urljoin(response.url, href))
        if target.netloc == base_host and target.scheme in {"http", "https"}:
            internal.append(normalize(urljoin(response.url, href)))
    checks.append(result("站内链接", len(set(internal)) >= 2,
                         f"发现 {len(set(internal))} 条站内链接"))

    robots_ok = False
    sitemap_url = urljoin(base_url + "/", "robots.txt")
    try:
        robots = fetch(session, sitemap_url).text
        directives = [
            line.split("#", 1)[0].strip().lower()
            for line in robots.splitlines()
            if line.split("#", 1)[0].strip()
        ]
        robots_ok = "disallow: /" not in directives
        checks.append(result("robots.txt", robots_ok,
                             "可访问且没有全局禁止抓取" if robots_ok else "疑似全局禁止抓取"))
        sitemap_match = re.search(r"(?im)^\s*sitemap:\s*(\S+)", robots)
        if sitemap_match:
            sitemap_url = sitemap_match.group(1)
    except requests.RequestException as exc:
        checks.append(result("robots.txt", False, str(exc)))

    in_sitemap = False
    try:
        sitemap = fetch(session, sitemap_url)
        root = ET.fromstring(sitemap.content)
        locs = {
            normalize(loc.text.strip())
            for loc in root.iter()
            if loc.tag.rsplit("}", 1)[-1] == "loc" and loc.text
        }
        in_sitemap = normalize(article_url) in locs
        checks.append(result("sitemap", in_sitemap,
                             f"找到 {len(locs)} 个 URL,文章{'在' if in_sitemap else '不在'}其中"))
    except (requests.RequestException, ET.ParseError) as exc:
        checks.append(result("sitemap", False, str(exc)))

    return {
        "url": article_url,
        "ok": all(item["ok"] for item in checks),
        "checks": checks,
    }


def main():
    parser = argparse.ArgumentParser(description="检查一篇文章的发布前 SEO 信号")
    parser.add_argument("base_url", help="站点根地址,例如 https://blog.zenleak.cn")
    parser.add_argument("article_url", help="待检查的文章完整 URL")
    args = parser.parse_args()
    report = audit(args.base_url, args.article_url)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0 if report["ok"] else 1


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

运行方式如下:

python seo_preflight.py \
  https://blog.zenleak.cn \
  https://blog.zenleak.cn/post/298/

输出中的 ok 只表示脚本列出的规则全部通过。检查器不会模拟搜索引擎,也不会把一个带有 200 状态码的页面误认为已经被收录。

canonical 要和真实访问地址一致

canonical 的作用是告诉搜索引擎一组近似页面中哪一个是首选地址。发布前至少要检查三点:

  • 使用 HTTPS,并且域名与站点实际域名一致;
  • 对当前文章使用自指 canonical,而不是复制另一篇文章的地址;
  • 去掉追踪参数、片段标识和临时预览路径。

如果文章可以通过多个旧 URL 访问,应在应用层设置永久重定向,并让 canonical、站内链接和 sitemap 统一指向最终地址。关于重复 URL 的处理原则,可以参考 Google 的 canonical 文档。

robots 和 sitemap 是两个不同检查

robots.txt 只表达抓取建议,不能替代登录权限,也不能保证页面不出现在搜索结果里。脚本可以先做最基本的检查:文件能打开、没有全局禁止抓取,并且存在可访问的 sitemap 地址。

站点地图解决的是发现入口。它应该包含文章的 canonical URL,不应把草稿、登录页、重复参数页和已经重定向的旧地址混进去。文章发布后先检查 sitemap,再检查站内专题页和相关文章模块是否能点击到它。这样搜索引擎既有 XML 入口,也有正常的 HTML 链接入口。

本站的 百度主动推送与 IndexNow 规划已经把两种提交机制区分开:提交只是发现提示,不代表抓取和收录。把预检放在提交之前,可以少提交明显错误的 URL。

结构化数据和页面内容要相互对应

JSON-LD 只能描述页面上真实存在的信息,不能为了获得富结果而填写不存在的作者、评分、图片或日期。文章页通常可以使用 Article,站点或面包屑可以使用相应类型,但字段值必须和可见内容一致。GEO 内容组织方法也强调了来源、作者和实体关系的可核验性。

预检脚本适合发现“没有 JSON-LD”或“JSON-LD 不是合法 JSON”这类工程问题;它无法判断一段描述是否真实,也不能替代搜索平台的结构化数据测试工具。结构化数据的定位可参考 Google 结构化数据介绍。

站内链接要形成可走通的路径

新文章发布后,至少应该从一个稳定的专题页、分类页或相关文章模块到达它。文章正文还可以链接到解释前置概念的旧文,形成“基础概念 → 实操检查 → 发布后验证”的阅读路径。

本文建议建立以下关系:

不要为了增加链接数量而堆砌链接。链接文字应该说明目标页面解决什么问题,并且放在读者确实需要下一步信息的位置。

预检通过后的发布流程

可以把一次发布拆成下面的顺序:

  1. 在草稿环境生成标题、摘要、canonical、文章正文和图片 alt;
  2. 用脚本检查 HTTP、HTML 元信息、robots、sitemap、JSON-LD 和站内链接;
  3. 修复阻断项,再人工抽查移动端布局、代码块和外部引用;
  4. 发布后重新访问文章、sitemap 和专题页,确认缓存没有返回旧内容;
  5. 把 canonical URL 放入百度主动推送和 IndexNow 队列;
  6. 用访问日志和搜索平台数据观察是否被发现、抓取和索引。

如果检查结果失败,不要把失败 URL 反复提交给搜索引擎。先修复页面,再提交一次新的 canonical URL。对已经发布过的文章,更新内容后还应记录更新时间和变更原因,方便后续判断抓取与展现变化。

结语:自动化负责发现错误,人工负责判断价值

SEO 发布前检查最适合处理确定性问题:状态码错误、canonical 缺失、sitemap 漏 URL、H1 数量异常、图片没有 alt,以及站内链接断开。它不能代替事实核验、原创性判断、用户体验测试,也不能承诺任何搜索引擎一定收录。

把检查结果保存成 JSON 后,可以在每次发布时比较差异;再结合 Django 部署后的日志排查和 Django API 性能优化,就能把“发布一篇文章”变成一条可复核的工程流程。

分享这篇文章:

评论 (0)

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

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