@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,144 @@
1
+ import { searchAnalytics, queryAnalytics } from "@pagebridge/db";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
4
+ export class SiteInsightAnalyzer {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async analyze(siteId, allPages) {
10
+ const [topPerformers, zeroClickPages, orphanPages, newKeywords] = await Promise.all([
11
+ this.getTopPerformers(siteId),
12
+ this.getZeroClickPages(siteId),
13
+ this.getOrphanPages(siteId, allPages),
14
+ this.getNewKeywordOpportunities(siteId),
15
+ ]);
16
+ return {
17
+ topPerformers,
18
+ zeroClickPages,
19
+ orphanPages,
20
+ newKeywordOpportunities: newKeywords,
21
+ };
22
+ }
23
+ async getTopPerformers(siteId) {
24
+ const startDate = daysAgo(28);
25
+ const endDate = daysAgo(3);
26
+ const results = await this.db
27
+ .select({
28
+ page: searchAnalytics.page,
29
+ totalClicks: sql `sum(${searchAnalytics.clicks})`,
30
+ totalImpressions: sql `sum(${searchAnalytics.impressions})`,
31
+ avgPosition: sql `avg(${searchAnalytics.position})`,
32
+ })
33
+ .from(searchAnalytics)
34
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
35
+ .groupBy(searchAnalytics.page);
36
+ return results
37
+ .map((r) => ({
38
+ page: r.page,
39
+ clicks: Number(r.totalClicks) || 0,
40
+ impressions: Number(r.totalImpressions) || 0,
41
+ position: Number(r.avgPosition) || 0,
42
+ }))
43
+ .sort((a, b) => b.clicks - a.clicks)
44
+ .slice(0, 20);
45
+ }
46
+ async getZeroClickPages(siteId) {
47
+ const startDate = daysAgo(28);
48
+ const endDate = daysAgo(3);
49
+ const results = await this.db
50
+ .select({
51
+ page: searchAnalytics.page,
52
+ totalClicks: sql `sum(${searchAnalytics.clicks})`,
53
+ totalImpressions: sql `sum(${searchAnalytics.impressions})`,
54
+ avgPosition: sql `avg(${searchAnalytics.position})`,
55
+ })
56
+ .from(searchAnalytics)
57
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
58
+ .groupBy(searchAnalytics.page);
59
+ return results
60
+ .filter((r) => {
61
+ const impressions = Number(r.totalImpressions) || 0;
62
+ const clicks = Number(r.totalClicks) || 0;
63
+ return impressions >= 100 && clicks <= 2;
64
+ })
65
+ .map((r) => ({
66
+ page: r.page,
67
+ impressions: Number(r.totalImpressions) || 0,
68
+ clicks: Number(r.totalClicks) || 0,
69
+ position: Number(r.avgPosition) || 0,
70
+ }))
71
+ .sort((a, b) => b.impressions - a.impressions);
72
+ }
73
+ async getOrphanPages(siteId, allPages) {
74
+ const startDate = daysAgo(28);
75
+ const endDate = daysAgo(3);
76
+ // Get pages that have had impressions in last 28 days
77
+ const activeResults = await this.db
78
+ .select({
79
+ page: searchAnalytics.page,
80
+ })
81
+ .from(searchAnalytics)
82
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
83
+ .groupBy(searchAnalytics.page);
84
+ const activePages = new Set(activeResults.map((r) => r.page));
85
+ const orphanPageUrls = allPages.filter((page) => !activePages.has(page));
86
+ if (orphanPageUrls.length === 0)
87
+ return [];
88
+ // Query the last impression date for each orphan page
89
+ const lastImpressionResults = await this.db
90
+ .select({
91
+ page: searchAnalytics.page,
92
+ lastDate: sql `max(${searchAnalytics.date})`,
93
+ })
94
+ .from(searchAnalytics)
95
+ .where(and(eq(searchAnalytics.siteId, siteId), sql `${searchAnalytics.page} IN (${sql.join(orphanPageUrls.map((u) => sql `${u}`), sql `, `)})`))
96
+ .groupBy(searchAnalytics.page);
97
+ const lastImpressionMap = new Map();
98
+ for (const row of lastImpressionResults) {
99
+ if (row.lastDate)
100
+ lastImpressionMap.set(row.page, row.lastDate);
101
+ }
102
+ return orphanPageUrls.map((page) => ({
103
+ page,
104
+ lastImpression: lastImpressionMap.get(page),
105
+ }));
106
+ }
107
+ async getNewKeywordOpportunities(siteId) {
108
+ const recentStart = daysAgo(7);
109
+ const recentEnd = daysAgo(0);
110
+ const historicStart = daysAgo(90);
111
+ const historicEnd = daysAgo(14);
112
+ // Get queries from last 7 days
113
+ const recentQueries = await this.db
114
+ .select({
115
+ query: queryAnalytics.query,
116
+ page: queryAnalytics.page,
117
+ totalImpressions: sql `sum(${queryAnalytics.impressions})`,
118
+ avgPosition: sql `avg(${queryAnalytics.position})`,
119
+ })
120
+ .from(queryAnalytics)
121
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(recentStart)), lte(queryAnalytics.date, formatDate(recentEnd))))
122
+ .groupBy(queryAnalytics.query, queryAnalytics.page);
123
+ // Get queries seen in days 14-90
124
+ const historicQueryResults = await this.db
125
+ .select({
126
+ query: queryAnalytics.query,
127
+ })
128
+ .from(queryAnalytics)
129
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(historicStart)), lte(queryAnalytics.date, formatDate(historicEnd))))
130
+ .groupBy(queryAnalytics.query);
131
+ const historicQueries = new Set(historicQueryResults.map((r) => r.query));
132
+ return recentQueries
133
+ .filter((r) => !historicQueries.has(r.query) &&
134
+ (Number(r.totalImpressions) || 0) >= 10)
135
+ .map((r) => ({
136
+ query: r.query,
137
+ page: r.page,
138
+ impressions: Number(r.totalImpressions) || 0,
139
+ position: Number(r.avgPosition) || 0,
140
+ }))
141
+ .sort((a, b) => b.impressions - a.impressions)
142
+ .slice(0, 50);
143
+ }
144
+ }
@@ -1,11 +1,46 @@
1
1
  import type { DrizzleClient } from "@pagebridge/db";
