@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.
- package/LICENSE +21 -21
- package/dist/cannibalization-analyzer.d.ts +38 -0
- package/dist/cannibalization-analyzer.d.ts.map +1 -0
- package/dist/cannibalization-analyzer.js +109 -0
- package/dist/ctr-anomaly-analyzer.d.ts +46 -0
- package/dist/ctr-anomaly-analyzer.d.ts.map +1 -0
- package/dist/ctr-anomaly-analyzer.js +118 -0
- package/dist/daily-metrics-collector.d.ts +12 -0
- package/dist/daily-metrics-collector.d.ts.map +1 -0
- package/dist/daily-metrics-collector.js +47 -0
- package/dist/decay-detector.d.ts.map +1 -1
- package/dist/decay-detector.js +1 -13
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/insight-generator.d.ts +100 -0
- package/dist/insight-generator.d.ts.map +1 -0
- package/dist/insight-generator.js +286 -0
- package/dist/insight-writer.d.ts +26 -0
- package/dist/insight-writer.d.ts.map +1 -0
- package/dist/insight-writer.js +87 -0
- package/dist/publishing-impact-analyzer.d.ts +23 -0
- package/dist/publishing-impact-analyzer.d.ts.map +1 -0
- package/dist/publishing-impact-analyzer.js +79 -0
- package/dist/quick-win-analyzer.d.ts +30 -0
- package/dist/quick-win-analyzer.d.ts.map +1 -0
- package/dist/quick-win-analyzer.js +66 -0
- package/dist/site-insight-analyzer.d.ts +45 -0
- package/dist/site-insight-analyzer.d.ts.map +1 -0
- package/dist/site-insight-analyzer.js +144 -0
- package/dist/sync-engine.d.ts +36 -1
- package/dist/sync-engine.d.ts.map +1 -1
- package/dist/sync-engine.js +194 -82
- package/dist/utils/date-utils.d.ts +4 -0
- package/dist/utils/date-utils.d.ts.map +1 -0
- package/dist/utils/date-utils.js +13 -0
- package/dist/utils/sanity-key.d.ts +6 -0
- package/dist/utils/sanity-key.d.ts.map +1 -0
- package/dist/utils/sanity-key.js +8 -0
- 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"}
|