每次发布时,根据已完成的公开页面集重新生成站点地图,然后将两者一起上传。此 Mac 工作流程使用一个小型 Python 脚本自动纳入新增页面并移除已删除的页面,同时检查公开结果。

这里的任务是为从 Mac 导出的一个小型静态网站维护站点地图。示例使用关于颜色的虚构页面。假设你现有的发布流程可以上传导出文件夹,并从公开网站中删除过时文件。

选择站点地图重新生成的环节

首先检查你的发布系统是否已经生成站点地图。Google 指出,许多内容管理系统会自动提供站点地图。如果你的系统会生成,请先添加并删除一个测试页面,检查生成的站点地图,再引入另一个生成器。了解站点地图。

对于静态导出,请按以下顺序操作:完成页面导出,根据导出内容生成站点地图,然后发布生成的文件夹。这是一种建议的实现模式:它能让站点地图描述与发布器收到的页面集一致。

在发布期间生成站点地图,也能用统一规则处理新增和删除的页面。已完成的导出中存在的页面会被纳入;不存在的页面会被省略。无需另外维护一份要记得更新的清单。

选择与你现有设置相匹配的集成方式:

现有发布设置 站点地图重新生成的位置
CMS 已维护站点地图 保留该生成器,并测试页面变更时的响应
静态导出器支持导出后执行命令 在上传前运行生成器
现有命令会发布已完成的文件夹 通过生成器的可选发布参数运行该命令
通过桌面界面上传 先生成,再上传完整文件夹;若要无人值守地更新公开网站,则需要受支持的发布集成

本地脚本无法独自更改远程网站。下面的工作流程会自动生成站点地图,也可以启动现有的发布命令。实际上传仍由你已采用的发布器负责。

明确哪些页面应纳入站点地图

对于普通网页 URL,Google 接受每行一个完整 URL 的文本站点地图。本任务无需使用 XML。每个页面应纳入其首选的公开 URL,而不是同一内容的多个 URL 版本。创建并提交站点地图。

文本格式要求使用 UTF-8 编码,且只能包含 URL,不能有标题或注释。单个文件最多可包含 50,000 个 URL,大小为 52,428,800 字节。将示例中的站点地图放在网站根目录,使其涵盖该位置下导出的页面。站点地图协议:文本文件及其位置。

生成器使用了一个刻意保持简单的导出约定:

导出的文件 示例公开 URL
public/index.html https://example.com/
public/colors/blue.html https://example.com/colors/blue.html
public/tiles/index.html https://example.com/tiles/

只有当主机为这些文件提供完全相同的 URL 时,才使用此约定。如果发布器将 blue.html 转换为 /colors/blue/,或在 /collection/ 之类的路径下提供网站,请改用其路由列表,或在发布前调整映射。扫描文件名无法推断自定义路由。

只保留计划纳入导出文件夹的页面。此示例会跳过以点开头的路径和名为 404.html 的文件;它不会检查页面元数据、规范网址声明或重定向规则。如果网站还有这些额外条件,应根据发布器提供的符合条件页面清单生成站点地图。

也要继续维护正常的导航链接。WHATWG 将由带有 href 属性的 a 元素创建的链接定义为超链接;Google 也说明,正确链接的页面通常可以通过导航被发现。更新站点地图时,也应更新指向新增或已删除页面的链接。WHATWG HTML 标准:链接,了解站点地图。

在 Mac 上创建生成器

你需要 Python 3.8 或更高版本,以及纯文本编辑器。在终端中运行 python3 --version,检查可用的解释器。如果需要安装 Python,请按照 Python 软件基金会提供的 macOS 安装说明操作。其文档还介绍了如何在终端中使用 python3 运行脚本。在 macOS 上使用 Python。

在桌面上创建一个名为 sitemap-demo 的文件夹。将以下完整程序保存到该文件夹中,文件名为 regenerate_sitemap.py。此程序使用 Python 标准库,无需安装其他软件包。

