为我的博客自建评论系统,摆脱 Giscus 局限

背景:为什么放弃 Giscus?

我遇到的问题

我的博客之前一直使用 Giscus 作为评论系统,它依托 GitHub Discussions 实现,开箱即用。但在长期使用中逐渐暴露了几个痛点:

  1. 登录状态不统一
    博客本身有账号密码登录体系,但 Giscus 只支持 GitHub 登录。用户每次评论都需要跳转到 GitHub 授权,评论完又回到博客,来回切换身份,体验非常割裂。

  2. 样式难以完全自定义
    Giscus 虽然提供了一定程度的主题配置,但底层基于 Web Component(<giscus-widget/>),Shadow DOM 天然隔离样式,外层 CSS 无法穿透。即使通过 CDN 加载自定义 CSS,仍有部分样式无论如何都无法覆盖。

  3. 功能无法与后端深度集成
    评论数据托管在 GitHub Discussions,但读写必须走 Giscus 前端的 SDK,无法与我的 Cloudflare Workers 后端做任何联动——比如评论审核、敏感词过滤、用户权限判断等统统无法在后端实现。

  4. 评论登录方式单一,无法兼容多种账号体系
    Giscus 只认 GitHub 登录。但我的博客除了 GitHub OAuth 登录外,还有独立的账号密码登录体系。这意味着账号密码用户无法在文章下方评论,只能干看着,或者逼迫他们也去注册 GitHub——这显然不合理。未来还计划接入微信扫码等更多登录方式,Giscus 的单一入口模式根本无法满足。

解决思路

我已有的 Cloudflare Workers 博客后端已经具备完整的用户系统(账号密码登录 + GitHub OAuth 登录)。思路很直接:

在 Worker 后端集成 GitHub API,直接操作 Discussions,由后端统一代理所有评论的读写。

这样:

  • 前端只需一个简单的评论组件,不再依赖 Giscus 的 Web Component
  • 样式完全由自己控制,不受 Shadow DOM 限制
  • 登录体系统一:无论是 GitHub 登录用户还是密码登录用户,都能通过同一个后端发评论
  • 评论数据仍保留在 GitHub Discussions,不丢失历史
  • 后端可以统一做敏感词过滤、反垃圾、评论审核等逻辑

整体架构

系统分三层:

  • 前端:轻量评论组件,负责展示评论树和输入框
  • 后端 Worker:代理所有评论操作,处理身份认证,决定使用哪种 Token 调用 GitHub API
  • 数据层:GitHub Discussions(评论存储)+ 自有数据库(点赞、自定义表情等,因为下文会提到 GitHub API 在这两方面的局限)

数据流示意:

前端评论组件 → HTTP POST → Worker API
  ├─ 检查用户登录态
  ├─ 有 GitHub Token → 以用户身份调 GitHub GraphQL API
  └─ 无 Token → 用 App Installation Token 代写
Worker → 返回前端统一格式的评论数据

评论与页面的对应关系(理解原理的关键)

很多读者第一次接触会好奇:博客的文章页面和 GitHub Discussions 是怎么对应上的?

答案很简单——每个页面的路径名就是 Discussion 的标题

一条评论的完整生命周期

假设有一篇博客文章路径为 /posts/hello-world

  • 文章发布时:
    我手动创建了一个 Discussion,标题设为 "/posts/hello-world",
    放在 "Announcements" 分类下。

  • 用户打开文章时:

    1. 浏览器加载前端评论组件
    2. 组件向后端 Worker 发请求:GET /api/comments?path=/posts/hello-world
    3. Worker 收到后,去 GitHub 搜 Discussion:
      搜索条件 → repo:pc-Blog/next "/posts/hello-world" in:title
    4. 找到对应的 Discussion 后,拉取下面所有评论
    5. Worker 将评论按父子关系(评论 + 回复)重组
    6. 返回 JSON 给前端渲染

发表评论的具体数据流——GitHub 用户如何走、密码用户如何走、旧 Giscus 评论如何兼容——在下方「核心实现」章节的「发评论:两种策略按用户身份路由」之后有详细举例说明,这里先不展开。

