为Hugo博客添加评论和统计功能

使用waline实现评论功能,浏览统计,评论统计

参考下面的博客和官方文档:

  1. https://blog.reincarnatey.net/2024/0719-better-waline
  2. https://www.liuhouliang.com/post/pageview/
  3. https://waline.js.org/zh-CN/

我的博客主题是 Stack, 其他主题应该也大同小异.

后端和管理后台安装

参考 官方的vercel安装教程即可, 需要自定义域名国内才可以访问.

hugo config 配置

我的是toml格式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[params.comments]
enabled = true # 评论系统
provider = "waline"

# https://waline.js.org/guide/get-started/
[params.comments.waline]
serverURL = "填你实际的URL"
emoji = [
  "https://unpkg.com/@waline/emojis@1.2.0/tieba",
  "https://unpkg.com/@waline/emojis@1.2.0/bilibili",
  "https://unpkg.com/@waline/emojis@1.2.0/bmoji",
  "https://unpkg.com/@waline/emojis@1.2.0/weibo",
]
imageUploader = false
lang = "en"
meta = ["nick", "mail"]
pageview = false # 浏览量统计,使用api而不是内置
comment = false # 评论数统计,使用api而不是内置
reaction = [
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_heart_eyes.png",
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_thumbsup.png",
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_zhoumei.png",
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_grievance.png",
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_dizzy_face.png",
  "https://npm.elemecdn.com/@waline/emojis@1.1.0/bilibili/bb_slap.png",
]
requiredMetaNames = ["nick", "mail"]
highlighter = true

[params.comments.waline.locale]
admin = "Admin"
placeholder = "Welcome to leave your valuable comments!\nPlease provide a correct email address so that we can send you an email notification when there is a reply.\nDo not post any content unrelated to this article."
reactionTitle = "Was this article helpful to you?"
reaction0 = "Very helpful"
reaction1 = "Helpful"
reaction2 = "Average"
reaction3 = "Not helpful"
reaction4 = "Confused"
reaction5 = "Incorrect"

更新waline模板到v3

Stack 默认的是v2

layouts\partials\comments\provider\waline.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css"/>
<div id="waline" class="waline-container"></div>
<style>
    .waline-container {
        background-color: var(--card-background);
        border-radius: var(--card-border-radius);
        box-shadow: var(--shadow-l1);
        padding: var(--card-padding);
        --waline-font-size: var(--article-font-size);
    }
    .waline-container .wl-count {
        color: var(--card-text-color-main);
    }
</style>

{{- $showReaction := (default true .Params.reaction) -}}
{{- with .Site.Params.comments.waline -}}
{{- $config := dict "el" "#waline" "dark" `html[data-scheme="dark"]` -}}
{{- $replaceKeys := dict "serverurl" "serverURL" "requiredmeta" "requiredMeta" "wordlimit" "wordLimit" "pagesize" "pageSize" "imageuploader" "imageUploader" "texrenderer" "texRenderer" "commentsorting" "commentSorting" "recaptchav3key" "recaptchaV3Key" "turnstilekey" "turnstileKey" -}}
{{- $replaceLocaleKeys := dict "reactiontitle" "reactionTitle" "gifsearchplaceholder" "gifSearchPlaceholder" "nickerror" "nickError" "mailerror" "mailError" "wordhint" "wordHint" "cancellike" "cancelLike" "cancelreply" "cancelReply" "uploadimage" "uploadImage" -}}

{{- range $key, $val := . -}}
    {{- if ne $val nil -}}  
        {{- $replaceKey := index $replaceKeys $key -}}
        {{- $k := default $key $replaceKey -}}

        {{- if eq $k "locale" -}}
            {{- $locale := dict -}}
            {{- range $lkey, $lval := $val -}}
                {{- if ne $lval nil -}}  
                    {{- $replaceLKey := index $replaceLocaleKeys $lkey -}}
                    {{- $lk := default $lkey $replaceLKey -}}

                    {{- $locale = merge $locale (dict $lk $lval) -}}
                {{- end -}}
            {{- end -}}
            {{- $config = merge $config (dict $k $locale) -}}
        {{- else if eq $k "reaction" -}}
            {{- $config = merge $config (dict $k (cond $showReaction $val false)) -}}
        {{- else -}}
            {{- $config = merge $config (dict $k $val) -}}
        {{- end -}}
    {{- end -}}
{{- end -}}

<script type="module">
    import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
    init({{ $config | jsonify | safeJS }});
