Featured image of post 从零开始:思源笔记 + Hugo + GitHub Actions 打造全自动双分支博客发布流建站教程

从零开始:思源笔记 + Hugo + GitHub Actions 打造全自动双分支博客发布流建站教程

未命名

0. 前言:为什么要这么折腾?

很多朋友使用思源笔记写文章,想直接发布到 Hugo 博客。但直接使用思源的发布插件往往会遇到图片路径不对、Front Matter(文章元数据)格式不兼容等问题。

为了解决这个问题,我设计了一套“双分支”工作流:

  1. Raw 分支:作为“生肉”仓库,接收本地和思源笔记推送的原始文件。
  2. GitHub Actions:充当“中央厨房”,自动清洗数据、修复格式。
  3. Main 分支:作为“熟肉”仓库,存放处理好的、干净的 Hugo 源码。
  4. Cloudflare Pages:直接拉取 Main 分支进行构建发布。

这篇教程的核心逻辑在于理清 数据流向:

本地/思源 -> (Push) -> GitHub Raw 分支 -> (Actions 清洗) -> GitHub Main 分支 -> (Cloudflare 监听) -> 自动构建部署。

如果你只是单纯想建一个网站,也可以参考这篇文章的内容。这个教程更为简单的就实现了一个网站的搭建,如果后期你想要与思源笔记配合,可以再看看本篇文章。

最极客的零成本建站教程
如果你想搭建一个自己的网站,但是没有域名也没有VPS,你可以试试这个方案,这个方案不需要这两个在大多数人看来的必要条件, …

1. 环境准备

  • 本地环境

    • Git(版本控制工具)
    • Hugo Extended 版本(用于本地预览,推荐 Extended 版以支持更多功能)
    • VS Code(推荐的代码编辑器)
  • 账号准备

    • GitHub 账号
    • Cloudflare 账号

2. 第一步:本地部署 Hugo 与目录结构解析

2.1 初始化 Hugo 站点

在你的电脑上打开终端(CMD/PowerShell/Terminal):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 1. 验证 Hugo 是否安装成功
hugo version

# 2. 创建新站点(假设名字叫 my-blog)
hugo new site my-blog

# 3. 进入目录
cd my-blog

# 4. 初始化 Git 仓库
git init

# 5. 下载一个主题(以 stack 为例,必须要有一个主题)
git submodule add https://github.com/CaiJimmy/hugo-theme-stack themes/hugo-theme-stack

2.2 详解 Hugo 目录结构

一个标准的 Hugo 目录是这样的:

  • content/ :核心目录。你的文章(Markdown 文件)都放在这里。

    • content/posts/:通常建议建个子目录存放博文。
  • static/ :存放图片、CSS 等静态资源。思源发布的图片通常需要映射到这里。

  • themes/ :存放主题文件。

  • hugo.toml :整个网站的配置文件。

如果你不知道目录的详细内容,你可以参考Github上hugo主题的示例网站,里面会有详细的目录。里面每一个目录都会有文件告诉你应该放什么更改什么。


3. 第二步:GitHub 仓库与双分支设置(关键!)

3.1 创建远程仓库

去 GitHub 创建一个新的 Repository(比如叫 blog-source),设为 Public 或 Private 均可。

3.2 本地分支重命名与推送

我们需要把本地默认分支设为 raw

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1. 将当前分支重命名为 raw
git branch -m raw

# 2. 关联远程仓库
git remote add origin https://github.com/你的用户名/blog-source.git

# 3. 首次推送 raw 分支
git add .
git commit -m "Initial commit on raw branch"
git push -u origin raw

3.3 创建 Main 分支(用于存放清洗后的代码)

在 GitHub 页面上,基于 raw 分支新建一个 main 分支;或者在本地创建并推送到远程,确保远程有两个分支:rawmain

  • 注意:在 GitHub 仓库设置(Settings -> General)里,建议把 raw 设为默认分支(Default Branch),方便查看源码。

4. 第三步:配置 GitHub Actions(自动化核心)

这是连接两个分支的桥梁。我们需要在项目中创建 Workflow 文件。

4.1 创建文件

在本地项目根目录创建路径: .github/workflows/process-sync.yml

4.2 编写配置(核心逻辑)

(这里重点解释逻辑,脚本部分由你自行填充)

文件内容结构如下:

YAML

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: Auto Clean for Hugo

