@pagebridge/core 0.1.0 → 0.3.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/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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -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.map +1 -1
- package/dist/quick-win-analyzer.js +1 -8
- 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 +34 -3
- package/dist/sync-engine.d.ts.map +1 -1
- package/dist/sync-engine.js +54 -10
- package/dist/url-matcher.d.ts +22 -3
- package/dist/url-matcher.d.ts.map +1 -1
- package/dist/url-matcher.js +128 -109
- 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
|
@@ -2,6 +2,39 @@ 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
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
|
+
}
|
|
5
38
|
export interface SyncOptions {
|
|
6
39
|
siteUrl: string;
|
|
7
40
|
startDate?: Date;
|
|
@@ -34,9 +67,7 @@ export declare class SyncEngine {
|
|
|
34
67
|
writeSnapshots(siteId: string, matches: {
|
|
35
68
|
gscUrl: string;
|
|
36
69
|
sanityId: string | undefined;
|
|
37
|
-
}[], siteUrl?: string, insights?:
|
|
38
|
-
quickWins?: Map<string, QuickWinQuery[]>;
|
|
39
|
-
}, onProgress?: (message: string) => void): Promise<void>;
|
|
70
|
+
}[], siteUrl?: string, insights?: SnapshotInsights, onProgress?: (message: string) => void): Promise<void>;
|
|
40
71
|
syncIndexStatus(siteUrl: string, pages: string[]): Promise<IndexStatusSyncResult>;
|
|
41
72
|
getIndexStatus(siteUrl: string, page: string): Promise<IndexStatusResult | null>;
|
|
42
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;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,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
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;
|
|
@@ -187,9 +190,46 @@ export class SyncEngine {
|
|
|
187
190
|
if (!metrics)
|
|
188
191
|
continue;
|
|
189
192
|
const indexStatusData = indexStatusMap.get(match.gscUrl);
|
|
190
|
-
const
|
|
193
|
+
const isLast28 = period === "last28";
|
|
194
|
+
const quickWinQueries = isLast28
|
|
191
195
|
? (insights?.quickWins?.get(match.gscUrl) ?? [])
|
|
192
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
|
+
}));
|
|
193
233
|
const snapshotData = {
|
|
194
234
|
_type: "gscSnapshot",
|
|
195
235
|
site: { _type: "reference", _ref: siteId },
|
|
@@ -204,7 +244,18 @@ export class SyncEngine {
|
|
|
204
244
|
ctr: metrics.ctr,
|
|
205
245
|
position: metrics.position,
|
|
206
246
|
topQueries,
|
|
207
|
-
...(
|
|
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
|
+
: {}),
|
|
208
259
|
fetchedAt: new Date().toISOString(),
|
|
209
260
|
indexStatus: indexStatusData
|
|
210
261
|
? {
|
|
@@ -368,6 +419,7 @@ export class SyncEngine {
|
|
|
368
419
|
}
|
|
369
420
|
return Array.from(queryMap.entries())
|
|
370
421
|
.map(([query, data]) => ({
|
|
422
|
+
_key: sanityKey(`tq:${query}`),
|
|
371
423
|
query,
|
|
372
424
|
clicks: data.clicks,
|
|
373
425
|
impressions: data.impressions,
|
|
@@ -377,14 +429,6 @@ export class SyncEngine {
|
|
|
377
429
|
.slice(0, 10);
|
|
378
430
|
}
|
|
379
431
|
}
|
|
380
|
-
function daysAgo(days) {
|
|
381
|
-
const date = new Date();
|
|
382
|
-
date.setDate(date.getDate() - days);
|
|
383
|
-
return date;
|
|
384
|
-
}
|
|
385
|
-
function formatDate(date) {
|
|
386
|
-
return date.toISOString().split("T")[0];
|
|
387
|
-
}
|
|
388
432
|
function delay(ms) {
|
|
389
433
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
390
434
|
}
|
package/dist/url-matcher.d.ts
CHANGED
|
@@ -14,17 +14,37 @@ export interface MatchResult {
|
|
|
14
14
|
matchedSlug?: string;
|
|
15
15
|
unmatchReason: UnmatchReason;
|
|
16
16
|
extractedSlug?: string;
|
|
17
|
+
matchedContentType?: string;
|
|
17
18
|
diagnostics?: MatchDiagnostics;
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for a single content type's URL structure
|
|
22
|
+
*/
|
|
23
|
+
export interface ContentTypeUrlConfig {
|
|
24
|
+
contentType: string;
|
|
25
|
+
pathPrefix?: string;
|
|
21
26
|
slugField: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* URLMatcher configuration with support for multiple content types and path structures
|
|
30
|
+
* @deprecated If using the old flat structure (contentTypes, slugField, pathPrefix),
|
|
31
|
+
* migrate to urlConfigs for more flexible URL path handling.
|
|
32
|
+
*/
|
|
33
|
+
export interface URLMatcherConfig {
|
|
34
|
+
/** New format: array of per-content-type URL configs (recommended) */
|
|
35
|
+
urlConfigs?: ContentTypeUrlConfig[];
|
|
36
|
+
/** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
|
|
37
|
+
contentTypes?: string[];
|
|
38
|
+
/** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
|
|
39
|
+
slugField?: string;
|
|
40
|
+
/** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
|
|
22
41
|
pathPrefix?: string;
|
|
23
42
|
baseUrl: string;
|
|
24
43
|
}
|
|
25
44
|
export declare class URLMatcher {
|
|
26
45
|
private sanityClient;
|
|
27
46
|
private config;
|
|
47
|
+
private normalizedConfigs;
|
|
28
48
|
constructor(sanityClient: SanityClient, config: URLMatcherConfig);
|
|
29
49
|
matchUrls(gscUrls: string[]): Promise<MatchResult[]>;
|
|
30
50
|
/**
|
|
@@ -33,7 +53,6 @@ export declare class URLMatcher {
|
|
|
33
53
|
getAvailableSlugs(): Promise<string[]>;
|
|
34
54
|
private matchSingleUrl;
|
|
35
55
|
private normalizeUrl;
|
|
36
|
-
private extractSlug;
|
|
37
56
|
private extractSlugWithDiagnostics;
|
|
38
57
|
private escapeRegex;
|
|
39
58
|
private normalizeSlug;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"url-matcher.d.ts","sourceRoot":"","sources":["../src/url-matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,CAAC;IACtD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"url-matcher.d.ts","sourceRoot":"","sources":["../src/url-matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,CAAC;IACtD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,UAAU,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACpC,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AASD,qBAAa,UAAU;IAInB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAyB;gBAGxC,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,gBAAgB;IAuB5B,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAuC1D;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAsB5C,OAAO,CAAC,cAAc;IA0GtB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,0BAA0B;IA2ClC,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,aAAa;IAIrB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAiBxB;;OAEG;IACH,OAAO,CAAC,mBAAmB;CA8B5B"}
|
package/dist/url-matcher.js
CHANGED
|
@@ -1,130 +1,152 @@
|
|
|
1
1
|
export class URLMatcher {
|
|
2
2
|
sanityClient;
|
|
3
3
|
config;
|
|
4
|
+
normalizedConfigs;
|
|
4
5
|
constructor(sanityClient, config) {
|
|
5
6
|
this.sanityClient = sanityClient;
|
|
6
7
|
this.config = config;
|
|
8
|
+
// Normalize config: convert old format to new format if needed
|
|
9
|
+
if (config.urlConfigs) {
|
|
10
|
+
this.normalizedConfigs = config.urlConfigs;
|
|
11
|
+
}
|
|
12
|
+
else if (config.contentTypes) {
|
|
13
|
+
// Backward compatibility: convert old flat format to new format
|
|
14
|
+
console.warn("[URLMatcher] Deprecated: contentTypes, slugField, and pathPrefix are deprecated. " +
|
|
15
|
+
"Please use urlConfigs instead for more flexible URL path handling per content type.");
|
|
16
|
+
this.normalizedConfigs = config.contentTypes.map((contentType) => ({
|
|
17
|
+
contentType,
|
|
18
|
+
slugField: config.slugField || "slug",
|
|
19
|
+
pathPrefix: config.pathPrefix,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
throw new Error("URLMatcher requires either 'urlConfigs' (new format) or 'contentTypes' (deprecated format)");
|
|
24
|
+
}
|
|
7
25
|
}
|
|
8
26
|
async matchUrls(gscUrls) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
_type,
|
|
12
|
-
"${this.config.slugField}": ${this.config.slugField}.current,
|
|
13
|
-
_createdAt
|
|
14
|
-
}`;
|
|
15
|
-
const documents = await this.sanityClient.fetch(query, {
|
|
16
|
-
types: this.config.contentTypes,
|
|
17
|
-
});
|
|
18
|
-
const slugToDoc = new Map();
|
|
27
|
+
// Query documents for each content type with its configured slug field
|
|
28
|
+
const slugToDocMap = new Map();
|
|
19
29
|
const allSlugs = [];
|
|
20
|
-
for (const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
for (const urlConfig of this.normalizedConfigs) {
|
|
31
|
+
const query = `*[_type == $type]{
|
|
32
|
+
_id,
|
|
33
|
+
_type,
|
|
34
|
+
"${urlConfig.slugField}": ${urlConfig.slugField}.current,
|
|
35
|
+
_createdAt
|
|
36
|
+
}`;
|
|
37
|
+
const documents = await this.sanityClient.fetch(query, {
|
|
38
|
+
type: urlConfig.contentType,
|
|
39
|
+
});
|
|
40
|
+
for (const doc of documents) {
|
|
41
|
+
const slug = doc[urlConfig.slugField];
|
|
42
|
+
if (slug) {
|
|
43
|
+
const normalized = this.normalizeSlug(slug);
|
|
44
|
+
const key = `${urlConfig.contentType}:${normalized}`;
|
|
45
|
+
slugToDocMap.set(key, {
|
|
46
|
+
_id: doc._id,
|
|
47
|
+
_createdAt: doc._createdAt,
|
|
48
|
+
contentType: urlConfig.contentType,
|
|
49
|
+
});
|
|
50
|
+
allSlugs.push(normalized);
|
|
51
|
+
}
|
|
26
52
|
}
|
|
27
53
|
}
|
|
28
|
-
return gscUrls.map((url) => this.matchSingleUrl(url,
|
|
54
|
+
return gscUrls.map((url) => this.matchSingleUrl(url, slugToDocMap, allSlugs));
|
|
29
55
|
}
|
|
30
56
|
/**
|
|
31
57
|
* Get all available slugs from Sanity for diagnostic purposes
|
|
32
58
|
*/
|
|
33
59
|
async getAvailableSlugs() {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
const allSlugs = [];
|
|
61
|
+
for (const urlConfig of this.normalizedConfigs) {
|
|
62
|
+
const query = `*[_type == $type]{
|
|
63
|
+
"${urlConfig.slugField}": ${urlConfig.slugField}.current
|
|
64
|
+
}`;
|
|
65
|
+
const documents = await this.sanityClient.fetch(query, {
|
|
66
|
+
type: urlConfig.contentType,
|
|
67
|
+
});
|
|
68
|
+
const slugs = documents
|
|
69
|
+
.map((doc) => doc[urlConfig.slugField])
|
|
70
|
+
.filter((slug) => !!slug)
|
|
71
|
+
.map((slug) => this.normalizeSlug(slug));
|
|
72
|
+
allSlugs.push(...slugs);
|
|
73
|
+
}
|
|
74
|
+
return allSlugs;
|
|
44
75
|
}
|
|
45
|
-
matchSingleUrl(gscUrl,
|
|
76
|
+
matchSingleUrl(gscUrl, slugToDocMap, allSlugs) {
|
|
46
77
|
const normalized = this.normalizeUrl(gscUrl);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
const withTrailing = slug + "/";
|
|
104
|
-
const addedTrailingMatch = slugToDoc.get(withTrailing);
|
|
105
|
-
if (addedTrailingMatch) {
|
|
106
|
-
return {
|
|
107
|
-
gscUrl,
|
|
108
|
-
sanityId: addedTrailingMatch._id,
|
|
109
|
-
confidence: "normalized",
|
|
110
|
-
matchedSlug: withTrailing,
|
|
111
|
-
unmatchReason: "matched",
|
|
112
|
-
extractedSlug: slug,
|
|
113
|
-
};
|
|
78
|
+
// Try to match against each content type's path prefix
|
|
79
|
+
for (const urlConfig of this.normalizedConfigs) {
|
|
80
|
+
const extractionResult = this.extractSlugWithDiagnostics(normalized, urlConfig.pathPrefix);
|
|
81
|
+
if (extractionResult.outsidePrefix) {
|
|
82
|
+
continue; // Try next content type
|
|
83
|
+
}
|
|
84
|
+
const slug = extractionResult.slug;
|
|
85
|
+
if (!slug) {
|
|
86
|
+
continue; // Try next content type
|
|
87
|
+
}
|
|
88
|
+
// Try exact match
|
|
89
|
+
const key = `${urlConfig.contentType}:${slug}`;
|
|
90
|
+
const exactMatch = slugToDocMap.get(key);
|
|
91
|
+
if (exactMatch) {
|
|
92
|
+
return {
|
|
93
|
+
gscUrl,
|
|
94
|
+
sanityId: exactMatch._id,
|
|
95
|
+
confidence: "exact",
|
|
96
|
+
matchedSlug: slug,
|
|
97
|
+
matchedContentType: urlConfig.contentType,
|
|
98
|
+
unmatchReason: "matched",
|
|
99
|
+
extractedSlug: slug,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// Try without trailing slash
|
|
103
|
+
const withoutTrailing = slug.replace(/\/$/, "");
|
|
104
|
+
const keyWithoutTrailing = `${urlConfig.contentType}:${withoutTrailing}`;
|
|
105
|
+
const trailingMatch = slugToDocMap.get(keyWithoutTrailing);
|
|
106
|
+
if (trailingMatch) {
|
|
107
|
+
return {
|
|
108
|
+
gscUrl,
|
|
109
|
+
sanityId: trailingMatch._id,
|
|
110
|
+
confidence: "normalized",
|
|
111
|
+
matchedSlug: withoutTrailing,
|
|
112
|
+
matchedContentType: urlConfig.contentType,
|
|
113
|
+
unmatchReason: "matched",
|
|
114
|
+
extractedSlug: slug,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Try with trailing slash
|
|
118
|
+
const withTrailing = slug + "/";
|
|
119
|
+
const keyWithTrailing = `${urlConfig.contentType}:${withTrailing}`;
|
|
120
|
+
const addedTrailingMatch = slugToDocMap.get(keyWithTrailing);
|
|
121
|
+
if (addedTrailingMatch) {
|
|
122
|
+
return {
|
|
123
|
+
gscUrl,
|
|
124
|
+
sanityId: addedTrailingMatch._id,
|
|
125
|
+
confidence: "normalized",
|
|
126
|
+
matchedSlug: withTrailing,
|
|
127
|
+
matchedContentType: urlConfig.contentType,
|
|
128
|
+
unmatchReason: "matched",
|
|
129
|
+
extractedSlug: slug,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
114
132
|
}
|
|
115
|
-
// No match found
|
|
116
|
-
const
|
|
133
|
+
// No match found across any content type - return diagnostic info
|
|
134
|
+
const firstConfig = this.normalizedConfigs[0];
|
|
135
|
+
const firstExtractionResult = this.extractSlugWithDiagnostics(normalized, firstConfig?.pathPrefix);
|
|
136
|
+
const similarSlugs = this.findSimilarSlugs(firstExtractionResult.slug || "", allSlugs, 3);
|
|
117
137
|
return {
|
|
118
138
|
gscUrl,
|
|
119
139
|
sanityId: undefined,
|
|
120
140
|
confidence: "none",
|
|
121
|
-
unmatchReason:
|
|
122
|
-
|
|
141
|
+
unmatchReason: firstExtractionResult.outsidePrefix
|
|
142
|
+
? "outside_path_prefix"
|
|
143
|
+
: "no_matching_document",
|
|
144
|
+
extractedSlug: firstExtractionResult.slug,
|
|
123
145
|
diagnostics: {
|
|
124
146
|
normalizedUrl: normalized,
|
|
125
|
-
pathAfterPrefix:
|
|
126
|
-
configuredPrefix:
|
|
127
|
-
availableSlugsCount:
|
|
147
|
+
pathAfterPrefix: firstExtractionResult.pathAfterPrefix,
|
|
148
|
+
configuredPrefix: firstConfig?.pathPrefix ?? null,
|
|
149
|
+
availableSlugsCount: slugToDocMap.size,
|
|
128
150
|
similarSlugs,
|
|
129
151
|
},
|
|
130
152
|
};
|
|
@@ -141,16 +163,13 @@ export class URLMatcher {
|
|
|
141
163
|
return url.toLowerCase();
|
|
142
164
|
}
|
|
143
165
|
}
|
|
144
|
-
|
|
145
|
-
return this.extractSlugWithDiagnostics(normalizedUrl).slug;
|
|
146
|
-
}
|
|
147
|
-
extractSlugWithDiagnostics(normalizedUrl) {
|
|
166
|
+
extractSlugWithDiagnostics(normalizedUrl, pathPrefix) {
|
|
148
167
|
try {
|
|
149
168
|
const parsed = new URL(normalizedUrl);
|
|
150
169
|
let path = parsed.pathname;
|
|
151
170
|
// Check if the URL is outside the configured path prefix
|
|
152
|
-
if (
|
|
153
|
-
const prefixRegex = new RegExp(`^${this.escapeRegex(
|
|
171
|
+
if (pathPrefix) {
|
|
172
|
+
const prefixRegex = new RegExp(`^${this.escapeRegex(pathPrefix)}(/|$)`);
|
|
154
173
|
if (!prefixRegex.test(path)) {
|
|
155
174
|
return {
|
|
156
175
|
slug: undefined,
|
|
@@ -158,7 +177,7 @@ export class URLMatcher {
|
|
|
158
177
|
outsidePrefix: true,
|
|
159
178
|
};
|
|
160
179
|
}
|
|
161
|
-
path = path.replace(new RegExp(`^${this.escapeRegex(
|
|
180
|
+
path = path.replace(new RegExp(`^${this.escapeRegex(pathPrefix)}`), "");
|
|
162
181
|
}
|
|
163
182
|
const slug = path.replace(/^\/+|\/+$/g, "");
|
|
164
183
|
return {
|
|
@@ -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"}
|