老T博客

如何使用 GitHub Issue 发布 Hugo 博客

如何使用 GitHub Issue 发布 Hugo 博客

近年来,静态博客的发布方式层出不穷,但许多方法要么复杂,要么难以长期坚持。本文介绍一种简单高效的方式:通过 GitHub Issue 作为 Hugo 博客的发布端,利用 GitHub Actions 自动将 Issue 转换为 Hugo 内容并部署到 GitHub Pages。这种方法尤其适合喜欢用 GitHub 移动端 App 随时随地发布博客的用户。本教程基于老 T 的实践经验,解决了私有仓库图片下载、标签提取等问题,适合公开和私有仓库。

为什么选择 GitHub Issue?

前提条件

实现步骤

1. 配置 GitHub Actions 工作流

我们需要两个工作流文件:

1.1 创建 deploy.yml

.github/workflows/deploy.yml 中添加以下内容,用于处理 Issue 转换为 Hugo 内容,并触发 Hugo 构建:

  1name: Sync Issues to Hugo Content
  2
  3on:
  4  issues:
  5    types: [opened]
  6  workflow_dispatch:
  7
  8permissions:
  9  contents: write
 10
 11concurrency:
 12  group: "issues-to-hugo"
 13  cancel-in-progress: false
 14
 15defaults:
 16  run:
 17    shell: bash
 18
 19jobs:
 20  convert_issues:
 21    name: Convert GitHub Issues
 22    runs-on: ubuntu-latest
 23    if: github.event_name == 'workflow_dispatch' || github.event_name == 'issues' && contains(github.event.issue.labels.*.name, '发布') && github.actor != 'IssueBot'
 24    steps:
 25      - name: Checkout repository
 26        uses: actions/checkout@v4
 27        with:
 28          fetch-depth: 0
 29          clean: true
 30          
 31      - name: Setup Python
 32        uses: actions/setup-python@v4
 33        with:
 34          python-version: '3.11'
 35      
 36      - name: Install dependencies
 37        run: |
 38          pip install requests PyGithub==2.4.0 beautifulsoup4
 39
 40      - name: Create content/posts directory
 41        run: |
 42          mkdir -p content/posts
 43          echo "Created content/posts directory"
 44
 45      - name: Convert issues to Hugo content
 46        id: convert
 47        env:
 48          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
 49        run: |
 50          find content/posts -type f 2>/dev/null | sort > /tmp/original_files.txt || touch /tmp/original_files.txt
 51          if [ -s /tmp/original_files.txt ]; then
 52            xargs sha1sum < /tmp/original_files.txt > /tmp/original_hashes.txt
 53          else
 54            touch /tmp/original_hashes.txt
 55          fi
 56          
 57          python .github/workflows/issue_to_hugo.py \
 58            --repo "${{ github.repository }}" \
 59            --output "content/posts" \
 60            --debug
 61          
 62          find content/posts -type f 2>/dev/null | sort > /tmp/new_files.txt || touch /tmp/new_files.txt
 63          if [ -s /tmp/new_files.txt ]; then
 64            xargs sha1sum < /tmp/new_files.txt > /tmp/new_hashes.txt
 65          else
 66            touch /tmp/new_hashes.txt
 67          fi
 68          
 69          if cmp -s /tmp/original_hashes.txt /tmp/new_hashes.txt; then
 70            echo "needs_build=false" >> $GITHUB_OUTPUT
 71            echo "🔄 没有内容变更,将跳过提交"
 72          else
 73            echo "needs_build=true" >> $GITHUB_OUTPUT
 74            echo "✅ 检测到内容变更,将执行提交"
 75          fi
 76          
 77      - name: Commit changes (if any)
 78        if: steps.convert.outputs.needs_build == 'true'
 79        env:
 80          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
 81        run: |
 82          git config --global user.name "IssueBot"
 83          git config --global user.email "actions@users.noreply.github.com"
 84          git remote set-url origin https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository }}.git
 85          
 86          if [ -n "${{ github.base_ref }}" ]; then
 87            DEFAULT_BRANCH="${{ github.base_ref }}"
 88          else
 89            DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
 90          fi
 91          
 92          if [ -z "$DEFAULT_BRANCH" ]; then
 93            DEFAULT_BRANCH="master"
 94          fi
 95          
 96          git checkout $DEFAULT_BRANCH
 97          
 98          git add content/posts
 99          
