<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: lizhaopeng-cn</title>
    <description>The latest articles on DEV Community by lizhaopeng-cn (@lizhaopengcn).</description>
    <link>https://dev.to/lizhaopengcn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3895162%2F3d029651-5359-4fda-92b7-87a7171460f8.jpeg</url>
      <title>DEV Community: lizhaopeng-cn</title>
      <link>https://dev.to/lizhaopengcn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lizhaopengcn"/>
    <language>en</language>
    <item>
      <title>从零搭建个人技术博客 · 篇三：用 Claude Code 的 skill 和 command 接管博客增删改查</title>
      <dc:creator>lizhaopeng-cn</dc:creator>
      <pubDate>Fri, 24 Apr 2026 16:31:21 +0000</pubDate>
      <link>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-san-yong-claude-code-de-skill-he-command-jie-guan-bo-ke-zeng-shan-gai-cha-3lm0</link>
      <guid>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-san-yong-claude-code-de-skill-he-command-jie-guan-bo-ke-zeng-shan-gai-cha-3lm0</guid>
      <description>&lt;p&gt;这是"从零搭建个人技术博客"系列的第三篇。前两篇讲了怎么把站点跑起来、怎么把文章自动分发到 dev.to / Hashnode；这一篇往回退一步，聊写作流本身 —— 写/改/删一篇博客的动作太零碎了，想把它自动化掉。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;本篇目标&lt;/strong&gt;：用 Claude Code 的 &lt;strong&gt;slash command&lt;/strong&gt; + &lt;strong&gt;skill&lt;/strong&gt; 给 &lt;code&gt;blog.xtuul.com&lt;/code&gt; 装一个"AI 管家"，任何目录下都能敲：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/blog new &lt;span class="s2"&gt;"pnpm workspace 踩坑"&lt;/span&gt;
/blog list 只看草稿
/blog edit setup-astro-cloudflare-astropaper 把踩坑第 4 条扩展一下
/blog delete some-old-post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;不在本篇&lt;/strong&gt;：主站搭建（篇一）和跨平台分发（篇二）。&lt;/p&gt;

&lt;h2&gt;
  
  
  目录
&lt;/h2&gt;

&lt;h2&gt;
  
  
  为什么不用脚本
&lt;/h2&gt;

&lt;p&gt;最朴素的方案是写几个 bash 脚本：&lt;code&gt;new-post.sh&lt;/code&gt;、&lt;code&gt;list-posts.sh&lt;/code&gt;、&lt;code&gt;delete-post.sh&lt;/code&gt;。能用，但对我来说有三个硬伤：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;frontmatter 的写入仍然要我自己想&lt;/strong&gt;。脚本能填日期、slug、author，但 &lt;code&gt;title&lt;/code&gt; / &lt;code&gt;tags&lt;/code&gt; / &lt;code&gt;description&lt;/code&gt; 这几个字段还是得我自己琢磨。AI 可以顺手把这几件事一起做了。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;自然语言参数处理不了&lt;/strong&gt;。我说"把篇一踩坑第 4 条扩展一下"，脚本怎么知道第 4 条在哪？Claude 能自己读文件理解结构。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;跨会话上下文。&lt;/strong&gt; 脚本不记得"我的博客在哪个目录、用什么 schema、风格是什么"；Claude Code 的 skill 可以把这些写进 &lt;code&gt;SKILL.md&lt;/code&gt;，每次触发自动加载。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;所以这次直接在 Claude Code 里做。&lt;/p&gt;

&lt;h2&gt;
  
  
  Slash command vs Skill
&lt;/h2&gt;

&lt;p&gt;开动之前先理清两个概念。Claude Code 里能扩展命令的有两样：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Slash command&lt;/th&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;位置&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude/commands/xxx.md&lt;/code&gt; 或 &lt;code&gt;.claude/commands/xxx.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude/skills/xxx/SKILL.md&lt;/code&gt; 或 &lt;code&gt;.claude/skills/xxx/SKILL.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;触发&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;只能&lt;/strong&gt;显式 &lt;code&gt;/xxx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/xxx&lt;/code&gt; &lt;strong&gt;或&lt;/strong&gt; Claude 根据 description 自动匹配&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结构&lt;/td&gt;
&lt;td&gt;单文件&lt;/td&gt;
&lt;td&gt;文件夹，可带 &lt;code&gt;templates/&lt;/code&gt; &lt;code&gt;references/&lt;/code&gt; &lt;code&gt;examples/&lt;/code&gt; &lt;code&gt;scripts/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长度&lt;/td&gt;
&lt;td&gt;适合一段短 prompt&lt;/td&gt;
&lt;td&gt;适合长 playbook（官方推荐 &amp;lt;2000 词）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;组合&lt;/td&gt;
&lt;td&gt;一个命令可以调用 skill&lt;/td&gt;
&lt;td&gt;skill 可以调用其他 skill&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;简单说&lt;/strong&gt;：skill 是 command 的超集。新写东西默认选 skill，除非只是"给我贴一段固定 prompt"。&lt;/p&gt;

&lt;p&gt;但 &lt;code&gt;/blog new&lt;/code&gt; 这种形态&lt;strong&gt;必须&lt;/strong&gt;由 command 提供 —— skill 的 slash 名就是它自己的 &lt;code&gt;name&lt;/code&gt;（比如 &lt;code&gt;/blog-new&lt;/code&gt;），没法拆成 &lt;code&gt;/blog&lt;/code&gt; + 子命令。所以最终方案是 &lt;strong&gt;command 做路由 + 四个 skill 做实现&lt;/strong&gt;。&lt;/p&gt;

&lt;h2&gt;
  
  
  整体架构
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      用户输入 /blog &amp;lt;sub&amp;gt; [args]
                                │
                                ▼
            ┌──────────────────────────────────────┐
            │ ~/.claude/commands/blog.md （路由）  │
            │  - 解析 $ARGUMENTS 第一个 token      │
            │  - 分派到对应 skill                  │
            └────────────────┬─────────────────────┘
                             │
       ┌──────────┬──────────┼──────────┬──────────┐
       ▼          ▼          ▼          ▼
   blog-new   blog-list  blog-edit  blog-delete
       │          │          │          │
       └──────────┴────┬─────┴──────────┘
                       │ 按需加载
                       ▼
       ~/.claude/skills/blog-shared/
         ├── templates/post.md
         ├── references/frontmatter-schema.md
         └── examples/reference-article.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blog.md&lt;/code&gt;&lt;/strong&gt;（command）只做一件事：看 &lt;code&gt;$ARGUMENTS&lt;/code&gt; 第一个词，转发给对应 skill。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;四个 skill&lt;/strong&gt; 各自是独立的 playbook，互不干扰。&lt;code&gt;edit&lt;/code&gt; 和 &lt;code&gt;delete&lt;/code&gt; 在 slug 为空时会调用 &lt;code&gt;blog-list&lt;/code&gt;（skill 之间可以互相调）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blog-shared&lt;/code&gt;&lt;/strong&gt; 不是 skill，而是&lt;strong&gt;共享资源目录&lt;/strong&gt;，放 frontmatter schema、正文模板、风格范文。所有 skill 都&lt;strong&gt;按需&lt;/strong&gt;读它，不常驻上下文。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  实现：command 薄壳
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;~/.claude/commands/blog.md&lt;/code&gt; 是一个带 YAML frontmatter 的 Markdown：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;argument-hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;new|list|edit|delete&amp;gt; [参数或自由文本]&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xtuul-blog 博客文章增删改查入口。按第一个词分派到对应 skill。&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Write, Edit, Bash, Glob, Grep&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# /blog 路由&lt;/span&gt;

用户输入：&lt;span class="sb"&gt;`$ARGUMENTS`&lt;/span&gt;

