Skip to content

Gitalk 自动初始化评论

前言

Gitalk 非常好用,但是一直有个问题困扰着我,评论不能自动初始化。我在网上看了一些文章,都是 Hexo 这个博客框架,然后什么 sitemap,网站文件地图,看的我云里雾里。

正思索之际,突然灵光一闪,我其实只需要把 Gitalk 自身自带的初始化评论功能,在我的项目里复刻一遍就可以了。

环境

node: v18.16.0

Gitalk: v1.7.2

方案

读取本地所有 md 文件 -> 解析内容提取 title -> 获取 issue -> 没有 issue -> 新建 issue

实现

读取本地 md 文件

用正则更好。

js
// 获取md文件路径
const getMdFilesPath = (path, fileArr = []) => {
  const files = fs.readdirSync(path);
  files.forEach((file) => {
    const filePath = `${path}/${file}`;
    stat = fs.statSync(filePath);
    // 判断是文件还是文件夹,如果是文件夹继续递归
    if (stat.isDirectory()) {
      fileArr.concat(getMdFilesPath(filePath, fileArr));
    } else {
      fileArr.push(filePath);
    }
  });
  return fileArr.filter((i) => i.split(".").pop() === "md");
};

解析本地 md 文件

这里我用的 marked 这个包,可以把 md 语法解析成 html 语法,我再通过截取 <h1> 标签内的文字,作为这篇文章的标题存起来。

js
// 获取md文件标题
const getMdFileTitle = (path, fn) => {
  const fileContent = marked.marked(fs.readFileSync(path, "utf-8")).toString();
  const startIndex =
    fileContent.indexOf("<h1>") === -1 ? 0 : fileContent.indexOf("<h1>") + 4;
  const endIndex =
    fileContent.indexOf("</h1>") === -1 ? 0 : fileContent.indexOf("</h1>");
  const title = fileContent.substring(startIndex, endIndex);
  return title;
};

获取 Github 授权

Github 在 2022 年就禁止了账号密码直接登录,所以要通过 Oath2 来实现授权获取。

我这使用的最简单的方法,直接调起浏览器打开 Github 授权页面,手动进行授权,拿到 code 后再关闭授权回调页面。

我是用 open 这个包来打开授权网址,这个包很简单,就是适配了不同系统下的打开网址命令。

发起请求我用的 axios ,当你网页点击授权以后,Github 会回调一个地址,使用 koa 来接受这个回调带来的 code 值,然后再发起获取 token 的请求。

js
// 打开网址
const openUrl = async (param = new ParamGithub()) => {
  const { clientID } = param;
  const domain = "https://github.com/login/oauth/authorize";
  const query = {
    client_id: clientID,
    redirect_uri: `http://localhost:${port}/`, // 回调地址
    scope: "public_repo", // 用户组
  };
  const url = `${domain}?${Object.keys(query)
    .map((key) => `${key}=${query[key]}`)
    .join("&")}`;
  await open(url);
};

// 监听code获取
const startupKoa = () => {
  const app = new Koa();
  // 启动服务,监听端口
  const _server = app.listen(port);
  openUrl();
  app.use((ctx) => {
    const urlArr = ctx.originalUrl.split("=");
    if (urlArr[0].indexOf("code") > -1) {
      accessCode = urlArr[1];
      createIssues();
      configMap.set("accessCode", accessCode);
      writeConfigFile();
      _server.close();
    }
    // 拿到code后关闭回调页面
    ctx.response.body = `<script>
      (function () {
        window.close()
      })(this)
    </script>`;
  });
};

// 获取token
const getAccessToken = (param = new ParamGithub()) => {
  const { clientID, clientSecret } = param;
  return axiosGithub
    .post("/login/oauth/access_token", {
      code: accessCode,
      client_id: clientID,
      client_secret: clientSecret,
    })
    .then((res) => {
      return Promise.resolve(
        res.data.error === "bad_verification_code"
          ? null
          : res.data.access_token
      );
    })
    .catch((err) => {
      appendErrorFile("获取token", err.response.data.message);
    });
};

创建 issue

授权拿到手以后,就要发起查询 issue 和创建 issue 的请求了。

