SuuS|

全部
life

为静态站点添加点赞和访问统计功能

| 阅读需 5 分钟 | 0 次阅读

本文介绍如何使用 Cloudflare Workers + KV 为静态网站添加点赞和访问统计功能。基于 hugo-bearneo 的 Upvote 功能扩展而来,支持:

  • 点赞功能:用户对文章点赞,统计点赞数,记录点赞状态
  • 访问统计:自动记录文章访问次数,防刷机制
  • 完全免费:使用 Cloudflare 免费额度
  • 隐私保护:使用哈希保护用户身份
  • 易于部署:无需数据库,几分钟完成部署

部署指南

部署 Worker

注册/登录 Cloudflare 后台,前往 Workers 模块后点击 Create(下图 2 处)。

点击 Create Worker(下图 3 处)。

随便输入一个名称(比如 post-upvote)后点击 Deploy(下图 5 处)。

然后点击 Edit code(下图 6 处)。

删除代码编辑器(下图 7 处)中原有的代码,将本项目 worker.js 中的代码完全复制粘贴到代码编辑器中,点击 Deploy(下图 8 处)。

创建 KV namespace

注册/登录 Cloudflare 后台,前往 KV 模块后点击 Create(下图 10 处)。

随便输入一个名称(比如 upvote-count)后点击 Add(下图 12 处)。

用相同的步骤再创建一个 KV namespace,依然可以随便命名(比如 upvote-record)。

为 Worker 绑定 KV namespace

注册/登录 Cloudflare 后台,前往 Workers 模块后点击进入刚刚创建的 Worker(如本案例中下图 14 处的 post-upvote)。

前往该 Worker 中的 Settings -> Bindings,点击 Add(下图 17 处)。

选择 KV namespace 后输入 Variable name 为 UPVOTE_COUNT,然后选择一个刚刚创建的 KV namespace(比如 upvote-count),随后点击 Save(下图 20 处)。

用相同的步骤再创建一个 Variable name 为 UPVOTE_RECORD,选择刚刚创建的另一个 KV namespace(比如 upvote-record),随后点击 Save。

正确的配置应如下图 21 处,Variable name(即 UPVOTE_COUNTUPVOTE_RECORD)一定不能错。

测试

注册/登录 Cloudflare 后台,前往 Workers 模块后点击进入刚刚创建的 Worker,进入该 Worker 中的 Settings -> Domains & Routes,此处默认启用的 workers.dev 域名后对应的 Value(下图 22 处)即为该 Worker 的域名。

或者直接点击下图 23 处访问该 Worker 的域名。

通过浏览器访问该 Worker 的域名后如果能看到如下图提示即为部署成功。

注意事项

中国境内可能无法顺畅访问 Cloudflare Workers 的 workers.dev 域名,可以通过为该 Worker 添加一个自定义域名解决。

通用集成方法

部署完成后,你可以在任何静态网站中集成这个点赞功能。以下是通用的前端集成方法:

API 接口说明

假设你的 Worker 域名为 https://your-worker.workers.dev,API 提供以下接口:

1. 获取文章点赞数

GET https://your-worker.workers.dev/upvote?postId=your-post-id

2. 点赞/取消点赞

POST https://your-worker.workers.dev/upvote
Content-Type: application/json

{
  "postId": "your-post-id"
}

前端实现示例

以下是一个简单的 HTML + JavaScript 实现示例:

<div class="upvote-container">
  <button id="upvote-btn" class="upvote-button">
    <span class="icon">👍</span>
    <span id="upvote-count">0</span>
  </button>
</div>

<script>
// 配置你的 Worker 域名
const API_URL = 'https://your-worker.workers.dev/upvote';
// 使用文章的唯一标识作为 postId(如 slug、路径等)
const POST_ID = window.location.pathname;

// 获取点赞数
async function getUpvoteCount() {
  try {
    const response = await fetch(`${API_URL}?postId=${encodeURIComponent(POST_ID)}`);
    const data = await response.json();
    
    document.getElementById('upvote-count').textContent = data.count || 0;
    
    // 根据用户是否已点赞更新按钮状态
    const button = document.getElementById('upvote-btn');
    if (data.upvoted) {
      button.classList.add('upvoted');
    }
  } catch (error) {
    console.error('获取点赞数失败:', error);
  }
}

// 点赞/取消点赞
async function toggleUpvote() {
  const button = document.getElementById('upvote-btn');
  button.disabled = true;
  
  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ postId: POST_ID })
    });
    
    const data = await response.json();
    
    // 更新点赞数
    document.getElementById('upvote-count').textContent = data.count || 0;
    
    // 更新按钮状态
    if (data.upvoted) {
      button.classList.add('upvoted');
    } else {
      button.classList.remove('upvoted');
    }
  } catch (error) {
    console.error('点赞操作失败:', error);
  } finally {
    button.disabled = false;
  }
}