&lt;span class="gu"&gt;## 解析规则&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; 把 &lt;span class="sb"&gt;`$ARGUMENTS`&lt;/span&gt; 按&lt;span class="gs"&gt;**第一个空白**&lt;/span&gt;切分：
&lt;span class="p"&gt;   -&lt;/span&gt; 第一个 token → &lt;span class="gs"&gt;**子命令**&lt;/span&gt;（必须是 new/list/edit/delete 之一）
&lt;span class="p"&gt;   -&lt;/span&gt; 剩余全部文本 → &lt;span class="gs"&gt;**自由参数**&lt;/span&gt;，&lt;span class="gs"&gt;**原样**&lt;/span&gt;透传给对应 skill
&lt;span class="p"&gt;
2.&lt;/span&gt; 子命令和参数之间只用空格，不识别 &lt;span class="sb"&gt;`:`&lt;/span&gt; &lt;span class="sb"&gt;`|`&lt;/span&gt; &lt;span class="sb"&gt;`-`&lt;/span&gt; &lt;span class="sb"&gt;`#`&lt;/span&gt; 等分隔符。
   含空格的标题/slug 用双引号包裹。

&lt;span class="gu"&gt;## 执行&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; new    → 调用 Skill &lt;span class="sb"&gt;`blog-new`&lt;/span&gt;，透传剩余文本
&lt;span class="p"&gt;-&lt;/span&gt; list   → 调用 Skill &lt;span class="sb"&gt;`blog-list`&lt;/span&gt;，透传剩余文本
&lt;span class="p"&gt;-&lt;/span&gt; edit   → 调用 Skill &lt;span class="sb"&gt;`blog-edit`&lt;/span&gt;，透传剩余文本
&lt;span class="p"&gt;-&lt;/span&gt; delete → 调用 Skill &lt;span class="sb"&gt;`blog-delete`&lt;/span&gt;，透传剩余文本
&lt;span class="p"&gt;-&lt;/span&gt; 其他或空 → 输出用法说明，不自行推断
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;关键点&lt;/strong&gt;：不自己设计任何分隔符，只用空格 + 引号 + 自然语言。Claude 自己解析。&lt;/p&gt;

&lt;h2&gt;
  
  
  实现：blog-new skill
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;~/.claude/skills/blog-new/SKILL.md&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;blog-new&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;This skill should be used when the user asks to&lt;/span&gt;
  &lt;span class="s"&gt;"write a new blog post", "draft an article", "新开一篇文章",&lt;/span&gt;
  &lt;span class="s"&gt;"起草博客", or invokes `/blog new`. Creates a new markdown&lt;/span&gt;
  &lt;span class="s"&gt;post under /Users/xtuul/projects/xtuul-blog/src/data/blog/&lt;/span&gt;
  &lt;span class="s"&gt;following xtuul-blog's AstroPaper frontmatter schema.&lt;/span&gt;
  &lt;span class="s"&gt;Does not touch git.&lt;/span&gt;
&lt;span class="na"&gt;argument-hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[标题&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;或&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;主题描述]"&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Write, Edit, Bash, Glob, Grep&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# blog-new：起草一篇新文章&lt;/span&gt;

... 工作流 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;description 是 router 的命根子&lt;/strong&gt;。Claude 靠这段决定你说"新写一篇博客"要不要触发这个 skill。官方文档里强调三点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;第三人称&lt;/strong&gt;：&lt;code&gt;This skill should be used when...&lt;/code&gt;，不用 &lt;code&gt;You should...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;具体触发短语&lt;/strong&gt;：把用户会怎么说列出来&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;中英文并列&lt;/strong&gt;：触发短语中英文都写，命中率更高&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;body 里按&lt;strong&gt;祈使句&lt;/strong&gt;写步骤：确定标题 → 生成 slug → 写 frontmatter → 写正文 → 写入文件 → 汇报。参数为空时&lt;strong&gt;追问&lt;/strong&gt;用户"想写什么"，而不是自己编。&lt;/p&gt;

&lt;h2&gt;
  
  
  实现：blog-list / edit / delete
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blog-list&lt;/code&gt;&lt;/strong&gt;：&lt;code&gt;Glob src/data/blog/**/*.md&lt;/code&gt;，读每个文件前 30 行 frontmatter，按 &lt;code&gt;pubDatetime&lt;/code&gt; 降序出表格。支持自然语言过滤（"只看草稿"、"标签 astro"）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blog-edit&lt;/code&gt;&lt;/strong&gt;：第一个 token 作 slug，剩余作修改意图。slug 为空时&lt;strong&gt;调用 &lt;code&gt;blog-list&lt;/code&gt;&lt;/strong&gt; 让用户选 —— skill 之间互相调用是合法的，而且复用现成逻辑最干净。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blog-delete&lt;/code&gt;&lt;/strong&gt;：&lt;strong&gt;强制二次确认&lt;/strong&gt;。用户必须原样打出 &lt;code&gt;确认删除 &amp;lt;slug&amp;gt;&lt;/code&gt; 才执行，回 &lt;code&gt;y&lt;/code&gt;/&lt;code&gt;是&lt;/code&gt;/&lt;code&gt;删&lt;/code&gt; 一概不算。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  为什么 delete 要这么死板
&lt;/h3&gt;

&lt;p&gt;删除不走 git（本仓库 push 之前都在本地）。文件一旦 &lt;code&gt;rm&lt;/code&gt; 就没了。&lt;br&gt;
&lt;code&gt;y&lt;/code&gt; 太容易手滑打出来，&lt;strong&gt;原样复述 slug&lt;/strong&gt; 才能保证用户真的看清了要删哪篇。&lt;/p&gt;
&lt;h2&gt;
  
  
  共享资源：blog-shared
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;blog-shared&lt;/code&gt; 不是 skill —— 没有 &lt;code&gt;SKILL.md&lt;/code&gt;，Claude 不会主动扫描它。它是普通文件夹：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/skills/blog-shared/
├── templates/post.md                # 带 {{...}} 占位符的正文骨架
├── references/frontmatter-schema.md # 字段硬规则（必填、格式、示例）
└── examples/reference-article.md    # draft: true 的风格范文
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;在各 skill 的 &lt;code&gt;## Additional Resources&lt;/code&gt; 小节里&lt;strong&gt;显式引用&lt;/strong&gt;绝对路径：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Additional Resources&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="sb"&gt;`~/.claude/skills/blog-shared/references/frontmatter-schema.md`&lt;/span&gt; — frontmatter 字段硬规则
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`~/.claude/skills/blog-shared/templates/post.md`&lt;/span&gt; — 正文骨架模板
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`~/.claude/skills/blog-shared/examples/reference-article.md`&lt;/span&gt; — 风格范文
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude 触发 skill 时，只加载 SKILL.md body；&lt;strong&gt;需要时&lt;/strong&gt;才 &lt;code&gt;Read&lt;/code&gt; 上面这三个资源。这就是官方说的 &lt;strong&gt;progressive disclosure&lt;/strong&gt;：主文件轻量化，详细内容按需加载。&lt;/p&gt;

&lt;p&gt;好处很实在：改 schema 只改一个文件，四个 skill 自动跟着更新；改风格范文也不会污染真实的 &lt;code&gt;src/data/blog/&lt;/code&gt;。&lt;/p&gt;

&lt;h2&gt;
  
  
  项目级 vs 用户级
&lt;/h2&gt;