2
2
  import type { SanityClient } from "@sanity/client";
3
3
  import type { GSCClient, IndexStatusResult } from "./gsc-client.js";
4
+ import type { QuickWinQuery } from "./quick-win-analyzer.js";
5
+ import type { CtrAnomaly } from "./ctr-anomaly-analyzer.js";
6
+ export interface PublishingImpact {
7
+ lastEditedAt: string;
8
+ daysSinceEdit: number;
9
+ positionBefore: number;
10
+ positionAfter: number;
11
+ positionDelta: number;
12
+ clicksBefore: number;
13
+ clicksAfter: number;
14
+ impressionsBefore: number;
15
+ impressionsAfter: number;
16
+ ctrBefore: number;
17
+ ctrAfter: number;
18
+ }
19
+ export interface CannibalizationTarget {
20
+ competingPage: string;
21
+ competingDocumentId: string;
22
+ sharedQueries: string[];
23
+ }
24
+ export interface DailyMetricPoint {
25
+ date: string;
26
+ clicks: number;
27
+ impressions: number;
28
+ position: number;
29
+ }
30
+ export interface SnapshotInsights {
31
+ quickWins?: Map<string, QuickWinQuery[]>;
32
+ ctrAnomalies?: Map<string, CtrAnomaly>;
33
+ dailyMetrics?: Map<string, DailyMetricPoint[]>;
34
+ publishingImpact?: Map<string, PublishingImpact>;
35
+ cannibalizationTargets?: Map<string, CannibalizationTarget[]>;
36
+ decayPages?: Set<string>;
37
+ }
4
38
  export interface SyncOptions {
5
39
  siteUrl: string;
6
40
  startDate?: Date;
7
41
  endDate?: Date;
8
42
  dimensions?: ("page" | "query" | "date")[];
43
+ onProgress?: (message: string) => void;
9
44
  }
