跳转至

腾讯云 COS + acme.sh:部署实操手册

这是落地篇

本篇是《用对象存储部署 AI 友好的个人知识库》的实操手册。主文讲清楚了为什么这么设计——可检验的 AI 友好标准、一颗 rsync 语法糖、两个写作约定,以及为什么选对象存储而非服务器;本篇把它跑起来:从建 bucket、绑域名,到 HTTPS 自动续期,再到完整代码与一次性安装命令。腾讯云 COS 特有的坑(强制下载、CNAME、证书 hook)都收在这里。

← 回到主文:设计与选型

落地

第一步:建 bucket

腾讯云控制台 → 对象存储 → 创建 bucket(其他家对象存储同理):

  • 名称:随便起,比如 wiki-1307341066
  • 访问权限:公有读私有写
  • 地域:这里有个关键选择(详见后面"强制下载"一节):

ap-hongkongap-singapore 等 —— 无强制下载、不需备案,最省事。

ap-shanghaiap-chengdu 等 —— 国内访问快,但默认强制下载,必须绑中国大陆 ICP 备案的域名才能解除。

建好后:

  • 基础配置 → 静态网站 → 启用
  • 索引文档:index.html
  • 错误文档:404.html

第二步:本地 Zensical 项目

mkdir wiki && cd wiki
uv init --no-readme
uv add zensical
mkdir docs
echo "# 首页" > docs/index.md

最简 mkdocs.yml(仅用来跑通,真实配置见文末完整版):

site_name: 我的知识库
theme:
  name: material
  language: zh
nav:
  - 首页: index.md

构建 + 预览:

uv run zensical build   # 产物在 site/
uv run zensical serve   # 本地预览 http://127.0.0.1:8000

第三步:deploy.sh —— 一次构建,两个视图

这一步是"AI 友好"从设计变成现实的地方,值得多说几句。

Zensical 本身没有"把 markdown 源保留进构建产物"的功能 —— 命令只有 build / serve,没有插件钩子,官方 roadmap 未来一年也没这个计划1。所以这一层是 deploy.sh 自己实现的,核心四步:

rm -rf site && uv run zensical build    # 1. 干净构建 —— HTML 视图 (1)
rsync -am --include='*/' --include='*.md' --exclude='*' docs/ site/  # 2. md 源镜像进 site/ —— AI 视图 (2)
coscmd upload -rs --delete -y ./ /      # 3. site/ 整体镜像到桶根,顺手清掉桶里陈旧文件
coscmd upload -r --include '*.md' \
    -H 'Content-Type: text/markdown; charset=utf-8' ./ /  # 4. 修 .md 的 Content-Type (3)
  1. rm -rf site 再 build 不能省:第 3 步 --deletesite/ 为准做全量镜像,本地残留的旧文件会"保护"桶里的陈旧内容不被清掉。
  2. .md.html 的 key 不冲突use_directory_urls 下页面渲染成 /x/index.html,源是 /x.md,天然错开 —— md 源镜像进 site/ 不会覆盖任何 HTML。
  3. 这一步不能省:coscmd 默认把 .md 传成 application/octet-stream,浏览器会直接下载而不是显示 —— 对"给 AI/人直接读源"这个用途是致命的。

coscmd(腾讯云 COS 的命令行;其他家换成对应 CLI,如 aws s3 syncrclone):

uv add --dev coscmd

凭证写 .env记得加进 .gitignore,模板见文末)。完整脚本见文末 deploy.sh snippet —— 引用的就是仓库里真跑的那份。

踩坑:别让 uv run --directory 把整个项目传上去

最早我写的是 cd site && uv run --directory .. coscmd upload -rs ./ /,但 uv run --directory 会把工作目录切回项目根,导致 coscmd 把整个项目(包括 .venv/.env)都往对象存储传。绝对不能这样写。正确做法是 cdsite/ 后直接用绝对路径调用 venv 里的 coscmd。

AI 友好这层跟 zensical 无关,怎么升级都不怕

复制 md 源、镜像上传、修 Content-Type 这三件事零 zensical 依赖 —— 哪怕以后 Zensical 大改、甚至换回 MkDocs 或别的生成器,这层照常工作。真正依赖 zensical 的只有渲染层(snippets 等 pymdownx 扩展),版本锁在 uv.lock 里不会自动升,升级前本地 build 验证即可。

第四步:踩坑 —— 国内 bucket 的强制下载

第一次访问 bucket 默认域名时,浏览器直接把 HTML 当成文件下载curl -I 看到响应正常:

HTTP/1.1 200 OK
Content-Type: text/html

curl -i(GET)看到 GET 请求多了两行:

Content-Disposition: attachment
x-cos-force-download: true

这是腾讯云为了响应监管"未备案不能搭网站"的策略:国内区域 bucket 通过 *.myqcloud.com 域名访问 HTML 一律强制下载,跟 bucket 配置无关。解除方式只有:

  • 绑定中国大陆 ICP 备案过的自定义域名,通过那个域名访问就豁免
  • 或换到海外区域 bucket

我选了"绑备案域名"。

换别家对象存储就没这道坎

这条是国内对象存储特有的监管策略。用 Cloudflare R2 / AWS S3 这类海外服务直接绑自定义域名就没有强制下载问题 —— 但国内访问速度和备案是另一笔账。

第五步:绑定自定义域名

前提:liuhetian.work 已经 ICP 备案。注意备案对象是主域 liuhetian.work,不是 www.liuhetian.work,所有子域(wikiapi 等)自动覆盖。

绑定要做两件事,缺一不可 —— 这是我反复踩坑的关键:

其一,在控制台添加自定义源站域名。控制台 → bucket → 域名与传输管理 → 自定义源站域名 → 添加

  • 域名:wiki.liuhetian.work
  • 源站类型:必须选 静态网站源站(不是默认源站!否则访问 / 会触发 ListBucket API,返回 AccessDenied
  • 是否启用 HTTPS:先关,证书后面再补

保存后,控制台会展示一个 CNAME 目标

wiki-1307341066.cos.ap-chengdu.myqcloud.com

其二,在 DNSPod 加一条 CNAME 记录

主机 类型
wiki CNAME wiki-1307341066.cos.ap-chengdu.myqcloud.com

等 5-30 分钟节点同步,访问 http://wiki.liuhetian.work/ 不会再有强制下载头。

容易绕进去的几个概念:

CNAME 目标 和 源站类型 是独立的两件事

CNAME 目标始终是 <bucket>.cos.<region>.myqcloud.com 这一个值,不管"源站类型"选什么,腾讯云给的 CNAME 目标都不变。

"源站类型"是后台的逻辑配置,决定 COS 接到请求后按什么模式处理

  • 默认源站:API 模式,访问 / 等于 ListBucket,匿名 → 403 AccessDenied
  • 静态网站源站:把请求映射到 index.html,访问 / 直接出首页

两个开关分别在两个地方配,都要对才行。我最早只改了 DNS 没改"源站类型",结果一直 AccessDenied。

为什么 CNAME 不能指 cos-website.* 域名

<bucket>.cos.<region>.myqcloud.com 是 COS 主入口,所有 bucket 路由都从这里进;<bucket>.cos-website.<region>.myqcloud.com 是辅助域名,只在你直接用这个域名访问时走静态网站模式,后台不接受作为自定义 CNAME 目标。腾讯云在主入口查 Host 头 → 查后台"该域名绑了哪个 bucket、什么模式" → 按那个模式处理。

push 上传走的根本不是自定义域名

coscmd / SDK 推内容时,URL 永远是 <bucket>.cos.<region>.myqcloud.com(API 域名 + SigV4 签名),跟 wiki.liuhetian.work 无关。push 走 API 域名,访问走自定义域名,两条独立链路。试图用自定义域名做 PUT 会被拒(自定义域名走静态网站模式,不接受写鉴权)。

第六步:HTTPS 自动化(重头戏)

证书有两种来源:

方案 有效期 续期方式
腾讯云免费 DV 证书 3 个月(不是 1 年) 控制台手动点"快速续期" + 手动重新绑定
Let's Encrypt + acme.sh 45 天(2026 年 5 月起;此前是 90 天2 acme.sh 自带 cron,全自动

我选 Let's Encrypt + acme.sh。但有个意外:

acme.sh 没有内置腾讯云 hook

acme.sh 的 80 个 deploy hook 里有阿里云 CDN、七牛云,但没有任何腾讯云产品。意味着"签证书"是自动的,但"把证书推到 COS"要自己写脚本。换成有内置 hook 的服务商(如 Cloudflare)这一步能省掉。

acme.sh + DNS-01 挑战。首次配置全部封装在 deploy-cert/install.sh(见文末 snippet),核心是这两条 —— 切 CA + 用 DNSPod 插件做 DNS-01(其他 DNS 服务商对应换插件名3):

~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
# DP_Id / DP_Key 来自 .env;其他 DNS 服务商对应换插件名
~/.acme.sh/acme.sh --issue --dns dns_dp -d wiki.liuhetian.work --keylength ec-256

DNS-01 挑战是什么

sequenceDiagram
    participant 你 as acme.sh
    participant LE as Let's Encrypt
    participant DP as DNSPod

    你->>LE: 我要 wiki.liuhetian.work 的证书
    LE->>你: 在 _acme-challenge.wiki.liuhetian.work 的 TXT 里写 xyz789
    你->>DP: 调 DNSPod API 加 TXT 记录
    LE->>DP: 查 _acme-challenge.wiki.liuhetian.work
    DP-->>LE: xyz789
    LE->>你: 验证通过,发证书
    你->>DP: 删除 TXT 验证记录

实质上是临时在 liuhetian.work 下创建一个四级子域名 _acme-challenge.wiki.liuhetian.work 的 TXT 记录,记录值就是 Let's Encrypt 出的题。验证完立刻删。

为什么必须 DNS-01 不能 HTTP-01:HTTP-01 要求域名指向的 80 端口能放挑战文件,但 wiki.liuhetian.work 指向的是对象存储,不是我们能控制的 web 服务器。

自己写 hook 推证书到 COS。完整脚本见文末 deploy-cert/deploy_cert_to_cos.py snippet。它从 acme.sh 注入的环境变量读证书路径,调一个 API 把证书绑到自定义域名,关键就是这个调用:

client.put_bucket_domain_certificate(
    Bucket=os.environ["COS_BUCKET"],
    DomainCertificateConfiguration={
        "CertificateInfo": {                    # (1)
            "CertType": "CustomCert",
            "CustomCert": {"Cert": cert, "PrivateKey": key},
        },
        "DomainList": [{"DomainName": os.environ["COS_DOMAIN"]}],
    },
)
  1. 重要CertificateInfo 这一层包装绝对不能漏。我第一版按 Python SDK 测试文件示例写少了这层,腾讯云返回 InvalidArgument。真正的 XML 结构以 Go SDK 测试代码为准4

注册 acme.sh reloadcmdinstall.sh 最后把"读 .env → 设环境变量 → 跑 Python 推证书"这串命令注册成 acme.sh 的 --reloadcmd。acme.sh 把它存到 ~/.acme.sh/wiki.liuhetian.work_ecc/wiki.liuhetian.work.conf每次续期成功后自动跑

第七步:从此不用管 —— 自动续期循环

acme.sh 装的时候自动加了 cron:

7 16 * * * "/home/lht/.acme.sh"/acme.sh --cron --home "/home/lht/.acme.sh" > /dev/null

每天 16:07 跑一次:

flowchart LR
    Cron[每天 16:07 cron] --> Check{证书进入<br>续期窗口?}
    Check -- 否 --> Sleep[继续睡到明天]
    Check -- 是 --> Issue[acme.sh 发起<br>DNS-01 挑战]
    Issue --> Hook[reloadcmd 触发<br>Python 脚本]
    Hook --> Push[推到对象存储绑定]
    Push --> Sleep

45 天证书约每 30 天自动续一次。你不需要做任何事 —— 2026 年 5 月 Let's Encrypt 把默认有效期从 90 天缩到 45 天2,acme.sh 不用改任何配置,自己跟着缩短了续期间隔。

日常写作流:主机部署,副机搭车

发布链路闭环之后,还剩一个日常问题:写作不一定总发生在这台配好部署环境的机器上 —— 可能在别的服务器上让 AI 直接生成文字稿。而部署环境(.env 凭证、uv、vendor)只想配一处,也不想每台写作机都惦记"怎么触发部署"。

解法是承认机器有主副之分,然后整个协同只花两行:

主机 = 主写作机 + 部署机deploy.sh 的第 0 步是 git pull --ff-only,再构建上传 —— GitHub 上有什么新内容,每次部署自动捎带上线,主机自己该怎么写还怎么写。

副机 = 轻量写作端(AI 生成的文字稿为主)。clone 同一个 GitHub 仓库,写完 push。不急的什么都不用做,等主机下次部署搭车;急着上线就一条 alias:

alias wikideploy='git push && ssh <主机> /path/to/wiki/deploy.sh'

push 完远程执行主机的部署脚本,脚本第 0 步的 pull 正好接住刚 push 的内容,部署日志实时回显在副机终端。前提只有一个:副机到主机免密 ssh(ssh-copy-id 配一次)。

图片跟文字一起走 Git(git-lfs)

文字和图片现在都走 Git。 二进制媒体经 git-lfs 入库(机制详见资源备份):commit 时文件在 git 里变成几十字节的指针,push 时真身由自制 agent 自动传进备份 COS 桶。副机加图不再需要 rsync 对路径、不需要手动跑备份 —— 一次 git push,文章和图片全部到位:

git add . && git commit && git push    # 图片 blob 自动上备份桶
ssh <主机> /path/to/wiki/deploy.sh     # 急件才需要;不急就等主机下次部署搭车

主机 deploy.sh 第 0 步的 git pull 拉到新指针时,smudge filter 经同一个 agent 把真身从桶里取回,构建看到的已是真图 —— 全程无人碰媒体文件。

副机首次接入(一次性):

GIT_LFS_SKIP_SMUDGE=1 git clone git@github.com:liuhetian/wiki.git && cd wiki
uv sync                          # 装出 .venv(qcloud_cos 随 coscmd 进来)
# 放一份 .env:只需 COS_SECRET_ID/KEY、COS_REGION、COS_BACKUP_BUCKET 四项;
# 建议用 CAM 子账号密钥、只授权备份桶,主密钥不出主机
bash scripts/setup-lfs.sh        # 把 agent 注册进本 clone 的 git config
git lfs pull                     # 还原全部媒体真身

只写文字、不碰图的副机可以零密钥GIT_LFS_SKIP_SMUDGE=1 clone 后直接写 —— 工作区里图片是指针文本,md 照常编辑、push 照常走,只是本地看不了图。

新部署机重建同理:上面五行就是完整恢复流程,不再有单独的"媒体恢复"步骤。

flowchart LR
    B[副机写作<br>文字 + 图片] -->|git push:指针| GH[GitHub<br>文本 + LFS 指针]
    B -->|git push:blob| BK[备份桶 lfs/<br>内容寻址档案]
    A[主机写作] --> R[主机仓库]
    GH -->|deploy.sh 第 0 步 pull| R
    BK -->|smudge 自动取回真身| R
    R -->|构建 + 上传| O[对象存储]
    B -.->|急件:ssh 主机跑 deploy.sh| R

为什么不用更'自动'的方案

GitHub Actions 直传对象存储、webhook 回调、cron 轮询拉取 —— 这些都是为"部署机上无人写作"设计的。这里主写作机就是部署机,"提醒部署"的问题在主链路上根本不存在,只剩副机偶尔要触发,一句 ssh 就够:凭证不出主机、没有轮询延迟、也不用把 COS 密钥交给 CI。将来图片等重资产变多,这条链路也不受 GitHub 仓库体积的限制。

--ff-only 的取舍值得单独一句:本地与远端分叉、或未提交的改动跟拉下来的内容相撞时,pull 会直接报错,set -e 让整个部署停住。看似严苛,实则是保护 —— 部署是"出门见人"的动作,宁可停下来让人先处理,也不要静默合并出意外内容再发布出去。代价是一条纪律:主机写完要及时 commit + push。

资源备份:自制 mini LFS,备份桶即媒体档案馆

wiki 里图片、视频、字体只会越攒越多,它们的备份方案迭代过三代:

方案 毛病
1 什么都塞 git 二进制无 diff 价值,历史体积只增不减,GitHub 仓库很快臃肿
2 .gitignore 全屏蔽 + backup-media.sh 手动镜像到备份桶 手动跑、无版本(同名覆盖)、git 状态和媒体状态各走各的会漂移
3 git-lfs + 自制 COS 后端(现行) ——

原理:一个 git 拉起的静态脚本,不是服务

git-lfs 的存储后端是可插拔的:除了标准的 HTTP 服务,它还支持 standalone custom transfer agent —— 一个本地程序,git-lfs 在 push/pull 需要搬 blob 时把它作为子进程拉起,stdin/stdout 上用 JSON-lines 对话(init / upload / download / terminate 四种事件),用完即退。没有常驻进程、不监听端口、不需要域名证书。

本站的 agent 是 ~140 行 Python(scripts/lfs-cos-agent.py),收到 upload/download 事件就用 qcloud_cos SDK 在备份桶里存取。并发、本地缓存(.git/lfs/objects)、sha256 完整性校验、增量判断全由 git-lfs 客户端自带,agent 只是个哑搬运工。

分工与数据流:

存放处 内容
GitHub markdown、代码、svg 等文本 + 每个媒体文件几十字节的 LFS 指针(不占 GitHub LFS 配额)
备份桶 lfs/ 前缀 媒体真身,按内容寻址存 lfs/ab/cd/<sha256>
工作区 smudge 后的真身,供构建、部署直接用

内容寻址带来三个免费性质:去重(同一张图无论出现在几篇文章、几个历史版本里,桶里只存一份)、幂等(agent 上传前先 HEAD,重复 push 秒过)、全历史可还原(桶只增不减,git checkout 任意旧 commit,当时的图原样回来)。

什么进 git,什么进 LFS

分界线不是"大小",是文本性

  • git 文本:md、svg、代码、自己写的 vendor/*-init.js —— diff 有意义的东西;
  • LFS:位图 / 视频 / 音频 / 3D / pdf / 字体(woff2 等)/ 第三方 vendor 真身(MathJax、ECharts、three.js、react 这些 min.js —— 压缩产物 diff 无意义,每次升级还会往历史里灌一遍全量)。规则都在 .gitattributes,加新文件零操作,git add 自动接管。

scripts/fetch-vendor.sh 从"恢复工具"降级为"升级工具":vendor 真身日常跟着 git 走(LFS 后端是自己的桶,不再依赖 jsdelivr 的可达性),只在想升级版本时改脚本里的版本号跑一次、commit。

密钥与信任边界

agent 从项目 .env 读凭证,所以碰媒体的机器才需要密钥(上传下载都要),纯文字机零密钥(见副机接入)。多机分发建议 CAM 子账号:只授权备份桶读写,主密钥永不离开主机。哪天要开放协作(让不持钥的人 clone),再把 agent 的存储层原样搬进一个签发预签名 URL 的 Batch API 服务即可,升级路径是平滑的。

两个已知的小事实:备份桶根下还留着第 2 代方案的路径镜像(历史存量,backup-media.sh 保留但不再日常使用);push 时进度条显示 0 B/s 是 standalone agent 不回报进度事件的正常现象,不影响传输。

认知:值得单独记下来的几件事

DNS 记录"值"是什么

DNS 不是"动作",是个 key-value 表,查表得到值:

记录类型
A IPv4 地址(43.134.81.54
AAAA IPv6 地址
CNAME 另一个域名(xxx.cos.ap-chengdu.myqcloud.com
TXT 任意字符串(ACME 挑战值 PpWCFq...、SPF、DMARC 等用这个)
MX 邮件服务器
NS 权威 DNS 服务器

ACME 挑战必须用 TXT,因为挑战值是随机字符串 —— 既不是 IP 也不是域名。

服务器只是 runner,不参与流量

这个方案里我的服务器(43.134.81.54)从头到尾不在用户请求链路上。它只是个"定时跑续期脚本的机器",跟 GitHub Actions runner 完全等价。未来想换成 GitHub Actions,Python 脚本能直接复用,只是触发方式从 cron 换成 workflow。

Content-Disposition 头是 GET 才加

腾讯云的 x-cos-force-download 只在 GET 请求时追加,HEAD 请求看不到。所以验证 bucket 是否被国内强制下载策略影响,必须 curl -i(发 GET),不能只 curl -I(HEAD)。

未来展望

未来如果延伸到更复杂的情况,比如协助用户真的安装一些东西(比如一个skill),这种复杂脚本,可能需要借助本仓库的github地址,脚本里进行clone,从而拿到一些目录并执行。

总成本

成本
对象存储存储 + 流量 个人知识库流量基本免费
域名 已有,不算
证书 Let's Encrypt 免费
acme.sh 开源
服务器 已有,且只跑 cron
配置时间 第一次约 1 小时(含踩坑)

后续维护:0。证书进入续期窗口后 acme.sh 自动续 + 自动推到对象存储。一年之内不需要登录控制台。

完整代码

下面用 --8<-- 折叠展示各文件原文 —— 引用的是本文 assets/ 里的真身assets/ 里是指向仓库根的 symlink,源文件只一处、不双写;build 时把真身内容注入 HTML,也拷一份到 assets/ 供直接下载)。

对 AI 友好

真身都在仓库根(含 deploy-cert/)。如果你是直接读 git 仓库的 AI,无需展开本节 —— 打开仓库根对应文件即最新版。

唯一例外:mkdocs.yml 无法软链进 docs

实测 Zensical 对 docs/ 里出现的 .yml / .yaml 极其敏感 —— 哪怕改名软链进去都会搅乱路由、让文章"消失"(build 报 page does not exist)。所以 mkdocs.yml 只能靠 snippets 从项目根引用:HTML 视图完整可见、跟真身不脱节,但线上没有它的独立 URL —— 本 wiki 唯一的例外,已接受。.toml / .py / .sh / .gitignore 软链都正常。

项目结构(一览)
zensical-wiki/
├── docs/                               ← 唯一内容源,连同 .md 全部随线上发布
│   ├── index.md
│   ├── posts/
│   │   ├── index.md                    ← 文章栏目着陆页
│   │   ├── cos-wiki-deploy/            ← 本文(index.md 概览 + reference/ 手册 + 共享 assets/)
│   │   │   ├── index.md                ← 概览:标准 / 背景 / 设计 / 选型
│   │   │   ├── reference/
│   │   │   │   ├── deploy.md            ← 落地实操手册(本页)
│   │   │   │   └── wiki-build-log.md    ← 建站手记
│   │   │   └── assets/                 ← 运行文件放软链(真身在仓库根,不双写)
│   │   │       ├── deploy.sh           → ../../../../deploy.sh
│   │   │       ├── install.sh          → ../../../../deploy-cert/install.sh
│   │   │       ├── deploy_cert_to_cos.py → ../../../../deploy-cert/deploy_cert_to_cos.py
│   │   │       ├── pyproject.toml      → ../../../../pyproject.toml
│   │   │       ├── llm-wiki.md         ← 外部资料存档(真身,非软链)
│   │   │       └── llms-txt.md         ← 外部资料存档(真身,非软链)
│   └── skills/<skill>/                 ← 同样 index.md + reference/ + assets/
├── deploy-cert/                        ← SSL 证书续期(真身)
│   ├── deploy_cert_to_cos.py
│   └── install.sh
├── scripts/                            ← 运维脚本(真身)
│   ├── lfs-cos-agent.py                ← mini LFS:媒体 blob ↔ 备份桶
│   ├── setup-lfs.sh                    ← 每个 clone 一次性接入 LFS
│   └── fetch-vendor.sh                 ← vendor 版本升级工具
├── mkdocs.yml · deploy.sh · pyproject.toml · .gitignore   ← 仓库根真身
└── .env                                ← 凭证(不入版本控制)
mkdocs.yml — Zensical 站点配置
site_name: 牛合天's wiki
site_url: https://wiki.liuhetian.work/
copyright: >
  Copyright &copy; 2026 Liu Hetian ·
  <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2021023978号-2</a>

theme:
  name: material
  custom_dir: overrides   # 首页开屏模板 home.html 所在目录
  language: zh
  font: false   # 关掉 Google Fonts 远程引用(渲染阻塞,境内常超时);字体本地化见 extra_css
  features:
    - navigation.tabs
    - navigation.indexes
    - content.code.copy
    - content.code.annotate

extra_css:
  - vendor/fonts/fonts.css       # 本地 Inter + JetBrains Mono,替代 fonts.googleapis.com;只出 --md-text-font/--md-code-font
  - stylesheets/zx-tokens.css    # 全站令牌层:Atelier Zero 令牌 + --md-*/--color-* 映射(首页 home.css 也用它)
  - stylesheets/zx-theme.css     # 全站皮肤层:只写选择器,消费上一层的变量

extra_javascript:
  - vendor/mathjax-init.js   # MathJax 配置,须在真身之前
  - vendor/mathjax/tex-svg.js  # 本地化真身(3.2.2,SVG 输出免字体文件),不依赖外网 CDN
  - vendor/echarts-init.js   # ```echarts 代码块渲染器;真身按需动态加载,无图页面零开销

markdown_extensions:
  - admonition
  - attr_list
  - footnotes
  - md_in_html
  - pymdownx.arithmatex:
      generic: true
  - pymdownx.details
  - pymdownx.highlight:
      anchor_linenums: true
      line_spans: __span
      pygments_lang_class: true
  - pymdownx.inlinehilite
  - pymdownx.snippets:
      base_path:
        - docs   # docs 内引用去掉 docs/ 前缀(如 skills/.../x.md),避免 AI 把 docs/ 当 URL
        - .      # 回退到项目根:mkdocs.yml / deploy.sh 等真实文件仍可被 --8<-- 引用
      check_paths: true
  - pymdownx.superfences:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:pymdownx.superfences.fence_code_format
        - name: echarts   # 声明式图表:fence 里写 ECharts option JSON,vendor/echarts-init.js 渲染
          class: echarts
          format: !!python/name:pymdownx.superfences.fence_code_format
  - pymdownx.tabbed:
      alternate_style: true

# zensical 的链接校验有并行竞态:同一份内容偶发(约 1/15 次构建)误报大量
# "page does not exist",产物 HTML 实测无差异(0.0.46 与 0.0.53 均复现)。
# 死链检查由 scripts/check-links.py 在部署前把关(会阻断部署),这里关掉不丢保障;
# invalid_link_anchors(锚点校验)check-links.py 不覆盖,保留 —— 若它也出现偶发误报,同样对待。
validation:
  invalid_links: false

nav:
  - 首页:
      - index.md
  - 文章:
      - posts/index.md
      - AI友好知识库:
          - posts/cos-wiki-deploy/index.md
          - 部署实操手册: posts/cos-wiki-deploy/reference/deploy.md
          - 建站手记: posts/cos-wiki-deploy/reference/wiki-build-log.md
      - 自建 git-lfs 后端: posts/git-lfs-cos.md
      - 预测项目闭环: posts/prediction-loop.md
      - AI 时代的产品经理: posts/ai-pm.md
      - 动画网页 PPT: posts/animated-ppt/index.md
      - 超级轻量的自用AI编程Harness框架: posts/ai-code-skeleton/index.md
      - Kindle 常显信息屏:
          - posts/kindle-dashboard/index.md
          - 越狱与显示通道: posts/kindle-dashboard/reference/jailbreak.md
          - 上报接口交接: posts/kindle-dashboard/reference/report-api.md
          - 决策日志: posts/kindle-dashboard/reference/decision-log.md
      - 工作方法:
          - posts/methods/index.md
          - 精力管理:
              - posts/methods/energy/index.md
              - 神经系统降载: posts/methods/energy/nervous-system-regulation.md
          - 推进执行:
              - posts/methods/execution/index.md
              - 如果—那么计划: posts/methods/execution/if-then-plans.md
              - 限制同时开工: posts/methods/execution/wip-limits.md
              - 每周清仓: posts/methods/execution/weekly-review.md
          - 做出决策:
              - posts/methods/decisions/index.md
              - 单向门与双向门: posts/methods/decisions/two-way-door-decisions.md
              - 事前验尸: posts/methods/decisions/premortem.md
  - 笔记:
      - notes/index.md
      - 概率与算法:
          - notes/probability/index.md
          - 12 枚硬币的奇偶性: notes/probability/coin-parity.md
          - 3 根绳子烧出 7 分钟: notes/probability/rope-timing.md
      - 统计学:
          - notes/statistics/index.md
          - 基础:
              - 描述统计: notes/statistics/data-description/index.md
              - 概率模型: notes/statistics/probability-models/index.md
              - 常见分布: notes/statistics/distributions/index.md
              - 抽样: notes/statistics/sampling/index.md
              - 抽样分布: notes/statistics/sampling-distributions/index.md
          - 统计推断:
              - 点估计: notes/statistics/estimation/index.md
              - 置信区间: notes/statistics/confidence-intervals/index.md
              - 假设检验: notes/statistics/hypothesis-testing/index.md
              - 组间比较: notes/statistics/group-comparisons/index.md
              - 功效与样本量: notes/statistics/power-sample-size/index.md
              - 分类数据: notes/statistics/categorical-data/index.md
              - 非参数方法: notes/statistics/nonparametric/index.md
              - 重抽样: notes/statistics/resampling/index.md
          - 统计模型:
              - 简单线性回归: notes/statistics/linear-regression/index.md
              - 多元回归: notes/statistics/multiple-regression/index.md
              - 方差分析: notes/statistics/anova/index.md
              - 时间序列: notes/statistics/time-series/index.md
          - 设计与实务:
              - 实验设计: notes/statistics/experimental-design/index.md
              - 因果推断: notes/statistics/causal-inference/index.md
              - 缺失数据: notes/statistics/missing-data/index.md
              - 贝叶斯统计: notes/statistics/bayesian/index.md
              - 统计分析工作流: notes/statistics/statistical-workflow/index.md
      - 机器学习:
          - notes/machine-learning/index.md
          - 准确率、精确率与召回率: notes/machine-learning/classification-metrics/index.md
      - Git:
          - notes/git/index.md
          - fork 后吸收上游: notes/git/fork-upstream-sync.md
          - stash 的心智模型: notes/git/stash.md
          - worktree 的心智模型: notes/git/worktree.md
  - Skills:
      - skills/index.md
      - FastAPI 后端:
          - skills/fastapi/index.md
          - 项目布局: skills/fastapi/reference/项目布局.md
          - 数据库: skills/fastapi/reference/数据库.md
          - RAG: skills/fastapi/reference/rag.md
          - MCP: skills/fastapi/reference/mcp.md
          - 优雅终止: skills/fastapi/reference/优雅终止.md
          - 定时任务: skills/fastapi/reference/定时任务.md
          - 配置管理: skills/fastapi/reference/配置管理.md
          - 对话前端: skills/fastapi/reference/对话前端.md
          - 运行时补丁: skills/fastapi/reference/运行时补丁.md
          - 日志管理: skills/fastapi/reference/日志管理.md
          - 测试: skills/fastapi/reference/测试.md
          - 部署: skills/fastapi/reference/部署.md
          - 监控: skills/fastapi/reference/监控.md
          - 事后检查: skills/fastapi/reference/事后检查.md
      - Dashboard 后台:
          - skills/dashboard/index.md
          - 表单:
              - 表单: skills/dashboard/reference/表单/表单.md
              - 向导表单: skills/dashboard/reference/表单/向导表单.md
              - 搜索下拉: skills/dashboard/reference/表单/搜索下拉.md
              - 文件上传: skills/dashboard/reference/表单/文件上传.md
              - 行内编辑: skills/dashboard/reference/表单/行内编辑.md
          - 列表与表格:
              - 表格: skills/dashboard/reference/列表与表格/表格.md
              - 卡片列表: skills/dashboard/reference/列表与表格/卡片列表.md
              - 紧凑列表: skills/dashboard/reference/列表与表格/紧凑列表.md
              - 无限滚动: skills/dashboard/reference/列表与表格/无限滚动.md
              - 虚拟表格: skills/dashboard/reference/列表与表格/虚拟表格.md
              - 列控制: skills/dashboard/reference/列表与表格/列控制.md
              - 筛选面板: skills/dashboard/reference/列表与表格/筛选面板.md
              - 保存视图: skills/dashboard/reference/列表与表格/保存视图.md
              - CSV 导入导出: skills/dashboard/reference/列表与表格/CSV导入导出.md
          - 富视图:
              - 看板: skills/dashboard/reference/富视图/看板.md
              - 日历: skills/dashboard/reference/富视图/日历.md
              - 树形: skills/dashboard/reference/富视图/树形.md
              - 时间线: skills/dashboard/reference/富视图/时间线.md
              - 主从视图: skills/dashboard/reference/富视图/主从视图.md
          - 详情与页面:
              - 详情页: skills/dashboard/reference/详情与页面/详情页.md
              - 记录选项卡: skills/dashboard/reference/详情与页面/记录选项卡.md
              - 关联记录: skills/dashboard/reference/详情与页面/关联记录.md
              - 多栏布局: skills/dashboard/reference/详情与页面/多栏布局.md
              - 设置页: skills/dashboard/reference/详情与页面/设置页.md
              - 图表页: skills/dashboard/reference/详情与页面/图表页.md
          - 展示与反馈:
              - 数据展示件: skills/dashboard/reference/展示与反馈/数据展示件.md
              - 反馈状态: skills/dashboard/reference/展示与反馈/反馈状态.md
              - 通知中心: skills/dashboard/reference/展示与反馈/通知中心.md
              - 审计日志: skills/dashboard/reference/展示与反馈/审计日志.md
          - 平台能力:
              - 权限门控: skills/dashboard/reference/平台能力/权限门控.md
              - 登录方式: skills/dashboard/reference/平台能力/登录方式.md
              - 全局搜索: skills/dashboard/reference/平台能力/全局搜索.md
              - i18n: skills/dashboard/reference/平台能力/i18n.md
              - 计费: skills/dashboard/reference/平台能力/计费.md
              - 实时刷新: skills/dashboard/reference/平台能力/实时刷新.md
      - 数据可视化:
          - skills/data-visualization/index.md
          - Lieflat Charts:
              - skills/data-visualization/lieflat-charts/index.md
              - 基础型 · Lupi Basics:
                  - F1 · 梯级柱状图: skills/data-visualization/lieflat-charts/reference/f1-rung-bars.md
                  - F2 · 发丝折线图: skills/data-visualization/lieflat-charts/reference/f2-hairline-line.md
                  - F3 · 发丝面积图: skills/data-visualization/lieflat-charts/reference/f3-hairline-area.md
                  - F4 · 刻线环形图: skills/data-visualization/lieflat-charts/reference/f4-tick-donut.md
                  - F5 · 刻线横条图: skills/data-visualization/lieflat-charts/reference/f5-tick-rows.md
                  - F6 · 并列梯级柱: skills/data-visualization/lieflat-charts/reference/f6-paired-rungs.md
                  - F7 · 堆叠梯级柱: skills/data-visualization/lieflat-charts/reference/f7-stacked-rungs.md
                  - F8 · 铅垂散点图: skills/data-visualization/lieflat-charts/reference/f8-plumb-scatter.md
                  - F9 · 梯级瀑布图: skills/data-visualization/lieflat-charts/reference/f9-rung-waterfall.md
                  - F10 · 点阵热力图: skills/data-visualization/lieflat-charts/reference/f10-dot-heat.md
                  - F11 · 刻线进度表: skills/data-visualization/lieflat-charts/reference/f11-tick-gauge.md
                  - F12 · 串珠哑铃图: skills/data-visualization/lieflat-charts/reference/f12-dumbbell-queue.md
              - 编辑型 · Lupi Editorial:
                  - L1 · 上线扇形图: skills/data-visualization/lieflat-charts/reference/l1-launch-fan.md
                  - L2 · 点阵级联图: skills/data-visualization/lieflat-charts/reference/l2-dot-cascade.md
                  - L3 · 条码棒棒糖图: skills/data-visualization/lieflat-charts/reference/l3-barcode-lollipop.md
                  - L4 · 弧形矩阵: skills/data-visualization/lieflat-charts/reference/l4-arc-matrix.md
                  - L5 · 径向汇聚图: skills/data-visualization/lieflat-charts/reference/l5-radial-convergence.md
                  - L6 · 贡献者星群: skills/data-visualization/lieflat-charts/reference/l6-cluster-field.md
                  - L7 · 品牌光谱: skills/data-visualization/lieflat-charts/reference/l7-brand-spectrum.md
                  - L8 · 点阵空间矩阵: skills/data-visualization/lieflat-charts/reference/l8-dotty-matrix.md
                  - L9 · 气泡年鉴: skills/data-visualization/lieflat-charts/reference/l9-bubble-almanac.md
                  - L10 · 径向拼布图: skills/data-visualization/lieflat-charts/reference/l10-radial-patchwork.md
                  - L11 · 趋势谱系图: skills/data-visualization/lieflat-charts/reference/l11-trend-lineage.md
                  - L12 · 归属柱廊图: skills/data-visualization/lieflat-charts/reference/l12-type-colonnade.md
                  - L13 · 沙漏流图: skills/data-visualization/lieflat-charts/reference/l13-hourglass-stream.md
                  - L14 · 百人点阵: skills/data-visualization/lieflat-charts/reference/l14-hundred-field.md
                  - L15 · 选票刻线图: skills/data-visualization/lieflat-charts/reference/l15-ballot-tally.md
              - 快读型 · Glance:
                  - G1 · 区间胶囊图: skills/data-visualization/lieflat-charts/reference/g1-range-capsules.md
                  - G2 · 花瓣玫瑰图: skills/data-visualization/lieflat-charts/reference/g2-petal-rose.md
                  - G3 · 粗体柱状图: skills/data-visualization/lieflat-charts/reference/g3-chunky-bars.md
                  - G4 · 点阵华夫图: skills/data-visualization/lieflat-charts/reference/g4-dot-waffle.md
                  - G5 · 象形柱状图: skills/data-visualization/lieflat-charts/reference/g5-pictorial-bar.md
                  - G6 · 小型环形关系图: skills/data-visualization/lieflat-charts/reference/g6-circular-graph.md
                  - G7 · 左右树状图: skills/data-visualization/lieflat-charts/reference/g7-tree-lr.md
                  - G8 · 雨幕双面积图: skills/data-visualization/lieflat-charts/reference/g8-rainfall-dual-area.md
                  - G9 · 散点变形图: skills/data-visualization/lieflat-charts/reference/g9-scatter-morph.md
                  - G10 · 发散条形图: skills/data-visualization/lieflat-charts/reference/g10-diverging-bar.md
                  - G11 · 小型力导向图: skills/data-visualization/lieflat-charts/reference/g11-force-graph.md
                  - G12 · 错落波形图: skills/data-visualization/lieflat-charts/reference/g12-stagger-wave.md
                  - G13 · 大切片图: skills/data-visualization/lieflat-charts/reference/g13-big-slice.md
                  - G14 · 单轴图: skills/data-visualization/lieflat-charts/reference/g14-single-axis.md
                  - G15 · 抖动条带图: skills/data-visualization/lieflat-charts/reference/g15-jitter-strip.md
                  - G16 · 动态条形竞赛: skills/data-visualization/lieflat-charts/reference/g16-bar-race.md
                  - G17 · 动态流图: skills/data-visualization/lieflat-charts/reference/g17-dynamic-stream.md
                  - G18 · 一笔绘制与计数器: skills/data-visualization/lieflat-charts/reference/g18-draw-in-plus-counter.md
              - 独立交互大图:
                  - B1 · 密集环形关系图: skills/data-visualization/lieflat-charts/reference/b1-circular-graph-dense.md
                  - B2 · 密集力导向图: skills/data-visualization/lieflat-charts/reference/b2-force-graph-dense.md
                  - B3 · 三段丝线图: skills/data-visualization/lieflat-charts/reference/b3-thread-triptych.md
      - 前端画布:
          - skills/canvas/index.md
          - L1 · 原生无限画布: skills/canvas/reference/l1-vanilla.md
          - L2 · React 手写画布: skills/canvas/reference/l2-react.md
          - L3 · 数据驱动节点图: skills/canvas/reference/l3-nodegraph.md
          - L4 · 白板: skills/canvas/reference/l4-whiteboard.md
      - 写作口味:
          - skills/writing/index.md
          - MkDocs Wiki 文档: skills/writing/mkdocs-wiki/index.md
          - 报纸版 HTML: skills/writing/newspaper/index.md
          - 滚动 deck 工程手册: skills/writing/deck/index.md
          - 去 AI 味:
              - skills/writing/qu-ai-wei/index.md
              - 51 条 AI 腔模式: skills/writing/qu-ai-wei/references/patterns.md
              - 平台场景规则: skills/writing/qu-ai-wei/references/platform-patterns.md
              - 品牌文案语体: skills/writing/qu-ai-wei/references/brand-voice.md
              - 白名单: skills/writing/qu-ai-wei/references/whitelists.md
              - 标点规范: skills/writing/qu-ai-wei/references/punctuation.md
              - 语序诊断: skills/writing/qu-ai-wei/references/syntax.md
              - 完整示例: skills/writing/qu-ai-wei/references/examples.md
              - 正面参考模型: skills/writing/qu-ai-wei/references/reference-models.md
              - 参考来源: skills/writing/qu-ai-wei/references/sources.md
          - 檄文:
              - skills/writing/xi-wen/index.md
              - 逐篇精读: skills/writing/xi-wen/references/逐篇精读.md
              - 骈句声律: skills/writing/xi-wen/references/骈句声律.md
              - 用典骂法库: skills/writing/xi-wen/references/用典骂法库.md
              - 戏仿范例: skills/writing/xi-wen/references/戏仿范例.md
              - 底本·为袁绍檄豫州文: skills/writing/xi-wen/references/originals/为袁绍檄豫州文.md
              - 底本·为李密檄洛州文: skills/writing/xi-wen/references/originals/为李密檄洛州文.md
              - 底本·为徐敬业讨武曌檄: skills/writing/xi-wen/references/originals/为徐敬业讨武曌檄.md
              - 底本·谕中原檄: skills/writing/xi-wen/references/originals/谕中原檄.md
              - 底本·讨粤匪檄: skills/writing/xi-wen/references/originals/讨粤匪檄.md
      - 前端风格收集:
          - skills/frontend-styles/index.md
          - PIP-BOY 琥珀终端: skills/frontend-styles/reference/pip-boy-terminal.md
          - Celestia 主题收藏卡: skills/frontend-styles/reference/celestia-collection.md
          - Verdant Glass 苔光用量台: skills/frontend-styles/reference/verdant-glass.md
          - Syzygy 克莱因蓝星穹: skills/frontend-styles/reference/syzygy-astral.md
          - NEXUS 2030 酸绿深空首屏: skills/frontend-styles/reference/nexus-2030.md
          - ATELIER 纸白动力学: skills/frontend-styles/reference/atelier-kinetic.md
          - PLATTER 平面音乐档案: skills/frontend-styles/reference/task1-platter.md
          - PARALLAX 三态作品档案: skills/frontend-styles/reference/task2-parallax-archive.md
          - LINE//SYSTEM 线性工业图形: skills/frontend-styles/reference/task3-line-system.md
          - WORLD FILES 档案袋叙事: skills/frontend-styles/reference/task4-world-files.md
          - PIXEL BLOOM 网格揭示: skills/frontend-styles/reference/task5-pixel-bloom.md
          - TINTORY 视觉考古编辑部: skills/frontend-styles/reference/task6-tintory.md
          - NEAT ANNOTATIONS 手绘标注标本: skills/frontend-styles/reference/task7-neat-annotations.md
          - One Hub 翡翠管理台: skills/frontend-styles/reference/onehub-berry-admin.md
          - Vercel Lanyard 可拖拽 3D 工牌: skills/frontend-styles/reference/vercel-lanyard.md
          - Open Design 官网拆解:
              - skills/frontend-styles/open-design/index.md
              - 滚动感知吸顶导航: skills/frontend-styles/open-design/headroom-nav.md
              - 标题逐词模糊入场: skills/frontend-styles/open-design/blur-text.md
              - 滚动入场编排: skills/frontend-styles/open-design/scroll-reveal.md
              - 宣言逐字点亮: skills/frontend-styles/open-design/statement-reveal.md
              - 滚动锁定步骤联动: skills/frontend-styles/open-design/scrolly-steps.md
              - 可拖拽贴纸: skills/frontend-styles/open-design/drag-sticker.md
              - 磁性 Dock 预览切换: skills/frontend-styles/open-design/magnetic-dock.md
              - 图标物理掉落: skills/frontend-styles/open-design/falling-chips.md
              - 点阵地球与贡献者轨道: skills/frontend-styles/open-design/globe-orbit.md
              - 贴纸统计卡与数字滚动: skills/frontend-styles/open-design/stat-cards.md
              - 页底渐进高斯模糊: skills/frontend-styles/open-design/gradual-blur.md
          - HAOQI 3D 官网拆解:
              - skills/frontend-styles/haoqi/index.md
              - 3D 开场专题: skills/frontend-styles/haoqi/01-opening.md
              - 第二个特效(预留): skills/frontend-styles/haoqi/02-effect.md
              - 第三个特效(预留): skills/frontend-styles/haoqi/03-effect.md
          - ORYZO 官网拆解:
              - skills/frontend-styles/oryzo/index.md
              - 蓝图开场动画: skills/frontend-styles/oryzo/blueprint-intro.md
              - 高斯泼溅渲染系统: skills/frontend-styles/oryzo/splat-system.md
              - 滚动叙事与场景切换: skills/frontend-styles/oryzo/scene-morph.md
              - Wearable 一幕: skills/frontend-styles/oryzo/wearable-scene.md
              - RGB 呼吸光边: skills/frontend-styles/oryzo/rgb-glow-border.md
      - 和 AI 协作:
          - skills/collab/index.md
          - CLAUDE.md 初始化模板: skills/collab/claude-md-template/index.md
          - 盘问我: skills/collab/grilling/index.md
          - 带文档盘问:
              - skills/collab/grill-with-docs/index.md
              - Domain Modeling: skills/collab/grill-with-docs/domain-modeling/SKILL.md
              - CONTEXT.md 格式: skills/collab/grill-with-docs/domain-modeling/CONTEXT-FORMAT.md
              - ADR 格式: skills/collab/grill-with-docs/domain-modeling/ADR-FORMAT.md
.gitignore — 关键排除项
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Zensical / MkDocs build output
site/
.cache/

# Secrets
.env
.env.*

# 二进制媒体与第三方 vendor 真身经 git-lfs 入库:.gitattributes 定规则,
# blob 存备份桶 lfs/ 前缀(agent:scripts/lfs-cos-agent.py;
# 新机器跑 scripts/setup-lfs.sh 接入;备份桶根下的路径镜像为历史双轨:scripts/backup-media.sh)

.claude/
.agents/
deploy.sh — 构建 + 同步两个视图到对象存储
#!/usr/bin/env bash
# 构建 + 同步到腾讯云 COS:site/ 的 .html 给人看,docs/ 的 .md 源给 AI 读
set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_DIR"

# 0. 收编副机 push 到 GitHub 的内容;--ff-only:本地有未推提交或冲突时报错停住(set -e 拦下),
#    逼人先处理再部署,不会静默合并出意外内容
git pull --ff-only

# 0.5 链路校验:本 wiki 的 AI 入口是相对链接、不是 nav,所以漏挂链接的文章对 AI 等于
#     不存在。这道闸门要求每篇 .md 都能被某个祖先索引页直接链到,另查死链与 nav 注册。
#     放在读凭证和构建之前 —— 失败得越早,浪费越少(set -e 拦下非零退出码)
python3 "$PROJECT_DIR/scripts/check-links.py"

# 1. 读凭证、写 coscmd 配置
set -a; source .env; set +a
COSCMD="$PROJECT_DIR/.venv/bin/coscmd"
"$COSCMD" config -a "$COS_SECRET_ID" -s "$COS_SECRET_KEY" -b "$COS_BUCKET" -r "$COS_REGION"

# 2. 构建(先清 site/:第 4 步 --delete 以 site/ 为准,残留会"保护"桶里陈旧不被清掉)
rm -rf "$PROJECT_DIR/site"
uv run zensical build

# 3. 复制 docs/ 的 .md 源进 site/(保留目录结构)→ site/ 成为桶的单一镜像;
#    .md 与 .html 的 key 不冲突(use_directory_urls:页面在 /x/index.html,源在 /x.md)
rsync -am --include='*/' --include='*.md' --exclude='*' "$PROJECT_DIR/docs/" "$PROJECT_DIR/site/"

# 3.5 llms.txt = index.md 的构建期副本(同一份内容双 URL:/index.md 给源、/llms.txt 给 llms.txt 协议爬虫)
#     写作时只维护 docs/index.md 一份即可;index.md 正文按 llms.txt 规范写(H1+blockquote+H2),
#     但头部带首页开屏的 frontmatter(template: home.html)和同步提示注释 —— llms.txt 要求
#     H1 开头,这里剥掉 frontmatter 块和正文前的 HTML 注释再落盘
awk 'NR==1 && /^---$/ {fm=1; next} fm && /^---$/ {fm=0; next} fm {next} {print}' \
  "$PROJECT_DIR/site/index.md" \
  | perl -0pe 's/\A\s*(<!--.*?-->\s*)*//s' > "$PROJECT_DIR/site/llms.txt"

# 4. 同步 site/ 到 bucket 根(cd 进 site/ 用绝对路径 coscmd;不能用 uv run --directory,
#    它会把 CWD 切回项目根、把 .venv/.env 一起传上去)
#    .md 必须先传:coscmd 默认给 .md 配 octet-stream,所以新增/变动的 .md 要由这条带
#    Content-Type 的命令落桶;随后的全量同步见 MD5 一致会跳过,不会用错类型覆盖回去。
#    两条递归都带 -s(MD5 一致跳过),没变化的文件两步都不动。
cd "$PROJECT_DIR/site"
"$COSCMD" upload -rs --include '*.md' -H 'Content-Type: text/markdown; charset=utf-8' ./ /
#    代码源文件(demo 的 source/、文章附的脚本)同理要钉类型:COS 按扩展名猜,
#    .ts 会猜成 video/mp2t(MPEG 视频流)、.tsx/.jsx/.mjs/.py 猜成 octet-stream——
#    AI 抓取工具按 content-type 过滤文本时直接拒收,「完整实现」的链接摆得再对也读不回内容。
#    带 -s:-s 只比对 MD5 不看类型,新增/内容变动的文件会带着正确类型上传,没变的跳过。
#    (桶里错类型的历史对象已在 2026-08 前的部署里全量重传修正过,此后无需再无条件重传;
#    若哪天又混入错类型对象,临时去掉 -s 跑一次即可自愈)
"$COSCMD" upload -rs --include '*.ts,*.tsx,*.jsx,*.mjs,*.py' -H 'Content-Type: text/plain; charset=utf-8' ./ /
# llms.txt 不带 -s:coscmd 单文件 upload -s 命中"MD5 一致跳过"时退出码是 254(且静默),
# 会被 set -e 误杀、后面的全量镜像不再执行;2KB 每次直传换脚本必然走完。
"$COSCMD" upload -H 'Content-Type: text/plain; charset=utf-8' llms.txt /llms.txt  # 内容是 markdown,扩展名按 llms.txt 规范用 .txt,浏览器直读用 text/plain
"$COSCMD" upload -rs --delete -y ./ /  # 全量镜像+清桶里陈旧;-y 免确认(set -e 保证 build 失败不会拿空 site 清空桶)

echo
echo "✅ 部署完成 — https://${COS_DOMAIN}/"
scripts/lfs-cos-agent.py — mini LFS:COS 后端 standalone transfer agent
#!/usr/bin/env python3
"""git-lfs standalone custom transfer agent,后端为腾讯云 COS 备份桶。

由 git-lfs 在 push/pull 时按需拉起(scripts/setup-lfs.sh 负责注册到 git config),
stdin/stdout 走 JSON-lines 协议;每个进程串行处理,git-lfs 靠多开进程实现并发。
协议:https://github.com/git-lfs/git-lfs/blob/main/docs/custom-transfers.md

用法(由 lfs.customtransfer.cos.args 传入):
  lfs-cos-agent.py [前缀]   # blob 存 cos://$COS_BACKUP_BUCKET/<前缀>/ab/cd/<oid>,默认 lfs

凭证复用项目 .env(COS_SECRET_ID/KEY、COS_REGION、COS_BACKUP_BUCKET)。
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

PROJECT_DIR = Path(__file__).resolve().parent.parent


def load_env():
    env = {}
    for line in (PROJECT_DIR / ".env").read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, _, v = line.partition("=")
        env[k.strip()] = v.strip().strip("'\"")
    return env


def reply(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()


def log(msg):
    print(f"[lfs-cos-agent] {msg}", file=sys.stderr, flush=True)


class Agent:
    def __init__(self, prefix):
        self.prefix = prefix.strip("/")
        self.client = None
        self.bucket = None
        self.tmpdir = None

    def key(self, oid):
        # 内容寻址:sha256 前两级做目录,避免单层百万对象
        return f"{self.prefix}/{oid[:2]}/{oid[2:4]}/{oid}"

    def handle_init(self, msg):
        try:
            from qcloud_cos import CosConfig, CosS3Client

            env = load_env()
            self.bucket = env["COS_BACKUP_BUCKET"]
            self.client = CosS3Client(
                CosConfig(
                    Region=env["COS_REGION"],
                    SecretId=env["COS_SECRET_ID"],
                    SecretKey=env["COS_SECRET_KEY"],
                )
            )
            # 下载先落 .git/lfs/tmp:与仓库同文件系统,git-lfs 移走时 rename 不跨设备
            try:
                gitdir = subprocess.check_output(
                    ["git", "rev-parse", "--absolute-git-dir"], text=True
                ).strip()
                self.tmpdir = Path(gitdir) / "lfs" / "tmp"
                self.tmpdir.mkdir(parents=True, exist_ok=True)
            except Exception:
                self.tmpdir = Path(tempfile.mkdtemp(prefix="lfs-cos-"))
            reply({})
        except Exception as e:
            reply({"error": {"code": 1, "message": f"init 失败: {e}"}})

    def handle_upload(self, msg):
        oid = msg["oid"]
        try:
            key = self.key(oid)
            if self.client.object_exists(Bucket=self.bucket, Key=key):
                log(f"跳过(桶里已有){oid[:12]}")
            else:
                self.client.upload_file(
                    Bucket=self.bucket, Key=key, LocalFilePath=msg["path"]
                )
                log(f"上传 {msg.get('size', '?')}B {oid[:12]}")
            reply({"event": "complete", "oid": oid})
        except Exception as e:
            reply(
                {
                    "event": "complete",
                    "oid": oid,
                    "error": {"code": 2, "message": f"上传 {self.key(oid)} 失败: {e}"},
                }
            )

    def handle_download(self, msg):
        oid = msg["oid"]
        try:
            fd, tmp = tempfile.mkstemp(dir=self.tmpdir, prefix=oid[:12] + "-")
            os.close(fd)
            self.client.download_file(
                Bucket=self.bucket, Key=self.key(oid), DestFilePath=tmp
            )
            log(f"下载 {msg.get('size', '?')}B {oid[:12]}")
            reply({"event": "complete", "oid": oid, "path": tmp})
        except Exception as e:
            reply(
                {
                    "event": "complete",
                    "oid": oid,
                    "error": {"code": 2, "message": f"下载 {self.key(oid)} 失败: {e}"},
                }
            )


def main():
    agent = Agent(sys.argv[1] if len(sys.argv) > 1 else "lfs")
    handlers = {
        "init": agent.handle_init,
        "upload": agent.handle_upload,
        "download": agent.handle_download,
    }
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        msg = json.loads(line)
        event = msg.get("event")
        if event == "terminate":
            break
        if event in handlers:
            handlers[event](msg)
        else:
            log(f"忽略未知事件: {event}")


if __name__ == "__main__":
    main()
scripts/setup-lfs.sh — 每个 clone 一次性接入 LFS
#!/usr/bin/env bash
# 把"当前所在的 git 仓库"接上 COS 后端的 git-lfs(scripts/lfs-cos-agent.py)。
#
# 每台机器 / 每个 clone 跑一次(agent 路径出于安全不能写进仓库内 .lfsconfig,
# 只能落在本地 git config)。在目标仓库目录里执行:
#   bash /path/to/wiki/scripts/setup-lfs.sh          # 前缀默认 lfs
#   bash /path/to/wiki/scripts/setup-lfs.sh lfs-test # 测试仓用独立前缀
#
# 依赖:本机装有 git-lfs;wiki 项目的 .venv(qcloud_cos)与 .env(COS 凭证)。
set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PREFIX="${1:-lfs}"

git rev-parse --is-inside-work-tree >/dev/null || { echo "请在目标 git 仓库里执行" >&2; exit 1; }
[ -x "$PROJECT_DIR/.venv/bin/python" ] || { echo "缺 $PROJECT_DIR/.venv,先 uv sync" >&2; exit 1; }
[ -f "$PROJECT_DIR/.env" ] || { echo "缺 $PROJECT_DIR/.env(COS 凭证)" >&2; exit 1; }

git lfs install --local
git config lfs.customtransfer.cos.path "$PROJECT_DIR/.venv/bin/python"
git config lfs.customtransfer.cos.args "$PROJECT_DIR/scripts/lfs-cos-agent.py $PREFIX"
git config lfs.standalonetransferagent cos

echo "✅ 已接入 COS-LFS:blob → cos://\$COS_BACKUP_BUCKET/$PREFIX/(凭证读 $PROJECT_DIR/.env)"
deploy-cert/install.sh — 首次配置 acme.sh + 注册 hook
#!/usr/bin/env bash
# 首次配置:装 acme.sh、申请证书、注册自动续期 hook
# 之后续期由 acme.sh 内置 cron 触发,无需再跑此脚本
set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"

set -a
# shellcheck disable=SC1091
source .env
set +a

# 必填项
need() {
    if [ -z "${!1:-}" ]; then
        echo "ERROR: .env 缺少 $1" >&2
        exit 1
    fi
}
need DP_Id
need DP_Key
need ACME_EMAIL
need COS_SECRET_ID
need COS_SECRET_KEY
need COS_BUCKET
need COS_REGION
need COS_DOMAIN

ACME="$HOME/.acme.sh/acme.sh"
PYTHON="$PROJECT_DIR/.venv/bin/python"
DEPLOY_SCRIPT="$PROJECT_DIR/deploy-cert/deploy_cert_to_cos.py"

# 1. 装 acme.sh(如果还没装)
if [ ! -x "$ACME" ]; then
    echo ">>> 安装 acme.sh ..."
    curl -fsSL https://get.acme.sh | sh -s email="$ACME_EMAIL"
fi

# 2. 切到 Let's Encrypt 作为默认 CA(acme.sh 0.6 起默认 ZeroSSL)
"$ACME" --set-default-ca --server letsencrypt

# 3. 申请证书(DNS-01)—— DP_Id/DP_Key 已经在环境里
echo ">>> 申请证书 for $COS_DOMAIN ..."
"$ACME" --issue --dns dns_dp -d "$COS_DOMAIN" --keylength ec-256

# 4. 注册 reloadcmd:续期成功后自动调 Python 脚本,把新证书推到 COS
RELOADCMD="set -a; . '$PROJECT_DIR/.env'; set +a; \
export TENCENT_SECRET_ID=\"\$COS_SECRET_ID\" TENCENT_SECRET_KEY=\"\$COS_SECRET_KEY\"; \
'$PYTHON' '$DEPLOY_SCRIPT'"

"$ACME" --install-cert -d "$COS_DOMAIN" --ecc --reloadcmd "$RELOADCMD"

# 5. 首次也手动跑一次 reloadcmd,把刚拿到的证书推到 COS
echo ">>> 首次部署证书到 COS ..."
"$ACME" --renew -d "$COS_DOMAIN" --ecc --force

echo
echo "✅ 完成"
echo
echo "已注册的 cron 任务(acme.sh 自动加的,每天 0 点检查):"
crontab -l 2>/dev/null | grep -i acme || echo "(看不到?运行 ~/.acme.sh/acme.sh --install-cronjob)"
echo
echo "常用命令:"
echo "  查看证书状态:    ~/.acme.sh/acme.sh --list"
echo "  强制立即续期:    ~/.acme.sh/acme.sh --renew -d $COS_DOMAIN --ecc --force"
echo "  查看续期日志:    tail -f ~/.acme.sh/acme.sh.log"
deploy-cert/deploy_cert_to_cos.py — acme.sh 调用的证书推送脚本
#!/usr/bin/env python3
"""
acme.sh 续期后调用此脚本,把新证书绑定到 COS 自定义域名。

acme.sh 通过 reloadcmd 调用时会注入:
  CERT_FULLCHAIN_PATH / CERT_KEY_PATH / Le_Domain 等

本脚本额外要求的环境变量(由 .env 提供):
  TENCENT_SECRET_ID / TENCENT_SECRET_KEY
  COS_REGION / COS_BUCKET / COS_DOMAIN
"""
import os
import sys
from pathlib import Path

from qcloud_cos import CosConfig, CosS3Client


def must_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        sys.exit(f"ERROR: missing required env var: {name}")
    return v


def main() -> None:
    secret_id = must_env("TENCENT_SECRET_ID")
    secret_key = must_env("TENCENT_SECRET_KEY")
    region = must_env("COS_REGION")
    bucket = must_env("COS_BUCKET")
    domain = must_env("COS_DOMAIN")

    cert_path = os.environ.get("CERT_FULLCHAIN_PATH") or os.environ.get("CERT_PATH")
    key_path = os.environ.get("CERT_KEY_PATH")
    if not cert_path or not key_path:
        sys.exit(
            "ERROR: CERT_FULLCHAIN_PATH and CERT_KEY_PATH must be set "
            "(acme.sh injects them automatically via --reloadcmd)"
        )

    cert = Path(cert_path).read_text()
    key = Path(key_path).read_text()

    client = CosS3Client(CosConfig(
        Region=region,
        SecretId=secret_id,
        SecretKey=secret_key,
        Scheme="https",
    ))

    client.put_bucket_domain_certificate(
        Bucket=bucket,
        DomainCertificateConfiguration={
            "CertificateInfo": {
                "CertType": "CustomCert",
                "CustomCert": {"Cert": cert, "PrivateKey": key},
            },
            "DomainList": [{"DomainName": domain}],
        },
    )
    print(f"OK: bound new cert to {domain} on bucket {bucket}")


if __name__ == "__main__":
    main()
pyproject.toml — uv 依赖清单
[project]
name = "zensical-wiki"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
dependencies = [
    "zensical>=0.0.46",
]

[dependency-groups]
dev = [
    "coscmd>=1.9.0.6",
]

其中 coscmd 会带来 cos-python-sdk-v5 作为传递依赖,deploy_cert_to_cos.pylfs-cos-agent.py 都用它。

.env 模板 — 凭证占位(绝对不要 commit)
# ----- 对象存储(腾讯云 COS):部署内容 + 给证书 hook 用 -----
COS_BUCKET=wiki-1307341066
COS_APPID=1307341066
COS_REGION=ap-chengdu
COS_SECRET_ID=AKID...你的 SecretId
COS_SECRET_KEY=你的 SecretKey
COS_DOMAIN=wiki.liuhetian.work

# ----- acme.sh:自动 SSL 证书 -----
ACME_EMAIL=you@example.com

# DNSPod API Token:去 https://console.dnspod.cn/account/token 创建后填
DP_Id=...
DP_Key=...

一次性安装命令(拷下来直接跑)

# 1. 项目初始化
mkdir wiki && cd wiki
uv init --no-readme
uv add zensical
uv add --dev coscmd

# 2. 把上面所有文件写好 + 填好 .env

# 3. 接入 git-lfs(媒体 blob 走备份桶,见「资源备份」一节)
bash scripts/setup-lfs.sh

# 4. 首次部署内容
./deploy.sh

# 5. 首次配置 SSL 自动化
./deploy-cert/install.sh

之后每次写完文章只跑 ./deploy.sh 一行,两个视图(HTML + md 源)同时更新,证书永久无需操心;在别的机器写的内容会被脚本第 0 步的 pull 自动捎带上线(见日常写作流)。


  1. Zensical roadmap —— 未来 12 个月聚焦模块系统(2026 初开放)、组件系统、CommonMark、搜索等,未涉及 markdown 源输出 / llms.txt。 

  2. Let's Encrypt - Decreasing Certificate Lifetimes to 45 Days —— 官方公告,2026 年 5 月 13 日起默认证书有效期从 90 天缩短到 45 天。 

  3. acme.sh DNS API 列表 —— 各家 DNS 服务商对应的 acme.sh 插件名(Cloudflare/阿里云/AWS Route53 等都有)。 

  4. 腾讯云 COS Go SDK 测试代码 —— BucketPutDomainCertificateOptions 的标准结构体定义,明确给出了 CertificateInfoCertType + CustomCert 的嵌套层级。