¥
立即购买

自动化网站地图更新脚本

497 浏览
43 试用
12 购买
Nov 26, 2025更新

帮助开发者创建Python脚本自动更新网站XML网站地图,支持变更检测与部署,确保代码规范与性能优化。

Django + Nginx 环境下的自动化 XML 网站地图更新方案

以下方案与示例代码专为 Python + Django 网站、Nginx 托管环境设计,满足“智能变更识别、网站地图生成与根目录部署”的要求,并兼顾效率与扩展性。


1. 依赖项清单

  • 运行环境
    • Python 3.9+(推荐)
    • Django 3.2+ 或 4.x
    • Nginx 1.18+(或更高)
  • Python 库
    • 标准库:hashlib、json、gzip、tempfile、xml.etree.ElementTree、datetime、pathlib
    • 第三方(可选,提升性能或易用性)
      • lxml(加速 XML 构建,替代标准库 ElementTree)
      • celery(异步任务队列,支持即时生成)
      • redis(配合 celery 做任务队列或状态缓存)
  • 服务器工具
    • systemd/cron(定时执行)
    • 权限设置(写入网站根目录或 Nginx web root)

2. 脚本逻辑架构

整体流程:

  • 信号监听:捕获 Django 模型新增/修改/删除事件,计算变更签名,标记“待重建”状态。
  • 变更判定:对比前后哈希,避免仅时间戳变更导致误报。
  • URL 收集:按配置统一遍历静态路由与模型实例,生成绝对 URL 与 lastmod。
  • XML 生成:按标准格式生成 sitemap.xml;大于 50,000 URL 自动分片 + 索引。
  • 部署发布:原子写入网站根目录(或 Nginx web root),可选 gzip 压缩与搜索引擎 ping。
  • 定期任务:兜底执行(防止漏触发),并在高并发下合并多次更新请求。

核心模块与函数:

  • ChangeDetector:计算哈希、读写状态存储(JSON/Redis/DB)
  • UrlCollector:解析配置,统一反查 URL(reverse),收集 lastmod
  • SitemapGenerator:构建 XML,支持分片、gzip、索引、原子替换
  • Management Command:命令入口(update_sitemap),可被 cron/systemd/celery 调用
  • Django Signals:post_save/post_delete 标记变更,避免阻塞请求线程

3. 变更检测策略

  • 变更识别对象:所有纳入网站地图的页面来源(静态路由 + 发布的模型实例)
  • 哈希策略:
    • 对“实质性内容字段”进行规范化(去 HTML 标签、去多余空白、降噪),计算 SHA256
    • 字段包含:slug、title、正文(content/body)、发布状态(is_published/status)等
    • 若仅 updated_at 变化但内容哈希一致,则不触发重建(降低误报)
  • 新增/删除识别:
    • 当前快照 URL 集合与上次状态对比,新增/缺失即判为变更
  • 合并与节流:
    • 通过“脏标记文件/Redis 键”合并频繁变更,避免高并发下重复生成
  • 可选阈值判定(需要更严格控制误报时启用):
    • 对文本内容进行相似度比对(difflib.SequenceMatcher),变化比例 < 2% 时忽略

4. 网站地图生成机制

  • 标准兼容:
    • 使用 + + + + + ,符合协议
    • 超过 50,000 URL 自动拆分为 sitemap-1.xml(.gz)、sitemap-2.xml(.gz)… 并生成 sitemap_index.xml
  • 数据来源:
    • 静态 URL:通过 Django reverse 反查
    • 模型 URL:基于配置的 model + url_name + lookup 字段反查
  • 时间戳:
    • lastmod 基于模型的 updated_at 或相似字段;静态页面可用部署时间或固定时标
  • 压缩与索引:
    • 可选生成 .gz 文件,Nginx 原样提供,搜索引擎支持加载压缩版本

5. 部署方案

  • 输出位置:
    • 推荐直接写到 Nginx web root(例如 /var/www/example.com/html/)
    • 或写到 Django STATIC_ROOT 并在 Nginx 中映射到根路径
  • 原子更新:
    • 写入临时文件后使用 os.replace() 原子替换,避免读到半成品
  • Nginx 配置示例:
    server {
        server_name example.com;
    
        root /var/www/example.com/html;
    
        # sitemap.xml / sitemap_index.xml / gz 直接静态提供
        location = /sitemap.xml    { try_files /sitemap.xml /sitemap.xml.gz =404; }
        location = /sitemap_index.xml { try_files /sitemap_index.xml /sitemap_index.xml.gz =404; }
    
        # 可选:开启 gzip 静态(依据系统环境)
        gzip on;
        gzip_types application/xml text/xml;
    }
    
  • robots.txt 提示:
    Sitemap: https://www.example.com/sitemap.xml
    

6. 定期执行建议

  • 频率建议:
    • 中小型网站:每 6-12 小时执行一次(cron/systemd)
    • 大型网站:每 1-2 小时执行一次,并启用变更信号的即时任务(celery)
  • 任务触发组合:
    • Signals 标记变更 + Celery 任务即时生成
    • Cron/systemd 定时生成作为兜底保证
  • 搜索引擎通知:
    • 可选 ping Google/Bing(限速触发,合并后统一通知)

7. 性能优化方案

  • 生成层面
    • 流式写入 XML,避免构建超大 DOM
    • 使用 lxml 加速(可选)
    • 大站启用分片与 gzip,减少网络与 IO 压力
  • 数据检索
    • 模型数据分批分页查询(iterator/chunked)
    • 只选择必要字段(values_list)
  • 并发控制
    • 文件锁或 Redis 锁,避免重复生成
    • 合并多次触发,设置冷却时间窗口(如 30-120 秒)
  • 扩展性
    • 配置化 Source 适配更多模型或微服务来源
    • 支持增量更新(后续迭代:局部 XML 节点更新)

示例代码(Django 管理命令 + 信号 + 生成器)

目录结构建议(示例):

  • your_project/
    • your_app/
      • management/commands/update_sitemap.py
      • sitemap_auto/
        • generator.py
        • change_detection.py
        • signals.py

settings.py 配置示例

# settings.py
SITEMAP_AUTO = {
    "BASE_URL": "https://www.example.com",
    "OUTPUT_DIR": "/var/www/example.com/html",       # Nginx web root
    "OUTPUT_NAME": "sitemap.xml",                    # 主文件名
    "MAX_URLS_PER_FILE": 50000,
    "GZIP": True,
    "PING_SEARCH_ENGINES": True,
    "STATE_FILE": "/var/www/example.com/.sitemap_state.json",
    "CHANGE_COOLDOWN_SECONDS": 60,                   # 合并更新的冷却时间窗口
    # 统一声明要进 sitemap 的来源
    "SOURCES": [
        # 静态路由(Django URL name)
        {"type": "static", "names": ["home", "about", "contact"]},
        # 模型来源示例:页面
        {
            "type": "model",
            "model": "pages.Page",                   # app_label.ModelName
            "url_name": "page-detail",               # 用于 reverse 的 URL name
            "lookup": "slug",                        # URL 参数字段
            "lastmod_field": "updated_at",
            "filter": {"is_published": True},
            "content_fields": ["title", "body", "slug", "is_published"]
        },
        # 模型来源示例:博文
        {
            "type": "model",
            "model": "blog.Post",
            "url_name": "blog-detail",
            "lookup": "slug",
            "lastmod_field": "updated_at",
            "filter": {"status": "published"},
            "content_fields": ["title", "content", "slug", "status"]
        },
    ],
}

变更检测与生成器

# your_app/sitemap_auto/change_detection.py
import json, hashlib, time
from pathlib import Path
from typing import Dict, Optional

def canonicalize_text(text: str) -> str:
    # 简化版:去多余空白与常见 HTML 标签(可定制为更高级的 HTML 处理)
    import re
    text = re.sub(r"<[^>]+>", " ", text or "")
    text = re.sub(r"\s+", " ", text).strip()
    return text

def compute_content_hash(fields: Dict[str, str]) -> str:
    canon = []
    for k in sorted(fields.keys()):
        v = fields[k]
        canon.append(f"{k}:{canonicalize_text(str(v))}")
    joined = "|".join(canon)
    return hashlib.sha256(joined.encode("utf-8")).hexdigest()

