从Disqus迁移到Waline的踩坑笔记
<img src="https://blog.cysi.me/2025/12/migrate-from-disqus-to-waline/comments.jpg" alt="Featured image of post 从Disqus迁移到Waline的踩坑笔记" /><p>我的博客是2022年从Wordpress换成Hugo的,受制于Hugo这种纯静态框架的限制,必须要额外再配备评论系统。当时Waline等产品只能使用Leancloud作为数据库,所以<a class="link" href="https://blog.cysi.me/2022/05/migrate-to-hugo/#%E8%AF%84%E8%AE%BA%E8%BF%81%E7%A7%BB%E5%92%8C%E4%BD%BF%E7%94%A8" target="_blank" rel="noopener" >为了省事直接用的Disqus</a>,后者是可以直接接入Wordpress的,因此可以非常简单地转移到Hugo,并且使用DisqusJS这样的项目并配合反向代理的话体验还不错。但到了现在,即便使用了Vercel的反代,Disqus的体验也很难说好,且我还多次收到反馈说有用户根本没法加载Disqus评论区。为了能有个阳间一点的体验,是时候换个新的了。</p> <h2 id="评论系统的选择">评论系统的选择 </h2><p>我的Hugo主题是Stack,它内置支持了不少评论系统,所以直接在<a class="link" href="https://stack.jimmycai.com/config/comments" target="_blank" rel="noopener" >兼容列表</a>里面选择是最方便的。</p> <p>其实当前评论系统无非两类:</p> <p>一是Giscus、Gitalk这类利用Github Discuss或者Issues的,很方便搭建且无需再配备数据库,使用GitHub登录也一定程度上避免了Spam,但缺点也是GitHub:它必须也只能使用GitHub(或类似平台)登录;另一类比如Twikoo、Waline就支持匿名评论,和WordPress的体验完全一致,但缺点是需要自行配备数据库存储评论。</p> <p>我个人来讲还是更希望使用类似WordPress那样的匿名评论系统,无需登录,仅需留下邮箱和昵称即可直接评论;同时访客也并不一定有GitHub账号。当时使用Disqus其实也有这个原因,因为Disqus其实也是可以不用注册账号就能评论的。</p> <p>基于上面的需求且比较适合国人的就只有Twikoo和Waline了。</p> <p>Twikoo是个新一点的评论系统,使用MongoDB,我个人感觉相较于Waline来讲要更简约简单一点。但有一个问题:太简单了。它没有单独的后台,只能自己找一个使用了Twikoo的页面进入管理面板,且这个面板不能在新页面中打开而是就评论区里面原地启动,显而易见地这个面板的展开面积就很小了。虽然GUI可修改的各种自定义项目很多(但主题都已经适配好了,所以一般来讲是不需要动的),管理起来是真的有点麻烦。如果有个独立面板就更好了。</p> <p>另一个大问题就是,我们都知道这些评论系统是通过文章URL来判断并输出对应文章的评论的,Twikoo配置的文章URL是使用的绝对路径而非相对路径,但我这个博客现在绑定了2个域名,且后续我想把<code>cysi.me</code>这个顶级域名也改成博客本体,如果评论对应的文章URL全是绝对路径(如<code>https://example.com/post/1</code>)而非相对路径(如<code>/post/1</code>),一旦域名更换就必然无法识别。</p> <p>至于Waline,已经存在挺久了,且现在还在活跃更新,用户群也不少。早年它只能用Leancloud数据库,当时因为这个也走了点弯路去用了Disqus,但现在它还支持MongoDB等多种数据库了。我最终也选择使用Waline。</p> <h2 id="搭建和配置waline">搭建和配置Waline </h2><h3 id="使用mongodbvercel搭建">使用MongoDB+Vercel搭建 </h3><p>我使用的是Vercel+MongoDB的组合,其中Vercel的部分可以参考<a class="link" href="https://waline.js.org/guide/get-started/#vercel-%E9%83%A8%E7%BD%B2-%E6%9C%8D%E5%8A%A1%E7%AB%AF" target="_blank" rel="noopener" >官方文档</a>。需要注意的是,官方文档的“快速上手”是基于Leancloud数据库的,如果不想使用Leancloud(比如我这种使用MongoDB的)则需要修改Vercel那边对应的环境变量,具体可以查阅<a class="link" href="https://waline.js.org/guide/database.html" target="_blank" rel="noopener" >这里</a>。</p> <p>总体来讲搭建并不算难,但MongoDB这边会有个坑,MongoDB默认给的URI是<code>mongodb+srv://</code>格式的,可以让客户端自动从DNS获取Seedlist服务器列表,但Waline的MongoDB实现比较老,并不支持<code>+srv</code>自动获取完整服务器列表(如果直接使用新版URI,Waline会出现500错误),必须获取旧版的<code>mongodb://</code>格式链接从而提取出服务器地址。</p> <p><img src="https://blog.cysi.me/2025/12/migrate-from-disqus-to-waline/Mongodb.png" width="1189" height="741" srcset="https://blog.cysi.me/2025/12/migrate-from-disqus-to-waline/Mongodb_hu_ca5eace7ef985747.png 480w, https://blog.cysi.me/2025/12/migrate-from-disqus-to-waline/Mongodb_hu_7cac141463026604.png 1024w" loading="lazy" alt="MongoDB" class="gallery-image" data-flex-grow="160" data-flex-basis="385px" ></p> <p>因此,我们需要在MongoDB面板中,点击Connect,并选择Compass,如上图一样选择“I have MongoDB Compass installed”,版本选择“1.11 or earlier”,你会得到类似这样的URI。</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt">1 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">mongodb://<username>:<db_password>@ac-vm0ftwk-shard-00-00.9cdzdlb.mongodb.net:27017,ac-vm0ftwk-shard-00-01.9cdzdlb.mongodb.net:27017,ac-vm0ftwk-shard-00-02.9cdzdlb.mongodb.net:27017/?replicaSet=atlas-opg9fs-shard-0&ssl=true&authSource=admin </span></span></code></pre></td></tr></table> </div> </div><p>可以看到这上面有3个服务器地址,我们提取出来。然后缝合进Waline官方已经整理好的环境变量里面,并将其设置进Vercel项目内即可。Waline部署完成后,访问<code>https://<your-site-url>/ui</code>即可进入后台并创建用户即可。</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt">1 </span><span class="lnt">2 </span><span class="lnt">3 </span><span class="lnt">4 </span><span class="lnt">5 </span><span class="lnt">6 </span><span class="lnt">7 </span><span class="lnt">8 </span><span class="lnt">9 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">// 服务器列表,记得换上上面提取出来的地址 </span></span><span class="line"><span class="cl">MONGO_HOST=["cluster0-shard-00-00.p4edw.mongodb.net","cluster0-shard-00-01.p4edw.mongodb.net","cluster0-shard-00-02.p4edw.mongodb.net"] </span></span><span class="line"><span class="cl">MONGO_PORT=[27017,27017,27017,27017] </span></span><span class="line"><span class="cl">MONGO_DB=waline </span></span><span class="line"><span class="cl">MONGO_USER=admin //修改为你设置的username </span></span><span class="line"><span class="cl">MONGO_PASSWORD=xxxx </span></span><span class="line"><span class="cl">MONGO_REPLICASET=atlas-12cebf-shard-0 </span></span><span class="line"><span class="cl">MONGO_AUTHSOURCE=admin </span></span><span class="line"><span class="cl">MONGO_OPT_SSL=true </span></span></code></pre></td></tr></table> </div> </div><h3 id="评论迁移">评论迁移 </h3><p>迁移也并不困难,先去Disqus的<a class="link" href="https://disqus.com/admin/discussions/export/" target="_blank" rel="noopener" >导出数据页面</a>导出并下载数据,再在Waline官方提供的<a class="link" href="https://waline.js.org/migration/tool.html" target="_blank" rel="noopener" >迁移助手</a>转换即可,记得要选对Waline的数据库格式。如果是MongoDB,导出的数据是CSV格式。</p> <p>正常情况下,直接在MongoDB那边导入这个CSV数据到Comment数据表里面就行了,MongoDB官方有一个GUI管理工具Compass,可以直接导入CSV文件, 官方也同样提供了<a class="link" href="https://www.mongodb.com/zh-cn/docs/compass/import-export/" target="_blank" rel="noopener" >相应教程</a>。如果不需要修改数据,导入CSV到指定数据库里面就算完成了,这时候评论系统也可以正常使用且旧评论都会正常迁移过去。</p> <h3 id="整理文章">整理文章 </h3><p>但我这里情况比较特殊,我的博客已经跑了10多年了而且期间变过很多次永久链接格式(Permalink),而且还变过网站架构(Wordpress变成Hugo),这一系列操作下来,结果就是我的博客的链接格式是很混乱的,比如有的链接末尾带了<code>/</code>而有的没有。另一个问题是,我之前启用了Hugo的<code>uglyURL</code>功能,它会在博客内所有页面添加<code>.html</code>的后缀,致敬早年SEO的伪装静态文件的做法(实际上是因为我当时WordPress链接就是这样子的格式,为了保留后缀所以启用了这个功能),但这个<code>uglyURL</code>功能<strong>仅对</strong>Hugo设置中的<code>baseURL</code>对应域名才有效,且<code>baseURL</code>仅能设置一个——但我的博客有多个域名,也就是说,我现在<strong>只有</strong><code>https://blog.cysi.me</code>这一个域名底下的URL后缀会带有<code>.html</code>而其他任何域名都不会有,除非我在每个Markdown文件的<code>slug</code>或者<code>url</code>字段都添加该后缀。</p> <p>而Waline这些评论系统是靠文章URL来输出对应URL下的评论内容的,即便它使用的是相对路径,理论上不同域名只要URL结构一样也不会出问题,评论都可以正常在各个域名中正常共享,但基于我上面的情况,还是引发了两个坑:</p> <ol> <li>Waline会把同URL但末尾带或者不带<code>/</code>的链接认为是2个不同文章,同理,带或者不带<code>.html</code>后缀也是会被识别成不同的文章的。Waline实际上提供了一个<a class="link" href="https://waline.js.org/reference/client/props.html#path" target="_blank" rel="noopener" >解决方案</a>,就是在客户端处增加一个<code>path</code>属性,但我添加了这个字段没有起作用,所以我就开始考虑直接修改数据库的内容,此举顺便也是为了整理和梳理我博客混乱的永久链接的格式,将所有文章URL的格式统一。</li> <li>要完全统一URL格式,就必须关闭<code>uglyURL</code>功能,也就说所有的<code>.html</code>后缀都将会被移除,但此时访问带有<code>.html</code>后缀的URL都不会正常跳转并直接报错404。</li> </ol> <p>好在这两个问题解决起来不难,第1个问题,上一步里面使用迁移助手转换得到的CSV文件,可以直接使用编辑器进行编辑,具体步骤就不必赘述了,毕竟现在让AI帮忙也很简单,然后再导入进MongoDB,会比直接在MongoDB上面使用数据库语句要方便点。Hugo默认情况下,文章URL末尾均带有<code>/</code>后缀,因此,我就要使用编辑器将CSV里面的评论URL字段,去除所有不必要的后缀(比如<code>.html</code>)并再在末尾都统一添加上<code>/</code>。</p> <p>接下来处理问题2,关闭了<code>uglyURL</code>之后,实质上我现在的URL格式已经得到统一,但还是得做一个跳转(至少是旧的文章需要做跳转),避免出现404问题。其实Vercel等平台是可以直接跳转的,只需要在<code>vercel.json</code>内设置跳转即可,但我碰巧使用了多个平台(不同域名使用了不同的部署平台),经过考量我决定直接在Markdown的Frontmatter元数据里面设定<code>aliases</code>字段(也就是别名),我们可以在别名中添加<code>.html</code>后缀,这样就可以自动跳转了。这种办法的好处是完全不挑平台支持跳转与否,完全通用。直接让Gemini生成了个Python脚本,这个脚本会递归扫描指定目录,处理所有 .md 文件,并按照此逻辑优先级执行:</p> <ol> <li>有 aliases -> 跳过。</li> <li>无 aliases 但有 url -> 新增 aliases 为 url内容.html。</li> <li>无 aliases 且无 url,但有 slug -> 新增 aliases 为 slug内容.html。</li> </ol> <p>运行该脚本后,我所有文章都会添加一个带有<code>.html</code>后缀的别名,访问后会自动跳转Hugo的标准无后缀的URL上,避免出现404错误。</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt"> 1 </span><span class="lnt"> 2 </span><span class="lnt"> 3 </span><span class="lnt"> 4 </span><span class="lnt"> 5 </span><span class="lnt"> 6 </span><span class="lnt"> 7 </span><span class="lnt"> 8 </span><span class="lnt"> 9 </span><span class="lnt">10 </span><span class="lnt">11 </span><span class="lnt">12 </span><span class="lnt">13 </span><span class="lnt">14 </span><span class="lnt">15 </span><span class="lnt">16 </span><span class="lnt">17 </span><span class="lnt">18 </span><span class="lnt">19 </span><span class="lnt">20 </span><span class="lnt">21 </span><span class="lnt">22 </span><span class="lnt">23 </span><span class="lnt">24 </span><span class="lnt">25 </span><span class="lnt">26 </span><span class="lnt">27 </span><span class="lnt">28 </span><span class="lnt">29 </span><span class="lnt">30 </span><span class="lnt">31 </span><span class="lnt">32 </span><span class="lnt">33 </span><span class="lnt">34 </span><span class="lnt">35 </span><span class="lnt">36 </span><span class="lnt">37 </span><span class="lnt">38 </span><span class="lnt">39 </span><span class="lnt">40 </span><span class="lnt">41 </span><span class="lnt">42 </span><span class="lnt">43 </span><span class="lnt">44 </span><span class="lnt">45 </span><span class="lnt">46 </span><span class="lnt">47 </span><span class="lnt">48 </span><span class="lnt">49 </span><span class="lnt">50 </span><span class="lnt">51 </span><span class="lnt">52 </span><span class="lnt">53 </span><span class="lnt">54 </span><span class="lnt">55 </span><span class="lnt">56 </span><span class="lnt">57 </span><span class="lnt">58 </span><span class="lnt">59 </span><span class="lnt">60 </span><span class="lnt">61 </span><span class="lnt">62 </span><span class="lnt">63 </span><span class="lnt">64 </span><span class="lnt">65 </span><span class="lnt">66 </span><span class="lnt">67 </span><span class="lnt">68 </span><span class="lnt">69 </span><span class="lnt">70 </span><span class="lnt">71 </span><span class="lnt">72 </span><span class="lnt">73 </span><span class="lnt">74 </span><span class="lnt">75 </span><span class="lnt">76 </span><span class="lnt">77 </span><span class="lnt">78 </span><span class="lnt">79 </span><span class="lnt">80 </span><span class="lnt">81 </span><span class="lnt">82 </span><span class="lnt">83 </span><span class="lnt">84 </span><span class="lnt">85 </span><span class="lnt">86 </span><span class="lnt">87 </span><span class="lnt">88 </span><span class="lnt">89 </span><span class="lnt">90 </span><span class="lnt">91 </span><span class="lnt">92 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">import os </span></span><span class="line"><span class="cl">import re </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl">def process_markdown_files(root_dir): </span></span><span class="line"><span class="cl"> # 遍历目录及其子目录 </span></span><span class="line"><span class="cl"> for root, dirs, files in os.walk(root_dir): </span></span><span class="line"><span class="cl"> for file in files: </span></span><span class="line"><span class="cl"> if file.endswith(".md"): </span></span><span class="line"><span class="cl"> file_path = os.path.join(root, file) </span></span><span class="line"><span class="cl"> process_single_file(file_path) </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl">def process_single_file(file_path): </span></span><span class="line"><span class="cl"> try: </span></span><span class="line"><span class="cl"> with open(file_path, 'r', encoding='utf-8') as f: </span></span><span class="line"><span class="cl"> content = f.read() </span></span><span class="line"><span class="cl"> except Exception as e: </span></span><span class="line"><span class="cl"> print(f"❌ 读取错误: {file_path} - {e}") </span></span><span class="line"><span class="cl"> return </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 正则表达式:匹配 Frontmatter (位于文件开头的两个 --- 之间) </span></span><span class="line"><span class="cl"> # re.DOTALL 让 . 也能匹配换行符 </span></span><span class="line"><span class="cl"> fm_pattern = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL) </span></span><span class="line"><span class="cl"> match = fm_pattern.match(content) </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> if not match: </span></span><span class="line"><span class="cl"> # print(f"⚠️ 跳过 (无 Frontmatter): {file_path}") </span></span><span class="line"><span class="cl"> return </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> frontmatter_content = match.group(1) </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 1. 检查是否已经存在 aliases </span></span><span class="line"><span class="cl"> if re.search(r'^aliases:', frontmatter_content, re.MULTILINE): </span></span><span class="line"><span class="cl"> # print(f"⏭️ 跳过 (已有 aliases): {file_path}") </span></span><span class="line"><span class="cl"> return </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 寻找 url 或 slug </span></span><span class="line"><span class="cl"> # 匹配 key: value 格式,并捕获 value 部分(去除首尾空格和引号) </span></span><span class="line"><span class="cl"> url_match = re.search(r'^url:\s*(.+)$', frontmatter_content, re.MULTILINE) </span></span><span class="line"><span class="cl"> slug_match = re.search(r'^slug:\s*(.+)$', frontmatter_content, re.MULTILINE) </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> target_value = None </span></span><span class="line"><span class="cl"> source_field = "" </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 2. 逻辑优先级:先找 url,没有再找 slug </span></span><span class="line"><span class="cl"> if url_match: </span></span><span class="line"><span class="cl"> raw_value = url_match.group(1).strip() </span></span><span class="line"><span class="cl"> # 去除可能存在的引号 </span></span><span class="line"><span class="cl"> target_value = raw_value.strip('"\'') </span></span><span class="line"><span class="cl"> source_field = "url" </span></span><span class="line"><span class="cl"> elif slug_match: </span></span><span class="line"><span class="cl"> raw_value = slug_match.group(1).strip() </span></span><span class="line"><span class="cl"> target_value = raw_value.strip('"\'') </span></span><span class="line"><span class="cl"> source_field = "slug" </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> if target_value: </span></span><span class="line"><span class="cl"> # 3. 构造新的 aliases 字段 </span></span><span class="line"><span class="cl"> # 注意:通常 aliases 是一个数组,为了兼容性,这里写成 aliases: ["value.html"] </span></span><span class="line"><span class="cl"> # 如果你的内容里已经有.html后缀,这里会重复添加吗? </span></span><span class="line"><span class="cl"> # 根据你的要求:“内容并在其末尾添加.html”,这里不做去重检查,直接添加。 </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> new_alias_line = f'aliases: ["{target_value}.html"]' </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 将新字段插入到 frontmatter 的末尾(但在第二个 --- 之前) </span></span><span class="line"><span class="cl"> new_frontmatter = frontmatter_content + "\n" + new_alias_line </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> # 替换原文中的 Frontmatter 部分 </span></span><span class="line"><span class="cl"> # 只替换第一个匹配项 </span></span><span class="line"><span class="cl"> new_content = content.replace(frontmatter_content, new_frontmatter, 1) </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> try: </span></span><span class="line"><span class="cl"> with open(file_path, 'w', encoding='utf-8') as f: </span></span><span class="line"><span class="cl"> f.write(new_content) </span></span><span class="line"><span class="cl"> print(f"✅ 已修改 ({source_field} -> aliases): {file_path}") </span></span><span class="line"><span class="cl"> except Exception as e: </span></span><span class="line"><span class="cl"> print(f"❌ 写入错误: {file_path} - {e}") </span></span><span class="line"><span class="cl"> else: </span></span><span class="line"><span class="cl"> # print(f"⚪ 跳过 (无 url 也无 slug): {file_path}") </span></span><span class="line"><span class="cl"> pass </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl">if __name__ == "__main__": </span></span><span class="line"><span class="cl"> # --- 配置区域 --- </span></span><span class="line"><span class="cl"> # 将下面的路径改为你的文件夹路径 </span></span><span class="line"><span class="cl"> # Windows 示例: r"C:\Users\Name\Documents\Notes" </span></span><span class="line"><span class="cl"> # Mac/Linux 示例: "/Users/name/notes" </span></span><span class="line"><span class="cl"> TARGET_DIR = r"./my_markdown_files" </span></span><span class="line"><span class="cl"> </span></span><span class="line"><span class="cl"> print(f"🚀 开始扫描目录: {TARGET_DIR}") </span></span><span class="line"><span class="cl"> if os.path.exists(TARGET_DIR): </span></span><span class="line"><span class="cl"> process_markdown_files(TARGET_DIR) </span></span><span class="line"><span class="cl"> print("🎉 处理完成!") </span></span><span class="line"><span class="cl"> else: </span></span><span class="line"><span class="cl"> print("❌ 错误: 找不到指定的目录,请检查路径配置。") </span></span></code></pre></td></tr></table> </div> </div><h2 id="完成">完成 </h2><p>至此,整理工作终于完成,文章URL得到了整理和统一,评论系统也正常工作,旧的评论也正常显示。唯一问题是所有评论者的头像(即邮箱)、网站链接都会被抹掉,但可惜的是这个无法解决,因为Disqus的原始数据里面根本就不提供这些信息,且为了隐私保护,Disqus的后台是无法看到用户的邮箱的。</p> <p>其实这次折腾更多是整理博客的结构,当初迁移到Hugo的时候太匆忙,细节上没有做好,现在再擦屁股考虑的事情就多了,不过趁此机会把之前遗留问题都清理了一下,整个Hugo的结构也看起来更干净了,还是不错的。</p>