如何使用 GitHub Issue 发布 Hugo 博客

如何使用 GitHub Issue 发布 Hugo 博客

2025-10-27 技术 10 分钟
本文详细介绍一种利用GitHub Issue作为Hugo博客发布端的简单高效方法。通过GitHub Actions自动化流程,可将Issue内容自动转换为Hugo文章并部署至GitHub

目录

近年来,静态博客的发布方式层出不穷,但许多方法要么复杂,要么难以长期坚持。本文介绍一种简单高效的方式:通过 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 SettingsDeveloper settingsPersonal 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

关键点

  • 触发条件:pushmaster 分支、手动触发,或 repository_dispatch 事件 (trigger-hugo-build)。
  • 权限:确保 pages: writeid-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"![{alt_text}]({final_filename})"
        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

  1. 创建 PAT

    • 访问 GitHub SettingsDeveloper settingsPersonal access tokensTokens (classic)
    • 创建新 PAT,勾选 repo(包括私有仓库访问)和 workflow(触发工作流)。
    • 复制生成的 token。
  2. 添加到仓库

    • 转到仓库(e.g., h2dcc/lawtee.github.io)→ SettingsSecrets and variablesActionsSecrets
    • 添加新 secret,命名为 PAT_TOKEN,粘贴 PAT 值。

3. 编写 Issue 发布博客

在 GitHub Issue 中按以下格式编写博客文章:

  1. 添加“发布”标签
    • 创建新 Issue,添加标签 发布 和分类标签(如 技术生活)。
  2. 正文格式
    • 标题:Issue 标题作为文章标题。
    • 封面图:正文第一张图片作为封面(Markdown 格式:![Image](URL))。
    • 正文:Markdown 格式,支持图片、标题、链接等。
    • 标签:最后一行以 $tag$ 格式添加标签(支持空格,如 $Hugo Post$)。
  3. 示例 Issue
    ![Image](https://github.com/user-attachments/assets/5cc86d74-ff70-401f-820d-520a99a504b9)
    ## 我的第一篇 Hugo 博客
    这是一篇通过 GitHub Issue 发布的博客。
    支持 **Markdown** 格式,图片会自动下载到本地。
    <!--more-->
    ![Another Image](https://github.com/user-attachments/assets/926e7e9b-d279-4db9-bbb9-60bdcedd1804)
    $Hugo$ $博客$ $Hugo Post$
    
  4. 提交:保存 Issue,触发 deploy.yml 工作流。

4. 工作流运行流程

  1. 触发 deploy.yml

    • Issue 打开(带有“发布”标签)或手动触发。
    • 脚本 issue_to_hugo.py 运行:
      • 提取标题、分类(从标签)、正文、封面图、标签。
      • 下载图片(使用 PAT 认证,适用于私有仓库)。
      • 生成 Markdown 文件到 content/posts/YYYYMMDD_X/index.md
      • 提交到 master 分支。
    • 触发 hugo.yml 通过 repository_dispatch 事件。
  2. 触发 hugo.yml

    • 构建 Hugo 站点(hugo --minify)。
    • 部署到 GitHub Pages。

5. 验证发布

  1. 检查 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 HugoDeploy to GitHub Pages 步骤。
  2. 检查生成文件

    • 打开 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-->
      ![Another Image](2_926e7e9b-d279-4db9-bbb9-60bdcedd1804.jpg)
      
  3. 访问站点

    • 访问 GitHub Pages 站点(e.g., https://h2dcc.github.io 或私有仓库的 Pages URL)。
    • 确认新文章显示,图片加载正常,标签和分类正确。

6. 常见问题及解决

  • 图片下载失败(404)
    • 原因:私有仓库图片需要 PAT 认证。
    • 解决:确保 PAT_TOKENrepo 权限,脚本已添加认证头。
  • Hugo 构建未触发
    • 原因repository_dispatch 事件失败。
    • 解决:检查 deploy.ymlTrigger Hugo build 步骤日志,确认 curl 请求返回 204。
  • 标签未提取
    • 原因:标签格式错误或不在最后一行。
    • 解决:确保标签以 $tag$ 格式在最后一行,空格需包含在 $ 内(如 $搞啥 呢$)。
  • 重复文章
    • 原因:脚本会跳过已有目录(YYYYMMDD_X)。
    • 解决:删除 content/posts/YYYYMMDD_X/ 后重新运行。

7. 优化建议

  • 单 Issue 处理:修改 deploy.ymlissue_to_hugo.py 支持仅处理触发 Issue,减少运行时间。
  • 错误通知:在 Issue 中添加评论,通知转换失败原因。
  • 移动端优化:使用 GitHub 移动端 App + Shortcut Maker,简化发布流程。
  • 日志查看:启用 --debug 模式,检查详细日志。

总结

通过 GitHub Issue 发布 Hugo 博客,只需几个步骤即可实现从移动端快速发布到自动部署的全流程。相比传统方式,这种方法简单、高效,尤其适合需要随时记录灵感的博主。无论是公开还是私有仓库,配合 PAT 认证和 GitHub Actions,图片、标签、分类都能完美处理。快去试试吧!