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 权限。