@pagebridge/core 0.0.2 → 0.1.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/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export { SyncEngine, type SyncOptions, type SyncResult, type IndexStatusSyncResu
3
3
  export { DecayDetector, defaultRules, type DecayRule, type DecaySignal, type QuietPeriodConfig, } from "./decay-detector.js";
4
4
  export { URLMatcher, type MatchResult, type URLMatcherConfig, type UnmatchReason, type MatchDiagnostics, } from "./url-matcher.js";
5
5
  export { TaskGenerator, type TaskGeneratorOptions, type QueryContext, } from "./task-generator.js";
6
+ export { QuickWinAnalyzer, type QuickWinQuery, type QuickWinConfig, } from "./quick-win-analyzer.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GAClB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,YAAY,EACZ,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,iBAAiB,GACvB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,YAAY,GAClB,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GAClB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,YAAY,EACZ,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,iBAAiB,GACvB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,KAAK,oBAAoB,EACzB,KAAK,YAAY,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,yBAAyB,CAAC"}
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { SyncEngine, } from "./sync-engine.js";
3
3
  export { DecayDetector, defaultRules, } from "./decay-detector.js";
4
4
  export { URLMatcher, } from "./url-matcher.js";
5
5
  export { TaskGenerator, } from "./task-generator.js";
6
+ export { QuickWinAnalyzer, } from "./quick-win-analyzer.js";
@@ -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;AAIpD,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,73 @@
1
+ import { queryAnalytics } from "@pagebridge/db";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ const defaultConfig = {
4
+ positionMin: 8,
5
+ positionMax: 20,
6
+ minImpressions: 50,
7
+ maxPerPage: 10,
8
+ };
9
+ export class QuickWinAnalyzer {
10
+ db;
11
+ config;
12
+ constructor(db, config) {
13
+ this.db = db;
14
+ this.config = { ...defaultConfig, ...config };
15
+ }
16
+ /**
17
+ * Finds "quick win" queries for all pages: queries where position is 8-20
18
+ * with significant impressions. These are page-1 opportunities where a small
19
+ * content tweak could improve ranking.
20
+ */
21
+ async analyze(siteId) {
22
+ const startDate = daysAgo(28);
23
+ const endDate = daysAgo(3);
24
+ const results = await this.db
25
+ .select({
26
+ page: queryAnalytics.page,
27
+ query: queryAnalytics.query,
28
+ totalClicks: sql `sum(${queryAnalytics.clicks})`,
29
+ totalImpressions: sql `sum(${queryAnalytics.impressions})`,
30
+ avgPosition: sql `avg(${queryAnalytics.position})`,
31
+ })
32
+ .from(queryAnalytics)
33
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(startDate)), lte(queryAnalytics.date, formatDate(endDate))))
34
+ .groupBy(queryAnalytics.page, queryAnalytics.query);
35
+ const quickWins = new Map();
36
+ for (const row of results) {
37
+ const position = Number(row.avgPosition) || 0;
38
+ const impressions = Number(row.totalImpressions) || 0;
39
+ const clicks = Number(row.totalClicks) || 0;
40
+ if (position < this.config.positionMin ||
41
+ position > this.config.positionMax ||
42
+ impressions < this.config.minImpressions) {
43
+ continue;
44
+ }
45
+ const entry = {
46
+ query: row.query,
47
+ clicks,
48
+ impressions,
49
+ ctr: impressions > 0 ? clicks / impressions : 0,
50
+ position,
51
+ };
52
+ const existing = quickWins.get(row.page) ?? [];
53
+ existing.push(entry);
54
+ quickWins.set(row.page, existing);
55
+ }
56
+ // Sort by impressions desc and cap per page
57
+ for (const [page, queries] of quickWins) {
58
+ queries.sort((a, b) => b.impressions - a.impressions);
59
+ if (queries.length > this.config.maxPerPage) {
60
+ quickWins.set(page, queries.slice(0, this.config.maxPerPage));
61
+ }
62
+ }
63
+ return quickWins;
64
+ }
65
+ }
66
+ function daysAgo(days) {
67
+ const date = new Date();
68
+ date.setDate(date.getDate() - days);
69
+ return date;
70
+ }
71
+ function formatDate(date) {
72
+ return date.toISOString().split("T")[0];
73
+ }
@@ -1,11 +1,13 @@
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";
4
5
  export interface SyncOptions {
5
6
  siteUrl: string;
6
7
  startDate?: Date;
7
8
  endDate?: Date;
8
9
  dimensions?: ("page" | "query" | "date")[];
10
+ onProgress?: (message: string) => void;
9
11
  }
