部署Twikoo留言板至Vercel和MongoDB
原本用的giscus本身很完美,但是一定要登入Github帳號才能留言,瞬間提高留言門檻。看到中國很多開源自架的留言系統,其中Twikoo可以使用第三方免費資源來部署,安裝步驟和備份檔案都很簡單,非常符合我的需求。裝完之後,幾年前在Disqus備份下來的檔案,稍作修改可順利匯入到Twikoo,留言顯示上沒遇到太大問題。
先參考這兩篇文章把Twikoo後端搞定:
然後要改部落格主題,不會改的話,丟給AI幫你弄好。因為Twikoo功能很多,記得看一下哪些設定參數是你需要的:
注意
本文由 Gemini 3.1 Pro 語言模型產生。
1. 基礎架構與更新機制
Twikoo 是一個採用前後端分離的輕量級留言板。
- 資料庫:使用 MongoDB Atlas(免費 512MB 方案),負責儲存
comment(留言)與config(系統設定)。 - 後端 API:部署於 Vercel。
- 版本更新:
- 開啟 GitHub 儲存庫,編輯
package.json,將"twikoo-vercel": "latest"其中的latest改成最新版號,點擊 Commit changes,自動觸發 Vercel 部署。 - 之後透過 GitHub 儲存庫的
package.json鎖定"twikoo-vercel": "1.7.7",並利用.github/dependabot.yml每日排程檢查。當有新版時,Dependabot 會發出 PR,手動 Merge 後 Vercel 即會自動重新部署升級。
- 開啟 GitHub 儲存庫,編輯
2. Hexo 前端整合與模組化
為了方便未來升級與管理,將 Twikoo 的變數抽離至 Hexo 的主題設定檔中。
步驟 2-1:修改 _config.yml
在 themes/light/_config.yml 加入以下設定:
# Twikoo 留言板設定
twikoo:
enable: true
version: 1.7.7 # 前端版號(需與 Vercel 後端版本保持一致)
integrity: sha256-2Y+BF1fgOp9gzaxC/SUjynWiIGOWdGf+3JDxYYP5joM= # 數值需與 twikoo 對應的版本一致,可到這邊查詢:https://www.jsdelivr.com/package/npm/twikoo?tab=files&path=dist
envId: https://comment.mynet.tw
步驟 2-2:修改 comment.ejs
在 themes/light/layout/_partial/comment.ejs 中寫入以下程式碼。
⚠️ 關鍵坑點:必須使用
decodeURI(window.location.pathname),強制將網址解碼為純中文,否則帶有中文字的網址會被編碼成%E6...亂碼,導致前後台路徑比對失敗,留言無法顯示。
<% if (theme.twikoo && theme.twikoo.enable && page.comments){ %>
<section id="comment">
<div class="title"><%= __('comment') %></div>
<div id="tcomment"></div>
<script src="https://cdn.jsdelivr.net/npm/twikoo@<%= theme.twikoo.version %>/dist/twikoo.min.js" integrity="<%= theme.twikoo.integrity %>" crossorigin="anonymous"></script>
<script>
twikoo.init({
envId: '<%= theme.twikoo.envId %>',
el: '#tcomment',
lang: 'zh-TW',
// 確保路徑解碼以正確顯示中文網址的留言
path: decodeURI(window.location.pathname)
})
</script>
</section>
<% } %>
3. 介面客製化(CSS 終極覆蓋)
因 Twikoo 底層採用 Vue.js 與 Element UI 動態渲染,需要利用高權重(!important)與特定的 CSS 選擇器來覆寫預設樣式。最穩定的做法是將自訂 CSS 整合進 Hexo 主題的編譯流程中。
步驟 3-1:建立專屬樣式檔
在你的 Hexo 專案中,建立一個全新的 Stylus 檔案:
路徑:themes/light/source/css/_custom/twikoo.styl
(註:若沒有 _custom 資料夾請自行建立)
步驟 3-2:貼上 CSS
將以下程式碼貼入 twikoo.styl 中並存檔:
/* =========================================
1. 三個小輸入框 (暱稱、郵箱、網址) 標籤替換
========================================= */
/* 把原本前置標籤的字體縮小到 0,讓預設文字隱形 */
.tk-meta-input .el-input-group__prepend {
font-size: 0 !important;
}
/* 利用 ::before 插入我們自訂的文字 */
.tk-meta-input .el-input:nth-child(1) .el-input-group__prepend::before { content: "名稱"; font-size: 14px; }
.tk-meta-input .el-input:nth-child(2) .el-input-group__prepend::before { content: "信箱"; font-size: 14px; }
.tk-meta-input .el-input:nth-child(3) .el-input-group__prepend::before { content: "網站"; font-size: 14px; }
/* =========================================
2. 三個小輸入框「假提示字」零延遲替換與顏色設定
========================================= */
/* 讓原生的提示字徹底透明 (消滅閃爍與衝突) */
.tk-meta-input .el-input__inner::placeholder { color: transparent !important; }
.tk-meta-input .el-input__inner::-webkit-input-placeholder { color: transparent !important; }
.tk-meta-input .el-input__inner::-moz-placeholder { color: transparent !important; }
.tk-meta-input .el-input__inner:-ms-input-placeholder { color: transparent !important; }
/* 讓外層容器可以作為絕對定位的基準 */
.tk-meta-input .el-input {
position: relative;
}
/* 創造我們專屬的「假提示字」 */
.tk-meta-input .el-input::after {
position: absolute;
left: 65px; /* 閃過前面的灰色標籤 */
top: 50%;
transform: translateY(-50%);
pointer-events: none; /* 關鍵:讓滑鼠點擊可以穿透 */
font-size: 13px;
transition: opacity 0.2s ease;
/* 這裡就是你決定那三個提示字顏色深淺的唯一地方!*/
/* 可以自由改成 #555555, #777777 或 #999999 */
color: #777777 !important;
}
/* 分別填入三個框的專屬文字 */
.tk-meta-input .el-input:nth-child(1)::after { content: "(必填)"; }
.tk-meta-input .el-input:nth-child(2)::after { content: "(選填)"; }
.tk-meta-input .el-input:nth-child(3)::after { content: "(選填)"; }
/* 現代 CSS 魔法:當輸入框有打字時,自動隱藏假提示字 */
.tk-meta-input .el-input:has(input:not(:placeholder-shown))::after {
opacity: 0;
}
/* =========================================
3. 按鈕文字替換 (傳送)
========================================= */
/* 讓原本的「傳送」隱形,但保留它佔用的絕對物理空間 */
.tk-submit .el-button--primary span {
visibility: hidden;
position: relative;
display: inline-block; /* 確保 span 擁有完整的區塊屬性以便定位 */
}
/* 利用 ::after 把「送出」完美置中疊在隱形的空位上 */
.tk-submit .el-button--primary span::after {
content: "送出";
visibility: visible;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
white-space: nowrap; /* 確保文字絕對不會掉到下一行 */
}
/* =========================================
4. 隱藏版權宣告
========================================= */
/* 隱藏右下角的 Powered by 版權資訊 */
.tk-copyright,
.tk-footer { display: none !important; }
步驟 3-3:引入主樣式表
打開主題的主樣式檔:
路徑:themes/light/source/css/style.styl
在檔案的合適位置(通常是最後面),加入以下這行程式碼,將剛才寫好的 Twikoo 樣式匯入:
@import '_custom/twikoo'
最後執行 hexo clean && hexo g,簡易的客製化留言板就會生效了!
效果如下:
4. 郵件通知設定(跨越 iCloud SMTP 驗證問題)
iCloud SMTP(smtp.mail.me.com)在第三方應用程式無法以自訂網域(@mynet.tw)發信(會收到550錯誤)。最佳替代方案為使用 Resend(resend.com)進行代發。
步驟 4-1:Resend DNS 設定
在 DNS 代管商(如 Cloudflare)新增 Resend 提供的 TXT 與 CNAME 紀錄。
📝 筆記:Resend 的 SPF 紀錄名稱為
send(子網域),因此不會與原本主網域(@)的 iCloud SPF 紀錄衝突,兩者可完美共存,無須合併。
步驟 4-2:Twikoo 郵件通知參數
- 發送者信箱(SENDER_EMAIL):
do-not-reply@mynet.tw - SMTP 服務名稱(SMTP_SERVICE):(留空)
- SMTP 伺服器(SMTP_HOST):
smtp.resend.com - SMTP 端口(SMTP_PORT):
465 - SMTP 安全協議(SMTP_SECURE):
true - SMTP 使用者(SMTP_USER):
resend - SMTP 密碼(SMTP_PASS):
re_xxxxxx(Resend API Key)
步驟 4-3:繁體中文 HTML 郵件範本
替換預設的簡體中文範本(填入 MAIL_TEMPLATE 與 MAIL_TEMPLATE_ADMIN):
<div style="font-family: Arial, 'PingFang TC', 'Heiti TC', sans-serif; line-height: 1.6; color: #333333; max-width: 600px; margin: 0 auto; padding: 20px;">
<p>您在 <a href="${SITE_URL}" style="color: #007bff; text-decoration: none;">${SITE_NAME}</a> 上的文章有了新的留言:</p>
<p><strong style="color: #000000;">${NICK}</strong> 回覆說:</p>
<div style="background-color: #f9f9f9; border-left: 4px solid #d0d7de; padding: 15px; margin: 20px 0; border-radius: 0 4px 4px 0;">
${COMMENT}
</div>
<p>您可以點擊 <a href="${POST_URL}" style="color: #007bff; text-decoration: none;">查看回覆的完整內容</a></p>
</div>
效果如下:
5. 防垃圾留言機制(Akismet)
為防禦國外廣告機器人,建議串接 WordPress 官方的 Akismet 服務。
- 前往 Akismet 官網註冊。
- 選擇 Personal 方案,並將價格拉桿設定為 $0,取得 API Key。
- 進入 Twikoo 後台 → 防垃圾 →
AKISMET_KEY→ 填入 API Key。
6. 人機驗證(CAPTCHA):使用 Cloudflare Turnstile
步驟 6-1:取得 Cloudflare Turnstile 金鑰
- 登入你的 Cloudflare 後台。
- 在左側主選單找到 Turnstile,點擊進入。
- 小工具名稱:隨便填(例如:
Twikoo)。 - 點擊 新增主機名稱。
- 新增自訂主機名稱:填入你的正式網域
carlos.mynet.tw。 - 小工具模式:選擇 受管,送出訊息時才會顯示驗證動畫。
- 建立後,你會得到兩串金鑰:
Site Key和Secret Key,把它們複製下來。
步驟 6-2:將金鑰綁定到 Twikoo
- 進入你網站的 Twikoo 留言板後台(點擊右下角齒輪登入)。
- 點擊 設定值管理。
- 往下捲動找到 人機驗證 → CAPTCHA_PROVIDER → 選擇 Cloudflare Turnstile。
- 將剛才拿到的
Site Key和Secret Key分別貼上去。 - 儲存設定。
實裝後的效果:
一旦設定好,以後任何人(或機器人)按下「送出」按鈕的瞬間,Twikoo 會先透過 Cloudflare 的 AI 判斷這是不是一個真人操作的瀏覽器環境,這已經能擋下 99% 的洗板機器人了。如果後續還有漏網之魚,再來考慮用 WAF 寫更嚴格的行為速率限制(Rate Limiting)。
7. Disqus 留言完美搬家法
Disqus 匯出的 XML 備份檔通常包含大量無留言的空網址,且文章 <id> 可能缺少開頭的斜線(/),導致 Twikoo 匯入後前台無法對應。
步驟 7-1:使用 Node.js 清洗與修復 XML
建立 clean.js 並執行(node clean.js),此腳本會過濾空留言,並自動補齊 <id> 前方的斜線:
const fs = require('fs');
const xml = fs.readFileSync('disqus.xml', 'utf8');
const activeThreadIds = new Set();
const posts = xml.match(/<post dsq:id="[^"]+">[\s\S]*?<\/post>/g) || [];
for (const post of posts) {
const match = post.match(/<thread dsq:id="(\d+)"/);
if (match) activeThreadIds.add(match[1]);
}
let cleanedXml = xml.replace(/<thread dsq:id="(\d+)">[\s\S]*?<\/thread>/g, (match, id) => {
if (activeThreadIds.has(id)) {
return match.replace(/<id>([^<]+)<\/id>/g, (idMatch, path) => {
if (!path.startsWith('/') && !path.startsWith('http')) return `<id>/${path}</id>`;
return idMatch;
});
}
return '';
});
const finalXml = cleanedXml.replace(/^\s*[\r\n]/gm, '');
fs.writeFileSync('disqus_clean.xml', finalXml, 'utf8');
步驟 7-2:處理重複匯入問題(資料庫大掃除)
如果曾經匯入失敗想重來,直接在 Twikoo 後台刪除大量留言非常耗時。
- 進入 MongoDB Atlas 後台。
- 找到
commentCollection 並直接 Drop(刪除)整個 Collection。 - 回到 Twikoo 後台,重新導入清洗好的
disqus_clean.xml。
8. 系統設定備份(JSON 直接匯出)
Twikoo 所有的系統設定(包含 SMTP、郵件範本、Akismet Key 等)都存放在 MongoDB 的 config Collection 中,且永遠只有一筆資料。
最暴力的備份法:
直接在 MongoDB Atlas 後台打開 config,將那唯一一筆包含 "_id": { "$oid": "..." } 的擴展 JSON 資料完整複製,存成 twikoo_config_backup.json 即可。未來若需重建系統,直接覆蓋貼上即可一秒還原所有設定。
9. 安全防護:使用 Cloudflare WAF 阻擋 API 濫用
Twikoo 為了方便新手本機測試,在底層原始碼中強制放行了 localhost 與 127.0.0.1 的 CORS 限制(Issue #808)。這導致任何人在網頁原始碼拿到你的 envId 後,都能在他們自己的本機環境呼叫你的資料庫 API。
為了防止 API 遭濫用,可利用 Cloudflare 的 WAF(Web 應用程式防火牆)在最外層攔截這些偽造的本機請求。
步驟 9-1:設定 Cloudflare WAF 規則
登入 Cloudflare 後台,選擇你的網域。
前往左側選單:網路安全→ WAF → 自訂規則,點擊 建立規則。
規則名稱:可自訂(如
Block Twikoo Localhost Bypass)。點擊右側的 編輯運算式,貼上以下精準攔截語法:
(http.host eq "comment.mynet.tw") and (any(http.request.headers["origin"][*] contains "localhost") or any(http.request.headers["origin"][*] contains "127.0.0.1"))(邏輯說明:只要請求是打向留言板後端
comment.mynet.tw,且來源標頭 Origin 包含了 localhost 或 127.0.0.1,就觸發攔截。)然後採取動作…:選擇 封鎖,並點擊右下角 部署。
步驟 9-2:預期副作用與驗證(本機測試狀態)
部署此規則後,你自己在電腦上執行 hexo s(localhost:4000)時,留言板也會被防火牆精準擋下。這是正常且預期的防禦效果,你會觀察到以下「空殼現象」:
- 介面異常:即使無留言,也會多出一個「檢視更多」按鈕。
- 設定錯亂:前端抓不到資料庫設定,信箱欄位屬性會從「選填」退回系統預設的「必填」。
- 傳送失敗:嘗試送出留言時,會直接跳出
評論失敗: 0(Network Error,因為跨域請求已被 Cloudflare 切斷)。
部署策略提醒:
此防護只會擋下 localhost 和 127.0.0.1。當你將 Hexo 部署到正式網域時,來源標頭不再是本機,留言板即可正常運作。