@pagebridge/core 0.0.3 → 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/LICENSE +21 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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 +73 -0
- package/dist/sync-engine.d.ts +5 -1
- package/dist/sync-engine.d.ts.map +1 -1
- package/dist/sync-engine.js +142 -74
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Soma Somorjai
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Soma Somorjai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
+
}
|
package/dist/sync-engine.d.ts
CHANGED
|
@@ -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
|
|
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;
|
|
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"}
|
package/dist/sync-engine.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
46
|
+
for (const row of pageRows)
|
|
30
47
|
pages.add(row.page);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
123
|
+
rowsProcessed: totalRows,
|
|
88
124
|
})
|
|
89
125
|
.where(eq(syncLog.id, syncLogId));
|
|
90
126
|
return {
|
|
91
127
|
pages: Array.from(pages),
|
|
92
|
-
rowsProcessed:
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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: {
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 = {
|