// 页面加载时获取点赞数
getUpvoteCount();

// 绑定点击事件
document.getElementById('upvote-btn').addEventListener('click', toggleUpvote);
</script>

<style>
.upvote-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  border: 2px solid #e5e7eb;
  background: white;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  font-size: 16px;
}

.upvote-button:hover {
  border-color: #3b82f6;
  background: #eff6ff;
}

.upvote-button.upvoted {
  border-color: #3b82f6;
  background: #3b82f6;
  color: white;
}

.upvote-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

框架集成示例

React / Next.js

import { useState, useEffect } from 'react';

export default function UpvoteButton({ postId }) {
  const [count, setCount] = useState(0);
  const [upvoted, setUpvoted] = useState(false);
  const [loading, setLoading] = useState(false);
  
  const API_URL = 'https://your-worker.workers.dev/upvote';
  
  useEffect(() => {
    fetchUpvoteData();
  }, [postId]);
  
  async function fetchUpvoteData() {
    try {
      const response = await fetch(`${API_URL}?postId=${encodeURIComponent(postId)}`);
      const data = await response.json();
      setCount(data.count || 0);
      setUpvoted(data.upvoted || false);
    } catch (error) {
      console.error('获取点赞数失败:', error);
    }
  }
  
  async function handleUpvote() {
    setLoading(true);
    try {
      const response = await fetch(API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ postId })
      });
      const data = await response.json();
      setCount(data.count || 0);
      setUpvoted(data.upvoted || false);
    } catch (error) {
      console.error('点赞操作失败:', error);
    } finally {
      setLoading(false);
    }
  }
  
  return (
    <button 
      onClick={handleUpvote} 
      disabled={loading}
      className={upvoted ? 'upvoted' : ''}
    >
      <span>👍</span>
      <span>{count}</span>
    </button>
  );
}

Vue / Nuxt

<template>
  <button 
    @click="toggleUpvote" 
    :disabled="loading"
    :class="{ upvoted }"
    class="upvote-button"
  >
    <span>👍</span>
    <span>{{ count }}</span>
  </button>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const props = defineProps({
  postId: String
});

const API_URL = 'https://your-worker.workers.dev/upvote';
const count = ref(0);
const upvoted = ref(false);
const loading = ref(false);

async function fetchUpvoteData() {
  try {
    const response = await fetch(`${API_URL}?postId=${encodeURIComponent(props.postId)}`);
    const data = await response.json();
    count.value = data.count || 0;
    upvoted.value = data.upvoted || false;
  } catch (error) {
    console.error('获取点赞数失败:', error);
  }
}

async function toggleUpvote() {
  loading.value = true;
  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId: props.postId })
    });
    const data = await response.json();
    count.value = data.count || 0;
    upvoted.value = data.upvoted || false;
  } catch (error) {
    console.error('点赞操作失败:', error);
  } finally {
    loading.value = false;
  }
}

onMounted(() => {
  fetchUpvoteData();
});
</script>

如何在 hugo 中启用 Upvote 功能?

详见 hugo-bearneo 提供的 使用指南

自定义域名配置

如果你想使用自定义域名(推荐),可以在 Cloudflare Workers 的 Settings -> Domains & Routes 中添加自定义域名,这样可以避免 workers.dev 域名在国内访问不畅的问题。

扩展功能:添加访问统计

在点赞功能的基础上,我们可以轻松添加访问统计功能,无需创建新的 KV namespace。

更新 Worker 代码

将以下完整代码替换到你的 Worker 中(包含点赞 + 访问统计):

