Hermes 维护 Trilium Sitemap 方案

一、链路架构图

┌─────────────────────────────────────────────────────────────┐
│                    Google Search Console                      │
│  → 读取 sitemap.xml → 发现 514 条 URL → 爬取/索引           │
└───────────────────────────┬─────────────────────────────────┘
                            │ HTTPS
                            ▼
┌─────────────────────────────────────────────────────────────┐
│              Nginx (反向代理 trilium.atibm.com)               │
│                                                              │
│  location = /sitemap.xml {                                   │
│      proxy_pass https://trilium.atibm.com/share/api/notes/   │
│                  387btDIOMxHp/download;                       │
│  }                                                            │
│                                                              │
│  → 将 /sitemap.xml 代理到 Trilium 的文件型笔记的下载链接      │
└───────────────────────────┬─────────────────────────────────┘
                            │ Docker 内网
                            ▼
┌─────────────────────────────────────────────────────────────┐
│              Trilium 容器 (zadam/trilium:0.63.7)             │
│                                                              │
│  Note 387btDIOMxHp (sitemap.xml)                             │
│  ├─ type: file                                               │
│  ├─ mime: application/xml                                    │
│  ├─ shareAlias: sitemap.xml                                  │
│  └─ content: 514 条 URL 的 sitemap                           │
└───────────────────────────┬─────────────────────────────────┘
                            │ ETAPI (REST)
                            ▼
┌─────────────────────────────────────────────────────────────┐
│         Hermes Agent (cron: sitemap-auto-update)             │
│                                                              │
│  每 6 小时触发一次 (0 */6 * * *)                              │
│  ┌───────────────────────────────────────────────────────┐   │
│  │ ~/.hermes/scripts/trilium-crawl-sitemap.py             │   │
│  │                                                         │   │
│  │  1. curl 获取 /share/hermes 根页 (~12s)                │   │
│  │     - 用 subprocess + curl 而非 urllib                 │   │
│  │     - Python urllib 有 SSL 超时问题,curl 稳定 0.5s/页  │   │
│  │                                                         │   │
│  │  2. 正则提取所有 href="./..." 笔记链接                   │   │
│  │     - is_note_path 过滤器: 8-15 位 ID / 别名 / CVE-     │   │
│  │     - 排除 .js/.css/.png 等静态资源                      │   │
│  │                                                         │   │
│  │  3. 生成 sitemap.xml (514 条 URL)                       │   │
│  │     - root: daily / priority 1.0                        │   │
│  │     - others: weekly / priority 0.6                     │   │
│  │                                                         │   │
│  │  4. ETAPI PUT → Trilium Note 387btDIOMxHp               │   │
│  │     - token: $TRILIUM_ETAPI_TOKEN (环境变量)             │   │
│  │                                                         │   │
│  │  5. stdout 输出 → cron deliver → 通知到 Telegram        │   │
│  └───────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

📌 注意:不采用 BFS,因为根页面 /share/hermes 已经链接到所有分享笔记
(每个子页面也链向全部 650+ 条笔记,无需深层爬取)

二、Sitemap 生命周期

2.1 触发时机

  • 定时触发:每 6 小时 (0 */6 * * *) 自动运行
  • 手动触发:通过 hermes cron run sitemap-auto-update
  • 新增笔记后:无需手动操作,下次 cron 自动发现(根页面会包含新笔记的链接)

2.2 数据流

  1. 单页爬取:curl 获取 /share/hermes(~12s,123KB HTML)
  2. 提取链接:正则提取所有 href="./xxx" 中的笔记路径
  3. 过滤:is_note_path 函数筛选有效笔记(8-15 位 ID / 别名 / CVE-)
  4. 生成 XML:按 sitemap 协议组装(loc / lastmod / changefreq / priority)
  5. 存储:写入 /tmp/sitemap-latest.xml
  6. 同步:curl PUT → Trilium ETAPI 更新文件型笔记
  7. 发布:Nginx proxy_pass 使 /sitemap.xml 对外服务

2.3 为什么不 BFS

