如何部署一个支持关键词过滤的评论服务
本文介绍了一个功能全面的评论系统 Remark42,包含内置多种登录方式、完善的评论功能、数据导入和导出、提供多种通知方式、快捷的部署和集成以及用户界面等特点。此外还介绍了如何使用容器化部署 Remark42 服务、启用邮件通知、管理评论以及拓展评论服务。
2023-05-02
Remark42 是什么?
Remark42 是一个开源的轻量级评论系统,支持自主托管。具有以下这些特性:
内置多种登录方式:
- 社交账号登录(Google、Twitter、Facebook、Microsoft、GitHub、Yandex、Patreon、Telegram);
- 邮件登录;
- 匿名访问。
完善的评论功能:
- 多级嵌套评论,支持树形和平面两种展示方式;
- 投票、置顶和验证系统;
- 可排序评论;
- 支持 Markdown 语法。
- 管理员可以删除评论和屏蔽用户;
- 支持拖放上传图片。
支持数据导入和导出:
- 从 Disqus 和 WordPress 导入评论;
- 可将数据导出为 JSON 格式,并自动备份。
提供多种通知方式:
- 管理员可通过 Telegram、Slack、Webhook 和电子邮件接收通知(当有新评论时发送通知);
- 用户可通过电子邮件和 Telegram 接收通知(当有人回复您的评论时即可收到通知)。
快捷的部署和集成
- 所有数据都保存在一个数据文件中,不需要依赖外部数据库;
- 支持容器化部署;
- 提供可以直接在 Linux、Windows 以及 macOS 上运行的可执行文件;
- 集成自动 SSL 工具(nginx-le)。
用户界面:
- 简洁轻量并且支持自定义的用户界面,内置明暗两种主题;
其他功能:
- 用于获取最新评论、跨文章评论的提取器;
- 可以针对单个评论或文章获取 RSS 订阅;
- 单个 Remark42 服务实例可为不同的站点提供评论系统;
- 注重用户隐私。
部署 Remark42
Remark42 可以很容易地部署到自己的网站上。下面介绍如何使用容器化的方式进行 Remark42 服务的部署。在部署 Remark42 服务前,应该在域名的 DNS 控制台为其准备一个域名,并将该域名解析到部署 Remark42 服务的服务器的 IP 地址上。
使用容器化部署 Remark42 服务,最需要注意的是要正确设置使用的环境变量,以开启相应的功能。比如下面这个
docker-compose.yaml
GitHub
version: "3.2"
services:
comment:
image: umputun/remark42:latest
container_name: "comment"
hostname: "comment"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
environment:
- ADMIN_PASSWD=password # 管理员密码
- ADMIN_SHARED_ID=shared_id # 管理员 ID,登录后获取
- ADMIN_SHARED_EMAIL=admin@example.com # 管理员邮箱,用于接收通知
- ALLOWED_HOSTS=example.com # 允许使用的域名
- AUTH_ANON=true # 是否支持匿名用户
- AUTH_EMAIL_FROM=notify@example.com # 发送用户认证邮件的邮箱
- AUTH_GITHUB_CID=github_id # Github 应用 ID
- AUTH_GITHUB_CSEC=gichub_secret # Github 应用 Secret
- DEBUG=true # 调试模式
- NOTIFY_ADMINS=email # 通知方式
- NOTIFY_EMAIL_FROM=notify@example.com # 发送用户认证邮件的邮箱
- REMARK_URL=https://remark42.example.com # Remark42 服务 url
- SECRET=secret # Remark42 secret
- SITE=site_id # Remark42 site id
- SMTP_HOST=smtp.sendgrid.net # SMTP 服务器(SendGrid)
- SMTP_PORT=465 # SMTP 端口(SendGrid)
- SMTP_TLS=true # SMTP 启用 TLS
- SMTP_USERNAME=apikey # SMTP 用户名(SendGrid 直接使用 "apikey")
- SMTP_PASSWORD=password # SMPT 密码(SendGrid 使用 apikey)
ports:
- "8081:8080"
volumes:
- /src/comment/.data:/srv/var
docker-compose.yaml
如果需要支持其他登录方式,需要配置对应的 AUTH_** 环境变量,具体参数可以参考官方文档。此外,Remark42 还支持通过 Slack、Webhook 或 Telegram 进行消息通知。
部署完成后,可以使用以下命令验证 Remark42 服务是否成功部署。请将 https://comment.example.com 替换为 Remark42 服务部署的域名,并将 <site_id> 替换为在 Remark42 中设置的站点 ID 。
curl https://comment.example.com/api/v1/config\?site\=<site_id> | jq .version
# 输出类似如下格式的字符串表示部署成功:
## backend-8357846-20230122T14:59:22
为了嵌入 Remark42 评论系统到站点中,需要在前端添加以下 HTML 代码:
<!-- 评论组件挂载点 -->
<div id="remark42"></div>
<script>
var remark_config = {
host: 'REMARK_URL',
site_id: 'YOUR_SITE_ID',
}
</script>
<script>
!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);
</script>
blog.html
请将 https://comment.example.com 替换为 Remark42 服务部署的域名,YOUR_SITE_ID 替换为在 Remark42 中设置的站点 ID。 对于使用 React 实现的站点,可以参考这个组件。
配置邮件通知
如果希望使用邮件通知,需要先选择一个邮件服务商。在 Remark42 中已经提供了对 Mailgun 和 SendGrid 的直接支持。SendGrid 的免费套餐支持每日发送 100 封的邮件,已经足够日常使用,因此下面的内容以 SendGrid 为主。
首先需要注册一个 SendGrid 账号。完成注册后认证一个发送邮箱。输入相关信息后再在收到的邮件中点击链接完成剩余步骤。创建完发送邮箱后按照下面的步骤为评论服务创建一个
api key
- 点击左侧导航栏的 /
Email API
;Integration Guide
- 选择 ;
SMTP Relay
- 输入 API Key 名称,点击 ,保存生成的
Create Key
。api key
修改
docker-compose.yaml
environments:
- ADMIN_SHARED_EMAIL=admin@example.com # 通知邮件将发送到这个邮箱
- NOTIFY_ADMINS=email
- NOTIFY_EMAIL_FROM=notify@example.com # 认证过的发送邮箱,和 `ADMIN_SHARED_EMAIL` 可以相同
- SMTP_HOST=smtp.sendgrid.net
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=apikey # 不需要修改
- SMTP_PASSWORD=<secret_from_sendgrid> # 填写刚才创建的 `api key`
docker-compose.yaml
完成配置之后,需要重启 Remark42 服务,并添加评论,以验证是否能够成功收到电子邮件通知。如果没有收到邮件,可以检查一下垃圾邮件文件夹,确认邮件是否被误判为垃圾邮件。
评论管理
在 Remark42 中,可以通过设置环境变量 'ADMIN_SHARED_ID' 来指定一个管理员账号。将此账号用于登录评论系统可以执行管理员操作,包括针对单个 URL 禁用评论、删除用户评论、永久/临时禁用用户。
获取 ADMIN_SHARED_ID 的方法如下:
- 访问挂载了评论系统的站点,并通过任意一种非匿名的标准登录方式登录到评论系统;
- 使用开发者工具查找 /api/v1/user?site= API,响应中的 id 字段即为管理员账号的 ID。
除了可以使用管理员界面管理评论,Remark42 还提供了一些 API 接口,可以通过使用这些 API 来拓展评论服务。例如,以下代码将每隔一分钟自动审核评论,如果评论中包含预设的关键词,就会将这些评论删除。这段代码中用到了下面这三个 API 接口:
- 用于获取包含评论的文章链接
GET /api/v1/list?site=site-id&limit=5&skip=2
- 用于获取特定链接下的评论
GET /api/v1/find?site=site-id&url=post-url&sort=fld&format=tree|plain
- 删除特定的评论。这个接口只有 admin 用户可以调用,因此需要设置
DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url
环境变量,并在请求时添加ADMIN_PASSWD
头。Basic Authorization
整个代码可以分为以下几个部分:
- 引入所需的模块,包括文件系统模块、node-cron 模块和 HTTP/HTTPS 模块。
- 定义常量以及读取环境变量,例如词汇存储文件、博客评论 API 地址、博客站点 ID、评论审核管理员账号和密码等。
- 实现工具函数,其中 用于生成 HTTP 基础认证的请求头、
basicAuthorizationHeader
方法用于发起 API 请求。request
- 为了实现自动审核的功能,需要使用 获取所有包含评论的文章的 URL 地址;使用
listPostUrls
用于获取一篇文章中所有可见的评论;使用listActiveComments
删除评论。deleteComments
- 方法的作用是组合上述三个函数,删除那些包含了关键词的评论。
audit
- 最后在 函数中使用
main
库每分钟执行一次cron
函数。audit
const fs = require("fs");
const cron = require("node-cron");
const https = require("https");
const http = require("http");
const WORDS_FILE = process.env.WORDS_FILE;
const REMARK_URL = process.env.REMARK_URL;
const SITE_ID = process.env.SITE_ID;
const REMARK_ADMIN = {
user: process.env.REMARK_ADMIN_USER,
pwd: process.env.REMARK_ADMIN_PWD,
};
const Words = JSON.parse(fs.readFileSync(WORDS_FILE).toString());
function basicAuthorizationHeader(user, pwd) {
const credentials = btoa(`${user}:${pwd}`);
return { Authorization: `Basic ${credentials}` };
}
function request(url, options) {
const _request = url.startsWith("https") ? https.request : http.request;
return new Promise((resolve, reject) => {
const req = _request(url, options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", resolve);
}).on("error", reject);
req.end();
});
}
async function getJSON(url) {
const data = await request(url, { method: "GET" });
return JSON.parse(data);
}
function del(url, headers) {
const options = { method: "DELETE", headers };
return request(url, options);
}
async function listPostUrls() {
const data = await getJSON(`${REMARK_URL}/api/v1/list?site=${SITE_ID}`);
return data.filter((p) => p.count).map((p) => p.url);
}
async function listActiveComments(post_url) {
const url = `${REMARK_URL}/api/v1/find?${new URLSearchParams({
site: SITE_ID,
url: post_url,
format: "plain",
})}`;
const { comments } = await getJSON(url);
return comments
.filter((c) => !c.delete)
.map((c) => ({
id: c.id,
content: c.text,
url: c.locator.url,
}));
}
async function deleteComments(comments) {
const authorization = basicAuthorizationHeader(
REMARK_ADMIN.user,
REMARK_ADMIN.pwd
);
await Promise.all(
comments.map(async (comment) => {
const url = `${REMARK_URL}/api/v1/admin/comment/${
comment.id
}?${new URLSearchParams({ site: SITE_ID, url: comment.url })}`;
const data = await del(url, { ...authorization });
if (data.trim() == "Unauthorized") throw Error("Unauthorized");
return data;
})
);
}
async function audit() {
console.log("=".repeat(30));
console.log("[INFO] start auditing comments");
try {
const urls = await listPostUrls();
let candidates = [];
for await (const url of urls) {
const comments = await listActiveComments(url);
console.log(`[INFO] \t${url} comment count ${comments.length}`);
candidates.push(...comments);
}
candidates = candidates.filter((c) =>
Words.some((w) => new RegExp(w, "gi").test(c.content))
);
await deleteComments(candidates);
console.log(`[INFO] \tremoved count ${candidates.length}`);
} catch (e) {
console.log("[ERROR] \tfilter failed -> ", e.message);
}
console.log("[INFO] done");
console.log("=".repeat(30));
}
function main() {
cron.schedule("* * * * *", () => {
audit();
});
}
main();
filter/index.js
一个包含了定时审核功能的部署示例可以参考这个仓库中的代码。