on:
  push:
    branches: [ raw ]  # 只要 raw 分支有更新,就触发

permissions:
  contents: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout raw code
        uses: actions/checkout@v4
        with:
          ref: raw # 拉取厨房的代码

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.9'

      - name: Run Clean Script
        # ⚠️ 注意:这里假设你的脚本文件名叫 fix_cover.py
        # 如果你的脚本在 content 文件夹里,要改成 python content/fix_cover.py
        run: |
          python fix_cover.py #这个文件后面会给出实例文件,如果你不是使用思源发布插件发布,可以忽略掉,但一定要创造这个文件,不然action可能会报错。

      - name: Push to main
        run: |
          git config --global user.name "GitHub Action"
          git config --global user.email "action@github.com"
          # 强制推送到 main 分支,这就相当于把干净的菜端上桌
          git checkout -b main_clean
          git add .
          git commit -m "Auto clean by Action"
          git push -f origin main_clean:main

5. 第四步:Cloudflare Pages 自动构建

5.1 绑定 GitHub

登录 Cloudflare Dashboard -> Pages -> Create a project -> Connect to Git -> 选择你的仓库 blog-source

5.2 构建配置(重点)

  • Production branch(生产分支)一定要选 main(因为那是清洗后的干净代码)。
  • Framework preset:选择 Hugo
  • Build commandhugo(如果主题较新,建议加上 --minify)。
  • Build output directorypublic

点击 Save and Deploy。以后只要 main 分支变动,CF 就会自动更新网站。

如果你要使用自己的域名,需要在自定义域中选择绑定自己的域名。否则就是cloudflare的默认域名(免费的)。


6. 第五步:实际使用演示

场景一:本地手写(Geek 模式)

  1. 在 VS Code 打开项目。
  2. hugo new posts/hello-world.md
  3. 编辑文章,设置 draft: false
  4. git add . -> git commit -> git push origin raw
  5. 结果:Actions 自动清洗推送到 main,CF 自动构建上线。

场景二:思源笔记发布(插件模式)

  1. 在思源中配置发布插件。
  2. Git 仓库地址:填你的 GitHub 仓库。
  3. 分支(Branch) raw
  4. 填入 GitHub Access Token (PAT)
  5. 保存路径:对应 Hugo 的 content/posts
  6. 点击发布。
  7. 结果:同上,所有繁琐的格式清洗都由 GitHub Actions 在云端默默完成了。

🟢 补充教程:如何获取 GitHub Access Token (PAT)

在配置思源笔记的发布插件时,你不能使用 GitHub 的登录密码,必须使用 Personal Access Token (PAT) 。以下是获取步骤:

1. 进入开发者设置

登录你的 GitHub 账号,点击右上角的头像,在下拉菜单中点击 Settings(设置)。

在设置页面最左侧的侧边栏,一直拉到底部,点击 Developer settings(开发者设置)。

2. 选择正确的 Token 类型(关键点!)

在左侧菜单中选择 Personal access tokens。

image

⚠️ 注意: 这里会出现两个选项,请务必选择 Tokens (classic)。

  • 原因:虽然 Fine-grained 是新版,但目前很多第三方插件对新版 Token 的权限细分支持不够完美,选择 Classic(经典版)兼容性最好,最不容易报错。

点击右侧的 Generate new token -> 选择 Generate new token (classic)

image

3. 配置 Token 权限

进入创建页面后,按照以下填写:

  • Note(备注): 随便填,例如 SiYuan-Blog,方便你以后知道这个 Token 是干嘛的。

  • Expiration(过期时间):

    • 如果你不想每隔几个月就重新配置一遍,建议选择 No expiration(永不过期)
    • GitHub 会弹窗警告你这样不安全,点击确认即可(因为只用于你自己的博客仓库)。
  • Select scopes(选择权限范围): 这是最重要的一步!

    • 请找到 repo 这一行。
    • 勾选 repo 旁边的主复选框(Full control of private repositories)。
    • 勾选这一个大项通常就足够了,它包含了对仓库的读写权限,足以支持插件推送代码。

4. 生成并复制

  1. 拉到页面最底部,点击绿色按钮 Generate token

  2. 🚨 红色警报: 页面会刷新并显示一串以 ghp_ 开头的字符串。

    • 立刻复制这一串字符!
    • 立刻保存到你的记事本里!
    • 一旦你刷新或关闭这个页面,你将永远无法再次看到这个 Token(如果丢了只能重新生成)。