&lt;p&gt;Claude Code 支持两个位置：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;位置&lt;/th&gt;
&lt;th&gt;作用域&lt;/th&gt;
&lt;th&gt;何时用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;repo&amp;gt;/.claude/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅该仓库工作目录下生效&lt;/td&gt;
&lt;td&gt;和仓库强耦合的命令；团队共享&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.claude/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;全局，任意目录都能用&lt;/td&gt;
&lt;td&gt;个人工具、跨仓库可复用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;两份同名会&lt;strong&gt;项目级覆盖用户级&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;我最开始放在项目级，结果发现两个问题：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;必须 &lt;code&gt;cd ~/projects/xtuul-blog &amp;amp;&amp;amp; claude&lt;/code&gt; 才能用 &lt;code&gt;/blog&lt;/code&gt;，在桌面/下载目录里想顺手开一篇博客就不行。&lt;/li&gt;
&lt;li&gt;GitHub 仓库里有 &lt;code&gt;.claude/&lt;/code&gt; 目录会被当成项目文件同步，别的设备或者协作者 clone 下来就自动启用了，不符合"这是我个人的工具"的定位。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;所以搬到了用户级 &lt;code&gt;~/.claude/&lt;/code&gt;，并把 SKILL.md 里所有 &lt;code&gt;src/data/blog/...&lt;/code&gt; 相对路径&lt;strong&gt;全部改成绝对路径&lt;/strong&gt; &lt;code&gt;/Users/xtuul/projects/xtuul-blog/src/data/blog/...&lt;/code&gt;。代价是这套 skill 只能我这台机器用，但对我来说就是自己用，刚好。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩过的坑
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;/blog&lt;/code&gt; 首次调用报 Unknown command&lt;/strong&gt;。Claude Code 只在&lt;strong&gt;会话启动时&lt;/strong&gt;扫描 commands 和 skills 目录。新建/搬迁文件后，&lt;strong&gt;必须重启 Claude Code&lt;/strong&gt; 才能识别，&lt;code&gt;/reload&lt;/code&gt; 之类的软重载不够。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;项目级和用户级同名冲突&lt;/strong&gt;。我忘了删项目级的旧版，新会话里仍然跑的是旧的相对路径 SKILL.md。删掉项目级的 &lt;code&gt;.claude/&lt;/code&gt; 后正常。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;description 写得像文档就失效&lt;/strong&gt;。第一版我写了 &lt;code&gt;用于管理 xtuul-blog 的博客文章&lt;/code&gt;，Claude 根本不匹配自然语言请求。改成 &lt;code&gt;This skill should be used when the user asks to "write a new blog post", "新开一篇文章", or invokes /blog new&lt;/code&gt; 之后命中率立马上去。&lt;strong&gt;description 是触发条件而不是文档&lt;/strong&gt;，越具体越好。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;skill body 超 2000 词后 Claude 抓不住重点&lt;/strong&gt;。第一版 &lt;code&gt;blog-new&lt;/code&gt; 把 schema 字段表、风格约束全塞进去，400 多行。后来按官方推荐拆成 &lt;code&gt;SKILL.md&lt;/code&gt;（只写流程）+ &lt;code&gt;references/frontmatter-schema.md&lt;/code&gt;（字段表）+ &lt;code&gt;templates/post.md&lt;/code&gt;（骨架），主文件压到 100 多行，生成质量明显好转。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;skill 内的路径必须是绝对路径&lt;/strong&gt;。只要没法保证用户一直在某个 CWD，&lt;strong&gt;相对路径都是坑&lt;/strong&gt;。我在 SKILL.md 里硬编码了 &lt;code&gt;/Users/xtuul/projects/xtuul-blog/&lt;/code&gt;，同时在每个 skill 开头加了一段"路径（硬编码绝对路径，不依赖 CWD）"说明 —— 未来换机器要改的地方集中在一处。&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  小结
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;架构&lt;/strong&gt;：&lt;code&gt;commands/blog.md&lt;/code&gt; 做&lt;strong&gt;路由薄壳&lt;/strong&gt;，四个独立 skill 做&lt;strong&gt;实现&lt;/strong&gt;，一个 &lt;code&gt;blog-shared&lt;/code&gt; 目录放&lt;strong&gt;共享资源&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;描述&lt;/strong&gt;：skill 的 &lt;code&gt;description&lt;/code&gt; 用第三人称 + 具体中英文触发短语，直接决定 router 命中率&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;篇幅&lt;/strong&gt;：SKILL.md 控制在 &amp;lt;2000 词，详细内容走 &lt;code&gt;references/&lt;/code&gt; 和 &lt;code&gt;templates/&lt;/code&gt;，按需加载&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;路径&lt;/strong&gt;：全局 skill 里一律用&lt;strong&gt;绝对路径&lt;/strong&gt;，不依赖当前工作目录&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;不碰 git&lt;/strong&gt;：所有 skill 都禁 &lt;code&gt;git&lt;/code&gt; 命令，最终 push 前人工 review —— 博客的每一次提交都值得亲眼看一遍&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下一步想把 &lt;code&gt;blog-new&lt;/code&gt; 再增强一下：支持传入&lt;strong&gt;参考文章 URL&lt;/strong&gt; 或&lt;strong&gt;本地 markdown 路径&lt;/strong&gt;，Claude 读完后再写。这样写技术选型类文章时就不用我一段段复制粘贴上下文。&lt;/p&gt;

&lt;p&gt;至此系列三篇闭环了：&lt;strong&gt;篇一搭主站，篇二接分发，篇三管写作流&lt;/strong&gt;。以后写一篇博客就是打开任意终端敲 &lt;code&gt;/blog new 主题&lt;/code&gt; → Claude 起稿 → 我修 → &lt;code&gt;git push&lt;/code&gt; → workflow 自动同步到所有海外平台。整条链路零运维、按需生效，对一个人博客来说刚刚好。&lt;/p&gt;

</description>
      <category>blog</category>
      <category>claudecode</category>
      <category>skill</category>
      <category>automation</category>
    </item>
    <item>
      <title>从零搭建个人技术博客 · 篇二：跨平台自动分发</title>
      <dc:creator>lizhaopeng-cn</dc:creator>
      <pubDate>Fri, 24 Apr 2026 11:52:15 +0000</pubDate>
      <link>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-er-kua-ping-tai-zi-dong-fen-fa-5e5h</link>
      <guid>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-er-kua-ping-tai-zi-dong-fen-fa-5e5h</guid>
      <description>&lt;p&gt;篇一是主站（&lt;code&gt;blog.xtuul.com&lt;/code&gt;）本身的搭建，篇三是写作流（&lt;code&gt;/blog new&lt;/code&gt; 这套 skill）。这一篇填篇二：&lt;strong&gt;文章写完 push 上去之后，自动同步到其他平台&lt;/strong&gt;，不用我手动复制粘贴。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;本篇讲什么&lt;/strong&gt;：一个具体的 GitHub Actions + TypeScript 小工具，push 到 main 就把 &lt;code&gt;src/data/blog/*.md&lt;/code&gt; 发到 dev.to、Hashnode、博客园，首发拿到平台 id，之后再推就是 update 而不是重发。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;本篇不讲什么&lt;/strong&gt;：具体某个平台的 API 怎么接（RTFM 的活），以及为什么最后&lt;strong&gt;国内平台一个没留&lt;/strong&gt;。最后一节会单独讲这个。&lt;/p&gt;

&lt;h2&gt;
  
  
  目录
&lt;/h2&gt;

&lt;h2&gt;
  
  
  要解决的问题
&lt;/h2&gt;

&lt;p&gt;主站在 Cloudflare Pages 上，但一篇文章想被人看到，光靠 Google 搜索和主站 RSS 远远不够。常见做法有三种：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;只在主站写&lt;/strong&gt;，其他平台空着——SEO 和流量都吃亏&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;每篇写完手动复制粘贴到其他平台&lt;/strong&gt;——第 3 篇之后我就会放弃&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;写完一次 push，代码自动同步&lt;/strong&gt;——这篇要做的事&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;关键约束：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;主站是 canonical&lt;/strong&gt;：dev.to / Hashnode 都支持声明 &lt;code&gt;canonical_url&lt;/code&gt;，明确告诉搜索引擎"原文在 blog.xtuul.com"，这样即使文章被多平台收录，SEO 权重也不会被稀释&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;幂等&lt;/strong&gt;：同一篇文章推两次不能变成两篇。要走"首发→拿到 id→以后用 id update"的路径&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;一个平台挂不阻塞别的&lt;/strong&gt;：博客园风控拦了不能让 dev.to 也发不出去&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  整体架构
&lt;/h2&gt;

&lt;p&gt;先把地图画出来：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────────────────────────────────────────────────┐
│ 本地写作：/blog new → src/data/blog/&amp;lt;slug&amp;gt;.md             │
│                                                           │
│                    git push origin main                   │
│                            │                              │
└────────────────────────────┼──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│ GitHub Actions: .github/workflows/syndicate.yml           │
│                                                           │
│   on.push.paths: [src/data/blog/**/*.md]                  │
│     │                                                     │
│     ▼                                                     │
│   pnpm dlx tsx scripts/crosspost/index.ts                 │
│     │                                                     │
│     │──► diff HEAD~1..HEAD 或 workflow_dispatch 入参     │
│     │──► 对每篇改动的 .md：                              │
│     │     ├─ load frontmatter                             │
│     │     ├─ if draft: skip                               │
│     │     ├─ for each platform (devto/hashnode/cnblogs): │
│     │     │    ├─ 已有 id → update                       │
│     │     │    └─ 无 id   → create, 拿 id+url           │
│     │     └─ 写回 frontmatter.crosspost.&amp;lt;platform&amp;gt;       │
│     └──► git add / commit / push [skip ci]               │
└───────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;代码都在 &lt;code&gt;scripts/crosspost/&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scripts/crosspost/
├── index.ts              # 入口，解析 diff，调度每个 publisher
├── lib/
│   ├── frontmatter.ts    # 手写的极简 YAML parse/stringify
│   └── types.ts          # Post / Publisher / PublishResult 接口
└── platforms/
    ├── devto.ts          # POST /api/articles
    ├── hashnode.ts       # GraphQL mutation publishPost / updatePost
    └── cnblogs.ts        # MetaWeblog XML-RPC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  设计决定与权衡
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. 文件就是状态，没有外部数据库
&lt;/h3&gt;

