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 写入
对于个人博客完全够用。如果流量较大,建议:
- 增加防刷时间(如10分钟)
- 使用 Cloudflare Analytics 获取更详细数据
- 升级到付费计划