近年来,静态博客的发布方式层出不穷,但许多方法要么复杂,要么难以长期坚持。本文介绍一种简单高效的方式:通过 GitHub Issue 作为 Hugo 博客的发布端,利用 GitHub Actions 自动将 Issue 转换为 Hugo 内容并部署到 GitHub Pages。这种方法尤其适合喜欢用 GitHub 移动端 App 随时随地发布博客的用户。本教程基于老 T 的实践经验,解决了私有仓库图片下载、标签提取等问题,适合公开和私有仓库。
为什么选择 GitHub Issue?
- 便捷性:GitHub 移动端 App 在国内网络环境下比网页版更稳定,发布只需几次点击,比微信朋友圈还简单。
- 无限制:Issue 几乎没有数量或容量限制,适合博客内容存储。
- 自动化:通过 GitHub Actions,可以将 Issue 自动转换为 Hugo 内容,并触发站点构建。
- 灵活性:支持图片、标签、分类,适合短篇“说说”或长篇文章。
前提条件
- 一个 Hugo 博客项目,已配置好 GitHub Pages(公开或私有仓库)。
- GitHub 个人访问令牌(PAT),具有
repo权限(私有仓库需要)和workflow权限(触发工作流)。在 GitHub Settings → Developer settings → Personal access tokens 创建。 - 基本的 GitHub Actions 和 Hugo 知识。
- 仓库结构包含
content/posts/目录,用于存放生成的文章。
实现步骤
1. 配置 GitHub Actions 工作流
我们需要两个工作流文件:
deploy.yml:将 Issue 转换为 Hugo 内容并提交到仓库。hugo.yml:构建 Hugo 站点并部署到 GitHub Pages。
1.1 创建 deploy.yml
在 .github/workflows/deploy.yml 中添加以下内容,用于处理 Issue 转换为 Hugo 内容,并触发 Hugo 构建:
name: Sync Issues to Hugo Content
on:
issues:
types: [opened]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: "issues-to-hugo"
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
convert_issues:
name: Convert GitHub Issues
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'issues' && contains(github.event.issue.labels.*.name, '发布') && github.actor != 'IssueBot'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
clean: true
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install requests PyGithub==2.4.0 beautifulsoup4
- name: Create content/posts directory
run: |
mkdir -p content/posts
echo "Created content/posts directory"
- name: Convert issues to Hugo content
id: convert
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
find content/posts -type f 2>/dev/null | sort > /tmp/original_files.txt || touch /tmp/original_files.txt
if [ -s /tmp/original_files.txt ]; then
xargs sha1sum < /tmp/original_files.txt > /tmp/original_hashes.txt
else
touch /tmp/original_hashes.txt
fi
python .github/workflows/issue_to_hugo.py \
--repo "${{ github.repository }}" \
--output "content/posts" \
--debug
find content/posts -type f 2>/dev/null | sort > /tmp/new_files.txt || touch /tmp/new_files.txt
if [ -s /tmp/new_files.txt ]; then
xargs sha1sum < /tmp/new_files.txt > /tmp/new_hashes.txt
else
touch /tmp/new_hashes.txt
fi
if cmp -s /tmp/original_hashes.txt /tmp/new_hashes.txt; then
echo "needs_build=false" >> $GITHUB_OUTPUT
echo "🔄 没有内容变更,将跳过提交"
else
echo "needs_build=true" >> $GITHUB_OUTPUT
echo "✅ 检测到内容变更,将执行提交"
fi
- name: Commit changes (if any)
if: steps.convert.outputs.needs_build == 'true'
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
git config --global user.name "IssueBot"
git config --global user.email "actions@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository }}.git
if [ -n "${{ github.base_ref }}" ]; then
DEFAULT_BRANCH="${{ github.base_ref }}"
else
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
fi
if [ -z "$DEFAULT_BRANCH" ]; then
DEFAULT_BRANCH="master"
fi
git checkout $DEFAULT_BRANCH
git add content/posts
if ! git diff-index --quiet HEAD --; then
git commit -m "Automated: Sync GitHub Issues as content"
echo "正在推送变更到分支: $DEFAULT_BRANCH"
git push origin $DEFAULT_BRANCH
else
echo "没有需要提交的变更"
fi
- name: Trigger Hugo build
if: steps.convert.outputs.needs_build == 'true'
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/dispatches \
-d '{"event_type": "trigger-hugo-build"}'
关键点:
- 触发条件:Issue 打开时带有“发布”标签,或手动触发。
- 防止递归:
github.actor != 'IssueBot'确保 IssueBot 的提交不会再次触发工作流。 - 提交检测:仅在内容变更时提交(通过文件哈希比较)。
- 触发 Hugo 构建:通过
repository_dispatch事件 (trigger-hugo-build) 调用 Hugo 工作流。
1.2 创建 hugo.yml
在 .github/workflows/hugo.yml 中添加以下内容,用于构建和部署 Hugo 站点:
name: Deploy Hugo site to Pages
on:
push:
branches: ["master"]
workflow_dispatch:
repository_dispatch:
types: [trigger-hugo-build]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.128.0
steps:
- name: Install Hugo CLI
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&& sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Install Dart Sass
run: sudo snap install dart-sass
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
- name: Install Node.js dependencies
run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
- name: Build with Hugo
env:
HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
HUGO_ENVIRONMENT: production
run: |
hugo \
--minify \
--baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./public
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
关键点:
- 触发条件:
push到master分支、手动触发,或repository_dispatch事件 (trigger-hugo-build)。 - 权限:确保
pages: write和id-token: write用于 GitHub Pages 部署。
1.3 添加转换脚本 issue_to_hugo.py
在 .github/workflows/issue_to_hugo.py 中添加以下 Python 脚本,用于将 Issue 转换为 Hugo 内容,支持私有仓库图片下载和标签提取:
import os
import argparse
import re
import requests
import json
import logging
from urllib.parse import unquote
from datetime import datetime
from github import Github, Auth
from bs4 import BeautifulSoup
CATEGORY_MAP = ["生活", "技术", "法律", "瞬间", "社会"]
PUBLISH_LABEL = "发布"
def setup_logger(debug=False):
logger = logging.getLogger('issue-to-hugo')
level = logging.DEBUG if debug else logging.INFO
logger.setLevel(level)
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def extract_cover_image(body):
"""提取并删除封面图(正文第一张图片)"""
img_pattern = r"!\[.*?\]\((https?:\/\/[^\)]+)\)"
match = re.search(img_pattern, body)
if match:
img_url = match.group(1)
body = body.replace(match.group(0), "")
return img_url, body
return None, body
def safe_filename(filename):
"""生成安全的文件名,保留或推断文件扩展名"""
clean_url = re.sub(r"\?.*$", "", filename)
basename = os.path.basename(clean_url)
decoded_name = unquote(basename)
name, ext = os.path.splitext(decoded_name)
safe_name = re.sub(r"[^a-zA-Z0-9\-_]", "_", name)
if not ext.lower() in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
ext = ""
if len(safe_name) > 100 - len(ext):
safe_name = safe_name[:100 - len(ext)]
return safe_name + ext
def download_image(url, output_path, token=None):
"""下载图片到指定路径,基于内容类型确定扩展名,并添加 GitHub 认证头"""
try:
headers = {}
if token:
headers['Authorization'] = f'token {token}'
response = requests.get(url, stream=True, headers=headers)
if response.status_code == 200:
content_type = response.headers.get("content-type", "").lower()
ext = ".jpg"
if "image/png" in content_type:
ext = ".png"
elif "image/jpeg" in content_type or "image/jpg" in content_type:
ext = ".jpg"
elif "image/gif" in content_type:
ext = ".gif"
elif "image/webp" in content_type:
ext = ".webp"
base, current_ext = os.path.splitext(output_path)
if current_ext.lower() not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
output_path = base + ext
else:
output_path = base + current_ext
with open(output_path, 'wb') as f:
f.write(response.content)
logging.info(f"图片下载成功: {url} -> {output_path}")
return output_path
else:
logging.error(f"图片下载失败,状态码: {response.status_code}, URL: {url}")
except Exception as e:
logging.error(f"下载图片失败: {url} - {e}")
return None
def replace_image_urls(body, issue_number, output_dir, token=None):
"""替换正文中的远程图片为本地图片"""
img_pattern = r"!\[(.*?)\]\((https?:\/\/[^\)]+)\)"
def replacer(match):
alt_text = match.group(1)
img_url = match.group(2)
filename = f"{issue_number}_{safe_filename(img_url)}"
output_path = os.path.join(output_dir, filename)
final_path = download_image(img_url, output_path, token)
if final_path:
final_filename = os.path.basename(final_path)
return f""
return match.group(0)
return re.sub(img_pattern, replacer, body, flags=re.IGNORECASE)
def sanitize_markdown(content):
"""清理Markdown中的不安全内容"""
if not content:
return ""
soup = BeautifulSoup(content, "html.parser")
allowed_tags = ["p", "a", "code", "pre", "blockquote", "ul", "ol", "li", "strong", "em", "img", "h1", "h2", "h3", "h4", "h5", "h6"]
for tag in soup.find_all(True):
if tag.name not in allowed_tags:
tag.unwrap()
return str(soup)
def extract_tags_from_body(body, logger):
"""从正文最后一行提取标签,使用 $tag$ 格式,并返回清理后的正文"""
if not body:
logger.debug("Body is empty, no tags to extract")
return [], body
body = body.replace('\r\n', '\n').rstrip()
lines = body.split('\n')
if not lines:
logger.debug("No lines in body, no tags to extract")
return [], body
last_line = lines[-1].strip()
logger.debug(f"Last line for tag extraction: '{last_line}'")
tags = re.findall(r'\$(.+?)\$', last_line, re.UNICODE)
tags = [tag.strip() for tag in tags if tag.strip()]
if tags:
logger.debug(f"Extracted tags: {tags}")
body = '\n'.join(lines[:-1]).rstrip()
else:
logger.debug("No tags found in last line")
return tags, body
def convert_issue(issue, output_dir, token, logger):
"""转换单个issue为Hugo内容"""
try:
labels = [label.name for label in issue.labels]
if PUBLISH_LABEL not in labels or issue.state != "open":
logger.debug(f"跳过 issue #{issue.number} - 未标记为发布")
return False
pub_date = issue.created_at.strftime("%Y%m%d")
slug = f"{pub_date}_{issue.number}"
post_dir = os.path.join(output_dir, slug)
if os.path.exists(post_dir):
logger.info(f"跳过 issue #{issue.number} - 目录 {post_dir} 已存在")
return False
os.makedirs(post_dir, exist_ok=True)
body = issue.body or ""
logger.debug(f"Raw issue body: '{body}'")
cover_url, body = extract_cover_image(body)
tags, body = extract_tags_from_body(body, logger)
body = sanitize_markdown(body)
body = replace_image_urls(body, issue.number, post_dir, token)
logger.info(f"图片处理完成,{issue.number} 号 issue")
categories = [tag for tag in labels if tag in CATEGORY_MAP]
category = categories[0] if categories else "生活"
cover_name = None
if cover_url:
try:
cover_filename = f"cover_{safe_filename(cover_url)}"
cover_path = os.path.join(post_dir, cover_filename)
final_cover_path = download_image(cover_url, cover_path, token)
if final_cover_path:
cover_name = os.path.basename(final_cover_path)
logger.info(f"封面图下载成功:{cover_url} > {cover_name}")
else:
logger.error(f"封面图下载失败:{cover_url}")
except Exception as e:
logger.error(f"封面图下载失败:{cover_url} - {e}")
title_escaped = issue.title.replace('"', '\\"')
category_escaped = category.replace('"', '\\"')
frontmatter_lines = [
"---",
f'title: "{title_escaped}"',
f"date: \"{issue.created_at.strftime('%Y-%m-%d')}\"",
f"slug: \"{slug}\"",
f"categories: [\"{category_escaped}\"]",
f"tags: {json.dumps(tags, ensure_ascii=False)}"
]
if cover_name:
frontmatter_lines.append(f"image: \"{cover_name}\"")
frontmatter_lines.append("---\n")
frontmatter = "\n".join(frontmatter_lines)
md_file = os.path.join(post_dir, "index.md")
with open(md_file, "w", encoding="utf-8") as f:
f.write(frontmatter + body)
logger.info(f"成功转换 issue #{issue.number} 到 {md_file}")
return True
except Exception as e:
logger.exception(f"转换 issue #{issue.number} 时发生严重错误")
error_file = os.path.join(output_dir, f"ERROR_{issue.number}.tmp")
with open(error_file, "w") as f:
f.write(f"Conversion failed: {str(e)}")
return False
def main():
args = parse_arguments()
logger = setup_logger(args.debug)
token = args.token or os.getenv("GITHUB_TOKEN")
if not token:
logger.error("Missing GitHub token")
return
try:
auth = Auth.Token(token)
g = Github(auth=auth)
repo = g.get_repo(args.repo)
logger.info(f"已连接至 GitHub 仓库:{args.repo}")
except Exception as e:
logger.error(f"连接GitHub失败: {str(e)}")
return
os.makedirs(args.output, exist_ok=True)
logger.info(f"输出目录: {os.path.abspath(args.output)}")
processed_count = 0
error_count = 0
try:
issues = repo.get_issues(state="open")
total_issues = issues.totalCount
logger.info(f"开始处理 {total_issues} 个打开状态的 issue")
for issue in issues:
if issue.pull_request:
continue
try:
if convert_issue(issue, args.output, token, logger):
processed_count += 1
except Exception as e:
error_count += 1
logger.error(f"处理 issue #{issue.number} 时出错: {str(e)}")
try:
error_comment = f"⚠️ 转换为Hugo内容失败,请检查格式错误:\n\n```\n{str(e)}\n```"
if len(error_comment) > 65536:
error_comment = error_comment[:65000] + "\n```\n...(内容过长,部分已省略)"
issue.create_comment(error_comment)
try:
error_label = repo.get_label("conversion-error")
except:
error_label = repo.create_label("conversion-error", "ff0000")
issue.add_to_labels(error_label)
except Exception as inner_e:
logger.error(f"创建评论或添加标签时出错: {inner_e}")
except Exception as e:
logger.exception(f"获取issues时出错: {e}")
summary = f"处理完成!成功转换 {processed_count} 个issues,{error_count} 个错误"
if processed_count == 0:
logger.info(summary + " - 没有需要处理的内容变更")
else:
logger.info(summary)
if args.debug:
logger.debug("内容目录状态:")
logger.debug(os.listdir(args.output))
def parse_arguments():
parser = argparse.ArgumentParser(description='Convert GitHub issues to Hugo content')
parser.add_argument('--token', type=str, default=None, help='GitHub access token')
parser.add_argument('--repo', type=str, required=True, help='GitHub repository in format owner/repo')
parser.add_argument('--output', type=str, default='content/posts', help='Output directory')
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
return parser.parse_args()
if __name__ == "__main__":
main()
关键点:
- 图片下载:
download_image使用 PAT 认证头(Authorization: token {token}),支持私有仓库附件下载。 - 标签提取:从 Issue 正文最后一行提取
$tag$格式标签(支持空格,如$搞啥 呢$)。 - 重复检查:跳过已存在的文章目录(
YYYYMMDD_X)。 - Markdown 生成:生成标准 Hugo frontmatter 和内容,图片转为本地路径。
2. 设置 PAT
创建 PAT:
- 访问 GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)。
- 创建新 PAT,勾选
repo(包括私有仓库访问)和workflow(触发工作流)。 - 复制生成的 token。
添加到仓库:
- 转到仓库(e.g.,
h2dcc/lawtee.github.io)→ Settings → Secrets and variables → Actions → Secrets。 - 添加新 secret,命名为
PAT_TOKEN,粘贴 PAT 值。
- 转到仓库(e.g.,
3. 编写 Issue 发布博客
在 GitHub Issue 中按以下格式编写博客文章:
- 添加“发布”标签:
- 创建新 Issue,添加标签
发布和分类标签(如技术、生活)。
- 创建新 Issue,添加标签
- 正文格式:
- 标题:Issue 标题作为文章标题。
- 封面图:正文第一张图片作为封面(Markdown 格式:
)。 - 正文:Markdown 格式,支持图片、标题、链接等。
- 标签:最后一行以
$tag$格式添加标签(支持空格,如$Hugo Post$)。
- 示例 Issue:
 ## 我的第一篇 Hugo 博客 这是一篇通过 GitHub Issue 发布的博客。 支持 **Markdown** 格式,图片会自动下载到本地。 <!--more-->  $Hugo$ $博客$ $Hugo Post$ - 提交:保存 Issue,触发
deploy.yml工作流。
4. 工作流运行流程
触发
deploy.yml:- Issue 打开(带有“发布”标签)或手动触发。
- 脚本
issue_to_hugo.py运行:- 提取标题、分类(从标签)、正文、封面图、标签。
- 下载图片(使用 PAT 认证,适用于私有仓库)。
- 生成 Markdown 文件到
content/posts/YYYYMMDD_X/index.md。 - 提交到
master分支。
- 触发
hugo.yml通过repository_dispatch事件。
触发
hugo.yml:- 构建 Hugo 站点(
hugo --minify)。 - 部署到 GitHub Pages。
- 构建 Hugo 站点(
5. 验证发布
检查 Actions 日志:
- 打开 GitHub Actions 选项卡。
- 确认
Sync Issues to Hugo Content运行:- 日志显示
图片下载成功和成功转换 issue #X。 - 提交到
master(e.g.,Automated: Sync GitHub Issues as content)。
- 日志显示
- 确认
Deploy Hugo site to Pages运行:- 检查
Build with Hugo和Deploy to GitHub Pages步骤。
- 检查
检查生成文件:
- 打开
content/posts/YYYYMMDD_X/:index.md包含 frontmatter(title,date,slug,categories,tags,image)和正文。- 本地图片文件(e.g.,
X_uuid.jpg)存在。
- 示例
index.md:--- title: "我的第一篇 Hugo 博客" date: "2025-10-27" slug: "20251027_2" categories: ["技术"] tags: ["Hugo", "博客", "Hugo Post"] image: "cover_5cc86d74-ff70-401f-820d-520a99a504b9.jpg" --- ## 我的第一篇 Hugo 博客 这是一篇通过 GitHub Issue 发布的博客。 支持 **Markdown** 格式,图片会自动下载到本地。 <!--more--> 
- 打开
访问站点:
- 访问 GitHub Pages 站点(e.g.,
https://h2dcc.github.io或私有仓库的 Pages URL)。 - 确认新文章显示,图片加载正常,标签和分类正确。
- 访问 GitHub Pages 站点(e.g.,
6. 常见问题及解决
- 图片下载失败(404):
- 原因:私有仓库图片需要 PAT 认证。
- 解决:确保
PAT_TOKEN有repo权限,脚本已添加认证头。
- Hugo 构建未触发:
- 原因:
repository_dispatch事件失败。 - 解决:检查
deploy.yml的Trigger Hugo build步骤日志,确认curl请求返回 204。
- 原因:
- 标签未提取:
- 原因:标签格式错误或不在最后一行。
- 解决:确保标签以
$tag$格式在最后一行,空格需包含在$内(如$搞啥 呢$)。
- 重复文章:
- 原因:脚本会跳过已有目录(
YYYYMMDD_X)。 - 解决:删除
content/posts/YYYYMMDD_X/后重新运行。
- 原因:脚本会跳过已有目录(
7. 优化建议
- 单 Issue 处理:修改
deploy.yml和issue_to_hugo.py支持仅处理触发 Issue,减少运行时间。 - 错误通知:在 Issue 中添加评论,通知转换失败原因。
- 移动端优化:使用 GitHub 移动端 App + Shortcut Maker,简化发布流程。
- 日志查看:启用
--debug模式,检查详细日志。
总结
通过 GitHub Issue 发布 Hugo 博客,只需几个步骤即可实现从移动端快速发布到自动部署的全流程。相比传统方式,这种方法简单、高效,尤其适合需要随时记录灵感的博主。无论是公开还是私有仓库,配合 PAT 认证和 GitHub Actions,图片、标签、分类都能完美处理。快去试试吧!
