@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/dist/index.js ADDED
@@ -0,0 +1,1560 @@
1
+ // src/index.ts
2
+ import {
3
+ PluginRouteError as PluginRouteError2,
4
+ definePlugin
5
+ } from "emdash";
6
+ import { z as z2 } from "astro/zod";
7
+
8
+ // src/constants.ts
9
+ var PLUGIN_ID = "emdash-google-analytics-dashboard";
10
+ var PLUGIN_VERSION = "0.1.0";
11
+ var AGENT_KEY_PREFIX = "yb_ins_";
12
+ var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
13
+ var GOOGLE_GA_BASE_URL = "https://analyticsdata.googleapis.com/v1beta";
14
+ var GOOGLE_GSC_BASE_URL = "https://www.googleapis.com/webmasters/v3";
15
+ var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
16
+ var GA_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
17
+ var GSC_DATA_DELAY_DAYS = 3;
18
+ var QUERY_REFRESH_STALE_HOURS = 48;
19
+ var GSC_QUERY_PAGE_LIMIT = 50;
20
+ var GSC_QUERY_ROW_LIMIT = 25;
21
+ var SITE_SUMMARY_KEY = "state:site-summary";
22
+ var FRESHNESS_KEY = "state:freshness";
23
+ var CONFIG_SITE_ORIGIN_KEY = "settings:siteOrigin";
24
+ var CONFIG_GA4_PROPERTY_ID_KEY = "settings:ga4PropertyId";
25
+ var CONFIG_GSC_SITE_URL_KEY = "settings:gscSiteUrl";
26
+ var CONFIG_SERVICE_ACCOUNT_KEY = "settings:serviceAccountCiphertext";
27
+ var CRON_SYNC_BASE = "sync-base";
28
+ var CRON_ENRICH_MANAGED = "enrich-managed-queries";
29
+ var PUBLIC_AGENT_ROUTES = {
30
+ SITE_SUMMARY: "agent/v1/site-summary",
31
+ OPPORTUNITIES: "agent/v1/opportunities",
32
+ CONTENT_CONTEXT: "agent/v1/content-context"
33
+ };
34
+ var ADMIN_ROUTES = {
35
+ STATUS: "admin/status",
36
+ OVERVIEW: "admin/overview",
37
+ LIST_PAGES: "admin/pages/list",
38
+ CONTENT_CONTEXT: "admin/content/get",
39
+ CONFIG_GET: "admin/config/get",
40
+ CONFIG_SAVE: "admin/config/save",
41
+ CONNECTION_TEST: "admin/connection/test",
42
+ SYNC_NOW: "admin/sync-now",
43
+ AGENT_KEYS_LIST: "admin/agent-keys/list",
44
+ AGENT_KEYS_CREATE: "admin/agent-keys/create",
45
+ AGENT_KEYS_REVOKE: "admin/agent-keys/revoke"
46
+ };
47
+
48
+ // src/config-validation.ts
49
+ import { z } from "astro/zod";
50
+ var serviceAccountSchema = z.object({
51
+ client_email: z.string().email("Service Account JSON must include a valid client_email"),
52
+ private_key: z.string().min(1, "Service Account JSON must include a private_key"),
53
+ token_uri: z.string().url().optional()
54
+ });
55
+ var savedConfigSchema = z.object({
56
+ siteOrigin: z.string().min(1, "Canonical Site Origin is required").refine(isHttpUrl, "Canonical Site Origin must be a valid http(s) URL"),
57
+ ga4PropertyId: z.string().min(1, "GA4 Property ID is required").regex(/^[0-9]+$/, "GA4 Property ID must be numeric"),
58
+ gscSiteUrl: z.string().min(1, "Search Console Property is required").refine(isValidSearchConsoleProperty, "Search Console Property must be a valid URL or sc-domain property"),
59
+ serviceAccountJson: z.string().min(1, "Service Account JSON is required").superRefine((value, ctx) => {
60
+ const parsed = parseServiceAccountSafe(value);
61
+ if (!parsed.success) {
62
+ ctx.addIssue({
63
+ code: z.ZodIssueCode.custom,
64
+ message: parsed.message
65
+ });
66
+ }
67
+ })
68
+ });
69
+ function resolveConfigInput(input, current = null) {
70
+ const candidate = {
71
+ siteOrigin: resolveField(input?.siteOrigin, current?.siteOrigin),
72
+ ga4PropertyId: resolveField(input?.ga4PropertyId, current?.ga4PropertyId),
73
+ gscSiteUrl: resolveField(input?.gscSiteUrl, current?.gscSiteUrl),
74
+ serviceAccountJson: resolveServiceAccountField(input?.serviceAccountJson, current?.serviceAccountJson)
75
+ };
76
+ const parsed = savedConfigSchema.safeParse(candidate);
77
+ if (!parsed.success) {
78
+ return {
79
+ success: false,
80
+ message: formatValidationError(parsed.error)
81
+ };
82
+ }
83
+ return {
84
+ success: true,
85
+ data: {
86
+ ...parsed.data,
87
+ siteOrigin: normalizeOrigin(parsed.data.siteOrigin)
88
+ }
89
+ };
90
+ }
91
+ function parseServiceAccount(json) {
92
+ const result = parseServiceAccountSafe(json);
93
+ if (!result.success) {
94
+ throw new Error(result.message);
95
+ }
96
+ return result.data;
97
+ }
98
+ function normalizeOrigin(origin) {
99
+ const url = new URL(origin);
100
+ url.pathname = "/";
101
+ url.search = "";
102
+ url.hash = "";
103
+ return url.origin;
104
+ }
105
+ function parseServiceAccountSafe(json) {
106
+ let parsedJson;
107
+ try {
108
+ parsedJson = JSON.parse(json);
109
+ } catch {
110
+ return {
111
+ success: false,
112
+ message: "Service Account JSON must be valid JSON"
113
+ };
114
+ }
115
+ const parsed = serviceAccountSchema.safeParse(parsedJson);
116
+ if (!parsed.success) {
117
+ return {
118
+ success: false,
119
+ message: formatValidationError(parsed.error)
120
+ };
121
+ }
122
+ return {
123
+ success: true,
124
+ data: parsed.data
125
+ };
126
+ }
127
+ function resolveField(value, fallback) {
128
+ if (value === void 0) {
129
+ return fallback ?? "";
130
+ }
131
+ const trimmed = value.trim();
132
+ return trimmed.length > 0 ? trimmed : fallback ?? "";
133
+ }
134
+ function resolveServiceAccountField(value, fallback) {
135
+ if (value === void 0) {
136
+ return fallback ?? "";
137
+ }
138
+ const trimmed = value.trim();
139
+ return trimmed.length > 0 ? trimmed : fallback ?? "";
140
+ }
141
+ function isHttpUrl(value) {
142
+ try {
143
+ const url = new URL(value);
144
+ return url.protocol === "http:" || url.protocol === "https:";
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ function isValidSearchConsoleProperty(value) {
150
+ if (value.startsWith("sc-domain:")) {
151
+ return value.slice("sc-domain:".length).trim().length > 0;
152
+ }
153
+ return isHttpUrl(value);
154
+ }
155
+ function formatValidationError(error) {
156
+ const messages = Array.from(new Set(error.issues.map((issue) => issue.message).filter(Boolean)));
157
+ return messages[0] || "Invalid settings";
158
+ }
159
+
160
+ // src/config.ts
161
+ import { decrypt, encrypt } from "@emdash-cms/auth";
162
+ async function loadConfig(ctx) {
163
+ const [siteOrigin, ga4PropertyId, gscSiteUrl, serviceAccountCiphertext] = await Promise.all([
164
+ ctx.kv.get(CONFIG_SITE_ORIGIN_KEY),
165
+ ctx.kv.get(CONFIG_GA4_PROPERTY_ID_KEY),
166
+ ctx.kv.get(CONFIG_GSC_SITE_URL_KEY),
167
+ ctx.kv.get(CONFIG_SERVICE_ACCOUNT_KEY)
168
+ ]);
169
+ if (!siteOrigin || !ga4PropertyId || !gscSiteUrl || !serviceAccountCiphertext) {
170
+ return null;
171
+ }
172
+ const authSecret = getAuthSecret();
173
+ const serviceAccountJson = await decrypt(serviceAccountCiphertext, authSecret);
174
+ const resolved = resolveConfigInput({
175
+ siteOrigin,
176
+ ga4PropertyId,
177
+ gscSiteUrl,
178
+ serviceAccountJson
179
+ });
180
+ if (!resolved.success) {
181
+ throw new Error(resolved.message);
182
+ }
183
+ return resolved.data;
184
+ }
185
+ async function saveConfig(ctx, input) {
186
+ const resolved = resolveConfigInput(input);
187
+ if (!resolved.success) {
188
+ throw new Error(resolved.message);
189
+ }
190
+ const parsed = resolved.data;
191
+ const authSecret = getAuthSecret();
192
+ const serviceAccountCiphertext = await encrypt(parsed.serviceAccountJson, authSecret);
193
+ await Promise.all([
194
+ ctx.kv.set(CONFIG_SITE_ORIGIN_KEY, normalizeOrigin(parsed.siteOrigin)),
195
+ ctx.kv.set(CONFIG_GA4_PROPERTY_ID_KEY, parsed.ga4PropertyId),
196
+ ctx.kv.set(CONFIG_GSC_SITE_URL_KEY, parsed.gscSiteUrl),
197
+ ctx.kv.set(CONFIG_SERVICE_ACCOUNT_KEY, serviceAccountCiphertext)
198
+ ]);
199
+ return summarizeConfig(parsed);
200
+ }
201
+ async function getConfigSummary(ctx) {
202
+ const config = await loadConfig(ctx);
203
+ if (!config) {
204
+ return {
205
+ siteOrigin: "",
206
+ ga4PropertyId: "",
207
+ gscSiteUrl: "",
208
+ hasServiceAccount: false
209
+ };
210
+ }
211
+ return summarizeConfig(config);
212
+ }
213
+ function getAuthSecret() {
214
+ const value = process.env.EMDASH_AUTH_SECRET || process.env.AUTH_SECRET || "";
215
+ if (!value) {
216
+ throw new Error("EMDASH_AUTH_SECRET is required to store analytics credentials");
217
+ }
218
+ return value;
219
+ }
220
+ function summarizeConfig(config) {
221
+ const serviceAccount = parseServiceAccount(config.serviceAccountJson);
222
+ return {
223
+ siteOrigin: normalizeOrigin(config.siteOrigin),
224
+ ga4PropertyId: config.ga4PropertyId,
225
+ gscSiteUrl: config.gscSiteUrl,
226
+ hasServiceAccount: true,
227
+ serviceAccountEmail: serviceAccount.client_email
228
+ };
229
+ }
230
+
231
+ // src/sync.ts
232
+ import { generatePrefixedToken, hashPrefixedToken } from "@emdash-cms/auth";
233
+ import { PluginRouteError } from "emdash";
234
+ import { ulid } from "ulidx";
235
+
236
+ // src/content.ts
237
+ import { getEmDashCollection, getEmDashEntry } from "emdash";
238
+ function classifyPageKind(urlPath) {
239
+ if (/^\/blog\/[^/]+\/$/.test(urlPath)) return "blog_post";
240
+ if (urlPath === "/blog/" || /^\/blog\/[^/]+\/$/.test(urlPath)) return "blog_archive";
241
+ if (urlPath.startsWith("/tag/")) return "tag";
242
+ if (urlPath.startsWith("/author/")) return "author";
243
+ if (urlPath === "/" || urlPath.startsWith("/company/") || urlPath.startsWith("/contact/") || urlPath.startsWith("/download/") || urlPath.startsWith("/foreignworkers/") || urlPath.startsWith("/jirei/") || urlPath.startsWith("/kaigo/") || urlPath.startsWith("/pr/") || urlPath.startsWith("/seminar/")) {
244
+ return "landing";
245
+ }
246
+ return "other";
247
+ }
248
+ function normalizePath(pathOrUrl, expectedHost) {
249
+ try {
250
+ const parsed = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://") ? new URL(pathOrUrl) : new URL(pathOrUrl, "https://placeholder.invalid");
251
+ if (expectedHost && parsed.hostname !== "placeholder.invalid" && parsed.hostname !== expectedHost) {
252
+ return null;
253
+ }
254
+ let pathname = parsed.pathname || "/";
255
+ if (!pathname.startsWith("/")) pathname = `/${pathname}`;
256
+ pathname = pathname.replace(/\/{2,}/g, "/");
257
+ if (pathname !== "/" && !pathname.endsWith("/")) pathname = `${pathname}/`;
258
+ return pathname;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+ function buildContentUrl(siteOrigin, urlPath) {
264
+ return new URL(urlPath, `${siteOrigin}/`).toString();
265
+ }
266
+ async function getManagedContentMap(siteOrigin) {
267
+ const result = await getEmDashCollection("posts", {
268
+ status: "published",
269
+ limit: 1e3,
270
+ orderBy: { updatedAt: "desc" }
271
+ });
272
+ const managed = /* @__PURE__ */ new Map();
273
+ for (const entry of result.entries) {
274
+ const id = typeof entry.id === "string" ? entry.id : "";
275
+ if (!id) continue;
276
+ const slug = typeof entry.slug === "string" ? entry.slug : null;
277
+ const data = entry.data ?? {};
278
+ const title = typeof data.title === "string" ? data.title : slug || id;
279
+ const excerpt = typeof data.excerpt === "string" ? data.excerpt : void 0;
280
+ const seoDescription = typeof data.seo_description === "string" ? data.seo_description : void 0;
281
+ const urlPath = `/blog/${slug || id}/`;
282
+ managed.set(urlPath, {
283
+ collection: "posts",
284
+ id,
285
+ slug,
286
+ urlPath,
287
+ title,
288
+ excerpt,
289
+ seoDescription
290
+ });
291
+ }
292
+ void siteOrigin;
293
+ return managed;
294
+ }
295
+ function pageStorageId(urlPath) {
296
+ return stableStorageId(urlPath);
297
+ }
298
+ function pageQueryStorageId(urlPath, query) {
299
+ return stableStorageId(`${urlPath}::${query}`);
300
+ }
301
+ function dailyMetricStorageId(source, scope, date) {
302
+ return stableStorageId(`${source}::${scope}::${date}`);
303
+ }
304
+ function stableStorageId(input) {
305
+ let hash = 5381;
306
+ for (let index = 0; index < input.length; index += 1) {
307
+ hash = hash * 33 ^ input.charCodeAt(index);
308
+ }
309
+ return `ybci_${(hash >>> 0).toString(16)}`;
310
+ }
311
+
312
+ // src/google.ts
313
+ var accessTokenCache = /* @__PURE__ */ new Map();
314
+ function buildWindows(now = /* @__PURE__ */ new Date()) {
315
+ const gaCurrentEnd = addDaysUtc(now, -1);
316
+ const gaCurrentStart = addDaysUtc(gaCurrentEnd, -27);
317
+ const gaPreviousEnd = addDaysUtc(gaCurrentStart, -1);
318
+ const gaPreviousStart = addDaysUtc(gaPreviousEnd, -27);
319
+ const gscCurrentEnd = addDaysUtc(now, -GSC_DATA_DELAY_DAYS);
320
+ const gscCurrentStart = addDaysUtc(gscCurrentEnd, -27);
321
+ const gscPreviousEnd = addDaysUtc(gscCurrentStart, -1);
322
+ const gscPreviousStart = addDaysUtc(gscPreviousEnd, -27);
323
+ return {
324
+ gscCurrent: { startDate: formatDate(gscCurrentStart), endDate: formatDate(gscCurrentEnd) },
325
+ gscPrevious: { startDate: formatDate(gscPreviousStart), endDate: formatDate(gscPreviousEnd) },
326
+ gaCurrent: { startDate: formatDate(gaCurrentStart), endDate: formatDate(gaCurrentEnd) },
327
+ gaPrevious: { startDate: formatDate(gaPreviousStart), endDate: formatDate(gaPreviousEnd) }
328
+ };
329
+ }
330
+ async function runConnectionTest(http, config, serviceAccount) {
331
+ const gaToken = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
332
+ const gscToken = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
333
+ const windows = buildWindows();
334
+ const ga = await postJson(
335
+ http,
336
+ `${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
337
+ gaToken,
338
+ {
339
+ dateRanges: [windows.gaCurrent],
340
+ dimensions: [{ name: "date" }],
341
+ metrics: [{ name: "sessions" }],
342
+ limit: 1
343
+ }
344
+ );
345
+ const gsc = await postJson(
346
+ http,
347
+ `${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
348
+ gscToken,
349
+ {
350
+ startDate: windows.gscCurrent.startDate,
351
+ endDate: windows.gscCurrent.endDate,
352
+ dimensions: ["date"],
353
+ rowLimit: 1,
354
+ startRow: 0
355
+ }
356
+ );
357
+ return {
358
+ ga: {
359
+ rowCount: numberOrZero(ga.rowCount),
360
+ sample: Array.isArray(ga.rows) ? ga.rows[0] ?? null : null
361
+ },
362
+ gsc: {
363
+ rowCount: Array.isArray(gsc.rows) ? gsc.rows.length : 0,
364
+ sample: Array.isArray(gsc.rows) ? gsc.rows[0] ?? null : null
365
+ }
366
+ };
367
+ }
368
+ async function fetchGscPageMetrics(http, config, serviceAccount, window) {
369
+ const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
370
+ const rows = [];
371
+ const canonicalHost = new URL(config.siteOrigin).hostname;
372
+ let startRow = 0;
373
+ while (true) {
374
+ const body = await postJson(
375
+ http,
376
+ `${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
377
+ token,
378
+ {
379
+ startDate: window.startDate,
380
+ endDate: window.endDate,
381
+ dimensions: ["page"],
382
+ rowLimit: 25e3,
383
+ startRow,
384
+ type: "web"
385
+ }
386
+ );
387
+ const pageRows = Array.isArray(body.rows) ? body.rows : [];
388
+ for (const row of pageRows) {
389
+ const rawUrl = stringValue(row.keys?.[0]);
390
+ if (!rawUrl) continue;
391
+ const urlPath = normalizePath(rawUrl, canonicalHost);
392
+ if (!urlPath) continue;
393
+ rows.push({
394
+ urlPath,
395
+ clicks: numberOrZero(row.clicks),
396
+ impressions: numberOrZero(row.impressions),
397
+ ctr: numberOrZero(row.ctr),
398
+ position: numberOrZero(row.position)
399
+ });
400
+ }
401
+ if (pageRows.length < 25e3) break;
402
+ startRow += 25e3;
403
+ }
404
+ return rows;
405
+ }
406
+ async function fetchGscDailyTrend(http, config, serviceAccount, window) {
407
+ const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
408
+ const body = await postJson(
409
+ http,
410
+ `${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
411
+ token,
412
+ {
413
+ startDate: window.startDate,
414
+ endDate: window.endDate,
415
+ dimensions: ["date"],
416
+ rowLimit: 1e3,
417
+ startRow: 0,
418
+ type: "web"
419
+ }
420
+ );
421
+ return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
422
+ date: stringValue(row.keys?.[0]) || window.startDate,
423
+ clicks: numberOrZero(row.clicks),
424
+ impressions: numberOrZero(row.impressions),
425
+ views: 0,
426
+ sessions: 0,
427
+ users: 0
428
+ }));
429
+ }
430
+ async function fetchGscPageQueries(http, config, serviceAccount, urlPath, window, limit) {
431
+ const token = await getGoogleAccessToken(http, serviceAccount, [GSC_SCOPE]);
432
+ const pageUrl = buildContentUrl(config.siteOrigin, urlPath);
433
+ const body = await postJson(
434
+ http,
435
+ `${GOOGLE_GSC_BASE_URL}/sites/${encodeURIComponent(config.gscSiteUrl)}/searchAnalytics/query`,
436
+ token,
437
+ {
438
+ startDate: window.startDate,
439
+ endDate: window.endDate,
440
+ dimensions: ["query"],
441
+ rowLimit: limit,
442
+ startRow: 0,
443
+ type: "web",
444
+ dimensionFilterGroups: [
445
+ {
446
+ filters: [
447
+ {
448
+ dimension: "page",
449
+ operator: "equals",
450
+ expression: pageUrl
451
+ }
452
+ ]
453
+ }
454
+ ]
455
+ }
456
+ );
457
+ return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
458
+ query: stringValue(row.keys?.[0]) || "",
459
+ clicks: numberOrZero(row.clicks),
460
+ impressions: numberOrZero(row.impressions),
461
+ ctr: numberOrZero(row.ctr),
462
+ position: numberOrZero(row.position)
463
+ })).filter((row) => row.query.length > 0);
464
+ }
465
+ async function fetchGaPageMetrics(http, config, serviceAccount, window) {
466
+ const token = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
467
+ const rows = [];
468
+ let offset = 0;
469
+ const limit = 1e4;
470
+ while (true) {
471
+ const body = await postJson(
472
+ http,
473
+ `${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
474
+ token,
475
+ {
476
+ dateRanges: [window],
477
+ dimensions: [{ name: "pagePath" }],
478
+ metrics: [
479
+ { name: "screenPageViews" },
480
+ { name: "activeUsers" },
481
+ { name: "sessions" },
482
+ { name: "engagementRate" },
483
+ { name: "bounceRate" },
484
+ { name: "averageSessionDuration" }
485
+ ],
486
+ dimensionFilter: {
487
+ filter: {
488
+ fieldName: "hostName",
489
+ stringFilter: {
490
+ matchType: "EXACT",
491
+ value: new URL(config.siteOrigin).hostname
492
+ }
493
+ }
494
+ },
495
+ limit,
496
+ offset
497
+ }
498
+ );
499
+ const pageRows = Array.isArray(body.rows) ? body.rows : [];
500
+ for (const row of pageRows) {
501
+ const urlPath = normalizePath(stringValue(row.dimensionValues?.[0]?.value) || "/");
502
+ if (!urlPath) continue;
503
+ rows.push({
504
+ urlPath,
505
+ views: numberOrZero(row.metricValues?.[0]?.value),
506
+ users: numberOrZero(row.metricValues?.[1]?.value),
507
+ sessions: numberOrZero(row.metricValues?.[2]?.value),
508
+ engagementRate: numberOrZero(row.metricValues?.[3]?.value),
509
+ bounceRate: numberOrZero(row.metricValues?.[4]?.value),
510
+ averageSessionDuration: numberOrZero(row.metricValues?.[5]?.value)
511
+ });
512
+ }
513
+ if (pageRows.length < limit) break;
514
+ offset += limit;
515
+ }
516
+ return rows;
517
+ }
518
+ async function fetchGaDailyTrend(http, config, serviceAccount, window) {
519
+ const token = await getGoogleAccessToken(http, serviceAccount, [GA_SCOPE]);
520
+ const body = await postJson(
521
+ http,
522
+ `${GOOGLE_GA_BASE_URL}/properties/${config.ga4PropertyId}:runReport`,
523
+ token,
524
+ {
525
+ dateRanges: [window],
526
+ dimensions: [{ name: "date" }],
527
+ metrics: [
528
+ { name: "screenPageViews" },
529
+ { name: "activeUsers" },
530
+ { name: "sessions" }
531
+ ],
532
+ dimensionFilter: {
533
+ filter: {
534
+ fieldName: "hostName",
535
+ stringFilter: {
536
+ matchType: "EXACT",
537
+ value: new URL(config.siteOrigin).hostname
538
+ }
539
+ }
540
+ },
541
+ limit: 1e3
542
+ }
543
+ );
544
+ return (Array.isArray(body.rows) ? body.rows : []).map((row) => ({
545
+ date: parseGaDate(stringValue(row.dimensionValues?.[0]?.value) || window.startDate),
546
+ clicks: 0,
547
+ impressions: 0,
548
+ views: numberOrZero(row.metricValues?.[0]?.value),
549
+ users: numberOrZero(row.metricValues?.[1]?.value),
550
+ sessions: numberOrZero(row.metricValues?.[2]?.value)
551
+ }));
552
+ }
553
+ async function getGoogleAccessToken(http, serviceAccount, scopes) {
554
+ const cacheKey = `${serviceAccount.client_email}:${scopes.join(" ")}`;
555
+ const cached = accessTokenCache.get(cacheKey);
556
+ if (cached && cached.expiresAt > Date.now() + 6e4) {
557
+ return cached.token;
558
+ }
559
+ const assertion = await createJwtAssertion(serviceAccount, scopes);
560
+ const response = await http.fetch(GOOGLE_TOKEN_URL, {
561
+ method: "POST",
562
+ headers: {
563
+ "Content-Type": "application/x-www-form-urlencoded"
564
+ },
565
+ body: new URLSearchParams({
566
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
567
+ assertion
568
+ }).toString()
569
+ });
570
+ const body = await parseJson(response);
571
+ if (!response.ok) {
572
+ throw new Error(`Failed to obtain Google access token: ${body.error_description || body.error || response.status}`);
573
+ }
574
+ const token = stringValue(body.access_token);
575
+ const expiresIn = numberOrZero(body.expires_in) || 3600;
576
+ if (!token) {
577
+ throw new Error("Google token response did not include access_token");
578
+ }
579
+ accessTokenCache.set(cacheKey, {
580
+ token,
581
+ expiresAt: Date.now() + expiresIn * 1e3
582
+ });
583
+ return token;
584
+ }
585
+ async function createJwtAssertion(serviceAccount, scopes) {
586
+ const now = Math.floor(Date.now() / 1e3);
587
+ const header = { alg: "RS256", typ: "JWT" };
588
+ const claims = {
589
+ iss: serviceAccount.client_email,
590
+ scope: scopes.join(" "),
591
+ aud: serviceAccount.token_uri || GOOGLE_TOKEN_URL,
592
+ exp: now + 3600,
593
+ iat: now
594
+ };
595
+ const encodedHeader = base64url(JSON.stringify(header));
596
+ const encodedClaims = base64url(JSON.stringify(claims));
597
+ const payload = `${encodedHeader}.${encodedClaims}`;
598
+ const key = await crypto.subtle.importKey(
599
+ "pkcs8",
600
+ pemToArrayBuffer(serviceAccount.private_key),
601
+ {
602
+ name: "RSASSA-PKCS1-v1_5",
603
+ hash: "SHA-256"
604
+ },
605
+ false,
606
+ ["sign"]
607
+ );
608
+ const signature = await crypto.subtle.sign(
609
+ "RSASSA-PKCS1-v1_5",
610
+ key,
611
+ new TextEncoder().encode(payload)
612
+ );
613
+ return `${payload}.${base64urlBytes(new Uint8Array(signature))}`;
614
+ }
615
+ async function postJson(http, url, accessToken, payload) {
616
+ const response = await http.fetch(url, {
617
+ method: "POST",
618
+ headers: {
619
+ Authorization: `Bearer ${accessToken}`,
620
+ "Content-Type": "application/json"
621
+ },
622
+ body: JSON.stringify(payload)
623
+ });
624
+ const body = await parseJson(response);
625
+ if (!response.ok) {
626
+ const message = body.error?.message || body.error_description || response.statusText;
627
+ throw new Error(`Google API request failed: ${message}`);
628
+ }
629
+ return body;
630
+ }
631
+ async function parseJson(response) {
632
+ const text = await response.text();
633
+ if (!text) return {};
634
+ try {
635
+ return JSON.parse(text);
636
+ } catch {
637
+ return { rawText: text };
638
+ }
639
+ }
640
+ function pemToArrayBuffer(pem) {
641
+ const normalized = pem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
642
+ const binary = atob(normalized);
643
+ const bytes = new Uint8Array(binary.length);
644
+ for (let index = 0; index < binary.length; index += 1) {
645
+ bytes[index] = binary.charCodeAt(index);
646
+ }
647
+ return bytes.buffer;
648
+ }
649
+ function base64url(value) {
650
+ return base64urlBytes(new TextEncoder().encode(value));
651
+ }
652
+ function base64urlBytes(value) {
653
+ let binary = "";
654
+ for (const byte of value) binary += String.fromCharCode(byte);
655
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
656
+ }
657
+ function parseGaDate(value) {
658
+ if (/^\d{8}$/.test(value)) {
659
+ return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
660
+ }
661
+ return value;
662
+ }
663
+ function addDaysUtc(date, delta) {
664
+ const next = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
665
+ next.setUTCDate(next.getUTCDate() + delta);
666
+ return next;
667
+ }
668
+ function formatDate(date) {
669
+ return date.toISOString().slice(0, 10);
670
+ }
671
+ function stringValue(value) {
672
+ return typeof value === "string" ? value : void 0;
673
+ }
674
+ function numberOrZero(value) {
675
+ if (typeof value === "number") return value;
676
+ if (typeof value === "string" && value.length > 0) {
677
+ const parsed = Number(value);
678
+ return Number.isFinite(parsed) ? parsed : 0;
679
+ }
680
+ return 0;
681
+ }
682
+
683
+ // src/scoring.ts
684
+ function scorePage(page, queries = []) {
685
+ let score = 0;
686
+ const tags = [];
687
+ const evidence = [];
688
+ if (page.gscImpressions28d >= 1e3 && page.gscCtr28d < 0.03) {
689
+ score += 35;
690
+ tags.push("high-impression-low-ctr");
691
+ evidence.push({
692
+ tag: "high-impression-low-ctr",
693
+ reason: `CTR ${toPercent(page.gscCtr28d)} with ${page.gscImpressions28d} impressions`
694
+ });
695
+ }
696
+ if (page.gscPosition28d >= 4 && page.gscPosition28d <= 15 && page.gscImpressions28d >= 300) {
697
+ score += 20;
698
+ tags.push("ranking-near-page-1");
699
+ evidence.push({
700
+ tag: "ranking-near-page-1",
701
+ reason: `Average position ${page.gscPosition28d.toFixed(1)} with meaningful impressions`
702
+ });
703
+ }
704
+ if (page.gscClicksPrev28d > 0 && page.gscClicks28d <= page.gscClicksPrev28d * 0.8 || page.gaViewsPrev28d > 0 && page.gaViews28d <= page.gaViewsPrev28d * 0.8) {
705
+ score += 20;
706
+ tags.push("traffic-decline");
707
+ evidence.push({
708
+ tag: "traffic-decline",
709
+ reason: `Traffic declined versus previous window`
710
+ });
711
+ }
712
+ if (page.gaViews28d >= 200 && (page.gaEngagementRate28d < 0.5 || page.gaBounceRate28d > 0.6)) {
713
+ score += 15;
714
+ tags.push("weak-engagement");
715
+ evidence.push({
716
+ tag: "weak-engagement",
717
+ reason: `Engagement ${toPercent(page.gaEngagementRate28d)}, bounce ${toPercent(page.gaBounceRate28d)}`
718
+ });
719
+ }
720
+ const hasQueryGap = queries.some((query) => query.impressions28d >= 100 && query.ctr28d < 0.02);
721
+ if (hasQueryGap) {
722
+ score += 10;
723
+ tags.push("query-capture-gap");
724
+ evidence.push({
725
+ tag: "query-capture-gap",
726
+ reason: "One or more high-impression queries have weak CTR"
727
+ });
728
+ }
729
+ return { score, tags, evidence };
730
+ }
731
+ function toPercent(value) {
732
+ return `${(value * 100).toFixed(1)}%`;
733
+ }
734
+
735
+ // src/sync.ts
736
+ async function getStatus(ctx) {
737
+ const config = await loadConfig(ctx);
738
+ return {
739
+ config: config ? {
740
+ siteOrigin: config.siteOrigin,
741
+ ga4PropertyId: config.ga4PropertyId,
742
+ gscSiteUrl: config.gscSiteUrl,
743
+ hasServiceAccount: true,
744
+ serviceAccountEmail: parseServiceAccount(config.serviceAccountJson).client_email
745
+ } : null,
746
+ summary: await ctx.kv.get(SITE_SUMMARY_KEY),
747
+ freshness: await getFreshness(ctx)
748
+ };
749
+ }
750
+ async function testConnection(ctx, draftConfig) {
751
+ const config = draftConfig ?? await loadConfig(ctx);
752
+ if (!config) {
753
+ throw new PluginRouteError("BAD_REQUEST", "Analytics connection is not configured", 400);
754
+ }
755
+ if (!ctx.http) {
756
+ throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
757
+ }
758
+ return runConnectionTest(ctx.http, config, parseServiceAccount(config.serviceAccountJson));
759
+ }
760
+ async function syncBase(ctx, jobType) {
761
+ const config = await requireConfig(ctx);
762
+ if (!ctx.http) {
763
+ throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
764
+ }
765
+ const serviceAccount = parseServiceAccount(config.serviceAccountJson);
766
+ const windows = buildWindows();
767
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
768
+ const managedMap = await getManagedContentMap(config.siteOrigin);
769
+ const [gscCurrent, gscPrevious, gscTrend, gaCurrent, gaPrevious, gaTrend] = await Promise.all([
770
+ fetchGscPageMetrics(ctx.http, config, serviceAccount, windows.gscCurrent),
771
+ fetchGscPageMetrics(ctx.http, config, serviceAccount, windows.gscPrevious),
772
+ fetchGscDailyTrend(ctx.http, config, serviceAccount, windows.gscCurrent),
773
+ fetchGaPageMetrics(ctx.http, config, serviceAccount, windows.gaCurrent),
774
+ fetchGaPageMetrics(ctx.http, config, serviceAccount, windows.gaPrevious),
775
+ fetchGaDailyTrend(ctx.http, config, serviceAccount, windows.gaCurrent)
776
+ ]);
777
+ const host = new URL(config.siteOrigin).hostname;
778
+ const pages = /* @__PURE__ */ new Map();
779
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
780
+ const ensurePage = (urlPath) => {
781
+ const existing = pages.get(urlPath);
782
+ if (existing) return existing;
783
+ const managed = managedMap.get(urlPath) ?? null;
784
+ const created = {
785
+ urlPath,
786
+ host,
787
+ pageKind: classifyPageKind(urlPath),
788
+ managed: !!managed,
789
+ title: managed?.title || urlPath,
790
+ contentCollection: managed?.collection || null,
791
+ contentId: managed?.id || null,
792
+ contentSlug: managed?.slug || null,
793
+ gscClicks28d: 0,
794
+ gscImpressions28d: 0,
795
+ gscCtr28d: 0,
796
+ gscPosition28d: 0,
797
+ gscClicksPrev28d: 0,
798
+ gscImpressionsPrev28d: 0,
799
+ gaViews28d: 0,
800
+ gaUsers28d: 0,
801
+ gaSessions28d: 0,
802
+ gaEngagementRate28d: 0,
803
+ gaBounceRate28d: 0,
804
+ gaAvgSessionDuration28d: 0,
805
+ gaViewsPrev28d: 0,
806
+ gaUsersPrev28d: 0,
807
+ gaSessionsPrev28d: 0,
808
+ opportunityScore: 0,
809
+ opportunityTags: [],
810
+ lastSyncedAt: nowIso,
811
+ lastGscDate: windows.gscCurrent.endDate,
812
+ lastGaDate: windows.gaCurrent.endDate
813
+ };
814
+ pages.set(urlPath, created);
815
+ return created;
816
+ };
817
+ for (const metric of gscCurrent) {
818
+ const page = ensurePage(metric.urlPath);
819
+ page.gscClicks28d = metric.clicks;
820
+ page.gscImpressions28d = metric.impressions;
821
+ page.gscCtr28d = metric.ctr;
822
+ page.gscPosition28d = metric.position;
823
+ }
824
+ for (const metric of gscPrevious) {
825
+ const page = ensurePage(metric.urlPath);
826
+ page.gscClicksPrev28d = metric.clicks;
827
+ page.gscImpressionsPrev28d = metric.impressions;
828
+ }
829
+ for (const metric of gaCurrent) {
830
+ const page = ensurePage(metric.urlPath);
831
+ page.gaViews28d = metric.views;
832
+ page.gaUsers28d = metric.users;
833
+ page.gaSessions28d = metric.sessions;
834
+ page.gaEngagementRate28d = metric.engagementRate;
835
+ page.gaBounceRate28d = metric.bounceRate;
836
+ page.gaAvgSessionDuration28d = metric.averageSessionDuration;
837
+ }
838
+ for (const metric of gaPrevious) {
839
+ const page = ensurePage(metric.urlPath);
840
+ page.gaViewsPrev28d = metric.views;
841
+ page.gaUsersPrev28d = metric.users;
842
+ page.gaSessionsPrev28d = metric.sessions;
843
+ }
844
+ for (const page of pages.values()) {
845
+ const score = scorePage(page, []);
846
+ page.opportunityScore = score.score;
847
+ page.opportunityTags = score.tags;
848
+ }
849
+ await ctx.storage.pages.putMany(
850
+ Array.from(pages.values()).map((page) => ({
851
+ id: pageStorageId(page.urlPath),
852
+ data: page
853
+ }))
854
+ );
855
+ const combinedTrend = mergeTrend(gscTrend, gaTrend);
856
+ await ctx.storage.daily_metrics.putMany(
857
+ combinedTrend.flatMap((row) => [
858
+ {
859
+ id: dailyMetricStorageId("gsc", "all_public", row.date),
860
+ data: {
861
+ source: "gsc",
862
+ scope: "all_public",
863
+ date: row.date,
864
+ clicks: row.clicks,
865
+ impressions: row.impressions,
866
+ views: 0,
867
+ sessions: 0,
868
+ users: 0
869
+ }
870
+ },
871
+ {
872
+ id: dailyMetricStorageId("ga", "all_public", row.date),
873
+ data: {
874
+ source: "ga",
875
+ scope: "all_public",
876
+ date: row.date,
877
+ clicks: 0,
878
+ impressions: 0,
879
+ views: row.views,
880
+ sessions: row.sessions,
881
+ users: row.users
882
+ }
883
+ }
884
+ ])
885
+ );
886
+ const freshness = {
887
+ lastSyncedAt: nowIso,
888
+ lastGscDate: windows.gscCurrent.endDate,
889
+ lastGaDate: windows.gaCurrent.endDate,
890
+ lastStatus: "success"
891
+ };
892
+ const summary = buildSummary(Array.from(pages.values()), combinedTrend, windows);
893
+ await Promise.all([
894
+ ctx.kv.set(FRESHNESS_KEY, freshness),
895
+ ctx.kv.set(SITE_SUMMARY_KEY, summary),
896
+ writeSyncRun(ctx, {
897
+ jobType,
898
+ status: "success",
899
+ startedAt,
900
+ finishedAt: nowIso,
901
+ summary: {
902
+ trackedPages: pages.size,
903
+ managedPages: Array.from(pages.values()).filter((page) => page.managed).length
904
+ },
905
+ error: null
906
+ })
907
+ ]);
908
+ return {
909
+ trackedPages: pages.size,
910
+ managedPages: Array.from(pages.values()).filter((page) => page.managed).length
911
+ };
912
+ }
913
+ async function enrichManagedQueries(ctx) {
914
+ const config = await requireConfig(ctx);
915
+ if (!ctx.http) {
916
+ throw new PluginRouteError("INTERNAL_ERROR", "HTTP capability is unavailable", 500);
917
+ }
918
+ const serviceAccount = parseServiceAccount(config.serviceAccountJson);
919
+ const windows = buildWindows();
920
+ const candidateResult = await ctx.storage.pages.query({
921
+ where: {
922
+ managed: true,
923
+ opportunityScore: { gt: 0 }
924
+ },
925
+ orderBy: { opportunityScore: "desc" },
926
+ limit: GSC_QUERY_PAGE_LIMIT
927
+ });
928
+ let refreshedPages = 0;
929
+ for (const item of candidateResult.items) {
930
+ const page = item.data;
931
+ const queries = await fetchGscPageQueries(
932
+ ctx.http,
933
+ config,
934
+ serviceAccount,
935
+ page.urlPath,
936
+ windows.gscCurrent,
937
+ GSC_QUERY_ROW_LIMIT
938
+ );
939
+ const existing = await ctx.storage.page_queries.query({
940
+ where: { urlPath: page.urlPath },
941
+ limit: 100
942
+ });
943
+ if (existing.items.length > 0) {
944
+ await ctx.storage.page_queries.deleteMany(existing.items.map((entry) => entry.id));
945
+ }
946
+ if (queries.length > 0) {
947
+ await ctx.storage.page_queries.putMany(
948
+ queries.map((query) => ({
949
+ id: pageQueryStorageId(page.urlPath, query.query),
950
+ data: {
951
+ urlPath: page.urlPath,
952
+ query: query.query,
953
+ clicks28d: query.clicks,
954
+ impressions28d: query.impressions,
955
+ ctr28d: query.ctr,
956
+ position28d: query.position,
957
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
958
+ }
959
+ }))
960
+ );
961
+ }
962
+ const queryRows = await getPageQueries(ctx, page.urlPath);
963
+ const rescored = scorePage(page, queryRows);
964
+ page.opportunityScore = rescored.score;
965
+ page.opportunityTags = rescored.tags;
966
+ page.lastSyncedAt = (/* @__PURE__ */ new Date()).toISOString();
967
+ await ctx.storage.pages.put(pageStorageId(page.urlPath), page);
968
+ refreshedPages += 1;
969
+ }
970
+ await refreshSummaryFromStorage(ctx);
971
+ await writeSyncRun(ctx, {
972
+ jobType: CRON_ENRICH_MANAGED,
973
+ status: "success",
974
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
975
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
976
+ summary: { refreshedPages },
977
+ error: null
978
+ });
979
+ return { refreshedPages };
980
+ }
981
+ async function listPages(ctx, filters) {
982
+ const where = {};
983
+ if (filters.managed === "managed") where.managed = true;
984
+ if (filters.managed === "unmanaged") where.managed = false;
985
+ if (filters.pageKind && filters.pageKind !== "all") where.pageKind = filters.pageKind;
986
+ if (filters.hasOpportunity) where.opportunityScore = { gt: 0 };
987
+ const result = await ctx.storage.pages.query({
988
+ where,
989
+ orderBy: { opportunityScore: "desc" },
990
+ limit: filters.limit ?? 50,
991
+ cursor: filters.cursor
992
+ });
993
+ return {
994
+ items: result.items.map((item) => item.data),
995
+ cursor: result.cursor,
996
+ hasMore: result.hasMore
997
+ };
998
+ }
999
+ async function getOverview(ctx) {
1000
+ const [summary, freshness, topOpportunities, topUnmanaged] = await Promise.all([
1001
+ ctx.kv.get(SITE_SUMMARY_KEY),
1002
+ getFreshness(ctx),
1003
+ ctx.storage.pages.query({
1004
+ where: { managed: true, opportunityScore: { gt: 0 } },
1005
+ orderBy: { opportunityScore: "desc" },
1006
+ limit: 5
1007
+ }),
1008
+ ctx.storage.pages.query({
1009
+ where: { managed: false },
1010
+ orderBy: { gaViews28d: "desc" },
1011
+ limit: 5
1012
+ })
1013
+ ]);
1014
+ return {
1015
+ summary,
1016
+ freshness,
1017
+ topOpportunities: topOpportunities.items.map((item) => item.data),
1018
+ topUnmanaged: topUnmanaged.items.map((item) => item.data)
1019
+ };
1020
+ }
1021
+ async function getContentContext(ctx, collection, id, slug) {
1022
+ const config = await requireConfig(ctx);
1023
+ const managedMap = await getManagedContentMap(config.siteOrigin);
1024
+ let contentRef = null;
1025
+ if (id) {
1026
+ contentRef = Array.from(managedMap.values()).find(
1027
+ (entry) => entry.collection === collection && entry.id === id
1028
+ ) || null;
1029
+ }
1030
+ if (!contentRef && slug) {
1031
+ contentRef = Array.from(managedMap.values()).find(
1032
+ (entry) => entry.collection === collection && entry.slug === slug
1033
+ ) || null;
1034
+ }
1035
+ if (!contentRef) {
1036
+ throw new PluginRouteError("NOT_FOUND", "Managed content not found", 404);
1037
+ }
1038
+ const page = await loadPage(ctx, contentRef.urlPath);
1039
+ if (!page) {
1040
+ throw new PluginRouteError("NOT_FOUND", "Analytics data not found for content", 404);
1041
+ }
1042
+ const queries = await getFreshQueriesForPage(ctx, config, contentRef.urlPath);
1043
+ const windows = buildWindows();
1044
+ const score = scorePage(page, queries);
1045
+ const freshness = await getFreshness(ctx);
1046
+ return {
1047
+ content: {
1048
+ collection: "posts",
1049
+ id: contentRef.id,
1050
+ slug: contentRef.slug,
1051
+ title: contentRef.title,
1052
+ urlPath: contentRef.urlPath,
1053
+ url: buildContentUrl(config.siteOrigin, contentRef.urlPath),
1054
+ excerpt: contentRef.excerpt ?? null,
1055
+ seoDescription: contentRef.seoDescription ?? null
1056
+ },
1057
+ analytics: {
1058
+ window: windows,
1059
+ page: {
1060
+ ...page,
1061
+ gscClicksDelta: page.gscClicks28d - page.gscClicksPrev28d,
1062
+ gscImpressionsDelta: page.gscImpressions28d - page.gscImpressionsPrev28d,
1063
+ gaViewsDelta: page.gaViews28d - page.gaViewsPrev28d,
1064
+ gaUsersDelta: page.gaUsers28d - page.gaUsersPrev28d,
1065
+ gaSessionsDelta: page.gaSessions28d - page.gaSessionsPrev28d
1066
+ },
1067
+ searchQueries: queries,
1068
+ opportunities: score.evidence,
1069
+ freshness
1070
+ }
1071
+ };
1072
+ }
1073
+ async function listAgentKeys(ctx) {
1074
+ const result = await ctx.storage.agent_keys.query({
1075
+ orderBy: { createdAt: "desc" },
1076
+ limit: 100
1077
+ });
1078
+ return result.items.map((item) => {
1079
+ const record = item.data;
1080
+ return {
1081
+ prefix: record.prefix,
1082
+ label: record.label,
1083
+ createdAt: record.createdAt,
1084
+ lastUsedAt: record.lastUsedAt,
1085
+ revokedAt: record.revokedAt
1086
+ };
1087
+ });
1088
+ }
1089
+ async function createAgentKey(ctx, label) {
1090
+ const created = generatePrefixedToken(AGENT_KEY_PREFIX);
1091
+ const key = created.raw;
1092
+ const hash = hashPrefixedToken(key);
1093
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1094
+ const record = {
1095
+ prefix: created.prefix,
1096
+ hash,
1097
+ label,
1098
+ createdAt: now,
1099
+ lastUsedAt: null,
1100
+ revokedAt: null
1101
+ };
1102
+ await ctx.storage.agent_keys.put(hash, record);
1103
+ return {
1104
+ key,
1105
+ metadata: {
1106
+ prefix: record.prefix,
1107
+ label: record.label,
1108
+ createdAt: record.createdAt,
1109
+ lastUsedAt: null,
1110
+ revokedAt: null
1111
+ }
1112
+ };
1113
+ }
1114
+ async function revokeAgentKey(ctx, prefix) {
1115
+ const result = await ctx.storage.agent_keys.query({
1116
+ where: { prefix },
1117
+ limit: 1
1118
+ });
1119
+ const item = result.items[0];
1120
+ if (!item) {
1121
+ throw new PluginRouteError("NOT_FOUND", "Agent key not found", 404);
1122
+ }
1123
+ const record = item.data;
1124
+ record.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
1125
+ await ctx.storage.agent_keys.put(item.id, record);
1126
+ }
1127
+ async function authenticateAgentRequest(ctx, request) {
1128
+ const token = extractAgentToken(request);
1129
+ if (!token.startsWith(AGENT_KEY_PREFIX)) {
1130
+ throw new PluginRouteError("UNAUTHORIZED", "Missing or invalid agent key", 401);
1131
+ }
1132
+ const hash = hashPrefixedToken(token);
1133
+ const record = await ctx.storage.agent_keys.get(hash);
1134
+ const keyRecord = record;
1135
+ if (!keyRecord || keyRecord.revokedAt) {
1136
+ throw new PluginRouteError("UNAUTHORIZED", "Missing or invalid agent key", 401);
1137
+ }
1138
+ keyRecord.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
1139
+ await ctx.storage.agent_keys.put(hash, keyRecord);
1140
+ }
1141
+ function extractAgentToken(request) {
1142
+ const authHeader = request.headers.get("Authorization") || "";
1143
+ if (authHeader.startsWith("AgentKey ")) {
1144
+ return authHeader.slice("AgentKey ".length).trim();
1145
+ }
1146
+ const headerToken = request.headers.get("X-Emdash-Agent-Key") || "";
1147
+ if (headerToken.trim()) {
1148
+ return headerToken.trim();
1149
+ }
1150
+ return "";
1151
+ }
1152
+ async function handleCron(ctx, eventName) {
1153
+ if (eventName === CRON_SYNC_BASE) {
1154
+ await syncBase(ctx, CRON_SYNC_BASE);
1155
+ return;
1156
+ }
1157
+ if (eventName === CRON_ENRICH_MANAGED) {
1158
+ await enrichManagedQueries(ctx);
1159
+ }
1160
+ }
1161
+ async function requireConfig(ctx) {
1162
+ const config = await loadConfig(ctx);
1163
+ if (!config) {
1164
+ throw new PluginRouteError("BAD_REQUEST", "Analytics connection is not configured", 400);
1165
+ }
1166
+ return config;
1167
+ }
1168
+ async function loadPage(ctx, urlPath) {
1169
+ const page = await ctx.storage.pages.get(pageStorageId(urlPath));
1170
+ return page ?? null;
1171
+ }
1172
+ async function getFreshQueriesForPage(ctx, config, urlPath) {
1173
+ let queries = await getPageQueries(ctx, urlPath);
1174
+ const updatedAt = queries[0]?.updatedAt ? Date.parse(queries[0].updatedAt) : 0;
1175
+ const isStale = !updatedAt || Date.now() - updatedAt > QUERY_REFRESH_STALE_HOURS * 60 * 60 * 1e3;
1176
+ if (!isStale || !ctx.http) return queries;
1177
+ const serviceAccount = parseServiceAccount(config.serviceAccountJson);
1178
+ const windows = buildWindows();
1179
+ const refreshed = await fetchGscPageQueries(
1180
+ ctx.http,
1181
+ config,
1182
+ serviceAccount,
1183
+ urlPath,
1184
+ windows.gscCurrent,
1185
+ GSC_QUERY_ROW_LIMIT
1186
+ );
1187
+ const existing = await ctx.storage.page_queries.query({
1188
+ where: { urlPath },
1189
+ limit: 100
1190
+ });
1191
+ if (existing.items.length > 0) {
1192
+ await ctx.storage.page_queries.deleteMany(existing.items.map((entry) => entry.id));
1193
+ }
1194
+ if (refreshed.length > 0) {
1195
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1196
+ await ctx.storage.page_queries.putMany(
1197
+ refreshed.map((query) => ({
1198
+ id: pageQueryStorageId(urlPath, query.query),
1199
+ data: {
1200
+ urlPath,
1201
+ query: query.query,
1202
+ clicks28d: query.clicks,
1203
+ impressions28d: query.impressions,
1204
+ ctr28d: query.ctr,
1205
+ position28d: query.position,
1206
+ updatedAt: nowIso
1207
+ }
1208
+ }))
1209
+ );
1210
+ }
1211
+ queries = await getPageQueries(ctx, urlPath);
1212
+ const page = await loadPage(ctx, urlPath);
1213
+ if (page) {
1214
+ const rescored = scorePage(page, queries);
1215
+ page.opportunityScore = rescored.score;
1216
+ page.opportunityTags = rescored.tags;
1217
+ page.lastSyncedAt = (/* @__PURE__ */ new Date()).toISOString();
1218
+ await ctx.storage.pages.put(pageStorageId(urlPath), page);
1219
+ }
1220
+ return queries;
1221
+ }
1222
+ async function getPageQueries(ctx, urlPath) {
1223
+ const result = await ctx.storage.page_queries.query({
1224
+ where: { urlPath },
1225
+ orderBy: { impressions28d: "desc" },
1226
+ limit: 50
1227
+ });
1228
+ return result.items.map((item) => item.data);
1229
+ }
1230
+ async function getFreshness(ctx) {
1231
+ return await ctx.kv.get(FRESHNESS_KEY) || {
1232
+ lastSyncedAt: null,
1233
+ lastGscDate: null,
1234
+ lastGaDate: null,
1235
+ lastStatus: "idle"
1236
+ };
1237
+ }
1238
+ async function refreshSummaryFromStorage(ctx) {
1239
+ const windows = buildWindows();
1240
+ const allPages = [];
1241
+ let cursor;
1242
+ do {
1243
+ const pageBatch = await ctx.storage.pages.query({
1244
+ limit: 500,
1245
+ cursor
1246
+ });
1247
+ cursor = pageBatch.cursor;
1248
+ allPages.push(...pageBatch.items.map((item) => item.data));
1249
+ } while (cursor);
1250
+ const trendMap = /* @__PURE__ */ new Map();
1251
+ let dailyCursor;
1252
+ do {
1253
+ const batch = await ctx.storage.daily_metrics.query({
1254
+ limit: 1e3,
1255
+ cursor: dailyCursor
1256
+ });
1257
+ dailyCursor = batch.cursor;
1258
+ for (const item of batch.items) {
1259
+ const row = item.data;
1260
+ let existing = trendMap.get(row.date);
1261
+ if (!existing) {
1262
+ existing = { date: row.date, clicks: 0, impressions: 0, views: 0, sessions: 0, users: 0 };
1263
+ trendMap.set(row.date, existing);
1264
+ }
1265
+ existing.clicks += row.clicks;
1266
+ existing.impressions += row.impressions;
1267
+ existing.views += row.views;
1268
+ existing.sessions += row.sessions;
1269
+ existing.users += row.users;
1270
+ }
1271
+ } while (dailyCursor);
1272
+ await ctx.kv.set(
1273
+ SITE_SUMMARY_KEY,
1274
+ buildSummary(allPages, Array.from(trendMap.values()).sort((a, b) => a.date.localeCompare(b.date)), windows)
1275
+ );
1276
+ }
1277
+ async function writeSyncRun(ctx, record) {
1278
+ await ctx.storage.sync_runs.put(ulid(), record);
1279
+ }
1280
+ function buildSummary(pages, trend, windows) {
1281
+ return {
1282
+ window: windows,
1283
+ totals: {
1284
+ gscClicks28d: pages.reduce((total, page) => total + page.gscClicks28d, 0),
1285
+ gscImpressions28d: pages.reduce((total, page) => total + page.gscImpressions28d, 0),
1286
+ gaViews28d: pages.reduce((total, page) => total + page.gaViews28d, 0),
1287
+ gaUsers28d: pages.reduce((total, page) => total + page.gaUsers28d, 0),
1288
+ gaSessions28d: pages.reduce((total, page) => total + page.gaSessions28d, 0),
1289
+ managedOpportunities: pages.filter((page) => page.managed && page.opportunityScore > 0).length,
1290
+ trackedPages: pages.length
1291
+ },
1292
+ trend: trend.map((row) => ({
1293
+ date: row.date,
1294
+ gscClicks: row.clicks,
1295
+ gscImpressions: row.impressions,
1296
+ gaViews: row.views,
1297
+ gaSessions: row.sessions,
1298
+ gaUsers: row.users
1299
+ }))
1300
+ };
1301
+ }
1302
+ function mergeTrend(gscTrend, gaTrend) {
1303
+ const map = /* @__PURE__ */ new Map();
1304
+ for (const row of gscTrend) {
1305
+ map.set(row.date, {
1306
+ date: row.date,
1307
+ clicks: row.clicks,
1308
+ impressions: row.impressions,
1309
+ views: 0,
1310
+ sessions: 0,
1311
+ users: 0
1312
+ });
1313
+ }
1314
+ for (const row of gaTrend) {
1315
+ const existing = map.get(row.date) || {
1316
+ date: row.date,
1317
+ clicks: 0,
1318
+ impressions: 0,
1319
+ views: 0,
1320
+ sessions: 0,
1321
+ users: 0
1322
+ };
1323
+ existing.views = row.views;
1324
+ existing.sessions = row.sessions;
1325
+ existing.users = row.users;
1326
+ map.set(row.date, existing);
1327
+ }
1328
+ return Array.from(map.values()).sort((left, right) => left.date.localeCompare(right.date));
1329
+ }
1330
+
1331
+ // src/index.ts
1332
+ var configSaveSchema = z2.object({
1333
+ siteOrigin: z2.string().optional(),
1334
+ ga4PropertyId: z2.string().optional(),
1335
+ gscSiteUrl: z2.string().optional(),
1336
+ serviceAccountJson: z2.string().optional()
1337
+ });
1338
+ var pageListSchema = z2.object({
1339
+ managed: z2.enum(["all", "managed", "unmanaged"]).optional(),
1340
+ hasOpportunity: z2.boolean().optional(),
1341
+ pageKind: z2.enum(["all", "blog_post", "blog_archive", "tag", "author", "landing", "other"]).optional(),
1342
+ limit: z2.number().int().min(1).max(100).optional(),
1343
+ cursor: z2.string().optional()
1344
+ });
1345
+ var contentContextSchema = z2.object({
1346
+ collection: z2.string().default("posts"),
1347
+ id: z2.string().optional(),
1348
+ slug: z2.string().optional()
1349
+ });
1350
+ var agentKeyCreateSchema = z2.object({
1351
+ label: z2.string().min(1).max(200)
1352
+ });
1353
+ var agentKeyRevokeSchema = z2.object({
1354
+ prefix: z2.string().min(1)
1355
+ });
1356
+ function contentInsightsPlugin() {
1357
+ return {
1358
+ id: PLUGIN_ID,
1359
+ version: PLUGIN_VERSION,
1360
+ entrypoint: "@yourbright/emdash-analytics-plugin",
1361
+ adminEntry: "@yourbright/emdash-analytics-plugin/admin",
1362
+ capabilities: ["network:fetch", "read:content"],
1363
+ allowedHosts: [
1364
+ "oauth2.googleapis.com",
1365
+ "analyticsdata.googleapis.com",
1366
+ "www.googleapis.com"
1367
+ ],
1368
+ adminPages: [
1369
+ { path: "/", label: "Overview", icon: "chart-bar" },
1370
+ { path: "/pages", label: "Pages", icon: "list" },
1371
+ { path: "/settings", label: "Analytics", icon: "gear" }
1372
+ ],
1373
+ adminWidgets: [
1374
+ { id: "content-opportunities", title: "Content Opportunities", size: "full" }
1375
+ ],
1376
+ options: {}
1377
+ };
1378
+ }
1379
+ function createPlugin() {
1380
+ return definePlugin({
1381
+ id: PLUGIN_ID,
1382
+ version: PLUGIN_VERSION,
1383
+ capabilities: ["network:fetch", "read:content"],
1384
+ allowedHosts: [
1385
+ "oauth2.googleapis.com",
1386
+ "analyticsdata.googleapis.com",
1387
+ "www.googleapis.com"
1388
+ ],
1389
+ storage: {
1390
+ pages: {
1391
+ indexes: [
1392
+ "managed",
1393
+ "pageKind",
1394
+ "opportunityScore",
1395
+ "gaViews28d",
1396
+ "urlPath",
1397
+ "contentCollection",
1398
+ "contentId",
1399
+ "contentSlug"
1400
+ ],
1401
+ uniqueIndexes: ["urlPath"]
1402
+ },
1403
+ page_queries: {
1404
+ indexes: ["urlPath", "impressions28d", "updatedAt"],
1405
+ uniqueIndexes: [["urlPath", "query"]]
1406
+ },
1407
+ daily_metrics: {
1408
+ indexes: ["source", "scope", "date"],
1409
+ uniqueIndexes: [["source", "scope", "date"]]
1410
+ },
1411
+ sync_runs: {
1412
+ indexes: ["jobType", "status", "startedAt"]
1413
+ },
1414
+ agent_keys: {
1415
+ indexes: ["prefix", "createdAt", "revokedAt"],
1416
+ uniqueIndexes: ["hash", "prefix"]
1417
+ }
1418
+ },
1419
+ hooks: {
1420
+ "plugin:activate": {
1421
+ handler: async (_event, ctx) => {
1422
+ if (ctx.cron) {
1423
+ await ctx.cron.schedule(CRON_SYNC_BASE, { schedule: "0 */6 * * *" });
1424
+ await ctx.cron.schedule(CRON_ENRICH_MANAGED, { schedule: "0 2 * * *" });
1425
+ }
1426
+ }
1427
+ },
1428
+ cron: {
1429
+ handler: async (event, ctx) => {
1430
+ await handleCron(ctx, event.name);
1431
+ }
1432
+ }
1433
+ },
1434
+ routes: {
1435
+ [ADMIN_ROUTES.STATUS]: {
1436
+ handler: async (ctx) => getStatus(ctx)
1437
+ },
1438
+ [ADMIN_ROUTES.OVERVIEW]: {
1439
+ handler: async (ctx) => getOverview(ctx)
1440
+ },
1441
+ [ADMIN_ROUTES.LIST_PAGES]: {
1442
+ input: pageListSchema,
1443
+ handler: async (ctx) => listPages(ctx, ctx.input)
1444
+ },
1445
+ [ADMIN_ROUTES.CONTENT_CONTEXT]: {
1446
+ input: contentContextSchema,
1447
+ handler: async (ctx) => {
1448
+ const input = ctx.input;
1449
+ return getContentContext(ctx, input.collection, input.id, input.slug);
1450
+ }
1451
+ },
1452
+ [ADMIN_ROUTES.CONFIG_GET]: {
1453
+ handler: async (ctx) => getConfigSummary(ctx)
1454
+ },
1455
+ [ADMIN_ROUTES.CONFIG_SAVE]: {
1456
+ input: configSaveSchema,
1457
+ handler: async (ctx) => {
1458
+ const input = ctx.input;
1459
+ const current = await loadConfig(ctx);
1460
+ const resolved = resolveConfigInput(input, current);
1461
+ if (!resolved.success) {
1462
+ throw new PluginRouteError2("BAD_REQUEST", resolved.message, 400);
1463
+ }
1464
+ return saveConfig(ctx, resolved.data);
1465
+ }
1466
+ },
1467
+ [ADMIN_ROUTES.CONNECTION_TEST]: {
1468
+ input: configSaveSchema,
1469
+ handler: async (ctx) => {
1470
+ const input = ctx.input;
1471
+ const current = await loadConfig(ctx);
1472
+ const resolved = resolveConfigInput(input, current);
1473
+ if (!resolved.success) {
1474
+ throw new PluginRouteError2("BAD_REQUEST", resolved.message, 400);
1475
+ }
1476
+ return testConnection(ctx, resolved.data);
1477
+ }
1478
+ },
1479
+ [ADMIN_ROUTES.SYNC_NOW]: {
1480
+ handler: async (ctx) => {
1481
+ const base = await syncBase(ctx, "manual");
1482
+ const enriched = await enrichManagedQueries(ctx);
1483
+ return { ...base, ...enriched };
1484
+ }
1485
+ },
1486
+ [ADMIN_ROUTES.AGENT_KEYS_LIST]: {
1487
+ handler: async (ctx) => listAgentKeys(ctx)
1488
+ },
1489
+ [ADMIN_ROUTES.AGENT_KEYS_CREATE]: {
1490
+ input: agentKeyCreateSchema,
1491
+ handler: async (ctx) => createAgentKey(ctx, ctx.input.label)
1492
+ },
1493
+ [ADMIN_ROUTES.AGENT_KEYS_REVOKE]: {
1494
+ input: agentKeyRevokeSchema,
1495
+ handler: async (ctx) => {
1496
+ await revokeAgentKey(ctx, ctx.input.prefix);
1497
+ return { success: true };
1498
+ }
1499
+ },
1500
+ [PUBLIC_AGENT_ROUTES.SITE_SUMMARY]: {
1501
+ public: true,
1502
+ handler: async (ctx) => {
1503
+ await authenticateAgentRequest(ctx, ctx.request);
1504
+ const overview = await getOverview(ctx);
1505
+ return {
1506
+ summary: overview.summary,
1507
+ freshness: overview.freshness
1508
+ };
1509
+ }
1510
+ },
1511
+ [PUBLIC_AGENT_ROUTES.OPPORTUNITIES]: {
1512
+ public: true,
1513
+ handler: async (ctx) => {
1514
+ await authenticateAgentRequest(ctx, ctx.request);
1515
+ return listPages(ctx, {
1516
+ managed: "managed",
1517
+ hasOpportunity: true,
1518
+ limit: parsePositiveInt(new URL(ctx.request.url).searchParams.get("limit")) || 50,
1519
+ cursor: new URL(ctx.request.url).searchParams.get("cursor") || void 0
1520
+ });
1521
+ }
1522
+ },
1523
+ [PUBLIC_AGENT_ROUTES.CONTENT_CONTEXT]: {
1524
+ public: true,
1525
+ handler: async (ctx) => {
1526
+ await authenticateAgentRequest(ctx, ctx.request);
1527
+ const params = new URL(ctx.request.url).searchParams;
1528
+ const collection = params.get("collection") || "posts";
1529
+ const id = params.get("id") || void 0;
1530
+ const slug = params.get("slug") || void 0;
1531
+ if (!id && !slug) {
1532
+ throw new PluginRouteError2("BAD_REQUEST", "id or slug is required", 400);
1533
+ }
1534
+ return getContentContext(ctx, collection, id, slug);
1535
+ }
1536
+ }
1537
+ },
1538
+ admin: {
1539
+ pages: [
1540
+ { path: "/", label: "Overview", icon: "chart-bar" },
1541
+ { path: "/pages", label: "Pages", icon: "list" },
1542
+ { path: "/settings", label: "Analytics", icon: "gear" }
1543
+ ],
1544
+ widgets: [
1545
+ { id: "content-opportunities", title: "Content Opportunities", size: "full" }
1546
+ ]
1547
+ }
1548
+ });
1549
+ }
1550
+ var src_default = createPlugin;
1551
+ function parsePositiveInt(value) {
1552
+ if (!value) return void 0;
1553
+ const parsed = Number.parseInt(value, 10);
1554
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1555
+ }
1556
+ export {
1557
+ contentInsightsPlugin,
1558
+ createPlugin,
1559
+ src_default as default
1560
+ };