@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,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
|
+
}
|
package/dist/sync-engine.d.ts
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"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"}
|
package/dist/sync-engine.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
49
|
+
for (const row of pageRows)
|
|
30
50
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
126
|
+
rowsProcessed: totalRows,
|
|
88
127
|
})
|
|
89
128
|
.where(eq(syncLog.id, syncLogId));
|
|
90
129
|
return {
|
|
91
130
|
pages: Array.from(pages),
|
|
92
|
-
rowsProcessed:
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
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: {
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|