@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/admin.js +860 -0
- package/dist/index.js +1560 -0
- package/package.json +8 -5
- package/src/admin.tsx +0 -1138
- package/src/config-validation.ts +0 -153
- package/src/config.ts +0 -90
- package/src/constants.ts +0 -55
- package/src/content.ts +0 -133
- package/src/google.ts +0 -518
- package/src/index.ts +0 -270
- package/src/scoring.ts +0 -83
- package/src/sync.ts +0 -749
- package/src/types.ts +0 -193
package/src/index.ts
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
import type { PluginDescriptor } from "emdash";
|
|
2
|
-
import {
|
|
3
|
-
PluginRouteError,
|
|
4
|
-
definePlugin
|
|
5
|
-
} from "emdash";
|
|
6
|
-
import { z } from "astro/zod";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
ADMIN_ROUTES,
|
|
10
|
-
CRON_ENRICH_MANAGED,
|
|
11
|
-
CRON_SYNC_BASE,
|
|
12
|
-
PLUGIN_ID,
|
|
13
|
-
PLUGIN_VERSION,
|
|
14
|
-
PUBLIC_AGENT_ROUTES
|
|
15
|
-
} from "./constants.js";
|
|
16
|
-
import { resolveConfigInput } from "./config-validation.js";
|
|
17
|
-
import { getConfigSummary, loadConfig, saveConfig } from "./config.js";
|
|
18
|
-
import {
|
|
19
|
-
authenticateAgentRequest,
|
|
20
|
-
createAgentKey,
|
|
21
|
-
enrichManagedQueries,
|
|
22
|
-
getContentContext,
|
|
23
|
-
getOverview,
|
|
24
|
-
getStatus,
|
|
25
|
-
handleCron,
|
|
26
|
-
listAgentKeys,
|
|
27
|
-
listPages,
|
|
28
|
-
revokeAgentKey,
|
|
29
|
-
syncBase,
|
|
30
|
-
testConnection
|
|
31
|
-
} from "./sync.js";
|
|
32
|
-
|
|
33
|
-
const configSaveSchema = z.object({
|
|
34
|
-
siteOrigin: z.string().optional(),
|
|
35
|
-
ga4PropertyId: z.string().optional(),
|
|
36
|
-
gscSiteUrl: z.string().optional(),
|
|
37
|
-
serviceAccountJson: z.string().optional()
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const pageListSchema = z.object({
|
|
41
|
-
managed: z.enum(["all", "managed", "unmanaged"]).optional(),
|
|
42
|
-
hasOpportunity: z.boolean().optional(),
|
|
43
|
-
pageKind: z.enum(["all", "blog_post", "blog_archive", "tag", "author", "landing", "other"]).optional(),
|
|
44
|
-
limit: z.number().int().min(1).max(100).optional(),
|
|
45
|
-
cursor: z.string().optional()
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const contentContextSchema = z.object({
|
|
49
|
-
collection: z.string().default("posts"),
|
|
50
|
-
id: z.string().optional(),
|
|
51
|
-
slug: z.string().optional()
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const agentKeyCreateSchema = z.object({
|
|
55
|
-
label: z.string().min(1).max(200)
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const agentKeyRevokeSchema = z.object({
|
|
59
|
-
prefix: z.string().min(1)
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
type ConfigSaveInput = z.infer<typeof configSaveSchema>;
|
|
63
|
-
type PageListInput = z.infer<typeof pageListSchema>;
|
|
64
|
-
type ContentContextInput = z.infer<typeof contentContextSchema>;
|
|
65
|
-
type AgentKeyCreateInput = z.infer<typeof agentKeyCreateSchema>;
|
|
66
|
-
type AgentKeyRevokeInput = z.infer<typeof agentKeyRevokeSchema>;
|
|
67
|
-
|
|
68
|
-
export function contentInsightsPlugin(): PluginDescriptor {
|
|
69
|
-
return {
|
|
70
|
-
id: PLUGIN_ID,
|
|
71
|
-
version: PLUGIN_VERSION,
|
|
72
|
-
entrypoint: "@yourbright/emdash-analytics-plugin",
|
|
73
|
-
adminEntry: "@yourbright/emdash-analytics-plugin/admin",
|
|
74
|
-
capabilities: ["network:fetch", "read:content"],
|
|
75
|
-
allowedHosts: [
|
|
76
|
-
"oauth2.googleapis.com",
|
|
77
|
-
"analyticsdata.googleapis.com",
|
|
78
|
-
"www.googleapis.com"
|
|
79
|
-
],
|
|
80
|
-
adminPages: [
|
|
81
|
-
{ path: "/", label: "Overview", icon: "chart-bar" },
|
|
82
|
-
{ path: "/pages", label: "Pages", icon: "list" },
|
|
83
|
-
{ path: "/settings", label: "Analytics", icon: "gear" }
|
|
84
|
-
],
|
|
85
|
-
adminWidgets: [
|
|
86
|
-
{ id: "content-opportunities", title: "Content Opportunities", size: "full" }
|
|
87
|
-
],
|
|
88
|
-
options: {}
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function createPlugin() {
|
|
93
|
-
return definePlugin({
|
|
94
|
-
id: PLUGIN_ID,
|
|
95
|
-
version: PLUGIN_VERSION,
|
|
96
|
-
capabilities: ["network:fetch", "read:content"],
|
|
97
|
-
allowedHosts: [
|
|
98
|
-
"oauth2.googleapis.com",
|
|
99
|
-
"analyticsdata.googleapis.com",
|
|
100
|
-
"www.googleapis.com"
|
|
101
|
-
],
|
|
102
|
-
storage: {
|
|
103
|
-
pages: {
|
|
104
|
-
indexes: [
|
|
105
|
-
"managed",
|
|
106
|
-
"pageKind",
|
|
107
|
-
"opportunityScore",
|
|
108
|
-
"gaViews28d",
|
|
109
|
-
"urlPath",
|
|
110
|
-
"contentCollection",
|
|
111
|
-
"contentId",
|
|
112
|
-
"contentSlug"
|
|
113
|
-
],
|
|
114
|
-
uniqueIndexes: ["urlPath"]
|
|
115
|
-
},
|
|
116
|
-
page_queries: {
|
|
117
|
-
indexes: ["urlPath", "impressions28d", "updatedAt"],
|
|
118
|
-
uniqueIndexes: [["urlPath", "query"]]
|
|
119
|
-
},
|
|
120
|
-
daily_metrics: {
|
|
121
|
-
indexes: ["source", "scope", "date"],
|
|
122
|
-
uniqueIndexes: [["source", "scope", "date"]]
|
|
123
|
-
},
|
|
124
|
-
sync_runs: {
|
|
125
|
-
indexes: ["jobType", "status", "startedAt"]
|
|
126
|
-
},
|
|
127
|
-
agent_keys: {
|
|
128
|
-
indexes: ["prefix", "createdAt", "revokedAt"],
|
|
129
|
-
uniqueIndexes: ["hash", "prefix"]
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
hooks: {
|
|
133
|
-
"plugin:activate": {
|
|
134
|
-
handler: async (_event, ctx) => {
|
|
135
|
-
if (ctx.cron) {
|
|
136
|
-
await ctx.cron.schedule(CRON_SYNC_BASE, { schedule: "0 */6 * * *" });
|
|
137
|
-
await ctx.cron.schedule(CRON_ENRICH_MANAGED, { schedule: "0 2 * * *" });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
cron: {
|
|
142
|
-
handler: async (event, ctx) => {
|
|
143
|
-
await handleCron(ctx, event.name);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
routes: {
|
|
148
|
-
[ADMIN_ROUTES.STATUS]: {
|
|
149
|
-
handler: async (ctx) => getStatus(ctx)
|
|
150
|
-
},
|
|
151
|
-
[ADMIN_ROUTES.OVERVIEW]: {
|
|
152
|
-
handler: async (ctx) => getOverview(ctx)
|
|
153
|
-
},
|
|
154
|
-
[ADMIN_ROUTES.LIST_PAGES]: {
|
|
155
|
-
input: pageListSchema,
|
|
156
|
-
handler: async (ctx) => listPages(ctx, ctx.input as PageListInput)
|
|
157
|
-
},
|
|
158
|
-
[ADMIN_ROUTES.CONTENT_CONTEXT]: {
|
|
159
|
-
input: contentContextSchema,
|
|
160
|
-
handler: async (ctx) => {
|
|
161
|
-
const input = ctx.input as ContentContextInput;
|
|
162
|
-
return getContentContext(ctx, input.collection, input.id, input.slug);
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
[ADMIN_ROUTES.CONFIG_GET]: {
|
|
166
|
-
handler: async (ctx) => getConfigSummary(ctx)
|
|
167
|
-
},
|
|
168
|
-
[ADMIN_ROUTES.CONFIG_SAVE]: {
|
|
169
|
-
input: configSaveSchema,
|
|
170
|
-
handler: async (ctx) => {
|
|
171
|
-
const input = ctx.input as ConfigSaveInput;
|
|
172
|
-
const current = await loadConfig(ctx);
|
|
173
|
-
const resolved = resolveConfigInput(input, current);
|
|
174
|
-
if (!resolved.success) {
|
|
175
|
-
throw new PluginRouteError("BAD_REQUEST", resolved.message, 400);
|
|
176
|
-
}
|
|
177
|
-
return saveConfig(ctx, resolved.data);
|
|
178
|
-
}
|
|
179
|
-
},
|
|
180
|
-
[ADMIN_ROUTES.CONNECTION_TEST]: {
|
|
181
|
-
input: configSaveSchema,
|
|
182
|
-
handler: async (ctx) => {
|
|
183
|
-
const input = ctx.input as Partial<ConfigSaveInput>;
|
|
184
|
-
const current = await loadConfig(ctx);
|
|
185
|
-
const resolved = resolveConfigInput(input, current);
|
|
186
|
-
if (!resolved.success) {
|
|
187
|
-
throw new PluginRouteError("BAD_REQUEST", resolved.message, 400);
|
|
188
|
-
}
|
|
189
|
-
return testConnection(ctx, resolved.data);
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
[ADMIN_ROUTES.SYNC_NOW]: {
|
|
193
|
-
handler: async (ctx) => {
|
|
194
|
-
const base = await syncBase(ctx, "manual");
|
|
195
|
-
const enriched = await enrichManagedQueries(ctx);
|
|
196
|
-
return { ...base, ...enriched };
|
|
197
|
-
}
|
|
198
|
-
},
|
|
199
|
-
[ADMIN_ROUTES.AGENT_KEYS_LIST]: {
|
|
200
|
-
handler: async (ctx) => listAgentKeys(ctx)
|
|
201
|
-
},
|
|
202
|
-
[ADMIN_ROUTES.AGENT_KEYS_CREATE]: {
|
|
203
|
-
input: agentKeyCreateSchema,
|
|
204
|
-
handler: async (ctx) => createAgentKey(ctx, (ctx.input as AgentKeyCreateInput).label)
|
|
205
|
-
},
|
|
206
|
-
[ADMIN_ROUTES.AGENT_KEYS_REVOKE]: {
|
|
207
|
-
input: agentKeyRevokeSchema,
|
|
208
|
-
handler: async (ctx) => {
|
|
209
|
-
await revokeAgentKey(ctx, (ctx.input as AgentKeyRevokeInput).prefix);
|
|
210
|
-
return { success: true };
|
|
211
|
-
}
|
|
212
|
-
},
|
|
213
|
-
[PUBLIC_AGENT_ROUTES.SITE_SUMMARY]: {
|
|
214
|
-
public: true,
|
|
215
|
-
handler: async (ctx) => {
|
|
216
|
-
await authenticateAgentRequest(ctx, ctx.request);
|
|
217
|
-
const overview = await getOverview(ctx);
|
|
218
|
-
return {
|
|
219
|
-
summary: overview.summary,
|
|
220
|
-
freshness: overview.freshness
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
[PUBLIC_AGENT_ROUTES.OPPORTUNITIES]: {
|
|
225
|
-
public: true,
|
|
226
|
-
handler: async (ctx) => {
|
|
227
|
-
await authenticateAgentRequest(ctx, ctx.request);
|
|
228
|
-
return listPages(ctx, {
|
|
229
|
-
managed: "managed",
|
|
230
|
-
hasOpportunity: true,
|
|
231
|
-
limit: parsePositiveInt(new URL(ctx.request.url).searchParams.get("limit")) || 50,
|
|
232
|
-
cursor: new URL(ctx.request.url).searchParams.get("cursor") || undefined
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
[PUBLIC_AGENT_ROUTES.CONTENT_CONTEXT]: {
|
|
237
|
-
public: true,
|
|
238
|
-
handler: async (ctx) => {
|
|
239
|
-
await authenticateAgentRequest(ctx, ctx.request);
|
|
240
|
-
const params = new URL(ctx.request.url).searchParams;
|
|
241
|
-
const collection = params.get("collection") || "posts";
|
|
242
|
-
const id = params.get("id") || undefined;
|
|
243
|
-
const slug = params.get("slug") || undefined;
|
|
244
|
-
if (!id && !slug) {
|
|
245
|
-
throw new PluginRouteError("BAD_REQUEST", "id or slug is required", 400);
|
|
246
|
-
}
|
|
247
|
-
return getContentContext(ctx, collection, id, slug);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
admin: {
|
|
252
|
-
pages: [
|
|
253
|
-
{ path: "/", label: "Overview", icon: "chart-bar" },
|
|
254
|
-
{ path: "/pages", label: "Pages", icon: "list" },
|
|
255
|
-
{ path: "/settings", label: "Analytics", icon: "gear" }
|
|
256
|
-
],
|
|
257
|
-
widgets: [
|
|
258
|
-
{ id: "content-opportunities", title: "Content Opportunities", size: "full" }
|
|
259
|
-
]
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export default createPlugin;
|
|
265
|
-
|
|
266
|
-
function parsePositiveInt(value: string | null): number | undefined {
|
|
267
|
-
if (!value) return undefined;
|
|
268
|
-
const parsed = Number.parseInt(value, 10);
|
|
269
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
270
|
-
}
|
package/src/scoring.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import type { OpportunityEvidence, OpportunityTag, PageAggregateRecord, PageQueryRecord } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export function scorePage(
|
|
4
|
-
page: Pick<
|
|
5
|
-
PageAggregateRecord,
|
|
6
|
-
| "gscImpressions28d"
|
|
7
|
-
| "gscCtr28d"
|
|
8
|
-
| "gscPosition28d"
|
|
9
|
-
| "gscClicks28d"
|
|
10
|
-
| "gscClicksPrev28d"
|
|
11
|
-
| "gaViews28d"
|
|
12
|
-
| "gaViewsPrev28d"
|
|
13
|
-
| "gaEngagementRate28d"
|
|
14
|
-
| "gaBounceRate28d"
|
|
15
|
-
>,
|
|
16
|
-
queries: PageQueryRecord[] = []
|
|
17
|
-
): { score: number; tags: OpportunityTag[]; evidence: OpportunityEvidence[] } {
|
|
18
|
-
let score = 0;
|
|
19
|
-
const tags: OpportunityTag[] = [];
|
|
20
|
-
const evidence: OpportunityEvidence[] = [];
|
|
21
|
-
|
|
22
|
-
if (page.gscImpressions28d >= 1000 && page.gscCtr28d < 0.03) {
|
|
23
|
-
score += 35;
|
|
24
|
-
tags.push("high-impression-low-ctr");
|
|
25
|
-
evidence.push({
|
|
26
|
-
tag: "high-impression-low-ctr",
|
|
27
|
-
reason: `CTR ${toPercent(page.gscCtr28d)} with ${page.gscImpressions28d} impressions`
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
page.gscPosition28d >= 4 &&
|
|
33
|
-
page.gscPosition28d <= 15 &&
|
|
34
|
-
page.gscImpressions28d >= 300
|
|
35
|
-
) {
|
|
36
|
-
score += 20;
|
|
37
|
-
tags.push("ranking-near-page-1");
|
|
38
|
-
evidence.push({
|
|
39
|
-
tag: "ranking-near-page-1",
|
|
40
|
-
reason: `Average position ${page.gscPosition28d.toFixed(1)} with meaningful impressions`
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
(page.gscClicksPrev28d > 0 && page.gscClicks28d <= page.gscClicksPrev28d * 0.8) ||
|
|
46
|
-
(page.gaViewsPrev28d > 0 && page.gaViews28d <= page.gaViewsPrev28d * 0.8)
|
|
47
|
-
) {
|
|
48
|
-
score += 20;
|
|
49
|
-
tags.push("traffic-decline");
|
|
50
|
-
evidence.push({
|
|
51
|
-
tag: "traffic-decline",
|
|
52
|
-
reason: `Traffic declined versus previous window`
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
page.gaViews28d >= 200 &&
|
|
58
|
-
(page.gaEngagementRate28d < 0.5 || page.gaBounceRate28d > 0.6)
|
|
59
|
-
) {
|
|
60
|
-
score += 15;
|
|
61
|
-
tags.push("weak-engagement");
|
|
62
|
-
evidence.push({
|
|
63
|
-
tag: "weak-engagement",
|
|
64
|
-
reason: `Engagement ${toPercent(page.gaEngagementRate28d)}, bounce ${toPercent(page.gaBounceRate28d)}`
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const hasQueryGap = queries.some((query) => query.impressions28d >= 100 && query.ctr28d < 0.02);
|
|
69
|
-
if (hasQueryGap) {
|
|
70
|
-
score += 10;
|
|
71
|
-
tags.push("query-capture-gap");
|
|
72
|
-
evidence.push({
|
|
73
|
-
tag: "query-capture-gap",
|
|
74
|
-
reason: "One or more high-impression queries have weak CTR"
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return { score, tags, evidence };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function toPercent(value: number): string {
|
|
82
|
-
return `${(value * 100).toFixed(1)}%`;
|
|
83
|
-
}
|