跳转到内容
博客
0 次阅读

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

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

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

在点赞功能的基础上,我们可以轻松添加访问统计功能,无需创建新的 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 中:

UPVOTE_RECORD 中:

前端集成示例(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>

功能特点

访问统计:

性能优化:

测试

测试访问统计:

# 记录访问
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 免费额度:

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

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