gscdump 0.4.0 → 0.6.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/README.md +7 -5
- package/dist/contracts.d.mts +136 -0
- package/dist/contracts.mjs +1 -0
- package/dist/driver.d.mts +78 -0
- package/dist/driver.mjs +1 -0
- package/dist/index.d.mts +158 -89
- package/dist/index.mjs +389 -257
- package/dist/normalize.d.mts +2 -0
- package/dist/normalize.mjs +16 -0
- package/dist/query/index.d.mts +73 -33
- package/dist/query/index.mjs +238 -190
- package/dist/query/plan.d.mts +130 -0
- package/dist/query/plan.mjs +296 -0
- package/dist/sitemap.d.mts +13 -0
- package/dist/sitemap.mjs +31 -0
- package/dist/tenant.d.mts +18 -0
- package/dist/tenant.mjs +18 -0
- package/dist/url.d.mts +9 -0
- package/dist/url.mjs +6 -0
- package/package.json +43 -9
- package/dist/analysis/index.d.mts +0 -513
- package/dist/analysis/index.mjs +0 -872
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { ofetch } from "ofetch";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
return {
|
|
67
47
|
url,
|
|
68
48
|
inspection,
|
|
69
49
|
isIndexed
|
|
70
50
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
149
|
-
return
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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(
|
|
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
|
|
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
|
|
473
|
+
async onRequest({ options }) {
|
|
427
474
|
const token = await resolveToken(authState);
|
|
428
475
|
if (token) {
|
|
429
|
-
options
|
|
430
|
-
options
|
|
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
|
|
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
|
|
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)}`, {
|
|
504
|
-
|
|
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`, {
|
|
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
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
suggestion: formatQuotaSuggestion(message, retryAfter)
|
|
670
|
+
cause
|
|
625
671
|
};
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
isRateLimitError: true,
|
|
629
|
-
isAuthError: false,
|
|
630
|
-
code,
|
|
672
|
+
if (status === 400 || status === 422) return {
|
|
673
|
+
kind: "validation",
|
|
631
674
|
message,
|
|
632
|
-
|
|
633
|
-
suggestion: formatRateLimitSuggestion(retryAfter)
|
|
675
|
+
cause
|
|
634
676
|
};
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
isRateLimitError: false,
|
|
638
|
-
isAuthError: true,
|
|
639
|
-
code,
|
|
677
|
+
return {
|
|
678
|
+
kind: "transport",
|
|
640
679
|
message,
|
|
641
|
-
|
|
680
|
+
status,
|
|
681
|
+
cause
|
|
642
682
|
};
|
|
683
|
+
}
|
|
684
|
+
function storageError(message, cause) {
|
|
643
685
|
return {
|
|
644
|
-
|
|
645
|
-
isRateLimitError: false,
|
|
646
|
-
isAuthError: false,
|
|
647
|
-
code,
|
|
686
|
+
kind: "storage",
|
|
648
687
|
message,
|
|
649
|
-
|
|
688
|
+
cause
|
|
650
689
|
};
|
|
651
690
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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(
|
|
724
|
+
lines.push(suggestion);
|
|
662
725
|
}
|
|
663
726
|
return lines.join("\n");
|
|
664
727
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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 };
|