js
// 获取issues
const getIssues = (param) => {
  const { owner, repo, clientID, clientSecret, labels, title } = param || {};
  axiosApiGithub
    .get(`/repos/${owner}/${repo}/issues`, {
      auth: {
        username: clientID,
        password: clientSecret,
      },
      params: {
        labels: labels
          .concat(title)
          .map((label) => (typeof label === "string" ? label : label.name))
          .join(","),
        t: Date.now(),
      },
    })
    .then((res) => {
      if (!(res && res.data && res.data.length)) {
        createIssue(param);
      }
    })
    .catch((err) => {
      console.log(err);
      appendErrorFile("获取issues", err?.response?.data?.message || "网络问题");
    });
};

// 创建issues
const createIssue = (param) => {
  const { owner, repo, labels, title } = param || {};
  axiosApiGithub
    .post(
      `/repos/${owner}/${repo}/issues`,
      {
        title: `${title} | 天秤的异端`,
        labels: labels.concat(title).map((label) =>
          typeof label === "string"
            ? {
                name: label,
              }
            : label
        ),
        body: "我的博客 https://libraheresy.github.io/site",
      },
      {
        headers: {
          authorization: `Bearer ${accessToken}`,
        },
      }
    )
    .then(() => {
      console.log(`创建成功:${title}`);
    })
    .catch((err) => {
      appendErrorFile("创建issues", err.response.data.message);
      if (
        ["Not Found", "Bad credentials"].includes(err.response.data.message)
      ) {
        getAccessToken();
      }
    });
};

修改 package.json

加一个脚本命令,方便调用。

json
"scripts": {
  "init:comment": "node ./utils/auto-create-blog-issues.js"
},

问题

获取 token 后,请求创建 issue,报 404

这里的 404 并不是找不到请求资源的意思,这里的 404 其实指的是你没有权限操作。这给我一顿好想,在翻看 Gitalk 源码的时候才发现打开授权页面时需要指明用户组,不然给你的就是最低权限,不能新增。

js
const query = {
  client_id: clientID,
  redirect_uri: `http://localhost:${port}/`, // 回调地址
  scope: "public_repo", // 用户组
};

代码

js
const fs = require("fs"); // 操作文件
const path = require("path"); // 获取路径
const marked = require("marked"); // 解析md文件
const axios = require("axios"); // 请求
const Koa = require("koa"); // 本地服务
const open = require("open"); // 打开网址
const moment = require("moment"); // 日期

// Github配置参数
class ParamGithub {
  title = "";
  owner = "LibraHeresy"; // GitHub repository 所有者
  repo = "site"; // GitHub repository
  clientID = ""; // 自己的clientID
  clientSecret = ""; // 自己的clientSecret
  admin = ["LibraHeresy"]; // GitHub repository 所有者
  labels = ["Gitalk"]; // GitHub issue 的标签

  constructor(title) {
    this.title = title;
  }
}

const writeConfigFile = () => {
  fs.writeFileSync(
    path.join(__dirname, "./config.txt"),
    Array.from(configMap)
      .map((arr) => arr.join("="))
      .join(";")
  );
};

const appendErrorFile = (opera, message) => {
  const filePath = path.join(__dirname, "./error.txt");
  if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "");
  const time = moment().format("yyyy-MM-DD HH:mm:ss");
  fs.appendFileSync(
    path.join(__dirname, "./error.txt"),
    `${opera}报错 ${time})}\n ${message}\n`
  );
  console.log(`${opera}报错`, time);
};

// 本地配置
let config = "";
let configMap = new Map();
if (!fs.existsSync(path.join(__dirname, "./config.txt"))) {
  writeConfigFile();
}
config = fs.readFileSync(path.join(__dirname, "./config.txt"), "utf-8");
configMap = new Map(config.split(";").map((text) => text.split("=")));
let accessCode = configMap.get("accessCode") || "";
let accessToken = configMap.get("accessToken") || "";
let port = 3000;

const axiosGithub = axios.create({
  baseURL: "https://github.com",
  headers: {
    accept: "application/json",
  },
});
const axiosApiGithub = axios.create({
  baseURL: "https://api.github.com",
  headers: {
    accept: "application/json",
  },
});

// 规避控制台警告
marked.setOptions({
  mangle: false,
  headerIds: false,
});

// 获取md文件路径
const getMdFilesPath = (path, fileArr = []) => {
  const files = fs.readdirSync(path);
  files.forEach((file) => {
    const filePath = `${path}/${file}`;
    stat = fs.statSync(filePath);
    if (stat.isDirectory()) {
      fileArr.concat(getMdFilesPath(filePath, fileArr));
    } else {
      fileArr.push(filePath);
    }
  });
  return fileArr.filter((i) => i.split(".").pop() === "md");
};