import argparse
from pathlib import Path, PurePosixPath
import subprocess
import tempfile
from urllib.parse import quote, urlsplit


def make_sitemap(paths, origin):
    parsed = urlsplit(origin)
    if (parsed.scheme not in ("http", "https") or not parsed.hostname
            or parsed.netloc != parsed.hostname
            or parsed.path not in ("", "/")
            or parsed.query or parsed.fragment):
        raise ValueError("Use a site origin, such as https://example.com")
    base = origin.rstrip("/")
    urls = set()
    for relative in paths:
        path = PurePosixPath(relative)
        if path.name == "index.html":
            parent = path.parent.as_posix()
            route = "" if parent == "." else parent + "/"
        else:
            route = path.as_posix()
        urls.add(base + "/" + quote(route, safe="/"))
    if not urls:
        raise ValueError("No eligible pages; sitemap was not replaced")
    if len(urls) > 50000:
        raise ValueError("Split the sitemap: more than 50,000 URLs")
    data = ("\n".join(sorted(urls)) + "\n").encode("utf-8")
    if len(data) > 50 * 1024 * 1024:
        raise ValueError("Split the sitemap: larger than 50 MB")
    return data


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("folder")
    parser.add_argument("origin")
    parser.add_argument("publish", nargs=argparse.REMAINDER)
    args = parser.parse_args()
    root = Path(args.folder).resolve()
    if not root.is_dir():
        parser.error("The export folder must already exist")
    paths = []
    for file in root.rglob("*.html"):
        relative = file.relative_to(root)
        if (file.is_file() and file.name != "404.html"
                and not any(p.startswith(".") for p in relative.parts)):
            paths.append(relative.as_posix())
    data = make_sitemap(paths, args.origin)
    target = root / "sitemap.txt"
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(dir=root, delete=False) as handle:
            temporary = Path(handle.name)
            handle.write(data)
        temporary.replace(target)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
    print(f"Wrote {len(data.splitlines())} URLs to {target}", flush=True)
    if args.publish:
        subprocess.run(args.publish, check=True)


if __name__ == "__main__":
    main()

每次运行都会重新构建整个 URL 列表。程序会对结果排序并去重,对路径中的空格等字符进行编码,并先写入临时文件,再替换 sitemap.txt。如果生成过程出错,程序会停止,不会运行可选的发布命令。

如果导出内容为空,程序会有意报错,并保留之前的站点地图。这样可以发现意外选错文件夹或文件夹尚未完成的情况。若确实要移除所有页面,应另行决定如何发布,而不是让空导出通过此工作流程。

在接入发布流程前测试新增和删除

在终端中进入演示文件夹:

cd "$HOME/Desktop/sitemap-demo"

用以下完整的设置命令创建两个虚构页面。在新建的演示文件夹中运行此命令;它会写入两个指定的示例文件。

python3 - <<'PY'
from pathlib import Path

root = Path("public")
(root / "colors").mkdir(parents=True, exist_ok=True)
pages = {
    "index.html": "<!doctype html><title>Color collection</title><p>Color collection.</p>",
    "colors/blue.html": "<!doctype html><title>Blue tile</title><p>A blue tile.</p>",
}
for name, content in pages.items():
    (root / name).write_text(content, encoding="utf-8")
PY

生成并检查本地站点地图。示例站点来源仅作说明;这些命令不会向该地址发布任何内容。

python3 regenerate_sitemap.py public https://example.com
cat public/sitemap.txt

预期文件恰好包含以下两行:

https://example.com/
https://example.com/colors/blue.html

现在将虚构的蓝色页面替换为绿色页面:

python3 - <<'PY'
from pathlib import Path

root = Path("public")
(root / "colors/blue.html").unlink()
(root / "colors/green.html").write_text(
    "<!doctype html><title>Green tile</title><p>A green tile.</p>",
    encoding="utf-8",
)
PY
python3 regenerate_sitemap.py public https://example.com
cat public/sitemap.txt

