diff --git a/docs/analytics-and-cache.md b/docs/analytics-and-cache.md
index 5e4791a..a7d6f3e 100644
--- a/docs/analytics-and-cache.md
+++ b/docs/analytics-and-cache.md
@@ -11,6 +11,14 @@
统计需要部署环境中的 `CF_API_TOKEN`、`CF_ZONE_ID`,Token 需要对应 Zone 的 Analytics 读取权限。两个域名应属于所配置的 Zone;附件域名若属于另一 Zone,可额外配置 `CF_ASSETS_ZONE_ID`,并授予该 Zone 的读取权限。D1 和 R2 资源统计仍分别使用 `CF_ACCOUNT_ID`、`CF_D1_ID`、`CF_R2_BUCKET`。
+## 页面浏览与访问次数
+
+页面趋势来自 Cloudflare Web Analytics(RUM),与上方 CDN 请求统计分开。基本设置中填写统计脚本里的 `token`(`site_token`),不要填写 `site_tag`,因为该设置同时用于浏览器采集脚本。
+
+后台通过 `/accounts/{account_id}/rum/site_info/list` 分页解析对应的 `site_tag`,再查询 `rumPageloadEventsAdaptiveGroups`。除 `CF_ACCOUNT_ID` 外,`CF_API_TOKEN` 需要该账户的 Account Settings Read(读取站点列表)和 Account Analytics Read(查询统计)权限。解析失败、权限不足或找不到站点时明确显示错误,不将采集 Token 当作 Site Tag 继续查询;查询成功但无记录时显示「暂无数据」。
+
+趋势按 UTC 日期统计,包含今天,共 30 个日期;最近 7 天同样包含今天。PV 使用 `count`,访问次数使用 `sum.visits`,Visits 不代表去重访客人数。已有记录的日期范围内,缺失日期补零;完全没有记录时不生成全零曲线。
+
## 缓存策略与更新
复用 `@sveltejs/adapter-cloudflare` 自带的 Cache API,通过服务端 hook 统一设置公开 HTML 的响应头:
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 66b0205..8bf3305 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -13,12 +13,13 @@ export interface TrafficScope {
}
export interface AudienceAnalytics {
+ empty?: boolean;
scopeLabel: string;
days: number;
pageViews: number | null;
- uniqueVisitors: number | null;
- last7Days: { pageViews: number; uniqueVisitors: number } | null;
- daily: { date: string; pageViews: number | null; uniqueVisitors: number | null }[];
+ visits: number | null;
+ last7Days: { pageViews: number; visits: number } | null;
+ daily: { date: string; pageViews: number | null; visits: number | null }[];
error?: string;
}
diff --git a/src/lib/components/admin/AnalyticsPanel.svelte b/src/lib/components/admin/AnalyticsPanel.svelte
index 9c13409..1d8b2df 100644
--- a/src/lib/components/admin/AnalyticsPanel.svelte
+++ b/src/lib/components/admin/AnalyticsPanel.svelte
@@ -198,8 +198,8 @@
yAxisID: 'y'
},
{
- label: '每日独立访客 (UV)',
- data: rows.map((row) => row.uniqueVisitors),
+ label: '每日访问次数 (Visits)',
+ data: rows.map((row) => row.visits),
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.3,
@@ -407,12 +407,18 @@
-
页面浏览与独立访客 · 近 30 天趋势
+ 页面浏览与访问次数 · 近 30 天趋势
{#if audience?.error}{audience.error}
{/if}
+ {#if audience?.empty}
+
+ 近 30 天暂无 Web Analytics 数据,请检查站点采集配置;新启用统计后请等待数据更新。
+
+ {/if}
+ 按 UTC 日期统计,包含今天。访问次数(Visits)不代表去重访客人数。
@@ -463,14 +469,12 @@
>
- 独立访客 (UV)
+ 访问次数 (Visits)
- {audience?.uniqueVisitors == null || audience.error
- ? '—'
- : formatNumber(audience.uniqueVisitors)}
+ {audience?.visits == null || audience.error ? '—' : formatNumber(audience.visits)}
-
近 30 天去重访客
+
近 30 天累计访问
@@ -519,14 +523,14 @@
>
- 独立访客 (UV)
+ 访问次数 (Visits)
- {audience?.last7Days?.uniqueVisitors == null || audience.error
+ {audience?.last7Days?.visits == null || audience.error
? '—'
- : formatNumber(audience.last7Days.uniqueVisitors)}
+ : formatNumber(audience.last7Days.visits)}
-
近 7 天去重访客
+
近 7 天累计访问
diff --git a/src/lib/server/cloudflare-audience.test.ts b/src/lib/server/cloudflare-audience.test.ts
new file mode 100644
index 0000000..1cc4997
--- /dev/null
+++ b/src/lib/server/cloudflare-audience.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fetchAnalytics } from './cloudflare';
+
+const siteToken = 'a'.repeat(32);
+const siteTag = 'b'.repeat(32);
+const site = { site_token: siteToken, site_tag: siteTag };
+const platform = {
+ env: {
+ CF_API_TOKEN: 'test-token',
+ CF_ZONE_ID: 'zone',
+ CF_ACCOUNT_ID: 'account',
+ CF_DOMAIN: 'https://blog.example.com'
+ }
+} as unknown as App.Platform;
+const row = (date: string, count = 10, visits = 2) => ({
+ count,
+ sum: { visits },
+ dimensions: { date }
+});
+
+function mockAnalytics(
+ list: (url: URL) => Response | Promise = () =>
+ Response.json({ success: true, result: [site] }),
+ rows = [row('2026-09-27')],
+ queryError?: string
+) {
+ const mock = vi.fn(async (url: string, options: RequestInit) => {
+ if (url.includes('/rum/')) return list(new URL(url));
+ const { query } = JSON.parse(String(options.body));
+ if (query.includes('WebAnalytics')) {
+ return queryError
+ ? Response.json({ errors: [{ message: queryError }] })
+ : Response.json({
+ data: { viewer: { accounts: [{ rumPageloadEventsAdaptiveGroups: rows }] } }
+ });
+ }
+ return Response.json({ data: { viewer: { zones: [{ httpRequestsAdaptiveGroups: [] }] } } });
+ });
+ vi.stubGlobal('fetch', mock);
+ return mock;
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-09-27T12:00:00Z'));
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('Web Analytics 站点解析与统计', () => {
+ it('通过正确的分页接口将采集 Token 转换为查询 Site Tag', async () => {
+ const mock = mockAnalytics((url) => {
+ expect(url.pathname).toBe('/client/v4/accounts/account/rum/site_info/list');
+ return Response.json({
+ success: true,
+ result: url.searchParams.get('page') === '2' ? [site] : [],
+ result_info: { total_pages: 2 }
+ });
+ });
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience).toMatchObject({ pageViews: 10, visits: 2 });
+ const requests = mock.mock.calls.filter(([url]) => url.includes('/rum/'));
+ expect(requests).toHaveLength(2);
+ expect(requests[0][1].signal).toBeInstanceOf(AbortSignal);
+ const query = mock.mock.calls
+ .map(([, options]) => options.body && JSON.parse(String(options.body)))
+ .find((body) => body?.query.includes('WebAnalytics'));
+ expect(query.variables).toEqual({
+ account: 'account',
+ site: siteTag,
+ date: '2026-08-29T00:00:00Z',
+ end: '2026-09-27T12:00:00.000Z'
+ });
+ expect(query.query).toContain('datetime_lt: $end');
+ });
+
+ it.each([403, 404])('站点解析 HTTP %i 时显示错误,不用采集 Token 继续查询', async (status) => {
+ const mock = mockAnalytics(() => new Response('', { status }));
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience?.error).toContain(`HTTP ${status}`);
+ expect(audience?.pageViews).toBeNull();
+ expect(audience?.daily).toEqual([]);
+ expect(
+ mock.mock.calls.some(([, options]) => String(options.body).includes('WebAnalytics'))
+ ).toBe(false);
+ });
+
+ it('站点不匹配时明确报错', async () => {
+ mockAnalytics(() => Response.json({ success: true, result: [] }));
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience?.error).toContain('未找到');
+ expect(audience?.pageViews).toBeNull();
+ });
+
+ it('站点列表返回失败状态时明确报错', async () => {
+ mockAnalytics(() => Response.json({ success: false, result: [] }));
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience?.error).toContain('站点列表不可用');
+ });
+
+ it('网络或超时异常只影响访问统计,不导致整个统计 Promise 失败', async () => {
+ mockAnalytics(() => {
+ throw new Error('请求超时');
+ });
+ const result = await fetchAnalytics(platform, undefined, siteToken);
+ expect(result.audience?.error).toBe('请求超时');
+ expect(result.scopes[0].error).toBeUndefined();
+ });
+
+ it('查询返回空数组时显示暂无数据,不生成全零趋势', async () => {
+ mockAnalytics(undefined, []);
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience).toMatchObject({ empty: true, pageViews: null, visits: null, daily: [] });
+ expect(audience?.error).toBeUndefined();
+ });
+
+ it('正确统计 30 个日期和最近 7 个日期,保留当天数据并补齐缺失日期', async () => {
+ mockAnalytics(undefined, [
+ row('2026-08-29'),
+ row('2026-09-20'),
+ row('2026-09-21'),
+ row('2026-09-27', 20, 3)
+ ]);
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience).toMatchObject({
+ pageViews: 50,
+ visits: 9,
+ last7Days: { pageViews: 30, visits: 5 }
+ });
+ expect(audience?.daily).toHaveLength(30);
+ expect(audience?.daily[0].date).toBe('2026-08-29');
+ expect(audience?.daily[1]).toEqual({ date: '2026-08-30', pageViews: 0, visits: 0 });
+ expect(audience?.daily.at(-1)).toEqual({ date: '2026-09-27', pageViews: 20, visits: 3 });
+ });
+
+ it('GraphQL 权限错误不会显示为零', async () => {
+ mockAnalytics(undefined, [], 'not authorized');
+ const { audience } = await fetchAnalytics(platform, undefined, siteToken);
+ expect(audience?.error).toBe('not authorized');
+ expect(audience?.pageViews).toBeNull();
+ });
+
+ it('兼容已保存的 Site Tag,但仍验证该站点属于当前账户', async () => {
+ mockAnalytics();
+ const { audience } = await fetchAnalytics(platform, undefined, siteTag);
+ expect(audience?.pageViews).toBe(10);
+ });
+
+ it('缺少账户配置时明确提示', async () => {
+ mockAnalytics();
+ const { audience } = await fetchAnalytics(
+ {
+ ...platform,
+ env: { ...platform.env, CF_ACCOUNT_ID: undefined }
+ } as unknown as App.Platform,
+ undefined,
+ siteToken
+ );
+ expect(audience?.error).toContain('CF_ACCOUNT_ID');
+ });
+});
diff --git a/src/lib/server/cloudflare.ts b/src/lib/server/cloudflare.ts
index fd4fcd4..6ecf611 100644
--- a/src/lib/server/cloudflare.ts
+++ b/src/lib/server/cloudflare.ts
@@ -41,29 +41,36 @@ async function getSiteTagByToken(
accountId: string,
siteToken: string
): Promise {
- if (siteToken.length !== 32) return siteToken; // It's probably already a site_tag
- try {
+ const signal = AbortSignal.timeout(15000);
+ let totalPages = 1;
+ for (let page = 1; page <= totalPages; page++) {
const response = await fetch(
- `https://api.cloudflare.com/client/v4/accounts/${accountId}/rum/site_info`,
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/rum/site_info/list?page=${page}&per_page=100`,
{
- headers: { Authorization: `Bearer ${apiToken}` }
+ headers: { Authorization: `Bearer ${apiToken}` },
+ signal
}
);
- if (!response.ok) return siteToken;
+ if (!response.ok) {
+ throw new Error(
+ `Web Analytics 站点解析失败(HTTP ${response.status}),请检查 CF_ACCOUNT_ID 和 CF_API_TOKEN 的 Account Settings Read 权限`
+ );
+ }
const data = (await response.json()) as {
success: boolean;
- result: { site_tag: string; site_token: string }[];
+ result?: { site_tag: string; site_token: string }[];
+ result_info?: { total_pages?: number };
};
- if (data.success && Array.isArray(data.result)) {
- const matched = data.result.find(
- (s) => s.site_token === siteToken || s.site_tag === siteToken
- );
- if (matched) return matched.site_tag;
+ if (!data.success || !Array.isArray(data.result)) {
+ throw new Error('Web Analytics 站点列表不可用,请检查账户和 API Token 权限');
}
- } catch (e) {
- console.warn('Failed to resolve site_tag from token', e);
+ const matched = data.result.find(
+ (site) => site.site_token === siteToken || site.site_tag === siteToken
+ );
+ if (matched?.site_tag) return matched.site_tag;
+ totalPages = data.result_info?.total_pages ?? 1;
}
- return siteToken;
+ throw new Error('当前账户中未找到对应的 Web Analytics 站点,请检查站点 Token 和 CF_ACCOUNT_ID');
}
export async function purgePublicCache(platform: App.Platform | undefined): Promise {
@@ -206,18 +213,20 @@ export async function fetchAnalytics(
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
]);
- let audiencePromise: Promise = Promise.resolve(undefined);
+ let audiencePromise: Promise;
if (accountId && webAnalyticsToken) {
audiencePromise = fetchAudience(token, accountId, webAnalyticsToken, now);
- } else if (!webAnalyticsToken) {
+ } else {
audiencePromise = Promise.resolve({
- scopeLabel: 'Web Analytics (未配置 Token)',
+ scopeLabel: 'Web Analytics (配置不完整)',
days: 30,
pageViews: null,
- uniqueVisitors: null,
+ visits: null,
last7Days: null,
daily: [],
- error: '请在上方配置 Web Analytics Token / Site Tag 以拉取数据。'
+ error: !webAnalyticsToken
+ ? '请在基本设置中配置 Web Analytics Token 以拉取数据。'
+ : '统计不可用:请配置 CF_ACCOUNT_ID'
});
}
@@ -292,9 +301,10 @@ async function fetchAudience(
siteTag: string,
now: Date
): Promise {
- const realSiteTag = await getSiteTagByToken(token, accountId, siteTag);
- const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
+ // 包含今天,共 30 个 UTC 日期,与图表的日期范围保持一致。
+ const startDate = new Date(now.getTime() - 29 * 86400000).toISOString().split('T')[0];
try {
+ const realSiteTag = await getSiteTagByToken(token, accountId, siteTag);
const data = await graphql<{
viewer: {
accounts: {
@@ -308,12 +318,12 @@ async function fetchAudience(
}>(
token,
`
- query WebAnalytics($account: String!, $site: String!, $date: String!) {
+ query WebAnalytics($account: String!, $site: String!, $date: String!, $end: String!) {
viewer {
accounts(filter: { accountTag: $account }) {
rumPageloadEventsAdaptiveGroups(
limit: 30
- filter: { siteTag: $site, datetime_geq: $date }
+ filter: { siteTag: $site, datetime_geq: $date, datetime_lt: $end }
orderBy: [date_ASC]
) {
count
@@ -331,43 +341,55 @@ async function fetchAudience(
{
account: accountId,
site: realSiteTag,
- date: `${thirtyDaysAgo}T00:00:00Z`
+ date: `${startDate}T00:00:00Z`,
+ end: now.toISOString()
}
);
const rows = data.viewer.accounts[0]?.rumPageloadEventsAdaptiveGroups;
- if (!rows)
+ if (!Array.isArray(rows))
throw new Error('未返回数据,请检查 Token、Account ID 和 Site Tag 是否正确并具有权限');
+ if (!rows.length) {
+ return {
+ scopeLabel: 'Web Analytics (RUM)',
+ days: 30,
+ pageViews: null,
+ visits: null,
+ last7Days: null,
+ daily: [],
+ empty: true
+ };
+ }
let totalPageViews = 0;
- let totalUniqueVisitors = 0;
+ let totalVisits = 0;
let last7DaysPV = 0;
- let last7DaysUV = 0;
- const sevenDaysAgoStr = new Date(now.getTime() - 7 * 86400000).toISOString().split('T')[0];
+ let last7DaysVisits = 0;
+ const sevenDaysAgoStr = new Date(now.getTime() - 6 * 86400000).toISOString().split('T')[0];
const dailyMap = new Map(rows.map((row) => [row.dimensions.date, row]));
const daily = Array.from({ length: 30 }, (_, index) => {
const date = new Date(now.getTime() - (29 - index) * 86400000).toISOString().split('T')[0];
const row = dailyMap.get(date);
const pv = row ? row.count : 0;
- const uv = row ? row.sum.visits : 0;
+ const visits = row ? row.sum.visits : 0;
totalPageViews += pv;
- totalUniqueVisitors += uv;
+ totalVisits += visits;
if (date >= sevenDaysAgoStr) {
last7DaysPV += pv;
- last7DaysUV += uv;
+ last7DaysVisits += visits;
}
- return { date, pageViews: pv, uniqueVisitors: uv };
+ return { date, pageViews: pv, visits };
});
return {
scopeLabel: 'Web Analytics (RUM)',
days: 30,
pageViews: totalPageViews,
- uniqueVisitors: totalUniqueVisitors,
+ visits: totalVisits,
last7Days: {
pageViews: last7DaysPV,
- uniqueVisitors: last7DaysUV
+ visits: last7DaysVisits
},
daily
};
@@ -377,7 +399,7 @@ async function fetchAudience(
scopeLabel: 'Web Analytics (RUM)',
days: 30,
pageViews: null,
- uniqueVisitors: null,
+ visits: null,
last7Days: null,
daily: [],
error: cause instanceof Error ? cause.message : 'Web Analytics 查询失败'
diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte
index 7b63ae9..d2c6295 100644
--- a/src/routes/admin/settings/+page.svelte
+++ b/src/routes/admin/settings/+page.svelte
@@ -235,8 +235,9 @@
Cloudflare Web Analytics
- 配置 Cloudflare Web Analytics 站点 Token。配置后博客将自动采集 PV、UV。如果您通过 Cloudflare
- Pages 部署并在后台一键开启了统计,只需在这里填写对应生成的 Site Tag 即可拉取数据展示。
+ 填写 Cloudflare Web Analytics 统计脚本中的 token,用于采集页面浏览量和访问次数。 请勿填写
+ Site Tag;它是查询标识,不能用于浏览器采集。后台查询还需配置 CF_ACCOUNT_ID, 并确保
+ CF_API_TOKEN 具有该账户的 Account Settings Read 和 Account Analytics Read 权限。