// 获取md文件标题
const getMdFileTitle = (path, fn) => {
  const fileContent = marked.marked(fs.readFileSync(path, "utf-8")).toString();
  const startIndex =
    fileContent.indexOf("<h1>") === -1 ? 0 : fileContent.indexOf("<h1>") + 4;
  const endIndex =
    fileContent.indexOf("</h1>") === -1 ? 0 : fileContent.indexOf("</h1>");
  const title = fileContent.substring(startIndex, endIndex);
  return title;
};

// 打开网址
const openUrl = async (param = new ParamGithub()) => {
  const { clientID } = param;
  const domain = "https://github.com/login/oauth/authorize";
  const query = {
    client_id: clientID,
    redirect_uri: `http://localhost:${port}/`, // 回调地址
    scope: "public_repo", // 用户组
  };
  const url = `${domain}?${Object.keys(query)
    .map((key) => `${key}=${query[key]}`)
    .join("&")}`;
  await open(url);
};

// 监听code获取
const startupKoa = () => {
  const app = new Koa();
  const _server = app.listen(port);
  openUrl();
  app.use((ctx) => {
    const urlArr = ctx.originalUrl.split("=");
    if (urlArr[0].indexOf("code") > -1) {
      accessCode = urlArr[1];
      createIssues();
      configMap.set("accessCode", accessCode);
      writeConfigFile();
      _server.close();
    }
    // 拿到code后关闭回调页面
    ctx.response.body = `<script>
      (function () {
        window.close()
      })(this)
    </script>`;
  });
};

// 获取token
const getAccessToken = (param = new ParamGithub()) => {
  const { clientID, clientSecret } = param;
  return axiosGithub
    .post("/login/oauth/access_token", {
      code: accessCode,
      client_id: clientID,
      client_secret: clientSecret,
    })
    .then((res) => {
      return Promise.resolve(
        res.data.error === "bad_verification_code"
          ? null
          : res.data.access_token
      );
    })
    .catch((err) => {
      appendErrorFile("获取token", err.response.data.message);
    });
};

// 获取授权
const getAuth = () => {
  return getAccessToken().then((res) => {
    configMap.set("accessToken", res);
    writeConfigFile();
    return res;
  });
};

// 批量创建issue
const createIssues = async () => {
  if (accessCode) {
    const token = await getAuth();
    if (token) {
      accessToken = token;
      mdFileTitleArr.forEach((title) => {
        getIssues(new ParamGithub(title));
      });
    } else {
      accessCode = "";
      createIssues();
    }
  } else {
    startupKoa();
  }
};

// 获取issues
const getIssues = (param) => {
  const { owner, repo, clientID, clientSecret, labels, title } = param || {};
  axiosApiGithub
    .get(`/repos/${owner}/${repo}/issues`, {
      auth: {
        username: clientID,
        password: clientSecret,
      },
      params: {
        labels: labels
          .concat(title)
          .map((label) => (typeof label === "string" ? label : label.name))
          .join(","),
        t: Date.now(),
      },
    })
    .then((res) => {
      if (!(res && res.data && res.data.length)) {
        createIssue(param);
      }
    })
    .catch((err) => {
      console.log(err);
      appendErrorFile("获取issues", err?.response?.data?.message || "网络问题");
    });
};

// 创建issues
const createIssue = (param) => {
  const { owner, repo, labels, title } = param || {};
  axiosApiGithub
    .post(
      `/repos/${owner}/${repo}/issues`,
      {
        title: `${title} | 天秤的异端`,
        labels: labels.concat(title).map((label) =>
          typeof label === "string"
            ? {
                name: label,
              }
            : label
        ),
        body: "我的博客 https://libraheresy.github.io/site",
      },
      {
        headers: {
          authorization: `Bearer ${accessToken}`,
        },
      }
    )
    .then(() => {
      console.log(`创建成功:${title}`);
    })
    .catch((err) => {
      appendErrorFile("创建issues", err.response.data.message);
      if (
        ["Not Found", "Bad credentials"].includes(err.response.data.message)
      ) {
        getAccessToken();
      }
    });
};

// 读取本地文件
const mdFilePathArr = getMdFilesPath(path.join(__dirname, "../docs"));
const mdFileTitleArr = mdFilePathArr
  .map((path) => getMdFileTitle(path))
  .filter((i) => i);

// 调用授权函数
createIssues();

Released under the MIT License.