10
12
  export interface SyncResult {
11
13
  pages: string[];
@@ -32,7 +34,9 @@ export declare class SyncEngine {
32
34
  writeSnapshots(siteId: string, matches: {
33
35
  gscUrl: string;
34
36
  sanityId: string | undefined;
35
- }[], siteUrl?: string): Promise<void>;
37
+ }[], siteUrl?: string, insights?: {
38
+ quickWins?: Map<string, QuickWinQuery[]>;
39
+ }, onProgress?: (message: string) => void): Promise<void>;
36
40
  syncIndexStatus(siteUrl: string, pages: string[]): Promise<IndexStatusSyncResult>;
37
41
  getIndexStatus(siteUrl: string, page: string): Promise<IndexStatusResult | null>;
38
42
  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":"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;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAE7D,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;IAC3C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;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;IAgK/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,EAChB,QAAQ,CAAC,EAAE;QACT,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;KAC1C,EACD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GACrC,OAAO,CAAC,IAAI,CAAC;IAqIV,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,5 +1,5 @@
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
3
  export class SyncEngine {
4
4
  gsc;
5
5
  db;
@@ -10,7 +10,8 @@ export class SyncEngine {
10
10
  this.sanity = options.sanity;
11
11
  }
12
12
  async sync(options) {
13
- const { siteUrl, startDate = daysAgo(90), endDate = daysAgo(3), dimensions = ["page", "date"], } = options;
13
+ const { siteUrl, startDate = daysAgo(90), endDate = daysAgo(3), dimensions = ["page", "query", "date"], onProgress, } = options;
14
+ const progress = onProgress ?? (() => { });
14
15
  const syncLogId = `${siteUrl}:${Date.now()}`;
15
16
  await this.db.insert(syncLog).values({
16
17
  id: syncLogId,
@@ -19,77 +20,112 @@ export class SyncEngine {
19
20
  status: "running",
20
21
  });
21
22
  try {
22
- const rows = await this.gsc.fetchSearchAnalytics({
23
- siteUrl,
24
- startDate,
25
- endDate,
26
- dimensions,
27
- });
23
+ // Fetch page-level and query-level data in parallel
24
+ const fetchQuery = dimensions.includes("query");
25
+ progress("Fetching data from Google Search Console...");
26
+ const [pageRows, queryRows] = await Promise.all([
27
+ this.gsc.fetchSearchAnalytics({
28
+ siteUrl,
29
+ startDate,
30
+ endDate,
31
+ dimensions: ["page", "date"],
32
+ }),
33
+ fetchQuery
34
+ ? this.gsc.fetchSearchAnalytics({
35
+ siteUrl,
36
+ startDate,
37
+ endDate,
38
+ dimensions: ["page", "query", "date"],
39
+ })
40
+ : Promise.resolve([]),
41
+ ]);
42
+ progress(`Fetched ${pageRows.length} page rows` +
43
+ (fetchQuery ? ` and ${queryRows.length} query rows` : "") +
44
+ ` from GSC`);
28
45
  const pages = new Set();
29
- for (const row of rows) {
46
+ for (const row of pageRows)
30
47
  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
- }
48
+ for (const row of queryRows)
49
+ pages.add(row.page);
50
+ const BATCH_SIZE = 500;
51
+ // Write page-level data to search_analytics
52
+ const pageValues = pageRows
53
+ .filter((row) => row.date)
54
+ .map((row) => ({
55
+ id: `${siteUrl}:${row.page}:${row.date}`,
56
+ siteId: siteUrl,
57
+ page: row.page,
58
+ date: row.date,
59
+ clicks: row.clicks,
60
+ impressions: row.impressions,
61
+ ctr: row.ctr,
62
+ position: row.position,
63
+ }));
64
+ for (let i = 0; i < pageValues.length; i += BATCH_SIZE) {
65
+ const batch = pageValues.slice(i, i + BATCH_SIZE);
66
+ await this.db
67
+ .insert(searchAnalytics)
68
+ .values(batch)
69
+ .onConflictDoUpdate({
70
+ target: searchAnalytics.id,
71
+ set: {
72
+ clicks: sql `excluded.clicks`,
73
+ impressions: sql `excluded.impressions`,
74
+ ctr: sql `excluded.ctr`,
75
+ position: sql `excluded.position`,
76
+ fetchedAt: new Date(),
77
+ },
78
+ });
79
+ progress(`Writing page data... ${Math.min(i + BATCH_SIZE, pageValues.length)}/${pageValues.length} rows`);
81
80
  }
81
+ if (pageValues.length > 0) {
82
+ progress(`Wrote ${pageValues.length} page rows to database`);
83
+ }
84
+ // Write query-level data to query_analytics
85
+ const queryValues = queryRows
86
+ .filter((row) => row.query && row.date)
87
+ .map((row) => ({
88
+ id: `${siteUrl}:${row.page}:${row.query}:${row.date}`,
89
+ siteId: siteUrl,
90
+ page: row.page,
91
+ query: row.query,
92
+ date: row.date,
93
+ clicks: row.clicks,
94
+ impressions: row.impressions,
95
+ ctr: row.ctr,
96
+ position: row.position,
97
+ }));
98
+ for (let i = 0; i < queryValues.length; i += BATCH_SIZE) {
99
+ const batch = queryValues.slice(i, i + BATCH_SIZE);
100
+ await this.db
101
+ .insert(queryAnalytics)
102
+ .values(batch)
103
+ .onConflictDoUpdate({
104
+ target: queryAnalytics.id,
105
+ set: {
106
+ clicks: sql `excluded.clicks`,
107
+ impressions: sql `excluded.impressions`,
108
+ ctr: sql `excluded.ctr`,
109
+ position: sql `excluded.position`,
110
+ },
111
+ });
112
+ progress(`Writing query data... ${Math.min(i + BATCH_SIZE, queryValues.length)}/${queryValues.length} rows`);
113
+ }
114
+ if (queryValues.length > 0) {
115
+ progress(`Wrote ${queryValues.length} query rows to database`);
116
+ }
117
+ const totalRows = pageRows.length + queryRows.length;
82
118
  await this.db
83
119
  .update(syncLog)
84
120
  .set({
85
121
  status: "completed",
86
122
  completedAt: new Date(),
87
- rowsProcessed: rows.length,
123
+ rowsProcessed: totalRows,
88
124
  })
89
125
  .where(eq(syncLog.id, syncLogId));
90
126
  return {
91
127
  pages: Array.from(pages),
92
- rowsProcessed: rows.length,
128
+ rowsProcessed: totalRows,
93
129
  syncLogId,
94
130
  };
95
131
  }
@@ -105,7 +141,8 @@ export class SyncEngine {
105
141
  throw error;
106
142
  }
107
143
  }
108
- async writeSnapshots(siteId, matches, siteUrl) {
144
+ async writeSnapshots(siteId, matches, siteUrl, insights, onProgress) {
145
+ const progress = onProgress ?? (() => { });
109
146
  // Get the siteUrl from Sanity if not provided
110
147
  let resolvedSiteUrl = siteUrl;
111
148
  if (!resolvedSiteUrl) {
@@ -115,32 +152,59 @@ export class SyncEngine {
115
152
  if (!resolvedSiteUrl) {
116
153
  throw new Error(`Could not find siteUrl for site ID: ${siteId}`);
117
154
  }
155
+ const validMatches = matches.filter((m) => m.sanityId);
156
+ // Pre-fetch all existing snapshot IDs in one query
157
+ progress(`Checking existing snapshots for ${validMatches.length} pages...`);
158
+ const existingSnapshots = await this.sanity.fetch(`*[_type == "gscSnapshot" && site._ref == $siteId]{ _id, page, period }`, { siteId });
159
+ const snapshotIdMap = new Map();
160
+ for (const snap of existingSnapshots) {
161
+ snapshotIdMap.set(`${snap.page}:${snap.period}`, snap._id);
162
+ }
118
163
  const periods = ["last7", "last28", "last90"];
119
164
  const periodDays = { last7: 7, last28: 28, last90: 90 };
165
+ // Pre-fetch index status for all pages in parallel
166
+ const indexStatusMap = new Map();
167
+ await Promise.all(validMatches.map(async (match) => {
168
+ const status = await this.getIndexStatus(resolvedSiteUrl, match.gscUrl);
169
+ indexStatusMap.set(match.gscUrl, status);
170
+ }));
171
+ // Build all snapshot data, querying DB in parallel per match
172
+ progress(`Computing metrics for ${validMatches.length} pages × ${periods.length} periods...`);
173
+ const transaction = this.sanity.transaction();
174
+ let mutationCount = 0;
120
175
  for (const period of periods) {
121
176
  const startDate = daysAgo(periodDays[period]);
122
177
  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);
178
+ // Fetch metrics and top queries in parallel for all matches in this period
179
+ const matchData = await Promise.all(validMatches.map(async (match) => {
180
+ const [metrics, topQueries] = await Promise.all([
181
+ this.getAggregatedMetrics(resolvedSiteUrl, match.gscUrl, startDate, endDate),
182
+ this.getTopQueries(resolvedSiteUrl, match.gscUrl, startDate, endDate),
183
+ ]);
184
+ return { match, metrics, topQueries };
185
+ }));
186
+ for (const { match, metrics, topQueries } of matchData) {
127
187
  if (!metrics)
128
188
  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 });
189
+ const indexStatusData = indexStatusMap.get(match.gscUrl);
190
+ const quickWinQueries = period === "last28"
191
+ ? (insights?.quickWins?.get(match.gscUrl) ?? [])
192
+ : [];
133
193
  const snapshotData = {
134
194
  _type: "gscSnapshot",
135
195
  site: { _type: "reference", _ref: siteId },
136
196
  page: match.gscUrl,
137
- linkedDocument: { _type: "reference", _ref: match.sanityId },
197
+ linkedDocument: {
198
+ _type: "reference",
199
+ _ref: match.sanityId,
200
+ },
138
201
  period,
139
202
  clicks: metrics.clicks,
140
203
  impressions: metrics.impressions,
141
204
  ctr: metrics.ctr,
142
205
  position: metrics.position,
143
206
  topQueries,
207
+ ...(quickWinQueries.length > 0 ? { quickWinQueries } : {}),
144
208
  fetchedAt: new Date().toISOString(),
145
209
  indexStatus: indexStatusData
146
210
  ? {
@@ -152,14 +216,18 @@ export class SyncEngine {
152
216
  }
153
217
  : undefined,
154
218
  };
155
- if (existingSnapshot) {
156
- await this.sanity.patch(existingSnapshot).set(snapshotData).commit();
219
+ const existingId = snapshotIdMap.get(`${match.gscUrl}:${period}`);
220
+ if (existingId) {
221
+ transaction.patch(existingId, (p) => p.set(snapshotData));
157
222
  }
158
223
  else {
159
- await this.sanity.create(snapshotData);
224
+ transaction.create(snapshotData);
160
225
  }
226
+ mutationCount++;
161
227
  }
162
228
  }
229
+ progress(`Committing ${mutationCount} snapshot mutations...`);
230
+ await transaction.commit();
163
231
  }
164
232
  async syncIndexStatus(siteUrl, pages) {
165
233
  const result = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagebridge/core",
3
- "version": "0.0.2",
3
+ "version": "0.1.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,
@@ -17,6 +17,11 @@
17
17
  "sync-engine",
18
18
  "sanity"
19
19
  ],
20
+ "files": [
21
+ "dist",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
20
25
  "exports": {
21
26
  ".": {
22
27
  "types": "./dist/index.d.ts",
@@ -1,2 +0,0 @@
1
- [?9001h[?1004h[?25l> @pagebridge/core@0.0.2 build F:\Code\pagebridge\oss\packages\core
2
- > tsc]0;C:\WINDOWS\system32\cmd.exe[?25h[?9001l[?1004l
@@ -1,4 +0,0 @@
1
-
2
- > @pagebridge/core@0.0.1 check-types F:\Code\pagebridge\oss\packages\core
3
- > tsc --noEmit
4
-
package/eslint.config.js DELETED
@@ -1,3 +0,0 @@
1
- import { config } from "@pagebridge/eslint-config/base";
2
-
3
- export default [...config];