部署
克隆
git clone git@github.com:twikoojs/twikoo-cloudflare.git安装依赖
npm install
# 或
pnpm install登录到 Cloudflare 账户
npx wrangler login创建 Cloudflare D1 数据库并设置模式
npx wrangler d1 create twikoo从命令输出中复制 database_name 和 database_id ,粘贴到 wrangler.toml 文件中,替换对应的原始值。
设置 Cloudflare D1 架构
npx wrangler r2 bucket create twikoo创建 Cloudflare R2 存储(可选)
npx wrangler r2 bucket create twikoo创建 R2 存储桶主要用于图片上传功能。请注意,创建 R2 存储桶需要绑定银行卡。如果你不需要图片上传,可以跳过此步骤。
若创建了 R2 存储桶,请将返回的 R2 域名更新到 wrangler.toml 文件中的 R2_PUBLIC_URL 字段。
部署 Workers
npx wrangler deploy --minify如果一切顺利,命令行会输出类似 https://twikoo-cloudflare.<your user name>.workers.dev 的地址。
访问该地址,如果配置正确,浏览器中应显示如下 JSON 信息:
{"code":100,"message":"Twikoo 云函数运行正常,请参考 https://twikoo.js.org/frontend.html 完成前端的配置","version":"1.7.11"}
直接访问该地址可能失败,建议绑定自定义域名后使用。
前端配置
以下配置以 Astro 框架为例。
创建组件
在 src\components 目录下创建 TwikooComments.astro ,内容如下:
---
interface Props {
envId: string;
path?: string;
}
const { envId, path } = Astro.props;
---
<!-- 提前加载 Twikoo 的 CSS -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/twikoo@1.7.11/dist/twikoo.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/twikoo@1.7.11/dist/twikoo.css"></noscript>
<!-- 备用的内联样式,确保在 CSS 加载前不会碎掉 -->
<style is:global>
/* 确保评论容器在 CSS 加载前有基本样式 */
#tcomment {
min-height: 200px;
}
#tcomment .tk-comment-container {
opacity: 1 !important;
visibility: visible !important;
}
</style>
<div class="mt-8">
<h2 class="mb-4 text-lg font-medium" style="color: oklch(var(--bc))">评论</h2>
<div
id="tcomment"
class="twikoo-container rounded-lg border p-4 dark:border-zinc-700"
data-env-id={envId}
data-path={path || 'auto'}
>
<div class="flex items-center justify-center py-8 text-zinc-500 dark:text-zinc-400">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mr-2"></div>
正在加载评论...
</div>
</div>
</div>
<!-- 使用 defer 属性延迟执行,确保 DOM 就绪 -->
<script
src="https://cdn.jsdelivr.net/npm/twikoo@1.7.11/dist/twikoo.min.js"
defer
></script>
<!-- 内联脚本处理初始化 -->
<script is:inline>
// 全局变量存储重试次数
window.twikooRetryCount = 0;
window.maxRetries = 20; // 最大重试次数
function waitForTwikoo(callback, retryCount = 0) {
if (typeof window.twikoo !== 'undefined') {
callback();
} else if (retryCount < window.maxRetries) {
setTimeout(() => waitForTwikoo(callback, retryCount + 1), 200);
} else {
console.error('Twikoo failed to load after maximum retries');
showErrorMessage();
}
}
function showErrorMessage() {
const containers = document.querySelectorAll('#tcomment');
containers.forEach(container => {
if (container) {
container.innerHTML = `
<div class="text-center py-8 text-red-500">
<p>评论系统加载失败</p>
<button onclick="location.reload()" class="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">
重新加载
</button>
</div>
`;
}
});
}
function initializeTwikoo() {
const container = document.getElementById('tcomment');
if (!container) {
console.warn('Twikoo container (#tcomment) not found');
return;
}
const envId = container.getAttribute('data-env-id');
const path = container.getAttribute('data-path');
if (!envId) {
console.error('Twikoo envId not provided');
container.innerHTML = '<div class="text-center py-8 text-red-500">评论系统配置错误</div>';
return;
}
try {
// 清理容器
container.innerHTML = '';
// 初始化 Twikoo
window.twikoo.init({
envId: envId,
el: '#tcomment',
path: path === 'auto' ? location.pathname : path,
lang: 'zh-CN'
});
console.log('Twikoo initialized successfully');
} catch (error) {
console.error('Failed to initialize Twikoo:', error);
container.innerHTML = `
<div class="text-center py-8 text-red-500">
<p>评论系统初始化失败</p>
<p class="text-sm mt-1">${error.message}</p>
<button onclick="location.reload()" class="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">
重新加载
</button>
</div>
`;
}
}
function handlePageInit() {
// 重置重试计数
window.twikooRetryCount = 0;
// 等待 Twikoo 脚本加载完成后初始化
waitForTwikoo(initializeTwikoo);
}
// 首次加载处理
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', handlePageInit);
} else {
// DOM 已经准备就绪,延迟一点执行以确保所有脚本都加载完成
setTimeout(handlePageInit, 100);
}
// Astro ViewTransitions 支持
document.addEventListener('astro:after-swap', () => {
console.log('Page swapped, reinitializing Twikoo');
setTimeout(handlePageInit, 150);
});
// 页面可见性变化处理(可选)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
const container = document.getElementById('tcomment');
if (container && container.children.length === 0) {
console.log('Page became visible, checking Twikoo');
setTimeout(handlePageInit, 100);
}
}
});
</script>
<style is:global>
.twikoo-container {
font-family: inherit;
min-height: 200px;
}
.dark .twikoo-container {
background-color: transparent;
}
/* 输入框样式 */
.dark .tk-content textarea,
.dark .tk-input input {
background-color: rgb(39 39 42) !important;
border-color: rgb(63 63 70) !important;
color: rgb(228 228 231) !important;
}
.dark .tk-content textarea:focus,
.dark .tk-input input:focus {
border-color: rgb(96 165 250) !important;
}
/* 按钮样式 */
.dark .tk-submit {
background-color: rgb(24 24 27) !important;
border-color: rgb(63 63 70) !important;
color: rgb(228 228 231) !important;
}
.dark .tk-submit:hover {
background-color: rgb(39 39 42) !important;
}
/* 评论区域样式 */
.dark .tk-comment,
.dark .tk-replies-wrap {
background-color: transparent !important;
border-color: rgb(63 63 70) !important;
}
.dark .tk-comment .tk-main {
color: rgb(228 228 231) !important;
}
.dark .tk-comment .tk-meta span {
color: rgb(161 161 170) !important;
}
/* 链接样式 */
.dark .tk-comment a {
color: rgb(96 165 250) !important;
}
.dark .tk-comment a:hover {
color: rgb(147 197 253) !important;
}
/* 表情包容器 */
.dark .tk-owo-container {
background-color: rgb(39 39 42) !important;
border-color: rgb(63 63 70) !important;
}
/* 标签和额外信息 */
.dark .tk-tag,
.dark .tk-extras {
color: rgb(161 161 170) !important;
}
/* 字体和边框圆角 */
.tk-comment,
.tk-content,
.tk-input {
font-family: 'Geist', system-ui, sans-serif !important;
}
.tk-content textarea,
.tk-input input,
.tk-submit,
.tk-comment,
.tk-owo-container {
border-radius: 0.5rem !important;
}
.tk-comment {
margin-bottom: 1rem !important;
}
/* 加载状态 */
.dark .tk-loading {
color: rgb(161 161 170) !important;
}
/* 加载动画 */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>使用组件
在博客文章模板(如 [...slug].astro )中引入组件:
<TwikooComments
envId="https://example.com/" <!--填你的 R2 的域名,没创建 R2 的话请填写你绑定的自定义域名或 Cloudflare 分配的域名-->
path={`/blog/${slug}`}
/>在其他独立页面(如友链页)中,可以省略 path 参数:
<TwikooComments
envId="https://example.com/" <!--填你的 R2 的域名,没创建 R2 的话请填写你绑定的自定义域名或 Cloudflare 分配的域名-->
/>邮箱提醒配置
注册 Resend 账号
这一步不用教了吧(。・ω・。)。
添加并验证域名
为了使用自己的域名邮箱(如我网站的 blog@email.fanzhuo.xyz ),需要将域名添加到 Resend 并验证。
- 登录 Resend 控制台,点击左侧
Domains - 点击
Add Domain - 输入你的域名(如
email.fanzhuo.xyz),选择区域(推荐选择距离你最近的区域,如Tokyo ap-northeast-1) - 按照提示配置 DNS 记录。如果你的域名托管在 Cloudflare,可以点击
Go to Cloudflare自动配置
添加记录后,DNS 传播通常需要几分钟到一小时。在 Resend Domains 页面查看状态变为 Verified(绿色) 即表示验证成功 。
创建 API Key
点击 API Keys ,点击 Create API Key ;
按照提示填写后,复制并保存 API Key。
在 Twikoo 后台配置邮件通知
进入后台,进入配置管理,点击邮件通知。
按以下内容填写:
| 配置项 | 填写内容(示例) | 说明 |
|---|---|---|
SENDER_EMAIL | blog@email.fanzhuo.xyz | 你刚刚在 Resend 添加的域名邮箱(@前面的内容自定义) |
SENDER_NAME | Fanzhuo's Blog 评论提醒 | 邮件通知标题 |
SMTP_SERVICE | Resend | 邮件通知邮箱服务商 |
SMTP_USER | resend | 任意非空值即可 |
SMTP_PASS | re_xxxxxxxxxxxx | 上一步复制的 API Key |
点击页面底部的保存按钮。
测试邮件功能
在配置页面底部,点击邮件通知测试,输入你的接收邮箱,点击发送测试邮件。
如果收到邮件,说明配置成功了!
邮件模板自定义
在 MAIL_TEMPLATE 字段中,可以自定义邮件模板,支持以下变量:
| 变量 | 说明 |
|---|---|
${SITE_URL} | 网站链接 |
${SITE_NAME} | 网站名称 |
${NICK} | 评论者昵称 |
${COMMENT} | 评论内容 |
${POST_URL} | 文章链接 |
${PARENT_NICK} | 被回复者昵称 |
${PARENT_COMMENT} | 被回复的评论内容 |
这边贴上我的 MAIL_TEMPLATE 和 MAIL_TEMPLATE_ADMIN 。
MAIL_TEMPLATE :
<div
style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">
<div
style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">
<p
style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">
您在<a style="text-decoration:none;color: #ffffff;" href="${SITE_URL}"
target="_blank">${SITE_NAME}</a>上的留言有新回复啦!</p>
</div>
<div style="margin:40px auto;width:90%">
<p>Hi, ${PARENT_NICK} 同学,您曾在文章上发表评论:</p>
<div
style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">
${PARENT_COMMENT}</div>
<p><strong>${NICK}</strong> 给您的回复如下:</p>
<div
style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">
${COMMENT}</div>
<p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整内容</a>,欢迎再次光临<a
style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。</p>
<hr />
<p style="font-size:12px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。</p>
</div>
</div>MAIL_TEMPLATE_ADMIN :
<div
style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">
<div
style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">
<p
style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">
(~ ̄▽ ̄)~<a style="text-decoration:none;color: #ffffff;" href="${SITE_URL}"
target="_blank">${SITE_NAME}</a>上有新评论啦!</p>
</div>
<div style="margin:40px auto;width:90%">
<p>${NICK} 回复说:</p>
<div
style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">
${COMMENT}</div>
<p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整内容</a>。</p>
<hr />
</div>
</div>自定义表情包
Twikoo 默认只有 3 套基础表情包,你可以通过配置 JSON 文件来添加自定义表情包。
快速上手
如果你不想自己折腾,可以直接使用我整理好的 JSON 文件:
https://fanzhuo.xyz/twikoo-emotion.json这个文件包含了2233娘、小电视、微博、贴吧、雪初音、QQ和小黑盒的表情包。
自己配置
在项目中创建一个 JSON 文件。
进入 Twikoo-Magic↗ 。
在 README 中找到你喜欢的表情包,进入该文件夹,复制 JSON 文件里的全部内容到你的 JSON 文件里。
如果需要添加多套表情包,需要将它们合并到一个 JSON 文件中。
合并注意事项:
| 规则 | 说明 |
|---|---|
| 分组之间用逗号分隔 | 每个表情包分组后面加英文逗号 , |
| 最后一个分组不加逗号 | 末尾分组后面不要加逗号 |
| 外层大括号包裹 | 整个文件最外层只有一个 { } |
| 使用双引号 | JSON 要求键名必须用双引号 |
格式示例:
{
"分组1": {
"type": "image",
"container": [...]
},
"分组2": {
"type": "image",
"container": [...]
},
"分组3": {
"type": "image",
"container": [...]
}
}如何使用
打开 Twikoo 后台,进入配置管理,找到插件。
在 EMOTION_CDN 中输入表情 CDN (就是刚刚的 https://fanzhuo.xyz/twikoo-emotion.json 或者你自己创建的JSON文件的地址)
点击页面底部的保存按钮。
数据迁移
从 Waline 到 Twikoo
前言
我原本是用的 Waline 评论系统,教程↗,但是由于我的 Vercel 账户被 paused 了,并且 LeanCloud 官方发布了停服通知,将于 2027 年 1 月 12 日正式关闭所有公众服务。
我尝试迁移到 MongoDB,但是一直出问题,无奈使用了 Twikoo 。
我在 LeanCloud 后台导出了 Waline 的评论数据,那么问题来了,如何将 Waline 的评论数据迁移至 Twikoo 呢?
我找到了这篇教程↗。
程序迁移
把导出来的 jsonl 文件转换为 json 文件
在与评论数据的同个文件夹中创建一个 z.py (起什么名都行)。
代码如下:
import json
lines = []
with open('Comment.0.jsonl', 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
if line.startswith('#'): # ← 跳过注释行
continue
lines.append(json.loads(line))
result = {"data": {"Comment": lines}}
with open('waline_data.json', 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print("✅ 转换完成!生成 waline_data.json")你就会得到一个 waline_data.json 。
把 json 文件转换为符合 Twikoo 格式的文件
Loading repository information...
打开这个项目,下载 main.py 到与评论数据的同个文件夹,或者直接在与评论数据的同个文件夹中创建一个 main.py ,代码如下:
import json
import uuid
import hashlib
import click
from pathlib import Path
from datetime import datetime
from urllib.parse import quote
from markdown import markdown
cidMap = { }
uidMap = { }
res = []
def step_start(prompt):
click.echo(prompt, nl=False)
def step_complete(another_newline=False):
if another_newline:
click.echo(" 完成✅\n")
else:
click.echo(" 完成✅")
def new_uuid():
return str(uuid.uuid4()).replace("-", "")
def md5_encrypt(data):
md5 = hashlib.md5()
md5.update(data.encode("UTF-8"))
return md5.hexdigest()
def iso2unix(iso):
dt = datetime.fromisoformat(iso)
return int(dt.timestamp() * 1000)
def get_converted(item, site_domain, master_mail):
url = quote(item["url"])
return {
"nick": item["nick"],
"mail": item["mail"],
"link": item["link"],
"ua": item["ua"],
"ip": item["ip"],
"url": url,
"comment": markdown(item["comment"].replace("\n", "\n\n")),
"pid": cidMap[item["pid"]] if "pid" in item else None,
"rid": cidMap[item["rid"]] if "rid" in item else None,
"_id": cidMap[item["objectId"]],
"uid": uidMap[item["mail"]],
"mailMd5": md5_encrypt(item["mail"]),
"master": bool(item["mail"] == master_mail),
"href": "https://" + site_domain + url,
"isSpam": bool(item["status"] != "approved"),
"created": iso2unix(item["insertedAt"]),
"updated": iso2unix(item["updatedAt"]),
"top": bool(item["sticky"] > 0) if "sticky" in item else False,
}
def read_waline(read_file):
step_start("读取 Waline 评论数据中……")
with open(read_file, "r", encoding="UTF-8") as f:
waline = json.load(f)["data"]["Comment"]
total = len(waline)
cnt = 0
step_complete()
return waline, total, cnt
def establish_map(waline):
step_start("映射建立中……")
for item in waline:
cidMap[item["objectId"]] = new_uuid()
if item["mail"] not in uidMap:
uidMap[item["mail"]] = new_uuid()
step_complete(True)
def convert_all(site_domain, master_mail, waline, total, cnt):
for item in waline:
cnt += 1
step_start(f"{cnt}/{total}: 正在转换来自 [{item["nick"]}] 的评论……")
res.append(get_converted(item, site_domain, master_mail))
step_complete()
def write_twikoo(write_file):
step_start("\n写入文件中……")
output_path = Path(write_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(write_file, "w", encoding="UTF-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
step_complete()
def main(site_domain, master_mail, master_uid, read_file, write_file):
global uidMap
if master_uid != "":
uidMap = { master_mail: master_uid }
waline, total, cnt = read_waline(read_file)
establish_map(waline)
convert_all(site_domain, master_mail, waline, total, cnt)
write_twikoo(write_file)
def interactive_input():
site_domain = click.prompt("你的站点域名")
bcf = click.prompt("你(博主)有在新的Twikoo评论系统上评论过吗?(y/N)", type=bool, default=False, show_default=False)
if bcf:
master_mail = click.prompt("你的电子邮件")
master_uid = click.prompt("你的 Twikoo UID(可在导出 Twikoo 评论数据后看到)")
else:
master_mail = master_uid = ""
read_file = click.prompt("原 Waline 评论数据文件路径(相对路径,JSON文件)")
write_file = click.prompt("新的 Twikoo 评论数据文件存储路径(相对路径,JSON文件)")
click.echo()
main(site_domain, master_mail, master_uid, read_file, write_file)
if __name__ == '__main__':
click.echo("Program started.\n")
interactive_input()
click.echo("\nProgram ended.")这个程序需提前安装的第三方库 click , Markdown :
pip install click markdown然后直接 python main.py ,按着提示输入就行了(原 waline 数据填刚刚的 waline_data.json )。
但是我在使用过程中发现有问题,会报错,原因是脚本在转换 insertedAt 时,直接传入了字典对象 {"__type":"Date","iso":"..."} ,而不是字符串。需要从字典中提取 iso 字段。
所以建议把第 59 到 60 行的:
"created": iso2unix(item["insertedAt"]),
"updated": iso2unix(item["updatedAt"]),改为:
"created": iso2unix(item["insertedAt"]["iso"] if isinstance(item["insertedAt"], dict) else item["insertedAt"]),
"updated": iso2unix(item["updatedAt"]["iso"] if isinstance(item["updatedAt"], dict) else item["updatedAt"]),最后你会的到一个转换完的 json 文件,打开看看,格式是这样的:
[
{
"nick": "匿名",
"mail": "",
"link": "",
"ua": "Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"ip": "203.123.64.219",
"url": "/blog/dijkstra",
"comment": "<p>加油!</p>",
"pid": null,
"rid": null,
"_id": "4af68593f00d4fe0bb551b695d195e6e",
"uid": "a8d5c853aa874040a093076bb81ed749",
"mailMd5": "d41d8cd98f00b204e9800998ecf8427e",
"master": false,
"href": "https://fanzhuo.xyz/blog/dijkstra",
"isSpam": false,
"created": 1755935243365,
"updated": 1755935244038,
"top": false
}
]注意,如果你的文章地址是中文,那么 url 和 href 会变成类似 https://fanzhuo.xyz/blog/%E8%AF%B4%E8%AF%B4/ 这样的内容,请把 %E8%AF%B4%E8%AF%B4 改为你的文章地址的明文,如这个应改为 https://fanzhuo.xyz/blog/说说/ 。
好了,打开 Twikoo 评论系统后台,选到导入界面,原系统选 Twikoo ,文件选你转换完的 json 文件,导入!
大功告成。
最后
感谢阅读!
参考文献:Twikoo 文档↗、数据迁移之从 waline 到 twikoo↗
评论