之所以用路径名作为 Discussion 标题,是为了和 Giscus 的 data-mapping="pathname" 保持一致——如果你原来就用 Giscus,迁移后历史评论一条都不会丢。

多篇文章共享一个 Discussion?还是各自独立?

绝大多数博客都是一篇文章对应一个 Discussion。因为:

  • 每篇文章都有自己的 URL 路径
  • 搜索时按路径精准匹配,互不干扰
  • 如果想对特定文章的评论做批量操作,直接操作对应的 Discussion 即可

但也有例外——如果你用的是 GitHub Pages 这类单页博客,或者用 title 映射而非 pathname,那就按标题匹配。本方案默认按路径匹配,和 Giscus 一致。


准备工作

1. 打开仓库的 Discussions 功能

进入仓库 Settings → General 页面,下滑到 Features 模块,找到 Discussions 复选框并打开。开启成功后,页面上方标签栏会出现 Discussions 标签。

image.png

2. 获取 Discussions 分类 ID 和名称

Discussions 分类的 ID 无法在 GitHub 页面直接查看,最简单的方法是通过 giscus.app 获取(前提是仓库已安装 Giscus App)。

  1. 安装 Giscus App → GitHub Apps - giscus,选择目标仓库
  2. 打开 giscus.app,填入仓库地址和分类名称,页面会自动生成完整的配置代码段

image.png

giscus 页面生成的 script 标签中包含 data-categorydata-category-id,记下来,后面要用。

<script src="https://giscus.app/client.js"
        data-repo="pc-Blog/next"
        data-repo-id="R_kgDOSk99gw"
        data-category="Announcements"
        data-category-id="DIC_kwDOSk99g84C9uoJ"
        data-mapping="pathname"
        data-strict="0"
        data-reactions-enabled="1"
        data-emit-metadata="0"
        data-input-position="bottom"
        data-theme="preferred_color_scheme"
        data-lang="zh-CN"
        crossorigin="anonymous"
        async>
</script>

3. 注册 GitHub App

注意:必须注册 GitHub App,而不是 OAuth App。OAuth App 只能做用户登录认证,无法以 App 身份代写评论。

image.png

生成 Private Key

在 GitHub App 设置页的 Private keys 区域,点击 Generate a private key,浏览器会自动下载一个 .pem 文件,例如 ppc-blog.2026-06-26.private-key.pem,里面就是 RSA 私钥内容,后续用于签发 JWT。

image.png

编辑权限

Permissions & Events 页面,将 Discussions 权限设为 Read and write

image.png

安装 App 到仓库

将 GitHub App 安装到个人账号,并选择目标仓库。

image.png

注意:如果在安装后更新了权限,需要在个人账号设置页面的 Installed GitHub Apps 中重新审核(Review)才能生效。

4. 获取必要数据清单

下面列出搭建评论系统所需的所有配置项及获取方式:

Installation ID

安装 GitHub App 后,进入个人 Settings → Applications → Installed GitHub Apps,点击你的 App 的 Configure。此时浏览器地址栏类似:

https://github.com/settings/installations/61234567

末尾的数字 61234567 就是 Installation ID

完整配置清单

配置 获取位置
仓库(owner/name) 仓库主页 URL,例如 pc-Blog/next
App ID GitHub App 设置页顶部(数字)
Installation ID 见上方两种方式
Private Key App 设置页生成的 .pem 文件内容
Client ID GitHub App 设置页 Basic information
Client Secret GitHub App 设置页,生成后保存
Discussions 分类名称 giscus.app 或仓库 Discussions 页面查看
Discussions 分类 ID giscus.app 生成的配置段中获取

核心实现:一步一步拆解

以下是我在 Worker 中实现评论功能的核心流程,每个步骤只展示关键 API 调用,完整代码在项目仓库中。

认证:签发 JWT → 换取 Installation Token

GitHub App 认证分两步走:

Private Key → RS256 签名 → JWT(10分钟有效)
JWT → POST /app/installations/{id}/access_tokens → Installation Token(1小时有效)

拿到 Installation Token 后,后续所有 API 调用都通过 Authorization: Bearer {token} 进行。

