@pagebridge/core 0.0.3 → 0.2.0

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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/dist/cannibalization-analyzer.d.ts +38 -0
  3. package/dist/cannibalization-analyzer.d.ts.map +1 -0
  4. package/dist/cannibalization-analyzer.js +109 -0
  5. package/dist/ctr-anomaly-analyzer.d.ts +46 -0
  6. package/dist/ctr-anomaly-analyzer.d.ts.map +1 -0
  7. package/dist/ctr-anomaly-analyzer.js +118 -0
  8. package/dist/daily-metrics-collector.d.ts +12 -0
  9. package/dist/daily-metrics-collector.d.ts.map +1 -0
  10. package/dist/daily-metrics-collector.js +47 -0
  11. package/dist/decay-detector.d.ts.map +1 -1
  12. package/dist/decay-detector.js +1 -13
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/insight-generator.d.ts +100 -0
  17. package/dist/insight-generator.d.ts.map +1 -0
  18. package/dist/insight-generator.js +286 -0
  19. package/dist/insight-writer.d.ts +26 -0
  20. package/dist/insight-writer.d.ts.map +1 -0
  21. package/dist/insight-writer.js +87 -0
  22. package/dist/publishing-impact-analyzer.d.ts +23 -0
  23. package/dist/publishing-impact-analyzer.d.ts.map +1 -0
  24. package/dist/publishing-impact-analyzer.js +79 -0
  25. package/dist/quick-win-analyzer.d.ts +30 -0
  26. package/dist/quick-win-analyzer.d.ts.map +1 -0
  27. package/dist/quick-win-analyzer.js +66 -0
  28. package/dist/site-insight-analyzer.d.ts +45 -0
  29. package/dist/site-insight-analyzer.d.ts.map +1 -0
  30. package/dist/site-insight-analyzer.js +144 -0
  31. package/dist/sync-engine.d.ts +36 -1
  32. package/dist/sync-engine.d.ts.map +1 -1
  33. package/dist/sync-engine.js +194 -82
  34. package/dist/utils/date-utils.d.ts +4 -0
  35. package/dist/utils/date-utils.d.ts.map +1 -0
  36. package/dist/utils/date-utils.js +13 -0
  37. package/dist/utils/sanity-key.d.ts +6 -0
  38. package/dist/utils/sanity-key.d.ts.map +1 -0
  39. package/dist/utils/sanity-key.js +8 -0
  40. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ import { EXPECTED_CTR_BY_POSITION } from "./ctr-anomaly-analyzer.js";