100          if ! git diff-index --quiet HEAD --; then
101            git commit -m "Automated: Sync GitHub Issues as content"
102            echo "正在推送变更到分支: $DEFAULT_BRANCH"
103            git push origin $DEFAULT_BRANCH
104          else
105            echo "没有需要提交的变更"
106          fi
107
108      - name: Trigger Hugo build
109        if: steps.convert.outputs.needs_build == 'true'
110        env:
111          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
112        run: |
113          curl -X POST \
114            -H "Accept: application/vnd.github.v3+json" \
115            -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
116            https://api.github.com/repos/${{ github.repository }}/dispatches \
117            -d '{"event_type": "trigger-hugo-build"}'

关键点

1.2 创建 hugo.yml

.github/workflows/hugo.yml 中添加以下内容,用于构建和部署 Hugo 站点:

 1name: Deploy Hugo site to Pages
 2
 3on:
 4  push:
 5    branches: ["master"]
 6  workflow_dispatch:
 7  repository_dispatch:
 8    types: [trigger-hugo-build]
 9
10permissions:
11  contents: read
12  pages: write
13  id-token: write
14
15concurrency:
16  group: "pages"
17  cancel-in-progress: false
18
19defaults:
20  run:
21    shell: bash
22
23jobs:
24  build:
25    runs-on: ubuntu-latest
26    env:
27      HUGO_VERSION: 0.128.0
28    steps:
29      - name: Install Hugo CLI
30        run: |
31          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
32          && sudo dpkg -i ${{ runner.temp }}/hugo.deb
33      - name: Install Dart Sass
34        run: sudo snap install dart-sass
35      - name: Checkout
36        uses: actions/checkout@v4
37        with:
38          submodules: recursive
39      - name: Setup Pages
40        id: pages
41        uses: actions/configure-pages@v5
42      - name: Install Node.js dependencies
43        run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
44      - name: Build with Hugo
45        env:
46          HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
47          HUGO_ENVIRONMENT: production
48        run: |
49          hugo \
50            --minify \
51            --baseURL "${{ steps.pages.outputs.base_url }}/"
52      - name: Upload artifact
53        uses: actions/upload-pages-artifact@v3
54        with:
55          path: ./public
56  deploy:
57    environment:
58      name: github-pages
59      url: ${{ steps.deployment.outputs.page_url }}
60    runs-on: ubuntu-latest
61    needs: build
62    steps:
63      - name: Deploy to GitHub Pages
64        id: deployment
65        uses: actions/deploy-pages@v4

关键点

1.3 添加转换脚本 issue_to_hugo.py

