目录

博客从 Jekyll 迁移到 Hugo:Valine评论系统相关问题

最近将博客从 Jekyll 迁移到 Hugo 了,虽然 Valine 评论系统在 Hugo 中可以直接通过配置文件 hugo.toml 进行设置,但在实际使用中发现了一些坑,以下是整理的解决方案。

1. 配置 requiredFields 无效问题

在 Valine 的官方文档中提到,可以通过 requiredFields 配置字段来设置评论时的必填项,比如昵称、邮箱等。然而,在 Hugo 中直接配置后发现该功能无效。

问题原因

Hugo 主题下文件 layouts/partials/comment.html 文件中只针对部分字段进行了处理,而没有对 requiredFields 进行正确处理。

解决方法

comment.html 文件复制到项目中相同目录下,然后增加以下代码:

{{- with $valine.requiredFields -}} {{- $commentConfig = dict "requiredFields" .
| dict "valine" | merge $commentConfig -}} {{- end -}}

2. Gravatar 头像显示问题

在配置头像时发现,随机头像无法正常显示,例如以下头像地址。但是将域名替换为 www.gravatar.com 则可以正常显示。
https://gravatar.loli.net/avatar/d41d8cd98f00b204e9800998ecf8427e?d=wavatar&v=1.5.2

问题原因

Gravatar 的中转服务,gravatar.loli.net 存在处理上的问题,导致随机头像无法显示。

解决方法

由于 Gravatar 的官网在国内访问存在较多问题,更换了另一个 Gravatar 的中转服务: cn.gravatar.com。在配置文件中增加 avatar_cdn 之后,发现这个配置不生效。需要增加类似的处理逻辑,修改 comment.html 文件:

{{- with $valine.avatar_cdn -}} {{- $commentConfig = dict "avatar_cdn" . | dict
"valine" | merge $commentConfig -}} {{- end -}}

修改后,Gravatar 头像和随机头像的显示恢复正常,但邮箱为空时随机头像无法随机。

邮箱为空时头像并不会随机

问题原因

获取随机头像的 MD5 是根据邮箱地址进行生成,而邮箱地址为空时其生成的 MD5 是固定的,这也导致获取的也都是固定头像。

解决方法

valine.min.js 中的 t.get("mail") 修改为 t.get("mail") || t.get("nick"),即邮箱为空时使用昵称计算随机头像。修改后需要将文件发布到 CDN 才能使用,具体步骤不在此赘述。

QQ 头像和昵称获取问题

在 Valine 中,昵称字段输入 QQ 时,预期是可以获取 QQ 头像和昵称的,但是实际测试的时候却无法正确获取。在 Chrome 的调试窗口,可以看到访问第三方的 API 地址的时候会报错:

https://api.qjqq.cn/api/qqinfo?qq=12345

问题原因

就在写该文章的几天前直接在浏览器中访问这个地址还是正常的,不过会因为跨域问题而导致在 Valine 中无法正常使用。今天访问这个地址,发现这个服务已经无法访问了,页面会跳转到一个 API 服务平台奶思猫

解决方法

在网站上搜索后,发现有免费的QQ 信息服务接口。这个 API 的回包结构和 Valine 中使用的 api.qjqq.cn 是不一致的。由于原来的接口已经下线,正确的回包结构目前也不清楚。好在现在有强大的 AI,直接把压缩的 JS 代码丢给 AI,它就能分析出原来处理 QQ 头像和昵称的逻辑。

var a = function (e, t) {
  var n = i.default.store.get(o.QQCacheKey);
  n && n.qq == e
    ? t && t(n)
    : i.default
        .ajax({
          url: "https://api.nsmao.net/api/qq/query?key=xx&qq=" + e,
          method: "get",
        })
        .then(function (e) {
          return e.json();
        })
        .then(function (n) {
          if (200 == n.code) {
            var r = n.data.nick,
              a = n.data.avatar,
              u = {
                nick: r,
                qq: e,
                pic: a,
              };
            i.default.store.set(o.QQCacheKey, u), t && t(u);
          }
        });
};

我们有两种方案分别是在客户端直接修改和在服务端进行中转:

方案 1:修改 Valine 中请求的 url 以及回包解析逻辑。方案修改起来很容易,最大的问题是会暴露了密钥。

  • 修改请求 URL:api.njqq.cn/api/qqinfo?qq=xxx 修改成 https://api.nsmao.net/api/qq/query?key=xx&qq=
  • 修改昵称和头像的获取:{var r=n.name,a=n.imgurl 修改成 {var r=n.data.nick,a=n.data.avatar

方案 2:后台提供中转服务

通过自己的服务器封装中转接口,避免密钥暴露,接口可以丢给 AI 实现,但需要额外的服务维护成本。

下面是用 golang 实现的获取QQ资料的 HTTP 服务,功能就是将奶思猫提供的 QQ 资料的API接口转换成适配Valine的格式。需要设置下密钥和访问端口。

package main

import (
        "encoding/json"
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
        "os"
)

// APIResponse represents the response from the QQ API
type APIResponse struct {
        Code     int    `json:"code"`
        Msg      string `json:"msg"`
        Data     QQData `json:"data"`
        ExecTime float64 `json:"exec_time"`
        IP       string  `json:"ip"`
}

// QQData represents the data field in the API response
type QQData struct {
        Nick      string `json:"nick"`
        Qid       string `json:"qid"`
        RegTime   string `json:"regTime"`
        Level     int    `json:"level"`
        Avatar    string `json:"avatar"`
        Email     string `json:"email"`
        IsVip     bool   `json:"is_vip"`
        IsYearsVip bool  `json:"is_years_vip"`
        VipLevel  int    `json:"vip_level"`
}

// ClientResponse represents the response we'll send to the client
type ClientResponse struct {
        Code   int    `json:"code"`
        Name   string `json:"name"`
        ImgURL string `json:"imgurl"`
        Email  string `json:"email"`
}

func main() {
        // Get the API key from environment variable or use default
        apiKey := os.Getenv("QQ_API_KEY")
        if apiKey == "" {
                apiKey = "MY_KEY" // Default key for development
        }

        http.HandleFunc("/qq_api/qqinfo", func(w http.ResponseWriter, r *http.Request) {
                // Get QQ number from query parameter
                qqNumber := r.URL.Query().Get("qq")
                if qqNumber == "" {
                        http.Error(w, "Missing qq parameter", http.StatusBadRequest)
                        return
                }

                // Construct the API URL
                apiURL := fmt.Sprintf("https://api.nsmao.net/api/qq/query?key=%s&qq=%s", apiKey, qqNumber)

                // Make the request to the QQ API
                resp, err := http.Get(apiURL)
                if err != nil {
                        log.Printf("Error making request to QQ API: %v", err)
                        http.Error(w, "Failed to fetch QQ information", http.StatusInternalServerError)
                        return
                }
                defer resp.Body.Close()

                // Read the response body
                body, err := ioutil.ReadAll(resp.Body)
                if err != nil {
                        log.Printf("Error reading response body: %v", err)
                        http.Error(w, "Failed to read QQ information", http.StatusInternalServerError)
                        return
                }

                // Parse the API response
                var apiResp APIResponse
                if err := json.Unmarshal(body, &apiResp); err != nil {
                        log.Printf("Error parsing API response: %v", err)
                        http.Error(w, "Failed to parse QQ information", http.StatusInternalServerError)
                        return
                }

                // Prepare the client response
                clientResp := ClientResponse{
                        Code:   200,
                        Name:   "",
                        ImgURL: "",
                        Email:  "",
                }

                // Only populate the fields if the API request was successful
                if apiResp.Code == 200 {
                        clientResp.Name = apiResp.Data.Nick
                        clientResp.ImgURL = apiResp.Data.Avatar
                        clientResp.Email = apiResp.Data.Email
                } else {
                        // If the API request failed, pass along the error code
                        clientResp.Code = apiResp.Code
                        log.Printf("API returned error: %s", apiResp.Msg)
                }

                // Set content type header
                w.Header().Set("Content-Type", "application/json")

                // Encode and send the response
                if err := json.NewEncoder(w).Encode(clientResp); err != nil {
                        log.Printf("Error encoding client response: %v", err)
                        http.Error(w, "Failed to encode response", http.StatusInternalServerError)
                        return
                }
        })

        // Start the HTTP server
        port := os.Getenv("PORT")
        if port == "" {
                port = "52101" // Default port
        }

        log.Printf("Starting server on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatalf("Failed to start server: %v", err)
        }
}

另外还需要在 nginx 配置中进行 proxy_pass 的转发配置,这里不再赘述i。

评论数据迁移

如果 URL 没有改变,评论数据是无需处理的。如果 URL 发生了变化,并且你想保留之前的评论数据,那就需要手动进行迁移。 Valine 的所有数据是存储在 LeanCloud 中,可以很方便地通过脚本进行修改。

不过因为我的评论量少得可怜,我就直接手动修改了。

总结

Valine 评论系统现在已经基于处理停止维护的状态了,而且新的版本源友也没有完全开源的服务,存在较大的局限性。未来如果有时间的话,升级 Waline 似乎是一个比较好的选择。Waline 不仅功能更完善,还提供了更好的国内访问体验和开源支持。另外最大的差异其实是 Waline 是需要后台服务,这一点可以说是优点,也可以说是限制。