class StateStore:
    def __init__(self, path: str):
        self.path = Path(path)
        self.data = {"urls": {}, "last_write_ts": 0}
        if self.path.exists():
            try:
                self.data = json.loads(self.path.read_text("utf-8"))
            except Exception:
                # 读失败则重置
                self.data = {"urls": {}, "last_write_ts": 0}

    def get_url_state(self, url: str) -> Optional[Dict]:
        return self.data["urls"].get(url)

    def set_url_state(self, url: str, content_hash: str, lastmod_iso: str):
        self.data["urls"][url] = {"hash": content_hash, "lastmod": lastmod_iso}

    def remove_missing(self, current_urls: set):
        prev = set(self.data["urls"].keys())
        for missing in prev - current_urls:
            self.data["urls"].pop(missing, None)

    def save(self):
        tmp = self.path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.data, ensure_ascii=False), "utf-8")
        tmp.replace(self.path)

    def mark_written(self):
        self.data["last_write_ts"] = int(time.time())
# your_app/sitemap_auto/generator.py
import os, gzip, tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Dict, Tuple, Optional

from django.conf import settings
from django.urls import reverse
from django.utils.module_loading import import_string
from django.db.models import Model

from .change_detection import StateStore, compute_content_hash

XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
NS_URLSET = 'http://www.sitemaps.org/schemas/sitemap/0.9'

def isoformat(dt: datetime) -> str:
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.isoformat(timespec="seconds")

class UrlCollector:
    def __init__(self, cfg: Dict):
        self.cfg = cfg
        self.base_url = cfg["BASE_URL"].rstrip("/")

    def collect(self) -> List[Dict]:
        """
        统一输出结构:
        {
            "loc": "https://www.example.com/xxx/",
            "lastmod": "2025-11-26T12:00:00+00:00",
            "changefreq": "daily",        # 可选
            "priority": 0.5,              # 可选
            "hash_fields": { ... }        # 用于哈希检测的字段值
        }
        """
        urls = []
        for src in self.cfg["SOURCES"]:
            if src["type"] == "static":
                for name in src["names"]:
                    path = reverse(name)
                    urls.append({
                        "loc": f"{self.base_url}{path}",
                        "lastmod": isoformat(datetime.now(timezone.utc)),
                        "hash_fields": {"url_name": name}
                    })
            elif src["type"] == "model":
                model = import_string(f"{src['model']}")
                qs = model.objects.all()
                flt = src.get("filter") or {}
                if flt:
                    qs = qs.filter(**flt)
                lookup = src["lookup"]
                lastmod_field = src.get("lastmod_field")
                content_fields = src.get("content_fields", [])
                # 分批读取,减少内存与锁占用
                for obj in qs.iterator(chunk_size=2000):
                    # 构造 URL
                    kw = {lookup: getattr(obj, lookup)}
                    path = reverse(src["url_name"], kwargs=kw)
                    loc = f"{self.base_url}{path}"
                    # lastmod
                    if lastmod_field:
                        last_dt = getattr(obj, lastmod_field)
                        lastmod = isoformat(last_dt) if last_dt else isoformat(datetime.now(timezone.utc))
                    else:
                        lastmod = isoformat(datetime.now(timezone.utc))

                    # 内容字段采集用于哈希
                    fields = {}
                    for f in content_fields:
                        fields[f] = getattr(obj, f, None)

                    urls.append({
                        "loc": loc,
                        "lastmod": lastmod,
                        "hash_fields": fields | {"loc": loc},
                    })
        return urls

class SitemapGenerator:
    def __init__(self, cfg: Dict):
        self.cfg = cfg
        self.output_dir = Path(cfg["OUTPUT_DIR"])
        self.output_name = cfg.get("OUTPUT_NAME", "sitemap.xml")
        self.max_urls = int(cfg.get("MAX_URLS_PER_FILE", 50000))
        self.gzip_enabled = bool(cfg.get("GZIP", True))
        self.state_store = StateStore(cfg["STATE_FILE"])

    def _build_url_xml(self, url_items: Iterable[Dict]) -> bytes:
        # 直接用拼接,避免巨型树;也可改为 lxml/ElementTree
        parts = [XML_HEADER, f'<urlset xmlns="{NS_URLSET}">\n']
        for item in url_items:
            parts.append("  <url>\n")
            parts.append(f"    <loc>{item['loc']}</loc>\n")
            parts.append(f"    <lastmod>{item['lastmod']}</lastmod>\n")
            # 如需 changefreq/priority,可根据配置添加
            parts.append("  </url>\n")
        parts.append("</urlset>\n")
        return "".join(parts).encode("utf-8")

    def _write_atomic(self, path: Path, payload: bytes, gz: bool = False):
        path.parent.mkdir(parents=True, exist_ok=True)
        with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as tmp:
            if gz:
                with gzip.open(tmp, "wb", compresslevel=5) as gzf:
                    gzf.write(payload)
            else:
                tmp.write(payload)
            tmp.flush()
            os.fsync(tmp.fileno())
            tmp_path = Path(tmp.name)
        tmp_path.replace(path)

    def _write_sitemap_files(self, urls: List[Dict]) -> Tuple[List[str], Optional[str]]:
        """
        返回:([sitemap_part_urls], sitemap_index_url|None)
        """
        base_url = self.cfg["BASE_URL"].rstrip("/")
        if len(urls) <= self.max_urls:
            xml = self._build_url_xml(urls)
            out = self.output_dir / self.output_name
            if self.gzip_enabled:
                self._write_atomic(out.with_suffix(out.suffix + ".gz"), xml, gz=True)
            self._write_atomic(out, xml, gz=False)
            return [f"{base_url}/{self.output_name}"], None
        # 分片与索引
        index_entries = []
        part_urls = []
        chunks = [urls[i:i+self.max_urls] for i in range(0, len(urls), self.max_urls)]
        for idx, chunk in enumerate(chunks, start=1):
            xml = self._build_url_xml(chunk)
            name = f"sitemap-{idx}.xml"
            out = self.output_dir / name
            if self.gzip_enabled:
                gz_out = out.with_suffix(out.suffix + ".gz")
                self._write_atomic(gz_out, xml, gz=True)
                part_urls.append(f"{base_url}/{gz_out.name}")
            self._write_atomic(out, xml, gz=False)
            part_urls.append(f"{base_url}/{name}")
            # 索引项(引用 .xml 或 .xml.gz,搜索引擎均可)
            index_entries.append({
                "loc": f"{base_url}/{name}",
                "lastmod": isoformat(datetime.now(timezone.utc)),
            })
        # 写索引
        parts = [XML_HEADER, '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n']
        for it in index_entries:
            parts.append("  <sitemap>\n")
            parts.append(f"    <loc>{it['loc']}</loc>\n")
            parts.append(f"    <lastmod>{it['lastmod']}</lastmod>\n")
            parts.append("  </sitemap>\n")
        parts.append("</sitemapindex>\n")
        index_xml = "".join(parts).encode("utf-8")
        index_out = self.output_dir / "sitemap_index.xml"
        if self.gzip_enabled:
            self._write_atomic(index_out.with_suffix(index_out.suffix + ".gz"), index_xml, gz=True)
        self._write_atomic(index_out, index_xml, gz=False)
        return part_urls, f"{base_url}/sitemap_index.xml"

    def generate_and_publish(self) -> Dict:
        collector = UrlCollector(self.cfg)
        urls = collector.collect()

        # 状态对比,判断是否需要重建
        changed = False
        current_set = set()
        for item in urls:
            loc = item["loc"]
            current_set.add(loc)
            content_hash = compute_content_hash(item["hash_fields"])
            prev = self.state_store.get_url_state(loc)
            if not prev or prev["hash"] != content_hash:
                changed = True
            # 更新内存状态(稍后统一保存)
            self.state_store.set_url_state(loc, content_hash, item["lastmod"])

        # 删除不存在的旧 URL
        self.state_store.remove_missing(current_set)

        # 合并/节流:冷却窗口内,如无新增/删除/实质修改,不写文件
        cooldown = int(self.cfg.get("CHANGE_COOLDOWN_SECONDS", 0))
        if cooldown > 0 and not changed:
            # 若近期已写过(未过冷却期),且无变化,则跳过
            import time
            if time.time() - self.state_store.data.get("last_write_ts", 0) < cooldown:
                return {"changed": False, "reason": "cooldown_no_change"}

        # 有变化或过了冷却期,重建发布
        part_urls, index_url = self._write_sitemap_files(urls)
        self.state_store.mark_written()
        self.state_store.save()

        # 可选:搜索引擎 ping(建议做异常保护与速率控制)
        if self.cfg.get("PING_SEARCH_ENGINES", False):
            self._ping_search_engines(index_url or part_urls[0])

        return {"changed": True, "count": len(urls), "index": index_url, "parts": part_urls}

    def _ping_search_engines(self, sitemap_url: str):
        import urllib.parse, urllib.request
        endpoints = [
            f"https://www.google.com/ping?sitemap={urllib.parse.quote(sitemap_url)}",
            f"https://www.bing.com/ping?sitemap={urllib.parse.quote(sitemap_url)}",
        ]
        for ep in endpoints:
            try:
                with urllib.request.urlopen(ep, timeout=5) as resp:
                    resp.read()
            except Exception:
                # 忽略 ping 失败,避免影响主流程
                pass

