尝试在 GitHub Issues 上写文章并自动同步至博客

Cover Image

在创作内容过程中,总会遇到需要在其他设备中继续的情况。由于我使用无后端的 Hexo 框架生成博客页面,自然无法仅仅在其他设备中打开个网页就回到上次结束的状态。尽管我曾经也有记录将网站源码同步至 GitHub 仓库并自动化更新网站的操作(参见「初探无后端静态博客自动化部署方案」),但万一设备上没有 Git 怎么办、万一是在手机上怎么办?

这时,我们不妨把目光投向 GitHub Issues,这个初衷是用于用户与开发者交流的工具,却不经意间成为非常好用的 Markdown 编辑器——基础语法支持、渲染预览、图片拖拽上传……对!甚至连图床都免了,完全可以只关注内容,无需在乎其它。更何况 GitHub Issues 还有完善的 API 支持,确实可以试试在 GitHub Issues 上写作,然后借助 GitHub Actions 更新至网站。

网站源码托管在 GitHub Repository、使用 GitHub Issues 编写文章、借助 GitHub Actions 自动更新、网站部署至 GitHub Pages……用到的工具全是 GitHub 的,不得不说这方面 GitHub 还是太会了。不可不谓之「完全围绕 GitHub 进行内容创作与分发」了。

准备工作

匿名账户调用 GitHub Issues API 是有较为严格的限制的,而且若仓库为私有仓库,匿名账户也是访问不到的。为此,我们最好给予一个 Personal Access Token 以获得必要权限。

登录你的 GitHub 账户,在「Settings => Developer Settings => Personal access token」处新建一个 Token,点击「Generate new token」。

勾选第一条,授予「repo」下的所有权限。注意保管好这个 Token,它只会出现一次。

GitHub token repo 权限

同时由于此方法使用 GitHub Actions 同步更新至网站,所以最好你已经实现借助 GitHub Actions 自动化部署网站。如果还没有好的实践,不妨参考下我之前写的文章「初探无后端静态博客自动化部署方案」,本文就不再过多探讨关于自动部署方面的内容了。

能从 GitHub API 中获得哪些信息

在正式开始操作前,不妨来看看,我们究竟能从 GitHub REST API 中获取到哪些关于 GitHub Issues 的信息。

对这个路由发起 GET 请求,注意将用户、仓库名换成自己的。

https://api.github.com/repos/username/reponame/issues

还记得刚刚生成的 token 吗?这里可以通过在请求的 header 中加入以下内容来认证。

Authorization: `token your_token_blablalba`

此时就会获取到一个由每个 Issue 信息键值对组成的数组,我们关注某一个 Issue 信息:

  • number/node_id:由于其他参数有可能变动,由此可以确定 issue 唯一性
  • title:由此可以获得文章标题
  • body:由此可以获得文章内容
  • created_at:由此可以获得文章创建日期
  • updated_at:由此可以获得文章最后更新日期
  • labels:由此可以获得文章标签
  • milestone:由此可以获得文章分类

最后两个,使用 labels 存储标签和使用 milestone 存储分类是我自作主张决定的,你也可以有别的想法。但前面几个应该是没有什么异议的,我们可以根据这些信息来生成文章页面。

创建 Issue 同时新建文章

由于 Hexo 每个页面都会使用 Front-matter 来存储关于这个页面的一些信息,但是在 GitHub Issues 中没有找到方便存储键值对的功能。所以这里只好退一步,当监听到开启新的 Issue 时,在 source/_posts 文件夹下新建一个 .md 文件仅用于存放 Front-matter。使用 GitHub Actions 就能很好地监听 Issues 事件。

on: 
  issues:
    types: [opened]

由于我不是很熟悉命令行编辑,所以采用了 Node.js 处理。

let content = '';
(content += '---\r\n'), (content += '<!-- titlePlaceHolder -->\r\n'), (content += '<!-- datePlaceHolder -->\r\n'), (content += '<!-- updatedPlaceHolder -->\r\n'), (content += '<!-- tagsPlaceHolder -->\r\n'), (content += '<!-- categoriesPlaceHolder -->\r\n');
if (data.front_matters) {
    data.front_matters.forEach((item) => {
        content += `${item}\r\n`;
    });
}
content += '---\r\n\r\n';

fs.writeFileSync(join(dirname(__dirname) + `/source/_posts/${id}.md`), content);

在标题、日期、标签、分类等可以从 GitHub Issues 中很方便获取的参数,只创建一个展位符留给后续更新部署的时候替换。至于其他无法方便获取的参数,只好修改对应 .md 文件来实现了。

我是使用 Issue number 作为文件名并以此确定 Issue 与文件的对应关系,你也可以使用别的方式。但注意最好是创建之后不会修改的参数来确定对应关系。

最后,完整的代码如下。注意你是无法明文存储 Token 的,上传的时候 GitHub 会帮你抹掉,更何况从安全的角度而言,将 Token 存至 GitHub Secret 也是更明智的选择。