Trilium 的分享页面是"导航页"结构——每个页面都包含指向所有其他分享笔记的链接。根页面 /share/hermes 已包含全部 514 条链接。BFS 只会重复发现同样的链接,无谓增加耗时和服务器负载。

早期版本尝试了并发 BFS(5~50 workers),发现两个坑:

  • 并发越高越慢:Trilium 容器连接池有限,20+ 并发导致排队降速
  • 所有页面内容相同:每个子页面都返回同样的 650+ 链接,BFS 不产生新信息

2.4 搜索引擎消费

  1. Googlebot 定期轮询 https://trilium.atibm.com/sitemap.xml
  2. 发现 514 条分享笔记 URL
  3. 逐个爬取页面 HTML,提取标题、正文、元数据
  4. 建立索引,出现在 Google 搜索结果中

三、Google Search Console 配置

3.1 提交 Sitemap

  • Search Console → 属性 (trilium.atibm.com) → Sitemaps
  • 提交 URL:https://trilium.atibm.com/sitemap.xml
  • 验证:返回 200 + 合法 XML + 所有 URL 可访问 (HTTP 200)

3.2 验证文件

Trilium nginx 已配置以下验证文件(用于 Google Search Console 所有权验证):

  • /google3eaf0c02c5ae17be.html → Google 所有权验证
  • /ads.txt → Google AdSense
  • /robots.txt → 爬虫指引

3.3 robots.txt 问题

当前 robots.txt 的 sitemap 指向了 https://trilium.atibm.com/share(302 跳转页),应改为:

User-agent: *
Sitemap: https://trilium.atibm.com/sitemap.xml

需要去 Docker 宿主机上编辑 /usr/share/nginx/html/trilium/robots.txt

四、脚本代码

文件路径:/opt/data/home/.hermes/scripts/trilium-crawl-sitemap.py

#!/usr/bin/env python3
"""Trilium sitemap crawler — single root-page fetch.

The root /share/hermes contains links to ALL shared notes directly visible.
Fetches once (~12s), extracts 500+ URLs, builds sitemap, updates ETAPI.

Cron-compatible: completes in ~15s, well within 120s limit.
"""
import subprocess, sys, re, os
from xml.dom import minidom
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