</script>
{{- end -}}

当 markdown 文件包含 reaction: false 时, 不会评论中的reaction.

配置邮件通知

以谷歌邮箱为例:

  1. 需要在 Google 邮箱设置中开启 IMAP 功能
  2. 然后在 https://security.google.com/settings/security/apppasswords 中生成应用专用密码
  3. 在 vercel 中添加以下变量
  • SMTP_SERVICE:填 Gmail
  • SMTP_USER:填谷歌邮箱
  • SMTP_PASS:填刚刚获取的应用专用密码,有 16 位,记得删除空格
  • SITE_NAME:填博客的名字
  • SITE_URL:要带 https://,不带最后一个斜杠,例如 https://blog.reincarnatey.net
  • AUTHOR_EMAIL:填waline注册管理员的邮箱

自定义邮件模板

参考: https://www.sarakale.top/blog/posts/537344b2

在vercel中添加以下变量(重新部署即可生效):

  • MAIL_TEMPLATE_ADMIN
1
<div style="background-image: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;"><div style="background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;"><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: #ffe8dd61;box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);margin:auto"><div style="width:100%;color:#9d2850;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;"><p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding: 23px 32px;margin:0;border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #9d2850;"href="{{site.url}}"target="_blank">{{site.name}}</a>上的文章有了新的评论</p></div><div style="margin:40px auto;width:90%;"><p><strong>{{self.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:15px;color:#555555;">{{self.comment|safe}}</div><p>您可以点击<a style="text-decoration:none; color:#cf5c83"href="{{site.postUrl}}"target="_blank">查看回复的完整内容</a></p></div></div></div></div>
  • MAIL_SUBJECT_ADMIN
1
{{site.name | safe}} 上有新评论了
  • MAIL_TEMPLATE
1
<div style="background-image:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;"><div style="background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;"><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:#ffe8dd61;box-shadow:0 1px 5px rgba(0,0,0,0.15);margin:auto"><div style="width:100%;border-radius:10px 10px 0 0;background-image:-moz-linear-gradient(0deg,rgb(67,198,184),rgb(255,209,244));height:66px;background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;color:#9d2850;"><p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding:23px 32px;margin:0;border-radius:10px 10px 0 0;">您在<a style="text-decoration:none;color:#9d2850;"href="{{site.url}}">『{{site.name|safe}}』</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:15px;color:#555555;">{{parent.comment|safe}}</div><p><strong>{{self.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:15px;color:#555555;">{{self.comment|safe}}</div><p>您可以点击<a style="text-decoration:none;color:#cf5c83"href="{{site.postUrl}}"target="_blank">查看回复的完整內容</a>,欢迎再次光临<a style="text-decoration:none;color:#cf5c83"href="{{site.url}}"target="_blank">{{site.name}}</a><hr/><p style="font-size:14px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。<br/>{{site.url}}</div></div></div></div>
  • MAIL_SUBJECT
1
{{parent.nick | safe}},『{{site.name | safe}}』上的评论收到了回复

效果大概如下:

添加评论统计和访问统计

修改文章页模板

layouts/partials/article/components/details.html

在 footer 中添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<div>
    {{ partial "helper/icon" "view" }}
    <time class="article-pageview">
      <span class="waline-pageview-count" data-path="{{ .RelPermalink }}">0</span> visits
    </time>
</div>

<div>
    {{ partial "helper/icon" "comment" }}
    <time class="article-comment">
        <span class="waline-comment-count" data-path="{{.RelPermalink}}">0</span> comments
    </time>
</div>

参考上面的第一篇博客, 顺便把阅读时间更符合中文模式,添加字数统计

替换

1
{{ T "article.readingTime" .ReadingTime }}

为:

1
2
3
{{ $fixedWordCount := add .WordCount 224}}
{{ $ReadingTime := div $fixedWordCount 225 }}
{{ T "article.readingTime" $ReadingTime }}, words {{ .WordCount }}

需要在assets/icons里添加view.svgcomment.svg

修改网站底部模板

layouts/partials/footer/footer.html

找个合适的位置添加

1
visits <span class="waline-index-count breathe" waline-url="{{ .Site.Params.comments.waline.serverURL }}" data-path="{{ .RelPermalink }}" style="font-weight: bold;">0</span>