# .github/workflows/new_post.yml

name: Create New Post

on: 
  issues:
    types: [opened]

jobs:
  build:
    runs-on: ubuntu-20.04

    strategy:
      matrix:
        node-version: [12.x]

    steps:
      - name: Checkout Source
        uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install Dependencies
        run: npm i

      - name: Generate Post File
        run: node -e "require('./src/new_post').generate_files(${{ github.event.issue.number }})"
          
      - name: Commit Changes
        run: |
          git config user.name "username"
          git config user.email "your_email@example.com"
          git add ./source/_posts
          git commit -m "New Post #${{ github.event.issue.number }}"

      - name: Push Changes
        uses: ad-m/github-push-action@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: ${{ github.ref }}
// src/new_post.js

'use strict';

const fs = require('fs');
const fetch = require('node-fetch');
const { join, dirname } = require('path');

const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const data = {
    repo: "username/reponame",
    front_matter: [], // 默认添加的 Front-matter
}

function generate_files(id) {
    if (!fs.existsSync(join(dirname(__dirname) + `/source/_posts/${id}.md`))) {
        let content = '';
        (content += '---\r\n'), (content += '<!-- titlePlaceHolder -->\r\n'), (content += '<!-- datePlaceHolder -->\r\n'), (content += '<!-- updatedPlaceHolder -->\r\n'), (content += '<!-- tagsPlaceHolder -->\r\n'), (content += '<!-- categoriesPlaceHolder -->\r\n');
        if (data.front_matter) {
            data.front_matter.forEach((item) => {
                content += `${item}\r\n`;
            });
        }
        content += '---\r\n\r\n';

        fs.writeFileSync(join(dirname(__dirname) + `/source/_posts/${id}.md`), content);

    }
}

module.exports = {
    generate_files: generate_files,
};

部署更新至网站

首先要明确一个问题——根据什么信号触发部署更新至网站?

原来的每次 push 部署更新肯定是行不通的了。一来现在文章根本就不在仓库中,而是在 Issues 里,更新并不会有在仓库中修改并推送;二来新建 Issue 创建的占位 .md 文件也会被算入更新中,然而此时往往还没有编辑好文章。
监听 Issue 更新也不太好,这样每次 Issue 更新都会被部署。然而有时我们只是想打草稿,还没想着发布。或者有时候文章还没写完,但是突然有事情不能继续写了,此时只是想保存当前状态,也不想发布。

我采用的是一种比较笨的方法——手动触发部署更新。

on: workflow_dispatch

这样在 GitHub Actions 页面就会有一个手动触发的按钮。

GitHub Actions 手动触发按钮

每次触发会根据从 GitHub API 获取的信息修改本地占位文件并重新生成网页部署至云端。我使用了 Fetch API 在 Node.js 中发送 HTTP 请求,fetch() 可以理解为 XMLHttpRequest 的升级版,使用 Promise 而非回调函数使其在编写上更为优雅。

fetch(postEntry.url, {
    method: 'GET',
    headers: {
        Authorization: `token your_tokekn_blablabla`,
    },
})
    .then((response) => {
        return response.json();
    })
    .then((post) => {
        let title = post.title,
            date = post.created_at.replace(/[TZ]/g, ' '),
            updated = post.updated_at.replace(/[TZ]/g, ' '),
            categories = post.milestone;
        let tags = [];
        post.labels.forEach((tag) => {
            tags.push(tag.name);
        });

        let content = fs.readFileSync(join(dirname(__dirname) + `/source/_posts/${post.number}.md`), 'utf-8');
        (title && (content = content.replace('<!-- titlePlaceHolder -->', `title: ${title}`))) || (content = content.replace('<!-- titlePlaceHolder -->', ''));
        (date && (content = content.replace('<!-- datePlaceHolder -->', `date: ${date}`))) || (content = content.replace('<!-- datePlaceHolder -->', ''));
        (updated && (content = content.replace('<!-- updatedPlaceHolder -->', `updated: ${updated}`))) || (content = content.replace('<!-- updatedPlaceHolder -->', ''));
        (tags && (content = content.replace('<!-- tagsPlaceHolder -->', `tags: ["${tags.join('", "')}"]`))) || (content = content.replace('<!-- tagsPlaceHolder -->', ''));
        (categories && (content = content.replace('<!-- categoriesPlaceHolder -->', `categories: ["${categories}"]`))) || (content = content.replace('<!-- categoriesPlaceHolder -->', ''));
        content += `\r\n\r\n${post.body}`;
    
    	fs.writeFileSync(join(dirname(__dirname) + `/source/_posts/${post.number}.md`), content);
    });

看到最后那一串长长的东西了吗?就是在根据从 GitHub API 中获取到的信息更新占位符,如果对应信息不存在(如没有设置 labels)则清除占位符。只不过这里我压行了,陋习陋习……