BASE = "https://trilium.atibm.com"
ALIAS = "hermes"
OUTPUT_FILE = "/tmp/sitemap-latest.xml"
TIMEOUT = 20
SKIP_SUFFIXES = (".js", ".css", ".png", ".jpg", ".jpeg",
                 ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot")

def fetch(url):
    # 关键: 用 curl subprocess 而非 urllib
    # Python urllib 在此环境下有 SSL 超时问题
    try:
        r = subprocess.run(["curl", "-s", "--max-time", str(TIMEOUT), url],
            capture_output=True, text=True, timeout=TIMEOUT+2)
        return r.stdout if r.returncode == 0 and r.stdout else None
    except Exception:
        return None

def is_note_path(p):
    if p.startswith("share/"): p = p[len("share/"):]
    if p == ALIAS or p.startswith("CVE-"): return True
    if re.match(r'^[A-Za-z0-9]{8,15}$', p): return True
    if re.match(r'^[a-zA-Z][a-zA-Z0-9]{4,30}$', p): return True
    return False

now = datetime.now(timezone.utc).strftime("%Y-%m-%d")

html = fetch(f"{BASE}/share/{ALIAS}")
if not html:
    sys.stderr.write("ERROR: root page fetch failed\n")
    sys.exit(1)

urls = {f"{BASE}/share/{ALIAS}"}
for m in re.finditer(r'href="\./([^"]+)"', html):
    c = m.group(1)
    if c != ALIAS and is_note_path(c):
        urls.add(f"{BASE}/share/{c}")

# Build XML
urlset = ET.Element("urlset",
    xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
el = ET.SubElement(urlset, "url")
ET.SubElement(el, "loc").text = f"{BASE}/share/{ALIAS}"
ET.SubElement(el, "lastmod").text = now
ET.SubElement(el, "changefreq").text = "daily"
ET.SubElement(el, "priority").text = "1.0"
for u in sorted(urls - {f"{BASE}/share/{ALIAS}"}):
    el = ET.SubElement(urlset, "url")
    ET.SubElement(el, "loc").text = u
    ET.SubElement(el, "lastmod").text = now
    ET.SubElement(el, "changefreq").text = "weekly"
    ET.SubElement(el, "priority").text = "0.6"

xml_bytes = minidom.parseString(
    ET.tostring(urlset, encoding="unicode")
).toprettyxml(indent="  ", encoding="utf-8")

with open(OUTPUT_FILE, "wb") as f: f.write(xml_bytes)
sys.stdout.buffer.write(xml_bytes)

# ETAPI update
token = os.environ.get("TRILIUM_ETAPI_TOKEN")
if not token:
    for ep in ["/opt/data/.env", os.path.expanduser("~/.env")]:
        if os.path.exists(ep):
            with open(ep) as f:
                for line in f:
                    if line.startswith("TRILIUM_ETAPI_TOKEN="):
                        token = line.split("=", 1)[1].strip("\"'")
                        break
            if token: break

nid = os.environ.get("TRILIUM_SITEMAP_NOTE_ID", "387btDIOMxHp")
if token:
    r = subprocess.run(["curl", "-s", "-X", "PUT",
        f"https://trilium.atibm.com/etapi/notes/{nid}/content",
        "-H", f"Authorization: {token}",
        "-H", "Content-Type: text/plain",
        "--data-binary", "@" + OUTPUT_FILE],
        capture_output=True, text=True, timeout=20)
    sys.stderr.write(f"ETAPI: exit={r.returncode}\n")

sys.stderr.write(f"DONE: {len(urls)} URLs -> {OUTPUT_FILE}\n")

五、Cron Job 配置

hermes cron list
────────────────────────────────────────────────
sitemap-auto-update
  schedule: 0 */6 * * *           (每天 00:00, 06:00, 12:00, 18:00)
  no_agent: true                  (直接跑脚本,不经过 LLM)
  script:   trilium-crawl-sitemap.py
  deliver:  origin                (结果发回 Telegram 群)
  state:    scheduled
  next:     2026-05-15 18:00
────────────────────────────────────────────────

六、运营指标

  • 当前 sitemap URL 数:514(全部 HTTP 200 ✅)
  • 爬取方式:单根页 curl(无需 BFS)
  • 爬取耗时:~15 秒(根页 12s + ETAPI 更新 3s)
  • 失败率:0%(单次请求,无并发问题)
  • 更新频率:每 6 小时(满足 Google 推荐频率)
  • sitemap 文件大小:约 90KB(远低于 50MB 限制)

七、故障排查

症状可能原因解决
sitemap 返回 404Nginx proxy_pass 配置错误,或笔记被删除检查 /etc/nginx/conf.d/trilium.conf 中的 proxy_pass URL
ETAPI 401Token 过期在 Trilium 设置中重新生成 ETAPI token,更新 .env
cron 超时no_agent 默认 120s 限制,根页加载超时检查 curl 能否正常访问 /share/hermes;调大 --max-time
URL 数减少新笔记未配置 #share 标签,或笔记被删除检查笔记是否已设置 share 标签
Python urllib 超时urllib 在此环境 SSL 不稳定脚本已经使用 curl subprocess 替代

八、开发历程(历次尝试)

  1. 顺序 BFS:urllib 逐页爬取 637 URL,耗时 3-5 分钟。成功但超时。
  2. 并发 BFS(50 workers):urllib SSL 超时,大量 FAIL。
  3. 并发 BFS + HEAD 预检(20 workers):urllib 仍然超时,预检也超时。
  4. 低并发 BFS(5 workers):可以工作但太慢(513 子页 × 2s = 17 分钟)。
  5. curl subprocess + 单根页:✅ 最终方案。curl 稳定 0.5s/页,根页 = 全量索引。

关键发现

  • Python urllib 在此环境 SSL 通信不稳定(间歇性超时),curl 稳定
  • Trilium 分享页是"导航页"结构,每个页面都包含全量链接,不需要 BFS
  • 并发请求会打满 Trilium 容器连接池,反而降低总吞吐