因为没有使用 waline 自带的统计和计数功能, 所以需要用js实现api调用

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<script>
    (async ()=>{
      let indexCountEle = document.querySelector('.waline-index-count')
      let walineUrl = indexCountEle.getAttribute('waline-url')

      let pageCountEles = document.querySelectorAll('.waline-pageview-count')
      let pagePathNames = Array.from(pageCountEles).map(item => item.getAttribute('data-path'))

      let commentCountEles = document.querySelectorAll('.waline-comment-count')
      let commentPathNames = Array.from(commentCountEles).map(item => item.getAttribute('data-path'))

      // 获取访问统计函数
      function getViewCount (urlList) {
        let url = walineUrl + '/api/article/?path='
        urlList.forEach((item,index) => {
          url += index === 0 ? item :  (',' + item)
        })
        return fetch(url)
        .then(res => res.json())
        .then(res => {
          if(res.errno == 0) {
            return res.data.map(item => item.time)
          } else {
            throw new Error('获取失败')
          }
        })
        .catch(err => {
          return new Promise((resolve,reject) => {
            reject(err)
          })
        })
      }
      // 添加访问统计函数
      function addViewCount (pathname) {
        let data = { path: pathname}
        return fetch(`${walineUrl}/api/article`,{
            method:'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body:JSON.stringify(data)
        })
        .then(res => res.json())
        .then(res => {
          if(res.errno == 0) {
            return res.data[0].time
          } else {
            throw new Error('获取失败')
          }
        })
        .catch(err => {
          return new Promise((resolve,reject) => {
            reject(err)
          })
        })
      }
      // 获取评论统计函数
      function getCommentCount (urlList) {
        let url = walineUrl + '/api/comment?type=count&lang=en&url='
        urlList.forEach((item,index) => {
          url += index === 0 ? item :  (',' + item)
        })
        return fetch(url)
        .then(res => res.json())
        .then(res => {
          if(res.errno == 0) {
            return res.data
          } else {
            throw new Error('获取失败')
          }
        })
        .catch(err => {
          return new Promise((resolve,reject) => {
            reject(err)
          })
        })
      }

      // 获取列表页的访问统计和评论统计
      if(window.location.pathname == '/' || window.location.pathname.includes('/page/')){
        getViewCount(pagePathNames).then(pageCounts => {
          pageCounts.forEach((item,index) => {
            pageCountEles[index].innerText = item
          })
        })
        getCommentCount(commentPathNames).then(commentCounts => {
          console.log('commentCounts',commentCounts)
          commentCounts.forEach((item,index) => {
            commentCountEles[index].innerText = item
          })
        })
      }

      // 更新文章页访问统计,获取评论统计
      if(window.location.pathname.includes('/post/')){
        addViewCount(pagePathNames[0]).then(pageCount => {
          pageCountEles[0].innerText = pageCount
        })
        getCommentCount(commentPathNames).then(commentCounts => {
          commentCounts.forEach((item,index) => {
            commentCountEles[index].innerText = item
          })
        })
      }

      // 更新页面底部访问统计,localStorage记录访问时间和访问统计
      // 没有访问过,往api添加访问记录并更新访问统计
      // 在1小时内访问过,从本地存储拿
      // 超过1小时,重新获取
      let indexCount = localStorage.getItem('indexCount')
      let lastVisitTime = localStorage.getItem('lastVisitTime')
      if (!!lastVisitTime == false) {
        addViewCount('/').then(indexCount => {
          localStorage.setItem('indexCount',indexCount)
          localStorage.setItem('lastVisitTime', Date.now())
          indexCountEle.innerHTML = indexCount
        })
      }
      if(!!lastVisitTime && !!indexCount && Date.now() - lastVisitTime < 3600000 ){
        indexCount = localStorage.getItem('indexCount')
        indexCountEle.innerHTML = indexCount
      }
      if(!!lastVisitTime && Date.now() - lastVisitTime >= 3600000){
        getViewCount(["/"]).then(res => {
          indexCount = res[0]
          localStorage.setItem('indexCount',indexCount)
          localStorage.setItem('lastVisitTime', Date.now())
          indexCountEle.innerHTML = indexCount
        })
      }

      // 样式
      setInterval(() => {
        new Date().getSeconds() % 2 === 0 ? indexCountEle.style.color = 'red' : indexCountEle.style.color = ''
      }, 1000)
    })()
</script>
<style>
    .wl-action[title="GIF"] {
      display: none;
    }
    footer.site-footer {
      padding-bottom: 20px;
    }
</style>
build with Hugo, theme Stack, visits 0