腾讯云 COS + acme.sh:部署实操手册
这是落地篇
本篇是《用对象存储部署 AI 友好的个人知识库》的实操手册。主文讲清楚了为什么这么设计——可检验的 AI 友好标准、一颗 rsync 语法糖、两个写作约定,以及为什么选对象存储而非服务器;本篇把它跑起来:从建 bucket、绑域名,到 HTTPS 自动续期,再到完整代码与一次性安装命令。腾讯云 COS 特有的坑(强制下载、CNAME、证书 hook)都收在这里。
落地
第一步:建 bucket
腾讯云控制台 → 对象存储 → 创建 bucket(其他家对象存储同理):
- 名称:随便起,比如
wiki-1307341066 - 访问权限:公有读私有写
- 地域:这里有个关键选择(详见后面"强制下载"一节):
ap-hongkong、ap-singapore 等 —— 无强制下载、不需备案,最省事。
ap-shanghai、ap-chengdu 等 —— 国内访问快,但默认强制下载,必须绑中国大陆 ICP 备案的域名才能解除。
建好后:
- 基础配置 → 静态网站 → 启用
- 索引文档:
index.html - 错误文档:
404.html
第二步:本地 Zensical 项目
最简 mkdocs.yml(仅用来跑通,真实配置见文末完整版):
构建 + 预览:
第三步: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)
- 先
rm -rf site再 build 不能省:第 3 步--delete以site/为准做全量镜像,本地残留的旧文件会"保护"桶里的陈旧内容不被清掉。 .md和.html的 key 不冲突:use_directory_urls下页面渲染成/x/index.html,源是/x.md,天然错开 —— md 源镜像进site/不会覆盖任何 HTML。- 这一步不能省:coscmd 默认把
.md传成application/octet-stream,浏览器会直接下载而不是显示 —— 对"给 AI/人直接读源"这个用途是致命的。
装 coscmd(腾讯云 COS 的命令行;其他家换成对应 CLI,如 aws s3 sync、rclone):
凭证写 .env(记得加进 .gitignore,模板见文末)。完整脚本见文末 deploy.sh snippet —— 引用的就是仓库里真跑的那份。
踩坑:别让 uv run --directory 把整个项目传上去
最早我写的是 cd site && uv run --directory .. coscmd upload -rs ./ /,但 uv run --directory 会把工作目录切回项目根,导致 coscmd 把整个项目(包括 .venv/ 和 .env!)都往对象存储传。绝对不能这样写。正确做法是 cd 进 site/ 后直接用绝对路径调用 venv 里的 coscmd。
AI 友好这层跟 zensical 无关,怎么升级都不怕
复制 md 源、镜像上传、修 Content-Type 这三件事零 zensical 依赖 —— 哪怕以后 Zensical 大改、甚至换回 MkDocs 或别的生成器,这层照常工作。真正依赖 zensical 的只有渲染层(snippets 等 pymdownx 扩展),版本锁在 uv.lock 里不会自动升,升级前本地 build 验证即可。
第四步:踩坑 —— 国内 bucket 的强制下载
第一次访问 bucket 默认域名时,浏览器直接把 HTML 当成文件下载。curl -I 看到响应正常:
但 curl -i(GET)看到 GET 请求多了两行:
这是腾讯云为了响应监管"未备案不能搭网站"的策略:国内区域 bucket 通过 *.myqcloud.com 域名访问 HTML 一律强制下载,跟 bucket 配置无关。解除方式只有:
- 绑定中国大陆 ICP 备案过的自定义域名,通过那个域名访问就豁免
- 或换到海外区域 bucket
我选了"绑备案域名"。
换别家对象存储就没这道坎
这条是国内对象存储特有的监管策略。用 Cloudflare R2 / AWS S3 这类海外服务直接绑自定义域名就没有强制下载问题 —— 但国内访问速度和备案是另一笔账。
第五步:绑定自定义域名
前提:liuhetian.work 已经 ICP 备案。注意备案对象是主域 liuhetian.work,不是 www.liuhetian.work,所有子域(wiki、api 等)自动覆盖。
绑定要做两件事,缺一不可 —— 这是我反复踩坑的关键:
其一,在控制台添加自定义源站域名。控制台 → bucket → 域名与传输管理 → 自定义源站域名 → 添加:
- 域名:
wiki.liuhetian.work - 源站类型:必须选 静态网站源站(不是默认源站!否则访问
/会触发 ListBucket API,返回AccessDenied) - 是否启用 HTTPS:先关,证书后面再补
保存后,控制台会展示一个 CNAME 目标:
其二,在 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"]}],
},
)
- 重要:
CertificateInfo这一层包装绝对不能漏。我第一版按 Python SDK 测试文件示例写少了这层,腾讯云返回InvalidArgument。真正的 XML 结构以 Go SDK 测试代码为准4。
注册 acme.sh reloadcmd。install.sh 最后把"读 .env → 设环境变量 → 跑 Python 推证书"这串命令注册成 acme.sh 的 --reloadcmd。acme.sh 把它存到 ~/.acme.sh/wiki.liuhetian.work_ecc/wiki.liuhetian.work.conf,每次续期成功后自动跑。
第七步:从此不用管 —— 自动续期循环
acme.sh 装的时候自动加了 cron:
每天 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:
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 © 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.py 和 lfs-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 自动捎带上线(见日常写作流)。
-
Zensical roadmap —— 未来 12 个月聚焦模块系统(2026 初开放)、组件系统、CommonMark、搜索等,未涉及 markdown 源输出 / llms.txt。 ↩
-
Let's Encrypt - Decreasing Certificate Lifetimes to 45 Days —— 官方公告,2026 年 5 月 13 日起默认证书有效期从 90 天缩短到 45 天。 ↩↩
-
acme.sh DNS API 列表 —— 各家 DNS 服务商对应的 acme.sh 插件名(Cloudflare/阿里云/AWS Route53 等都有)。 ↩
-
腾讯云 COS Go SDK 测试代码 ——
BucketPutDomainCertificateOptions的标准结构体定义,明确给出了CertificateInfo→CertType+CustomCert的嵌套层级。 ↩