管理命令入口

# your_app/management/commands/update_sitemap.py
from django.core.management.base import BaseCommand
from django.conf import settings
from your_app.sitemap_auto.generator import SitemapGenerator

class Command(BaseCommand):
    help = "Generate and deploy XML sitemap to Nginx web root."

    def add_arguments(self, parser):
        parser.add_argument("--force", action="store_true", help="Force regenerate regardless of change detection.")

    def handle(self, *args, **options):
        cfg = getattr(settings, "SITEMAP_AUTO", None)
        if not cfg:
            self.stderr.write(self.style.ERROR("SITEMAP_AUTO not configured in settings.py"))
            return

        gen = SitemapGenerator(cfg)
        if options["force"]:
            # 强制重建:清空状态后重建
            gen.state_store.data = {"urls": {}, "last_write_ts": 0}
        result = gen.generate_and_publish()

        if result.get("changed"):
            self.stdout.write(self.style.SUCCESS(
                f"Sitemap updated. URLs: {result.get('count')}, index: {result.get('index')}"
            ))
        else:
            self.stdout.write(self.style.WARNING(f"No changes: {result.get('reason', '')}"))

Django 信号(可选,标记变更,避免阻塞)

# your_app/sitemap_auto/signals.py
import time
from pathlib import Path
from django.conf import settings
from django.db.models.signals import post_save, post_delete
from django.apps import apps

DIRTY_FLAG = Path(settings.SITEMAP_AUTO["OUTPUT_DIR"]) / ".sitemap_dirty.flag"

def mark_dirty(*args, **kwargs):
    # 写一个标记文件,供定时任务或 celery 检测到后生成
    try:
        DIRTY_FLAG.write_text(str(int(time.time())), "utf-8")
    except Exception:
        pass

def connect_signals():
    cfg = settings.SITEMAP_AUTO
    for src in cfg["SOURCES"]:
        if src["type"] == "model":
            app_label, model_name = src["model"].split(".")
            model = apps.get_model(app_label, model_name)
            post_save.connect(mark_dirty, sender=model, dispatch_uid=f"sitemap_dirty_save_{src['model']}")
            post_delete.connect(mark_dirty, sender=model, dispatch_uid=f"sitemap_dirty_del_{src['model']}")

在 Django AppConfig 中自动连接信号:

# your_app/apps.py
from django.apps import AppConfig

class YourAppConfig(AppConfig):
    name = "your_app"

    def ready(self):
        try:
            from .sitemap_auto.signals import connect_signals
            connect_signals()
        except Exception:
            # 避免迁移时因 import 失败影响流程
            pass

定期执行示例

  • 使用 cron(每 6 小时)
    */360 * * * * /path/to/venv/bin/python /path/to/project/manage.py update_sitemap >> /var/log/update_sitemap.log 2>&1
    
  • 使用 systemd 定时器(推荐,便于管理)
    • /etc/systemd/system/update-sitemap.service
      [Unit]
      Description=Update sitemap.xml
      
      [Service]
      Type=oneshot
      WorkingDirectory=/path/to/project
      ExecStart=/path/to/venv/bin/python manage.py update_sitemap
      
    • /etc/systemd/system/update-sitemap.timer
      [Unit]
      Description=Run sitemap update every 6 hours
      
      [Timer]
      OnCalendar=*-*-* 00/6:00:00
      Persistent=true
      
      [Install]
      WantedBy=timers.target
      
    • 启用
      systemctl enable --now update-sitemap.timer
      

结语与扩展

  • 若网站规模巨大(百万级 URL),建议:
    • 在生成器中改用 lxml 的 incremental API 进行流式写入
    • 以模型分区拆分 sitemap(如 blog-sitemap-.xml、page-sitemap-.xml)
    • 使用 Redis 分布式锁控制生成并发
    • 将状态存储改为数据库表,便于横向扩展与统计
  • 若存在多站点/多域名:
    • 按 BASE_URL 拆分输出目录与文件,分别生成并在 robots.txt 中声明多个 Sitemap 条目

此方案兼顾智能变更识别、标准化 XML 构建、原子化部署与运行效率,并与 Django + Nginx 技术栈深度适配。

React 网站自动更新 XML 网站地图方案(Python)

以下方案面向 JavaScript/React 网站,部署在 Linux VPS 环境,提供页面变更智能识别、XML 网站地图生成与根目录部署的自动化脚本与实践建议。


1. 依赖项清单

请在 Python 3.10+ 环境中安装以下依赖:

  • playwright(支持执行 JavaScript 渲染,适合 React SPA)
  • beautifulsoup4(内容解析与清洗)
  • lxml(高性能 XML 生成)
  • requests(拉取 robots.txt、健康检查)
  • urllib3(可选,网络优化)
  • sqlite3(标准库,用于持久化变更状态)
  • typing-extensions(某些环境兼容)
  • python-dotenv(可选,配置注入)
  • logging(标准库,日志输出)
  • cron(系统级定时器,Linux 环境使用)

安装示例:

pip install playwright beautifulsoup4 lxml requests python-dotenv
python -m playwright install

2. 脚本逻辑架构

整体流程:

  1. 初始化配置与数据库,加载上次运行记录。
  2. 读取 robots.txt,确定允许抓取的路径规则。
  3. 以 Playwright 启动无头浏览器,从站点入口开始 BFS 爬取:
  4. 对每个 URL 计算内容指纹(DOM 清洗 + 规范化文本哈希),对比历史记录识别新增与修改。
  5. 构建 XML 网站地图(urlset),添加 loc、lastmod、changefreq、priority。
  6. 原子化部署到网站根目录并可选生成 sitemap.gz。
  7. 写回数据库,输出日志与状态报告。

核心函数:

  • load_config(): 载入基础配置(站点地址、部署目录、排除规则等)
  • setup_db(): 初始化 SQLite 表结构与索引
  • allowed_by_robots(url): 使用 robots.txt 判定是否允许抓取
  • normalize_url(url): URL 归一化(去碎片、过滤追踪参数)
  • crawl_site(base_url): Playwright 并发 BFS 抓取,收集候选 URL
  • fingerprint_html(html): 内容清洗与哈希,降低误报
  • detect_changes(urls): 与历史指纹对比,标注新增、修改、删除
  • build_sitemap(entries): 生成标准 XML
  • deploy_sitemap(xml_path, web_root): 原子写入与权限校验
  • main(): 调度入口,支持 CLI

3. 变更检测策略

为降低 SPA 环境中的误报率与漏报:

  • 渲染级抓取:使用 Playwright 执行 JS,避免仅静态 HTML 的不足。
  • DOM 清洗指纹:
    • 移除易变区域:script/style/noscript,以及可配置选择器(如广告、浮动通知、时间戳等)。
    • 规范化文本:合并空白、去掉多余换行与非语义字符。
    • 哈希算法:SHA-256。
  • 查询参数归一化:
    • 移除跟踪参数(utm_*, gclid, fbclid 等)。
    • 可配置保留重要查询键(如分页 ?page=)。
  • 路由发现多源融合:
    • 爬虫 BFS 链接发现。
    • 可选路由清单(例如构建期间导出 routes.json,由前端写入 /__routes.json)。
    • 可选业务数据源(如 /api/posts 获取 slug 列表,拼装 /posts/{slug})。
  • 持久化与阈值:
    • SQLite 存储 URL 与内容哈希、上次时间戳。
    • 修改判断:哈希变化即判定为实质性修改(可扩展为相似度阈值)。
  • 删除检测:
    • 本次未发现但历史存在的 URL 视为下线,不再生成到 sitemap(可选保留以避免硬删除)。

4. 网站地图生成机制

