feat: 优化 Cloudflare Web Analytics 统计,支持访问次数(Visits)并完善分页查询与错误处理
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
1 parent
e9a5177a2f
commit
375413a58d
6 files changed
+253
-52
No files matched your search
@@ -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 的响应头:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)) {
|
||||||
|
throw new Error('Web Analytics 站点列表不可用,请检查账户和 API Token 权限');
|
||||||
|
}
|
||||||
const matched = data.result.find(
|
const matched = data.result.find(
|
||||||
(s) => s.site_token === siteToken || s.site_tag === siteToken
|
(site) => site.site_token === siteToken || site.site_tag === siteToken
|
||||||
);
|
);
|
||||||
if (matched) return matched.site_tag;
|
if (matched?.site_tag) return matched.site_tag;
|
||||||
|
totalPages = data.result_info?.total_pages ?? 1;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
throw new Error('当前账户中未找到对应的 Web Analytics 站点,请检查站点 Token 和 CF_ACCOUNT_ID');
|
||||||
console.warn('Failed to resolve site_tag from token', e);
|
|
||||||
}
|
|
||||||
return siteToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 查询失败'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user