如果你有用户的 GitHub OAuth Token,也可以直接用用户 Token 调 API,好处是评论会显示为用户本人身份。

查评论:按页面路径搜索 Discussion

核心思路是通过 GraphQL Search 找到当前页面对应的 Discussion(按 pathname 匹配,与 Giscus 的 data-mapping="pathname" 一致):

query($q: String!) {
  search(query: $q, type: DISCUSSION, first: 1) {
    nodes {
      ... on Discussion {
        id
        comments(first: 100) {
          nodes { id body createdAt author { login avatarUrl } replyTo { id }
            replies(first: 50) { nodes { id body createdAt author { login avatarUrl } } } }
        }
      }
    }
  }
}

拿到数据后,在代码中按 replyTo 字段将评论和回复重组成树形结构返回给前端。

发评论:两种策略按用户身份路由

这是整个系统最巧妙的部分——根据用户身份选择不同的调用方式

用户提交评论 → Worker 验证 session
  ├─ GitHub 登录用户 → 用 userToken 调 addDiscussionComment → 显示为本人
  └─ 账号密码用户 → 用 appToken 调 addDiscussionComment → 显示为 bot,body 嵌入用户信息

使用的 mutation 是一样的:

mutation($did: ID!, $b: String!) {
  addDiscussionComment(input: { discussionId: $did, body: $b }) {
    comment { id body createdAt author { login avatarUrl } }
  }
}

差异在于:

  • GitHub 用户$b 直接放评论内容,评论显示为用户本人
  • 账号密码用户$b 前面加一段 HTML 注释嵌入用户身份,例如 <!--u:12345|小明|https://...-->\n评论内容。GitHub 上看到的是 App Bot,但前端渲染时会解析注释头,展示真实昵称和头像。

下面用三个具体场景展示完整的数据流动过程,以便理解不同身份的用户评论时,后端到底做了什么。


场景一:GitHub 登录用户评论

[前端] 小明(GitHub 用户)已登录博客,打开文章 /posts/hello-world
[前端] 输入"好文章!",点击提交
[前端] POST /api/comments
       请求体:{ path: "/posts/hello-world", content: "好文章!" }

[后端] Worker 检查 session → 发现小明有 GitHub Token(OAuth 登录时保存的)
[后端] 直接用小明的 userToken 调 GitHub GraphQL:
       mutation($did: "DIC_kwDOSk99g84C9uoJ", $b: "好文章!")
[GitHub] Discussion 中新增一条评论
         作者:xiaoming(小明的 GitHub 账号)
         body: "好文章!"

[后端] 返回给前端:
       { id: "DC_xxx", author: "xiaoming", avatar: "https://...", content: "好文章!" }
[前端] 评论区出现小明的评论,显示他的 GitHub 头像和用户名

✅ 优点:身份真实,body 干净
❌ 局限:只有 GitHub 登录的用户才能走这个流程


场景二:账号密码登录用户评论(App 代发)

[前端] 小红(密码用户)已登录博客,打开同一篇文章 /posts/hello-world
[前端] 输入"写得太棒了!",点击提交
[前端] POST /api/comments
       请求体:{ path: "/posts/hello-world", content: "写得太棒了!" }

[后端] Worker 检查 session → 小红没有 GitHub Token(她是密码登录的)
[后端] Worker 取出 App 的 Installation Token(缓存中,若过期先签发)
[后端] 合成带身份信息的 body:
       "<!--u:42|小红|https://blog.cdn/avatar/xiaohong.png-->\n写得太棒了!"
[后端] 用 appToken 调 GitHub GraphQL:
       mutation($did: "DIC_kwDOSk99g84C9uoJ", $b: "<!--u:42|小红|...png-->\n写得太棒了!")
[GitHub] Discussion 中新增一条评论
         作者:pc-blog[bot](因为用的是 App Token)
         body: "<!--u:42|小红|...png-->\n写得太棒了!"

[后端] 读取刚写入的评论,解析 body 中的注释头:
       提取 → { id: "42", nickname: "小红", avatar: "https://blog.cdn/avatar/xiaohong.png" }
