部署Twikoo留言板至Vercel和MongoDB

原本用的giscus本身很完美,但是一定要登入Github帳號才能留言,瞬間提高留言門檻。看到中國很多開源自架的留言系統,其中Twikoo可以使用第三方免費資源來部署,安裝步驟和備份檔案都很簡單,非常符合我的需求。裝完之後,幾年前在Disqus備份下來的檔案,稍作修改可順利匯入到Twikoo,留言顯示上沒遇到太大問題。

先參考這兩篇文章把Twikoo後端搞定:

然後要改部落格主題,不會改的話,丟給AI幫你弄好。因為Twikoo功能很多,記得看一下哪些設定參數是你需要的:

注意

本文由 Gemini 3.1 Pro 語言模型產生。

1. 基礎架構與更新機制

Twikoo 是一個採用前後端分離的輕量級留言板。

  • 資料庫:使用 MongoDB Atlas(免費 512MB 方案),負責儲存 comment(留言)與 config(系統設定)。
  • 後端 API:部署於 Vercel。
  • 版本更新:
    1. 開啟 GitHub 儲存庫,編輯 package.json,將 "twikoo-vercel": "latest" 其中的 latest 改成最新版號,點擊 Commit changes,自動觸發 Vercel 部署。
    2. 之後透過 GitHub 儲存庫的 package.json 鎖定 "twikoo-vercel": "1.7.7",並利用 .github/dependabot.yml 每日排程檢查。當有新版時,Dependabot 會發出 PR,手動 Merge 後 Vercel 即會自動重新部署升級。

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,簡易的客製化留言板就會生效了!

效果如下:
Hexo Twikoo

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_TEMPLATEMAIL_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>

效果如下:
Twikoo mail template

5. 防垃圾留言機制(Akismet)

為防禦國外廣告機器人,建議串接 WordPress 官方的 Akismet 服務。

  1. 前往 Akismet 官網註冊。
  2. 選擇 Personal 方案,並將價格拉桿設定為 $0,取得 API Key。
  3. 進入 Twikoo 後台 → 防垃圾 → AKISMET_KEY → 填入 API Key。

6. 人機驗證(CAPTCHA):使用 Cloudflare Turnstile

步驟 6-1:取得 Cloudflare Turnstile 金鑰

  1. 登入你的 Cloudflare 後台。
  2. 在左側主選單找到 Turnstile,點擊進入。
  3. 小工具名稱:隨便填(例如:Twikoo)。
  4. 點擊 新增主機名稱
  5. 新增自訂主機名稱:填入你的正式網域 carlos.mynet.tw
  6. 小工具模式:選擇 受管,送出訊息時才會顯示驗證動畫。
  7. 建立後,你會得到兩串金鑰:Site KeySecret Key,把它們複製下來。

步驟 6-2:將金鑰綁定到 Twikoo

  1. 進入你網站的 Twikoo 留言板後台(點擊右下角齒輪登入)。
  2. 點擊 設定值管理
  3. 往下捲動找到 人機驗證CAPTCHA_PROVIDER → 選擇 Cloudflare Turnstile
  4. 將剛才拿到的 Site KeySecret Key 分別貼上去。
  5. 儲存設定。

實裝後的效果:
一旦設定好,以後任何人(或機器人)按下「送出」按鈕的瞬間,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 後台刪除大量留言非常耗時。

  1. 進入 MongoDB Atlas 後台。
  2. 找到 comment Collection 並直接 Drop(刪除)整個 Collection。
  3. 回到 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 為了方便新手本機測試,在底層原始碼中強制放行了 localhost127.0.0.1 的 CORS 限制(Issue #808)。這導致任何人在網頁原始碼拿到你的 envId 後,都能在他們自己的本機環境呼叫你的資料庫 API。

為了防止 API 遭濫用,可利用 Cloudflare 的 WAF(Web 應用程式防火牆)在最外層攔截這些偽造的本機請求。

步驟 9-1:設定 Cloudflare WAF 規則

  1. 登入 Cloudflare 後台,選擇你的網域。

  2. 前往左側選單:網路安全WAF自訂規則,點擊 建立規則

  3. 規則名稱:可自訂(如 Block Twikoo Localhost Bypass)。

  4. 點擊右側的 編輯運算式,貼上以下精準攔截語法:

    (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,就觸發攔截。)

  5. 然後採取動作…:選擇 封鎖,並點擊右下角 部署

步驟 9-2:預期副作用與驗證(本機測試狀態)

部署此規則後,你自己在電腦上執行 hexo s(localhost:4000)時,留言板也會被防火牆精準擋下。這是正常且預期的防禦效果,你會觀察到以下「空殼現象」:

  • 介面異常:即使無留言,也會多出一個「檢視更多」按鈕。
  • 設定錯亂:前端抓不到資料庫設定,信箱欄位屬性會從「選填」退回系統預設的「必填」。
  • 傳送失敗:嘗試送出留言時,會直接跳出 評論失敗: 0(Network Error,因為跨域請求已被 Cloudflare 切斷)。

部署策略提醒:
此防護只會擋下 localhost127.0.0.1。當你將 Hexo 部署到正式網域時,來源標頭不再是本機,留言板即可正常運作。