2
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
3
+ /**
4
+ * Converts all analyzer outputs into unified insight documents
5
+ */
6
+ export class InsightGenerator {
7
+ siteId;
8
+ property;
9
+ constructor(siteId, property) {
10
+ this.siteId = siteId;
11
+ this.property = property;
12
+ }
13
+ /**
14
+ * Generate unified insights from all analyzer results
15
+ * Returns a Map keyed by insightId for idempotency
16
+ */
17
+ generate(options) {
18
+ const insights = new Map();
19
+ const matchMap = new Map((options.matches ?? []).map((m) => [m.gscUrl, m]));
20
+ // Quick Wins
21
+ if (options.quickWins) {
22
+ for (const [url, queries] of options.quickWins) {
23
+ const match = matchMap.get(url);
24
+ const topQuery = queries[0];
25
+ const insightId = this.makeId("quick_win", url);
26
+ const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
27
+ insights.set(insightId, {
28
+ insightId,
29
+ insightType: "quick_win",
30
+ status: "open",
31
+ priority: this.prioritizeQuickWin(totalImpressions),
32
+ opportunityScore: this.scoreQuickWin(queries),
33
+ reason: `${queries.length} queries at positions 8-20 with ${totalImpressions.toLocaleString()} total impressions`,
34
+ recommendation: `Target the query "${topQuery?.query}" (position ${topQuery?.position.toFixed(1)}) by expanding content, updating meta tags, and adding internal links to reach page 1.`,
35
+ url,
36
+ pathScope: url,
37
+ linkedDocumentId: match?.sanityId,
38
+ metrics: {
39
+ queryCount: queries.length,
40
+ totalImpressions,
41
+ avgPosition: queries.reduce((sum, q) => sum + q.position, 0) / queries.length,
42
+ },
43
+ queries: queries.slice(0, 10).map((q) => ({
44
+ query: q.query,
45
+ impressions: q.impressions,
46
+ clicks: q.clicks,
47
+ position: q.position,
48
+ })),
49
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
50
+ detectedAt: new Date().toISOString(),
51
+ });
52
+ }
53
+ }
54
+ // CTR Anomalies
55
+ if (options.ctrAnomalies) {
56
+ for (const [url, anomaly] of options.ctrAnomalies) {
57
+ const match = matchMap.get(url);
58
+ const insightId = this.makeId("ctr_anomaly", url);
59
+ const estimatedImpressions = 500; // Conservative estimate
60
+ insights.set(insightId, {
61
+ insightId,
62
+ insightType: "ctr_anomaly",
63
+ status: "open",
64
+ priority: anomaly.severity === "high" ? "high" : anomaly.severity === "medium" ? "medium" : "low",
65
+ opportunityScore: this.scoreCtrAnomaly(anomaly, estimatedImpressions),
66
+ reason: anomaly.message,
67
+ recommendation: `Review and update your title tag and meta description. Your CTR is ${(anomaly.actualCtr * 100).toFixed(1)}%, but should be around ${(anomaly.expectedCtr * 100).toFixed(1)}% for position ${anomaly.positionBucket}.`,
68
+ url,
69
+ pathScope: url,
70
+ linkedDocumentId: match?.sanityId,
71
+ metrics: {
72
+ actualCtr: anomaly.actualCtr,
73
+ expectedCtr: anomaly.expectedCtr,
74
+ position: anomaly.positionBucket,
75
+ severity: anomaly.severity === "high" ? 3 : anomaly.severity === "medium" ? 2 : 1,
76
+ },
77
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
78
+ detectedAt: new Date().toISOString(),
79
+ });
80
+ }
81
+ }
82
+ // Decay Signals
83
+ if (options.decaySignals) {
84
+ for (const signal of options.decaySignals) {
85
+ const match = matchMap.get(signal.page);
86
+ const insightId = this.makeId(signal.reason, signal.page);
87
+ insights.set(insightId, {
88
+ insightId,
89
+ insightType: signal.reason,
90
+ status: "open",
91
+ priority: signal.severity === "high"
92
+ ? "critical"
93
+ : signal.severity === "medium"
94
+ ? "high"
95
+ : "medium",
96
+ opportunityScore: this.scoreDecay(signal),
97
+ reason: this.formatDecayReason(signal),
98
+ recommendation: this.formatDecayRecommendation(signal),
99
+ url: signal.page,
100
+ pathScope: signal.page,
101
+ linkedDocumentId: match?.sanityId,
102
+ metrics: {
103
+ positionBefore: signal.metrics.positionBefore,
104
+ positionNow: signal.metrics.positionNow,
105
+ positionDelta: signal.metrics.positionDelta,
106
+ ctrBefore: signal.metrics.ctrBefore,
107
+ ctrNow: signal.metrics.ctrNow,
108
+ impressions: signal.metrics.impressions,
109
+ },
110
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
111
+ detectedAt: new Date().toISOString(),
112
+ });
113
+ }
114
+ }
115
+ // Cannibalization
116
+ if (options.cannibalizationTargets) {
117
+ for (const [url, targets] of options.cannibalizationTargets) {
118
+ const match = matchMap.get(url);
119
+ const insightId = this.makeId("cannibalization", url);
120
+ const sharedQueryCount = targets.reduce((sum, t) => sum + t.sharedQueries.length, 0);
121
+ insights.set(insightId, {
122
+ insightId,
123
+ insightType: "cannibalization",
124
+ status: "open",
125
+ priority: sharedQueryCount > 5 ? "high" : "medium",
126
+ opportunityScore: sharedQueryCount * 10,
127
+ reason: `Competing with ${targets.length} other page${targets.length !== 1 ? "s" : ""} for ${sharedQueryCount} shared queries`,
128
+ recommendation: `Consolidate content or differentiate pages. Consider merging into primary page or rewriting to target different angles. Top competing page: ${targets[0]?.competingPage}`,
129
+ url,
130
+ pathScope: url,
131
+ linkedDocumentId: match?.sanityId,
132
+ metrics: {
133
+ competitorCount: targets.length,
134
+ sharedQueryCount,
135
+ },
136
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
137
+ detectedAt: new Date().toISOString(),
138
+ });
139
+ }
140
+ }
141
+ // Zero-Click Pages
142
+ if (options.zeroClickPages && options.zeroClickPages.length > 0) {
143
+ for (const page of options.zeroClickPages) {
144
+ const insightId = this.makeId("zero_click", page.page);
145
+ insights.set(insightId, {
146
+ insightId,
147
+ insightType: "zero_click",
148
+ status: "open",
149
+ priority: page.impressions > 500 ? "high" : "medium",
150
+ opportunityScore: page.impressions * 0.01, // Conservative: 1% of impressions
151
+ reason: `${page.impressions} impressions but only ${page.clicks} clicks - searchers see your page but don't click`,
152
+ recommendation: `Add a concise answer block at the top of your content, improve snippet with power words, and ensure title/description clearly match search intent.`,
153
+ url: page.page,
154
+ pathScope: page.page,
155
+ linkedDocumentId: page.documentId,
156
+ metrics: {
157
+ impressions: page.impressions,
158
+ clicks: page.clicks,
159
+ ctr: page.clicks / Math.max(1, page.impressions),
160
+ },
161
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
162
+ detectedAt: new Date().toISOString(),
163
+ });
164
+ }
165
+ }
166
+ // New Keywords
167
+ if (options.newKeywords && options.newKeywords.length > 0) {
168
+ for (const keyword of options.newKeywords) {
169
+ const insightId = this.makeId("new_keyword", keyword.page);
170
+ insights.set(insightId, {
171
+ insightId,
172
+ insightType: "new_keyword",
173
+ status: "open",
174
+ priority: keyword.impressions > 100 ? "medium" : "low",
175
+ opportunityScore: keyword.impressions * 0.05,
176
+ reason: `New keyword opportunity: "${keyword.query}" appearing with ${keyword.impressions} impressions`,
177
+ recommendation: `This keyword is new to your rankings. Double-check your content covers this topic. Add related sections or internal links to strengthen topical authority.`,
178
+ url: keyword.page,
179
+ pathScope: keyword.page,
180
+ linkedDocumentId: keyword.documentId,
181
+ metrics: {
182
+ impressions: keyword.impressions,
183
+ position: keyword.position,
184
+ },
185
+ queries: [
186
+ {
187
+ query: keyword.query,
188
+ impressions: keyword.impressions,
189
+ clicks: 0,
190
+ position: keyword.position,
191
+ },
192
+ ],
193
+ dateRange: { start: formatDate(daysAgo(7)), end: formatDate(daysAgo(3)) },
194
+ detectedAt: new Date().toISOString(),
195
+ });
196
+ }
197
+ }
198
+ // Publishing Impact
199
+ if (options.publishingImpact) {
200
+ for (const [url, impact] of options.publishingImpact) {
201
+ const match = matchMap.get(url);
202
+ const insightId = this.makeId("publishing_impact", url);
203
+ const positionImproved = (impact.positionDelta ?? 0) < 0;
204
+ const clicksDelta = (impact.clicksAfter ?? 0) - (impact.clicksBefore ?? 0);
205
+ insights.set(insightId, {
206
+ insightId,
207
+ insightType: "publishing_impact",
208
+ status: "open",
209
+ priority: clicksDelta > 50 ? "high" : "medium",
210
+ opportunityScore: Math.max(0, clicksDelta),
211
+ reason: `Content was edited ${impact.daysSinceEdit} days ago. Impact: ${positionImproved ? "improved" : "declined"} position by ${Math.abs(impact.positionDelta ?? 0).toFixed(1)}`,
212
+ recommendation: `Monitor trends. If positive: document what worked. If negative: consider reverting changes or further optimization.`,
213
+ url,
214
+ pathScope: url,
215
+ linkedDocumentId: match?.sanityId,
216
+ metrics: {
217
+ positionBefore: impact.positionBefore ?? 0,
218
+ positionAfter: impact.positionAfter ?? 0,
219
+ positionDelta: impact.positionDelta ?? 0,
220
+ clicksBefore: impact.clicksBefore ?? 0,
221
+ clicksAfter: impact.clicksAfter ?? 0,
222
+ clicksDelta,
223
+ daysSinceEdit: impact.daysSinceEdit,
224
+ },
225
+ dateRange: { start: formatDate(daysAgo(28)), end: formatDate(daysAgo(3)) },
226
+ detectedAt: new Date().toISOString(),
227
+ });
228
+ }
229
+ }
230
+ return insights;
231
+ }
232
+ makeId(type, url) {
233
+ const urlHash = Buffer.from(url).toString("base64").slice(0, 16);
234
+ return `${this.siteId}:${type}:${urlHash}`;
235
+ }
236
+ prioritizeQuickWin(totalImpressions) {
237
+ if (totalImpressions >= 1000)
238
+ return "high";
239
+ if (totalImpressions >= 500)
240
+ return "medium";
241
+ return "low";
242
+ }
243
+ scoreQuickWin(queries) {
244
+ const targetCtr = 0.185; // Position 3 CTR
245
+ return Math.round(queries.reduce((sum, q) => sum + Math.max(0, q.impressions * targetCtr - q.clicks), 0));
246
+ }
247
+ scoreCtrAnomaly(anomaly, impressions) {
248
+ return Math.round(impressions * (anomaly.expectedCtr - anomaly.actualCtr));
249
+ }
250
+ scoreDecay(signal) {
251
+ const ctrBefore = this.estimateCtrFromPosition(signal.metrics.positionBefore);
252
+ const ctrNow = this.estimateCtrFromPosition(signal.metrics.positionNow);
253
+ return Math.round(signal.metrics.impressions * (ctrBefore - ctrNow));
254
+ }
255
+ estimateCtrFromPosition(pos) {
256
+ const bucket = Math.floor(pos);
257
+ const fraction = pos - bucket;
258
+ const ctr1 = EXPECTED_CTR_BY_POSITION[bucket] ?? 0.028;
259
+ const ctr2 = EXPECTED_CTR_BY_POSITION[bucket + 1] ?? 0.028;
260
+ return ctr1 + (ctr2 - ctr1) * fraction;
261
+ }
262
+ formatDecayReason(signal) {
263
+ if (signal.reason === "position_decay") {
264
+ return `Position dropped from ${signal.metrics.positionBefore.toFixed(1)} to ${signal.metrics.positionNow.toFixed(1)} (−${Math.abs(signal.metrics.positionDelta).toFixed(1)} positions) over 28 days`;
265
+ }
266
+ if (signal.reason === "low_ctr") {
267
+ return `CTR is ${(signal.metrics.ctrNow * 100).toFixed(1)}%, below 1% threshold for top-10 position`;
268
+ }
269
+ if (signal.reason === "impressions_drop") {
270
+ return `Impressions have declined >50% over 28 days`;
271
+ }
272
+ return "Content decay detected";
273
+ }
274
+ formatDecayRecommendation(signal) {
275
+ if (signal.reason === "position_decay") {
276
+ return `Refresh this page: update intro, add recent examples, verify factual accuracy, check internal linking from related posts.`;
277
+ }
278
+ if (signal.reason === "low_ctr") {
279
+ return `Review title tag and meta description. Use power words and match user intent more closely.`;
280
+ }
281
+ if (signal.reason === "impressions_drop") {
282
+ return `Content may be outdated or overtaken by competitors. Perform competitive audit and refresh with new information.`;
283
+ }
284
+ return "Review and refresh page content";
285
+ }
286
+ }
@@ -0,0 +1,26 @@
1
+ import type { SanityClient } from "@sanity/client";
2
+ import type { SiteInsightData } from "./site-insight-analyzer.js";
3
+ import type { CannibalizationGroup } from "./cannibalization-analyzer.js";
4
+ import type { QuickWinQuery } from "./quick-win-analyzer.js";
5
+ export interface QuickWinPageSummary {
6
+ page: string;
7
+ documentId: string;
8
+ documentTitle: string;
9
+ queryCount: number;
10
+ totalImpressions: number;
11
+ avgPosition: number;
12
+ }
13
+ export declare class InsightWriter {
14
+ private sanity;
15
+ constructor(sanity: SanityClient);
16
+ /**
17
+ * Upserts a gscSiteInsight document for the given site.
18
+ * Contains site-wide insights: top performers, zero-click pages,
19
+ * orphan pages, new keywords, quick win pages, and cannibalization groups.
20
+ */
21
+ write(siteId: string, data: SiteInsightData, cannibalizationGroups: CannibalizationGroup[], matchLookup: Map<string, {
22
+ sanityId: string;
23
+ title?: string;
24
+ }>, quickWins?: Map<string, QuickWinQuery[]>): Promise<void>;
25
+ }
26
+ //# sourceMappingURL=insight-writer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insight-writer.d.ts","sourceRoot":"","sources":["../src/insight-writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAG7D,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,aAAa;IACZ,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,YAAY;IAExC;;;;OAIG;IACG,KAAK,CACT,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,eAAe,EACrB,qBAAqB,EAAE,oBAAoB,EAAE,EAC7C,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EAC9D,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,GACvC,OAAO,CAAC,IAAI,CAAC;CA8EjB"}
@@ -0,0 +1,87 @@
1
+ import { sanityKey } from "./utils/sanity-key.js";
2
+ export class InsightWriter {
3
+ sanity;
4
+ constructor(sanity) {
5
+ this.sanity = sanity;
6
+ }
7
+ /**
8
+ * Upserts a gscSiteInsight document for the given site.
9
+ * Contains site-wide insights: top performers, zero-click pages,
10
+ * orphan pages, new keywords, quick win pages, and cannibalization groups.
11
+ */
12
+ async write(siteId, data, cannibalizationGroups, matchLookup, quickWins) {
13
+ const insightId = `siteInsight-${siteId}`;
14
+ // Aggregate quick wins per page for the dashboard
15
+ const quickWinPages = [];
16
+ if (quickWins) {
17
+ for (const [page, queries] of quickWins) {
18
+ if (queries.length === 0)
19
+ continue;
20
+ const totalImpressions = queries.reduce((s, q) => s + q.impressions, 0);
21
+ const avgPosition = queries.reduce((s, q) => s + q.position, 0) / queries.length;
22
+ quickWinPages.push({
23
+ _key: sanityKey(`qw:${page}`),
24
+ page,
25
+ documentId: matchLookup.get(page)?.sanityId ?? "",
26
+ documentTitle: matchLookup.get(page)?.title ?? "",
27
+ queryCount: queries.length,
28
+ totalImpressions,
29
+ avgPosition,
30
+ });
31
+ }
32
+ quickWinPages.sort((a, b) => b.totalImpressions - a.totalImpressions);
33
+ }
34
+ const enriched = {
35
+ _id: insightId,
36
+ _type: "gscSiteInsight",
37
+ site: { _type: "reference", _ref: siteId },
38
+ topPerformers: data.topPerformers.map((p) => ({
39
+ _key: sanityKey(`tp:${p.page}`),
40
+ page: p.page,
41
+ documentId: matchLookup.get(p.page)?.sanityId ?? "",
42
+ documentTitle: matchLookup.get(p.page)?.title ?? "",
43
+ clicks: p.clicks,
44
+ impressions: p.impressions,
45
+ position: p.position,
46
+ })),
47
+ zeroClickPages: data.zeroClickPages.map((p) => ({
48
+ _key: sanityKey(`zc:${p.page}`),
49
+ page: p.page,
50
+ documentId: matchLookup.get(p.page)?.sanityId ?? "",
51
+ documentTitle: matchLookup.get(p.page)?.title ?? "",
52
+ impressions: p.impressions,
53
+ clicks: p.clicks,
54
+ position: p.position,
55
+ })),
56
+ orphanPages: data.orphanPages.map((p) => ({
57
+ _key: sanityKey(`op:${p.page}`),
58
+ page: p.page,
59
+ documentId: matchLookup.get(p.page)?.sanityId ?? "",
60
+ documentTitle: matchLookup.get(p.page)?.title ?? "",
61
+ })),
62
+ quickWinPages,
63
+ newKeywordOpportunities: data.newKeywordOpportunities.map((k) => ({
64
+ _key: sanityKey(`nk:${k.query}:${k.page}`),
65
+ query: k.query,
66
+ page: k.page,
67
+ documentId: matchLookup.get(k.page)?.sanityId ?? "",
68
+ impressions: k.impressions,
69
+ position: k.position,
70
+ })),
71
+ cannibalizationGroups: cannibalizationGroups.slice(0, 50).map((g) => ({
72
+ _key: sanityKey(`cg:${g.query}`),
73
+ query: g.query,
74
+ pages: g.pages.map((p) => ({
75
+ _key: sanityKey(`cgp:${g.query}:${p.page}`),
76
+ page: p.page,
77
+ documentId: matchLookup.get(p.page)?.sanityId ?? "",
78
+ clicks: p.clicks,
79
+ impressions: p.impressions,
80
+ position: p.position,
81
+ })),
82
+ })),
83
+ lastComputedAt: new Date().toISOString(),
84
+ };
85
+ await this.sanity.createOrReplace(enriched);
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ import type { DrizzleClient } from "@pagebridge/db";
2
+ import type { PublishingImpact } from "./sync-engine.js";
3
+ export interface EditDateInfo {
4
+ url: string;
5
+ editedAt: Date;
6
+ }
7
+ export declare class PublishingImpactAnalyzer {
8
+ private db;
9
+ constructor(db: DrizzleClient);
10
+ /**
11
+ * Compares 14-day window before vs. 14-day window after the last content edit.
12
+ * Requires at least 7 days since edit to have meaningful "after" data.
13
+ *
14
+ * Note: `editDates` typically comes from Sanity's `_updatedAt` field, which
15
+ * includes ALL document updates (not just content edits). This means schema
16
+ * migrations, metadata changes, or SEO field updates will also trigger a
17
+ * before/after comparison. For more accurate results, pass a dedicated
18
+ * `contentLastEditedAt` field if available.
19
+ */
20
+ analyze(siteId: string, editDates: Map<string, Date>): Promise<Map<string, PublishingImpact>>;
21
+ private getWindowMetrics;
22
+ }
23
+ //# sourceMappingURL=publishing-impact-analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publishing-impact-analyzer.d.ts","sourceRoot":"","sources":["../src/publishing-impact-analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAGpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGzD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,IAAI,CAAC;CAChB;AAED,qBAAa,wBAAwB;IACvB,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,aAAa;IAErC;;;;;;;;;OASG;IACG,OAAO,CACX,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,GAC3B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;YA+C3B,gBAAgB;CAyC/B"}
@@ -0,0 +1,79 @@
1
+ import { searchAnalytics } from "@pagebridge/db";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { formatDate } from "./utils/date-utils.js";
4
+ export class PublishingImpactAnalyzer {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ /**
10
+ * Compares 14-day window before vs. 14-day window after the last content edit.
11
+ * Requires at least 7 days since edit to have meaningful "after" data.
12
+ *
13
+ * Note: `editDates` typically comes from Sanity's `_updatedAt` field, which
14
+ * includes ALL document updates (not just content edits). This means schema
15
+ * migrations, metadata changes, or SEO field updates will also trigger a
16
+ * before/after comparison. For more accurate results, pass a dedicated
17
+ * `contentLastEditedAt` field if available.
18
+ */
19
+ async analyze(siteId, editDates) {
20
+ const results = new Map();
21
+ const now = new Date();
22
+ for (const [page, editDate] of editDates) {
23
+ const daysSinceEdit = Math.floor((now.getTime() - editDate.getTime()) / (1000 * 60 * 60 * 24));
24
+ // Need at least 7 days of post-edit data
25
+ if (daysSinceEdit < 7)
26
+ continue;
27
+ const beforeStart = new Date(editDate);
28
+ beforeStart.setDate(beforeStart.getDate() - 14);
29
+ const beforeEnd = new Date(editDate);
30
+ const afterStart = new Date(editDate);
31
+ const afterEnd = new Date(editDate);
32
+ afterEnd.setDate(afterEnd.getDate() + Math.min(14, daysSinceEdit));
33
+ const [beforeMetrics, afterMetrics] = await Promise.all([
34
+ this.getWindowMetrics(siteId, page, beforeStart, beforeEnd),
35
+ this.getWindowMetrics(siteId, page, afterStart, afterEnd),
36
+ ]);
37
+ if (!beforeMetrics || !afterMetrics)
38
+ continue;
39
+ results.set(page, {
40
+ lastEditedAt: editDate.toISOString(),
41
+ daysSinceEdit,
42
+ positionBefore: beforeMetrics.position,
43
+ positionAfter: afterMetrics.position,
44
+ positionDelta: afterMetrics.position - beforeMetrics.position,
45
+ clicksBefore: beforeMetrics.clicks,
46
+ clicksAfter: afterMetrics.clicks,
47
+ impressionsBefore: beforeMetrics.impressions,
48
+ impressionsAfter: afterMetrics.impressions,
49
+ ctrBefore: beforeMetrics.ctr,
50
+ ctrAfter: afterMetrics.ctr,
51
+ });
52
+ }
53
+ return results;
54
+ }
55
+ async getWindowMetrics(siteId, page, startDate, endDate) {
56
+ const rows = await this.db
57
+ .select({
58
+ totalClicks: sql `sum(${searchAnalytics.clicks})`,
59
+ totalImpressions: sql `sum(${searchAnalytics.impressions})`,
60
+ avgPosition: sql `avg(${searchAnalytics.position})`,
61
+ })
62
+ .from(searchAnalytics)
63
+ .where(and(eq(searchAnalytics.siteId, siteId), eq(searchAnalytics.page, page), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))));
64
+ const row = rows[0];
65
+ if (!row)
66
+ return undefined;
67
+ const clicks = Number(row.totalClicks) || 0;
68
+ const impressions = Number(row.totalImpressions) || 0;
69
+ const position = Number(row.avgPosition) || 0;
70
+ if (impressions === 0)
71
+ return undefined;
72
+ return {
73
+ clicks,
74
+ impressions,
75
+ ctr: impressions > 0 ? clicks / impressions : 0,
76
+ position,
77
+ };
78
+ }
79
+ }
@@ -0,0 +1,30 @@
1
+ import type { DrizzleClient } from "@pagebridge/db";
2
+ export interface QuickWinQuery {
3
+ query: string;
4
+ clicks: number;
5
+ impressions: number;
6
+ ctr: number;
7
+ position: number;
8
+ }
9
+ export interface QuickWinConfig {
10
+ /** Minimum average position to qualify (default: 8) */
11
+ positionMin: number;
12
+ /** Maximum average position to qualify (default: 20) */
13
+ positionMax: number;
14
+ /** Minimum total impressions to qualify (default: 50) */
15
+ minImpressions: number;
16
+ /** Maximum quick wins per page (default: 10) */
17
+ maxPerPage: number;
18
+ }
19
+ export declare class QuickWinAnalyzer {
20
+ private db;
21
+ private config;
22
+ constructor(db: DrizzleClient, config?: Partial<QuickWinConfig>);
23
+ /**
24
+ * Finds "quick win" queries for all pages: queries where position is 8-20
25
+ * with significant impressions. These are page-1 opportunities where a small
26
+ * content tweak could improve ranking.
27
+ */
28
+ analyze(siteId: string): Promise<Map<string, QuickWinQuery[]>>;
29
+ }
30
+ //# sourceMappingURL=quick-win-analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quick-win-analyzer.d.ts","sourceRoot":"","sources":["../src/quick-win-analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAKpD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,cAAc,EAAE,MAAM,CAAC;IACvB,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;CACpB;AASD,qBAAa,gBAAgB;IAIzB,OAAO,CAAC,EAAE;IAHZ,OAAO,CAAC,MAAM,CAAiB;gBAGrB,EAAE,EAAE,aAAa,EACzB,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAKlC;;;;OAIG;IACG,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;CA4DrE"}
@@ -0,0 +1,66 @@
1
+ import { queryAnalytics } from "@pagebridge/db";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
4
+ const defaultConfig = {
5
+ positionMin: 8,
6
+ positionMax: 20,
7
+ minImpressions: 50,
8
+ maxPerPage: 10,
9
+ };
10
+ export class QuickWinAnalyzer {
11
+ db;
12
+ config;
13
+ constructor(db, config) {
14
+ this.db = db;
15
+ this.config = { ...defaultConfig, ...config };
16
+ }
17
+ /**
18
+ * Finds "quick win" queries for all pages: queries where position is 8-20
19
+ * with significant impressions. These are page-1 opportunities where a small
20
+ * content tweak could improve ranking.
21
+ */
22
+ async analyze(siteId) {
23
+ const startDate = daysAgo(28);
24
+ const endDate = daysAgo(3);
25
+ const results = await this.db
26
+ .select({
27
+ page: queryAnalytics.page,
28
+ query: queryAnalytics.query,
29
+ totalClicks: sql `sum(${queryAnalytics.clicks})`,
30
+ totalImpressions: sql `sum(${queryAnalytics.impressions})`,
31
+ avgPosition: sql `avg(${queryAnalytics.position})`,
32
+ })
33
+ .from(queryAnalytics)
34
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(startDate)), lte(queryAnalytics.date, formatDate(endDate))))
35
+ .groupBy(queryAnalytics.page, queryAnalytics.query);
36
+ const quickWins = new Map();
37
+ for (const row of results) {
38
+ const position = Number(row.avgPosition) || 0;
39
+ const impressions = Number(row.totalImpressions) || 0;
40
+ const clicks = Number(row.totalClicks) || 0;
41
+ if (position < this.config.positionMin ||
42
+ position > this.config.positionMax ||
43
+ impressions < this.config.minImpressions) {
44
+ continue;
45
+ }
46
+ const entry = {
47
+ query: row.query,
48
+ clicks,
49
+ impressions,
50
+ ctr: impressions > 0 ? clicks / impressions : 0,
51
+ position,
52
+ };
53
+ const existing = quickWins.get(row.page) ?? [];
54
+ existing.push(entry);
55
+ quickWins.set(row.page, existing);
56
+ }
57
+ // Sort by impressions desc and cap per page
58
+ for (const [page, queries] of quickWins) {
59
+ queries.sort((a, b) => b.impressions - a.impressions);
60
+ if (queries.length > this.config.maxPerPage) {
61
+ quickWins.set(page, queries.slice(0, this.config.maxPerPage));
62
+ }
63
+ }
64
+ return quickWins;
65
+ }
66
+ }
@@ -0,0 +1,45 @@
1
+ import type { DrizzleClient } from "@pagebridge/db";
2
+ export interface TopPerformer {
3
+ page: string;
4
+ documentId?: string;
5
+ documentTitle?: string;
6
+ clicks: number;
7
+ impressions: number;
8
+ position: number;
9
+ }
10
+ export interface ZeroClickPage {
11
+ page: string;
12
+ documentId?: string;
13
+ documentTitle?: string;
14
+ impressions: number;
15
+ clicks: number;
16
+ position: number;
17
+ }
18
+ export interface OrphanPage {
19
+ page: string;
20
+ documentId?: string;
21
+ documentTitle?: string;
22
+ lastImpression?: string;
23
+ }
24
+ export interface NewKeywordOpportunity {
25
+ query: string;
26
+ page: string;
27
+ impressions: number;
28
+ position: number;
29
+ }
30
+ export interface SiteInsightData {
31
+ topPerformers: TopPerformer[];
32
+ zeroClickPages: ZeroClickPage[];
33
+ orphanPages: OrphanPage[];
34
+ newKeywordOpportunities: NewKeywordOpportunity[];
35
+ }
36
+ export declare class SiteInsightAnalyzer {
37
+ private db;
38
+ constructor(db: DrizzleClient);
39
+ analyze(siteId: string, allPages: string[]): Promise<SiteInsightData>;
40
+ private getTopPerformers;
41
+ private getZeroClickPages;
42
+ private getOrphanPages;
43
+ private getNewKeywordOpportunities;
44
+ }
45
+ //# sourceMappingURL=site-insight-analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-insight-analyzer.d.ts","sourceRoot":"","sources":["../src/site-insight-analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAKpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,uBAAuB,EAAE,qBAAqB,EAAE,CAAC;CAClD;AAED,qBAAa,mBAAmB;IAClB,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,aAAa;IAE/B,OAAO,CACX,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAAE,GACjB,OAAO,CAAC,eAAe,CAAC;YAiBb,gBAAgB;YAgChB,iBAAiB;YAoCjB,cAAc;YAqDd,0BAA0B;CA0DzC"}