@yourbright/emdash-analytics-plugin 0.1.1
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/README.md +151 -0
- package/package.json +57 -0
- package/src/admin.tsx +1138 -0
- package/src/config-validation.ts +153 -0
- package/src/config.ts +90 -0
- package/src/constants.ts +55 -0
- package/src/content.ts +133 -0
- package/src/google.ts +518 -0
- package/src/index.ts +270 -0
- package/src/scoring.ts +83 -0
- package/src/sync.ts +749 -0
- package/src/types.ts +193 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
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
|
+
}
|