&lt;p&gt;frontmatter 里直接加一个 &lt;code&gt;crosspost&lt;/code&gt; 字段：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;crosspost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3544836&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://dev.to/lizhaopengcn/xxx"&lt;/span&gt;
  &lt;span class="na"&gt;hashnode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;69eb1b54bada4a44e9c589e2&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://xtuul.hashnode.dev/xxx"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;第一次推文章：三个字段都不存在 → 脚本走 create → 拿到 id 和 url 写回 frontmatter → auto-commit 回 &lt;code&gt;main&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;第二次推同一篇：frontmatter 里已经有 id → 脚本走 update，不会产生重复文章。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;为什么不用 SQLite / KV&lt;/strong&gt;：这是个一人博客。加一个外部存储等于多一个要备份、要恢复、要对齐状态的东西。文章本身已经在 git 里了，id 就放旁边，&lt;strong&gt;状态和内容原子地一起 commit&lt;/strong&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  2. canonical URL 固定指向主站
&lt;/h3&gt;

&lt;p&gt;每次发文章时都显式带 &lt;code&gt;canonical_url: https://blog.xtuul.com/posts/&amp;lt;slug&amp;gt;/&lt;/code&gt;，哪怕主站还没上线那篇。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dev.to：支持 &lt;code&gt;canonical_url&lt;/code&gt; 字段，显示"Originally published at blog.xtuul.com"&lt;/li&gt;
&lt;li&gt;Hashnode：&lt;code&gt;originalArticleURL&lt;/code&gt; 字段，行为一样&lt;/li&gt;
&lt;li&gt;博客园：MetaWeblog 没 canonical 这个字段，只能在正文开头手动加一行"原文："（实际上最后没做，原因后面说）&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. 每个 publisher 实现同一个接口
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Publisher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;devto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hashnode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cnblogs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 环境变量齐了就 enable&lt;/span&gt;
  &lt;span class="nl"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PublishResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index.ts&lt;/code&gt; 不关心具体平台怎么调 API，只负责：挑出 enabled 的平台、串行跑、把 &lt;code&gt;PublishResult&lt;/code&gt; 写回 frontmatter、最后 git commit。每加一个平台只要写一个新的 &lt;code&gt;platforms/&amp;lt;name&amp;gt;.ts&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  4. per-post 串行、per-platform 独立
&lt;/h3&gt;

&lt;p&gt;一篇文章里三个平台&lt;strong&gt;串行&lt;/strong&gt;发（dev.to → Hashnode → 博客园），每个都单独 try/catch。任意一个失败不阻塞其他平台，也不阻塞下一篇文章。所有结果最后统一打印。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;writeback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✗ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  踩过的坑
&lt;/h2&gt;

&lt;p&gt;真写出来之后，前后一共掉坑里三次。记下来。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑 1：Astro 的 &lt;code&gt;z.date()&lt;/code&gt; 不接受带引号的 ISO 字符串
&lt;/h3&gt;

&lt;p&gt;写完代码本地测试通过，push 到 main 之后 Cloudflare Pages &lt;strong&gt;构建挂了&lt;/strong&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pubDatetime: Expected type "date", received "string"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;问题出在 "脚本回写 frontmatter" 这一步。Astro content collection 的 schema 里 &lt;code&gt;pubDatetime&lt;/code&gt; 是 &lt;code&gt;z.date()&lt;/code&gt;，它要求 YAML 里是&lt;strong&gt;裸的 timestamp&lt;/strong&gt;，不能是字符串：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 这样 Astro 能识别为 Date&lt;/span&gt;
&lt;span class="na"&gt;pubDatetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-24T19:50:10+08:00&lt;/span&gt;

&lt;span class="c1"&gt;# 这样 Astro 会当 string，schema 直接挂&lt;/span&gt;
&lt;span class="na"&gt;pubDatetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-04-24T19:50:10+08:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;我的 YAML stringify 函数 "遇到包含 &lt;code&gt;:&lt;/code&gt; 或其他特殊字符的字符串就加引号"，ISO 时间戳正好命中。修法是&lt;strong&gt;给日期类字段开白名单&lt;/strong&gt;，裸输出：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATETIME_KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pubDatetime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;modDatetime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DATETIME_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 不加引号&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...其他走通用 stringify&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;这种"主站和同步脚本之间有个看不见的契约"的坑，&lt;strong&gt;本地开发过程中完全不会碰到&lt;/strong&gt;，只有 push 之后 Cloudflare 那边才会炸。CI 流水线一定要&lt;strong&gt;在两边都跑一次&lt;/strong&gt;才能发现。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑 2：自己写的 YAML parser 不支持嵌套对象
&lt;/h3&gt;

&lt;p&gt;上面那段 &lt;code&gt;crosspost.devto.id&lt;/code&gt; 是嵌套两层的。一开始我图省事，手写了个 20 行的 YAML parser，只支持"key: value"和列表。结果第二次推文章时：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ devto create: Canonical url has already been taken
✓ hashnode create → xxx-1-1     ← 注意 "-1-1"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;脚本&lt;strong&gt;没读到已有的 id&lt;/strong&gt;，当成全新文章再发了一遍。dev.to 靠 canonical 查重、直接 422 拒绝；但 &lt;strong&gt;Hashnode 完全不查重&lt;/strong&gt;，默默给新文章一个 &lt;code&gt;-1&lt;/code&gt;、&lt;code&gt;-1-1&lt;/code&gt; 的递增 slug，看起来"发布成功"，&lt;strong&gt;实际上我的博客上重复文章越堆越多&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;后来把 parser 换成支持递归缩进的版本，才读得出嵌套结构。&lt;strong&gt;教训&lt;/strong&gt;：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;不要在状态机关上图省事自己造轮子。要省就&lt;strong&gt;连嵌套都别用&lt;/strong&gt;（比如把 &lt;code&gt;crosspost_devto_id&lt;/code&gt; 拍平成一级 key），要嵌套就用 &lt;code&gt;js-yaml&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"默默成功"比"显式失败"可怕得多&lt;/strong&gt;。Hashnode 这种接口设计等于在地雷区里埋了一个脸朝下的地雷。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  坑 3：博客园把 GitHub Actions 的出站 IP 风控了
&lt;/h3&gt;

&lt;p&gt;这是让我最后放弃国内平台的直接原因，值得单拎一节。&lt;/p&gt;

&lt;h2&gt;
  
  
  为什么最后没做国内平台
&lt;/h2&gt;

&lt;p&gt;本来清单上是：&lt;strong&gt;dev.to + Hashnode + 博客园&lt;/strong&gt;。三个平台的 API 都接好了、secret 也配好了、本地 dry-run 全过。推上去跑 workflow：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ devto update → https://dev.to/...
✓ hashnode update → https://xtuul.hashnode.dev/...
✗ cnblogs create: HTTP 500 (empty body)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;第一反应：鉴权有问题？XML-RPC 包错了？字段不全？&lt;/p&gt;