🔵 在思源发布插件中填入 Token

回到思源笔记,打开你的发布插件配置界面(以 siyuan-blog-github 或类似插件为例):

  1. Token:粘贴刚才复制的那串 ghp_xxxx... 字符。
  2. 仓库 (Repo) :填写 用户名/仓库名 (例如 yourname/my-blog)。
  3. 分支 (Branch)填写 raw (千万别填 mian,记得我们是推送到生肉分支)。
  4. 路径 (Path) :通常填 content/posts (根据你的 Hugo 目录结构定)。
  5. 点击“检测”或“保存”,如果提示成功,说明连接已打通!

7. 避坑指南(FAQ)

  • Q: Cloudflare 构建失败怎么办?

    • A: 检查 Hugo 版本环境变量。在 CF Pages 设置里添加 HUGO_VERSION,设为 0.120.0 或更高。
  • Q: 为什么思源图片不显示?

    • A: 检查中间脚本是否正确将图片移动到了 static 目录,或者修正了 Markdown 里的引用路径。
  • Q: 用于洗版的PY脚本?

    • 1. 脚本功能总结

      这个脚本会自动遍历 content/post 目录下的所有 .md 文件,并执行以下操作:

      1. 清洗不可见字符:删除零宽空格(\u200b),防止解析错误。

      2. 强制表格规范化

        • 修复断行:如果表格上方没有空行,强制插入一个空行(Markdown 标准要求)。
        • 重构结构:自动删除空列,对齐表格内容。
        • 强制左对齐:将所有表格的对齐方式强制改为 :---(左对齐)。
      3. Front Matter(文章头信息)提取与整理

        • 封面图:如果 Front Matter 里没图,但正文第一张图是网络图片(http开头),则提取它作为封面图(image 字段),并从正文删除。
        • 标题:提取正文中的 H1 (# 标题)。逻辑是优先取第二个 H1,如果没有则取第一个。提取后存入 title 字段并从正文删除。
        • 标签:识别正文中的 Tags: xxx标签: xxx,提取为 Front Matter 的 tags 列表,并从正文删除。
      4. 格式美化:去除多余的连续空行。

    •   1
        2
        3
        4
        5
        6
        7
        8
        9
       10
       11
       12
       13
       14
       15
       16
       17
       18
       19
       20
       21
       22
       23
       24
       25
       26
       27
       28
       29
       30
       31
       32
       33
       34
       35
       36
       37
       38
       39
       40
       41
       42
       43
       44
       45
       46
       47
       48
       49
       50
       51
       52
       53
       54
       55
       56
       57
       58
       59
       60
       61
       62
       63
       64
       65
       66
       67
       68
       69
       70
       71
       72
       73
       74
       75
       76
       77
       78
       79
       80
       81
       82
       83
       84
       85
       86
       87
       88
       89
       90
       91
       92
       93
       94
       95
       96
       97
       98
       99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      236
      237
      238
      239
      240
      241
      242
      243
      
      import os
      import re
      
      # ================= 配置区域 =================
      # 配置:文章所在的根目录,脚本会递归遍历该目录
      POSTS_DIR = 'content/post'
      
      def fix_markdown_files():
          """
          主入口函数:遍历目录并处理文件
          """
          print(">>> 正在执行 V10.0 标准格式版 (强制 :--- 左对齐 + 插入空行)...")
      
          # os.walk 递归遍历目录
          for root, dirs, files in os.walk(POSTS_DIR):
              for file in files:
                  # 只处理 .md 文件,且忽略以 _index 开头的文件(通常是 Hugo 的列表页配置)
                  if file.endswith('.md') and not file.startswith('_index'):
                      filepath = os.path.join(root, file)
                      process_file(filepath)
      
      def process_file(filepath):
          """
          核心处理逻辑:读取单个文件 -> 清洗 -> 提取元数据 -> 重组表格 -> 保存
          """
          try:
              # 使用 utf-8 读取文件
              with open(filepath, 'r', encoding='utf-8') as f:
                  content = f.read()
          except Exception as e:
              print(f"读取失败: {filepath} - {e}")
              return
      
          is_modified = False # 标记文件是否被修改过
      
          # 1. 【清洗】去除零宽空格 (Zero-width space)
          # 这种字符通常来自于从网页复制粘贴,会导致编辑器或解析器报错
          if '\u200b' in content:
              content = content.replace('\u200b', '')
              is_modified = True
      
          # 分割 Front Matter (头部 YAML) 和正文 (Body)
          # split('---', 2) 会切分成 ['', 'Front Matter内容', '正文内容']
          parts = content.split('---', 2)
          if len(parts) < 3: return # 如果不是标准的 Hugo/Hexo 格式,跳过
          front_matter, body = parts[1], parts[2]
      
          # 2. 【表格处理】重构表格 + 修复表格前的空行
          new_body_lines = []
          body_lines = body.split('\n')
      
          in_table = False    # 状态机:是否正在读取表格
          table_buffer = []   # 缓存表格行
      
          for i, line in enumerate(body_lines):
              stripped = line.strip()
      
              # 判断当前行是否是表格行 (以 | 开头)
              if stripped.startswith('|'):
                  if not in_table:
                      # === 发现表格开始 ===
      
                      # 【关键修复】检查上一行是否为空行
                      # Markdown 标准要求表格前必须有空行,否则可能无法渲染
                      if new_body_lines and new_body_lines[-1].strip() != "":
                          new_body_lines.append("") 
      
                      in_table = True
                      table_buffer = []
      
                  # 将当前行加入表格缓存
                  table_buffer.append(line)
              else:
                  if in_table:
                      # === 表格结束 ===
                      # 调用重构函数处理刚才缓存的表格
                      reconstructed = reconstruct_table(table_buffer)
      
                      # 检查重构前后内容是否一致,不一致说明表格被优化了
                      if "".join(reconstructed) != "".join(table_buffer):
                          print(f"  [表格] 重构为标准格式: {os.path.basename(filepath)}")
                          is_modified = True
      
                      # 将重构后的表格行加入新正文
                      new_body_lines.extend(reconstructed)
      
                      # 重置状态
                      in_table = False
                      table_buffer = []
      
                      # 当前这一行不是表格,直接加入(例如表格后的文字)
                      new_body_lines.append(line)
                  else:
                      # 普通文本行,直接加入
                      new_body_lines.append(line)
      
          # 处理文件以表格结尾的情况 (EOF)
          if in_table and table_buffer:
              reconstructed = reconstruct_table(table_buffer)
              if "".join(reconstructed) != "".join(table_buffer):
                   is_modified = True
              new_body_lines.extend(reconstructed)
      
          # 如果在步骤2中修改了内容,更新 body 变量
          if is_modified:
              body = '\n'.join(new_body_lines)
      
          # 3. 【封面图提取】
          # 如果 Front Matter 里没有 image 字段
          if not re.search(r'^image:\s*http', front_matter, re.MULTILINE):
              # 查找正文中的第一张网络图片 ![] (http...)
              match = re.search(r'!\[.*?\]\((http.*?)\)', body)
              if match:
                  # 清理旧的 image 字段(如果有的话)
                  front_matter = re.sub(r'^image:.*\{\{.*\}\}.*$', '', front_matter, flags=re.MULTILINE).strip()
                  # 添加新的 image 字段
                  front_matter += f'\nimage: {match.group(1)}'
                  # 从正文中删除这张图片的代码
                  body = body.replace(match.group(0), '', 1) 
                  is_modified = True
      
          # 4. 【标题提取】
          # 查找所有的 H1 标题 (# Title)
          h1_matches = list(re.finditer(r'^\s*#\s+(.+?)\s*$', body, re.MULTILINE))
          target_title = ""
      
          # 逻辑:有些博客习惯第一个 # 是站点名,第二个 # 才是文章名
          if len(h1_matches) >= 2: 
              target_title = h1_matches[1].group(1).strip()
          elif len(h1_matches) == 1: 
              target_title = h1_matches[0].group(1).strip()
      
          if target_title:
              # 转义标题中的双引号,防止 YAML 语法错误
              safe_title = target_title.replace('"', '\\"')
      
              # 如果 Front Matter 已有 title,替换它;否则追加
              if re.search(r'^title:', front_matter, re.MULTILINE):
                  front_matter = re.sub(r'^title:.*$', f'title: "{safe_title}"', front_matter, flags=re.MULTILINE)
              else:
                  front_matter += f'\ntitle: "{safe_title}"'
      
              # 从正文中删除所有的 H1 标题,避免重复显示
              body = re.sub(r'^\s*#\s+(.+?)\s*$', '', body, flags=re.MULTILINE) 
              is_modified = True
      
          # 5. 【标签提取】
          # 如果 Front Matter 没有 tags
          if not re.search(r'^tags:', front_matter, re.MULTILINE):
              # 在正文中查找 "Tags: a, b, c" 或 "标签: a, b, c"
              match = re.search(r'^(?:Tags|标签)[::]\s*(.*)$', body, re.MULTILINE | re.IGNORECASE)
              if match:
                  # 按逗号、顿号、空格分割标签
                  items = [t.strip() for t in re.split(r'[,,、\s]+', match.group(1)) if t.strip()]
                  if items:
                      # 格式化为 YAML 列表格式
                      front_matter += "\ntags:\n" + "\n".join([f"  - {t}" for t in items])
                      # 从正文中删除这一行
                      body = body.replace(match.group(0), '')
                      is_modified = True
      
          # 6. 【保存文件】
          if is_modified:
              # 将正文中连续的3个以上换行符替换为2个(去除多余空行)
              body = re.sub(r'\n{3,}', '\n\n', body.strip())
              # 重新拼接文件内容
              new_content = f'---{front_matter}\n---\n\n{body}'
              with open(filepath, 'w', encoding='utf-8') as f:
                  f.write(new_content)
      
      def reconstruct_table(lines):
          """
          表格重构核心逻辑:
          1. 解析 Markdown 表格为矩阵
          2. 删除没有任何内容的列
          3. 重新生成表格字符串,强制使用 :--- 左对齐
          """
          if not lines: return lines
      
          # 1. 过滤有效行:排除掉原本的分隔线行(例如 |--|--|)
          # 这一步会把原本的对齐方式丢弃,准备重新生成
          content_lines = []
          for line in lines:
              # 正则含义:如果行只包含 空格、|、-、:、+,则认为是分隔线,不存入 content_lines
              if not re.match(r'^[\s\|\-\:\–\—\+]+$', line.strip()):
                  content_lines.append(line)
      
          if not content_lines: return lines
      
          # 2. 解析矩阵
          matrix = []
          for line in content_lines:
              # 按 | 切割单元格
              cells = line.strip().split('|')
              # 去除首尾因为 split 产生的空元素 (比如 "| a | b |" split后首尾会有空字符)
              if len(cells) > 0 and cells[0].strip() == '': cells.pop(0)
              if len(cells) > 0 and cells[-1].strip() == '': cells.pop(-1)
              matrix.append(cells)
      
          # 3. 识别有效列(如果某一列在所有行里都是空的,就丢弃该列)
          max_cols = 0
          if matrix:
              max_cols = max(len(row) for row in matrix)
      
          cols_to_keep = []
          for c in range(max_cols):
              is_empty = True
              for row in matrix:
                  if c < len(row) and row[c].strip():
                      is_empty = False
                      break
              if not is_empty:
                  cols_to_keep.append(c)
      
          # 4. 重组表格文本
          new_lines = []
      
          # A. 写入表头 (第一行)
          header_row = matrix[0]
          new_header = []
          for c in cols_to_keep:
              cell = header_row[c].strip() if c < len(header_row) else "" 
              new_header.append(cell)
          new_lines.append("| " + " | ".join(new_header) + " |")
      
          # B. 生成分隔线 (强制左对齐 :---)
          # 如果你想改对齐方式,改这里,例如 ":-:" 是居中
          separators = [":---"] * len(cols_to_keep)
          new_lines.append("| " + " | ".join(separators) + " |")
      
          # C. 写入数据行 (从第二行开始)
          for row in matrix[1:]:
              new_data = []
              for c in cols_to_keep:
                  cell = row[c].strip() if c < len(row) else "" 
                  new_data.append(cell)
              new_lines.append("| " + " | ".join(new_data) + " |")
      
          return new_lines
      
      if __name__ == "__main__":
          fix_markdown_files()
          print(">>> V10.0 处理完成")
      
  • 最后是一个标准的md文件,仅供参考

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    ---
    date: 2024-01-21
    image: http://example.com/banner.jpg
    title: "Python 自动化指南"
    tags:
      - Python
      - 脚本
      - 效率
    ---
    
    这是正文内容。这里隐藏了一个零宽空格。
    

Licensed under CC BY-NC-SA 4.0
最后更新于 Jan 21, 2026 19:17 +0800
使用 Hugo 构建
主题 StackJimmy 设计