预期结果是首页 URL 后面跟着 https://example.com/colors/green.html。蓝色页面的导出文件已不存在,因此其 URL 也会消失。这样即可在不依赖搜索引擎发现变更的情况下,验证新增和删除两个方向的更新。

将重新生成纳入每次发布

对于真实网站,请将演示文件夹和站点来源替换为实际完成的导出文件夹和公开来源。仅在导出完成后运行生成器,并在发布完成前保持该文件夹不变。按顺序执行发布。

如果项目文件夹中已有可用的发布脚本,名为 deploy-site.sh,以下集成命令会先生成站点地图,再运行该脚本。此示例要求已有该脚本;生成器不会创建脚本,也不会提供托管配置。

python3 regenerate_sitemap.py public https://example.com sh ./deploy-site.sh

你的发布脚本必须上传同一个 public 文件夹,包括 sitemap.txt,并删除导出内容中已不存在的公开文件。它不得在站点地图生成后重新构建页面。如果发布器也负责构建网站,应将生成步骤放在其构建阶段和上传阶段之间。

如果生成失败,发布命令会被跳过。如果发布失败,Python 会报告错误,但远程网站可能已经有部分内容发生变化。重试前请检查公开结果。如果主机支持一次性发布完整版本,请使用其文档说明的机制。

如果使用桌面上传器,请先生成站点地图,再选择导出文件夹上传。确认上传器会替换现有站点地图,并处理已删除的页面。你仍需手动执行上传操作;仅在本地自动生成并不能实现无人值守的公开发布。

分别验证公开站点地图和已删除页面

发布成功后,在浏览器中打开实际公开的 sitemap.txt URL。检查新增页面是否出现,以及已删除页面是否消失。然后直接访问每个受影响的页面。这样可以区分本地导出是否正确与公开发布是否正确。

删除站点地图条目并不能决定页面旧地址的 HTTP 响应。对于没有替代页面的已删除页面,请检查主机是否返回了适当的页面不存在响应。RFC 9110 将 404 定义为资源当前没有表示形式时的响应,将 410 定义为已知其不可用很可能是永久性的响应。RFC 9110:HTTP 语义。

如果页面显示页面不存在的消息,却返回 200,可能会被视为软 404。Google 还说明,之前已编入索引的 URL 若返回适用的 4xx 响应,会随时间从其索引中移除。因此,从站点地图中删除 URL 只是移除页面工作的一部分。HTTP 状态代码如何影响 Google 抓取工具。

遇到异常时,按以下顺序排查:

现象 下一步检查
本地站点地图仍列出已删除页面 检查是否有遗留的导出文件,或是否选错了导出文件夹
本地站点地图正确,但公开副本不同 检查上传的文件夹、目标位置和缓存
公开站点地图正确,但已删除页面仍能打开 检查发布过程是否删除了远程旧文件
生成的 URL 打开了错误页面 根据文件名约定检查主机的路由映射
新 URL 已公开,但未出现在搜索结果中 将搜索发现与编入索引视为独立流程

缓存可能导致公开站点地图看起来没有变化。RFC 9111 定义了缓存响应如何保持新鲜以及如何重新验证。如果主机支持配置响应标头,Cache-Control: no-cache 会要求先成功验证,再复用存储的响应;仅更改本地文件并不会配置该标头。RFC 9111:HTTP 缓存。

保持站点地图地址稳定

通过 Search Console 的 Sitemaps 报告提交公开站点地图的固定 URL,或在网站现有的 robots.txt 文件中引用该 URL,以便发现站点地图。Google 介绍了这两种方法,并指出提交只是一种提示。创建并提交站点地图。

对于示例网站,发现站点地图所需的行如下:

Sitemap: https://example.com/sitemap.txt

替换发布期间站点地图的内容,同时保持其地址稳定。实际完成情况的检查标准是:公开站点地图与已完成的导出内容一致;新增页面可以通过其列出的 URL 打开;已删除页面返回预期响应。搜索引擎会按自己的时间安排处理这些变更;将页面纳入站点地图并不保证它会被抓取或编入索引。了解站点地图。

来源