&lt;p&gt;挨个验证：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;在本地跑 curl&lt;/strong&gt;，同样的 token、同样的 username、同样的 endpoint，打一条最小的 &lt;code&gt;metaWeblog.newPost&lt;/code&gt; 过去。&lt;strong&gt;200，返回 postid，完美&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;在本地跑脚本&lt;/strong&gt;，用 &lt;code&gt;loadPost&lt;/code&gt; 读真实文章、构造和 Actions 里字节级完全相同的 XML，再 curl 发出去。&lt;strong&gt;又是 200，postid 正常返回&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;回到 Actions，一模一样的代码、一模一样的 secret。&lt;strong&gt;仍然 HTTP 500，body 空&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;排查逻辑很简单——如果鉴权或内容有问题，博客园会返回 &lt;code&gt;&amp;lt;fault&amp;gt;&lt;/code&gt; 带具体 &lt;code&gt;faultString&lt;/code&gt;；400、401、404 也都会有 body 说明原因。&lt;strong&gt;500 + 空 body&lt;/strong&gt; 是非常特殊的组合：请求根本没到业务层，而是在前置的网关/WAF 上就被掐掉了。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rpc.cnblogs.com&lt;/code&gt; 的网关对 &lt;strong&gt;Azure westus 的 IP 段&lt;/strong&gt;做了风控。GitHub Actions 的 runner 正好在那里。对 "Azure IP 对国内内容平台的出站连接" 这件事有过了解的人应该都见过类似剧情——十年来这类平台对海外云的 IP 越来越敏感。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;没有干净的解法&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;在 runner 上装代理 → 需要一台国内机器当出口，相当于给博客同步这件事凭空加一台要维护的 VPS，&lt;strong&gt;零运维的前提破了&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;换平台官方 SDK → 博客园官方 API 只有 MetaWeblog，没有别的公开协议&lt;/li&gt;
&lt;li&gt;用 Puppeteer 模拟浏览器 → CI 里跑无头 Chrome 要装 100MB 依赖、要在 CI 内完成扫码/验证码，&lt;strong&gt;越想越不值&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;调研了一下其他国内平台（掘金、CSDN、SegmentFault）：要么同样的 IP 风控，要么没有公开 API，抓包出来的接口几个月就会变一次&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我算了下账：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;搭建成本&lt;/th&gt;
&lt;th&gt;维护成本&lt;/th&gt;
&lt;th&gt;稳定性&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;加一台国内中转机&lt;/td&gt;
&lt;td&gt;半天&lt;/td&gt;
&lt;td&gt;每月续费 + 偶尔抢救&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Puppeteer 方案&lt;/td&gt;
&lt;td&gt;2~3 天&lt;/td&gt;
&lt;td&gt;平台前端一改就挂，每平台每季度至少修一次&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;浏览器插件（ArtiPub 之流）&lt;/td&gt;
&lt;td&gt;装一下&lt;/td&gt;
&lt;td&gt;低（但&lt;strong&gt;手动点击&lt;/strong&gt;，不算自动化）&lt;/td&gt;
&lt;td&gt;高但不自动&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;全部放弃国内平台&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;对一个个人博客来说，&lt;strong&gt;我不做国内平台的"机会成本"&lt;/strong&gt; 是：国内读者来主站（或 dev.to / Hashnode 的英文版）的时候少看到一点入口。可以接受。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;继续做国内平台的"直接成本"&lt;/strong&gt; 是：一台新机器 or 一套会定期 rot 的 Puppeteer 代码。&lt;strong&gt;不能接受&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;所以：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scripts/crosspost/platforms/cnblogs.ts&lt;/code&gt; 代码留着，以后哪天有国内机器了直接改 endpoint 就能用&lt;/li&gt;
&lt;li&gt;workflow 里留着 &lt;code&gt;CNBLOGS_*&lt;/code&gt; 的 secret 判断——没配就跳过，&lt;strong&gt;什么都不会打印&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;目前跑 workflow 只会看到 dev.to 和 Hashnode 成功&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  当前成品
&lt;/h2&gt;