生成的 XML 文件满足标准:

  • 根元素:urlset,xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  • 每个 URL 元素包含:
    • loc:完整绝对地址
    • lastmod:ISO 8601 时间(UTC)
    • changefreq:根据页面类型可配置(默认每日/每周)
    • priority:默认 0.5,可根据深度/类型调整(首页更高)
  • 对于大站:
    • 可拆分多个 sitemapN.xml 并生成 sitemap_index.xml。
  • 可选压缩:生成 sitemap.xml.gz。

5. 部署方案

  • 原子写入:先写入临时文件(例如 sitemap.xml.tmp),完成后使用 mv 原子替换。
  • 路径:部署到网站根目录(例如 /var/www/example.com/html/sitemap.xml)。
  • 权限与所有者:确保 web 用户可读(例如 chmod 644,chown www-data:www-data)。
  • 健康检查:完成后使用 HTTP HEAD 请求检测可访问性。
  • CI/CD 集成:构建完成后触发脚本或在 VPS 上定时运行。

6. 定期执行建议

  • 触发策略组合:
    • 构建后:在前端构建发布脚本结束时运行(确保路由最新)。
    • 定时:每天 02:00 运行(低流量时段),对于内容更新频繁的站点每 6 小时一次。
  • Linux Cron 示例:
# 编辑 crontab
crontab -e

# 每天 02:00 执行
0 2 * * * /usr/bin/python3 /opt/sitemap/sitemap_updater.py \
  --base-url https://example.com \
  --web-root /var/www/example.com/html \
  --max-depth 3 >> /var/log/sitemap_updater.log 2>&1

7. 性能优化方案

  • 并发爬取:Playwright 多页面上下文 + 限流(Semaphore)。
  • URL 去重与缓存:已抓取/失败 URL 集合;对静态资源忽略。
  • 动态内容数据源:引入 API 端点列表,避免页面层级过深导致漏抓。
  • 选择器排除优化:针对站点自定义排除动态区域(如实时组件、用户个性化模块),降低误报。
  • 站点规模适配:
    • 大型站分区:按路径前缀分批执行。
    • Sitemap 切分与索引:避免单文件过大(>50,000 URL 或 >50MB)。
  • 失败重试:对偶发网络错误进行退避重试。
  • 监控与告警:运行失败或变更量异常时邮件/Webhook 报警。

Python 脚本示例

该脚本适配 React SPA,支持 Playwright 渲染抓取、变更检测与 XML 网站地图生成与部署。你可根据站点结构微调参数与排除选择器。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import asyncio
import argparse
import hashlib
import logging
import os
import re
import shutil
import sqlite3
import time
from datetime import datetime, timezone
from typing import Dict, List, Set, Tuple, Optional
from urllib.parse import urlparse, urljoin, urlunparse, parse_qsl, urlencode

import requests
from bs4 import BeautifulSoup
from lxml import etree
from urllib.robotparser import RobotFileParser

from playwright.async_api import async_playwright

# ----------------------------
# 配置与常量
# ----------------------------

DEFAULT_EXCLUDE_QUERY_KEYS = {
    "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
    "gclid", "fbclid", "msclkid", "ref", "ref_src"
}
DEFAULT_KEEP_QUERY_KEYS = {"page"}  # 可根据业务保留分页等查询键

DEFAULT_EXCLUDE_SELECTORS = [
    "script", "style", "noscript",
    # 常见动态或噪声区域(可按需调整)
    "header .timestamp", ".ad", ".ads", ".advert", ".cookie-banner",
    ".toast", ".notification", ".modal", ".overlay", ".live-counter"
]

DB_DIR = os.path.join(os.path.expanduser("~"), ".sitemap_cache")
DB_PATH = os.path.join(DB_DIR, "sitemap.db")

# ----------------------------
# 工具函数
# ----------------------------

def ensure_dir(path: str):
    if not os.path.exists(path):
        os.makedirs(path, exist_ok=True)

def isoformat_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def normalize_url(url: str, base_netloc: str) -> Optional[str]:
    """归一化 URL:同源过滤、去碎片、过滤追踪参数,仅保留白名单查询键。"""
    try:
        parsed = urlparse(url)
        if not parsed.scheme:
            return None
        if parsed.netloc != base_netloc:
            return None
        # 去 fragment
        fragmentless = parsed._replace(fragment="")
        # 过滤查询参数
        query_pairs = parse_qsl(fragmentless.query, keep_blank_values=True)
        filtered = []
        for k, v in query_pairs:
            if k in DEFAULT_EXCLUDE_QUERY_KEYS:
                continue
            if k in DEFAULT_KEEP_QUERY_KEYS:
                filtered.append((k, v))
        new_query = urlencode(filtered, doseq=True)
        normalized = fragmentless._replace(query=new_query)
        return urlunparse(normalized)
    except Exception:
        return None

def sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest()

def clean_html_to_text(html: str, extra_selectors: List[str]) -> str:
    """清洗 HTML,移除噪声,输出规范化文本用于指纹。"""
    soup = BeautifulSoup(html, "html.parser")

    # 统一移除默认 + 额外的选择器
    for selector in set(DEFAULT_EXCLUDE_SELECTORS + extra_selectors):
        for tag in soup.select(selector):
            tag.decompose()

    # 删除隐藏元素
    for tag in soup.select("[hidden], [aria-hidden=true]"):
        tag.decompose()

    # 规范化文本
    text = soup.get_text(separator=" ", strip=True)
    text = re.sub(r"\s+", " ", text).strip()
    return text

# ----------------------------
# 数据库持久化
# ----------------------------

