gscdump 0.4.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  import { ofetch } from "ofetch";
2
-
3
- //#region src/api/indexing.ts
4
- /**
5
- * Request Google to index or remove a URL via the Indexing API.
6
- * Note: The Indexing API officially supports only job posting and livestream content,
7
- * but can be used for any URL with varying success.
8
- */
2
+ async function runSequentialBatch(items, operation, options = {}) {
3
+ const { delayMs = 0, onProgress } = options;
4
+ const results = [];
5
+ for (let i = 0; i < items.length; i++) {
6
+ const result = await operation(items[i], i);
7
+ results.push(result);
8
+ onProgress?.(result, i, items.length);
9
+ if (i < items.length - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
10
+ }
11
+ return results;
12
+ }
9
13
  async function requestIndexing(client, url, options = {}) {
10
14
  const { type = "URL_UPDATED" } = options;
11
15
  return client.indexing.publish(url, type).then((r) => ({
@@ -14,10 +18,6 @@ async function requestIndexing(client, url, options = {}) {
14
18
  notifyTime: r.urlNotificationMetadata?.latestUpdate?.notifyTime || void 0
15
19
  }));
16
20
  }
17
- /**
18
- * Get the indexing notification metadata for a URL.
19
- * Returns when Google was last notified about updates/removals.
20
- */
21
21
  async function getIndexingMetadata(client, url) {
22
22
  return client.indexing.getMetadata(url).then((r) => ({
23
23
  url,
@@ -25,27 +25,13 @@ async function getIndexingMetadata(client, url) {
25
25
  latestRemove: r.latestRemove || void 0
26
26
  }));
27
27
  }
28
- /**
29
- * Batch request indexing for multiple URLs with rate limiting.
30
- * Returns results for each URL.
31
- */
32
28
  async function batchRequestIndexing(client, urls, options = {}) {
33
29
  const { type = "URL_UPDATED", delayMs = 100, onProgress } = options;
34
- const results = [];
35
- for (let i = 0; i < urls.length; i++) {
36
- const result = await requestIndexing(client, urls[i], { type });
37
- results.push(result);
38
- onProgress?.(result, i, urls.length);
39
- if (i < urls.length - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
40
- }
41
- return results;
30
+ return runSequentialBatch(urls, (url) => requestIndexing(client, url, { type }), {
31
+ delayMs,
32
+ onProgress
33
+ });
42
34
  }
43
-
44
- //#endregion
45
- //#region src/api/inspection.ts
46
- /**
47
- * Inspects a URL in Google Search Console to check its indexing status.
48
- */
49
35
  async function inspectUrl(client, siteUrl, inspectionUrl) {
50
36
  const inspection = (await client.inspect(siteUrl, inspectionUrl)).inspectionResult;
51
37
  return {
@@ -53,39 +39,23 @@ async function inspectUrl(client, siteUrl, inspectionUrl) {
53
39
  isIndexed: inspection?.indexStatusResult?.verdict === "PASS"
54
40
  };
55
41
  }
56
- /**
57
- * Batch inspect multiple URLs with rate limiting.
58
- * Returns inspection results for each URL.
59
- */
60
42
  async function batchInspectUrls(client, siteUrl, urls, options = {}) {
61
43
  const { delayMs = 200, onProgress } = options;
62
- const results = [];
63
- for (let i = 0; i < urls.length; i++) {
64
- const url = urls[i];
44
+ return runSequentialBatch(urls, async (url) => {
65
45
  const { inspection, isIndexed } = await inspectUrl(client, siteUrl, url);
66
- const result = {
46
+ return {
67
47
  url,
68
48
  inspection,
69
49
  isIndexed
70
50
  };
71
- results.push(result);
72
- onProgress?.(result, i, urls.length);
73
- if (i < urls.length - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
74
- }
75
- return results;
51
+ }, {
52
+ delayMs,
53
+ onProgress
54
+ });
76
55
  }
77
-
78
- //#endregion
79
- //#region src/api/sites.ts
80
- /**
81
- * Fetches all sites the authenticated user has access to in Google Search Console.
82
- */
83
56
  async function fetchSites(client) {
84
57
  return client.sites();
85
58
  }
86
- /**
87
- * Fetches all verified sites with their sitemaps from Google Search Console.
88
- */
89
59
  async function fetchSitesWithSitemaps(client) {
90
60
  const sites = (await client.sites()).filter((s) => !!s.siteUrl && s.permissionLevel !== "siteUnverifiedUser");
91
61
  return Promise.all(sites.map(async (site) => {
@@ -96,33 +66,122 @@ async function fetchSitesWithSitemaps(client) {
96
66
  };
97
67
  }));
98
68
  }
99
- /**
100
- * Fetches all sitemaps for a site.
101
- */
102
69
  async function fetchSitemaps(client, siteUrl) {
103
70
  return client.sitemaps.list(siteUrl);
104
71
  }
105
- /**
106
- * Fetches a specific sitemap.
107
- */
108
72
  async function fetchSitemap(client, siteUrl, feedpath) {
109
73
  return client.sitemaps.get(siteUrl, feedpath);
110
74
  }
111
- /**
112
- * Submits a sitemap to Google Search Console.
113
- */
114
75
  async function submitSitemap(client, siteUrl, feedpath) {
115
76
  return client.sitemaps.submit(siteUrl, feedpath);
116
77
  }
117
- /**
118
- * Deletes a sitemap from Google Search Console.
119
- */
120
78
  async function deleteSitemap(client, siteUrl, feedpath) {
121
79
  return client.sitemaps.delete(siteUrl, feedpath);
122
80
  }
123
-
124
- //#endregion
125
- //#region src/query/resolver.ts
81
+ const MS_PER_DAY = 864e5;
82
+ function toIsoDate(d) {
83
+ return d.toISOString().slice(0, 10);
84
+ }
85
+ const GSC_FINALIZED_LAG_DAYS = 3;
86
+ const GSC_FRESHEST_LAG_DAYS = 1;
87
+ const GSC_RETENTION_MONTHS = 16;
88
+ function getPstDate() {
89
+ return (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" });
90
+ }
91
+ function getPstDateDaysAgo(daysAgo) {
92
+ const pstNow = new Date((/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: "America/Los_Angeles" }));
93
+ pstNow.setDate(pstNow.getDate() - daysAgo);
94
+ return toIsoDate(pstNow);
95
+ }
96
+ function daysAgo(n) {
97
+ return toIsoDate(new Date(Date.now() - n * MS_PER_DAY));
98
+ }
99
+ function getLatestGscDate() {
100
+ return getPstDateDaysAgo(3);
101
+ }
102
+ function getFreshestGscDate() {
103
+ return getPstDateDaysAgo(1);
104
+ }
105
+ function getPendingDates() {
106
+ const dates = [];
107
+ for (let daysAgo = 1; daysAgo <= 3; daysAgo++) dates.push(getPstDateDaysAgo(daysAgo));
108
+ return dates;
109
+ }
110
+ function getDateRange(startDate, endDate) {
111
+ const dates = [];
112
+ const endMs = Date.parse(`${endDate}T00:00:00Z`);
113
+ for (let cursor = Date.parse(`${startDate}T00:00:00Z`); cursor <= endMs; cursor += MS_PER_DAY) dates.push(toIsoDate(new Date(cursor)));
114
+ return dates;
115
+ }
116
+ const DAYS_PER_RANGE = 30;
117
+ function generateGscDateRange(startDate, endDate) {
118
+ const dates = [];
119
+ let current = startDate;
120
+ while (current <= endDate && isValidGscDate(current)) {
121
+ dates.push(current);
122
+ current = getNextDate(current);
123
+ }
124
+ return dates;
125
+ }
126
+ function groupIntoRanges(dates, daysPerRange = 30) {
127
+ if (dates.length === 0) return [];
128
+ const sorted = [...dates].sort();
129
+ const ranges = [];
130
+ let rangeStart = sorted[0];
131
+ let rangePrev = sorted[0];
132
+ for (let i = 1; i < sorted.length; i++) {
133
+ const current = sorted[i];
134
+ const expected = getNextDate(rangePrev);
135
+ const daysInRange = countDays(rangeStart, rangePrev);
136
+ if (current !== expected || daysInRange >= daysPerRange) {
137
+ ranges.push({
138
+ startDate: rangeStart,
139
+ endDate: rangePrev
140
+ });
141
+ rangeStart = current;
142
+ }
143
+ rangePrev = current;
144
+ }
145
+ ranges.push({
146
+ startDate: rangeStart,
147
+ endDate: rangePrev
148
+ });
149
+ return ranges;
150
+ }
151
+ function countDays(startDate, endDate) {
152
+ return Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / MS_PER_DAY) + 1;
153
+ }
154
+ function getOldestGscDate() {
155
+ const date = /* @__PURE__ */ new Date();
156
+ date.setMonth(date.getMonth() - 16);
157
+ return toIsoDate(date);
158
+ }
159
+ function addDays(dateStr, n) {
160
+ return toIsoDate(new Date(Date.parse(`${dateStr}T00:00:00Z`) + n * MS_PER_DAY));
161
+ }
162
+ function getPreviousDate(dateStr) {
163
+ return addDays(dateStr, -1);
164
+ }
165
+ function getNextDate(dateStr) {
166
+ return addDays(dateStr, 1);
167
+ }
168
+ function isValidGscDate(dateStr) {
169
+ return dateStr >= getOldestGscDate() && dateStr <= getFreshestGscDate();
170
+ }
171
+ function getBackfillProgress(oldestDateSynced, newestDateSynced) {
172
+ if (!oldestDateSynced || !newestDateSynced) return null;
173
+ const oldestGsc = getOldestGscDate();
174
+ const newestGsc = getLatestGscDate();
175
+ const totalDays = Math.ceil((new Date(newestGsc).getTime() - new Date(oldestGsc).getTime()) / MS_PER_DAY);
176
+ const syncedDays = Math.ceil((new Date(newestDateSynced).getTime() - new Date(oldestDateSynced).getTime()) / MS_PER_DAY);
177
+ return {
178
+ progress: Math.round(Math.min(1, syncedDays / totalDays) * 100) / 100,
179
+ daysAvailable: totalDays,
180
+ daysSynced: syncedDays,
181
+ oldestGscDate: oldestGsc,
182
+ isComplete: oldestDateSynced <= oldestGsc
183
+ };
184
+ }
126
185
  const DATE_OPERATORS = [
127
186
  "gte",
128
187
  "gt",
@@ -139,22 +198,17 @@ const METRIC_OPERATORS = [
139
198
  ];
140
199
  const SPECIAL_OPERATORS = ["topLevel"];
141
200
  const QUERY_PARAMS = ["searchType"];
201
+ function isDateOperator(op) {
202
+ return DATE_OPERATORS.includes(op);
203
+ }
142
204
  function isMetricOperator(op) {
143
205
  return METRIC_OPERATORS.includes(op);
144
206
  }
145
207
  function isSpecialOperator(op) {
146
208
  return SPECIAL_OPERATORS.includes(op);
147
209
  }
148
- function isDateOperator(op) {
149
- return DATE_OPERATORS.includes(op);
150
- }
151
- function isQueryParam(dim) {
152
- return QUERY_PARAMS.includes(dim);
153
- }
154
- function addDays(dateStr, days) {
155
- const d = new Date(dateStr);
156
- d.setDate(d.getDate() + days);
157
- return d.toISOString().split("T")[0];
210
+ function isQueryParam(value) {
211
+ return QUERY_PARAMS.includes(value);
158
212
  }
159
213
  function extractSpecialFilters(filter) {
160
214
  if (!filter) return {};
@@ -244,25 +298,23 @@ function resolveFilter(filter) {
244
298
  if (filter._nestedGroups) for (const nested of filter._nestedGroups) groups.push(...resolveFilter(nested));
245
299
  return groups;
246
300
  }
247
-
248
- //#endregion
249
- //#region src/core/api-client.ts
250
- /**
251
- * Create a client that queries GSC data through gscdump.com API
252
- * instead of directly to Google. Useful when you don't have OAuth credentials.
253
- *
254
- * @example
255
- * ```ts
256
- * const client = gscdumpApi({ apiKey: 'gsd_user_xxx' })
257
- *
258
- * // Same query builder usage as direct client
259
- * for await (const rows of client.query(siteId, gsc.select('page', 'query').where(...))) {
260
- * console.log(rows)
261
- * }
262
- * ```
263
- */
301
+ function rowWithMetricDefaults(row) {
302
+ return {
303
+ clicks: row.clicks ?? 0,
304
+ impressions: row.impressions ?? 0,
305
+ ctr: row.ctr ?? 0,
306
+ position: row.position ?? 0
307
+ };
308
+ }
309
+ function progressBar(current, total, label, width = 30) {
310
+ const percent = Math.min(current / total, 1);
311
+ const filled = Math.round(width * percent);
312
+ const empty = width - filled;
313
+ return ` ${`\x1B[36m${"█".repeat(filled)}\x1B[0m\x1B[90m${"░".repeat(empty)}\x1B[0m`} \x1B[90m${current}/${total}\x1B[0m ${label}`;
314
+ }
315
+ const TRAILING_SLASH_RE = /\/$/;
264
316
  function gscdumpApi(options) {
265
- const baseUrl = options.baseUrl?.replace(/\/$/, "") || "https://gscdump.com";
317
+ const baseUrl = options.baseUrl?.replace(TRAILING_SLASH_RE, "") || "https://gscdump.com";
266
318
  const fetch = ofetch.create({
267
319
  baseURL: baseUrl,
268
320
  retry: 3,
@@ -280,10 +332,11 @@ function gscdumpApi(options) {
280
332
  "Content-Type": "application/json"
281
333
  }
282
334
  });
283
- const rawQuery = async (siteId, body) => {
335
+ const rawQuery = async (siteId, body, opts) => {
284
336
  const response = await fetch(`/api/sites/${encodeURIComponent(siteId)}/query`, {
285
337
  method: "POST",
286
- body
338
+ body,
339
+ signal: opts?.signal
287
340
  });
288
341
  return { rows: response.rows.map((row) => {
289
342
  return {
@@ -296,27 +349,24 @@ function gscdumpApi(options) {
296
349
  }) };
297
350
  };
298
351
  return {
299
- async *query(siteId, builder) {
352
+ async *query(siteId, builder, opts) {
300
353
  const state = builder.getState();
301
354
  const body = resolveToBody(state);
302
355
  const rowLimit = body.rowLimit || 25e3;
303
356
  let startRow = body.startRow || 0;
304
357
  while (true) {
358
+ opts?.signal?.throwIfAborted();
305
359
  const response = await fetch(`/api/sites/${encodeURIComponent(siteId)}/query`, {
306
360
  method: "POST",
307
361
  body: {
308
362
  ...body,
309
363
  startRow,
310
364
  rowLimit
311
- }
365
+ },
366
+ signal: opts?.signal
312
367
  });
313
368
  const rows = response.rows.map((row) => {
314
- const result = {
315
- clicks: row.clicks ?? 0,
316
- impressions: row.impressions ?? 0,
317
- ctr: row.ctr ?? 0,
318
- position: row.position ?? 0
319
- };
369
+ const result = rowWithMetricDefaults(row);
320
370
  state.dimensions.forEach((dim) => {
321
371
  result[dim] = row[dim];
322
372
  });
@@ -327,8 +377,8 @@ function gscdumpApi(options) {
327
377
  startRow += rows.length;
328
378
  }
329
379
  },
330
- sites: async () => {
331
- return (await fetch("/api/sites")).sites.map((s) => ({
380
+ sites: async (opts) => {
381
+ return (await fetch("/api/sites", { signal: opts?.signal })).sites.map((s) => ({
332
382
  siteUrl: s.gscSiteUrl,
333
383
  permissionLevel: s.permissionLevel || "siteOwner"
334
384
  }));
@@ -337,8 +387,8 @@ function gscdumpApi(options) {
337
387
  throw new Error("URL inspection not available via gscdump API. Use googleSearchConsole() with OAuth credentials.");
338
388
  },
339
389
  sitemaps: {
340
- list: async (siteId) => {
341
- return (await fetch(`/api/sites/${encodeURIComponent(siteId)}/sitemaps`)).sitemaps || [];
390
+ list: async (siteId, opts) => {
391
+ return (await fetch(`/api/sites/${encodeURIComponent(siteId)}/sitemaps`, { signal: opts?.signal })).sitemaps || [];
342
392
  },
343
393
  get: () => {
344
394
  throw new Error("Sitemap get not available via gscdump API.");
@@ -361,9 +411,6 @@ function gscdumpApi(options) {
361
411
  _rawQuery: rawQuery
362
412
  };
363
413
  }
364
-
365
- //#endregion
366
- //#region src/core/client.ts
367
414
  const GSC_API = "https://searchconsole.googleapis.com";
368
415
  const INDEXING_API = "https://indexing.googleapis.com";
369
416
  function createAuth(options) {
@@ -423,11 +470,11 @@ function createFetch(auth, options) {
423
470
  "Accept-Encoding": "gzip",
424
471
  "User-Agent": "gscdump (gzip)"
425
472
  },
426
- async onRequest({ options: options$1 }) {
473
+ async onRequest({ options }) {
427
474
  const token = await resolveToken(authState);
428
475
  if (token) {
429
- options$1.headers = new Headers(options$1.headers);
430
- options$1.headers.set("Authorization", `Bearer ${token}`);
476
+ options.headers = new Headers(options.headers);
477
+ options.headers.set("Authorization", `Bearer ${token}`);
431
478
  }
432
479
  },
433
480
  async onResponseError(ctx) {
@@ -453,28 +500,25 @@ function googleSearchConsole(auth, options = {}) {
453
500
  }
454
501
  fetch = createFetch(authState, fetchOptions);
455
502
  }
456
- const rawQuery = (siteUrl, body) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, {
503
+ const rawQuery = (siteUrl, body, opts) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, {
457
504
  method: "POST",
458
- body
505
+ body,
506
+ signal: opts?.signal
459
507
  });
460
508
  return {
461
- async *query(siteUrl, builder) {
509
+ async *query(siteUrl, builder, opts) {
462
510
  const state = builder.getState();
463
511
  const body = resolveToBody(state);
464
512
  const rowLimit = body.rowLimit || 25e3;
465
513
  let startRow = body.startRow || 0;
466
514
  while (true) {
515
+ opts?.signal?.throwIfAborted();
467
516
  const rows = ((await rawQuery(siteUrl, {
468
517
  ...body,
469
518
  startRow,
470
519
  rowLimit
471
- })).rows || []).map((row) => {
472
- const result = {
473
- clicks: row.clicks ?? 0,
474
- impressions: row.impressions ?? 0,
475
- ctr: row.ctr ?? 0,
476
- position: row.position ?? 0
477
- };
520
+ }, opts)).rows || []).map((row) => {
521
+ const result = rowWithMetricDefaults(row);
478
522
  state.dimensions.forEach((dim, i) => {
479
523
  result[dim] = row.keys?.[i];
480
524
  });
@@ -485,183 +529,271 @@ function googleSearchConsole(auth, options = {}) {
485
529
  startRow += rows.length;
486
530
  }
487
531
  },
488
- sites: async () => {
489
- return (await fetch(`${GSC_API}/webmasters/v3/sites`)).siteEntry || [];
532
+ sites: async (opts) => {
533
+ return (await fetch(`${GSC_API}/webmasters/v3/sites`, { signal: opts?.signal })).siteEntry || [];
490
534
  },
491
- inspect: (siteUrl, url) => fetch(`${GSC_API}/v1/urlInspection/index:inspect`, {
535
+ inspect: (siteUrl, url, opts) => fetch(`${GSC_API}/v1/urlInspection/index:inspect`, {
492
536
  method: "POST",
493
537
  body: {
494
538
  inspectionUrl: url,
495
539
  siteUrl
496
- }
540
+ },
541
+ signal: opts?.signal
497
542
  }),
498
543
  sitemaps: {
499
- list: async (siteUrl) => {
500
- return (await fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps`)).sitemap || [];
544
+ list: async (siteUrl, opts) => {
545
+ return (await fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps`, { signal: opts?.signal })).sitemap || [];
501
546
  },
502
- get: (siteUrl, feedpath) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`),
503
- submit: (siteUrl, feedpath) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, { method: "PUT" }),
504
- delete: (siteUrl, feedpath) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, { method: "DELETE" })
547
+ get: (siteUrl, feedpath, opts) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, { signal: opts?.signal }),
548
+ submit: (siteUrl, feedpath, opts) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, {
549
+ method: "PUT",
550
+ signal: opts?.signal
551
+ }),
552
+ delete: (siteUrl, feedpath, opts) => fetch(`${GSC_API}/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`, {
553
+ method: "DELETE",
554
+ signal: opts?.signal
555
+ })
505
556
  },
506
557
  indexing: {
507
- publish: (url, type) => fetch(`${INDEXING_API}/v3/urlNotifications:publish`, {
558
+ publish: (url, type, opts) => fetch(`${INDEXING_API}/v3/urlNotifications:publish`, {
508
559
  method: "POST",
509
560
  body: {
510
561
  url,
511
562
  type
512
- }
563
+ },
564
+ signal: opts?.signal
513
565
  }),
514
- getMetadata: (url) => fetch(`${INDEXING_API}/v3/urlNotifications/metadata`, { query: { url } })
566
+ getMetadata: (url, opts) => fetch(`${INDEXING_API}/v3/urlNotifications/metadata`, {
567
+ query: { url },
568
+ signal: opts?.signal
569
+ })
515
570
  },
516
571
  _rawQuery: rawQuery
517
572
  };
518
573
  }
519
-
520
- //#endregion
521
- //#region src/core/errors.ts
522
- /** GSC API quota limits (approximate) */
523
574
  const GSC_QUOTAS = {
524
575
  searchAnalytics: 25e3,
525
576
  urlInspection: 2e3,
526
577
  indexing: 200
527
578
  };
528
- /**
529
- * Detects if an error is a quota exceeded error (403 quotaExceeded).
530
- */
531
- function isQuotaError(error) {
532
- const msg = getErrorMessage(error).toLowerCase();
533
- return getErrorCode(error) === 403 && (msg.includes("quota") || msg.includes("limit exceeded") || msg.includes("rate limit") || msg.includes("quotaexceeded"));
534
- }
535
- /**
536
- * Detects if an error is a rate limit error (429 Too Many Requests).
537
- */
538
- function isRateLimitError(error) {
539
- return getErrorCode(error) === 429;
540
- }
541
- /**
542
- * Detects if an error is an authentication error (401/403 without quota).
543
- */
544
- function isAuthError(error) {
545
- const code = getErrorCode(error);
546
- const msg = getErrorMessage(error).toLowerCase();
547
- if (code === 401) return true;
548
- if (code === 403 && !isQuotaError(error)) return msg.includes("access") || msg.includes("permission") || msg.includes("forbidden");
549
- return false;
550
- }
551
- /**
552
- * Extracts HTTP status code from various error formats.
553
- */
554
- function getErrorCode(error) {
579
+ function pickField(error, paths, is) {
555
580
  if (!error || typeof error !== "object") return void 0;
556
- const e = error;
557
- if ("statusCode" in e && typeof e.statusCode === "number") return e.statusCode;
558
- if ("status" in e && typeof e.status === "number") return e.status;
559
- if ("response" in e && e.response && typeof e.response === "object") {
560
- const resp = e.response;
561
- if ("status" in resp && typeof resp.status === "number") return resp.status;
581
+ for (const path of paths) {
582
+ let current = error;
583
+ for (const key of path) {
584
+ if (!current || typeof current !== "object") {
585
+ current = void 0;
586
+ break;
587
+ }
588
+ current = current[key];
589
+ }
590
+ if (is(current)) return current;
562
591
  }
563
- if ("code" in e && typeof e.code === "number") return e.code;
564
592
  }
565
- /**
566
- * Extracts error message from various error formats.
567
- */
568
- function getErrorMessage(error) {
593
+ const isNumber = (v) => typeof v === "number";
594
+ const isString = (v) => typeof v === "string";
595
+ function extractStatus(error) {
596
+ return pickField(error, [
597
+ ["statusCode"],
598
+ ["status"],
599
+ ["response", "status"],
600
+ ["code"]
601
+ ], isNumber);
602
+ }
603
+ function extractMessage(error) {
569
604
  if (!error) return "Unknown error";
570
605
  if (typeof error === "string") return error;
571
606
  if (error instanceof Error) return error.message;
572
- if (typeof error === "object") {
573
- const e = error;
574
- if ("message" in e && typeof e.message === "string") return e.message;
575
- if ("statusMessage" in e && typeof e.statusMessage === "string") return e.statusMessage;
576
- if ("data" in e && e.data && typeof e.data === "object") {
577
- const data = e.data;
578
- if ("error" in data && data.error && typeof data.error === "object") {
579
- const err = data.error;
580
- if ("message" in err && typeof err.message === "string") return err.message;
581
- }
582
- }
607
+ if (typeof error !== "object") return String(error);
608
+ return pickField(error, [
609
+ [
610
+ "data",
611
+ "error",
612
+ "message"
613
+ ],
614
+ ["message"],
615
+ ["statusMessage"]
616
+ ], isString) ?? String(error);
617
+ }
618
+ function extractRetryAfter(error) {
619
+ const raw = pickField(error, [
620
+ ["headers", "retry-after"],
621
+ ["headers", "Retry-After"],
622
+ [
623
+ "response",
624
+ "headers",
625
+ "retry-after"
626
+ ],
627
+ [
628
+ "response",
629
+ "headers",
630
+ "Retry-After"
631
+ ]
632
+ ], (v) => typeof v === "number" || typeof v === "string");
633
+ if (typeof raw === "number") return raw;
634
+ if (typeof raw === "string") {
635
+ const seconds = Number.parseInt(raw, 10);
636
+ return Number.isNaN(seconds) ? void 0 : seconds;
583
637
  }
584
- return String(error);
585
638
  }
586
- /**
587
- * Extracts retry-after value from error headers (in seconds).
588
- */
589
- function getRetryAfter(error) {
590
- if (!error || typeof error !== "object") return void 0;
591
- const e = error;
592
- if ("headers" in e && e.headers && typeof e.headers === "object") {
593
- const headers = e.headers;
594
- const retryAfter = headers["retry-after"] || headers["Retry-After"];
595
- if (typeof retryAfter === "string") {
596
- const seconds = Number.parseInt(retryAfter, 10);
597
- return Number.isNaN(seconds) ? void 0 : seconds;
598
- }
599
- if (typeof retryAfter === "number") return retryAfter;
639
+ const QUOTA_MESSAGE_RE = /quota|rate\s*limit/i;
640
+ function classifyError(cause) {
641
+ const status = extractStatus(cause);
642
+ const message = extractMessage(cause);
643
+ if (status === 401) return {
644
+ kind: "auth-expired",
645
+ message,
646
+ cause
647
+ };
648
+ if (status === 429) return {
649
+ kind: "rate-limited",
650
+ message,
651
+ retryAfter: extractRetryAfter(cause),
652
+ cause
653
+ };
654
+ if (status === 403) {
655
+ if (QUOTA_MESSAGE_RE.test(message)) return {
656
+ kind: "rate-limited",
657
+ message,
658
+ retryAfter: extractRetryAfter(cause),
659
+ cause
660
+ };
661
+ return {
662
+ kind: "auth-expired",
663
+ message,
664
+ cause
665
+ };
600
666
  }
601
- }
602
- function formatQuotaSuggestion(message, retryAfter) {
603
- if (message.includes("Search Console API")) return `You exceeded the Search Analytics quota (${GSC_QUOTAS.searchAnalytics}/day). Try again tomorrow.`;
604
- if (message.includes("Indexing API")) return `You exceeded the Indexing API quota (${GSC_QUOTAS.indexing}/day). Try again tomorrow.`;
605
- return `Quota exceeded. Try again in ${retryAfter ? `${retryAfter}s` : "24 hours"}.`;
606
- }
607
- function formatRateLimitSuggestion(retryAfter) {
608
- return `Rate limited. Slow down requests. Try again in ${retryAfter ? `${retryAfter}s` : "a few minutes"}.`;
609
- }
610
- /**
611
- * Analyzes an error and returns structured information with suggestions.
612
- */
613
- function analyzeError(error) {
614
- const code = getErrorCode(error);
615
- const message = getErrorMessage(error);
616
- const retryAfter = getRetryAfter(error);
617
- if (isQuotaError(error)) return {
618
- isQuotaError: true,
619
- isRateLimitError: false,
620
- isAuthError: false,
621
- code,
667
+ if (status === 404) return {
668
+ kind: "not-found",
622
669
  message,
623
- retryAfter,
624
- suggestion: formatQuotaSuggestion(message, retryAfter)
670
+ cause
625
671
  };
626
- if (isRateLimitError(error)) return {
627
- isQuotaError: false,
628
- isRateLimitError: true,
629
- isAuthError: false,
630
- code,
672
+ if (status === 400 || status === 422) return {
673
+ kind: "validation",
631
674
  message,
632
- retryAfter: retryAfter || 60,
633
- suggestion: formatRateLimitSuggestion(retryAfter)
675
+ cause
634
676
  };
635
- if (isAuthError(error)) return {
636
- isQuotaError: false,
637
- isRateLimitError: false,
638
- isAuthError: true,
639
- code,
677
+ return {
678
+ kind: "transport",
640
679
  message,
641
- suggestion: "Run `gscdump auth` to re-authenticate."
680
+ status,
681
+ cause
642
682
  };
683
+ }
684
+ function storageError(message, cause) {
643
685
  return {
644
- isQuotaError: false,
645
- isRateLimitError: false,
646
- isAuthError: false,
647
- code,
686
+ kind: "storage",
648
687
  message,
649
- suggestion: ""
688
+ cause
650
689
  };
651
690
  }
652
- /**
653
- * Formats an error for CLI display with color codes.
654
- */
655
- function formatErrorForCli(error) {
656
- const info = analyzeError(error);
657
- const lines = [];
658
- lines.push(`\x1B[31m${info.message}\x1B[0m`);
659
- if (info.suggestion) {
691
+ const PERMISSION_SIGNALS = [
692
+ "403 forbidden",
693
+ "permission_denied",
694
+ "does not have sufficient permission",
695
+ "insufficient permission"
696
+ ];
697
+ function isPermissionDeniedError(err) {
698
+ const msg = String(err?.message ?? err ?? "").toLowerCase();
699
+ return PERMISSION_SIGNALS.some((s) => msg.includes(s));
700
+ }
701
+ function suggestionFor(err) {
702
+ switch (err.kind) {
703
+ case "auth-expired": return "Run `gscdump auth` to re-authenticate.";
704
+ case "rate-limited": {
705
+ const retryIn = err.retryAfter ? `${err.retryAfter}s` : "a few minutes";
706
+ if (QUOTA_MESSAGE_RE.test(err.message)) {
707
+ if (err.message.includes("Indexing API")) return `Indexing API quota exhausted (~${GSC_QUOTAS.indexing}/day). Try again tomorrow.`;
708
+ return `Quota or rate limit hit (Search Analytics ~${GSC_QUOTAS.searchAnalytics}/day). Try again in ${retryIn}.`;
709
+ }
710
+ return `Rate limited. Slow down requests. Try again in ${retryIn}.`;
711
+ }
712
+ case "not-found":
713
+ case "validation":
714
+ case "storage":
715
+ case "transport": return "";
716
+ }
717
+ }
718
+ function formatErrorForCli(cause) {
719
+ const err = classifyError(cause);
720
+ const lines = [`\x1B[31m${err.message}\x1B[0m`];
721
+ const suggestion = suggestionFor(err);
722
+ if (suggestion) {
660
723
  lines.push("");
661
- lines.push(info.suggestion);
724
+ lines.push(suggestion);
662
725
  }
663
726
  return lines.join("\n");
664
727
  }
665
-
666
- //#endregion
667
- export { GSC_QUOTAS, analyzeError, batchInspectUrls, batchRequestIndexing, createAuth, createFetch, deleteSitemap, fetchSitemap, fetchSitemaps, fetchSites, fetchSitesWithSitemaps, formatErrorForCli, getErrorCode, getErrorMessage, getIndexingMetadata, getRetryAfter, googleSearchConsole, gscdumpApi, inspectUrl, isAuthError, isQuotaError, isRateLimitError, requestIndexing, submitSitemap };
728
+ const INDEXING_ISSUE_FILTERS = {
729
+ canonical_mismatch: `user_canonical IS NOT NULL AND google_canonical IS NOT NULL AND user_canonical != google_canonical`,
730
+ stale_crawl: `last_crawl_time < datetime('now', '-30 days')`,
731
+ very_stale_crawl: `last_crawl_time < datetime('now', '-60 days')`,
732
+ not_indexed: `verdict IN ('FAIL', 'PARTIAL', 'NEUTRAL')`,
733
+ unknown_to_google: `coverage_state = 'URL is unknown to Google'`,
734
+ crawled_not_indexed: `coverage_state LIKE '%Crawled%not indexed%' OR coverage_state LIKE '%Discovered%not indexed%'`,
735
+ not_found: `page_fetch_state = 'NOT_FOUND' OR coverage_state = 'Not found (404)'`,
736
+ soft_404: `page_fetch_state = 'SOFT_404'`,
737
+ server_error: `page_fetch_state = 'SERVER_ERROR'`,
738
+ blocked_robots: `robots_txt_state = 'DISALLOWED'`,
739
+ noindex: `indexing_state LIKE '%noindex%' OR coverage_state LIKE '%noindex%'`,
740
+ redirect: `coverage_state = 'Page with redirect'`,
741
+ fragment_url: `url LIKE '%#%'`,
742
+ mobile_fail: `mobile_verdict IN ('FAIL', 'PARTIAL')`,
743
+ rich_results_fail: `rich_results_verdict = 'FAIL'`,
744
+ rich_results_warning: `rich_results_verdict = 'PARTIAL'`,
745
+ rich_results_pass: `rich_results_verdict = 'PASS'`
746
+ };
747
+ const INDEXING_ISSUE_LABELS = {
748
+ canonical_mismatch: "Canonical mismatch",
749
+ stale_crawl: "Not crawled in 30+ days",
750
+ very_stale_crawl: "Not crawled in 60+ days",
751
+ not_indexed: "Not indexed",
752
+ unknown_to_google: "Unknown to Google",
753
+ crawled_not_indexed: "Crawled but not indexed",
754
+ not_found: "404 Not Found",
755
+ soft_404: "Soft 404",
756
+ server_error: "Server error",
757
+ blocked_robots: "Blocked by robots.txt",
758
+ noindex: "Noindex tag",
759
+ redirect: "Redirect",
760
+ fragment_url: "Fragment URL (#)",
761
+ mobile_fail: "Mobile usability issues",
762
+ rich_results_fail: "Rich results errors",
763
+ rich_results_warning: "Rich results warnings",
764
+ rich_results_pass: "Has rich results"
765
+ };
766
+ const INDEXING_ISSUE_SEVERITY = {
767
+ canonical_mismatch: "warning",
768
+ stale_crawl: "info",
769
+ very_stale_crawl: "warning",
770
+ not_indexed: "error",
771
+ unknown_to_google: "warning",
772
+ crawled_not_indexed: "error",
773
+ not_found: "error",
774
+ soft_404: "error",
775
+ server_error: "error",
776
+ blocked_robots: "warning",
777
+ noindex: "info",
778
+ redirect: "info",
779
+ fragment_url: "warning",
780
+ mobile_fail: "warning",
781
+ rich_results_fail: "error",
782
+ rich_results_warning: "warning",
783
+ rich_results_pass: "info"
784
+ };
785
+ const INDEXING_DAILY_LIMIT = 2e3;
786
+ const INDEXING_EFFECTIVE_LIMIT = 1800;
787
+ function hasGscReadScope(scopes) {
788
+ if (!scopes) return false;
789
+ return scopes.includes("webmasters.readonly") || scopes.includes("webmasters");
790
+ }
791
+ function hasGscWriteScope(scopes) {
792
+ if (!scopes) return false;
793
+ return scopes.includes("webmasters") && !scopes.includes("webmasters.readonly");
794
+ }
795
+ function hasIndexingScope(scopes) {
796
+ if (!scopes) return false;
797
+ return scopes.includes("googleapis.com/auth/indexing");
798
+ }
799
+ export { DAYS_PER_RANGE, GSC_FINALIZED_LAG_DAYS, GSC_FRESHEST_LAG_DAYS, GSC_QUOTAS, GSC_RETENTION_MONTHS, INDEXING_DAILY_LIMIT, INDEXING_EFFECTIVE_LIMIT, INDEXING_ISSUE_FILTERS, INDEXING_ISSUE_LABELS, INDEXING_ISSUE_SEVERITY, MS_PER_DAY, addDays, batchInspectUrls, batchRequestIndexing, classifyError, countDays, createAuth, createFetch, daysAgo, deleteSitemap, fetchSitemap, fetchSitemaps, fetchSites, fetchSitesWithSitemaps, formatErrorForCli, generateGscDateRange, getBackfillProgress, getDateRange, getFreshestGscDate, getIndexingMetadata, getLatestGscDate, getNextDate, getOldestGscDate, getPendingDates, getPreviousDate, getPstDate, googleSearchConsole, groupIntoRanges, gscdumpApi, hasGscReadScope, hasGscWriteScope, hasIndexingScope, inspectUrl, isPermissionDeniedError, isValidGscDate, progressBar, requestIndexing, rowWithMetricDefaults, storageError, submitSitemap, toIsoDate };