@pagebridge/cli 0.0.1 → 0.1.1

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.
@@ -1,129 +0,0 @@
1
- import { Command } from "commander";
2
- import postgres from "postgres";
3
- import {
4
- createDbWithClient,
5
- unmatchDiagnostics,
6
- eq,
7
- desc,
8
- } from "@pagebridge/db";
9
-
10
- export const diagnoseCommand = new Command("diagnose")
11
- .description("View diagnostics for unmatched URLs")
12
- .requiredOption("--site <url>", "GSC site URL (e.g., sc-domain:example.com)")
13
- .option("--reason <reason>", "Filter by unmatch reason")
14
- .option("--limit <n>", "Limit number of results", "20")
15
- .option("--json", "Output as JSON")
16
- .action(async (options) => {
17
- if (!process.env.DATABASE_URL) {
18
- console.error("Missing required environment variable: DATABASE_URL");
19
- process.exit(1);
20
- }
21
-
22
- const sql = postgres(process.env.DATABASE_URL);
23
- const db = createDbWithClient(sql);
24
-
25
- try {
26
- const query = db
27
- .select()
28
- .from(unmatchDiagnostics)
29
- .where(eq(unmatchDiagnostics.siteId, options.site))
30
- .orderBy(desc(unmatchDiagnostics.lastSeenAt))
31
- .limit(parseInt(options.limit));
32
-
33
- const results = await query;
34
-
35
- if (options.json) {
36
- console.log(JSON.stringify(results, null, 2));
37
- return;
38
- }
39
-
40
- if (results.length === 0) {
41
- console.log(`No unmatched URLs found for ${options.site}`);
42
- console.log(
43
- `Run 'sync --site ${options.site}' first to generate diagnostics.`,
44
- );
45
- return;
46
- }
47
-
48
- // Group by reason
49
- const byReason = new Map<string, typeof results>();
50
- for (const r of results) {
51
- const existing = byReason.get(r.unmatchReason) ?? [];
52
- existing.push(r);
53
- byReason.set(r.unmatchReason, existing);
54
- }
55
-
56
- console.log(`\nUnmatched URL Diagnostics for ${options.site}\n`);
57
- console.log(`Total: ${results.length} unmatched URLs\n`);
58
-
59
- for (const [reason, items] of byReason) {
60
- console.log(
61
- `${getReasonEmoji(reason)} ${getReasonDescription(reason)} (${items.length}):`,
62
- );
63
- console.log();
64
-
65
- for (const item of items) {
66
- console.log(` ${item.gscUrl}`);
67
- if (item.extractedSlug) {
68
- console.log(` Extracted slug: "${item.extractedSlug}"`);
69
- }
70
- if (item.similarSlugs) {
71
- try {
72
- const similar = JSON.parse(item.similarSlugs) as string[];
73
- if (similar.length > 0) {
74
- console.log(` Similar slugs in Sanity:`);
75
- for (const s of similar) {
76
- console.log(` - ${s}`);
77
- }
78
- }
79
- } catch {
80
- // Ignore parse errors
81
- }
82
- }
83
- console.log();
84
- }
85
- }
86
-
87
- console.log(`\nTo fix unmatched URLs:`);
88
- console.log(
89
- ` 1. Check if the Sanity document exists with the correct slug`,
90
- );
91
- console.log(` 2. Verify the document type is in the contentTypes list`);
92
- console.log(` 3. Ensure the slug field name matches your configuration`);
93
- console.log(
94
- ` 4. If using a path prefix, verify it matches your URL structure`,
95
- );
96
- } finally {
97
- await sql.end();
98
- }
99
- });
100
-
101
- function getReasonEmoji(reason: string): string {
102
- switch (reason) {
103
- case "matched":
104
- return "[OK]";
105
- case "no_slug_extracted":
106
- return "[SLUG]";
107
- case "no_matching_document":
108
- return "[DOC]";
109
- case "outside_path_prefix":
110
- return "[PREFIX]";
111
- default:
112
- return "[?]";
113
- }
114
- }
115
-
116
- function getReasonDescription(reason: string): string {
117
- switch (reason) {
118
- case "matched":
119
- return "Successfully matched";
120
- case "no_slug_extracted":
121
- return "Could not extract slug from URL";
122
- case "no_matching_document":
123
- return "No Sanity document with matching slug";
124
- case "outside_path_prefix":
125
- return "URL outside configured path prefix";
126
- default:
127
- return `Unknown reason: ${reason}`;
128
- }
129
- }
@@ -1,47 +0,0 @@
1
- import { Command } from "commander";
2
- import { GSCClient } from "@pagebridge/core";
3
-
4
- export const listSitesCommand = new Command("list-sites")
5
- .description("List all sites the service account has access to")
6
- .action(async () => {
7
- if (!process.env.GOOGLE_SERVICE_ACCOUNT) {
8
- console.error("❌ Missing GOOGLE_SERVICE_ACCOUNT environment variable");
9
- process.exit(1);
10
- }
11
-
12
- let credentials;
13
- try {
14
- credentials = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT);
15
- } catch {
16
- console.error("❌ Failed to parse GOOGLE_SERVICE_ACCOUNT as JSON");
17
- process.exit(1);
18
- }
19
-
20
- console.log(`🔑 Using service account: ${credentials.client_email}`);
21
-
22
- const gsc = new GSCClient({ credentials });
23
-
24
- try {
25
- const sites = await gsc.listSites();
26
-
27
- if (sites.length === 0) {
28
- console.log(
29
- "\n⚠️ No sites found. The service account has no access to any GSC properties.",
30
- );
31
- console.log("\nTo fix this:");
32
- console.log(
33
- "1. Go to Google Search Console → Settings → Users and permissions",
34
- );
35
- console.log(`2. Add user: ${credentials.client_email}`);
36
- console.log("3. Set permission level to 'Full'");
37
- } else {
38
- console.log(`\n✅ Found ${sites.length} site(s):\n`);
39
- sites.forEach((site) => console.log(` ${site}`));
40
- console.log(
41
- '\nUse one of these exact values with: pnpm sync --site "<value>"',
42
- );
43
- }
44
- } catch (error) {
45
- console.error("❌ Failed to list sites:", error);
46
- }
47
- });
@@ -1,406 +0,0 @@
1
- import { Command } from "commander";
2
- import { createClient as createSanityClient } from "@sanity/client";
3
- import postgres from "postgres";
4
- import {
5
- GSCClient,
6
- SyncEngine,
7
- DecayDetector,
8
- URLMatcher,
9
- TaskGenerator,
10
- type MatchResult,
11
- type UnmatchReason,
12
- } from "@pagebridge/core";
13
- import { createDbWithClient, unmatchDiagnostics } from "@pagebridge/db";
14
-
15
- function daysAgo(days: number): Date {
16
- const date = new Date();
17
- date.setDate(date.getDate() - days);
18
- return date;
19
- }
20
-
21
- function createTimer(debug: boolean) {
22
- return {
23
- start: () => performance.now(),
24
- end: (label: string, startTime: number) => {
25
- if (debug) {
26
- const elapsed = ((performance.now() - startTime) / 1000).toFixed(2);
27
- console.log(`[DEBUG] ${label} completed in ${elapsed}s`);
28
- }
29
- },
30
- };
31
- }
32
-
33
- export const syncCommand = new Command("sync")
34
- .description("Sync GSC data and generate refresh tasks")
35
- .requiredOption("--site <url>", "GSC site URL (e.g., sc-domain:example.com)")
36
- .option("--dry-run", "Preview changes without writing to Sanity")
37
- .option("--skip-tasks", "Only sync data, do not generate tasks")
38
- .option("--check-index", "Check Google index status for matched pages")
39
- .option("--quiet-period <days>", "Ignore pages published within N days", "45")
40
- .option("--diagnose", "Show detailed diagnostics for unmatched URLs")
41
- .option("--diagnose-url <url>", "Diagnose why a specific URL is not matching")
42
- .option("--debug", "Enable debug logging with timing information")
43
- .action(async (options) => {
44
- const timer = createTimer(options.debug);
45
- const syncStartTime = timer.start();
46
- const requiredEnvVars = [
47
- "GOOGLE_SERVICE_ACCOUNT",
48
- "DATABASE_URL",
49
- "SANITY_PROJECT_ID",
50
- "SANITY_DATASET",
51
- "SANITY_TOKEN",
52
- "SITE_URL",
53
- ];
54
-
55
- for (const envVar of requiredEnvVars) {
56
- if (!process.env[envVar]) {
57
- console.error(`Missing required environment variable: ${envVar}`);
58
- process.exit(1);
59
- }
60
- }
61
-
62
- let t = timer.start();
63
- const sanity = createSanityClient({
64
- projectId: process.env.SANITY_PROJECT_ID!,
65
- dataset: process.env.SANITY_DATASET!,
66
- token: process.env.SANITY_TOKEN!,
67
- apiVersion: "2024-01-01",
68
- useCdn: false,
69
- });
70
-
71
- const sql = postgres(process.env.DATABASE_URL!);
72
- const db = createDbWithClient(sql);
73
-
74
- const gsc = new GSCClient({
75
- credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT!),
76
- });
77
- timer.end("Client initialization", t);
78
-
79
- console.log(`Starting sync for ${options.site}...`);
80
-
81
- // Find or create the gscSite document in Sanity
82
- t = timer.start();
83
- let siteDoc = await sanity.fetch<{
84
- _id: string;
85
- pathPrefix?: string;
86
- contentTypes?: string[];
87
- slugField?: string;
88
- } | null>(
89
- `*[_type == "gscSite" && siteUrl == $siteUrl][0]{
90
- _id,
91
- pathPrefix,
92
- contentTypes,
93
- slugField
94
- }`,
95
- { siteUrl: options.site },
96
- );
97
-
98
- if (!siteDoc) {
99
- console.log(`Creating gscSite document for ${options.site}...`);
100
- siteDoc = await sanity.create({
101
- _type: "gscSite",
102
- siteUrl: options.site,
103
- enabled: true,
104
- contentTypes: ["post", "page"],
105
- slugField: "slug",
106
- });
107
- }
108
- timer.end("Fetch gscSite document", t);
109
-
110
- const siteId = siteDoc._id;
111
-
112
- // Use configuration from gscSite document
113
- const contentTypes = siteDoc.contentTypes ?? ["post", "page"];
114
- const slugField = siteDoc.slugField ?? "slug";
115
- const pathPrefix = siteDoc.pathPrefix ?? undefined;
116
-
117
- console.log(`Configuration:`);
118
- console.log(` Content types: ${contentTypes.join(", ")}`);
119
- console.log(` Slug field: ${slugField}`);
120
- console.log(` Path prefix: ${pathPrefix ?? "(none)"}`);
121
-
122
- const syncEngine = new SyncEngine({ gsc, db, sanity });
123
- const matcher = new URLMatcher(sanity, {
124
- contentTypes,
125
- slugField,
126
- baseUrl: process.env.SITE_URL!,
127
- pathPrefix,
128
- });
129
-
130
- try {
131
- t = timer.start();
132
- const { pages, rowsProcessed } = await syncEngine.sync({
133
- siteUrl: options.site,
134
- startDate: daysAgo(90),
135
- endDate: daysAgo(3),
136
- });
137
- timer.end("GSC data sync", t);
138
-
139
- console.log(`Processed ${rowsProcessed} rows for ${pages.length} pages`);
140
-
141
- t = timer.start();
142
- const matches = await matcher.matchUrls(pages);
143
- timer.end("URL matching", t);
144
-
145
- const matched = matches.filter(
146
- (m): m is MatchResult & { sanityId: string } => !!m.sanityId,
147
- );
148
- const unmatched = matches.filter((m) => !m.sanityId);
149
-
150
- console.log(
151
- `Matched ${matched.length}/${pages.length} URLs to Sanity documents`,
152
- );
153
-
154
- // Store diagnostics for unmatched URLs
155
- if (unmatched.length > 0) {
156
- console.log(`${unmatched.length} unmatched URLs`);
157
-
158
- // Store diagnostics in database
159
- t = timer.start();
160
- for (const u of unmatched) {
161
- const diagId = `${options.site}:${u.gscUrl}`;
162
- await db
163
- .insert(unmatchDiagnostics)
164
- .values({
165
- id: diagId,
166
- siteId: options.site,
167
- gscUrl: u.gscUrl,
168
- extractedSlug: u.extractedSlug ?? null,
169
- unmatchReason: u.unmatchReason,
170
- normalizedUrl: u.diagnostics?.normalizedUrl ?? null,
171
- pathAfterPrefix: u.diagnostics?.pathAfterPrefix ?? null,
172
- configuredPrefix: u.diagnostics?.configuredPrefix ?? null,
173
- similarSlugs: u.diagnostics?.similarSlugs
174
- ? JSON.stringify(u.diagnostics.similarSlugs)
175
- : null,
176
- availableSlugsCount: u.diagnostics?.availableSlugsCount ?? null,
177
- lastSeenAt: new Date(),
178
- })
179
- .onConflictDoUpdate({
180
- target: unmatchDiagnostics.id,
181
- set: {
182
- extractedSlug: u.extractedSlug ?? null,
183
- unmatchReason: u.unmatchReason,
184
- normalizedUrl: u.diagnostics?.normalizedUrl ?? null,
185
- pathAfterPrefix: u.diagnostics?.pathAfterPrefix ?? null,
186
- configuredPrefix: u.diagnostics?.configuredPrefix ?? null,
187
- similarSlugs: u.diagnostics?.similarSlugs
188
- ? JSON.stringify(u.diagnostics.similarSlugs)
189
- : null,
190
- availableSlugsCount: u.diagnostics?.availableSlugsCount ?? null,
191
- lastSeenAt: new Date(),
192
- },
193
- });
194
- }
195
-
196
- // Update gscSite with unmatched count
197
- await sanity
198
- .patch(siteId)
199
- .set({
200
- unmatchedCount: unmatched.length,
201
- lastDiagnosticsAt: new Date().toISOString(),
202
- })
203
- .commit();
204
- timer.end("Store unmatched diagnostics", t);
205
-
206
- // Show detailed diagnostics if --diagnose flag is set
207
- if (options.diagnose) {
208
- console.log(`\nUnmatched URL Diagnostics:\n`);
209
-
210
- // Group by reason
211
- const byReason = new Map<UnmatchReason, MatchResult[]>();
212
- for (const u of unmatched) {
213
- const existing = byReason.get(u.unmatchReason) ?? [];
214
- existing.push(u);
215
- byReason.set(u.unmatchReason, existing);
216
- }
217
-
218
- for (const [reason, urls] of byReason) {
219
- console.log(
220
- ` ${getReasonEmoji(reason)} ${getReasonDescription(reason)} (${urls.length}):`,
221
- );
222
- const toShow = urls.slice(0, 5);
223
- for (const u of toShow) {
224
- console.log(` ${u.gscUrl}`);
225
- if (u.extractedSlug) {
226
- console.log(` Extracted slug: "${u.extractedSlug}"`);
227
- }
228
- if (u.diagnostics?.similarSlugs?.length) {
229
- console.log(` Similar slugs in Sanity:`);
230
- for (const similar of u.diagnostics.similarSlugs) {
231
- console.log(` - ${similar}`);
232
- }
233
- }
234
- }
235
- if (urls.length > 5) {
236
- console.log(` ... and ${urls.length - 5} more`);
237
- }
238
- console.log();
239
- }
240
- } else if (unmatched.length <= 10) {
241
- unmatched.forEach((u) => console.log(` - ${u.gscUrl}`));
242
- console.log(`\n Run with --diagnose for detailed diagnostics`);
243
- } else {
244
- console.log(` Run with --diagnose to see detailed diagnostics`);
245
- }
246
- }
247
-
248
- // Handle --diagnose-url for a specific URL
249
- if (options.diagnoseUrl) {
250
- const targetUrl = options.diagnoseUrl;
251
- const allUrls = [targetUrl];
252
- const [result] = await matcher.matchUrls(allUrls);
253
- console.log(`\nDiagnostics for: ${targetUrl}\n`);
254
- if (result) {
255
- console.log(` Matched: ${result.sanityId ? "Yes" : "No"}`);
256
- console.log(
257
- ` Reason: ${getReasonDescription(result.unmatchReason)}`,
258
- );
259
- if (result.extractedSlug) {
260
- console.log(` Extracted slug: "${result.extractedSlug}"`);
261
- }
262
- if (result.matchedSlug) {
263
- console.log(` Matched to Sanity slug: "${result.matchedSlug}"`);
264
- }
265
- if (result.diagnostics) {
266
- console.log(
267
- ` Normalized URL: ${result.diagnostics.normalizedUrl}`,
268
- );
269
- console.log(
270
- ` Path after prefix: ${result.diagnostics.pathAfterPrefix}`,
271
- );
272
- console.log(
273
- ` Configured prefix: ${result.diagnostics.configuredPrefix ?? "(none)"}`,
274
- );
275
- console.log(
276
- ` Available Sanity slugs: ${result.diagnostics.availableSlugsCount}`,
277
- );
278
- if (result.diagnostics.similarSlugs?.length) {
279
- console.log(` Similar slugs in Sanity:`);
280
- for (const similar of result.diagnostics.similarSlugs) {
281
- console.log(` - ${similar}`);
282
- }
283
- }
284
- }
285
- }
286
- }
287
-
288
- // Check index status if requested
289
- if (options.checkIndex && matched.length > 0) {
290
- console.log(`\nChecking index status for ${matched.length} pages...`);
291
- t = timer.start();
292
- const matchedUrls = matched.map((m) => m.gscUrl);
293
- const indexResult = await syncEngine.syncIndexStatus(
294
- options.site,
295
- matchedUrls,
296
- );
297
- timer.end("Index status check", t);
298
- console.log(
299
- ` Indexed: ${indexResult.indexed}, Not indexed: ${indexResult.notIndexed}, Skipped: ${indexResult.skipped}`,
300
- );
301
- }
302
-
303
- if (!options.skipTasks) {
304
- t = timer.start();
305
- const publishedDates = await getPublishedDates(sanity, matched);
306
- const detector = new DecayDetector(db);
307
- const signals = await detector.detectDecay(
308
- options.site,
309
- publishedDates,
310
- {
311
- enabled: true,
312
- days: parseInt(options.quietPeriod),
313
- },
314
- );
315
- timer.end("Decay detection", t);
316
-
317
- console.log(`Detected ${signals.length} decay signals`);
318
-
319
- if (options.dryRun) {
320
- console.log("\nWould create the following tasks:");
321
- signals.forEach((s) => {
322
- console.log(` [${s.severity.toUpperCase()}] ${s.page}`);
323
- console.log(` Reason: ${s.reason}`);
324
- console.log(
325
- ` Position: ${s.metrics.positionBefore} -> ${s.metrics.positionNow}`,
326
- );
327
- });
328
- } else {
329
- t = timer.start();
330
- const taskGenerator = new TaskGenerator(sanity);
331
- const created = await taskGenerator.createTasks(
332
- siteId,
333
- signals,
334
- matches,
335
- );
336
- timer.end("Task generation", t);
337
- console.log(`Created ${created} new refresh tasks`);
338
- }
339
- }
340
-
341
- if (!options.dryRun) {
342
- t = timer.start();
343
- await syncEngine.writeSnapshots(siteId, matched);
344
- timer.end("Write Sanity snapshots", t);
345
- console.log(`Updated Sanity snapshots`);
346
- }
347
-
348
- timer.end("Total sync", syncStartTime);
349
- console.log(`\nSync complete!`);
350
- } catch (error) {
351
- console.error("Sync failed:", error);
352
- process.exit(1);
353
- } finally {
354
- await sql.end();
355
- }
356
- });
357
-
358
- async function getPublishedDates(
359
- sanity: ReturnType<typeof createSanityClient>,
360
- matches: MatchResult[],
361
- ): Promise<Map<string, Date>> {
362
- const ids = matches.map((m) => m.sanityId).filter(Boolean);
363
- const docs = await sanity.fetch<
364
- { _id: string; _createdAt: string; publishedAt?: string }[]
365
- >(`*[_id in $ids]{ _id, _createdAt, publishedAt }`, { ids });
366
-
367
- const map = new Map<string, Date>();
368
- for (const doc of docs) {
369
- const match = matches.find((m) => m.sanityId === doc._id);
370
- if (match) {
371
- const dateStr = doc.publishedAt ?? doc._createdAt;
372
- map.set(match.gscUrl, new Date(dateStr));
373
- }
374
- }
375
- return map;
376
- }
377
-
378
- function getReasonEmoji(reason: UnmatchReason): string {
379
- switch (reason) {
380
- case "matched":
381
- return "[OK]";
382
- case "no_slug_extracted":
383
- return "[SLUG]";
384
- case "no_matching_document":
385
- return "[DOC]";
386
- case "outside_path_prefix":
387
- return "[PREFIX]";
388
- default:
389
- return "[?]";
390
- }
391
- }
392
-
393
- function getReasonDescription(reason: UnmatchReason): string {
394
- switch (reason) {
395
- case "matched":
396
- return "Successfully matched";
397
- case "no_slug_extracted":
398
- return "Could not extract slug from URL";
399
- case "no_matching_document":
400
- return "No Sanity document with matching slug";
401
- case "outside_path_prefix":
402
- return "URL outside configured path prefix";
403
- default:
404
- return "Unknown reason";
405
- }
406
- }
package/src/index.ts DELETED
@@ -1,26 +0,0 @@
1
- #!/usr/bin/env node
2
- import { config } from "dotenv";
3
- import { resolve } from "path";
4
- import { fileURLToPath } from "url";
5
-
6
- const __dirname = fileURLToPath(new URL(".", import.meta.url));
7
- // Load .env from monorepo root
8
- config({ path: resolve(__dirname, "../../../.env") });
9
-
10
- import { program } from "commander";
11
- import { syncCommand } from "./commands/sync.js";
12
- import { listSitesCommand } from "./commands/list-sites.js";
13
- import { diagnoseCommand } from "./commands/diagnose.js";
14
-
15
- program
16
- .name("pagebridge")
17
- .description(
18
- "PageBridge - Connect Google Search Console to Sanity CMS",
19
- )
20
- .version("0.0.1");
21
-
22
- program.addCommand(syncCommand);
23
- program.addCommand(listSitesCommand);
24
- program.addCommand(diagnoseCommand);
25
-
26
- program.parse();
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "@pagebridge/typescript-config/library.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src/**/*"],
8
- "exclude": ["node_modules", "dist"]
9
- }