def setup_db():
    ensure_dir(DB_DIR)
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS pages (
            url TEXT PRIMARY KEY,
            content_hash TEXT,
            last_seen TEXT,
            last_modified TEXT
        );
    """)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_last_seen ON pages(last_seen);")
    conn.commit()
    return conn

def load_existing(conn) -> Dict[str, Tuple[str, str, str]]:
    cur = conn.execute("SELECT url, content_hash, last_seen, last_modified FROM pages;")
    return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}

def upsert_page(conn, url: str, content_hash: str, last_modified: Optional[str] = None):
    now = isoformat_now()
    if last_modified is None:
        last_modified = now
    conn.execute("""
        INSERT INTO pages(url, content_hash, last_seen, last_modified)
        VALUES(?, ?, ?, ?)
        ON CONFLICT(url) DO UPDATE SET
            content_hash=excluded.content_hash,
            last_seen=excluded.last_seen,
            last_modified=CASE
                WHEN pages.content_hash != excluded.content_hash THEN excluded.last_modified
                ELSE pages.last_modified
            END;
    """, (url, content_hash, now, last_modified))

def mark_seen(conn, url: str):
    now = isoformat_now()
    conn.execute("UPDATE pages SET last_seen=? WHERE url=?;", (now, url))

# ----------------------------
# 机器人协议
# ----------------------------

def load_robots(base_url: str) -> RobotFileParser:
    parsed = urlparse(base_url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
    rp = RobotFileParser()
    try:
        rp.set_url(robots_url)
        rp.read()
    except Exception:
        # 不可用则默认允许
        rp = RobotFileParser()
        rp.parse([])
    return rp

def allowed_by_robots(rp: RobotFileParser, url: str, ua: str = "SitemapUpdater"):
    try:
        return rp.can_fetch(ua, url)
    except Exception:
        return True

# ----------------------------
# Playwright 抓取
# ----------------------------

class Crawler:
    def __init__(self, base_url: str, max_depth: int = 3, concurrency: int = 4, extra_selectors: Optional[List[str]] = None):
        self.base_url = base_url.rstrip("/")
        self.parsed_base = urlparse(self.base_url)
        self.base_netloc = self.parsed_base.netloc
        self.max_depth = max_depth
        self.concurrency = concurrency
        self.extra_selectors = extra_selectors or []
        self.visited: Set[str] = set()
        self.discovered: Set[str] = set()
        self.sem = asyncio.Semaphore(concurrency)
        self.rp = load_robots(self.base_url)

    async def crawl(self) -> Dict[str, Dict]:
        """返回 URL -> {hash, lastmod} 的映射。"""
        results: Dict[str, Dict] = {}
        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=True)
            context = await browser.new_context()
            try:
                # BFS 队列 (url, depth)
                queue: List[Tuple[str, int]] = [(self.base_url, 0)]
                while queue:
                    url, depth = queue.pop(0)
                    if depth > self.max_depth:
                        continue
                    normalized = normalize_url(url, self.base_netloc)
                    if not normalized:
                        continue
                    if normalized in self.visited:
                        continue
                    if not allowed_by_robots(self.rp, normalized):
                        logging.debug(f"Disallowed by robots: {normalized}")
                        continue

                    # 并发控制
                    await self.sem.acquire()
                    asyncio.create_task(self._process_url(context, normalized, depth, queue, results))
                # 等待所有任务完成
                while self.sem._value != self.concurrency:
                    await asyncio.sleep(0.05)
            finally:
                await context.close()
                await browser.close()
        return results

    async def _process_url(self, context, url: str, depth: int, queue: List[Tuple[str, int]], results: Dict[str, Dict]):
        page = await context.new_page()
        try:
            logging.info(f"Crawling: {url} (depth={depth})")
            response = await page.goto(url, wait_until="networkidle", timeout=30000)
            # 有些 React SPA 总是 200,但我们仍继续提取
            html = await page.content()

            # Canonical URL
            canonical = await page.evaluate("""
                () => {
                    const link = document.querySelector('link[rel="canonical"]');
                    return link ? link.href : null;
                }
            """)
            target_url = normalize_url(canonical, self.base_netloc) or url

            # 内容指纹
            text = clean_html_to_text(html, self.extra_selectors)
            content_hash = sha256_text(text)
            results[target_url] = {
                "hash": content_hash,
                "lastmod": isoformat_now()
            }
            self.visited.add(target_url)
            self.discovered.add(target_url)

            # 发现新链接
            links = await page.evaluate("""
                () => Array.from(document.querySelectorAll('a[href]'))
                          .map(a => a.href)
            """)
            for href in set(links or []):
                norm = normalize_url(href, self.base_netloc)
                if not norm:
                    continue
                if norm in self.visited:
                    continue
                # 过滤常见静态资源与登录页等(可扩展)
                if re.search(r"\.(jpg|jpeg|png|gif|svg|webp|pdf|zip|rar|7z|mp4|mp3)$", norm, re.I):
                    continue
                if re.search(r"/login|/auth|/signup", norm, re.I):
                    continue
                queue.append((norm, depth + 1))
        except Exception as e:
            logging.warning(f"Failed to crawl {url}: {e}")
        finally:
            await page.close()
            self.sem.release()

# ----------------------------
# 变更检测与网站地图
# ----------------------------

def detect_changes(conn, crawled: Dict[str, Dict]) -> Tuple[List[str], List[str], List[str]]:
    """返回 (new_urls, modified_urls, removed_urls)"""
    existing = load_existing(conn)
    new_urls, modified_urls = [], []
    current_set = set(crawled.keys())
    previous_set = set(existing.keys())

    for url, info in crawled.items():
        h = info["hash"]
        if url not in existing:
            new_urls.append(url)
            upsert_page(conn, url, h, last_modified=info["lastmod"])
        else:
            old_hash = existing[url][0]
            if old_hash != h:
                modified_urls.append(url)
                upsert_page(conn, url, h, last_modified=info["lastmod"])
            else:
                mark_seen(conn, url)

    removed_urls = sorted(list(previous_set - current_set))
    return new_urls, modified_urls, removed_urls

def build_sitemap_xml(base_url: str, urls: List[Tuple[str, str]], default_changefreq: str = "daily") -> bytes:
    """
    urls: List of (url, lastmod)
    返回 XML 字节数据
    """
    NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
    urlset = etree.Element("urlset", xmlns=NS)
    # 可按路径设置优先级
    def priority_for(u: str) -> str:
        if u.rstrip("/") == base_url.rstrip("/"):
            return "1.0"
        depth = len(urlparse(u).path.strip("/").split("/")) if urlparse(u).path.strip("/") else 1
        return "0.5" if depth <= 2 else "0.3"

    for u, lastmod in sorted(urls):
        url_el = etree.SubElement(urlset, "url")
        loc = etree.SubElement(url_el, "loc");        loc.text = u
        lm  = etree.SubElement(url_el, "lastmod");    lm.text = lastmod
        cf  = etree.SubElement(url_el, "changefreq"); cf.text = default_changefreq
        pr  = etree.SubElement(url_el, "priority");   pr.text = priority_for(u)

    return etree.tostring(urlset, xml_declaration=True, encoding="utf-8", pretty_print=True)

def deploy_sitemap(xml_bytes: bytes, web_root: str, filename: str = "sitemap.xml"):
    ensure_dir(web_root)
    tmp_path = os.path.join(web_root, f".{filename}.tmp")
    final_path = os.path.join(web_root, filename)
    with open(tmp_path, "wb") as f:
        f.write(xml_bytes)
    # 原子替换
    shutil.move(tmp_path, final_path)
    # 权限(按需调整)
    os.chmod(final_path, 0o644)

    logging.info(f"Sitemap deployed: {final_path}")

    # 健康检查(可选)
    try:
        # 通过 base_url 拼接确认
        # 如果不易确定,可跳过或传入完整 URL
        logging.info("Health check skipped (requires public URL).")
    except Exception as e:
        logging.warning(f"Health check failed: {e}")

# ----------------------------
# 主流程
# ----------------------------

async def run(base_url: str, web_root: str, max_depth: int, concurrency: int, extra_selectors: List[str]):
    logging.info("Initializing database...")
    conn = setup_db()

    logging.info("Starting crawler...")
    crawler = Crawler(base_url, max_depth=max_depth, concurrency=concurrency, extra_selectors=extra_selectors)
    crawled = await crawler.crawl()

    logging.info(f"Crawled URLs: {len(crawled)}")
    new_urls, modified_urls, removed_urls = detect_changes(conn, crawled)
    conn.commit()

    logging.info(f"New: {len(new_urls)}, Modified: {len(modified_urls)}, Removed: {len(removed_urls)}")

    # 构建网站地图(仅包含本次发现的所有有效 URL)
    url_list = [(u, crawled[u]["lastmod"]) for u in sorted(crawled.keys())]
    xml_bytes = build_sitemap_xml(base_url, url_list, default_changefreq="daily")

    # 部署
    deploy_sitemap(xml_bytes, web_root)

    conn.close()
    logging.info("Done.")

def parse_args():
    ap = argparse.ArgumentParser(description="React site XML sitemap auto updater")
    ap.add_argument("--base-url", required=True, help="站点入口 URL,例如 https://example.com")
    ap.add_argument("--web-root", required=True, help="网站根目录,例如 /var/www/example.com/html")
    ap.add_argument("--max-depth", type=int, default=3, help="爬取最大深度,默认 3")
    ap.add_argument("--concurrency", type=int, default=4, help="并发页面数,默认 4")
    ap.add_argument("--exclude-selectors", nargs="*", default=[], help="额外排除的 CSS 选择器")
    ap.add_argument("--log-level", default="INFO", choices=["DEBUG","INFO","WARNING","ERROR"])
    return ap.parse_args()

if __name__ == "__main__":
    args = parse_args()
    logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(message)s")
    try:
        asyncio.run(run(
            base_url=args.base_url,
            web_root=args.web_root,
            max_depth=args.max_depth,
            concurrency=args.concurrency,
            extra_selectors=args.exclude_selectors
        ))
    except KeyboardInterrupt:
        logging.warning("Interrupted by user")

使用与集成建议

  • 运行示例:
python /opt/sitemap/sitemap_updater.py \
  --base-url https://example.com \
  --web-root /var/www/example.com/html \
  --max-depth 3 \
  --concurrency 6 \
  --exclude-selectors ".live-ticker" ".user-panel"
  • 与 React 构建集成:

    • 在构建流程中导出路由清单(如 routes.json),并由脚本定制读取该清单的 URL 追加到 crawler.discovered 集合,以减少对页面点击路径的依赖。
    • 对动态页面(如博客、商品详情),建议提供 API 列表端点,脚本在抓取前预加载,拼装目标 URL 加入队列。
  • robots.txt:

    • 确保允许抓取关键路由,例如:
      User-agent: *
      Allow: /
      Sitemap: https://example.com/sitemap.xml
      

如需进一步定制(例如多 sitemap 拆分、优先级策略、API 路由聚合、在 CI/CD 内触发执行),可告知具体站点结构与规模,我将提供针对性的增强脚本。

自动化更新 XML 网站地图方案(适配 Hugo + Kubernetes)

1. 依赖项清单

  • 运行环境
    • Python 3.10+
    • Hugo CLI(建议最新稳定版)
    • Kubernetes 集群(生产站点运行环境)
  • Python 库
    • gitpython(读取 Git 历史,用于 lastmod 与变更识别)
    • python-frontmatter(解析 Markdown Front Matter,支持 TOML/YAML/JSON)
    • tomli(读取 Hugo 的 TOML 配置)
    • PyYAML(如使用 YAML 格式配置)
    • beautifulsoup4(解析生成页中的 canonical 链接)
    • lxml 或使用内置 xml.etree.ElementTree(生成 XML)
    • watchdog(可选,用于文件监听模式)
    • kubernetes(可选,若需通过 K8s API/Job 推送)
  • 非必须但推荐
    • 单独的持久化卷(PVC),作为 Web 根目录与脚本共享卷
    • CI/CD 工具(如 GitHub Actions),触发构建与脚本执行

示例安装命令:

pip install gitpython python-frontmatter tomli PyYAML beautifulsoup4 lxml watchdog
# Hugo 安装请参考官方文档或使用基础镜像 klakegg/hugo

2. 脚本逻辑架构

  • 目标:当 Hugo 站点页面新增、删除或实质性修改时,自动更新 XML 网站地图并部署到网站根目录(例如 public/sitemap.xml 或 Web 根路径)。
  • 模块与流程
    1. 配置加载
      • 读取 Hugo 配置(config.toml / config.yaml),获取 baseURL、publishDir(默认 public)、多语言设置。
    2. 变更检测
      • 扫描 content 目录(Markdown/HTML),解析 Front Matter,结合 Git 历史获取 lastmod。
      • 构建指纹(哈希)并与上次缓存状态对比,识别新增、修改、删除。
      • 支持按需忽略非影响输出的字段变更(控制误报)。
    3. 构建输出索引
      • 优先使用 Hugo 构建产物中的 JSON 索引(如配置自定义输出),准确获取 permalink 与 lastmod。
      • 缺省回退:扫描 publishDir 的 HTML 文件,解析 canonical 链接或推导 URL。
    4. 网站地图生成
      • 生成符合标准的 sitemap.xml(支持多语言/alternate)、压缩可选(sitemap.xml.gz)。
    5. 部署到根目录
      • 将 sitemap.xml 写入 publishDir 或共享卷中 Web 根路径,保持稳定可访问。
    6. 状态更新与日志
      • 更新缓存(JSON),记录已处理的 commit 与页面指纹。
      • 结构化日志输出,便于在 K8s 中观察。

3. 变更检测策略

  • 数据源与优先级
    • Git 历史为主:使用 GitPython 获取文件最后一次提交时间(last commit ISO8601),作为 lastmod。
    • Front Matter 作为补充:若存在 lastmod 字段则优先;否则使用 date;无则使用 Git 或文件 mtime。
  • 指纹构建(防误报)
    • 参与指纹的关键字段:slug、url、aliases、title、date、lastmod、draft、type、layout、permalink。
    • 内容主体:Markdown 正文(去除空白差异)、资源引用路径。
    • 可配置忽略字段:如 tags、categories、作者信息等对 URL 与主要内容不敏感的字段。
  • 新增/删除识别
    • 新增:缓存中不存在且非 draft 的页面。
    • 删除:缓存中存在但当前扫描缺失或被标记 private。
  • 触发条件
    • 存在新增/删除/指纹变更,或上次构建后 Hugo 的 publishDir 中无 sitemap.xml。
    • 可选阈值:小改动批量合并触发,避免频繁更新。

4. 网站地图生成机制

  • 源数据
    • 首选:Hugo 自定义 JSON 索引(推荐配置),含 permalink、lastmod、alternates(多语言)、changefreq/priority 可选。
    • 备选:扫描 publishDir,解析每个 HTML 的 canonical 链接;lastmod 使用 Git/Front Matter 或输出文件 mtime。
  • XML 结构(标准)
    • 根元素:urlset,xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
    • 多语言:使用 xmlns:xhtml 并添加 xhtml:link rel="alternate" hreflang。
    • 字段:
      • loc:绝对 URL
      • lastmod:ISO8601(UTC),例如 2025-11-26T10:23:00Z
      • changefreq(可选):按目录/类型配置(如 posts 为 weekly,docs 为 monthly)
      • priority(可选):首页较高(0.8),普通页面(0.5)
  • 大型站点
    • 支持生成 sitemap_index.xml,将多个分卷(如 posts、docs、pages)拆分;每卷至多 50,000 条或 50MB。

推荐在 Hugo 中添加 JSON 索引(更准确,减少复杂映射): 在 config.toml 添加:

[outputs]
home = ["HTML", "JSON"]
section = ["HTML", "JSON"]
page = ["HTML", "JSON"]

在 layouts/_default/list.json 与 layouts/_default/single.json 中输出所需字段(permalink、lastmod、alternates 等),Python 脚本读取 public/*.json 合并为索引。

5. 部署方案(Kubernetes)

  • 方案 A:Sidecar 容器(推荐,低延迟)
    • Web 容器与 Python 脚本容器共享同一个 PVC(网站根目录)。
    • Sidecar 监听/定时执行:变更检测 -> 执行 hugo -> 生成 sitemap.xml -> 写入共享卷根路径。
  • 方案 B:Kubernetes CronJob(稳定,定时批处理)
    • CronJob 定期拉取仓库、执行脚本、更新 sitemap.xml 到共享卷。
  • 方案 C:CI/CD 构建阶段生成
    • 在构建镜像或发布产物时,运行脚本并将 sitemap.xml 打包入镜像的 Web 根路径。

示例 CronJob 清单(简化版):

apiVersion: batch/v1
kind: CronJob
metadata:
  name: sitemap-updater
spec:
  schedule: "*/30 * * * *"  # 每 30 分钟
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: updater
              image: ghcr.io/your-org/hugo-python:latest  # 基于 Hugo + Python
              command: ["python", "/app/sitemap_updater.py"]
              env:
                - name: SITE_BASE_URL
                  value: "https://example.com"
                - name: SITE_ROOT_PATH
                  value: "/site-root"            # Web 根目录挂载点
                - name: HUGO_PUBLISH_DIR
                  value: "/site-root"            # Hugo 输出目录与 Web 根一致
              volumeMounts:
                - name: site-pvc
                  mountPath: /site-root
            # 如需从 Git 拉源,可在此添加 initContainer 执行 git clone
          restartPolicy: OnFailure
          volumes:
            - name: site-pvc
              persistentVolumeClaim:
                claimName: site-content-pvc

6. 定期执行建议

  • 如果站点更新频率高(博客/新闻):每 10–30 分钟执行一次 CronJob。
  • 文档类站点:每日或每 6 小时。
  • 与 Git 合流:
    • 推荐在 CI/CD 中触发脚本(合并到 main 时执行),生产环境 CronJob 作为兜底。
  • 谷歌/Bing 抓取优化:
    • 重要更新后(新内容发布),立即执行脚本并向搜索引擎主动 ping(可扩展加 HTTP 请求到 /ping 的 API)。

7. 性能优化方案

  • 增量索引与缓存
    • 缓存页面指纹到 JSON(仅更新变化条目),减少无效重建。
  • 并发处理
    • 并行计算文件哈希与 Git 最后提交时间(concurrent.futures)。
  • 目录过滤
    • 仅扫描 content、layouts(影响输出),忽略 static/assets 等不影响 URL 的路径。
  • 大型站点分卷
    • 自动分片 sitemap(sitemap_index.xml),并启用 gzip 压缩以降低 IO。
  • 异常保护
    • 超时控制与失败重试;不阻塞 Web 服务。
  • 扩展能力
    • 支持多语言 alternate 链接、定制 changefreq/priority、自动更新 robots.txt(追加 Sitemap: ...)。

Python 脚本示例(sitemap_updater.py)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Hugo + Kubernetes 自动更新 XML 网站地图脚本
- 变更检测(Git + Front Matter + 内容哈希)
- 构建输出索引(优先读取自定义 JSON;回退扫描 public)
- 生成 sitemap.xml(可选分片与 gzip)
- 部署到网站根目录(publishDir 或共享卷)
"""