&lt;h3&gt;
  
  
  workflow 配置
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/syndicate.yml&lt;/code&gt; 的骨架：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Syndicate&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/data/blog/**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;要同步的文件路径（空格分隔），留空=diff"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;syndicate&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;crosspost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;# ... setup pnpm / node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm dlx tsx scripts/crosspost/index.ts ${{ inputs.files }}&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEVTO_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;HASHNODE_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HASHNODE_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;HASHNODE_PUBLICATION_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HASHNODE_PUBLICATION_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;SITE_BASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://blog.xtuul.com/&lt;/span&gt;
      &lt;span class="c1"&gt;# ... auto-commit if frontmatter changed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;关键点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;paths&lt;/code&gt; 过滤让"我只改了 README 或配置"的 push 不触发&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;concurrency&lt;/code&gt; 防止连续两次 push 引起竞态（前一次还没写回 id，后一次又 create 一遍）&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch-depth: 2&lt;/code&gt; 才够算 &lt;code&gt;HEAD~1..HEAD&lt;/code&gt; 的 diff&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;permissions: contents: write&lt;/code&gt; 才允许回写 commit&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  每篇文章的状态
&lt;/h3&gt;

&lt;p&gt;前两篇文章现在的 frontmatter 尾部看起来是这样：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;crosspost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3544836&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://dev.to/lizhaopengcn/..."&lt;/span&gt;
  &lt;span class="na"&gt;hashnode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;69eb1b54bada4a44e9c589e2&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://xtuul.hashnode.dev/..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;以后任何一篇我改了正文 push 上去，脚本看到已有 id，走 update 路径，&lt;strong&gt;平台上直接原地更新&lt;/strong&gt;，url 不变、评论不丢。&lt;/p&gt;

&lt;h2&gt;
  
  
  小结
&lt;/h2&gt;

&lt;p&gt;这篇的主题本来应该是"跨平台分发"，实际结果是"跨两个海外平台分发 + 一篇国内平台劝退录"。&lt;/p&gt;

&lt;p&gt;一些可以带走的结论：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;canonical URL 必须显式&lt;/strong&gt;。主站永远是 source of truth，分发只是副本。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;幂等靠 id，不靠 title/slug 做匹配&lt;/strong&gt;。id 写回 frontmatter，和文章原子地一起 commit，不要引入外部状态。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"默默成功"的平台要特别警惕&lt;/strong&gt;。dev.to 的 422 比 Hashnode 的 &lt;code&gt;-1-1&lt;/code&gt; slug 友好得多。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;零运维的门槛是"不需要我再额外养任何一台机器"&lt;/strong&gt;。一旦为了一个副功能要上国内 VPS 或浏览器自动化，整个系统的维护成本结构就变了。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;篇四还没想好，大概会写主站接入 Umami、或者 AstroPaper 主题的几处魔改。&lt;/p&gt;

</description>
      <category>blog</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>从零搭建个人技术博客 · 篇一：Astro + Cloudflare Pages + AstroPaper</title>
      <dc:creator>lizhaopeng-cn</dc:creator>
      <pubDate>Fri, 24 Apr 2026 07:27:15 +0000</pubDate>
      <link>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-astro-cloudflare-pages-astropaper-4ib</link>
      <guid>https://dev.to/lizhaopengcn/cong-ling-da-jian-ge-ren-ji-zhu-bo-ke-pian-astro-cloudflare-pages-astropaper-4ib</guid>
      <description>&lt;p&gt;这是"从零搭建个人技术博客"系列的第一篇，记录我把 &lt;code&gt;blog.xtuul.com&lt;/code&gt; 从无到有跑起来的完整过程 —— 技术选型、架构、操作步骤、踩到的坑。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;本篇目标&lt;/strong&gt;：用最小的维护成本搭一个&lt;strong&gt;快、干净、免费、有个人品牌感&lt;/strong&gt;的主站。&lt;br&gt;
&lt;strong&gt;不在本篇&lt;/strong&gt;：跨平台自动分发（dev.to / Hashnode / 掘金 / 公众号）—— 放到后续篇章。&lt;/p&gt;
&lt;h2&gt;
  
  
  目录
&lt;/h2&gt;
&lt;h2&gt;
  
  
  整体架构与选型
&lt;/h2&gt;
&lt;h3&gt;
  
  
  最终架构
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         Claude Code（在本地生成 Markdown）
                       │
                       ▼
          src/data/blog/xxx.md（Git 跟踪）
                       │  push
                       ▼
           GitHub: lizhaopeng-cn/xtuul-blog
                       │  webhook
                       ▼
              Cloudflare Pages（自动构建）
                       │
                       ▼
          blog.xtuul.com（HTTPS + 全球 CDN）
             + Cloudflare Web Analytics（站长后台）
             + 不蒜子（页面可见计数）
             + Giscus（GitHub Discussions 评论）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;一个仓库、一次 push、全自动部署。没有服务器、没有数据库、没有后台。&lt;/p&gt;
&lt;h3&gt;
  
  
  为什么选这套组合
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;选型&lt;/th&gt;
&lt;th&gt;为什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;框架&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Astro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;静态输出、零 JS 默认、支持 MD/MDX、生态比 Hugo 现代、比 Next.js 轻&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主题&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AstroPaper&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lighthouse 100、自带明暗主题/搜索/标签/RSS/sitemap、TypeScript + Tailwind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;托管&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;免费、自带 CDN 和 HTTPS、与 Cloudflare DNS 天然融合&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;域名本来就在这里，一条 CNAME 都省了&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;包管理&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pnpm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;磁盘效率和 monorepo 友好；AstroPaper 官方模板就是 pnpm&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  排除项（为什么不选它们）
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress&lt;/strong&gt; —— 要维护数据库、安全补丁、主机账单。静态博客是一次性工作，没必要。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hexo / VuePress&lt;/strong&gt; —— 能用但生态趋冷，主题更新慢。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; —— 大炮打蚊子。博客不需要 SSR / Server Components。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; —— 也不错，但国内访问不如 Cloudflare 稳，且 DNS 不在一处。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Pages&lt;/strong&gt; —— 无 CDN、自定义域名要额外配置、国内访问慢。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  前置条件
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;一个顶级域名（我用的 &lt;code&gt;xtuul.com&lt;/code&gt;），&lt;strong&gt;已托管在 Cloudflare&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;本地装好：&lt;code&gt;node&lt;/code&gt; (20+)、&lt;code&gt;pnpm&lt;/code&gt;、&lt;code&gt;git&lt;/code&gt;、&lt;code&gt;gh&lt;/code&gt;（GitHub CLI）&lt;/li&gt;
&lt;li&gt;一个 GitHub 账号，&lt;code&gt;gh auth login&lt;/code&gt; 登录过&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;验证：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-v&lt;/span&gt;          &lt;span class="c"&gt;# v20 以上&lt;/span&gt;
pnpm &lt;span class="nt"&gt;-v&lt;/span&gt;
git &lt;span class="nt"&gt;--version&lt;/span&gt;
gh auth status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  第一步：用 pnpm 脚手架生成 AstroPaper
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;全程只用 pnpm&lt;/strong&gt;。AstroPaper 官方模板、本地开发、Cloudflare Pages 构建命令都统一到 pnpm，保证只有一份 &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;，后面构建稳定性会省很多事。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects
pnpm create astro@latest xtuul-blog &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt; satnaing/astro-paper &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-houston&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-git&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;参数含义：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--template satnaing/astro-paper&lt;/code&gt; 直接以 AstroPaper 作为起点&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--install&lt;/code&gt; 自动 &lt;code&gt;pnpm install&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-git&lt;/code&gt; 先不初始化 git，后面手动推（避免 Astro 默认 commit 污染历史）&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--skip-houston&lt;/code&gt; / &lt;code&gt;--yes&lt;/code&gt; 跳过交互式问答&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;完成后：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;xtuul-blog
pnpm dev
&lt;span class="c"&gt;# 浏览器访问 http://localhost:4321/ 应该能看到 AstroPaper 默认主题&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  第二步：品牌化与本地化
&lt;/h2&gt;

&lt;p&gt;AstroPaper 的&lt;strong&gt;全站配置&lt;/strong&gt;集中在 &lt;code&gt;src/config.ts&lt;/code&gt;。把站点信息改成自己的：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://blog.xtuul.com/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Xtuul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://github.com/lizhaopeng-cn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;记录 AI、编程、自动化和个人项目。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Xtuul Blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astropaper-og.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lightAndDarkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;postPerIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;postPerPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scheduledPostMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;showArchives&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;showBackButton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;editPost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;dynamicOgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ltr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zh-CN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Shanghai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;社交链接&lt;/strong&gt;在 &lt;code&gt;src/constants.ts&lt;/code&gt;，默认带了 X / LinkedIn / WhatsApp 等，按需裁剪。我只留 GitHub + 邮箱：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SOCIALS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Social&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GitHub&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://github.com/lizhaopeng-cn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;linkTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on GitHub`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IconGitHub&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mailto:xtuul@xtuul.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;linkTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Send an email to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SITE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IconMail&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  导航栏中文化
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/components/Header.astro&lt;/code&gt; 里把 &lt;code&gt;Posts / Tags / About / Archives / Search&lt;/code&gt; 改成 &lt;code&gt;文章 / 标签 / 关于 / 归档 / 搜索&lt;/code&gt;，&lt;code&gt;Skip to content&lt;/code&gt; 改成 &lt;code&gt;跳转到正文&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  默认深色模式
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/layouts/Layout.astro&lt;/code&gt; 里找到这段内联脚本，把空字符串改成 &lt;code&gt;"dark"&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialColorScheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 之前是 ""，现在强制首访深色&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lightAndDarkMode: true&lt;/code&gt; 保留，用户点顶部月亮/太阳按钮仍可切换。&lt;/p&gt;

&lt;h3&gt;
  
  
  验证本地构建
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;看到 &lt;code&gt;Finished in Xs&lt;/code&gt; 并且没有红色错误，就说明配置改动都是合法的。&lt;/p&gt;

&lt;h2&gt;
  
  
  第三步：推送到 GitHub
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/xtuul-blog
git init
git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"initial commit: Xtuul Blog on AstroPaper"&lt;/span&gt;

&lt;span class="c"&gt;# 用 gh 一条命令建 public 仓库 + 推送&lt;/span&gt;
gh repo create xtuul-blog &lt;span class="nt"&gt;--public&lt;/span&gt; &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--remote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;origin &lt;span class="nt"&gt;--push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;public 还是 private？&lt;/strong&gt; 博客内容公开，仓库也建议 public。public 仓库的 GitHub Actions 额度是&lt;strong&gt;无限&lt;/strong&gt;的，private 每月只有 2000 分钟，这在后续加自动分发时会成为实际差别。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  第四步：Cloudflare Pages 首次部署
&lt;/h2&gt;

&lt;p&gt;Dashboard → &lt;strong&gt;Workers &amp;amp; Pages&lt;/strong&gt; → Create → Pages → Connect to Git。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;选仓库 &lt;code&gt;lizhaopeng-cn/xtuul-blog&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Framework preset: &lt;code&gt;Astro&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Build command: &lt;code&gt;pnpm install --frozen-lockfile &amp;amp;&amp;amp; pnpm build&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Build output directory: &lt;code&gt;dist&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Environment variables（Production）: &lt;code&gt;NODE_VERSION = 22&lt;/code&gt;（纯文本）&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;为什么显式写 pnpm 命令&lt;/strong&gt;：Cloudflare Pages 默认根据 lockfile 探测包管理器，大多数情况会对，但偶尔会 fallback 到 npm。显式写最稳。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;为什么固定 Node 版本&lt;/strong&gt;：构建容器默认 Node 可能是 18，而新版 Astro 要求 20+。指定 22 是目前的 LTS，最保险。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;点 &lt;strong&gt;Save and Deploy&lt;/strong&gt;，1–3 分钟后应该看到绿色 Success。&lt;/p&gt;

&lt;h2&gt;
  
  
  第五步：绑定自定义域名
&lt;/h2&gt;

&lt;p&gt;Pages 项目 → &lt;strong&gt;Custom domains&lt;/strong&gt; → Set up a custom domain → 填 &lt;code&gt;blog.xtuul.com&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;因为域名本来就在 Cloudflare，它会&lt;strong&gt;自动在 DNS 区加一条 CNAME&lt;/strong&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog    CNAME    xtuul-blog.pages.dev    (Proxied)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;等几十秒证书签发完成，&lt;code&gt;https://blog.xtuul.com/&lt;/code&gt; 就活了。&lt;/p&gt;

&lt;h2&gt;
  
  
  以后发布新文章的流程
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;文章存放位置&lt;/strong&gt;：&lt;code&gt;src/data/blog/&lt;/code&gt;（文件名即 URL slug）。&lt;/p&gt;

&lt;h3&gt;
  
  
  标准 frontmatter 模板
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Xtuul&lt;/span&gt;
&lt;span class="na"&gt;pubDatetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-22T10:30:00+08:00&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;文章标题"&lt;/span&gt;
&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;article-slug&lt;/span&gt;
&lt;span class="na"&gt;featured&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# true 显示在首页"精选文章"&lt;/span&gt;
&lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;      &lt;span class="c1"&gt;# true 不会发布&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag1&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag2&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;一句话摘要（会出现在文章卡片和 SEO 描述里）&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="s"&gt;正文...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  三步发布
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. 写文章（放到 src/data/blog/xxx.md）&lt;/span&gt;
&lt;span class="c"&gt;# 2. 本地预览（可选）&lt;/span&gt;
pnpm dev

&lt;span class="c"&gt;# 3. 提交推送&lt;/span&gt;
git add src/data/blog/xxx.md
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"post: 标题"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare Pages 监听到 push，自动构建 + 部署，&lt;strong&gt;从 push 到线上可见通常 2 分钟内&lt;/strong&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  写作期间的小技巧
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;draft: true&lt;/code&gt; → 线上看不到但本地能预览，适合攒稿&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;featured: true&lt;/code&gt; → 首页置顶&lt;/li&gt;
&lt;li&gt;本地改 &lt;code&gt;src/config.ts&lt;/code&gt; / 组件时 &lt;code&gt;pnpm dev&lt;/code&gt; 会热更新&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  用 Claude Code 直接产出 Markdown
&lt;/h2&gt;

&lt;p&gt;上面的发布流程能跑，但&lt;strong&gt;从 0 到一篇成稿还是我自己打字&lt;/strong&gt;。博客更顺手的工作流是：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;一句话需求 → Claude Code 写到 src/data/blog/xxx.md → 本地预览 → git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;我写博客的时候直接在仓库根目录开 Claude Code：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/xtuul-blog
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;然后一句话命题作文：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"写一篇新文章放到 &lt;code&gt;src/data/blog/setup-astro-cloudflare-astropaper.md&lt;/code&gt;，主题是从零搭建个人技术博客，选型是 Astro + Cloudflare Pages + AstroPaper，frontmatter 用 &lt;code&gt;author: Xtuul&lt;/code&gt;、&lt;code&gt;pubDatetime&lt;/code&gt; 用当前时间、&lt;code&gt;featured: true&lt;/code&gt;、tags 给 astro/cloudflare/astropaper/blog/devops。"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code 直接落盘成 &lt;code&gt;.md&lt;/code&gt; 文件，本地 &lt;code&gt;pnpm dev&lt;/code&gt; 实时预览，不满意继续对话改（"第二部分太啰嗦，合并到第一部分"），满意了就 commit + push。&lt;/p&gt;

&lt;h3&gt;
  
  
  为什么要这样写，而不是甩一个"帮我写博客"
&lt;/h3&gt;

&lt;p&gt;几点经验：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;文件路径写死&lt;/strong&gt;。明确告诉 Claude 写到哪个文件、slug 是什么，它就会直接 &lt;code&gt;Write&lt;/code&gt; 工具落盘，不会先在对话里生成一坨让你复制。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;frontmatter 所有字段都写死&lt;/strong&gt;。作者、tags、是否 featured、是否 draft。Claude 对 AstroPaper 的 frontmatter schema 没先验知识，不写死它会漏字段或乱填。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;技术选型和事实由你给&lt;/strong&gt;，Claude 负责组织行文和代码块。让 Claude 自由发挥容易出"正确但不是你的"内容——你的博客是写给你自己的受众，口吻、技术偏好、踩坑细节都得自己提供。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;分多轮迭代&lt;/strong&gt;。先让它写大纲，再写某一节正文，再让它把某个代码块换成更简洁的版本。一次性让它写完 2000 字通常质量不如分块。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  工作流的好处
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;不离开编辑器&lt;/strong&gt;：写、改、预览、提交在一个 shell 里完成，不用在对话窗口和编辑器之间来回切。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;可追溯&lt;/strong&gt;：所有改动都是 &lt;code&gt;git diff&lt;/code&gt;，不好回滚就 &lt;code&gt;git checkout --&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;自动化友好&lt;/strong&gt;：以后可以把"发布到 dev.to / Hashnode / 掘金"也交给 Claude Code 跑 MCP 调用外部 API，这是篇二要做的事。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  第六步：接入访问统计与评论
&lt;/h2&gt;

&lt;p&gt;主站能跑之后，马上会想要两件事：&lt;strong&gt;这篇文章有多少人看过&lt;/strong&gt;、&lt;strong&gt;读者能不能留言&lt;/strong&gt;。我接了三个互不重叠的服务：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;需求&lt;/th&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;数据给谁看&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;站长看流量后台（趋势、地理分布、Referer）&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Web Analytics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;我，在 CF Dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;页面上可见的计数（页脚总 PV/UV、文章阅读量）&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;不蒜子&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;所有访客&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;评论系统&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Giscus&lt;/strong&gt;（基于 GitHub Discussions）&lt;/td&gt;
&lt;td&gt;所有访客，数据在我仓库&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;全部免费、零后端。&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 Cloudflare Web Analytics
&lt;/h3&gt;

&lt;p&gt;Dashboard → Analytics &amp;amp; Logs → Web Analytics → Add a site → 选 Manual（手动 JS 片段），会拿到一段：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://static.cloudflareinsights.com/beacon.min.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-cf-beacon=&lt;/span&gt;&lt;span class="s"&gt;'{"token": "YOUR_TOKEN"}'&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;把它放到 &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt; 的 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 里（&lt;code&gt;&amp;lt;ClientRouter /&amp;gt;&lt;/code&gt; 之后），&lt;strong&gt;全站自动上报&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;注意：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;后台数据只有自己看&lt;/strong&gt;。访客看不到任何数字，不蒜子才是"前台展示"。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;每个 site 一个独立 token&lt;/strong&gt;。我 &lt;code&gt;xtuul.com&lt;/code&gt; 是橙云走自动统计，&lt;code&gt;blog.xtuul.com&lt;/code&gt; 是 Pages 自带的灰云（Pages 的自定义域都是灰云，这是正常的，不用改橙），走不到自动统计，所以单独为它生成了 token。&lt;/li&gt;
&lt;li&gt;如果你还想统计主站就再加一个 site、给主站页面嵌入对应 token 的片段即可，数据不会互相混。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.2 不蒜子：页面可见计数
&lt;/h3&gt;

&lt;p&gt;不蒜子是国内开发者博客最常用的极简计数器，一个 script + 三个约定的 span id：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- 站点总 PV --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_container_site_pv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  总访问量 &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_value_site_pv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt; 次
&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 站点 UV --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_container_site_uv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  访客数 &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_value_site_uv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt; 人
&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 当前文章 PV --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_container_page_pv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  阅读量 &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"busuanzi_value_page_pv"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt; 次
&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;脚本只有三行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;defer&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;三个关键细节&lt;/strong&gt;：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;容器默认 &lt;code&gt;display:none&lt;/code&gt;&lt;/strong&gt;。不蒜子脚本拿到数据后会把 &lt;code&gt;busuanzi_container_*&lt;/code&gt; 显示出来。如果不默认隐藏，脚本返回前会看到 &lt;code&gt;访客数： 人&lt;/code&gt; 这种裸文案，很丑。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astro ClientRouter 切页脚本不会重跑&lt;/strong&gt;。AstroPaper 用 &lt;code&gt;&amp;lt;ClientRouter /&amp;gt;&lt;/code&gt; 做无刷新页面切换，&lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; 只在首次加载时执行一次，&lt;strong&gt;切换到下一篇文章的 page_pv 不会刷新&lt;/strong&gt;。解决：在 &lt;code&gt;astro:page-load&lt;/code&gt; 事件里重新注入脚本。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;只在 &lt;code&gt;astro:page-load&lt;/code&gt; 里注入，不要在 IIFE 里再调一次&lt;/strong&gt;。否则页面首次加载时首次 IIFE + &lt;code&gt;astro:page-load&lt;/code&gt; 事件会各发一次 JSONP，&lt;strong&gt;PV +2&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;最后得到的是这样一段：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;script is:inline&amp;gt;
  document.addEventListener("astro:page-load", () =&amp;gt; {
    // 清掉上次注入的脚本标签
    document.querySelectorAll("script[data-busuanzi]")
      .forEach(node =&amp;gt; node.remove());
    const s = document.createElement("script");
    s.src = "https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js";
    s.async = true;
    s.defer = true;
    s.setAttribute("data-busuanzi", "1");
    document.body.appendChild(s);
  });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;不蒜子的局限（被很多教程模糊带过）&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;只有 &lt;code&gt;page_pv&lt;/code&gt;，没有 &lt;code&gt;page_uv&lt;/code&gt;&lt;/strong&gt;。想知道某篇文章有多少独立访客——不蒜子做不到，只能自己上 Cloudflare Workers + KV。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;本地 &lt;code&gt;localhost:4321&lt;/code&gt; 的数字是全球所有本地开发者共享的&lt;/strong&gt;，动辄几万几十万，&lt;strong&gt;这是正常现象&lt;/strong&gt;，上线到真实域名后从 0 开始计数。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.3 Giscus：基于 GitHub Discussions 的评论
&lt;/h3&gt;

&lt;p&gt;Giscus 把评论直接挂在你仓库的 GitHub Discussions 上。好处：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;评论数据在你自己仓库，跟着仓库迁移走&lt;/li&gt;
&lt;li&gt;访客用 GitHub 账号登录，天然过滤机器人&lt;/li&gt;
&lt;li&gt;支持 reaction、Markdown、代码块&lt;/li&gt;
&lt;li&gt;不要你跑任何后端&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;准备工作&lt;/strong&gt;：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;仓库开启 Discussions：Settings → Features → Discussions ✅&lt;/li&gt;
&lt;li&gt;安装 &lt;a href="https://github.com/apps/giscus" rel="noopener noreferrer"&gt;giscus GitHub App&lt;/a&gt; 到仓库&lt;/li&gt;
&lt;li&gt;去 &lt;a href="https://giscus.app" rel="noopener noreferrer"&gt;giscus.app&lt;/a&gt; 配置向导，选 repo + discussion category（建议用 &lt;code&gt;Announcements&lt;/code&gt; 这种受限分类，避免无关讨论），拿到一段 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;在 Astro 里包装成组件&lt;/strong&gt;，关键要点有两个：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;主题要跟站内明暗同步&lt;/strong&gt;。Giscus 默认 &lt;code&gt;preferred_color_scheme&lt;/code&gt; 跟系统，但 AstroPaper 允许用户手动切换 &lt;code&gt;&amp;lt;html data-theme="..."&amp;gt;&lt;/code&gt;，两者会对不上。要用 &lt;code&gt;MutationObserver&lt;/code&gt; 监听 &lt;code&gt;data-theme&lt;/code&gt; 变化，通过 &lt;code&gt;postMessage&lt;/code&gt; 告诉 giscus iframe 换皮。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClientRouter 切页要重新挂载&lt;/strong&gt;。否则上一篇文章的评论残留在下一篇。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;简化后的组件（完整版在仓库 &lt;code&gt;src/components/Giscus.astro&lt;/code&gt;）：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;section id="giscus-container"&amp;gt;
  &amp;lt;h2&amp;gt;评论&amp;lt;/h2&amp;gt;
  &amp;lt;div id="giscus"&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;

&amp;lt;script is:inline data-astro-rerun&amp;gt;
  (function () {
    const container = document.getElementById("giscus");
    if (!container) return;
    container.innerHTML = ""; // 清理上一页残留

    function currentGiscusTheme() {
      return document.documentElement.getAttribute("data-theme") === "dark"
        ? "noborder_dark"
        : "noborder_light";
    }

    const s = document.createElement("script");
    s.src = "https://giscus.app/client.js";
    s.setAttribute("data-repo", "lizhaopeng-cn/xtuul-blog");
    s.setAttribute("data-repo-id", "...");
    s.setAttribute("data-category", "Announcements");
    s.setAttribute("data-category-id", "...");
    s.setAttribute("data-mapping", "pathname");
    s.setAttribute("data-theme", currentGiscusTheme());
    s.setAttribute("data-lang", "zh-CN");
    s.setAttribute("data-loading", "lazy");
    s.setAttribute("crossorigin", "anonymous");
    s.async = true;
    container.appendChild(s);

    // 站内主题切换时通知 iframe 同步
    new MutationObserver(() =&amp;gt; {
      const frame = document.querySelector("iframe.giscus-frame");
      frame?.contentWindow?.postMessage(
        { giscus: { setConfig: { theme: currentGiscusTheme() } } },
        "https://giscus.app"
      );
    }).observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["data-theme"],
    });
  })();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;然后在 &lt;code&gt;src/layouts/PostDetails.astro&lt;/code&gt; 里 &lt;code&gt;&amp;lt;Giscus /&amp;gt;&lt;/code&gt; 一行就挂上了。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩过的坑
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cloudflare Pages 不改构建命令&lt;/strong&gt;：默认的 &lt;code&gt;npm run build&lt;/code&gt; 在项目只有 &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; 时会因为缺 &lt;code&gt;package-lock.json&lt;/code&gt; 失败。&lt;strong&gt;必须显式改成&lt;/strong&gt; &lt;code&gt;pnpm install --frozen-lockfile &amp;amp;&amp;amp; pnpm build&lt;/code&gt;。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Node 版本&lt;/strong&gt;：不指定 &lt;code&gt;NODE_VERSION&lt;/code&gt;，构建容器可能给你 18，新版 Astro 直接报错。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;默认主题探测&lt;/strong&gt;：AstroPaper 默认是跟随系统 &lt;code&gt;prefers-color-scheme&lt;/code&gt;。想强制首次深色，改 &lt;code&gt;Layout.astro&lt;/code&gt; 里的 &lt;code&gt;initialColorScheme = "dark"&lt;/code&gt;，别去动 &lt;code&gt;lightAndDarkMode&lt;/code&gt;（那个是&lt;strong&gt;是否允许&lt;/strong&gt;切换的开关）。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;不蒜子在 &lt;code&gt;localhost:4321&lt;/code&gt; 上数字离谱&lt;/strong&gt;：不蒜子按域名隔离，&lt;code&gt;localhost&lt;/code&gt; 被所有开发者共享，本地看到几万几十万是正常的。上线到 &lt;code&gt;blog.xtuul.com&lt;/code&gt; 才从 0 开始独立计数。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Giscus 不跟随 AstroPaper 主题切换&lt;/strong&gt;：Giscus 默认用 &lt;code&gt;preferred_color_scheme&lt;/code&gt; 跟系统，但 AstroPaper 允许用户点按钮切 &lt;code&gt;data-theme&lt;/code&gt;，两者会脱节。必须用 &lt;code&gt;MutationObserver&lt;/code&gt; 监听 &lt;code&gt;data-theme&lt;/code&gt;，通过 &lt;code&gt;postMessage&lt;/code&gt; 通知 iframe 换皮。&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  下一篇预告
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;篇二：把一篇 Markdown 自动分发到 dev.to / Hashnode，canonical 指向主站。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;核心思路：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;统一 frontmatter 里加 &lt;code&gt;syndicate: { devto: true, hashnode: true }&lt;/code&gt; 开关&lt;/li&gt;
&lt;li&gt;GitHub Actions 监听 &lt;code&gt;src/data/blog/&lt;/code&gt; 变化，调用各平台 API 发布/更新&lt;/li&gt;
&lt;li&gt;首发后把平台返回的文章 ID 回写到 frontmatter，实现幂等更新&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;等系列跑完，最终目标是：&lt;strong&gt;在 Claude Code 里一句话生成 md → &lt;code&gt;git push&lt;/code&gt; → 全网同步更新&lt;/strong&gt;。&lt;/p&gt;

</description>
      <category>astro</category>
      <category>cloudflare</category>
      <category>astropaper</category>
      <category>blog</category>
    </item>
  </channel>
</rss>
