本文是基于为静态站点添加点赞功能来实现站点的访问统计。
- ✅ 访问统计:自动记录文章访问次数,防刷机制
- ✅ 完全免费:使用 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 获取更详细数据
- 升级到付费计划