import os
import sys
import json
import hashlib
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import frontmatter
from git import Repo
from bs4 import BeautifulSoup

try:
    import tomli  # Python 3.10+
except ImportError:
    tomli = None

try:
    import yaml  # PyYAML
except ImportError:
    yaml = None

from xml.etree.ElementTree import Element, SubElement, ElementTree

# --------------------
# 配置与常量
# --------------------

CACHE_DIR = Path(".cache")
STATE_FILE = CACHE_DIR / "sitemap_state.json"
DEFAULT_PUBLISH_DIR = "public"
XML_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
XML_XHTML_NS = "http://www.w3.org/1999/xhtml"
MAX_URLS_PER_SITEMAP = 50000

# --------------------
# 工具函数
# --------------------

def isoformat_utc(dt: datetime) -> str:
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()

def read_file_bytes(path: Path) -> bytes:
    with path.open("rb") as f:
        return f.read()

def ensure_cache_dir():
    CACHE_DIR.mkdir(exist_ok=True)

def load_json(path: Path) -> dict:
    if not path.exists():
        return {}
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)

def save_json(path: Path, data: dict):
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def log(msg: str):
    print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)

# --------------------
# 配置加载
# --------------------

def load_hugo_config(config_path: Optional[Path] = None) -> dict:
    """
    支持读取 config.toml / config.yaml
    """
    cfg = {}
    if config_path is None:
        # 按惯例查找
        candidates = [Path("config.toml"), Path("config.yaml"), Path("config.yml")]
        config_path = next((p for p in candidates if p.exists()), None)
    if config_path is None:
        log("未找到 Hugo 配置文件,使用环境变量与默认值。")
        return cfg

    if config_path.suffix == ".toml":
        if tomli is None:
            raise RuntimeError("tomli 未安装,无法解析 TOML。请 pip install tomli。")
        with config_path.open("rb") as f:
            cfg = tomli.load(f)
    else:
        if yaml is None:
            raise RuntimeError("PyYAML 未安装,无法解析 YAML。请 pip install PyYAML。")
        with config_path.open("r", encoding="utf-8") as f:
            cfg = yaml.safe_load(f)
    return cfg or {}