// Cloudflare Worker - 点赞 + 访问统计
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  const path = url.pathname

  // CORS 头
  const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  }

  // 处理 OPTIONS 请求
  if (request.method === 'OPTIONS') {
    return new Response(null, { headers: corsHeaders })
  }

  // 路由处理
  if (path === '/like' || path === '/upvote') {
    return handleLike(request, corsHeaders)
  } else if (path === '/count') {
    return handleGetCount(request, corsHeaders)
  } else if (path === '/view' || path === '/pageview') {
    return handlePageView(request, corsHeaders)
  } else if (path === '/stats') {
    return handleGetStats(request, corsHeaders)
  } else {
    return new Response(JSON.stringify({
      code: 0,
      message: 'API is running',
      endpoints: {
        like: 'POST /like - 点赞/取消点赞',
        count: 'GET /count?post=xxx - 获取点赞数',
        view: 'POST /view - 记录访问',
        stats: 'GET /stats?post=xxx - 获取统计数据(点赞+访问)'
      }
    }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
}

// 处理点赞
async function handleLike(request, corsHeaders) {
  if (request.method !== 'POST') {
    return new Response(JSON.stringify({ code: 1, message: 'Method not allowed' }), {
      status: 405,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }

  try {
    const body = await request.json()
    const postId = body.postId || body.post
    
    if (!postId) {
      return new Response(JSON.stringify({ code: 1, message: 'Missing postId' }), {
        status: 400,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    }

    // 获取用户标识(IP + User-Agent)
    const ip = request.headers.get('CF-Connecting-IP') || 'unknown'
    const userAgent = request.headers.get('User-Agent') || 'unknown'
    const userId = await hashString(`${ip}-${userAgent}`)

    // 检查是否已点赞
    const recordKey = `like:${postId}:${userId}`
    const hasLiked = await UPVOTE_RECORD.get(recordKey)

    let newCount
    if (hasLiked) {
      // 取消点赞
      await UPVOTE_RECORD.delete(recordKey)
      const countKey = `like:count:${postId}`
      const currentCount = parseInt(await UPVOTE_COUNT.get(countKey) || '0')
      newCount = Math.max(0, currentCount - 1)
      await UPVOTE_COUNT.put(countKey, newCount.toString())
    } else {
      // 点赞
      await UPVOTE_RECORD.put(recordKey, '1', { expirationTtl: 31536000 })
      const countKey = `like:count:${postId}`
      const currentCount = parseInt(await UPVOTE_COUNT.get(countKey) || '0')
      newCount = currentCount + 1
      await UPVOTE_COUNT.put(countKey, newCount.toString())
    }

    return new Response(JSON.stringify({
      code: 0,
      data: {
        count: newCount,
        hasLiked: !hasLiked
      }
    }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  } catch (error) {
    return new Response(JSON.stringify({ code: 1, message: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
}

// 获取点赞数
async function handleGetCount(request, corsHeaders) {
  const url = new URL(request.url)
  const postId = url.searchParams.get('post') || url.searchParams.get('postId')

  if (!postId) {
    return new Response(JSON.stringify({ code: 1, message: 'Missing post parameter' }), {
      status: 400,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }

  try {
    const countKey = `like:count:${postId}`
    const count = parseInt(await UPVOTE_COUNT.get(countKey) || '0')

    const ip = request.headers.get('CF-Connecting-IP') || 'unknown'
    const userAgent = request.headers.get('User-Agent') || 'unknown'
    const userId = await hashString(`${ip}-${userAgent}`)
    const recordKey = `like:${postId}:${userId}`
    const hasLiked = !!(await UPVOTE_RECORD.get(recordKey))

    return new Response(JSON.stringify({
      code: 0,
      data: { count, hasLiked }
    }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  } catch (error) {
    return new Response(JSON.stringify({ code: 1, message: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
}

// 记录页面访问
async function handlePageView(request, corsHeaders) {
  if (request.method !== 'POST') {
    return new Response(JSON.stringify({ code: 1, message: 'Method not allowed' }), {
      status: 405,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }

  try {
    const body = await request.json()
    const postId = body.postId || body.post
    
    if (!postId) {
      return new Response(JSON.stringify({ code: 1, message: 'Missing postId' }), {
        status: 400,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    }

    const ip = request.headers.get('CF-Connecting-IP') || 'unknown'
    const userAgent = request.headers.get('User-Agent') || 'unknown'
    const userId = await hashString(`${ip}-${userAgent}`)

    // 检查是否在5分钟内重复访问(防刷)
    const viewRecordKey = `view:record:${postId}:${userId}`
    const recentView = await UPVOTE_RECORD.get(viewRecordKey)

    if (!recentView) {
      // 记录访问(5分钟内不重复计数)
      await UPVOTE_RECORD.put(viewRecordKey, '1', { expirationTtl: 300 })
      
      // 增加访问计数
      const countKey = `view:count:${postId}`
      const currentCount = parseInt(await UPVOTE_COUNT.get(countKey) || '0')
      const newCount = currentCount + 1
      await UPVOTE_COUNT.put(countKey, newCount.toString())

      return new Response(JSON.stringify({
        code: 0,
        data: { views: newCount, counted: true }
      }), {
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    } else {
      const countKey = `view:count:${postId}`
      const currentCount = parseInt(await UPVOTE_COUNT.get(countKey) || '0')
      
      return new Response(JSON.stringify({
        code: 0,
        data: { views: currentCount, counted: false }
      }), {
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    }
  } catch (error) {
    return new Response(JSON.stringify({ code: 1, message: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
}

// 获取统计数据(点赞 + 访问)
async function handleGetStats(request, corsHeaders) {
  const url = new URL(request.url)
  const postId = url.searchParams.get('post') || url.searchParams.get('postId')

  if (!postId) {
    return new Response(JSON.stringify({ code: 1, message: 'Missing post parameter' }), {
      status: 400,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }

  try {
    const likeCountKey = `like:count:${postId}`
    const likeCount = parseInt(await UPVOTE_COUNT.get(likeCountKey) || '0')

    const viewCountKey = `view:count:${postId}`
    const viewCount = parseInt(await UPVOTE_COUNT.get(viewCountKey) || '0')

    const ip = request.headers.get('CF-Connecting-IP') || 'unknown'
    const userAgent = request.headers.get('User-Agent') || 'unknown'
    const userId = await hashString(`${ip}-${userAgent}`)
    const recordKey = `like:${postId}:${userId}`
    const hasLiked = !!(await UPVOTE_RECORD.get(recordKey))

    return new Response(JSON.stringify({
      code: 0,
      data: { likes: likeCount, views: viewCount, hasLiked }
    }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  } catch (error) {
    return new Response(JSON.stringify({ code: 1, message: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
}

// 哈希函数
async function hashString(str) {
  const encoder = new TextEncoder()
  const data = encoder.encode(str)
  const hashBuffer = await crypto.subtle.digest('SHA-256', data)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

API 接口说明

更新后的 API 提供以下接口:

1. 点赞/取消点赞

POST /like
Content-Type: application/json

{
  "postId": "your-post-id"
}

2. 获取点赞数

GET /count?post=your-post-id

3. 记录页面访问(新增)

POST /view
Content-Type: application/json

{
  "postId": "your-post-id"
}

4. 获取完整统计(新增)

GET /stats?post=your-post-id

返回:

{
  "code": 0,
  "data": {
    "likes": 10,
    "views": 123,
    "hasLiked": false
  }
}

数据存储说明

无需创建新的 KV namespace,通过 Key 前缀区分数据类型:

UPVOTE_COUNT 中:

  • like:count:{postId} - 点赞计数
  • view:count:{postId} - 访问计数

UPVOTE_RECORD 中:

  • like:{postId}:{userId} - 点赞记录(1年过期)
  • view:record:{postId}:{userId} - 访问记录(5分钟过期,防刷)

前端集成示例(Astro)

创建访问统计组件 src/components/ViewCount.astro

---
import { siteConfig } from '../config';

interface Props {
  postId: string;
}

const { postId } = Astro.props;
const apiUrl = siteConfig.like.apiUrl;
---

<span class="view-count" id="view-count-container">
  <span id="view-count">--</span>
  <span>次阅读</span>
</span>

<script is:inline define:vars={{ postId, apiUrl }}>
  (function() {
    let viewRecorded = false;

    function initViewCount() {
      fetchViewCount();
      
      if (!viewRecorded) {
        setTimeout(() => {
          recordPageView();
          viewRecorded = true;
        }, 2000);
      }
    }

    async function fetchViewCount() {
      try {
        const response = await fetch(`${apiUrl}/stats?post=${encodeURIComponent(postId)}`);
        const data = await response.json();
        
        if (data.code === 0) {
          document.getElementById('view-count').textContent = data.data.views || 0;
        }
      } catch (error) {
        console.error('获取访问次数失败:', error);
      }
    }

    async function recordPageView() {
      try {
        await fetch(`${apiUrl}/view`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ postId })
        });
      } catch (error) {
        console.error('记录访问失败:', error);
      }
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', initViewCount);
    } else {
      initViewCount();
    }
    
    document.addEventListener('astro:page-load', () => {
      viewRecorded = false;
      initViewCount();
    });
  })();
</script>

在文章页面使用:

---
import ViewCount from '../../components/ViewCount.astro';
---

<div class="article-meta">
  <time>{formatDate(post.data.date)}</time>
  <span>|</span>
  <span>{readingTime} 分钟</span>
  <span>|</span>
  <ViewCount postId={post.slug} />
</div>

功能特点

访问统计:

  • ✅ 自动记录页面访问
  • ✅ 5分钟内同一用户不重复计数
  • ✅ 使用 SHA-256 哈希保护隐私
  • ✅ 页面加载2秒后记录(避免误计数)

性能优化:

  • ✅ 异步记录,不阻塞页面
  • ✅ 复用现有 KV,零额外成本
  • ✅ 支持 Astro View Transitions

测试

测试访问统计:

# 记录访问
curl -X POST https://your-worker.workers.dev/view \
  -H "Content-Type: application/json" \
  -d '{"postId": "test-post"}'

# 获取统计
curl "https://your-worker.workers.dev/stats?post=test-post"

注意事项

Cloudflare Workers 免费额度:

  • 每天 100,000 次请求
  • 每天 100,000 次 KV 读取
  • 每天 1,000 次 KV 写入

对于个人博客完全够用。如果流量较大,建议:

  1. 增加防刷时间(如10分钟)
  2. 使用 Cloudflare Analytics 获取更详细数据
  3. 升级到付费计划

鸣谢