[后端] 替换 author 信息后返回给前端:
       { id: "DC_yyy", author: "小红", avatar: "https://blog.cdn/avatar/xiaohong.png", content: "写得太棒了!" }
[前端] 评论区出现小红的评论,显示她的昵称和头像
       用户完全看不出这其实是 App 机器人代发的

✅ 优点:没有 GitHub 账号的用户也能参与评论
⚠️ 代价:GitHub 上看到的是 bot,但前端展示是真实用户


场景三:旧的 Giscus 评论原样保留(迁移无感)

[背景] 博客之前一直用 Giscus,GitHub Discussions 里已有大量旧评论
       旧 Discussion 标题也是 "/posts/hello-world",分类也是 "Announcements"

[用户] 打开文章 → 前端 GET /api/comments?path=/posts/hello-world
[后端] 按 pathname 搜索 → 找到同一个 Discussion
[后端] 拉取 Discussion 下的所有 comments 和 replies
       旧评论的 body 没有 "<!--u:...-->" 注释头(因为之前是 Giscus 发的)
[后端] 无需特殊处理,直接返回原始数据:
       [
         { author: "laowang", content: "沙发!", replies: [...] },
         { author: "zhang3", content: "好文!", replies: [...] },
       ]
[前端] 按普通评论渲染,显示 GitHub 用户本人的身份

[混排效果] 同一篇文章下的评论区:
         — laowang:沙发!              ← 旧 Giscus 评论(无注释头)
         — 小明:好文章!               ← 场景一(GitHub 用户新评论)
         — 小红:写得太棒了!            ← 场景二(密码用户,前端解析后显示)
[用户视角] 三条评论排在一起,看不出有任何迁移痕迹

✅ 向后兼容:无需迁移数据,无需修改旧 Discussion,一切开箱即用


删评论:手工级联

GitHub API 不会自动删回复,需要三步走:

查回复 → 逐条删回复 → 删父评论

核心也是两个 GraphQL 调用——先 queryreplies,再循环 deleteDiscussionComment

怎么把上面的流程变成代码?

上面给出了所有关键的 GraphQL 查询和变更。如果你不想手写代码,直接把这几段 GraphQL 丢给 AI(如 Codex、ChatGPT、Claude),告诉它你要在 Cloudflare Workers 里用 TypeScript 实现,它就能帮你生成完整的函数实现——包括 JWT 签名、API 封装、错误处理等。

我就是这么写的。


GitHub API 的局限性

在实际开发中,我遇到了两个 GitHub API 无法直接解决的问题:

1. Emoji 表情

Discussions 的 addDiscussionComment Mutation 不支持添加 Reaction 表情。虽然有 addReaction Mutation,但它是针对 Issue/PR 的,Discussions 走的是另一套机制,且无法自定义表情种类。

解决:放弃 GitHub API,在自己数据库中维护一套表情系统,前后端自己实现。

2. 点赞功能

GitHub 明确禁止 App Token 调用 addUpvote Mutation,即使赋了 Read and write 权限也不行。用户 Token 虽然可以点赞,但无法给 App 代发的评论点赞。

解决:点赞系统完全由自己的数据库维护,与 GitHub Discussions 解耦。


展示效果

最终效果如下:

image.png


途中遇到的问题汇总

问题 原因 解决
Installation Token 过期 Token 有效期仅 1 小时 在 Worker 中缓存 Token,过期前刷新
JWT 签名失败 Private Key 格式不对 / 算法不匹配 确保使用 RS256,PEM 格式含完整头尾标记
App 权限更新后不生效 GitHub 需要用户重新审核 到个人 Settings → Applications → Installed GitHub Apps → Configure → 重新同意权限
addUpvote 返回权限不足 GitHub 限制 App Token 调此接口 改用自有数据库维护点赞
删除评论后回复成孤儿 GitHub API 不级联删除 先查出所有回复再逐条删除
Discussion 搜索不到 搜索索引有延迟 等待几秒后重试,或确认 pathname 与页面路径完全一致

参考资料


🌐 欢迎关注我的其他平台:

📧 联系我:mail@lxpavilion.top

posted @ 2026-07-16 20:30  PC2005-cloud  阅读(5)  评论(0)    收藏  举报