@yourbright/emdash-analytics-plugin 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.ts DELETED
@@ -1,749 +0,0 @@
1
- import { generatePrefixedToken, hashPrefixedToken } from "@emdash-cms/auth";
2
- import type { PluginContext } from "emdash";
3
- import { PluginRouteError } from "emdash";
4
- import { ulid } from "ulidx";
5
-
6
- import {
7
- AGENT_KEY_PREFIX,
8
- CRON_ENRICH_MANAGED,
9
- CRON_SYNC_BASE,
10
- FRESHNESS_KEY,
11
- GSC_QUERY_PAGE_LIMIT,
12
- GSC_QUERY_ROW_LIMIT,
13
- QUERY_REFRESH_STALE_HOURS,
14
- SITE_SUMMARY_KEY
15
- } from "./constants.js";
16
- import {
17
- buildContentUrl,
18
- classifyPageKind,
19
- dailyMetricStorageId,
20
- getManagedContentMap,
21
- pageQueryStorageId,
22
- pageStorageId
23
- } from "./content.js";
24
- import { parseServiceAccount } from "./config-validation.js";
25
- import { loadConfig } from "./config.js";
26
- import {
27
- buildWindows,
28
- fetchGaDailyTrend,
29
- fetchGaPageMetrics,
30
- fetchGscDailyTrend,
31
- fetchGscPageMetrics,
32
- fetchGscPageQueries,
33
- runConnectionTest
34
- } from "./google.js";
35
- import { scorePage } from "./scoring.js";
36
- import type {
37
- AgentKeyRecord,
38
- ContentContextResponse,
39
- DailyMetricRecord,
40
- FreshnessState,
41
- ManagedContentRef,
42
- PageAggregateRecord,
43
- PageListFilters,
44
- PageListResponse,
45
- PageQueryRecord,
46
- PluginConfigSummary,
47
- SavedPluginConfig,
48
- SiteSummary,
49
- SyncRunRecord
50
- } from "./types.js";
51
-
52
- type PluginCtx = PluginContext;
53
- type CombinedTrendMetric = {
54
- date: string;
55
- clicks: number;
56
- impressions: number;
57
- views: number;
58
- sessions: number;
59
- users: number;
60
- };
61
-
62
- export async function getStatus(ctx: PluginCtx): Promise<{
63
- config: PluginConfigSummary | null;
64
- summary: SiteSummary | null;
65
- freshness: FreshnessState;
66
- }> {
67
- const config = await loadConfig(ctx);
68
- return {
69
- config: config
70
- ? {
71
- siteOrigin: config.siteOrigin,
72
- ga4PropertyId: config.ga4PropertyId,
73
- gscSiteUrl: config.gscSiteUrl,
74
- hasServiceAccount: true,
75
- serviceAccountEmail: parseServiceAccount(config.serviceAccountJson).client_email
76
- }
77
- : null,
78
- summary: await ctx.kv.get<SiteSummary>(SITE_SUMMARY_KEY),
79
- freshness: await getFreshness(ctx)
80
- };
81
- }
82
-
83
- export async function testConnection(
84
- ctx: PluginCtx,
85
- draftConfig?: SavedPluginConfig | null
86
- ): Promise<Record<string, unknown>> {
87
- const config = draftConfig ?? (await loadConfig(ctx));
88
- if (!config) {
89
- throw new PluginRouteError("BAD_REQUEST", "Analytics connection is not configured", 400);
90
- }
91
- if (!ctx.http) {
92
- throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
93
- }
94
- return runConnectionTest(ctx.http, config, parseServiceAccount(config.serviceAccountJson));
95
- }
96
-
97
- export async function syncBase(ctx: PluginCtx, jobType: SyncRunRecord["jobType"]): Promise<{
98
- trackedPages: number;
99
- managedPages: number;
100
- }> {
101
- const config = await requireConfig(ctx);
102
- if (!ctx.http) {
103
- throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
104
- }
105
-
106
- const serviceAccount = parseServiceAccount(config.serviceAccountJson);
107
- const windows = buildWindows();
108
- const startedAt = new Date().toISOString();
109
- const managedMap = await getManagedContentMap(config.siteOrigin);
110
-
111
- const [gscCurrent, gscPrevious, gscTrend, gaCurrent, gaPrevious, gaTrend] = await Promise.all([
112
- fetchGscPageMetrics(ctx.http, config, serviceAccount, windows.gscCurrent),
113
- fetchGscPageMetrics(ctx.http, config, serviceAccount, windows.gscPrevious),
114
- fetchGscDailyTrend(ctx.http, config, serviceAccount, windows.gscCurrent),
115
- fetchGaPageMetrics(ctx.http, config, serviceAccount, windows.gaCurrent),
116
- fetchGaPageMetrics(ctx.http, config, serviceAccount, windows.gaPrevious),
117
- fetchGaDailyTrend(ctx.http, config, serviceAccount, windows.gaCurrent)
118
- ]);
119
-
120
- const host = new URL(config.siteOrigin).hostname;
121
- const pages = new Map<string, PageAggregateRecord>();
122
- const nowIso = new Date().toISOString();
123
-
124
- const ensurePage = (urlPath: string): PageAggregateRecord => {
125
- const existing = pages.get(urlPath);
126
- if (existing) return existing;
127
-
128
- const managed = managedMap.get(urlPath) ?? null;
129
- const created: PageAggregateRecord = {
130
- urlPath,
131
- host,
132
- pageKind: classifyPageKind(urlPath),
133
- managed: !!managed,
134
- title: managed?.title || urlPath,
135
- contentCollection: managed?.collection || null,
136
- contentId: managed?.id || null,
137
- contentSlug: managed?.slug || null,
138
- gscClicks28d: 0,
139
- gscImpressions28d: 0,
140
- gscCtr28d: 0,
141
- gscPosition28d: 0,
142
- gscClicksPrev28d: 0,
143
- gscImpressionsPrev28d: 0,
144
- gaViews28d: 0,
145
- gaUsers28d: 0,
146
- gaSessions28d: 0,
147
- gaEngagementRate28d: 0,
148
- gaBounceRate28d: 0,
149
- gaAvgSessionDuration28d: 0,
150
- gaViewsPrev28d: 0,
151
- gaUsersPrev28d: 0,
152
- gaSessionsPrev28d: 0,
153
- opportunityScore: 0,
154
- opportunityTags: [],
155
- lastSyncedAt: nowIso,
156
- lastGscDate: windows.gscCurrent.endDate,
157
- lastGaDate: windows.gaCurrent.endDate
158
- };
159
- pages.set(urlPath, created);
160
- return created;
161
- };
162
-
163
- for (const metric of gscCurrent) {
164
- const page = ensurePage(metric.urlPath);
165
- page.gscClicks28d = metric.clicks;
166
- page.gscImpressions28d = metric.impressions;
167
- page.gscCtr28d = metric.ctr;
168
- page.gscPosition28d = metric.position;
169
- }
170
- for (const metric of gscPrevious) {
171
- const page = ensurePage(metric.urlPath);
172
- page.gscClicksPrev28d = metric.clicks;
173
- page.gscImpressionsPrev28d = metric.impressions;
174
- }
175
- for (const metric of gaCurrent) {
176
- const page = ensurePage(metric.urlPath);
177
- page.gaViews28d = metric.views;
178
- page.gaUsers28d = metric.users;
179
- page.gaSessions28d = metric.sessions;
180
- page.gaEngagementRate28d = metric.engagementRate;
181
- page.gaBounceRate28d = metric.bounceRate;
182
- page.gaAvgSessionDuration28d = metric.averageSessionDuration;
183
- }
184
- for (const metric of gaPrevious) {
185
- const page = ensurePage(metric.urlPath);
186
- page.gaViewsPrev28d = metric.views;
187
- page.gaUsersPrev28d = metric.users;
188
- page.gaSessionsPrev28d = metric.sessions;
189
- }
190
-
191
- for (const page of pages.values()) {
192
- const score = scorePage(page, []);
193
- page.opportunityScore = score.score;
194
- page.opportunityTags = score.tags;
195
- }
196
-
197
- await ctx.storage.pages.putMany(
198
- Array.from(pages.values()).map((page) => ({
199
- id: pageStorageId(page.urlPath),
200
- data: page
201
- }))
202
- );
203
-
204
- const combinedTrend = mergeTrend(gscTrend, gaTrend);
205
- await ctx.storage.daily_metrics.putMany(
206
- combinedTrend.flatMap((row) => [
207
- {
208
- id: dailyMetricStorageId("gsc", "all_public", row.date),
209
- data: {
210
- source: "gsc",
211
- scope: "all_public",
212
- date: row.date,
213
- clicks: row.clicks,
214
- impressions: row.impressions,
215
- views: 0,
216
- sessions: 0,
217
- users: 0
218
- } satisfies DailyMetricRecord
219
- },
220
- {
221
- id: dailyMetricStorageId("ga", "all_public", row.date),
222
- data: {
223
- source: "ga",
224
- scope: "all_public",
225
- date: row.date,
226
- clicks: 0,
227
- impressions: 0,
228
- views: row.views,
229
- sessions: row.sessions,
230
- users: row.users
231
- } satisfies DailyMetricRecord
232
- }
233
- ])
234
- );
235
-
236
- const freshness: FreshnessState = {
237
- lastSyncedAt: nowIso,
238
- lastGscDate: windows.gscCurrent.endDate,
239
- lastGaDate: windows.gaCurrent.endDate,
240
- lastStatus: "success"
241
- };
242
- const summary = buildSummary(Array.from(pages.values()), combinedTrend, windows);
243
- await Promise.all([
244
- ctx.kv.set(FRESHNESS_KEY, freshness),
245
- ctx.kv.set(SITE_SUMMARY_KEY, summary),
246
- writeSyncRun(ctx, {
247
- jobType,
248
- status: "success",
249
- startedAt,
250
- finishedAt: nowIso,
251
- summary: {
252
- trackedPages: pages.size,
253
- managedPages: Array.from(pages.values()).filter((page) => page.managed).length
254
- },
255
- error: null
256
- })
257
- ]);
258
-
259
- return {
260
- trackedPages: pages.size,
261
- managedPages: Array.from(pages.values()).filter((page) => page.managed).length
262
- };
263
- }
264
-
265
- export async function enrichManagedQueries(ctx: PluginCtx): Promise<{ refreshedPages: number }> {
266
- const config = await requireConfig(ctx);
267
- if (!ctx.http) {
268
- throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
269
- }
270
- const serviceAccount = parseServiceAccount(config.serviceAccountJson);
271
- const windows = buildWindows();
272
-
273
- const candidateResult = await ctx.storage.pages.query({
274
- where: {
275
- managed: true,
276
- opportunityScore: { gt: 0 }
277
- },
278
- orderBy: { opportunityScore: "desc" },
279
- limit: GSC_QUERY_PAGE_LIMIT
280
- });
281
-
282
- let refreshedPages = 0;
283
- for (const item of candidateResult.items) {
284
- const page = item.data as PageAggregateRecord;
285
- const queries = await fetchGscPageQueries(
286
- ctx.http,
287
- config,
288
- serviceAccount,
289
- page.urlPath,
290
- windows.gscCurrent,
291
- GSC_QUERY_ROW_LIMIT
292
- );
293
- const existing = await ctx.storage.page_queries.query({
294
- where: { urlPath: page.urlPath },
295
- limit: 100
296
- });
297
- if (existing.items.length > 0) {
298
- await ctx.storage.page_queries.deleteMany(existing.items.map((entry) => entry.id));
299
- }
300
- if (queries.length > 0) {
301
- await ctx.storage.page_queries.putMany(
302
- queries.map((query) => ({
303
- id: pageQueryStorageId(page.urlPath, query.query),
304
- data: {
305
- urlPath: page.urlPath,
306
- query: query.query,
307
- clicks28d: query.clicks,
308
- impressions28d: query.impressions,
309
- ctr28d: query.ctr,
310
- position28d: query.position,
311
- updatedAt: new Date().toISOString()
312
- } satisfies PageQueryRecord
313
- }))
314
- );
315
- }
316
-
317
- const queryRows = await getPageQueries(ctx, page.urlPath);
318
- const rescored = scorePage(page, queryRows);
319
- page.opportunityScore = rescored.score;
320
- page.opportunityTags = rescored.tags;
321
- page.lastSyncedAt = new Date().toISOString();
322
- await ctx.storage.pages.put(pageStorageId(page.urlPath), page);
323
- refreshedPages += 1;
324
- }
325
-
326
- await refreshSummaryFromStorage(ctx);
327
- await writeSyncRun(ctx, {
328
- jobType: CRON_ENRICH_MANAGED,
329
- status: "success",
330
- startedAt: new Date().toISOString(),
331
- finishedAt: new Date().toISOString(),
332
- summary: { refreshedPages },
333
- error: null
334
- });
335
-
336
- return { refreshedPages };
337
- }
338
-
339
- export async function listPages(ctx: PluginCtx, filters: PageListFilters): Promise<PageListResponse> {
340
- const where: Record<string, any> = {};
341
- if (filters.managed === "managed") where.managed = true;
342
- if (filters.managed === "unmanaged") where.managed = false;
343
- if (filters.pageKind && filters.pageKind !== "all") where.pageKind = filters.pageKind;
344
- if (filters.hasOpportunity) where.opportunityScore = { gt: 0 };
345
-
346
- const result = await ctx.storage.pages.query({
347
- where,
348
- orderBy: { opportunityScore: "desc" },
349
- limit: filters.limit ?? 50,
350
- cursor: filters.cursor
351
- });
352
-
353
- return {
354
- items: result.items.map((item) => item.data as PageAggregateRecord),
355
- cursor: result.cursor,
356
- hasMore: result.hasMore
357
- };
358
- }
359
-
360
- export async function getOverview(ctx: PluginCtx): Promise<{
361
- summary: SiteSummary | null;
362
- freshness: FreshnessState;
363
- topOpportunities: PageAggregateRecord[];
364
- topUnmanaged: PageAggregateRecord[];
365
- }> {
366
- const [summary, freshness, topOpportunities, topUnmanaged] = await Promise.all([
367
- ctx.kv.get<SiteSummary>(SITE_SUMMARY_KEY),
368
- getFreshness(ctx),
369
- ctx.storage.pages.query({
370
- where: { managed: true, opportunityScore: { gt: 0 } },
371
- orderBy: { opportunityScore: "desc" },
372
- limit: 5
373
- }),
374
- ctx.storage.pages.query({
375
- where: { managed: false },
376
- orderBy: { gaViews28d: "desc" },
377
- limit: 5
378
- })
379
- ]);
380
-
381
- return {
382
- summary,
383
- freshness,
384
- topOpportunities: topOpportunities.items.map((item) => item.data as PageAggregateRecord),
385
- topUnmanaged: topUnmanaged.items.map((item) => item.data as PageAggregateRecord)
386
- };
387
- }
388
-
389
- export async function getContentContext(
390
- ctx: PluginCtx,
391
- collection: string,
392
- id?: string,
393
- slug?: string
394
- ): Promise<ContentContextResponse> {
395
- const config = await requireConfig(ctx);
396
- const managedMap = await getManagedContentMap(config.siteOrigin);
397
- let contentRef: ManagedContentRef | null = null;
398
-
399
- if (id) {
400
- contentRef =
401
- Array.from(managedMap.values()).find(
402
- (entry) => entry.collection === collection && entry.id === id
403
- ) || null;
404
- }
405
- if (!contentRef && slug) {
406
- contentRef =
407
- Array.from(managedMap.values()).find(
408
- (entry) => entry.collection === collection && entry.slug === slug
409
- ) || null;
410
- }
411
- if (!contentRef) {
412
- throw new PluginRouteError("NOT_FOUND", "Managed content not found", 404);
413
- }
414
-
415
- const page = await loadPage(ctx, contentRef.urlPath);
416
- if (!page) {
417
- throw new PluginRouteError("NOT_FOUND", "Analytics data not found for content", 404);
418
- }
419
-
420
- const queries = await getFreshQueriesForPage(ctx, config, contentRef.urlPath);
421
- const windows = buildWindows();
422
- const score = scorePage(page, queries);
423
- const freshness = await getFreshness(ctx);
424
-
425
- return {
426
- content: {
427
- collection: "posts",
428
- id: contentRef.id,
429
- slug: contentRef.slug,
430
- title: contentRef.title,
431
- urlPath: contentRef.urlPath,
432
- url: buildContentUrl(config.siteOrigin, contentRef.urlPath),
433
- excerpt: contentRef.excerpt ?? null,
434
- seoDescription: contentRef.seoDescription ?? null
435
- },
436
- analytics: {
437
- window: windows,
438
- page: {
439
- ...page,
440
- gscClicksDelta: page.gscClicks28d - page.gscClicksPrev28d,
441
- gscImpressionsDelta: page.gscImpressions28d - page.gscImpressionsPrev28d,
442
- gaViewsDelta: page.gaViews28d - page.gaViewsPrev28d,
443
- gaUsersDelta: page.gaUsers28d - page.gaUsersPrev28d,
444
- gaSessionsDelta: page.gaSessions28d - page.gaSessionsPrev28d
445
- },
446
- searchQueries: queries,
447
- opportunities: score.evidence,
448
- freshness
449
- }
450
- };
451
- }
452
-
453
- export async function listAgentKeys(ctx: PluginCtx): Promise<Array<Omit<AgentKeyRecord, "hash">>> {
454
- const result = await ctx.storage.agent_keys.query({
455
- orderBy: { createdAt: "desc" },
456
- limit: 100
457
- });
458
- return result.items.map((item) => {
459
- const record = item.data as AgentKeyRecord;
460
- return {
461
- prefix: record.prefix,
462
- label: record.label,
463
- createdAt: record.createdAt,
464
- lastUsedAt: record.lastUsedAt,
465
- revokedAt: record.revokedAt
466
- };
467
- });
468
- }
469
-
470
- export async function createAgentKey(ctx: PluginCtx, label: string): Promise<{
471
- key: string;
472
- metadata: Omit<AgentKeyRecord, "hash">;
473
- }> {
474
- const created = generatePrefixedToken(AGENT_KEY_PREFIX);
475
- const key = created.raw;
476
- const hash = hashPrefixedToken(key);
477
- const now = new Date().toISOString();
478
-
479
- const record: AgentKeyRecord = {
480
- prefix: created.prefix,
481
- hash,
482
- label,
483
- createdAt: now,
484
- lastUsedAt: null,
485
- revokedAt: null
486
- };
487
- await ctx.storage.agent_keys.put(hash, record);
488
-
489
- return {
490
- key,
491
- metadata: {
492
- prefix: record.prefix,
493
- label: record.label,
494
- createdAt: record.createdAt,
495
- lastUsedAt: null,
496
- revokedAt: null
497
- }
498
- };
499
- }
500
-
501
- export async function revokeAgentKey(ctx: PluginCtx, prefix: string): Promise<void> {
502
- const result = await ctx.storage.agent_keys.query({
503
- where: { prefix },
504
- limit: 1
505
- });
506
- const item = result.items[0];
507
- if (!item) {
508
- throw new PluginRouteError("NOT_FOUND", "Agent key not found", 404);
509
- }
510
- const record = item.data as AgentKeyRecord;
511
- record.revokedAt = new Date().toISOString();
512
- await ctx.storage.agent_keys.put(item.id, record);
513
- }
514
-
515
- export async function authenticateAgentRequest(ctx: PluginCtx, request: Request): Promise<void> {
516
- const token = extractAgentToken(request);
517
- if (!token.startsWith(AGENT_KEY_PREFIX)) {
518
- throw new PluginRouteError("UNAUTHORIZED", "Missing or invalid agent key", 401);
519
- }
520
-
521
- const hash = hashPrefixedToken(token);
522
- const record = await ctx.storage.agent_keys.get(hash);
523
- const keyRecord = record as AgentKeyRecord | null;
524
- if (!keyRecord || keyRecord.revokedAt) {
525
- throw new PluginRouteError("UNAUTHORIZED", "Missing or invalid agent key", 401);
526
- }
527
-
528
- keyRecord.lastUsedAt = new Date().toISOString();
529
- await ctx.storage.agent_keys.put(hash, keyRecord);
530
- }
531
-
532
- export function extractAgentToken(request: Request): string {
533
- const authHeader = request.headers.get("Authorization") || "";
534
- if (authHeader.startsWith("AgentKey ")) {
535
- return authHeader.slice("AgentKey ".length).trim();
536
- }
537
-
538
- const headerToken = request.headers.get("X-Emdash-Agent-Key") || "";
539
- if (headerToken.trim()) {
540
- return headerToken.trim();
541
- }
542
-
543
- return "";
544
- }
545
-
546
- export async function handleCron(ctx: PluginCtx, eventName: string): Promise<void> {
547
- if (eventName === CRON_SYNC_BASE) {
548
- await syncBase(ctx, CRON_SYNC_BASE);
549
- return;
550
- }
551
- if (eventName === CRON_ENRICH_MANAGED) {
552
- await enrichManagedQueries(ctx);
553
- }
554
- }
555
-
556
- async function requireConfig(ctx: PluginCtx): Promise<SavedPluginConfig> {
557
- const config = await loadConfig(ctx);
558
- if (!config) {
559
- throw new PluginRouteError("BAD_REQUEST", "Analytics connection is not configured", 400);
560
- }
561
- return config;
562
- }
563
-
564
- async function loadPage(ctx: PluginCtx, urlPath: string): Promise<PageAggregateRecord | null> {
565
- const page = await ctx.storage.pages.get(pageStorageId(urlPath));
566
- return (page as PageAggregateRecord | null) ?? null;
567
- }
568
-
569
- async function getFreshQueriesForPage(
570
- ctx: PluginCtx,
571
- config: SavedPluginConfig,
572
- urlPath: string
573
- ): Promise<PageQueryRecord[]> {
574
- let queries = await getPageQueries(ctx, urlPath);
575
- const updatedAt = queries[0]?.updatedAt ? Date.parse(queries[0].updatedAt) : 0;
576
- const isStale = !updatedAt || Date.now() - updatedAt > QUERY_REFRESH_STALE_HOURS * 60 * 60 * 1000;
577
- if (!isStale || !ctx.http) return queries;
578
-
579
- const serviceAccount = parseServiceAccount(config.serviceAccountJson);
580
- const windows = buildWindows();
581
- const refreshed = await fetchGscPageQueries(
582
- ctx.http,
583
- config,
584
- serviceAccount,
585
- urlPath,
586
- windows.gscCurrent,
587
- GSC_QUERY_ROW_LIMIT
588
- );
589
- const existing = await ctx.storage.page_queries.query({
590
- where: { urlPath },
591
- limit: 100
592
- });
593
- if (existing.items.length > 0) {
594
- await ctx.storage.page_queries.deleteMany(existing.items.map((entry) => entry.id));
595
- }
596
- if (refreshed.length > 0) {
597
- const nowIso = new Date().toISOString();
598
- await ctx.storage.page_queries.putMany(
599
- refreshed.map((query) => ({
600
- id: pageQueryStorageId(urlPath, query.query),
601
- data: {
602
- urlPath,
603
- query: query.query,
604
- clicks28d: query.clicks,
605
- impressions28d: query.impressions,
606
- ctr28d: query.ctr,
607
- position28d: query.position,
608
- updatedAt: nowIso
609
- } satisfies PageQueryRecord
610
- }))
611
- );
612
- }
613
- queries = await getPageQueries(ctx, urlPath);
614
- const page = await loadPage(ctx, urlPath);
615
- if (page) {
616
- const rescored = scorePage(page, queries);
617
- page.opportunityScore = rescored.score;
618
- page.opportunityTags = rescored.tags;
619
- page.lastSyncedAt = new Date().toISOString();
620
- await ctx.storage.pages.put(pageStorageId(urlPath), page);
621
- }
622
- return queries;
623
- }
624
-
625
- async function getPageQueries(ctx: PluginCtx, urlPath: string): Promise<PageQueryRecord[]> {
626
- const result = await ctx.storage.page_queries.query({
627
- where: { urlPath },
628
- orderBy: { impressions28d: "desc" },
629
- limit: 50
630
- });
631
- return result.items.map((item) => item.data as PageQueryRecord);
632
- }
633
-
634
- async function getFreshness(ctx: PluginCtx): Promise<FreshnessState> {
635
- return (
636
- (await ctx.kv.get<FreshnessState>(FRESHNESS_KEY)) || {
637
- lastSyncedAt: null,
638
- lastGscDate: null,
639
- lastGaDate: null,
640
- lastStatus: "idle"
641
- }
642
- );
643
- }
644
-
645
- async function refreshSummaryFromStorage(ctx: PluginCtx): Promise<void> {
646
- const windows = buildWindows();
647
- const allPages: PageAggregateRecord[] = [];
648
- let cursor: string | undefined;
649
-
650
- do {
651
- const pageBatch = await ctx.storage.pages.query({
652
- limit: 500,
653
- cursor
654
- });
655
- cursor = pageBatch.cursor;
656
- allPages.push(...pageBatch.items.map((item) => item.data as PageAggregateRecord));
657
- } while (cursor);
658
-
659
- const trendMap = new Map<string, { date: string; clicks: number; impressions: number; views: number; sessions: number; users: number }>();
660
- let dailyCursor: string | undefined;
661
- do {
662
- const batch = await ctx.storage.daily_metrics.query({
663
- limit: 1000,
664
- cursor: dailyCursor
665
- });
666
- dailyCursor = batch.cursor;
667
- for (const item of batch.items) {
668
- const row = item.data as DailyMetricRecord;
669
- let existing = trendMap.get(row.date);
670
- if (!existing) {
671
- existing = { date: row.date, clicks: 0, impressions: 0, views: 0, sessions: 0, users: 0 };
672
- trendMap.set(row.date, existing);
673
- }
674
- existing.clicks += row.clicks;
675
- existing.impressions += row.impressions;
676
- existing.views += row.views;
677
- existing.sessions += row.sessions;
678
- existing.users += row.users;
679
- }
680
- } while (dailyCursor);
681
-
682
- await ctx.kv.set(
683
- SITE_SUMMARY_KEY,
684
- buildSummary(allPages, Array.from(trendMap.values()).sort((a, b) => a.date.localeCompare(b.date)), windows)
685
- );
686
- }
687
-
688
- async function writeSyncRun(ctx: PluginCtx, record: SyncRunRecord): Promise<void> {
689
- await ctx.storage.sync_runs.put(ulid(), record);
690
- }
691
-
692
- function buildSummary(
693
- pages: PageAggregateRecord[],
694
- trend: CombinedTrendMetric[],
695
- windows: SiteSummary["window"]
696
- ): SiteSummary {
697
- return {
698
- window: windows,
699
- totals: {
700
- gscClicks28d: pages.reduce((total, page) => total + page.gscClicks28d, 0),
701
- gscImpressions28d: pages.reduce((total, page) => total + page.gscImpressions28d, 0),
702
- gaViews28d: pages.reduce((total, page) => total + page.gaViews28d, 0),
703
- gaUsers28d: pages.reduce((total, page) => total + page.gaUsers28d, 0),
704
- gaSessions28d: pages.reduce((total, page) => total + page.gaSessions28d, 0),
705
- managedOpportunities: pages.filter((page) => page.managed && page.opportunityScore > 0).length,
706
- trackedPages: pages.length
707
- },
708
- trend: trend.map((row) => ({
709
- date: row.date,
710
- gscClicks: row.clicks,
711
- gscImpressions: row.impressions,
712
- gaViews: row.views,
713
- gaSessions: row.sessions,
714
- gaUsers: row.users
715
- }))
716
- };
717
- }
718
-
719
- function mergeTrend(
720
- gscTrend: Array<{ date: string; clicks: number; impressions: number }>,
721
- gaTrend: Array<{ date: string; views: number; sessions: number; users: number }>
722
- ): CombinedTrendMetric[] {
723
- const map = new Map<string, CombinedTrendMetric>();
724
- for (const row of gscTrend) {
725
- map.set(row.date, {
726
- date: row.date,
727
- clicks: row.clicks,
728
- impressions: row.impressions,
729
- views: 0,
730
- sessions: 0,
731
- users: 0
732
- });
733
- }
734
- for (const row of gaTrend) {
735
- const existing = map.get(row.date) || {
736
- date: row.date,
737
- clicks: 0,
738
- impressions: 0,
739
- views: 0,
740
- sessions: 0,
741
- users: 0
742
- };
743
- existing.views = row.views;
744
- existing.sessions = row.sessions;
745
- existing.users = row.users;
746
- map.set(row.date, existing);
747
- }
748
- return Array.from(map.values()).sort((left, right) => left.date.localeCompare(right.date));
749
- }