到这里,如果文章少的话是不会遇到什么问题的。但要是文章比较多,可能会发现旧文章都不见了。这是因为 GitHub API 会自动分页,默认 30 条 Issue 每页,可以手动上调至不超过 100 条每页。要处理分页也不难,每次请求返回中的 Link header 会包含分页信息,跑个递归就好了。

function main(page = 1) {
    fetch(`https://api.github.com/repos/username/reponame/issues?page=${page}&per_page=100`, {
        method: 'GET',
        headers: {
            Authorization: `token your_token_blablabla`,
        },
    })
        .then((response) => {
            if (response.headers.get('Link') && /rel\=\"next\"/gi.test(response.headers.get('Link'))) {
                main(page + 1);
            }
            return response.json();
        })
        .then((list) => {
            list.forEach((postEntry) => {
                // ...
            });
        });
}

最后,完整的代码如下。

// src/main.js

'use strict';

const fs = require('fs');
const fetch = require('node-fetch');
const { join, dirname } = require('path');
const { generate_files } = require('./new_post');

const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const data = {
    repo: "username/reponame",
    
}

function main(page = 1) {
    fetch(`https://api.github.com/repos/${data.repo}/issues?page=${page}&per_page=100`, {
        method: 'GET',
        headers: {
            Authorization: `token ${GITHUB_TOKEN}`,
        },
    })
        .then((response) => {
            if (response.headers.get('Link') && /rel\=\"next\"/gi.test(response.headers.get('Link'))) {
                main(page + 1);
            }
            return response.json();
        })
        .then((list) => {
            list.forEach((postEntry) => {
                fetch(postEntry.url, {
                    method: 'GET',
                    headers: {
                        Authorization: `token ${GITHUB_TOKEN}`,
                    },
                })
                    .then((response) => {
                        return response.json();
                    })
                    .then((post) => {
                        let title = post.title,
                            date = post.created_at.replace(/[TZ]/g, ' '),
                            updated = post.updated_at.replace(/[TZ]/g, ' '),
                            categories = post.milestone;
                        let tags = [];
                        post.labels.forEach((tag) => {
                            tags.push(tag.name);
                        });

                        if (!fs.existsSync(join(dirname(__dirname) + `/source/_posts/${post.number}.md`))) {
                            generate_files(post.number);
                        }
                        let content = fs.readFileSync(join(dirname(__dirname) + `/source/_posts/${post.number}.md`), 'utf-8');
                        (title && (content = content.replace('<!-- titlePlaceHolder -->', `title: ${title}`))) || (content = content.replace('<!-- titlePlaceHolder -->', ''));
                        (date && (content = content.replace('<!-- datePlaceHolder -->', `date: ${date}`))) || (content = content.replace('<!-- datePlaceHolder -->', ''));
                        (updated && (content = content.replace('<!-- updatedPlaceHolder -->', `updated: ${updated}`))) || (content = content.replace('<!-- updatedPlaceHolder -->', ''));
                        (tags && (content = content.replace('<!-- tagsPlaceHolder -->', `tags: ["${tags.join('", "')}"]`))) || (content = content.replace('<!-- tagsPlaceHolder -->', ''));
                        (categories && (content = content.replace('<!-- categoriesPlaceHolder -->', `categories: ["${categories}"]`))) || (content = content.replace('<!-- categoriesPlaceHolder -->', ''));
                        content += `\r\n\r\n${post.body}`;

                        fs.writeFileSync(join(dirname(__dirname) + `/source/_posts/${post.number}.md`), content);
                    })
                    .catch((e) => {
                        console.log(e);
                    });
            });
        })
        .catch((e) => {
            console.log(e);
        });
}

module.exports = {
    main: main,
};
# ,github/workflows/deploy.yml

name: Build WebSite

on: workflow_dispatch

jobs:
  build:
    runs-on: ubuntu-20.04

    strategy:
      matrix:
        node-version: [12.x]

    steps:
      - name: Checkout Source
        uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install Dependencies
        run: npm i

      - name: Process Post
        run: node -e "require('./src/main').main()"
        env:
          GITHUB_TOKEN: ${{ secrets.token }}

      - name: Build WebSite
        run: npx hexo g

      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: gh-pages
          publish_dir: ./public
          force_orphan: true
          cname: your.domain.com

其实主要是将 Process Post 部分加入 hexo g 之前即可。如果你以前有配置过 GitHub Actions 自动化部署,其它部分基本不用修改。

如果只是习惯在 GitHub Issues 上写作,并不在乎 Hexo 生态,尝试下友人 蝉时雨 写的单页博客 Aurora 也是个不错的选择。

如果你只是想简单的写作,并不打算定制修改主题源码,刚好我也写了个小工具,BloginHub,使用 Template 生成仓库,最速开始 Hexo 使用、直接在 GitHub Issues 上编写文章。

尝试在 GitHub Issues 上写文章并自动同步至博客
本文作者
ChrAlpha
最后更新
2021-04-04
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!

评论

您所在的地区可能无法访问 Disqus 评论系统,请切换网络环境再尝试。