SuuS|

全部
Tech

使用cf为静态站点添加访问统计

| 2 分钟 | -- 次阅读

本文是基于为静态站点添加点赞功能来实现站点的访问统计。

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

在点赞功能的基础上,我们可以轻松添加访问统计功能,无需创建新的 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. 升级到付费计划