feat: 优化 Cloudflare Web Analytics 统计,支持访问次数(Visits)并完善分页查询与错误处理
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-27 13:18:34 +08:00
1 parent e9a5177a2f
commit 375413a58d
6 files changed
+254 -53

No files matched your search

+8
View File
@@ -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`。 统计需要部署环境中的 `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 的响应头: 复用 `@sveltejs/adapter-cloudflare` 自带的 Cache API,通过服务端 hook 统一设置公开 HTML 的响应头:
+4 -3
View File
@@ -13,12 +13,13 @@ export interface TrafficScope {
} }
export interface AudienceAnalytics { export interface AudienceAnalytics {
empty?: boolean;
scopeLabel: string; scopeLabel: string;
days: number; days: number;
pageViews: number | null; pageViews: number | null;
uniqueVisitors: number | null; visits: number | null;
last7Days: { pageViews: number; uniqueVisitors: number } | null; last7Days: { pageViews: number; visits: number } | null;
daily: { date: string; pageViews: number | null; uniqueVisitors: number | null }[]; daily: { date: string; pageViews: number | null; visits: number | null }[];
error?: string; error?: string;
} }
+17 -13
View File
@@ -198,8 +198,8 @@
yAxisID: 'y' yAxisID: 'y'
}, },
{ {
label: '每日独立访客 (UV)', label: '每日访问次数 (Visits)',
data: rows.map((row) => row.uniqueVisitors), data: rows.map((row) => row.visits),
borderColor: '#2196F3', borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)', backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.3, tension: 0.3,
@@ -407,12 +407,18 @@
</div> </div>
</div> </div>
<div class="section-title">页面浏览与独立访客 · 近 30 天趋势</div> <div class="section-title">页面浏览与访问次数 · 近 30 天趋势</div>
{#if audience?.error}<div class="error-state">{audience.error}</div>{/if} {#if audience?.error}<div class="error-state">{audience.error}</div>{/if}
{#if audience?.empty}
<div class="info-alert">
近 30 天暂无 Web Analytics 数据,请检查站点采集配置;新启用统计后请等待数据更新。
</div>
{/if}
<p class="metric-subtitle">按 UTC 日期统计,包含今天。访问次数(Visits)不代表去重访客人数。</p>
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="chart-container"> <div class="chart-container">
<div class="canvas-wrapper"> <div class="canvas-wrapper">
<canvas bind:this={audienceCanvas} aria-label="PV 与每日 UV 趋势图"></canvas> <canvas bind:this={audienceCanvas} aria-label="PV 与每日访问次数趋势图"></canvas>
</div> </div>
</div> </div>
<div class="core-metrics"> <div class="core-metrics">
@@ -463,14 +469,12 @@
></circle></svg ></circle></svg
> >
</span> </span>
独立访客 (UV) 访问次数 (Visits)
</div> </div>
<div class="metric-value"> <div class="metric-value">
{audience?.uniqueVisitors == null || audience.error {audience?.visits == null || audience.error ? '—' : formatNumber(audience.visits)}
? '—'
: formatNumber(audience.uniqueVisitors)}
</div> </div>
<div class="metric-subtitle" title={audience?.scopeLabel}>近 30 天去重访客</div> <div class="metric-subtitle" title={audience?.scopeLabel}>近 30 天累计访问</div>
</div> </div>
<div class="metric-item"> <div class="metric-item">
<div class="metric-title"> <div class="metric-title">
@@ -519,14 +523,14 @@
></circle></svg ></circle></svg
> >
</span> </span>
独立访客 (UV) 访问次数 (Visits)
</div> </div>
<div class="metric-value"> <div class="metric-value">
{audience?.last7Days?.uniqueVisitors == null || audience.error {audience?.last7Days?.visits == null || audience.error
? '—' ? '—'
: formatNumber(audience.last7Days.uniqueVisitors)} : formatNumber(audience.last7Days.visits)}
</div> </div>
<div class="metric-subtitle" title={audience?.scopeLabel}>近 7 天去重访客</div> <div class="metric-subtitle" title={audience?.scopeLabel}>近 7 天累计访问</div>
</div> </div>
</div> </div>
</div> </div>
+165
View File
@@ -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> = () =>
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');
});
});
+57 -35
View File
@@ -41,29 +41,36 @@ async function getSiteTagByToken(
accountId: string, accountId: string,
siteToken: string siteToken: string
): Promise<string> { ): Promise<string> {
if (siteToken.length !== 32) return siteToken; // It's probably already a site_tag const signal = AbortSignal.timeout(15000);
try { let totalPages = 1;
for (let page = 1; page <= totalPages; page++) {
const response = await fetch( 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 { const data = (await response.json()) as {
success: boolean; 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)) { if (!data.success || !Array.isArray(data.result)) {
const matched = data.result.find( throw new Error('Web Analytics 站点列表不可用,请检查账户和 API Token 权限');
(s) => s.site_token === siteToken || s.site_tag === siteToken
);
if (matched) return matched.site_tag;
} }
} catch (e) { const matched = data.result.find(
console.warn('Failed to resolve site_tag from token', e); (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<string | null> { export async function purgePublicCache(platform: App.Platform | undefined): Promise<string | null> {
@@ -206,18 +213,20 @@ export async function fetchAnalytics(
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now) fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
]); ]);
let audiencePromise: Promise<AnalyticsData['audience']> = Promise.resolve(undefined); let audiencePromise: Promise<AnalyticsData['audience']>;
if (accountId && webAnalyticsToken) { if (accountId && webAnalyticsToken) {
audiencePromise = fetchAudience(token, accountId, webAnalyticsToken, now); audiencePromise = fetchAudience(token, accountId, webAnalyticsToken, now);
} else if (!webAnalyticsToken) { } else {
audiencePromise = Promise.resolve({ audiencePromise = Promise.resolve({
scopeLabel: 'Web Analytics (未配置 Token)', scopeLabel: 'Web Analytics (配置不完整)',
days: 30, days: 30,
pageViews: null, pageViews: null,
uniqueVisitors: null, visits: null,
last7Days: null, last7Days: null,
daily: [], daily: [],
error: '请在上方配置 Web Analytics Token / Site Tag 以拉取数据。' error: !webAnalyticsToken
? '请在基本设置中配置 Web Analytics Token 以拉取数据。'
: '统计不可用:请配置 CF_ACCOUNT_ID'
}); });
} }
@@ -292,9 +301,10 @@ async function fetchAudience(
siteTag: string, siteTag: string,
now: Date now: Date
): Promise<AnalyticsData['audience']> { ): Promise<AnalyticsData['audience']> {
const realSiteTag = await getSiteTagByToken(token, accountId, siteTag); // 包含今天,共 30 个 UTC 日期,与图表的日期范围保持一致。
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0]; const startDate = new Date(now.getTime() - 29 * 86400000).toISOString().split('T')[0];
try { try {
const realSiteTag = await getSiteTagByToken(token, accountId, siteTag);
const data = await graphql<{ const data = await graphql<{
viewer: { viewer: {
accounts: { accounts: {
@@ -308,12 +318,12 @@ async function fetchAudience(
}>( }>(
token, token,
` `
query WebAnalytics($account: String!, $site: String!, $date: String!) { query WebAnalytics($account: String!, $site: String!, $date: String!, $end: String!) {
viewer { viewer {
accounts(filter: { accountTag: $account }) { accounts(filter: { accountTag: $account }) {
rumPageloadEventsAdaptiveGroups( rumPageloadEventsAdaptiveGroups(
limit: 30 limit: 30
filter: { siteTag: $site, datetime_geq: $date } filter: { siteTag: $site, datetime_geq: $date, datetime_lt: $end }
orderBy: [date_ASC] orderBy: [date_ASC]
) { ) {
count count
@@ -331,43 +341,55 @@ async function fetchAudience(
{ {
account: accountId, account: accountId,
site: realSiteTag, site: realSiteTag,
date: `${thirtyDaysAgo}T00:00:00Z` date: `${startDate}T00:00:00Z`,
end: now.toISOString()
} }
); );
const rows = data.viewer.accounts[0]?.rumPageloadEventsAdaptiveGroups; const rows = data.viewer.accounts[0]?.rumPageloadEventsAdaptiveGroups;
if (!rows) if (!Array.isArray(rows))
throw new Error('未返回数据,请检查 Token、Account ID 和 Site Tag 是否正确并具有权限'); 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 totalPageViews = 0;
let totalUniqueVisitors = 0; let totalVisits = 0;
let last7DaysPV = 0; let last7DaysPV = 0;
let last7DaysUV = 0; let last7DaysVisits = 0;
const sevenDaysAgoStr = new Date(now.getTime() - 7 * 86400000).toISOString().split('T')[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 dailyMap = new Map(rows.map((row) => [row.dimensions.date, row]));
const daily = Array.from({ length: 30 }, (_, index) => { const daily = Array.from({ length: 30 }, (_, index) => {
const date = new Date(now.getTime() - (29 - index) * 86400000).toISOString().split('T')[0]; const date = new Date(now.getTime() - (29 - index) * 86400000).toISOString().split('T')[0];
const row = dailyMap.get(date); const row = dailyMap.get(date);
const pv = row ? row.count : 0; const pv = row ? row.count : 0;
const uv = row ? row.sum.visits : 0; const visits = row ? row.sum.visits : 0;
totalPageViews += pv; totalPageViews += pv;
totalUniqueVisitors += uv; totalVisits += visits;
if (date >= sevenDaysAgoStr) { if (date >= sevenDaysAgoStr) {
last7DaysPV += pv; last7DaysPV += pv;
last7DaysUV += uv; last7DaysVisits += visits;
} }
return { date, pageViews: pv, uniqueVisitors: uv }; return { date, pageViews: pv, visits };
}); });
return { return {
scopeLabel: 'Web Analytics (RUM)', scopeLabel: 'Web Analytics (RUM)',
days: 30, days: 30,
pageViews: totalPageViews, pageViews: totalPageViews,
uniqueVisitors: totalUniqueVisitors, visits: totalVisits,
last7Days: { last7Days: {
pageViews: last7DaysPV, pageViews: last7DaysPV,
uniqueVisitors: last7DaysUV visits: last7DaysVisits
}, },
daily daily
}; };
@@ -377,7 +399,7 @@ async function fetchAudience(
scopeLabel: 'Web Analytics (RUM)', scopeLabel: 'Web Analytics (RUM)',
days: 30, days: 30,
pageViews: null, pageViews: null,
uniqueVisitors: null, visits: null,
last7Days: null, last7Days: null,
daily: [], daily: [],
error: cause instanceof Error ? cause.message : 'Web Analytics 查询失败' error: cause instanceof Error ? cause.message : 'Web Analytics 查询失败'
+3 -2
View File
@@ -235,8 +235,9 @@
<div class="settings-card full-width" style="margin-top: 25px;"> <div class="settings-card full-width" style="margin-top: 25px;">
<h3>Cloudflare Web Analytics</h3> <h3>Cloudflare Web Analytics</h3>
<p class="desc"> <p class="desc">
配置 Cloudflare Web Analytics 站点 Token。配置后博客将自动采集 PV、UV。如果您通过 Cloudflare 填写 Cloudflare Web Analytics 统计脚本中的 token,用于采集页面浏览量和访问次数。 请勿填写
Pages 部署并在后台一键开启了统计,只需在这里填写对应生成的 Site Tag 即可拉取数据展示。 Site Tag;它是查询标识,不能用于浏览器采集。后台查询还需配置 CF_ACCOUNT_ID, 并确保
CF_API_TOKEN 具有该账户的 Account Settings Read 和 Account Analytics Read 权限。
</p> </p>
<form <form
method="POST" method="POST"