.github/workflows/issue_to_hugo.py 中添加以下 Python 脚本,用于将 Issue 转换为 Hugo 内容,支持私有仓库图片下载和标签提取:

  1import os
  2import argparse
  3import re
  4import requests
  5import json
  6import logging
  7from urllib.parse import unquote
  8from datetime import datetime
  9from github import Github, Auth
 10from bs4 import BeautifulSoup
 11
 12CATEGORY_MAP = ["生活", "技术", "法律", "瞬间", "社会"]
 13PUBLISH_LABEL = "发布"
 14
 15def setup_logger(debug=False):
 16    logger = logging.getLogger('issue-to-hugo')
 17    level = logging.DEBUG if debug else logging.INFO
 18    logger.setLevel(level)
 19    
 20    handler = logging.StreamHandler()
 21    formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 22    handler.setFormatter(formatter)
 23    logger.addHandler(handler)
 24    
 25    return logger
 26
 27def extract_cover_image(body):
 28    """提取并删除封面图(正文第一张图片)"""
 29    img_pattern = r"!\[.*?\]\((https?:\/\/[^\)]+)\)"
 30    match = re.search(img_pattern, body)
 31    
 32    if match:
 33        img_url = match.group(1)
 34        body = body.replace(match.group(0), "")
 35        return img_url, body
 36    return None, body
 37
 38def safe_filename(filename):
 39    """生成安全的文件名,保留或推断文件扩展名"""
 40    clean_url = re.sub(r"\?.*$", "", filename)
 41    basename = os.path.basename(clean_url)
 42    decoded_name = unquote(basename)
 43    
 44    name, ext = os.path.splitext(decoded_name)
 45    safe_name = re.sub(r"[^a-zA-Z0-9\-_]", "_", name)
 46    if not ext.lower() in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
 47        ext = ""
 48    if len(safe_name) > 100 - len(ext):
 49        safe_name = safe_name[:100 - len(ext)]
 50    
 51    return safe_name + ext
 52
 53def download_image(url, output_path, token=None):
 54    """下载图片到指定路径,基于内容类型确定扩展名,并添加 GitHub 认证头"""
 55    try:
 56        headers = {}
 57        if token:
 58            headers['Authorization'] = f'token {token}'
 59        
 60        response = requests.get(url, stream=True, headers=headers)
 61        if response.status_code == 200:
 62            content_type = response.headers.get("content-type", "").lower()
 63            ext = ".jpg"
 64            if "image/png" in content_type:
 65                ext = ".png"
 66            elif "image/jpeg" in content_type or "image/jpg" in content_type:
 67                ext = ".jpg"
 68            elif "image/gif" in content_type:
 69                ext = ".gif"
 70            elif "image/webp" in content_type:
 71                ext = ".webp"
 72            
 73            base, current_ext = os.path.splitext(output_path)
 74            if current_ext.lower() not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
 75                output_path = base + ext
 76            else:
 77                output_path = base + current_ext
 78            
 79            with open(output_path, 'wb') as f:
 80                f.write(response.content)
 81            logging.info(f"图片下载成功: {url} -> {output_path}")
 82            return output_path
 83        else:
 84            logging.error(f"图片下载失败,状态码: {response.status_code}, URL: {url}")
 85    except Exception as e:
 86        logging.error(f"下载图片失败: {url} - {e}")
 87    return None
 88
 89def replace_image_urls(body, issue_number, output_dir, token=None):
 90    """替换正文中的远程图片为本地图片"""
 91    img_pattern = r"!\[(.*?)\]\((https?:\/\/[^\)]+)\)"
 92    
 93    def replacer(match):
 94        alt_text = match.group(1)
 95        img_url = match.group(2)
 96        filename = f"{issue_number}_{safe_filename(img_url)}"
 97        output_path = os.path.join(output_dir, filename)
 98        final_path = download_image(img_url, output_path, token)
 99        if final_path:
100            final_filename = os.path.basename(final_path)
101            return f"![{alt_text}]({final_filename})"
102        return match.group(0)
103    
104    return re.sub(img_pattern, replacer, body, flags=re.IGNORECASE)
105
106def sanitize_markdown(content):
107    """清理Markdown中的不安全内容"""
108    if not content:
109        return ""
110    
111    soup = BeautifulSoup(content, "html.parser")
112    allowed_tags = ["p", "a", "code", "pre", "blockquote", "ul", "ol", "li", "strong", "em", "img", "h1", "h2", "h3", "h4", "h5", "h6"]
113    for tag in soup.find_all(True):
114        if tag.name not in allowed_tags:
115            tag.unwrap()
116    
117    return str(soup)
118
119def extract_tags_from_body(body, logger):
120    """从正文最后一行提取标签,使用 $tag$ 格式,并返回清理后的正文"""
121    if not body:
122        logger.debug("Body is empty, no tags to extract")
123        return [], body
124    
125    body = body.replace('\r\n', '\n').rstrip()
126    lines = body.split('\n')
127    if not lines:
128        logger.debug("No lines in body, no tags to extract")
129        return [], body
130    
131    last_line = lines[-1].strip()
132    logger.debug(f"Last line for tag extraction: '{last_line}'")
133    
134    tags = re.findall(r'\$(.+?)\$', last_line, re.UNICODE)
135    tags = [tag.strip() for tag in tags if tag.strip()]
136    
137    if tags:
138        logger.debug(f"Extracted tags: {tags}")
139        body = '\n'.join(lines[:-1]).rstrip()
140    else:
141        logger.debug("No tags found in last line")
142    
143    return tags, body
144
145def convert_issue(issue, output_dir, token, logger):
146    """转换单个issue为Hugo内容"""
147    try:
148        labels = [label.name for label in issue.labels]
149        if PUBLISH_LABEL not in labels or issue.state != "open":
150            logger.debug(f"跳过 issue #{issue.number} - 未标记为发布")
151            return False
152        
153        pub_date = issue.created_at.strftime("%Y%m%d")
154        slug = f"{pub_date}_{issue.number}"
155        post_dir = os.path.join(output_dir, slug)
156        
157        if os.path.exists(post_dir):
158            logger.info(f"跳过 issue #{issue.number} - 目录 {post_dir} 已存在")
159            return False
160        
161        os.makedirs(post_dir, exist_ok=True)
162        
163        body = issue.body or ""
164        logger.debug(f"Raw issue body: '{body}'")
165        cover_url, body = extract_cover_image(body)
166        tags, body = extract_tags_from_body(body, logger)
167        body = sanitize_markdown(body)
168        body = replace_image_urls(body, issue.number, post_dir, token)
169        logger.info(f"图片处理完成,{issue.number} 号 issue")
170        
171        categories = [tag for tag in labels if tag in CATEGORY_MAP]
172        category = categories[0] if categories else "生活"
173        
174        cover_name = None
175        if cover_url:
176            try:
177                cover_filename = f"cover_{safe_filename(cover_url)}"
178                cover_path = os.path.join(post_dir, cover_filename)
179                final_cover_path = download_image(cover_url, cover_path, token)
180                if final_cover_path:
181                    cover_name = os.path.basename(final_cover_path)
182                    logger.info(f"封面图下载成功:{cover_url} > {cover_name}")
183                else:
184                    logger.error(f"封面图下载失败:{cover_url}")
185            except Exception as e:
186                logger.error(f"封面图下载失败:{cover_url} - {e}")
187        
188        title_escaped = issue.title.replace('"', '\\"')
189        category_escaped = category.replace('"', '\\"')
190        frontmatter_lines = [
191            "---",
192            f'title: "{title_escaped}"',
193            f"date: \"{issue.created_at.strftime('%Y-%m-%d')}\"",
194            f"slug: \"{slug}\"",
195            f"categories: [\"{category_escaped}\"]",
196            f"tags: {json.dumps(tags, ensure_ascii=False)}"
197        ]
198        
199        if cover_name:
200            frontmatter_lines.append(f"image: \"{cover_name}\"")
201        
202        frontmatter_lines.append("---\n")
203        frontmatter = "\n".join(frontmatter_lines)
204        
205        md_file = os.path.join(post_dir, "index.md")
206        with open(md_file, "w", encoding="utf-8") as f:
207            f.write(frontmatter + body)
208        
209        logger.info(f"成功转换 issue #{issue.number}{md_file}")
210        return True
211    except Exception as e:
212        logger.exception(f"转换 issue #{issue.number} 时发生严重错误")
213        error_file = os.path.join(output_dir, f"ERROR_{issue.number}.tmp")
214        with open(error_file, "w") as f:
215            f.write(f"Conversion failed: {str(e)}")
216        return False
217
218def main():
219    args = parse_arguments()
220    logger = setup_logger(args.debug)
221    
222    token = args.token or os.getenv("GITHUB_TOKEN")
223    if not token:
224        logger.error("Missing GitHub token")
225        return
226    
227    try:
228        auth = Auth.Token(token)
229        g = Github(auth=auth)
230        repo = g.get_repo(args.repo)
231        logger.info(f"已连接至 GitHub 仓库:{args.repo}")
232    except Exception as e:
233        logger.error(f"连接GitHub失败: {str(e)}")
234        return
235    
236    os.makedirs(args.output, exist_ok=True)
237    logger.info(f"输出目录: {os.path.abspath(args.output)}")
238    
239    processed_count = 0
240    error_count = 0
241    
242    try:
243        issues = repo.get_issues(state="open")
244        total_issues = issues.totalCount
245        logger.info(f"开始处理 {total_issues} 个打开状态的 issue")
246        
247        for issue in issues:
248            if issue.pull_request:
249                continue
250            try:
251                if convert_issue(issue, args.output, token, logger):
252                    processed_count += 1
253            except Exception as e:
254                error_count += 1
255                logger.error(f"处理 issue #{issue.number} 时出错: {str(e)}")
256                try:
257                    error_comment = f"⚠️ 转换为Hugo内容失败,请检查格式错误:\n\n```\n{str(e)}\n```"
258                    if len(error_comment) > 65536:
259                        error_comment = error_comment[:65000] + "\n```\n...(内容过长,部分已省略)"
260                    
261                    issue.create_comment(error_comment)
262                    try:
263                        error_label = repo.get_label("conversion-error")
264                    except:
265                        error_label = repo.create_label("conversion-error", "ff0000")
266                    issue.add_to_labels(error_label)
267                except Exception as inner_e:
268                    logger.error(f"创建评论或添加标签时出错: {inner_e}")
269    except Exception as e:
270        logger.exception(f"获取issues时出错: {e}")
271        
272    summary = f"处理完成!成功转换 {processed_count} 个issues,{error_count} 个错误"
273    if processed_count == 0:
274        logger.info(summary + " - 没有需要处理的内容变更")
275    else:
276        logger.info(summary)
277        
278    if args.debug:
279        logger.debug("内容目录状态:")
280        logger.debug(os.listdir(args.output))
281
282def parse_arguments():
283    parser = argparse.ArgumentParser(description='Convert GitHub issues to Hugo content')
284    parser.add_argument('--token', type=str, default=None, help='GitHub access token')
285    parser.add_argument('--repo', type=str, required=True, help='GitHub repository in format owner/repo')
286    parser.add_argument('--output', type=str, default='content/posts', help='Output directory')
287    parser.add_argument('--debug', action='store_true', help='Enable debug logging')
288    return parser.parse_args()
289
290if __name__ == "__main__":
291    main()

关键点

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
    1![Image](https://github.com/user-attachments/assets/5cc86d74-ff70-401f-820d-520a99a504b9)
    2## 我的第一篇 Hugo 博客
    3这是一篇通过 GitHub Issue 发布的博客。
    4支持 **Markdown** 格式,图片会自动下载到本地。
    5<!--more-->
    6![Another Image](https://github.com/user-attachments/assets/926e7e9b-d279-4db9-bbb9-60bdcedd1804)
    7$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
       1---
       2title: "我的第一篇 Hugo 博客"
       3date: "2025-10-27"
       4slug: "20251027_2"
       5categories: ["技术"]
       6tags: ["Hugo", "博客", "Hugo Post"]
       7image: "cover_5cc86d74-ff70-401f-820d-520a99a504b9.jpg"
       8---
       9## 我的第一篇 Hugo 博客
      10这是一篇通过 GitHub Issue 发布的博客。
      11支持 **Markdown** 格式,图片会自动下载到本地。
      12<!--more-->
      13![Another Image](2_926e7e9b-d279-4db9-bbb9-60bdcedd1804.jpg)
  3. 访问站点

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

6. 常见问题及解决

7. 优化建议

总结

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

#github issue #hugo博客 #自动化部署 #github actions #静态博客

评论