10
45
  export interface SyncResult {
11
46
  pages: string[];
@@ -32,7 +67,7 @@ export declare class SyncEngine {
32
67
  writeSnapshots(siteId: string, matches: {
33
68
  gscUrl: string;
34
69
  sanityId: string | undefined;
35
- }[], siteUrl?: string): Promise<void>;
70
+ }[], siteUrl?: string, insights?: SnapshotInsights, onProgress?: (message: string) => void): Promise<void>;
36
71
  syncIndexStatus(siteUrl: string, pages: string[]): Promise<IndexStatusSyncResult>;
37
72
  getIndexStatus(siteUrl: string, page: string): Promise<IndexStatusResult | null>;
38
73
  private getAggregatedMetrics;
@@ -1 +1 @@
1
- {"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAQpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEpE,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,OAAO,CAAC,EAAE,IAAI,CAAC;IACf,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,SAAS,CAAC;IACf,EAAE,EAAE,aAAa,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,GAAG,CAAY;IACvB,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,MAAM,CAAe;gBAEjB,OAAO,EAAE,iBAAiB;IAMhC,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC;IA+G/C,cAAc,CAClB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,EAAE,EAC3D,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC;IAmFV,eAAe,CACnB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,qBAAqB,CAAC;IA6E3B,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YAqBtB,oBAAoB;YAiDpB,aAAa;CAyD5B"}
1
+ {"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,8 @@
1
1
  import { searchAnalytics, queryAnalytics, syncLog, pageIndexStatus, } from "@pagebridge/db";
2
- import { and, eq, gte, lte } from "drizzle-orm";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { CtrAnomalyAnalyzer } from "./ctr-anomaly-analyzer.js";
4
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
5
+ import { sanityKey } from "./utils/sanity-key.js";
3
6
  export class SyncEngine {
4
7
  gsc;
5
8
  db;
@@ -10,7 +13,8 @@ export class SyncEngine {
10
13
  this.sanity = options.sanity;
11
14
  }
12
15
  async sync(options) {
13
- const { siteUrl, startDate = daysAgo(90), endDate = daysAgo(3), dimensions = ["page", "date"], } = options;
16
+ const { siteUrl, startDate = daysAgo(90), endDate = daysAgo(3), dimensions = ["page", "query", "date"], onProgress, } = options;
17
+ const progress = onProgress ?? (() => { });
14
18
  const syncLogId = `${siteUrl}:${Date.now()}`;
15
19
  await this.db.insert(syncLog).values({
16
20
  id: syncLogId,
@@ -19,77 +23,112 @@ export class SyncEngine {
19
23
  status: "running",
20
24
  });
21
25
  try {
22
- const rows = await this.gsc.fetchSearchAnalytics({
23
- siteUrl,
24
- startDate,
25
- endDate,
26
- dimensions,
27
- });
26
+ // Fetch page-level and query-level data in parallel
27
+ const fetchQuery = dimensions.includes("query");
28
+ progress("Fetching data from Google Search Console...");
29
+ const [pageRows, queryRows] = await Promise.all([
30
+ this.gsc.fetchSearchAnalytics({
31
+ siteUrl,
32
+ startDate,
33
+ endDate,
34
+ dimensions: ["page", "date"],
35
+ }),
36
+ fetchQuery
37
+ ? this.gsc.fetchSearchAnalytics({
38
+ siteUrl,
39
+ startDate,
40
+ endDate,
41
+ dimensions: ["page", "query", "date"],
42
+ })
43
+ : Promise.resolve([]),
44
+ ]);
45
+ progress(`Fetched ${pageRows.length} page rows` +
46
+ (fetchQuery ? ` and ${queryRows.length} query rows` : "") +
47
+ ` from GSC`);
28
48
  const pages = new Set();
29
- for (const row of rows) {
49
+ for (const row of pageRows)
30
50
  pages.add(row.page);
31
- if (row.date) {
32
- const id = `${siteUrl}:${row.page}:${row.date}`;
33
- await this.db
34
- .insert(searchAnalytics)
35
- .values({
36
- id,
37
- siteId: siteUrl,
38
- page: row.page,
39
- date: row.date,
40
- clicks: row.clicks,
41
- impressions: row.impressions,
42
- ctr: row.ctr,
43
- position: row.position,
44
- })
45
- .onConflictDoUpdate({
46
- target: searchAnalytics.id,
47
- set: {
48
- clicks: row.clicks,
49
- impressions: row.impressions,
50
- ctr: row.ctr,
51
- position: row.position,
52
- fetchedAt: new Date(),
53
- },
54
- });
55
- }
56
- if (row.query && row.date) {
57
- const id = `${siteUrl}:${row.page}:${row.query}:${row.date}`;
58
- await this.db
59
- .insert(queryAnalytics)
60
- .values({
61
- id,
62
- siteId: siteUrl,
63
- page: row.page,
64
- query: row.query,
65
- date: row.date,
66
- clicks: row.clicks,
67
- impressions: row.impressions,
68
- ctr: row.ctr,
69
- position: row.position,
70
- })
71
- .onConflictDoUpdate({
72
- target: queryAnalytics.id,
73
- set: {
74
- clicks: row.clicks,
75
- impressions: row.impressions,
76
- ctr: row.ctr,
77
- position: row.position,
78
- },
79
- });
80
- }
51
+ for (const row of queryRows)
52
+ pages.add(row.page);
53
+ const BATCH_SIZE = 500;
54
+ // Write page-level data to search_analytics
55
+ const pageValues = pageRows
56
+ .filter((row) => row.date)
57
+ .map((row) => ({
58
+ id: `${siteUrl}:${row.page}:${row.date}`,
59
+ siteId: siteUrl,
60
+ page: row.page,
61
+ date: row.date,
62
+ clicks: row.clicks,
63
+ impressions: row.impressions,
64
+ ctr: row.ctr,
65
+ position: row.position,
66
+ }));
67
+ for (let i = 0; i < pageValues.length; i += BATCH_SIZE) {
68
+ const batch = pageValues.slice(i, i + BATCH_SIZE);
69
+ await this.db
70
+ .insert(searchAnalytics)
71
+ .values(batch)
72
+ .onConflictDoUpdate({
73
+ target: searchAnalytics.id,
74
+ set: {
75
+ clicks: sql `excluded.clicks`,
76
+ impressions: sql `excluded.impressions`,
77
+ ctr: sql `excluded.ctr`,
78
+ position: sql `excluded.position`,
79
+ fetchedAt: new Date(),
80
+ },
81
+ });
82
+ progress(`Writing page data... ${Math.min(i + BATCH_SIZE, pageValues.length)}/${pageValues.length} rows`);
83
+ }
84
+ if (pageValues.length > 0) {
85
+ progress(`Wrote ${pageValues.length} page rows to database`);
81
86
  }
87
+ // Write query-level data to query_analytics
88
+ const queryValues = queryRows
89
+ .filter((row) => row.query && row.date)
90
+ .map((row) => ({
91
+ id: `${siteUrl}:${row.page}:${row.query}:${row.date}`,
92
+ siteId: siteUrl,
93
+ page: row.page,
94
+ query: row.query,
95
+ date: row.date,
96
+ clicks: row.clicks,
97
+ impressions: row.impressions,
98
+ ctr: row.ctr,
99
+ position: row.position,
100
+ }));
101
+ for (let i = 0; i < queryValues.length; i += BATCH_SIZE) {
102
+ const batch = queryValues.slice(i, i + BATCH_SIZE);
103
+ await this.db
104
+ .insert(queryAnalytics)
105
+ .values(batch)
106
+ .onConflictDoUpdate({
107
+ target: queryAnalytics.id,
108
+ set: {
109
+ clicks: sql `excluded.clicks`,
110
+ impressions: sql `excluded.impressions`,
111
+ ctr: sql `excluded.ctr`,
112
+ position: sql `excluded.position`,
113
+ },
114
+ });
115
+ progress(`Writing query data... ${Math.min(i + BATCH_SIZE, queryValues.length)}/${queryValues.length} rows`);
116
+ }
117
+ if (queryValues.length > 0) {
118
+ progress(`Wrote ${queryValues.length} query rows to database`);
119
+ }
120
+ const totalRows = pageRows.length + queryRows.length;
82
121
  await this.db
83
122
  .update(syncLog)
84
123
  .set({
85
124
  status: "completed",
86
125
  completedAt: new Date(),
87
- rowsProcessed: rows.length,
126
+ rowsProcessed: totalRows,
88
127
  })
89
128
  .where(eq(syncLog.id, syncLogId));
90
129
  return {
91
130
  pages: Array.from(pages),
92
- rowsProcessed: rows.length,
131
+ rowsProcessed: totalRows,
93
132
  syncLogId,
94
133
  };
95
134
  }
@@ -105,7 +144,8 @@ export class SyncEngine {
105
144
  throw error;
106
145
  }
107
146
  }
108
- async writeSnapshots(siteId, matches, siteUrl) {
147
+ async writeSnapshots(siteId, matches, siteUrl, insights, onProgress) {
148
+ const progress = onProgress ?? (() => { });
109
149
  // Get the siteUrl from Sanity if not provided
110
150
  let resolvedSiteUrl = siteUrl;
111
151
  if (!resolvedSiteUrl) {
@@ -115,32 +155,107 @@ export class SyncEngine {
115
155
  if (!resolvedSiteUrl) {
116
156
  throw new Error(`Could not find siteUrl for site ID: ${siteId}`);
117
157
  }
158
+ const validMatches = matches.filter((m) => m.sanityId);
159
+ // Pre-fetch all existing snapshot IDs in one query
160
+ progress(`Checking existing snapshots for ${validMatches.length} pages...`);
161
+ const existingSnapshots = await this.sanity.fetch(`*[_type == "gscSnapshot" && site._ref == $siteId]{ _id, page, period }`, { siteId });
162
+ const snapshotIdMap = new Map();
163
+ for (const snap of existingSnapshots) {
164
+ snapshotIdMap.set(`${snap.page}:${snap.period}`, snap._id);
165
+ }
118
166
  const periods = ["last7", "last28", "last90"];
119
167
  const periodDays = { last7: 7, last28: 28, last90: 90 };
168
+ // Pre-fetch index status for all pages in parallel
169
+ const indexStatusMap = new Map();
170
+ await Promise.all(validMatches.map(async (match) => {
171
+ const status = await this.getIndexStatus(resolvedSiteUrl, match.gscUrl);
172
+ indexStatusMap.set(match.gscUrl, status);
173
+ }));
174
+ // Build all snapshot data, querying DB in parallel per match
175
+ progress(`Computing metrics for ${validMatches.length} pages × ${periods.length} periods...`);
176
+ const transaction = this.sanity.transaction();
177
+ let mutationCount = 0;
120
178
  for (const period of periods) {
121
179
  const startDate = daysAgo(periodDays[period]);
122
180
  const endDate = daysAgo(3);
123
- for (const match of matches) {
124
- if (!match.sanityId)
125
- continue;
126
- const metrics = await this.getAggregatedMetrics(resolvedSiteUrl, match.gscUrl, startDate, endDate);
181
+ // Fetch metrics and top queries in parallel for all matches in this period
182
+ const matchData = await Promise.all(validMatches.map(async (match) => {
183
+ const [metrics, topQueries] = await Promise.all([
184
+ this.getAggregatedMetrics(resolvedSiteUrl, match.gscUrl, startDate, endDate),
185
+ this.getTopQueries(resolvedSiteUrl, match.gscUrl, startDate, endDate),
186
+ ]);
187
+ return { match, metrics, topQueries };
188
+ }));
189
+ for (const { match, metrics, topQueries } of matchData) {
127
190
  if (!metrics)
128
191
  continue;
129
- const topQueries = await this.getTopQueries(resolvedSiteUrl, match.gscUrl, startDate, endDate);
130
- // Get index status from database
131
- const indexStatusData = await this.getIndexStatus(resolvedSiteUrl, match.gscUrl);
132
- const existingSnapshot = await this.sanity.fetch(`*[_type == "gscSnapshot" && site._ref == $siteId && page == $page && period == $period][0]._id`, { siteId, page: match.gscUrl, period });
192
+ const indexStatusData = indexStatusMap.get(match.gscUrl);
193
+ const isLast28 = period === "last28";
194
+ const quickWinQueries = isLast28
195
+ ? (insights?.quickWins?.get(match.gscUrl) ?? [])
196
+ : [];
197
+ const ctrAnomaly = isLast28
198
+ ? insights?.ctrAnomalies?.get(match.gscUrl)
199
+ : undefined;
200
+ const dailyClicks = isLast28
201
+ ? insights?.dailyMetrics?.get(match.gscUrl)
202
+ : undefined;
203
+ const publishingImpact = isLast28
204
+ ? insights?.publishingImpact?.get(match.gscUrl)
205
+ : undefined;
206
+ const cannibalizationTargets = isLast28
207
+ ? insights?.cannibalizationTargets?.get(match.gscUrl)
208
+ : undefined;
209
+ // Build alerts from all insight sources
210
+ const hasQuickWins = quickWinQueries.length > 0;
211
+ const hasDecay = insights?.decayPages?.has(match.gscUrl) ?? false;
212
+ const hasCannibalization = (cannibalizationTargets?.length ?? 0) > 0;
213
+ const alerts = isLast28
214
+ ? CtrAnomalyAnalyzer.buildAlerts(ctrAnomaly, hasQuickWins, hasDecay, hasCannibalization)
215
+ : [];
216
+ // Add _key to all array items for Sanity
217
+ const keyedQuickWins = quickWinQueries.map((q) => ({
218
+ _key: sanityKey(`qw:${q.query}`),
219
+ ...q,
220
+ }));
221
+ const keyedAlerts = alerts.map((a) => ({
222
+ _key: sanityKey(`al:${a.type}:${a.severity}`),
223
+ ...a,
224
+ }));
225
+ const keyedDailyClicks = dailyClicks?.map((d) => ({
226
+ _key: sanityKey(`dc:${d.date}`),
227
+ ...d,
228
+ }));
229
+ const keyedCannibalization = cannibalizationTargets?.map((t) => ({
230
+ _key: sanityKey(`ct:${t.competingPage}`),
231
+ ...t,
232
+ }));
133
233
  const snapshotData = {
134
234
  _type: "gscSnapshot",
135
235
  site: { _type: "reference", _ref: siteId },
136
236
  page: match.gscUrl,
137
- linkedDocument: { _type: "reference", _ref: match.sanityId },
237
+ linkedDocument: {
238
+ _type: "reference",
239
+ _ref: match.sanityId,
240
+ },
138
241
  period,
139
242
  clicks: metrics.clicks,
140
243
  impressions: metrics.impressions,
141
244
  ctr: metrics.ctr,
142
245
  position: metrics.position,
143
246
  topQueries,
247
+ ...(keyedQuickWins.length > 0
248
+ ? { quickWinQueries: keyedQuickWins }
249
+ : {}),
250
+ ...(ctrAnomaly ? { ctrAnomaly } : {}),
251
+ ...(keyedAlerts.length > 0 ? { alerts: keyedAlerts } : {}),
252
+ ...(keyedDailyClicks && keyedDailyClicks.length > 0
253
+ ? { dailyClicks: keyedDailyClicks }
254
+ : {}),
255
+ ...(publishingImpact ? { publishingImpact } : {}),
256
+ ...(keyedCannibalization && keyedCannibalization.length > 0
257
+ ? { cannibalizationTargets: keyedCannibalization }
258
+ : {}),
144
259
  fetchedAt: new Date().toISOString(),
145
260
  indexStatus: indexStatusData
146
261
  ? {
@@ -152,14 +267,18 @@ export class SyncEngine {
152
267
  }
153
268
  : undefined,
154
269
  };
155
- if (existingSnapshot) {
156
- await this.sanity.patch(existingSnapshot).set(snapshotData).commit();
270
+ const existingId = snapshotIdMap.get(`${match.gscUrl}:${period}`);
271
+ if (existingId) {
272
+ transaction.patch(existingId, (p) => p.set(snapshotData));
157
273
  }
158
274
  else {
159
- await this.sanity.create(snapshotData);
275
+ transaction.create(snapshotData);
160
276
  }
277
+ mutationCount++;
161
278
  }
162
279
  }
280
+ progress(`Committing ${mutationCount} snapshot mutations...`);
281
+ await transaction.commit();
163
282
  }
164
283
  async syncIndexStatus(siteUrl, pages) {
165
284
  const result = {
@@ -300,6 +419,7 @@ export class SyncEngine {
300
419
  }
301
420
  return Array.from(queryMap.entries())
302
421
  .map(([query, data]) => ({
422
+ _key: sanityKey(`tq:${query}`),
303
423
  query,
304
424
  clicks: data.clicks,
305
425
  impressions: data.impressions,
@@ -309,14 +429,6 @@ export class SyncEngine {
309
429
  .slice(0, 10);
310
430
  }
311
431
  }
312
- function daysAgo(days) {
313
- const date = new Date();
314
- date.setDate(date.getDate() - days);
315
- return date;
316
- }
317
- function formatDate(date) {
318
- return date.toISOString().split("T")[0];
319
- }
320
432
  function delay(ms) {
321
433
  return new Promise((resolve) => setTimeout(resolve, ms));
322
434
  }
@@ -0,0 +1,4 @@
1
+ export declare function daysAgo(days: number): Date;
2
+ export declare function formatDate(date: Date): string;
3
+ export declare function daysSince(date: Date): number;
4
+ //# sourceMappingURL=date-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"date-utils.d.ts","sourceRoot":"","sources":["../../src/utils/date-utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI1C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE7C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAI5C"}
@@ -0,0 +1,13 @@
1
+ export function daysAgo(days) {
2
+ const date = new Date();
3
+ date.setDate(date.getDate() - days);
4
+ return date;
5
+ }
6
+ export function formatDate(date) {
7
+ return date.toISOString().split("T")[0];
8
+ }
9
+ export function daysSince(date) {
10
+ const now = new Date();
11
+ const diff = now.getTime() - date.getTime();
12
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
13
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Generates a deterministic Sanity `_key` from a string seed.
3
+ * Uses a short hash so keys stay compact but unique within an array.
4
+ */
5
+ export declare function sanityKey(seed: string): string;
6
+ //# sourceMappingURL=sanity-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanity-key.d.ts","sourceRoot":"","sources":["../../src/utils/sanity-key.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C"}
@@ -0,0 +1,8 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Generates a deterministic Sanity `_key` from a string seed.
4
+ * Uses a short hash so keys stay compact but unique within an array.
5
+ */
6
+ export function sanityKey(seed) {
7
+ return createHash("sha256").update(seed).digest("hex").slice(0, 12);
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagebridge/core",
3
- "version": "0.0.3",
3
+ "version": "0.2.0",
4
4
  "description": "Core engine for PageBridge — GSC sync, URL matching, content decay detection, and task generation",
5
5
  "license": "MIT",
6
6
  "private": false,