def get_base_url(cfg: dict) -> str:
    env = os.getenv("SITE_BASE_URL")
    base = env or cfg.get("baseURL", "") or ""
    base = base.strip()
    if not base:
        raise RuntimeError("缺少 baseURL(请在 config 或环境变量 SITE_BASE_URL 指定)。")
    # 统一去除末尾斜杠
    return base.rstrip("/")

def get_publish_dir(cfg: dict) -> Path:
    env = os.getenv("HUGO_PUBLISH_DIR")
    pub = env or cfg.get("publishDir", DEFAULT_PUBLISH_DIR)
    return Path(pub)

def get_site_root_path() -> Optional[Path]:
    # 若 Web 根与 publishDir 不同,可用此变量指定部署路径
    p = os.getenv("SITE_ROOT_PATH")
    return Path(p) if p else None

# --------------------
# Git 与 Front Matter
# --------------------

def locate_repo(start: Path = Path(".")) -> Optional[Repo]:
    try:
        return Repo(start, search_parent_directories=True)
    except Exception:
        return None

def git_last_commit_iso(repo: Repo, file_path: Path) -> Optional[str]:
    try:
        rel = str(file_path)
        commits = list(repo.iter_commits(paths=rel, max_count=1))
        if commits:
            return commits[0].committed_datetime.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    except Exception:
        pass
    return None

def normalize_frontmatter(meta: dict) -> dict:
    """
    仅保留与输出/URL相关的关键字段,降低误报
    """
    keys = ["slug", "url", "aliases", "title", "date", "lastmod", "draft", "type", "layout", "permalink"]
    return {k: meta.get(k) for k in keys if k in meta}

def compute_page_fingerprint(file_path: Path, repo: Optional[Repo]) -> Tuple[str, str]:
    """
    返回 (fingerprint_hash, lastmod_iso)
    """
    try:
        post = frontmatter.load(file_path)
        meta_norm = normalize_frontmatter(post.metadata or {})
        content_bytes = (post.content or "").encode("utf-8")
        # 结合 Front Matter 与正文
        meta_bytes = json.dumps(meta_norm, ensure_ascii=False, sort_keys=True).encode("utf-8")
        fingerprint = sha256_bytes(meta_bytes + b"\n" + content_bytes)
        # lastmod 优先级:frontmatter.lastmod -> git commit -> frontmatter.date -> mtime
        lastmod = None
        lm = post.metadata.get("lastmod")
        if lm:
            lastmod = lm if isinstance(lm, str) else str(lm)
        if not lastmod and repo:
            lastmod = git_last_commit_iso(repo, file_path)
        if not lastmod and post.metadata.get("date"):
            lm_date = post.metadata.get("date")
            lastmod = lm_date if isinstance(lm_date, str) else str(lm_date)
        if not lastmod:
            ts = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc)
            lastmod = isoformat_utc(ts)
        return fingerprint, lastmod
    except Exception as e:
        log(f"Front Matter 解析失败: {file_path} - {e}")
        # 回退:仅用文件内容与 mtime
        data = read_file_bytes(file_path)
        fingerprint = sha256_bytes(data)
        ts = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc)
        lastmod = isoformat_utc(ts)
        return fingerprint, lastmod

def list_content_files(content_dir: Path = Path("content")) -> List[Path]:
    exts = {".md", ".markdown", ".html"}
    files = []
    for p in content_dir.rglob("*"):
        if p.is_file() and p.suffix.lower() in exts:
            files.append(p)
    return files

# --------------------
# 变更检测
# --------------------

def detect_changes(content_dir: Path, repo: Optional[Repo]) -> Tuple[bool, dict]:
    """
    返回 (has_changes, state)
    state: { "pages": {rel_path: {"fp": ..., "lastmod": ..., "draft": bool}}, "git_head": sha }
    """
    ensure_cache_dir()
    prev = load_json(STATE_FILE)
    prev_pages = prev.get("pages", {})
    prev_head = prev.get("git_head")

    current = {"pages": {}, "git_head": None}
    if repo:
        try:
            current["git_head"] = repo.head.commit.hexsha
        except Exception:
            current["git_head"] = None

    has_changes = False
    for f in list_content_files(content_dir):
        rel = str(f)
        fingerprint, lastmod = compute_page_fingerprint(f, repo)
        # 读取 draft 状态(无则默认 False)
        try:
            post = frontmatter.load(f)
            draft = bool(post.metadata.get("draft", False))
        except Exception:
            draft = False

        current["pages"][rel] = {"fp": fingerprint, "lastmod": lastmod, "draft": draft}

        prev_rec = prev_pages.get(rel)
        if not prev_rec:
            if not draft:
                has_changes = True  # 新增非草稿
        else:
            if prev_rec.get("fp") != fingerprint or prev_rec.get("draft") != draft:
                has_changes = True  # 实质性修改或草稿状态变化

    # 检测删除
    prev_set = set(prev_pages.keys())
    curr_set = set(current["pages"].keys())
    deleted = prev_set - curr_set
    if deleted:
        has_changes = True

    # Git HEAD 变化也可触发(保证与 CI 协同)
    if current["git_head"] and current["git_head"] != prev_head:
        has_changes = True

    return has_changes, current

# --------------------
# Hugo 构建与输出索引
# --------------------

def run_hugo_build():
    cmd = ["hugo", "--minify"]
    log(f"执行 Hugo 构建: {' '.join(cmd)}")
    subprocess.run(cmd, check=True)

def parse_canonical_from_html(html_path: Path) -> Optional[str]:
    try:
        with html_path.open("r", encoding="utf-8", errors="ignore") as f:
            soup = BeautifulSoup(f, "html.parser")
        link = soup.find("link", rel="canonical")
        if link and link.get("href"):
            return link["href"].strip()
    except Exception:
        pass
    return None

def derive_url_from_path(base_url: str, rel_path: str) -> str:
    # Hugo 输出:/path/index.html -> /path/, /file.html -> /file.html
    if rel_path.endswith("/index.html"):
        url_path = rel_path[: -len("index.html")]
    else:
        url_path = rel_path
    url_path = url_path.replace("\\", "/")
    # 确保不出现重复斜杠
    return f"{base_url}/{url_path}".replace("//", "/").replace(":/", "://")

def load_output_index(publish_dir: Path, base_url: str) -> List[dict]:
    """
    返回 [{loc, lastmod}] 列表
    优先使用自定义 JSON 索引(public/*.json),否则扫描 HTML。
    """
    urls = []

    # 1) 尝试读取自定义 JSON 索引(按站点实现而定,此处扫描顶层 JSON)
    json_files = list(publish_dir.glob("*.json"))
    if json_files:
        for jf in json_files:
            try:
                data = load_json(jf)
                # 兼容可能的结构:数组或对象下的列表
                if isinstance(data, list):
                    items = data
                elif isinstance(data, dict) and "pages" in data:
                    items = data["pages"]
                else:
                    items = []
                for it in items:
                    if not isinstance(it, dict):
                        continue
                    loc = it.get("permalink") or it.get("loc") or it.get("url")
                    if not loc:
                        continue
                    lastmod = it.get("lastmod")
                    if not lastmod:
                        # 回退 mtime
                        ts = datetime.fromtimestamp(jf.stat().st_mtime, tz=timezone.utc)
                        lastmod = isoformat_utc(ts)
                    urls.append({"loc": loc, "lastmod": lastmod})
            except Exception as e:
                log(f"读取索引 JSON 失败: {jf} - {e}")

    # 2) 回退扫描 HTML 文件
    if not urls:
        for p in publish_dir.rglob("*.html"):
            rel = str(p.relative_to(publish_dir))
            loc = parse_canonical_from_html(p) or derive_url_from_path(base_url, rel)
            ts = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc)
            urls.append({"loc": loc, "lastmod": isoformat_utc(ts)})

    # 去重
    seen = set()
    dedup = []
    for u in urls:
        if u["loc"] in seen:
            continue
        seen.add(u["loc"])
        dedup.append(u)
    return dedup

# --------------------
# 网站地图生成
# --------------------

def build_sitemap(urls: List[dict], out_path: Path, alternates: Optional[Dict[str, List[dict]]] = None):
    """
    生成 sitemap.xml;可扩展 alternates 用于多语言
    alternates 示例: { loc: [ {"hreflang": "en", "href": "https://.../en/..."} ] }
    """
    urlset = Element("urlset")
    urlset.set("xmlns", XML_NS)
    urlset.set(f"{{{XML_NS}}}schemaLocation", XML_NS)  # 可选
    urlset.set(f"xmlns:xhtml", XML_XHTML_NS)

    for u in urls:
        url_el = SubElement(urlset, "url")
        SubElement(url_el, "loc").text = u["loc"]
        if u.get("lastmod"):
            SubElement(url_el, "lastmod").text = u["lastmod"]
        if u.get("changefreq"):
            SubElement(url_el, "changefreq").text = u["changefreq"]
        if u.get("priority"):
            SubElement(url_el, "priority").text = str(u["priority"])
        if alternates and u["loc"] in alternates:
            for alt in alternates[u["loc"]]:
                link_el = SubElement(url_el, f"{{{XML_XHTML_NS}}}link")
                link_el.set("rel", "alternate")
                link_el.set("hreflang", alt.get("hreflang", "x-default"))
                link_el.set("href", alt["href"])

    tree = ElementTree(urlset)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    tree.write(out_path, encoding="utf-8", xml_declaration=True)
    log(f"已生成网站地图: {out_path} ({len(urls)} 条)")

# --------------------
# 部署与状态更新
# --------------------

def deploy_sitemap(publish_dir: Path, site_root: Optional[Path], sitemap_name: str = "sitemap.xml"):
    src = publish_dir / sitemap_name
    if site_root and site_root != publish_dir:
        dst = site_root / sitemap_name
        data = read_file_bytes(src)
        with dst.open("wb") as f:
            f.write(data)
        log(f"已部署网站地图到站点根目录: {dst}")
    else:
        log(f"网站根目录即 publishDir,无需额外拷贝: {src}")

def update_state(state: dict):
    save_json(STATE_FILE, state)
    log(f"已更新状态缓存: {STATE_FILE}")

# --------------------
# 主流程
# --------------------

def main():
    # 1) 加载配置与环境
    cfg = load_hugo_config()
    base_url = get_base_url(cfg)
    publish_dir = get_publish_dir(cfg)
    site_root = get_site_root_path()
    content_dir = Path("content")
    repo = locate_repo()

    # 2) 变更检测
    has_changes, state = detect_changes(content_dir, repo)
    if not has_changes and (publish_dir / "sitemap.xml").exists():
        log("检测到无变化,且 sitemap 已存在,跳过重建。")
        return

    # 3) Hugo 构建
    run_hugo_build()

    # 4) 构建输出索引
    urls = load_output_index(publish_dir, base_url)

    # 可选:根据目录或类型设置 changefreq/priority
    for u in urls:
        # 简单示例规则:主页优先级高
        if u["loc"].rstrip("/") == base_url:
            u["priority"] = 0.8
            u["changefreq"] = "daily"
        elif "/posts/" in u["loc"] or "/blog/" in u["loc"]:
            u["priority"] = 0.6
            u["changefreq"] = "weekly"
        else:
            u["priority"] = 0.5
            u["changefreq"] = "monthly"

    # 5) 生成网站地图
    sitemap_path = publish_dir / "sitemap.xml"
    build_sitemap(urls, sitemap_path)

    # 6) 部署到根目录
    deploy_sitemap(publish_dir, site_root)

    # 7) 更新状态
    update_state(state)

if __name__ == "__main__":
    try:
        t0 = time.time()
        main()
        log(f"完成,耗时 {time.time() - t0:.2f}s")
    except subprocess.CalledProcessError as e:
        log(f"Hugo 构建失败: {e}")
        sys.exit(1)
    except Exception as e:
        log(f"执行失败: {e}")
        sys.exit(1)

附加:Hugo 模板输出 JSON 索引示例(提高准确性)

在 layouts/_default/sitemap.json(或单页 list/single 对应 json 模板)中:

{{- /* 输出基础索引 */ -}}
{
  "pages": [
    {{- $first := true -}}
    {{- range .Site.RegularPages -}}
      {{- if not $first }},{{ end -}}
      {
        "permalink": "{{ .Permalink }}",
        "lastmod": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}",
        "section": "{{ .Section }}",
        "language": "{{ .Lang }}"
      }
      {{- $first = false -}}
    {{- end -}}
  ]
}

在 config.toml 配置对应输出:

[outputs]
home = ["HTML", "JSON"]
page = ["HTML", "JSON"]
section = ["HTML", "JSON"]

备注与最佳实践

  • Hugo 已自带 sitemap.xml 生成;若无需复杂变更检测与多语言扩展,可直接启用 Hugo 的内建功能。但本方案更侧重自动化监控、精确 lastmod 与定制策略。
  • 若站点启用多语言(languages),建议在 JSON 索引中输出 .Translations 并在 Python 中写入 xhtml:link。
  • 确保 robots.txt 中包含 Sitemap: https://example.com/sitemap.xml,可由脚本在部署后自动校验或追加。

示例详情

解决的问题

为开发者提供一种自动化解决方案,通过编写Python脚本实现XML网站地图的动态更新,以适应网站页面的变更和优化SEO表现,同时兼顾代码规范和性能优化。

适用用户

中小企业网站管理员

无需手动处理复杂的地图更新,利用自动化脚本轻松管理网站SEO,帮助提升搜索排名。

技术型自由职业者

为客户快速搭建并维护为搜索引擎优化的智能化网站地图,节省人工工作时间,提高项目交付价值。

教育或培训平台开发者

通过脚本自动化管理不断变化的内容页面,将更多时间集中在内容策划与教学质量提升上。

特征总结

智能化生成与更新网站地图,自动检测页面变更并实时更新,确保网站SEO优化效果最大化。
支持全面的变更自动捕获,从新增页面到已有页面的修改,精准甄别关键内容变动,减少人工监控成本。
内置智能性能优化建议,提升脚本运行效率,让大规模网站的地图更新更快捷、精准。
一键完成网站地图的根目录部署,让新生成的XML地图快速上线,不影响用户访问。
全流程自动化:从变更识别、地图生成到发布,全自动完成,节省开发者时间和精力。
支持不同规模和架构网站的适配性强,灵活处理多语言开发及部署环境。
便捷维护:通过模块化架构和时效性运行设置,轻松配置定期自动运行脚本,无需担心遗漏更新。
确保生成的网站地图结构规范,通过标准校验工具,提升搜索引擎对网页内容的抓取效率。
为开发者提供灵活的扩展场景,从单一站点到多站点管理,适应多场景需求。
精准控制误报率的变更算法,帮助开发者减少不必要的干扰,提高生产效率。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥25.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 373 tokens
- 3 个可调节参数
{ 网站编程语言 } { 网站框架类型 } { 网站托管环境 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
使用提示词兑换券,低至 ¥ 9.9
了解兑换券 →
限时半价

不要错过!

半价获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59