@gscdump/sdk 1.0.1 → 1.0.3

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.
Files changed (52) hide show
  1. package/README.md +7 -1
  2. package/dist/_chunks/errors.d.mts +22 -0
  3. package/dist/_chunks/request.d.mts +25 -0
  4. package/dist/_chunks/request.mjs +84 -0
  5. package/dist/_chunks/search-console-signals.mjs +9 -0
  6. package/dist/analytics-client.d.mts +53 -0
  7. package/dist/analytics-client.mjs +128 -0
  8. package/dist/analyzer-defs.d.mts +73 -0
  9. package/dist/analyzer-defs.mjs +4 -0
  10. package/dist/anonymization.d.mts +6 -0
  11. package/dist/anonymization.mjs +12 -0
  12. package/dist/archetype-compile.d.mts +48 -0
  13. package/dist/archetype-compile.mjs +124 -0
  14. package/dist/client.d.mts +122 -0
  15. package/dist/client.mjs +365 -0
  16. package/dist/country-names.d.mts +2 -0
  17. package/dist/country-names.mjs +56 -0
  18. package/dist/cwv-thresholds.d.mts +13 -0
  19. package/dist/cwv-thresholds.mjs +23 -0
  20. package/dist/errors.d.mts +2 -0
  21. package/dist/errors.mjs +47 -0
  22. package/dist/gsc-console-url.d.mts +12 -0
  23. package/dist/gsc-console-url.mjs +15 -0
  24. package/dist/gsc-constants.d.mts +17 -0
  25. package/dist/gsc-constants.mjs +2 -0
  26. package/dist/gsc-error.d.mts +12 -0
  27. package/dist/gsc-error.mjs +42 -0
  28. package/dist/gsc-period-presets.d.mts +31 -0
  29. package/dist/gsc-period-presets.mjs +115 -0
  30. package/dist/gsc-rows.d.mts +54 -0
  31. package/dist/gsc-rows.mjs +48 -0
  32. package/dist/hosted-query.d.mts +31 -0
  33. package/dist/hosted-query.mjs +105 -0
  34. package/dist/index.d.mts +21 -852
  35. package/dist/index.mjs +21 -2275
  36. package/dist/indexing-issues.d.mts +29 -0
  37. package/dist/indexing-issues.mjs +168 -0
  38. package/dist/lifecycle.d.mts +14 -0
  39. package/dist/lifecycle.mjs +84 -0
  40. package/dist/period.d.mts +49 -0
  41. package/dist/period.mjs +138 -0
  42. package/dist/search-console-stage.d.mts +102 -0
  43. package/dist/search-console-stage.mjs +292 -0
  44. package/dist/site-baseline.d.mts +69 -0
  45. package/dist/site-baseline.mjs +112 -0
  46. package/dist/site-triage.d.mts +86 -0
  47. package/dist/site-triage.mjs +268 -0
  48. package/dist/v1/index.d.mts +1 -1
  49. package/dist/v1/index.mjs +2 -2
  50. package/dist/webhook.d.mts +29 -0
  51. package/dist/webhook.mjs +118 -0
  52. package/package.json +110 -5
package/dist/index.mjs CHANGED
@@ -1,2276 +1,22 @@
1
- import { analyticsEndpoints } from "@gscdump/contracts/analytics";
2
- import { err, ok, unwrapResult } from "gscdump/result";
3
- import { ofetch } from "ofetch";
4
- import { entityDailyTimeseries, multiSeriesStackedDaily, siteDailyTimeseries, topNBreakdown, twoDimensionDetail } from "@gscdump/contracts/archetypes";
5
- import { extractDateRange, normalizeFilter } from "gscdump/query";
6
- import { partnerEndpoints } from "@gscdump/contracts/partner";
7
- import { resolveWindow } from "@gscdump/engine/period";
8
- import { endOfMonth, format, startOfMonth, startOfQuarter, startOfWeek, subDays, subMonths } from "date-fns";
9
- import { CANONICAL_WEBHOOK_EVENTS, VALID_WEBHOOK_EVENTS, WEBHOOK_CONTRACT_VERSION, WEBHOOK_CONTRACT_VERSION_HEADER, WEBHOOK_CONTRACT_VERSION_HEADER as WEBHOOK_CONTRACT_VERSION_HEADER$1, WEBHOOK_DELIVERY_HEADER, WEBHOOK_DELIVERY_HEADER as WEBHOOK_DELIVERY_HEADER$1, WEBHOOK_EVENT_HEADER, WEBHOOK_EVENT_HEADER as WEBHOOK_EVENT_HEADER$1, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_HEADER as WEBHOOK_SIGNATURE_HEADER$1, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_TIMESTAMP_HEADER as WEBHOOK_TIMESTAMP_HEADER$1, partnerWebhookEnvelopeSchema } from "@gscdump/contracts";
10
- const DEFAULT_SEARCH_TYPE = "web";
11
- function isAnalysisSourcesOptions(value) {
12
- return !!value && typeof value === "object" && !Array.isArray(value);
13
- }
14
- function withDefaultSearchType(value, searchType) {
15
- if (!value || typeof value !== "object" || Array.isArray(value)) return value;
16
- const scoped = value;
17
- return {
18
- ...scoped,
19
- searchType: searchType ?? scoped.searchType ?? "web"
20
- };
21
- }
22
- function searchTypeQuery(searchType) {
23
- return { searchType: searchType ?? "web" };
24
- }
25
- function stableJson$1(value) {
26
- if (value == null || typeof value !== "object") return JSON.stringify(value) ?? "null";
27
- if (Array.isArray(value)) return `[${value.map((item) => item === void 0 ? "null" : stableJson$1(item)).join(",")}]`;
28
- return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson$1(item)}`).join(",")}}`;
29
- }
30
- function dateRangeOptionsQuery(options) {
31
- const query = {};
32
- const start = options?.start ?? options?.startDate;
33
- const end = options?.end ?? options?.endDate;
34
- if (start) query.start = start;
35
- if (end) query.end = end;
36
- return query;
37
- }
38
- function sourceInfoQuery(options) {
39
- return {
40
- ...searchTypeQuery(options?.searchType),
41
- ...dateRangeOptionsQuery(options)
42
- };
43
- }
44
- function tablesQuery(tablesOrOptions, options) {
45
- const tables = isAnalysisSourcesOptions(tablesOrOptions) ? tablesOrOptions.tables : tablesOrOptions;
46
- const source = isAnalysisSourcesOptions(tablesOrOptions) ? tablesOrOptions : options;
47
- const query = {
48
- ...searchTypeQuery(source?.searchType),
49
- ...dateRangeOptionsQuery(source)
50
- };
51
- if (tables) query.tables = Array.isArray(tables) ? [...new Set(tables.filter(Boolean))].sort().join(",") : [...new Set(tables.split(",").map((t) => t.trim()).filter(Boolean))].sort().join(",");
52
- if (source?.maxBytes != null) query.maxBytes = String(Math.max(1, Math.floor(source.maxBytes)));
53
- if (source?.maxRows != null) query.maxRows = String(Math.max(1, Math.floor(source.maxRows)));
54
- if (source?.maxFiles != null) query.maxFiles = String(Math.max(1, Math.floor(source.maxFiles)));
55
- return query;
56
- }
57
- function dataQuery(state, options) {
58
- const opts = options;
59
- const scoped = withDefaultSearchType(state, opts?.searchType);
60
- const query = {
61
- q: stableJson$1(scoped),
62
- searchType: scoped.searchType ?? "web"
63
- };
64
- if (opts?.comparison) query.qc = stableJson$1(withDefaultSearchType(opts.comparison, scoped.searchType));
65
- if (opts?.filter) query.filter = opts.filter;
66
- return query;
67
- }
68
- function dataDetailQuery(state, options) {
69
- const opts = options;
70
- const scoped = withDefaultSearchType(state, opts?.searchType);
71
- const query = {
72
- q: stableJson$1(scoped),
73
- searchType: scoped.searchType ?? "web"
74
- };
75
- if (opts?.comparison) query.qc = stableJson$1(withDefaultSearchType(opts.comparison, scoped.searchType));
76
- return query;
77
- }
78
- function indexingUrlsQuery(params = {}) {
79
- const query = {};
80
- if (params.limit != null) query.limit = params.limit;
81
- if (params.offset != null) query.offset = params.offset;
82
- if (params.status) query.status = params.status;
83
- if (params.issue) query.issue = params.issue;
84
- if (params.search) query.search = params.search;
85
- if (params.count === 0 || params.count === false) query.count = 0;
86
- return query;
87
- }
88
- function indexingDiagnosticsQuery(params = {}) {
89
- const query = {};
90
- if (params.sampleIssues) query.sampleIssues = Array.isArray(params.sampleIssues) ? [...new Set(params.sampleIssues.map((item) => String(item).trim()).filter(Boolean))].sort().join(",") : params.sampleIssues;
91
- if (params.sampleLimit != null) query.sampleLimit = params.sampleLimit;
92
- return query;
93
- }
94
- function queryTrendQuery(params) {
95
- const query = {
96
- startDate: params.startDate,
97
- endDate: params.endDate,
98
- searchType: params.searchType ?? "web"
99
- };
100
- if (params.prevStartDate) query.prevStartDate = params.prevStartDate;
101
- if (params.prevEndDate) query.prevEndDate = params.prevEndDate;
102
- return query;
103
- }
104
- function pageTrendQuery(params) {
105
- const query = {
106
- startDate: params.startDate,
107
- endDate: params.endDate,
108
- searchType: params.searchType ?? "web"
109
- };
110
- if (params.prevStartDate) query.prevStartDate = params.prevStartDate;
111
- if (params.prevEndDate) query.prevEndDate = params.prevEndDate;
112
- return query;
113
- }
114
- var PartnerApiError = class extends Error {
115
- kind;
116
- statusCode;
117
- data;
118
- constructor(info) {
119
- super(info.message);
120
- this.name = "PartnerApiError";
121
- this.kind = info.kind;
122
- this.statusCode = info.statusCode;
123
- this.data = info.data;
124
- }
125
- };
126
- function statusOf(error) {
127
- const rec = error;
128
- return rec.statusCode ?? rec.status ?? rec.response?.status;
129
- }
130
- function messageOf(error) {
131
- const rec = error;
132
- return rec.data?.message ?? rec.data?.statusMessage ?? rec.message ?? rec.statusMessage ?? String(error);
133
- }
134
- function kindOf(status, message) {
135
- if (status === 401 || status === 403) return "auth";
136
- if (status === 404) return "not-found";
137
- if (status === 409 || /provision/i.test(message)) return "provisioning";
138
- if (status === 429) return "rate-limit";
139
- if (status === 400 || status === 422) return "validation";
140
- if (/permission|reauth|access/i.test(message)) return "permission";
141
- if (status && status >= 500) return "server";
142
- if (!status) return "network";
143
- return "unknown";
144
- }
145
- function toPartnerError(error) {
146
- if (error instanceof PartnerApiError) return error;
147
- const statusCode = statusOf(error);
148
- const message = messageOf(error);
149
- const data = error?.data;
150
- return new PartnerApiError({
151
- kind: kindOf(statusCode, message),
152
- statusCode,
153
- message,
154
- data
155
- });
156
- }
157
- function partnerErrorToException(error) {
158
- return error;
159
- }
160
- const TRAILING_SLASH_RE = /\/+$/;
161
- const LEADING_SLASH_RE = /^\/+/;
162
- function trimApiBase(apiBase, fallback) {
163
- return (apiBase ?? fallback).replace(TRAILING_SLASH_RE, "");
164
- }
165
- function buildPath(apiBase, path) {
166
- if (!apiBase) return path.startsWith("/") ? path : `/${path}`;
167
- return `${apiBase}/${path.replace(LEADING_SLASH_RE, "")}`;
168
- }
169
- function mergeHeaders(base, extra) {
170
- const headers = new Headers(base);
171
- if (extra) for (const [key, value] of new Headers(extra).entries()) headers.set(key, value);
172
- return headers;
173
- }
174
- async function resolveHeaders(options) {
175
- const resolved = typeof options.headers === "function" ? await options.headers() : options.headers;
176
- if (!options.apiKey) return resolved;
177
- return mergeHeaders(resolved, { "x-api-key": options.apiKey });
178
- }
179
- function shouldValidate(options, phase) {
180
- return options.validate === true || options.validate === phase;
181
- }
182
- function parseWith(schema, value) {
183
- return schema ? schema.parse(value) : value;
184
- }
185
- function stableJson(value) {
186
- if (value == null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
187
- if (Array.isArray(value)) return `[${value.map((item) => item === void 0 ? "undefined" : stableJson(item)).join(",")}]`;
188
- return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
189
- }
190
- function headersKey(headers) {
191
- return [...headers.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}:${value}`).join("\n");
192
- }
193
- function isDedupeable(init) {
194
- if ("signal" in init && init.signal) return false;
195
- const method = (init.method ?? "GET").toUpperCase();
196
- return method === "GET" || method === "HEAD" || init.dedupe === true;
197
- }
198
- function createHostedRequester(options, defaults) {
199
- const fetchImpl = options.fetch ?? ofetch;
200
- const apiBase = trimApiBase(options.apiBase, defaults.apiBase);
201
- const dedupe = options.dedupe !== false;
202
- const inflight = options.dedupeScope ?? /* @__PURE__ */ new Map();
203
- async function requestResult(path, init = {}, responseSchema) {
204
- const { dedupe: _dedupe, ...fetchInit } = init;
205
- const headers = mergeHeaders(await resolveHeaders(options), init.headers);
206
- const fullPath = buildPath(apiBase, path);
207
- const dedupeKey = dedupe && isDedupeable(init) ? `${(init.method ?? "GET").toUpperCase()} ${fullPath}\nq=${stableJson(init.query)}\nb=${stableJson(init.body)}\nh=${headersKey(headers)}` : null;
208
- if (dedupeKey) {
209
- const existing = inflight.get(dedupeKey);
210
- if (existing) return existing;
211
- }
212
- const run = (async () => {
213
- try {
214
- const out = await fetchImpl(fullPath, {
215
- ...fetchInit,
216
- headers
217
- });
218
- return ok(shouldValidate(options, "response") ? parseWith(responseSchema, out) : out);
219
- } catch (error) {
220
- return err(toPartnerError(error));
221
- }
222
- })();
223
- if (dedupeKey) {
224
- inflight.set(dedupeKey, run);
225
- run.finally(() => {
226
- if (inflight.get(dedupeKey) === run) inflight.delete(dedupeKey);
227
- });
228
- }
229
- return run;
230
- }
231
- async function request(path, init = {}, responseSchema) {
232
- return unwrapResult(await requestResult(path, init, responseSchema), partnerErrorToException);
233
- }
234
- return {
235
- request,
236
- requestResult,
237
- shouldValidate: (phase) => shouldValidate(options, phase)
238
- };
239
- }
240
- function createAnalyticsClient(options = {}) {
241
- const { request, shouldValidate } = createHostedRequester(options, { apiBase: "" });
242
- return {
243
- whoami() {
244
- const endpoint = analyticsEndpoints.whoami;
245
- return request(endpoint.path, { method: endpoint.method }, endpoint.response);
246
- },
247
- listSites() {
248
- const endpoint = analyticsEndpoints.listSites;
249
- return request(endpoint.path, { method: endpoint.method }, endpoint.response);
250
- },
251
- getBulkSources(params) {
252
- const endpoint = analyticsEndpoints.getBulkSources;
253
- const { siteIds, tables, ...options } = params;
254
- const query = tablesQuery(tables, options);
255
- if (siteIds?.length) query.siteIds = [...new Set(siteIds.filter(Boolean))].join(",");
256
- return request(endpoint.path, {
257
- method: endpoint.method,
258
- query
259
- }, endpoint.response);
260
- },
261
- getSourceInfo(siteId, options) {
262
- const endpoint = analyticsEndpoints.getSourceInfo;
263
- return request(endpoint.path(siteId), {
264
- method: endpoint.method,
265
- query: sourceInfoQuery(options)
266
- }, endpoint.response);
267
- },
268
- getAnalysisSources(siteId, tables, options) {
269
- const endpoint = analyticsEndpoints.getAnalysisSources;
270
- return request(endpoint.path(siteId), {
271
- method: endpoint.method,
272
- query: tablesQuery(tables, options)
273
- }, endpoint.response);
274
- },
275
- getQueryDimSource(siteId) {
276
- const endpoint = analyticsEndpoints.getQueryDimSource;
277
- return request(endpoint.path(siteId), { method: endpoint.method }, endpoint.response);
278
- },
279
- analyze(siteId, params) {
280
- const endpoint = analyticsEndpoints.analyze;
281
- return request(endpoint.path(siteId), {
282
- method: endpoint.method,
283
- body: withDefaultSearchType(params),
284
- dedupe: true
285
- });
286
- },
287
- getRollup(siteId, rollupId, params) {
288
- const endpoint = analyticsEndpoints.getRollup;
289
- return request(endpoint.path(siteId, rollupId), {
290
- method: endpoint.method,
291
- query: params
292
- }, endpoint.response);
293
- },
294
- requestBackfill(siteId, range) {
295
- const endpoint = analyticsEndpoints.requestBackfill;
296
- const body = shouldValidate("request") ? endpoint.body.parse(range) : range;
297
- return request(endpoint.path(siteId), {
298
- method: endpoint.method,
299
- body
300
- }, endpoint.response);
301
- },
302
- getSitemaps(siteId) {
303
- const endpoint = analyticsEndpoints.getSitemaps;
304
- return request(endpoint.path(siteId), { method: endpoint.method }, endpoint.response);
305
- },
306
- getSitemapHistory(siteId, hash) {
307
- const endpoint = analyticsEndpoints.getSitemapHistory;
308
- return request(endpoint.path(siteId, hash), { method: endpoint.method }, endpoint.response);
309
- },
310
- getSitemapChanges(siteId, params = {}) {
311
- const endpoint = analyticsEndpoints.getSitemapChanges;
312
- return request(endpoint.path(siteId), {
313
- method: endpoint.method,
314
- query: params
315
- }, endpoint.response);
316
- },
317
- getInspections(siteId) {
318
- const endpoint = analyticsEndpoints.getInspections;
319
- return request(endpoint.path(siteId), { method: endpoint.method }, endpoint.response);
320
- },
321
- getInspectionHistory(siteId, hash) {
322
- const endpoint = analyticsEndpoints.getInspectionHistory;
323
- return request(endpoint.path(siteId, hash), { method: endpoint.method }, endpoint.response);
324
- },
325
- getIndexingUrls(siteId, params = {}) {
326
- const endpoint = analyticsEndpoints.getIndexingUrls;
327
- return request(endpoint.path(siteId), {
328
- method: endpoint.method,
329
- query: indexingUrlsQuery(params)
330
- }, endpoint.response);
331
- },
332
- getIndexingDiagnostics(siteId, params = {}) {
333
- const endpoint = analyticsEndpoints.getIndexingDiagnostics;
334
- const parsed = shouldValidate("request") ? endpoint.query.parse(params) : params;
335
- return request(endpoint.path(siteId), {
336
- method: endpoint.method,
337
- query: indexingDiagnosticsQuery(parsed)
338
- }, endpoint.response);
339
- },
340
- requestIndexingInspect(siteId, body) {
341
- const endpoint = analyticsEndpoints.requestIndexingInspect;
342
- const parsed = shouldValidate("request") ? endpoint.body.parse(body) : body;
343
- return request(endpoint.path(siteId), {
344
- method: endpoint.method,
345
- body: parsed
346
- }, endpoint.response);
347
- },
348
- getCountries(siteId, range) {
349
- const endpoint = analyticsEndpoints.getCountries;
350
- return request(endpoint.path(siteId), {
351
- method: endpoint.method,
352
- query: range
353
- }, endpoint.response);
354
- },
355
- getSearchAppearance(siteId, range) {
356
- const endpoint = analyticsEndpoints.getSearchAppearance;
357
- return request(endpoint.path(siteId), {
358
- method: endpoint.method,
359
- query: range
360
- }, endpoint.response);
361
- }
362
- };
363
- }
364
- function defineGscAnalyzer(def) {
365
- return def;
366
- }
367
- function weightedAnonPct(days, window = 28) {
368
- if (!days?.length) return null;
369
- const trailing = days.slice(-window);
370
- let totalImpressions = 0;
371
- let weighted = 0;
372
- for (const d of trailing) {
373
- totalImpressions += d.impressions;
374
- weighted += d.impressions * d.anonymizedImpressionsPct;
375
- }
376
- return totalImpressions > 0 ? weighted / totalImpressions : null;
377
- }
378
- const KNOWN_DIMENSIONS = /* @__PURE__ */ new Set([
379
- "query",
380
- "queryCanonical",
381
- "page",
382
- "country",
383
- "device",
384
- "searchAppearance",
385
- "date",
386
- "hour"
387
- ]);
388
- const DATE_RANGE_OPERATORS = /* @__PURE__ */ new Set([
389
- "between",
390
- "gte",
391
- "gt",
392
- "lte",
393
- "lt"
394
- ]);
395
- const EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["equals", "eq"]);
396
- const ENTITY_DIMENSIONS = [
397
- "page",
398
- "query",
399
- "queryCanonical"
400
- ];
401
- function collectEqualityMatches(filter, out) {
402
- if (!filter) return true;
403
- if (filter._groupType === "or") return false;
404
- for (const f of filter._filters) {
405
- if (f.dimension === "date") {
406
- if (!DATE_RANGE_OPERATORS.has(f.operator)) return false;
407
- continue;
408
- }
409
- if (!EQUALITY_OPERATORS.has(f.operator) || !KNOWN_DIMENSIONS.has(f.dimension)) return false;
410
- const dim = f.dimension;
411
- const existing = out.get(dim);
412
- if (existing !== void 0 && existing !== f.expression) return false;
413
- out.set(dim, f.expression);
414
- }
415
- for (const nested of filter._nestedGroups ?? []) if (!collectEqualityMatches(nested, out)) return false;
416
- return true;
417
- }
418
- function extractWireDateRange(filter) {
419
- const { startDate, endDate } = extractDateRange(filter);
420
- return startDate && endDate ? {
421
- start: startDate,
422
- end: endDate
423
- } : null;
424
- }
425
- function builderStateToArchetype(siteId, state, opts = {}) {
426
- const range = extractWireDateRange(state.filter);
427
- if (!range) return null;
428
- const matches = /* @__PURE__ */ new Map();
429
- if (!collectEqualityMatches(normalizeFilter(state.filter), matches)) return null;
430
- const dims = state.dimensions ?? [];
431
- if (dims.some((d) => !KNOWN_DIMENSIONS.has(d))) return null;
432
- if (dims.includes("hour") || dims.includes("searchAppearance")) return null;
433
- const searchType = opts.searchType;
434
- const cmp = opts.compareRange;
435
- const order = state.orderBy ? {
436
- metric: state.orderBy.column,
437
- dir: state.orderBy.dir
438
- } : void 0;
439
- if (dims.length === 1 && dims[0] === "date") {
440
- const entityDims = ENTITY_DIMENSIONS.filter((c) => matches.has(c));
441
- if (entityDims.length > 1) return null;
442
- if (entityDims.length === 1) {
443
- const dim = entityDims[0];
444
- if (matches.size > 1) return null;
445
- return entityDailyTimeseries(siteId, range, {
446
- dimension: dim,
447
- value: matches.get(dim)
448
- }, {
449
- searchType,
450
- compareRange: cmp
451
- });
452
- }
453
- if (matches.size > 0) return null;
454
- return siteDailyTimeseries(siteId, range, {
455
- searchType,
456
- compareRange: cmp
457
- });
458
- }
459
- if (dims.length === 2 && dims.includes("date")) {
460
- if (matches.size > 0) return null;
461
- return multiSeriesStackedDaily(siteId, range, dims.find((d) => d !== "date"), {
462
- searchType,
463
- compareRange: cmp
464
- });
465
- }
466
- if (dims.length === 1 && dims[0] !== "date") {
467
- const primary = dims[0];
468
- if (matches.has(primary)) return null;
469
- const facets = [...matches.entries()].map(([column, value]) => ({
470
- column,
471
- op: "eq",
472
- value
473
- }));
474
- return topNBreakdown(siteId, range, primary, {
475
- searchType,
476
- compareRange: cmp,
477
- ...order ? { orderBy: order } : {},
478
- limit: state.rowLimit ?? 50,
479
- ...state.startRow ? { offset: state.startRow } : {},
480
- ...facets.length ? { facets } : {}
481
- });
482
- }
483
- if (dims.length === 2 && !dims.includes("date")) {
484
- const set = new Set(dims);
485
- if (!set.has("page") || !set.has("query")) return null;
486
- if (matches.size > 0) return null;
487
- return twoDimensionDetail(siteId, range, {
488
- searchType,
489
- compareRange: cmp,
490
- ...order ? { orderBy: order } : {},
491
- ...state.rowLimit ? { limit: state.rowLimit } : {}
492
- });
493
- }
494
- return null;
495
- }
496
- function archetypeSeamSupportsQuery(query) {
497
- return query.archetype !== "two-dimension-detail";
498
- }
499
- function normalizeLifecycleUrl(url) {
500
- return (url || "").replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "").toLowerCase();
501
- }
502
- function normalizeGscPropertyKey(url) {
503
- const value = url || "";
504
- if (!value) return "";
505
- if (value.startsWith("sc-domain:")) return `domain:${normalizeLifecycleUrl(value)}`;
506
- if (!/^https?:\/\//.test(value)) return "";
507
- return `url:${normalizeLifecycleUrl(value)}`;
508
- }
509
- function analyticsStatusToSyncStatus(status) {
510
- switch (status) {
511
- case "ready":
512
- case "queryable_live":
513
- case "queryable_partial": return "synced";
514
- case "syncing":
515
- case "preparing": return "syncing";
516
- case "failed": return "error";
517
- default: return "pending";
518
- }
519
- }
520
- function lifecycleSiteToUserSite(site) {
521
- const syncStatus = analyticsStatusToSyncStatus(site.analytics.status);
522
- return {
523
- siteId: site.siteId,
524
- siteUrl: site.gscPropertyUrl || site.requestedUrl,
525
- analyticsSyncStatus: syncStatus,
526
- analyticsSyncProgress: site.analytics.progress,
527
- syncStatus,
528
- syncProgress: site.analytics.progress,
529
- indexingEligible: site.indexing.eligible,
530
- indexingIneligibleReason: site.indexing.reason,
531
- indexingPermissionLevel: site.permissionLevel,
532
- indexingStatus: site.indexing.status === "ready" ? "complete" : site.indexing.status === "not_requested" ? "not_started" : "indexing",
533
- indexingProgress: site.indexing.progress,
534
- lastSyncAt: site.updatedAt ? Date.parse(site.updatedAt) : null,
535
- newestDateSynced: site.analytics.syncedRange.newest,
536
- oldestDateSynced: site.analytics.syncedRange.oldest
537
- };
538
- }
539
- function lifecycleSiteToSyncStatus(site) {
540
- const syncStatus = analyticsStatusToSyncStatus(site.analytics.status);
541
- const completed = site.analytics.progress.completed;
542
- const failed = site.analytics.progress.failed;
543
- const total = site.analytics.progress.total;
544
- const queued = Math.max(total - completed - failed, 0);
545
- return {
546
- siteUrl: site.gscPropertyUrl || site.requestedUrl,
547
- syncStatus,
548
- oldestDateAvailable: site.analytics.syncedRange.oldest,
549
- oldestDateSynced: site.analytics.syncedRange.oldest,
550
- newestDateSynced: site.analytics.syncedRange.newest,
551
- lastSyncAt: site.updatedAt ? Date.parse(site.updatedAt) : null,
552
- lastError: site.latestError?.message ?? null,
553
- jobs: {
554
- queued,
555
- processing: [
556
- "queued",
557
- "preparing",
558
- "syncing"
559
- ].includes(site.analytics.status) ? 1 : 0,
560
- completed,
561
- failed
562
- },
563
- progress: site.analytics.progress.percent,
564
- jobProgress: site.analytics.progress.percent,
565
- daysSynced: completed,
566
- daysAvailable: total,
567
- isSyncing: [
568
- "queued",
569
- "preparing",
570
- "syncing"
571
- ].includes(site.analytics.status),
572
- hasData: site.analytics.queryable,
573
- isComplete: site.analytics.queryable && site.analytics.status === "ready",
574
- tables: {}
575
- };
576
- }
577
- function findLifecycleSite(lifecycle, siteIdOrPropertyUrl) {
578
- const normalized = normalizeLifecycleUrl(siteIdOrPropertyUrl);
579
- const propertyKey = normalizeGscPropertyKey(siteIdOrPropertyUrl);
580
- return lifecycle.sites.find((site) => site.siteId === siteIdOrPropertyUrl || site.externalSiteId === siteIdOrPropertyUrl || !!propertyKey && normalizeGscPropertyKey(site.gscPropertyUrl) === propertyKey || !site.gscPropertyUrl && normalizeLifecycleUrl(site.requestedUrl) === normalized || !propertyKey && normalizeLifecycleUrl(site.requestedUrl) === normalized) ?? null;
581
- }
582
- const endpoints = partnerEndpoints;
583
- function sleep(ms) {
584
- return new Promise((resolve) => setTimeout(resolve, ms));
585
- }
586
- function createPartnerClient(options = {}) {
587
- const { request, requestResult, shouldValidate } = createHostedRequester(options, { apiBase: "/api" });
588
- async function waitForUserReadyResult(userId, waitOptions = {}) {
589
- const attempts = waitOptions.attempts ?? 12;
590
- const intervalMs = waitOptions.intervalMs ?? 1e3;
591
- let latest = null;
592
- for (let attempt = 0; attempt < attempts; attempt++) {
593
- const result = await requestResult(endpoints.getUserStatus.path(userId), { method: endpoints.getUserStatus.method });
594
- if (!result.ok) return result;
595
- latest = result.value;
596
- if (latest.status === "ready") return ok(latest);
597
- if (attempt < attempts - 1) await sleep(intervalMs);
598
- }
599
- return err(new PartnerApiError({
600
- kind: "provisioning",
601
- statusCode: 409,
602
- message: "gscdump user database is still provisioning",
603
- data: latest
604
- }));
605
- }
606
- function waitForUserReadyToException(error) {
607
- const e = new Error(error.message);
608
- e.data = error.data;
609
- return e;
610
- }
611
- async function waitForUserLifecycleReadyResult(userId, waitOptions = {}) {
612
- const attempts = waitOptions.attempts ?? 12;
613
- const intervalMs = waitOptions.intervalMs ?? 1e3;
614
- let latest = null;
615
- for (let attempt = 0; attempt < attempts; attempt++) {
616
- const result = await requestResult(endpoints.getUserLifecycle.path(userId), { method: endpoints.getUserLifecycle.method });
617
- if (!result.ok) return result;
618
- latest = result.value;
619
- const status = latest.account.status;
620
- if (status === "ready") return ok(latest);
621
- if (status === "refresh_missing" || status === "scope_missing" || status === "reauth_required") return err(new PartnerApiError({
622
- kind: "auth",
623
- statusCode: 401,
624
- message: "Google Search Console authorization must be refreshed",
625
- data: latest.account
626
- }));
627
- if (status === "disconnected" || status === "oauth_received") return err(new PartnerApiError({
628
- kind: "provisioning",
629
- statusCode: 409,
630
- message: "gscdump user is not fully connected",
631
- data: latest.account
632
- }));
633
- if (attempt < attempts - 1) await sleep(intervalMs);
634
- }
635
- return err(new PartnerApiError({
636
- kind: "provisioning",
637
- statusCode: 409,
638
- message: "gscdump user database is still provisioning",
639
- data: latest
640
- }));
641
- }
642
- async function getSiteSyncStatusResult(siteId, userId) {
643
- if (!userId) return requestResult(endpoints.getSyncStatus.path(siteId), { method: endpoints.getSyncStatus.method });
644
- const lifecycle = await requestResult(endpoints.getUserLifecycle.path(userId), { method: endpoints.getUserLifecycle.method });
645
- if (!lifecycle.ok) return lifecycle;
646
- const site = findLifecycleSite(lifecycle.value, siteId);
647
- if (!site) return err(new PartnerApiError({
648
- kind: "not-found",
649
- statusCode: 404,
650
- message: "gscdump lifecycle site not found",
651
- data: {
652
- siteId,
653
- userId
654
- }
655
- }));
656
- return ok(lifecycleSiteToSyncStatus(site));
657
- }
658
- function postSitemapAction(siteId, action) {
659
- const body = shouldValidate("request") ? endpoints.postSitemaps.body.parse(action) : action;
660
- return request(endpoints.postSitemaps.path(siteId), {
661
- method: endpoints.postSitemaps.method,
662
- body
663
- }, endpoints.postSitemaps.response);
664
- }
665
- return {
666
- registerUser(params) {
667
- const body = shouldValidate("request") ? endpoints.registerUser.body.parse(params) : params;
668
- return request(endpoints.registerUser.path, {
669
- method: endpoints.registerUser.method,
670
- body
671
- }, endpoints.registerUser.response);
672
- },
673
- updateUserTokens(userId, params) {
674
- const body = shouldValidate("request") ? endpoints.updateUserTokens.body.parse(params) : params;
675
- return request(endpoints.updateUserTokens.path(userId), {
676
- method: endpoints.updateUserTokens.method,
677
- body
678
- }, endpoints.updateUserTokens.response);
679
- },
680
- getUserStatus(userId) {
681
- return request(endpoints.getUserStatus.path(userId), { method: endpoints.getUserStatus.method }, endpoints.getUserStatus.response);
682
- },
683
- getUserLifecycle(userId) {
684
- return request(endpoints.getUserLifecycle.path(userId), { method: endpoints.getUserLifecycle.method }, endpoints.getUserLifecycle.response);
685
- },
686
- async waitForUserReady(userId, waitOptions = {}) {
687
- return unwrapResult(await waitForUserReadyResult(userId, waitOptions), waitForUserReadyToException);
688
- },
689
- async waitForUserLifecycleReady(userId, waitOptions = {}) {
690
- return unwrapResult(await waitForUserLifecycleReadyResult(userId, waitOptions), partnerErrorToException);
691
- },
692
- getUserSites(userId) {
693
- return request(endpoints.getUserSites.path(userId), { method: endpoints.getUserSites.method }, endpoints.getUserSites.response);
694
- },
695
- getAvailableSites(userId) {
696
- return request(endpoints.getAvailableSites.path(userId), { method: endpoints.getAvailableSites.method }, endpoints.getAvailableSites.response);
697
- },
698
- getUserSiteIntIdCrosswalk(userId) {
699
- return request(endpoints.getUserSiteIntIdCrosswalk.path(userId), { method: endpoints.getUserSiteIntIdCrosswalk.method }, endpoints.getUserSiteIntIdCrosswalk.response);
700
- },
701
- registerSite(params) {
702
- const body = shouldValidate("request") ? endpoints.registerSite.body.parse(params) : params;
703
- return request(endpoints.registerSite.path, {
704
- method: endpoints.registerSite.method,
705
- body
706
- }, endpoints.registerSite.response);
707
- },
708
- bulkRegisterSites(params) {
709
- const body = shouldValidate("request") ? endpoints.bulkRegisterSites.body.parse(params) : params;
710
- return request(endpoints.bulkRegisterSites.path, {
711
- method: endpoints.bulkRegisterSites.method,
712
- body
713
- }, endpoints.bulkRegisterSites.response);
714
- },
715
- requestSiteVerificationToken(params) {
716
- const body = shouldValidate("request") ? endpoints.requestSiteVerificationToken.body.parse(params) : params;
717
- return request(endpoints.requestSiteVerificationToken.path, {
718
- method: endpoints.requestSiteVerificationToken.method,
719
- body
720
- }, endpoints.requestSiteVerificationToken.response);
721
- },
722
- addAndVerifySite(params) {
723
- const body = shouldValidate("request") ? endpoints.addAndVerifySite.body.parse(params) : params;
724
- return request(endpoints.addAndVerifySite.path, {
725
- method: endpoints.addAndVerifySite.method,
726
- body
727
- }, endpoints.addAndVerifySite.response);
728
- },
729
- deleteUser(userId) {
730
- return request(endpoints.deleteUser.path(userId), { method: endpoints.deleteUser.method }, endpoints.deleteUser.response);
731
- },
732
- deleteSite(siteId) {
733
- return request(endpoints.deleteSite.path(siteId), { method: endpoints.deleteSite.method });
734
- },
735
- getAnalysisSources(siteId, tables, options) {
736
- return request(endpoints.getAnalysisSources.path(siteId), {
737
- method: endpoints.getAnalysisSources.method,
738
- query: tablesQuery(tables, options)
739
- }, endpoints.getAnalysisSources.response);
740
- },
741
- async getSiteSyncStatus(siteId, userId) {
742
- return unwrapResult(await getSiteSyncStatusResult(siteId, userId), partnerErrorToException);
743
- },
744
- getData(siteId, state, queryOptions) {
745
- if (shouldValidate("request")) {
746
- endpoints.getData.state.parse(state);
747
- endpoints.getData.options.parse(queryOptions);
748
- }
749
- return request(endpoints.getData.path(siteId), {
750
- method: endpoints.getData.method,
751
- query: dataQuery(state, queryOptions)
752
- }, endpoints.getData.response);
753
- },
754
- getDataDetail(siteId, state, queryOptions) {
755
- if (shouldValidate("request")) {
756
- endpoints.getDataDetail.state.parse(state);
757
- endpoints.getDataDetail.options.parse(queryOptions);
758
- }
759
- return request(endpoints.getDataDetail.path(siteId), {
760
- method: endpoints.getDataDetail.method,
761
- query: dataDetailQuery(state, queryOptions)
762
- }, endpoints.getDataDetail.response);
763
- },
764
- getSitemaps(siteId) {
765
- return request(endpoints.getSitemaps.path(siteId), { method: endpoints.getSitemaps.method }, endpoints.getSitemaps.response);
766
- },
767
- getSitemapChanges(siteId, days = 28) {
768
- return request(endpoints.getSitemapChanges.path(siteId), {
769
- method: endpoints.getSitemapChanges.method,
770
- query: { days }
771
- }, endpoints.getSitemapChanges.response);
772
- },
773
- getSitemapMembership(siteId, params) {
774
- const body = shouldValidate("request") ? endpoints.getSitemapMembership.body.parse(params) : params;
775
- return request(endpoints.getSitemapMembership.path(siteId), {
776
- method: endpoints.getSitemapMembership.method,
777
- body,
778
- dedupe: true
779
- }, endpoints.getSitemapMembership.response);
780
- },
781
- postSitemapAction,
782
- async submitSitemap(siteId, sitemapUrl, action = "submit") {
783
- const response = await postSitemapAction(siteId, {
784
- action,
785
- sitemapUrl
786
- });
787
- const expected = action === "submit" ? "submitted" : "deleted";
788
- if (response.action !== expected) throw new TypeError(`Unexpected sitemap action response: expected ${expected}, got ${response.action}`);
789
- return response;
790
- },
791
- async refreshSitemaps(siteId) {
792
- const response = await postSitemapAction(siteId, { action: "refresh" });
793
- if (response.action !== "refreshed") throw new TypeError(`Unexpected sitemap action response: expected refreshed, got ${response.action}`);
794
- return response;
795
- },
796
- async autoDiscoverSitemap(siteId) {
797
- const response = await postSitemapAction(siteId, { action: "auto-discover" });
798
- if (response.action !== "auto-discover") throw new TypeError(`Unexpected sitemap action response: expected auto-discover, got ${response.action}`);
799
- return response;
800
- },
801
- getIndexing(siteId, days = 28) {
802
- return request(endpoints.getIndexing.path(siteId), {
803
- method: endpoints.getIndexing.method,
804
- query: { days }
805
- }, endpoints.getIndexing.response);
806
- },
807
- getIndexingUrls(siteId, params = {}) {
808
- const parsed = shouldValidate("request") ? endpoints.getIndexingUrls.query.parse(params) : params;
809
- return request(endpoints.getIndexingUrls.path(siteId), {
810
- method: endpoints.getIndexingUrls.method,
811
- query: indexingUrlsQuery(parsed)
812
- }, endpoints.getIndexingUrls.response);
813
- },
814
- getIndexingDiagnostics(siteId, params = {}) {
815
- const parsed = shouldValidate("request") ? endpoints.getIndexingDiagnostics.query.parse(params) : params;
816
- return request(endpoints.getIndexingDiagnostics.path(siteId), {
817
- method: endpoints.getIndexingDiagnostics.method,
818
- query: indexingDiagnosticsQuery(parsed)
819
- }, endpoints.getIndexingDiagnostics.response);
820
- },
821
- requestIndexingInspect(siteId, body) {
822
- const parsed = shouldValidate("request") ? endpoints.requestIndexingInspect.body.parse(body) : body;
823
- return request(endpoints.requestIndexingInspect.path(siteId), {
824
- method: endpoints.requestIndexingInspect.method,
825
- body: parsed
826
- }, endpoints.requestIndexingInspect.response);
827
- },
828
- getUserSettings() {
829
- return request(endpoints.getUserSettings.path, { method: endpoints.getUserSettings.method }, endpoints.getUserSettings.response);
830
- },
831
- patchUserSettings(body) {
832
- const parsed = shouldValidate("request") ? endpoints.patchUserSettings.body.parse(body) : body;
833
- return request(endpoints.patchUserSettings.path, {
834
- method: endpoints.patchUserSettings.method,
835
- body: parsed
836
- }, endpoints.patchUserSettings.response);
837
- },
838
- recoverPermission(siteId) {
839
- return request(endpoints.recoverPermission.path(siteId), { method: endpoints.recoverPermission.method }, endpoints.recoverPermission.response);
840
- },
841
- getTopAssociation(siteId, params) {
842
- const query = shouldValidate("request") ? endpoints.getTopAssociation.query.parse(params) : params;
843
- return request(endpoints.getTopAssociation.path(siteId), {
844
- method: endpoints.getTopAssociation.method,
845
- query
846
- }, endpoints.getTopAssociation.response);
847
- },
848
- getKeywordSparklines(siteId, params) {
849
- const withSearchType = {
850
- ...params,
851
- searchType: params.searchType ?? "web"
852
- };
853
- const body = shouldValidate("request") ? endpoints.getKeywordSparklines.body.parse(withSearchType) : withSearchType;
854
- return request(endpoints.getKeywordSparklines.path(siteId), {
855
- method: endpoints.getKeywordSparklines.method,
856
- body,
857
- dedupe: true
858
- }, endpoints.getKeywordSparklines.response);
859
- },
860
- getQueryTrend(siteId, params) {
861
- const query = shouldValidate("request") ? endpoints.getQueryTrend.query.parse(params) : params;
862
- return request(endpoints.getQueryTrend.path(siteId), {
863
- method: endpoints.getQueryTrend.method,
864
- query: queryTrendQuery(query)
865
- }, endpoints.getQueryTrend.response);
866
- },
867
- getPageTrend(siteId, params) {
868
- const query = shouldValidate("request") ? endpoints.getPageTrend.query.parse(params) : params;
869
- return request(endpoints.getPageTrend.path(siteId), {
870
- method: endpoints.getPageTrend.method,
871
- query: pageTrendQuery(query)
872
- }, endpoints.getPageTrend.response);
873
- },
874
- getCanonicalMismatches(siteId) {
875
- return request(endpoints.getCanonicalMismatches.path(siteId), { method: endpoints.getCanonicalMismatches.method }, endpoints.getCanonicalMismatches.response);
876
- },
877
- getIndexPercent(siteId, params = {}) {
878
- const query = shouldValidate("request") ? endpoints.getIndexPercent.query.parse(params) : params;
879
- return request(endpoints.getIndexPercent.path(siteId), {
880
- method: endpoints.getIndexPercent.method,
881
- query
882
- }, endpoints.getIndexPercent.response);
883
- },
884
- createTeam(params) {
885
- const body = shouldValidate("request") ? endpoints.createTeam.body.parse(params) : params;
886
- return request(endpoints.createTeam.path, {
887
- method: endpoints.createTeam.method,
888
- body
889
- }, endpoints.createTeam.response);
890
- },
891
- renameTeam(teamId, params) {
892
- const body = shouldValidate("request") ? endpoints.renameTeam.body.parse(params) : params;
893
- return request(endpoints.renameTeam.path(teamId), {
894
- method: endpoints.renameTeam.method,
895
- body
896
- }, endpoints.renameTeam.response);
897
- },
898
- deleteTeam(teamId) {
899
- return request(endpoints.deleteTeam.path(teamId), { method: endpoints.deleteTeam.method }, endpoints.deleteTeam.response);
900
- },
901
- listTeamMembers(teamId) {
902
- return request(endpoints.listTeamMembers.path(teamId), { method: endpoints.listTeamMembers.method }, endpoints.listTeamMembers.response);
903
- },
904
- addTeamMember(teamId, params) {
905
- const body = shouldValidate("request") ? endpoints.addTeamMember.body.parse(params) : params;
906
- return request(endpoints.addTeamMember.path(teamId), {
907
- method: endpoints.addTeamMember.method,
908
- body
909
- }, endpoints.addTeamMember.response);
910
- },
911
- updateTeamMemberRole(teamId, userId, params) {
912
- const body = shouldValidate("request") ? endpoints.updateTeamMemberRole.body.parse(params) : params;
913
- return request(endpoints.updateTeamMemberRole.path(teamId, userId), {
914
- method: endpoints.updateTeamMemberRole.method,
915
- body
916
- }, endpoints.updateTeamMemberRole.response);
917
- },
918
- removeTeamMember(teamId, userId) {
919
- return request(endpoints.removeTeamMember.path(teamId, userId), { method: endpoints.removeTeamMember.method }, endpoints.removeTeamMember.response);
920
- },
921
- bindSiteToTeam(userId, siteId, params) {
922
- const body = shouldValidate("request") ? endpoints.bindSiteToTeam.body.parse(params) : params;
923
- return request(endpoints.bindSiteToTeam.path(userId, siteId), {
924
- method: endpoints.bindSiteToTeam.method,
925
- body
926
- }, endpoints.bindSiteToTeam.response);
927
- },
928
- getTeamCatalog(teamId) {
929
- return request(endpoints.getTeamCatalog.path(teamId), { method: endpoints.getTeamCatalog.method }, endpoints.getTeamCatalog.response);
930
- },
931
- bindTeamCatalog(teamId, params) {
932
- const body = shouldValidate("request") ? endpoints.bindTeamCatalog.body.parse(params) : params;
933
- return request(endpoints.bindTeamCatalog.path(teamId), {
934
- method: endpoints.bindTeamCatalog.method,
935
- body
936
- }, endpoints.bindTeamCatalog.response);
937
- }
938
- };
939
- }
940
- const COUNTRY_NAMES = {
941
- US: "United States",
942
- GB: "United Kingdom",
943
- DE: "Germany",
944
- FR: "France",
945
- CA: "Canada",
946
- AU: "Australia",
947
- IN: "India",
948
- BR: "Brazil",
949
- JP: "Japan",
950
- IT: "Italy",
951
- ES: "Spain",
952
- NL: "Netherlands",
953
- SE: "Sweden",
954
- CH: "Switzerland",
955
- MX: "Mexico",
956
- KR: "South Korea",
957
- RU: "Russia",
958
- PL: "Poland",
959
- BE: "Belgium",
960
- AT: "Austria",
961
- NO: "Norway",
962
- DK: "Denmark",
963
- FI: "Finland",
964
- PT: "Portugal",
965
- IE: "Ireland",
966
- NZ: "New Zealand",
967
- SG: "Singapore",
968
- HK: "Hong Kong",
969
- TW: "Taiwan",
970
- IL: "Israel",
971
- ZA: "South Africa",
972
- AR: "Argentina",
973
- CL: "Chile",
974
- CO: "Colombia",
975
- TH: "Thailand",
976
- PH: "Philippines",
977
- MY: "Malaysia",
978
- ID: "Indonesia",
979
- VN: "Vietnam",
980
- TR: "Turkey",
981
- CZ: "Czech Republic",
982
- RO: "Romania",
983
- HU: "Hungary",
984
- GR: "Greece",
985
- UA: "Ukraine",
986
- EG: "Egypt",
987
- NG: "Nigeria",
988
- KE: "Kenya",
989
- PK: "Pakistan",
990
- BD: "Bangladesh",
991
- AE: "United Arab Emirates",
992
- SA: "Saudi Arabia"
993
- };
994
- const countryName = (code) => COUNTRY_NAMES[code] || code;
995
- const CWV_GOOD_LCP = 2500;
996
- const CWV_POOR_LCP = 4e3;
997
- const CWV_GOOD_INP = 200;
998
- const CWV_POOR_INP = 500;
999
- const CWV_GOOD_CLS = .1;
1000
- const CWV_POOR_CLS = .25;
1001
- function cwvBucket(metric, v) {
1002
- if (metric === "lcp") return v <= 2500 ? "good" : v <= 4e3 ? "ni" : "poor";
1003
- if (metric === "inp") return v <= 200 ? "good" : v <= 500 ? "ni" : "poor";
1004
- return v <= .1 ? "good" : v <= .25 ? "ni" : "poor";
1005
- }
1006
- function truncateQuery(q, max = 48) {
1007
- return q.length > max ? `${q.slice(0, max - 1)}…` : q;
1008
- }
1009
- function siteUrlToHostname(url) {
1010
- if (!url) return void 0;
1011
- try {
1012
- return new URL(url.startsWith("http") ? url : `https://${url}`).hostname;
1013
- } catch {
1014
- return;
1015
- }
1016
- }
1017
- function gscConsoleUrl(opts) {
1018
- const base = "https://search.google.com/search-console";
1019
- const resource = opts.resource ?? "performance";
1020
- const params = new URLSearchParams();
1021
- const siteLabel = /^(?:https?:|sc-domain:)/.test(opts.siteLabel) ? opts.siteLabel : `sc-domain:${opts.siteLabel}`;
1022
- params.set("resource_id", siteLabel);
1023
- if (resource === "url-inspection") {
1024
- if (opts.page) params.set("id", opts.page);
1025
- } else {
1026
- if (opts.page) params.set("page", `*${opts.page}`);
1027
- if (opts.query) params.set("query", `*${opts.query}`);
1028
- }
1029
- return `${base}${resource === "performance" ? "/performance/search-analytics" : resource === "url-inspection" ? "/inspect" : resource === "sitemaps" ? "/sitemaps" : "/index"}?${params.toString()}`;
1030
- }
1031
- const GSC_STABLE_LATENCY_DAYS = 3;
1032
- function classifyGscError(e) {
1033
- const code = e?.statusCode ?? e?.status;
1034
- const message = extractMessage(e);
1035
- if (code === 401 || code === 403) return {
1036
- status: "auth-missing",
1037
- code,
1038
- message
1039
- };
1040
- if (code === 429 || code === 503) {
1041
- const data = e?.data;
1042
- const retry = data?.retryAfter ?? data?.retryAfterSeconds;
1043
- return {
1044
- status: code === 429 ? "rate-limited" : "network",
1045
- code,
1046
- message,
1047
- retryAfter: typeof retry === "number" ? retry : void 0
1048
- };
1049
- }
1050
- if (code == null && !isAbort(e)) return {
1051
- status: "network",
1052
- message
1053
- };
1054
- return {
1055
- status: "error",
1056
- code,
1057
- message
1058
- };
1059
- }
1060
- function extractMessage(e) {
1061
- if (!e || typeof e !== "object") return void 0;
1062
- const data = e.data;
1063
- if (data && typeof data === "object") {
1064
- const m = data.message ?? data.error;
1065
- if (typeof m === "string") return m;
1066
- }
1067
- const m = e.message;
1068
- return typeof m === "string" ? m : void 0;
1069
- }
1070
- function isAbort(e) {
1071
- return e?.name === "AbortError";
1072
- }
1073
- const PERIOD_PRESETS = [
1074
- {
1075
- value: "7d",
1076
- label: "Last 7 days",
1077
- shortLabel: "7d",
1078
- group: "rolling"
1079
- },
1080
- {
1081
- value: "28d",
1082
- label: "Last 28 days",
1083
- shortLabel: "28d",
1084
- group: "rolling"
1085
- },
1086
- {
1087
- value: "3m",
1088
- label: "Last 3 months",
1089
- shortLabel: "3m",
1090
- group: "rolling"
1091
- },
1092
- {
1093
- value: "6m",
1094
- label: "Last 6 months",
1095
- shortLabel: "6m",
1096
- group: "rolling"
1097
- },
1098
- {
1099
- value: "12m",
1100
- label: "Last 12 months",
1101
- shortLabel: "12m",
1102
- group: "rolling"
1103
- },
1104
- {
1105
- value: "this-week",
1106
- label: "This week",
1107
- shortLabel: "Week",
1108
- group: "calendar"
1109
- },
1110
- {
1111
- value: "this-month",
1112
- label: "This month",
1113
- shortLabel: "Month",
1114
- group: "calendar"
1115
- },
1116
- {
1117
- value: "last-month",
1118
- label: "Last month",
1119
- shortLabel: "Last mo",
1120
- group: "calendar"
1121
- },
1122
- {
1123
- value: "this-quarter",
1124
- label: "This quarter",
1125
- shortLabel: "Qtr",
1126
- group: "calendar"
1127
- },
1128
- {
1129
- value: "this-year",
1130
- label: "This year",
1131
- shortLabel: "Year",
1132
- group: "calendar"
1133
- }
1134
- ];
1135
- const COMPARE_OPTIONS = [
1136
- {
1137
- value: "previous",
1138
- label: "Previous period",
1139
- description: "Compare to the period immediately before"
1140
- },
1141
- {
1142
- value: "year",
1143
- label: "Year over year",
1144
- description: "Compare to the same period last year"
1145
- },
1146
- {
1147
- value: "none",
1148
- label: "No comparison",
1149
- description: "Disable comparison"
1150
- }
1151
- ];
1152
- const GSC_PERIOD_OPTIONS = PERIOD_PRESETS.filter((p) => p.group === "rolling").map((p) => ({
1153
- label: p.shortLabel,
1154
- value: p.value,
1155
- longLabel: p.label
1156
- }));
1157
- const GSC_PERIOD_OPTIONS_LONG = GSC_PERIOD_OPTIONS.map((o) => ({
1158
- label: o.longLabel,
1159
- value: o.value
1160
- }));
1161
- const GSC_COLUMN_OPTIONS = [
1162
- {
1163
- key: "clicks",
1164
- label: "Clicks",
1165
- icon: "i-lucide-mouse-pointer-click",
1166
- color: "blue"
1167
- },
1168
- {
1169
- key: "impressions",
1170
- label: "Views",
1171
- icon: "i-lucide-eye",
1172
- color: "purple"
1173
- },
1174
- {
1175
- key: "ctr",
1176
- label: "CTR",
1177
- icon: "i-lucide-percent",
1178
- color: "green"
1179
- },
1180
- {
1181
- key: "position",
1182
- label: "Pos",
1183
- icon: "i-lucide-hash",
1184
- color: "orange"
1185
- }
1186
- ];
1187
- function coerceRowMetrics(row) {
1188
- const position = row.position ?? 0;
1189
- return {
1190
- ...row,
1191
- sum_position: row.sum_position ?? (position > 0 ? (position - 1) * row.impressions : 0)
1192
- };
1193
- }
1194
- function summarizeDailyRows(raw) {
1195
- const daily = [];
1196
- for (let index = 0; index < raw.length; index++) {
1197
- const row = raw[index];
1198
- const position = row.position ?? 0;
1199
- const sumPosition = row.sum_position ?? (position > 0 ? (position - 1) * row.impressions : 0);
1200
- daily.push({
1201
- date: row.date,
1202
- clicks: row.clicks,
1203
- impressions: row.impressions,
1204
- sum_position: sumPosition
1205
- });
1206
- }
1207
- daily.sort((a, b) => a.date.localeCompare(b.date));
1208
- let clicks = 0;
1209
- let impressions = 0;
1210
- let weightedPosition = 0;
1211
- for (const row of daily) {
1212
- clicks += row.clicks;
1213
- impressions += row.impressions;
1214
- weightedPosition += row.sum_position;
1215
- }
1216
- return {
1217
- daily,
1218
- totals: {
1219
- clicks,
1220
- impressions,
1221
- ctr: impressions > 0 ? clicks / impressions : 0,
1222
- position: impressions > 0 ? weightedPosition / impressions + 1 : 0
1223
- },
1224
- chartData: daily.map((d) => ({
1225
- date: d.date,
1226
- clicks: d.clicks,
1227
- impressions: d.impressions
1228
- }))
1229
- };
1230
- }
1231
- function positionFor(r) {
1232
- return r.impressions > 0 ? r.sum_position / r.impressions + 1 : 0;
1233
- }
1234
- const issueDetails = {
1235
- crawled_not_indexed: {
1236
- description: "Google crawled these pages but decided not to add them to the index. This often means the content was deemed low-quality, duplicate, or not useful enough.",
1237
- fix: "Improve content quality and uniqueness. Add internal links pointing to these pages. Ensure they have clear, distinct value compared to other pages on your site."
1238
- },
1239
- discovered_not_indexed: {
1240
- description: "Google knows these URLs exist but hasn't crawled them yet. This is usually a crawl-budget or priority signal — Google deemed other pages more important.",
1241
- fix: "Add strong internal links from indexed pages. Submit the URL via Search Console's URL Inspection > Request Indexing. Improve site authority and crawl-budget signals; check no resource constraints (slow server, large response sizes) are deterring the crawl."
1242
- },
1243
- server_error: {
1244
- description: "Google encountered 5xx server errors when trying to crawl these URLs. The pages were unreachable at crawl time.",
1245
- fix: "Check your server logs for errors. Ensure your hosting can handle Googlebot traffic. Fix any backend issues causing 500/502/503 errors."
1246
- },
1247
- access_forbidden: {
1248
- description: "Your server returned 403 Forbidden to Googlebot. Usually a WAF, bot-protection rule, or firewall treating the crawler as an attacker — a human visitor may see the page fine.",
1249
- fix: "Allowlist Googlebot in your WAF / bot-protection rules and verify by IP, not user agent. Check Cloudflare bot-fight mode, rate limits, and any geo or ASN blocking. Re-test with the URL Inspection tool's \"Test live URL\"."
1250
- },
1251
- access_denied: {
1252
- description: "Your server returned 401 Unauthorized to Googlebot. The URL sits behind an authentication wall.",
1253
- fix: "If the page should rank, remove the auth requirement for crawlers or move the content to a public URL. If it is genuinely private, block it in robots.txt or noindex it so it stops being reported as an indexing failure."
1254
- },
1255
- blocked_4xx: {
1256
- description: "Google got a 4xx response other than 401, 403, or 404 — commonly 410 Gone, 429 Too Many Requests, or 451.",
1257
- fix: "Check which status the URL actually returns. A 429 means Googlebot is being rate-limited: raise or exempt the crawler limit. A 410 is intentional deletion and will clear on its own."
1258
- },
1259
- redirect_error: {
1260
- description: "Google could not follow the redirect — a redirect chain that is too long, a loop, an empty Location header, or a URL that exceeds the maximum length.",
1261
- fix: "Collapse redirect chains to a single hop. Look for loops (A→B→A) and self-redirects. Point internal links and sitemap entries at the final destination URL."
1262
- },
1263
- crawl_error: {
1264
- description: "Google hit an internal crawl error or considered the URL malformed. Often transient on Google's side, but a persistent count means the URL itself is invalid.",
1265
- fix: "Verify the URL parses and resolves. Remove malformed URLs from your sitemap and internal links. If the URLs are valid, re-inspect in a few days — transient crawl errors clear themselves."
1266
- },
1267
- sitemap_redirect: {
1268
- description: "These URLs are submitted in your sitemap but redirect elsewhere. A sitemap should only list final, canonical, 200-status URLs — a redirecting entry wastes crawl budget and tells Google your sitemap is stale.",
1269
- fix: "Replace each redirecting entry with its destination URL, or drop it from the sitemap entirely. Then resubmit the sitemap."
1270
- },
1271
- alternate_canonical: {
1272
- description: "These pages declare a canonical pointing at another page, and Google honoured it. The canonical target is what gets indexed. Usually intentional.",
1273
- fix: "Nothing to fix if the canonical is deliberate. If these pages should rank on their own, make each one self-canonical and give it genuinely distinct content."
1274
- },
1275
- duplicate_no_canonical: {
1276
- description: "Google decided these pages duplicate another URL and picked the canonical itself, because the page declared no preference. You did not choose which URL ranks — Google did.",
1277
- fix: "Add a canonical link to every page. Point it at itself for pages that should rank, or at the preferred URL for genuine duplicates. Leaving it unset hands the decision to Google."
1278
- },
1279
- page_removed: {
1280
- description: "These URLs are suppressed by a request in Search Console's Removals tool. The block is temporary — roughly six months — and then they become eligible again.",
1281
- fix: "If the removal was intentional, back it with a real signal: a noindex tag, a 404/410, or authentication. The removal tool alone does not keep a page out of Search permanently."
1282
- },
1283
- unknown_to_google: {
1284
- description: "These URLs exist on your site but Google hasn't discovered them yet. They may be orphaned pages or missing from your sitemap.",
1285
- fix: "Add these URLs to your sitemap. Create internal links to them from well-indexed pages. Submit the sitemap in Google Search Console."
1286
- },
1287
- stale_crawl: {
1288
- description: "Google hasn't re-crawled these pages in over 30 days. They may have low perceived value or your crawl budget may be exhausted.",
1289
- fix: "Update content on these pages to signal freshness. Improve internal linking. Ensure your site loads quickly to maximize crawl budget efficiency."
1290
- },
1291
- very_stale_crawl: {
1292
- description: "Google hasn't visited these pages in over 60 days. They are at risk of being dropped from the index entirely.",
1293
- fix: "Prioritize updating these pages immediately. Add fresh internal links. Consider requesting re-indexing via Google Search Console's URL Inspection tool."
1294
- },
1295
- not_found: {
1296
- description: "These URLs return 404 errors. Google previously knew about them but they no longer exist.",
1297
- fix: "If the content moved, add 301 redirects to the new URLs. If intentionally removed, ensure no internal links still point to them. The 404s will clear over time."
1298
- },
1299
- soft_404: {
1300
- description: "These pages return a 200 status but Google detects them as effectively empty or error pages — \"soft\" 404s.",
1301
- fix: "Return a proper 404 status code for missing pages. If the pages should exist, add meaningful content. Avoid thin placeholder pages."
1302
- },
1303
- blocked_robots: {
1304
- description: "Your robots.txt file is preventing Google from crawling these URLs.",
1305
- fix: "Review your robots.txt rules. Remove Disallow directives for pages you want indexed. Remember that blocked pages can't be indexed even if linked."
1306
- },
1307
- noindex: {
1308
- description: "These pages have a noindex meta tag or X-Robots-Tag header, telling Google not to include them in search results.",
1309
- fix: "If these pages should be indexed, remove the noindex directive. Check for noindex in meta tags, HTTP headers, and any SEO plugin configuration."
1310
- },
1311
- redirect: {
1312
- description: "These URLs redirect to other pages. Google follows the redirect and indexes the destination instead.",
1313
- fix: "This is usually expected behavior. Ensure redirects point to the correct destination. Update internal links to point directly to the final URL to save crawl budget."
1314
- },
1315
- canonical_mismatch: {
1316
- description: "The canonical URL declared on these pages points to a different URL. Google may index the canonical target instead.",
1317
- fix: "Ensure each page's canonical tag points to itself, or intentionally to the preferred version. Fix any unintended canonical tags added by CMS plugins."
1318
- },
1319
- fragment_url: {
1320
- description: "These URLs contain fragment identifiers (#). Googlebot typically ignores fragments as they're client-side only.",
1321
- fix: "Avoid using fragment URLs as unique pages. If using client-side routing with hashes, migrate to proper URL paths for better indexability."
1322
- },
1323
- not_indexed: {
1324
- description: "These URLs are not in Google's index. This is a general category — the specific reason may vary.",
1325
- fix: "Check individual URLs in Google Search Console's URL Inspection tool for specific reasons. Common causes include quality, duplicate content, or crawl issues."
1326
- }
1327
- };
1328
- const severityOrder = [
1329
- "error",
1330
- "warning",
1331
- "info"
1332
- ];
1333
- const issueGroups = [
1334
- {
1335
- id: "quick-wins",
1336
- label: "Quick Wins",
1337
- icon: "i-lucide-zap",
1338
- description: "Configuration changes you can make right now",
1339
- effort: "quick",
1340
- controlLevel: "full",
1341
- education: "These issues are caused by your site's configuration preventing Google from indexing certain pages. If these pages should be indexed, the fix is usually a one-line config change — remove a robots.txt rule, fix a canonical URL, or drop a redirecting entry from your sitemap. Highest-ROI fixes, zero content work.",
1342
- issueTypes: [
1343
- "blocked_robots",
1344
- "canonical_mismatch",
1345
- "duplicate_no_canonical",
1346
- "sitemap_redirect"
1347
- ]
1348
- },
1349
- {
1350
- id: "technical",
1351
- label: "Technical Fixes",
1352
- icon: "i-lucide-wrench",
1353
- description: "Server and URL issues to resolve",
1354
- effort: "moderate",
1355
- controlLevel: "full",
1356
- education: "These are infrastructure problems — your server is returning errors, blocking Googlebot at the edge, failing to follow redirects, or serving pages that look empty. Fix crawl blocks and server errors first (they cost crawl budget and can drop indexed pages), then handle 404s with redirects.",
1357
- issueTypes: [
1358
- "server_error",
1359
- "not_found",
1360
- "soft_404",
1361
- "access_forbidden",
1362
- "access_denied",
1363
- "blocked_4xx",
1364
- "redirect_error",
1365
- "crawl_error"
1366
- ]
1367
- },
1368
- {
1369
- id: "content-discovery",
1370
- label: "Content & Discovery",
1371
- icon: "i-lucide-file-search",
1372
- description: "Help Google find and value your pages",
1373
- effort: "involved",
1374
- controlLevel: "partial",
1375
- education: "Google found these pages but either didn't think they were worth indexing, or hasn't discovered them yet. For crawled-but-not-indexed pages, improving content quality and internal linking helps — but Google ultimately decides what to index. For undiscovered pages, adding them to your sitemap and linking to them from indexed pages is the fix.",
1376
- issueTypes: [
1377
- "crawled_not_indexed",
1378
- "discovered_not_indexed",
1379
- "unknown_to_google",
1380
- "stale_crawl",
1381
- "very_stale_crawl"
1382
- ]
1383
- },
1384
- {
1385
- id: "expected",
1386
- label: "Expected Behavior",
1387
- icon: "i-lucide-info",
1388
- description: "Usually intentional — review but likely fine",
1389
- effort: "quick",
1390
- controlLevel: "none",
1391
- education: "These aren't really \"issues\" — they're usually intentional. Noindex tags are set deliberately to keep pages out of search. Redirects are normal when you move pages. An alternate page with a proper canonical is consolidation working as designed. Fragment URLs are stripped by Google. Review to make sure nothing unexpected is here.",
1392
- issueTypes: [
1393
- "noindex",
1394
- "redirect",
1395
- "alternate_canonical",
1396
- "page_removed",
1397
- "fragment_url"
1398
- ]
1399
- }
1400
- ];
1401
- function isCustomPeriod(p) {
1402
- return typeof p === "string" && p.startsWith("custom:");
1403
- }
1404
- function parseCustomPeriod(p) {
1405
- if (!isCustomPeriod(p)) return null;
1406
- const [, start, end, prevStart, prevEnd] = p.split(":");
1407
- if (!start || !end) return null;
1408
- if (prevStart && prevEnd) return {
1409
- start,
1410
- end,
1411
- prevStart,
1412
- prevEnd
1413
- };
1414
- return {
1415
- start,
1416
- end
1417
- };
1418
- }
1419
- function todayInTimezone(timezone = "America/Los_Angeles", now = /* @__PURE__ */ new Date()) {
1420
- const date = new Intl.DateTimeFormat("en-CA", {
1421
- timeZone: timezone,
1422
- year: "numeric",
1423
- month: "2-digit",
1424
- day: "2-digit"
1425
- }).format(now);
1426
- return /* @__PURE__ */ new Date(`${date}T00:00:00`);
1427
- }
1428
- const ROLLING_TO_UPSTREAM = {
1429
- "7d": "last-7d",
1430
- "28d": "last-28d",
1431
- "3m": "last-90d",
1432
- "6m": "last-180d",
1433
- "12m": "last-365d"
1434
- };
1435
- const CALENDAR_TO_UPSTREAM = {
1436
- "this-month": "mtd",
1437
- "this-year": "ytd"
1438
- };
1439
- function fmt(d) {
1440
- return format(d, "yyyy-MM-dd");
1441
- }
1442
- function buildResultFromIso(start, end) {
1443
- const startDate = /* @__PURE__ */ new Date(`${start}T00:00:00`);
1444
- const endDate = /* @__PURE__ */ new Date(`${end}T00:00:00`);
1445
- const days = Math.round((endDate.getTime() - startDate.getTime()) / 864e5) + 1;
1446
- const prev = resolveWindow({
1447
- preset: "custom",
1448
- start,
1449
- end,
1450
- comparison: "prev-period"
1451
- });
1452
- const yoy = resolveWindow({
1453
- preset: "custom",
1454
- start,
1455
- end,
1456
- comparison: "yoy"
1457
- });
1458
- return {
1459
- start,
1460
- end,
1461
- prevStart: prev.comparison.start,
1462
- prevEnd: prev.comparison.end,
1463
- yearStart: yoy.comparison.start,
1464
- yearEnd: yoy.comparison.end,
1465
- days
1466
- };
1467
- }
1468
- function periodToDateRange(period, stableDataOrOptions = true) {
1469
- const options = typeof stableDataOrOptions === "boolean" ? { stableData: stableDataOrOptions } : stableDataOrOptions;
1470
- const stableData = options.stableData ?? true;
1471
- const custom = parseCustomPeriod(period);
1472
- if (custom) {
1473
- const result = buildResultFromIso(custom.start, custom.end);
1474
- if (custom.prevStart && custom.prevEnd) return {
1475
- ...result,
1476
- prevStart: custom.prevStart,
1477
- prevEnd: custom.prevEnd,
1478
- yearStart: custom.prevStart,
1479
- yearEnd: custom.prevEnd
1480
- };
1481
- return result;
1482
- }
1483
- const today = todayInTimezone(options.timezone, options.now);
1484
- const end = stableData ? subDays(today, 3) : subDays(today, 1);
1485
- const endIso = fmt(end);
1486
- const upstreamPreset = ROLLING_TO_UPSTREAM[period] ?? CALENDAR_TO_UPSTREAM[period];
1487
- if (upstreamPreset) {
1488
- const win = resolveWindow({
1489
- preset: upstreamPreset,
1490
- anchor: endIso
1491
- });
1492
- return buildResultFromIso(win.start, win.end);
1493
- }
1494
- let start;
1495
- switch (period) {
1496
- case "this-week":
1497
- start = startOfWeek(end, { weekStartsOn: 1 });
1498
- break;
1499
- case "last-month": {
1500
- const prevMonth = subMonths(end, 1);
1501
- return buildResultFromIso(fmt(startOfMonth(prevMonth)), fmt(endOfMonth(prevMonth)));
1502
- }
1503
- case "this-quarter":
1504
- start = startOfQuarter(end);
1505
- break;
1506
- default: start = subDays(end, 27);
1507
- }
1508
- return buildResultFromIso(fmt(start), endIso);
1509
- }
1510
- function periodToDays(period, stableDataOrOptions = true) {
1511
- return periodToDateRange(period, stableDataOrOptions).days;
1512
- }
1513
- function compareRange(range, mode) {
1514
- if (mode === "none") return null;
1515
- if (mode === "year") return {
1516
- start: range.yearStart,
1517
- end: range.yearEnd
1518
- };
1519
- return {
1520
- start: range.prevStart,
1521
- end: range.prevEnd
1522
- };
1523
- }
1524
- function getGscUnstableCutoffDate() {
1525
- const [y, m, d] = (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" }).split("-").map(Number);
1526
- const cutoff = new Date(y, m - 1, d - 3);
1527
- return `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`;
1528
- }
1529
- function formatSearchConsoleCount(value) {
1530
- return new Intl.NumberFormat("en").format(Math.max(0, Math.round(value)));
1531
- }
1532
- function countSearchConsoleIssues(issues, ...types) {
1533
- if (!issues?.length) return 0;
1534
- const wanted = new Set(types);
1535
- return issues.reduce((sum, issue) => sum + (wanted.has(issue.type) ? issue.count : 0), 0);
1536
- }
1537
- const KNOWN_SITE_TYPES = /* @__PURE__ */ new Set([
1538
- "saas",
1539
- "ecommerce",
1540
- "docs",
1541
- "blog",
1542
- "agency",
1543
- "portfolio",
1544
- "other"
1545
- ]);
1546
- function normalizeSiteType(raw) {
1547
- if (!raw) return "other";
1548
- const t = raw.toLowerCase().trim();
1549
- return KNOWN_SITE_TYPES.has(t) ? t : "other";
1550
- }
1551
- const SITE_TYPE_BASELINE = {
1552
- docs: {
1553
- label: "Documentation",
1554
- coverageMatters: false,
1555
- ctrExpectation: "low",
1556
- primaryGoal: "content"
1557
- },
1558
- blog: {
1559
- label: "Blog / content",
1560
- coverageMatters: false,
1561
- ctrExpectation: "low",
1562
- primaryGoal: "content"
1563
- },
1564
- portfolio: {
1565
- label: "Portfolio",
1566
- coverageMatters: false,
1567
- ctrExpectation: "low",
1568
- primaryGoal: "content"
1569
- },
1570
- saas: {
1571
- label: "SaaS",
1572
- coverageMatters: true,
1573
- ctrExpectation: "medium",
1574
- primaryGoal: "authority"
1575
- },
1576
- agency: {
1577
- label: "Agency",
1578
- coverageMatters: true,
1579
- ctrExpectation: "medium",
1580
- primaryGoal: "authority"
1581
- },
1582
- ecommerce: {
1583
- label: "Ecommerce",
1584
- coverageMatters: true,
1585
- ctrExpectation: "high",
1586
- primaryGoal: "coverage"
1587
- },
1588
- other: {
1589
- label: "General",
1590
- coverageMatters: true,
1591
- ctrExpectation: "medium",
1592
- primaryGoal: "content"
1593
- }
1594
- };
1595
- function siteTypeBaseline(raw) {
1596
- return SITE_TYPE_BASELINE[normalizeSiteType(raw)];
1597
- }
1598
- const MIN_CONFIDENT_PEERS = 3;
1599
- const LEADER_RATIO = 1.15;
1600
- const BEHIND_RATIO = .85;
1601
- function median(values) {
1602
- if (!values || values.length === 0) return null;
1603
- const xs = [...values].sort((a, b) => a - b);
1604
- const mid = Math.floor(xs.length / 2);
1605
- return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
1606
- }
1607
- function derivePeerStanding(input) {
1608
- const peerMedianDomainRank = median(input.competitorDomainRanks);
1609
- const peerMedianOrganicTraffic = median(input.competitorOrganicTraffic);
1610
- const peerCount = Math.max(input.competitorDomainRanks?.length ?? 0, input.competitorOrganicTraffic?.length ?? 0);
1611
- const confidence = peerCount === 0 ? "none" : peerCount >= MIN_CONFIDENT_PEERS ? "high" : "low";
1612
- const compare = (site, peer) => {
1613
- if (site == null || peer == null || peer === 0) return null;
1614
- const ratio = site / peer;
1615
- if (ratio >= LEADER_RATIO) return "leader";
1616
- if (ratio <= BEHIND_RATIO) return "behind";
1617
- return "on_par";
1618
- };
1619
- return {
1620
- standing: compare(input.siteDomainRank, peerMedianDomainRank) ?? compare(input.siteOrganicTraffic, peerMedianOrganicTraffic) ?? "unknown",
1621
- confidence,
1622
- peerMedianDomainRank,
1623
- peerMedianOrganicTraffic
1624
- };
1625
- }
1626
- const GOAL_HEADLINE = {
1627
- content: "publish & expand content",
1628
- authority: "build authority & backlinks",
1629
- conversion: "lift conversion & CTR",
1630
- coverage: "expand indexable catalogue"
1631
- };
1632
- function deriveSiteBaseline(rawType, peer = {}) {
1633
- const siteType = normalizeSiteType(rawType);
1634
- const baseline = SITE_TYPE_BASELINE[siteType];
1635
- const { standing, confidence, peerMedianDomainRank, peerMedianOrganicTraffic } = derivePeerStanding(peer);
1636
- const lever = GOAL_HEADLINE[baseline.primaryGoal];
1637
- return {
1638
- siteType,
1639
- baseline,
1640
- peerStanding: standing,
1641
- peerConfidence: confidence,
1642
- peerMedianDomainRank,
1643
- peerMedianOrganicTraffic,
1644
- recommendedGoal: standing === "behind" ? `Close the gap on peers — ${lever}` : standing === "leader" ? `Defend the lead — ${lever}` : standing === "on_par" ? `Pull ahead of peers — ${lever}` : `${baseline.label}: ${lever}`,
1645
- goalKind: baseline.primaryGoal
1646
- };
1647
- }
1648
- function totalSitemapErrors(sitemaps) {
1649
- return (sitemaps ?? []).reduce((sum, sitemap) => {
1650
- return sum + (sitemap.errors ?? 0) + (sitemap.lastError ? 1 : 0);
1651
- }, 0);
1652
- }
1653
- function signedPct1(value) {
1654
- return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
1655
- }
1656
- const SEARCH_CONSOLE_STAGES = {
1657
- not_connected: {
1658
- label: "Not connected",
1659
- severity: "neutral",
1660
- summary: "Search Console is not connected for this site yet.",
1661
- primaryAction: "Connect Google Search Console so we can read discovery, indexing, and performance data.",
1662
- nextStage: "waiting_for_data",
1663
- sprintFindingTypes: []
1664
- },
1665
- waiting_for_data: {
1666
- label: "Waiting for data",
1667
- severity: "info",
1668
- summary: "Search Console is connected, but there is not enough indexing data to diagnose the site yet.",
1669
- primaryAction: "Let the first sync finish, then make sure a sitemap is submitted.",
1670
- nextStage: "weak_discovery",
1671
- sprintFindingTypes: []
1672
- },
1673
- weak_discovery: {
1674
- label: "Weak discovery",
1675
- severity: "warning",
1676
- summary: "Google does not have a clean map of the pages you want indexed.",
1677
- primaryAction: "Submit a clean sitemap that contains only canonical, indexable 200 URLs.",
1678
- nextStage: "discovery_backlog",
1679
- sprintFindingTypes: ["search-console-stage", "sitemap-missing"]
1680
- },
1681
- discovery_backlog: {
1682
- label: "Discovery backlog",
1683
- severity: "warning",
1684
- summary: "Google knows these pages exist, but is not crawling them fast enough.",
1685
- primaryAction: "Add internal links from indexed pages and remove low-value URLs from the sitemap.",
1686
- nextStage: "crawl_blocked",
1687
- sprintFindingTypes: ["search-console-stage"]
1688
- },
1689
- crawl_blocked: {
1690
- label: "Crawl blocked",
1691
- severity: "error",
1692
- summary: "Google is trying to access pages, but technical access problems are blocking progress.",
1693
- primaryAction: "Fix robots.txt blocks, server errors, broken URLs, and access failures before content work.",
1694
- nextStage: "indexability_blocked",
1695
- sprintFindingTypes: ["search-console-stage", "noindex-block"]
1696
- },
1697
- indexability_blocked: {
1698
- label: "Indexability blocked",
1699
- severity: "error",
1700
- summary: "Google can reach pages, but index directives or canonical signals are preventing clean indexing.",
1701
- primaryAction: "Remove accidental noindex directives and make canonical signals agree.",
1702
- nextStage: "index_rejection",
1703
- sprintFindingTypes: [
1704
- "search-console-stage",
1705
- "noindex-block",
1706
- "canonicalisation"
1707
- ]
1708
- },
1709
- index_rejection: {
1710
- label: "Index rejection",
1711
- severity: "warning",
1712
- summary: "Google is crawling pages but skipping too many of them from the index.",
1713
- primaryAction: "Improve or consolidate crawled-but-not-indexed pages before publishing more.",
1714
- nextStage: "partially_indexed",
1715
- sprintFindingTypes: ["search-console-stage", "pages-not-indexed"]
1716
- },
1717
- partially_indexed: {
1718
- label: "Partially indexed",
1719
- severity: "warning",
1720
- summary: "A meaningful share of the site is indexed, but coverage is still below a healthy level.",
1721
- primaryAction: "Work through the largest remaining indexing blocker first.",
1722
- nextStage: "indexed_invisible",
1723
- sprintFindingTypes: ["search-console-stage", "pages-not-indexed"]
1724
- },
1725
- indexed_invisible: {
1726
- label: "Indexed but invisible",
1727
- severity: "warning",
1728
- summary: "Pages are indexed, but too many are not earning impressions in Search.",
1729
- primaryAction: "Improve query targeting, titles, headings, internal links, and page depth.",
1730
- nextStage: "visible_not_clicked",
1731
- sprintFindingTypes: ["search-console-stage"]
1732
- },
1733
- visible_not_clicked: {
1734
- label: "Visible but not clicked",
1735
- severity: "warning",
1736
- summary: "Google is showing your pages, but searchers are not clicking often enough.",
1737
- primaryAction: "Rewrite titles and descriptions for the queries already producing impressions.",
1738
- nextStage: "ranking_stalled",
1739
- sprintFindingTypes: ["search-console-stage", "ctr-outliers"]
1740
- },
1741
- ranking_stalled: {
1742
- label: "Ranking but stalled",
1743
- severity: "info",
1744
- summary: "The site has search visibility, but many pages are not yet ranking in useful positions.",
1745
- primaryAction: "Prioritise striking-distance pages, refresh content, and add internal links.",
1746
- nextStage: "healthy_growth_ready",
1747
- sprintFindingTypes: ["striking-distance", "internal-linking"]
1748
- },
1749
- declining_visibility: {
1750
- label: "Declining visibility",
1751
- severity: "error",
1752
- summary: "Search visibility is dropping compared with the previous period.",
1753
- primaryAction: "Review affected pages, recent releases, competitors, and SERP changes before expanding.",
1754
- nextStage: "healthy_growth_ready",
1755
- sprintFindingTypes: ["search-console-stage", "negative-movers"]
1756
- },
1757
- healthy_growth_ready: {
1758
- label: "Healthy",
1759
- severity: "success",
1760
- summary: "Google can discover, index, and show your pages.",
1761
- primaryAction: "Push striking-distance pages (positions 11 to 20) and close content gaps to grow impressions.",
1762
- nextStage: null,
1763
- sprintFindingTypes: ["striking-distance", "competitor-content-gap"]
1764
- }
1765
- };
1766
- function stage(key, evidence) {
1767
- const definition = SEARCH_CONSOLE_STAGES[key];
1768
- return {
1769
- key,
1770
- evidence,
1771
- ...definition,
1772
- sprintFindingTypes: [...definition.sprintFindingTypes]
1773
- };
1774
- }
1775
- const ESTABLISHED_IMPRESSIONS_28D$1 = 2e4;
1776
- const NASCENT_IMPRESSIONS_28D$1 = 1e3;
1777
- const GROWTH_CLICKS_PCT_90D = 10;
1778
- const GROWTH_IMPRESSIONS_PCT_90D = 20;
1779
- const DECLINE_CLICKS_PCT$1 = -10;
1780
- const MIN_DECLINE_PRIOR_CLICKS$1 = 50;
1781
- function classifySearchConsoleStage(input) {
1782
- const issues = input.issues ?? [];
1783
- const summary = input.summary ?? null;
1784
- const sitemaps = input.sitemaps ?? [];
1785
- const totalUrls = summary?.totalUrls ?? 0;
1786
- const indexed = summary?.indexed ?? 0;
1787
- const indexedPercent = summary?.indexedPercent ?? 0;
1788
- const notIndexed = Math.max(0, totalUrls - indexed);
1789
- if (!input.connected) return stage("not_connected", [{
1790
- label: "Connection",
1791
- value: "Not connected",
1792
- source: "connection"
1793
- }]);
1794
- if (input.indexingStatus === "pending" || !summary || totalUrls === 0) return stage("waiting_for_data", [{
1795
- label: "Indexing sync",
1796
- value: input.indexingStatus === "pending" ? "Pending" : "No URLs yet",
1797
- source: "indexing"
1798
- }]);
1799
- const sitemapErrors = totalSitemapErrors(sitemaps);
1800
- const unknown = countSearchConsoleIssues(issues, "unknown_to_google");
1801
- const discovered = countSearchConsoleIssues(issues, "discovered_not_indexed");
1802
- const crawled = countSearchConsoleIssues(issues, "crawled_not_indexed");
1803
- const hardBlocks = countSearchConsoleIssues(issues, "blocked_robots", "server_error", "access_denied", "forbidden") + (input.crawlAuditBlockerCount ?? 0);
1804
- const canonicalMismatches = Math.min(notIndexed, input.canonicalMismatchCount ?? countSearchConsoleIssues(issues, "canonical_mismatch"));
1805
- let visibleNoClickPages = 0;
1806
- let poorPositionPages = 0;
1807
- for (const page of input.pageInventory ?? []) {
1808
- if (page.impressions < 50) continue;
1809
- if (page.clicks === 0) visibleNoClickPages++;
1810
- if ((page.position ?? 0) > 20) poorPositionPages++;
1811
- }
1812
- const ctrOutlierCount = input.ctrOutlierCount ?? 0;
1813
- const traj = input.trajectory ?? null;
1814
- const impressions28d = input.impressions28d ?? null;
1815
- const clicks90d = traj?.clicksPct90d ?? null;
1816
- const imp90d = traj?.impressionsPct90d ?? null;
1817
- const posDelta90d = traj?.positionDelta90d ?? null;
1818
- const clicks28d = traj?.clicksPct28d ?? null;
1819
- const clicksPrior28d = traj?.clicksPrior28d ?? null;
1820
- const isGrowing = clicks90d != null && clicks90d > GROWTH_CLICKS_PCT_90D || imp90d != null && imp90d > GROWTH_IMPRESSIONS_PCT_90D && posDelta90d != null && posDelta90d < 0;
1821
- const isDeclining = clicks90d != null && clicks90d < DECLINE_CLICKS_PCT$1 && clicks28d != null && clicks28d < DECLINE_CLICKS_PCT$1 && clicksPrior28d != null && clicksPrior28d >= MIN_DECLINE_PRIOR_CLICKS$1;
1822
- const isNascent = impressions28d != null && impressions28d < NASCENT_IMPRESSIONS_28D$1;
1823
- const isEstablished = impressions28d != null && impressions28d >= ESTABLISHED_IMPRESSIONS_28D$1;
1824
- if (hardBlocks > Math.max(10, totalUrls * .05)) return stage("crawl_blocked", [{
1825
- label: "Crawl / on-page faults",
1826
- value: formatSearchConsoleCount(hardBlocks),
1827
- source: "indexing"
1828
- }, {
1829
- label: "Indexed pages",
1830
- value: `${formatSearchConsoleCount(indexed)} of ${formatSearchConsoleCount(totalUrls)}`,
1831
- source: "indexing"
1832
- }]);
1833
- if (isDeclining) return stage("declining_visibility", [{
1834
- label: "Clicks 90d",
1835
- value: `${clicks90d.toFixed(1)}%`,
1836
- source: "performance"
1837
- }, {
1838
- label: "Clicks 28d",
1839
- value: `${clicks28d.toFixed(1)}%`,
1840
- source: "performance"
1841
- }]);
1842
- if (isGrowing) return stage("healthy_growth_ready", [
1843
- ...hardBlocks > 0 ? [{
1844
- label: "Critical blockers",
1845
- value: formatSearchConsoleCount(hardBlocks),
1846
- source: "indexing"
1847
- }] : [],
1848
- ...clicks90d != null ? [{
1849
- label: "Clicks 90d",
1850
- value: signedPct1(clicks90d),
1851
- source: "performance"
1852
- }] : [],
1853
- ...imp90d != null ? [{
1854
- label: "Impressions 90d",
1855
- value: signedPct1(imp90d),
1856
- source: "performance"
1857
- }] : [],
1858
- ...(input.recoverableBacklinkCount ?? 0) > 0 ? [{
1859
- label: "Recoverable backlinks",
1860
- value: formatSearchConsoleCount(input.recoverableBacklinkCount),
1861
- source: "performance"
1862
- }] : [],
1863
- ...(input.competitorGapCount ?? 0) > 0 ? [{
1864
- label: "Competitor content gaps",
1865
- value: formatSearchConsoleCount(input.competitorGapCount),
1866
- source: "performance"
1867
- }] : []
1868
- ]);
1869
- if (crawled > Math.max(10, totalUrls * .3) && totalUrls > 500) return stage("index_rejection", [{
1870
- label: "Crawled, not indexed",
1871
- value: formatSearchConsoleCount(crawled),
1872
- source: "indexing"
1873
- }, {
1874
- label: "Not indexed",
1875
- value: formatSearchConsoleCount(notIndexed),
1876
- source: "indexing"
1877
- }]);
1878
- if (isNascent) return stage("waiting_for_data", [{
1879
- label: "Impressions (28d)",
1880
- value: formatSearchConsoleCount(impressions28d ?? 0),
1881
- source: "performance"
1882
- }, {
1883
- label: "Indexed pages",
1884
- value: `${formatSearchConsoleCount(indexed)} of ${formatSearchConsoleCount(totalUrls)}`,
1885
- source: "indexing"
1886
- }]);
1887
- if (!isEstablished && (sitemaps.length === 0 || sitemapErrors > 0 || unknown > Math.max(5, totalUrls * .1))) return stage("weak_discovery", [{
1888
- label: "Sitemaps",
1889
- value: sitemaps.length === 0 ? "None registered" : `${formatSearchConsoleCount(sitemapErrors)} errors`,
1890
- source: "sitemap"
1891
- }, ...unknown > 0 ? [{
1892
- label: "Unknown URLs",
1893
- value: formatSearchConsoleCount(unknown),
1894
- source: "indexing"
1895
- }] : []]);
1896
- if (discovered > Math.max(10, totalUrls * .15)) return stage("discovery_backlog", [{
1897
- label: "Discovered, not crawled",
1898
- value: formatSearchConsoleCount(discovered),
1899
- source: "indexing"
1900
- }, {
1901
- label: "Indexed pages",
1902
- value: `${indexedPercent.toFixed(1)}%`,
1903
- source: "indexing"
1904
- }]);
1905
- if (canonicalMismatches > Math.max(5, totalUrls * .05)) return stage("indexability_blocked", [{
1906
- label: "Canonical mismatches",
1907
- value: formatSearchConsoleCount(canonicalMismatches),
1908
- source: "canonical"
1909
- }]);
1910
- const lowCtrType = siteTypeBaseline(input.siteType).ctrExpectation === "low";
1911
- const noClickShare = lowCtrType ? .5 : .25;
1912
- const noClickTrigger = visibleNoClickPages >= Math.max(1, (input.pageInventory?.length ?? 0) * noClickShare);
1913
- if (ctrOutlierCount > 0 && !lowCtrType || noClickTrigger) return stage("visible_not_clicked", [...ctrOutlierCount > 0 ? [{
1914
- label: "CTR outliers",
1915
- value: formatSearchConsoleCount(ctrOutlierCount),
1916
- source: "performance"
1917
- }] : [], ...visibleNoClickPages > 0 ? [{
1918
- label: "Visible, no clicks",
1919
- value: formatSearchConsoleCount(visibleNoClickPages),
1920
- source: "performance"
1921
- }] : []]);
1922
- if (poorPositionPages >= Math.max(1, (input.pageInventory?.length ?? 0) * .25)) return stage("ranking_stalled", [{
1923
- label: "Low-ranking visible pages",
1924
- value: formatSearchConsoleCount(poorPositionPages),
1925
- source: "performance"
1926
- }]);
1927
- return stage("healthy_growth_ready", [{
1928
- label: "Indexed",
1929
- value: `${indexedPercent.toFixed(1)}%`,
1930
- source: "indexing"
1931
- }, {
1932
- label: "Critical blockers",
1933
- value: formatSearchConsoleCount(hardBlocks),
1934
- source: "indexing"
1935
- }]);
1936
- }
1937
- const NASCENT_IMPRESSIONS_28D = 1e3;
1938
- const ESTABLISHED_IMPRESSIONS_28D = 2e4;
1939
- const LIFETIME_FLOOR = 500;
1940
- const GROWTH_CLICKS_PCT = 10;
1941
- const GROWTH_IMPRESSIONS_PCT = 20;
1942
- const DECLINE_CLICKS_PCT = -10;
1943
- const MIN_DECLINE_PRIOR_CLICKS = 50;
1944
- const FADED_LIVENESS = .2;
1945
- const DECAY_CTR = .005;
1946
- const HARD_BLOCK_FLOOR = 10;
1947
- const HARD_BLOCK_SHARE = .03;
1948
- const REJECT_SHARE = .3;
1949
- const REJECT_MIN = 50;
1950
- function reachLivenessRatio(daily) {
1951
- if (!daily || daily.length < 14) return null;
1952
- const series = daily.map((d) => d.impressions);
1953
- while (series.length && series[series.length - 1] === 0) series.pop();
1954
- if (series.length < 14) return null;
1955
- const rolling7 = (end) => {
1956
- let sum = 0;
1957
- for (let i = Math.max(0, end - 6); i <= end; i++) sum += series[i];
1958
- return sum;
1959
- };
1960
- const latestWeek = rolling7(series.length - 1);
1961
- let peakWeek = 0;
1962
- for (let i = 6; i < series.length; i++) peakWeek = Math.max(peakWeek, rolling7(i));
1963
- return peakWeek > 0 ? latestWeek / peakWeek : null;
1964
- }
1965
- function clamp01(n) {
1966
- if (!Number.isFinite(n)) return 0;
1967
- return Math.min(1, Math.max(0, n));
1968
- }
1969
- function pctStr(n) {
1970
- return `${n >= 0 ? "+" : ""}${Math.round(n)}%`;
1971
- }
1972
- function advance(nextStage, metric, value, target, gapLabel) {
1973
- return {
1974
- nextStage,
1975
- metric,
1976
- value,
1977
- target,
1978
- pct: clamp01(value / target),
1979
- gapLabel,
1980
- direction: "advance"
1981
- };
1982
- }
1983
- function escapeToward(nextStage, metric, value, target, gapLabel) {
1984
- return {
1985
- nextStage,
1986
- metric,
1987
- value,
1988
- target,
1989
- pct: clamp01(value / target),
1990
- gapLabel,
1991
- direction: "escape"
1992
- };
1993
- }
1994
- function escapeReduce(nextStage, metric, value, target, gapLabel) {
1995
- return {
1996
- nextStage,
1997
- metric,
1998
- value,
1999
- target,
2000
- pct: target <= 0 ? value <= 0 ? 1 : 0 : clamp01(2 - value / target),
2001
- gapLabel,
2002
- direction: "escape"
2003
- };
2004
- }
2005
- function sustain(metric, value, gapLabel) {
2006
- return {
2007
- nextStage: null,
2008
- metric,
2009
- value,
2010
- target: value,
2011
- pct: 1,
2012
- gapLabel,
2013
- direction: "sustain"
2014
- };
2015
- }
2016
- const HEALTH_COPY = {
2017
- healthy: {
2018
- summary: "Google can crawl and index the pages that should be indexed.",
2019
- primaryAction: "No indexing cleanup needed — focus on reach."
2020
- },
2021
- crawl_faults: {
2022
- summary: "Real access faults (server errors / broken links) are capping otherwise-indexable pages.",
2023
- primaryAction: "Fix the 5xx and broken URLs; return 410/404 for pages you retire on purpose."
2024
- },
2025
- quality_rejection: {
2026
- summary: "Google is crawling pages and refusing to index them — a soft quality signal it never reports explicitly.",
2027
- primaryAction: "Consolidate or improve the rejected pages, or noindex the thin/low-value set."
2028
- }
2029
- };
2030
- function isIntentionalRetirementSite(input, notFound, serverError) {
2031
- const type = normalizeSiteType(input.siteType);
2032
- const typeMatches = type === "agency" || type === "portfolio" || type === "other";
2033
- const fingerprint = notFound > 50 && serverError <= Math.max(5, notFound * .1);
2034
- return typeMatches && fingerprint;
2035
- }
2036
- function classifyHealthStage(input) {
2037
- const issues = input.issues ?? [];
2038
- const totalUrls = input.totalUrls ?? 0;
2039
- const noindex = countSearchConsoleIssues(issues, "noindex");
2040
- const notFound = countSearchConsoleIssues(issues, "not_found");
2041
- const softFound = countSearchConsoleIssues(issues, "soft_404");
2042
- const serverError = countSearchConsoleIssues(issues, "server_error", "blocked_robots", "access_denied", "forbidden");
2043
- const crawledNotIndexed = countSearchConsoleIssues(issues, "crawled_not_indexed");
2044
- const intentionalDead = isIntentionalRetirementSite(input, notFound, serverError) ? notFound : 0;
2045
- const indexableUrls = Math.max(1, totalUrls - noindex - intentionalDead);
2046
- const hardBlocks = serverError + (input.crawlAuditBlockerCount ?? 0);
2047
- const faultTarget = Math.max(HARD_BLOCK_FLOOR, indexableUrls * HARD_BLOCK_SHARE);
2048
- if (hardBlocks > faultTarget) {
2049
- const toFix = Math.ceil(hardBlocks - faultTarget);
2050
- return {
2051
- stage: "crawl_faults",
2052
- ...HEALTH_COPY.crawl_faults,
2053
- evidence: [{
2054
- label: "Access faults (5xx / broken)",
2055
- value: formatSearchConsoleCount(hardBlocks)
2056
- }],
2057
- progression: escapeReduce("healthy", "access faults", hardBlocks, faultTarget, `${formatSearchConsoleCount(hardBlocks)} faults — fix ~${formatSearchConsoleCount(toFix)} to clear the gate`)
2058
- };
2059
- }
2060
- const rejectPool = crawledNotIndexed + softFound;
2061
- if (totalUrls > 0 && rejectPool > REJECT_MIN && rejectPool / totalUrls >= REJECT_SHARE) {
2062
- const share = rejectPool / totalUrls;
2063
- const toClear = Math.max(0, Math.ceil(rejectPool - REJECT_SHARE * totalUrls));
2064
- return {
2065
- stage: "quality_rejection",
2066
- ...HEALTH_COPY.quality_rejection,
2067
- evidence: [{
2068
- label: "Crawled, then refused",
2069
- value: formatSearchConsoleCount(rejectPool)
2070
- }, {
2071
- label: "Share of known URLs",
2072
- value: `${(share * 100).toFixed(0)}%`
2073
- }],
2074
- progression: escapeReduce("healthy", "crawled-rejected share", share, REJECT_SHARE, `${(share * 100).toFixed(0)}% rejected — improve/consolidate ~${formatSearchConsoleCount(toClear)} pages to clear`)
2075
- };
2076
- }
2077
- return {
2078
- stage: "healthy",
2079
- ...HEALTH_COPY.healthy,
2080
- evidence: [],
2081
- progression: sustain("indexing health", 1, "Indexable pages are getting indexed — no gate blocking reach.")
2082
- };
2083
- }
2084
- const REACH_COPY = {
2085
- waiting_for_data: {
2086
- summary: "Too new to diagnose — not enough search history yet.",
2087
- primaryAction: "Submit a clean sitemap, add internal links, and give it time."
2088
- },
2089
- emerging: {
2090
- summary: "Early but climbing — real impressions are starting to land.",
2091
- primaryAction: "Keep publishing on the themes already gaining impressions."
2092
- },
2093
- growing: {
2094
- summary: "Discoverable, indexed-enough, and trending up. The next work is expansion, not cleanup.",
2095
- primaryAction: "Expand: striking-distance pages, content gaps, authority."
2096
- },
2097
- plateaued: {
2098
- summary: "Established but flat — visibility is steady, not compounding.",
2099
- primaryAction: "Refresh top pages and open a new content cluster to restart growth."
2100
- },
2101
- declining: {
2102
- summary: "Established visibility is genuinely falling versus the previous period.",
2103
- primaryAction: "Investigate the losing pages, recent releases, and competitor moves before expanding."
2104
- },
2105
- faded: {
2106
- summary: "The site had real reach that has collapsed — it spiked and is now near-invisible in search.",
2107
- primaryAction: "Diagnose the drop (quality, deindexing, lost rankings) — the 90-day total hides it; look at the recent week."
2108
- },
2109
- decayed: {
2110
- summary: "Still shows for old terms but earns almost no clicks — the content has aged out of relevance.",
2111
- primaryAction: "Refresh the decayed top pages, or mark the site low-priority if it is no longer maintained."
2112
- }
2113
- };
2114
- function reach(stage, evidence, progression) {
2115
- return {
2116
- stage,
2117
- ...REACH_COPY[stage],
2118
- evidence,
2119
- progression
2120
- };
2121
- }
2122
- function classifyReachStage(input) {
2123
- const imp28d = input.impressions28d ?? 0;
2124
- const imp12m = input.impressions12m ?? null;
2125
- const clicks28d = input.clicks28d ?? null;
2126
- const clicks90dPct = input.clicksPct90d ?? null;
2127
- const clicks28dPct = input.clicksPct28d ?? null;
2128
- const priorClicks = input.clicksPrior28d ?? null;
2129
- const imp90dPct = input.impressionsPct90d ?? null;
2130
- const posDelta = input.positionDelta90d ?? null;
2131
- const liveness = input.livenessRatio ?? null;
2132
- if (imp12m != null && imp12m < LIFETIME_FLOOR) return reach("waiting_for_data", [{
2133
- label: "Impressions (12m)",
2134
- value: formatSearchConsoleCount(imp12m)
2135
- }], advance("emerging", "lifetime impressions", imp12m, LIFETIME_FLOOR, `${formatSearchConsoleCount(imp12m)} of ${formatSearchConsoleCount(LIFETIME_FLOOR)} lifetime impressions — collecting data, keep indexing`));
2136
- const hadRealReach = (imp12m ?? imp28d) > LIFETIME_FLOOR;
2137
- const isGrowing = clicks90dPct != null && clicks90dPct > GROWTH_CLICKS_PCT || imp90dPct != null && imp90dPct > GROWTH_IMPRESSIONS_PCT && posDelta != null && posDelta < 0;
2138
- if (hadRealReach && liveness != null && liveness < FADED_LIVENESS && !isGrowing) return reach("faded", [{
2139
- label: "Recent week vs peak",
2140
- value: `${(liveness * 100).toFixed(0)}%`
2141
- }], escapeToward("growing", "recent week vs peak (liveness)", liveness, .5, `recent week is ${(liveness * 100).toFixed(0)}% of peak — revive toward ~50%`));
2142
- if (clicks90dPct != null && clicks90dPct < DECLINE_CLICKS_PCT && clicks28dPct != null && clicks28dPct < DECLINE_CLICKS_PCT && priorClicks != null && priorClicks >= MIN_DECLINE_PRIOR_CLICKS) return reach("declining", [{
2143
- label: "Clicks 90d",
2144
- value: pctStr(clicks90dPct)
2145
- }, {
2146
- label: "Clicks 28d",
2147
- value: pctStr(clicks28dPct)
2148
- }], {
2149
- nextStage: "plateaued",
2150
- metric: "90d clicks change",
2151
- value: clicks90dPct,
2152
- target: DECLINE_CLICKS_PCT,
2153
- pct: clamp01(2 - Math.abs(clicks90dPct) / Math.abs(DECLINE_CLICKS_PCT)),
2154
- gapLabel: `down ${pctStr(clicks90dPct)} (90d) — recover clicks above ${pctStr(DECLINE_CLICKS_PCT)} to exit`,
2155
- direction: "escape"
2156
- });
2157
- if (isGrowing) {
2158
- const clickGrowth = clicks90dPct != null && clicks90dPct > GROWTH_CLICKS_PCT;
2159
- const growthPct = clickGrowth ? clicks90dPct : imp90dPct ?? clicks90dPct ?? 0;
2160
- const growthMetric = clickGrowth ? "90d clicks growth" : "90d impressions growth";
2161
- const growthLabel = clickGrowth ? `${pctStr(clicks90dPct)} 90d clicks — comfortably growing; defend & expand` : `${pctStr(growthPct)} 90d impressions — comfortably growing; defend & expand`;
2162
- return reach("growing", [...clicks90dPct != null ? [{
2163
- label: "Clicks 90d",
2164
- value: pctStr(clicks90dPct)
2165
- }] : [], ...imp90dPct != null ? [{
2166
- label: "Impressions 90d",
2167
- value: pctStr(imp90dPct)
2168
- }] : []], sustain(growthMetric, growthPct, growthLabel));
2169
- }
2170
- const ctr = clicks28d != null && imp28d > 0 ? clicks28d / imp28d : null;
2171
- if (hadRealReach && imp28d >= NASCENT_IMPRESSIONS_28D && ctr != null && ctr < DECAY_CTR) return reach("decayed", [{
2172
- label: "Impressions (28d)",
2173
- value: formatSearchConsoleCount(imp28d)
2174
- }, {
2175
- label: "Clicks (28d)",
2176
- value: formatSearchConsoleCount(clicks28d ?? 0)
2177
- }], escapeToward("growing", "CTR (clicks/impressions)", ctr, DECAY_CTR, `${(ctr * 100).toFixed(2)}% CTR on ${formatSearchConsoleCount(imp28d)} impressions — refresh content toward ~${(DECAY_CTR * 100).toFixed(1)}%`));
2178
- const growthVal = clicks90dPct ?? 0;
2179
- const growthGap = Math.max(0, GROWTH_CLICKS_PCT - growthVal);
2180
- const growthProgression = (stage) => advance("growing", "90d clicks growth", growthVal, GROWTH_CLICKS_PCT, clicks90dPct != null ? `${pctStr(growthVal)} 90d clicks — need ${pctStr(growthGap)} more to clear the +${GROWTH_CLICKS_PCT}% growth bar` : `${stage === "plateaued" ? "flat" : "rising"} — reach +${GROWTH_CLICKS_PCT}% 90d clicks growth to break into growing`);
2181
- if (imp28d >= ESTABLISHED_IMPRESSIONS_28D) return reach("plateaued", [{
2182
- label: "Impressions (28d)",
2183
- value: formatSearchConsoleCount(imp28d)
2184
- }], growthProgression("plateaued"));
2185
- if (imp28d < NASCENT_IMPRESSIONS_28D) return reach("emerging", [{
2186
- label: "Impressions (28d)",
2187
- value: formatSearchConsoleCount(imp28d)
2188
- }], growthProgression("emerging"));
2189
- return reach("plateaued", [{
2190
- label: "Impressions (28d)",
2191
- value: formatSearchConsoleCount(imp28d)
2192
- }], growthProgression("plateaued"));
2193
- }
2194
- function classifySiteTriage(input) {
2195
- const health = classifyHealthStage(input);
2196
- return {
2197
- reach: classifyReachStage(input),
2198
- health,
2199
- headline: health.stage === "healthy" ? "reach" : "health"
2200
- };
2201
- }
2202
- const encoder = new TextEncoder();
2203
- function toPayloadString(payload) {
2204
- return typeof payload === "string" ? payload : JSON.stringify(payload);
2205
- }
2206
- function hexToBytes(hex) {
2207
- if (!/^[\da-f]+$/i.test(hex) || hex.length % 2 !== 0) return null;
2208
- const bytes = new Uint8Array(hex.length / 2);
2209
- for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
2210
- return bytes;
2211
- }
2212
- function constantTimeEqual(a, b) {
2213
- if (a.length !== b.length) return false;
2214
- let diff = 0;
2215
- for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
2216
- return diff === 0;
2217
- }
2218
- function signatureHex(signature) {
2219
- const trimmed = signature.trim();
2220
- return trimmed.startsWith("sha256=") ? trimmed.slice(7) : null;
2221
- }
2222
- async function hmacSha256(payload, secret) {
2223
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
2224
- name: "HMAC",
2225
- hash: "SHA-256"
2226
- }, false, ["sign"]);
2227
- return crypto.subtle.sign("HMAC", key, encoder.encode(payload));
2228
- }
2229
- async function verifyWebhookSignature(payload, signature, secret) {
2230
- if (!signature) return false;
2231
- const hex = signatureHex(signature);
2232
- if (!hex) return false;
2233
- const received = hexToBytes(hex);
2234
- if (!received) return false;
2235
- return constantTimeEqual(new Uint8Array(await hmacSha256(toPayloadString(payload), secret)), received);
2236
- }
2237
- async function parseWebhookPayloadResult(payload, options = {}) {
2238
- const payloadString = toPayloadString(payload);
2239
- const signature = options.signature ?? readWebhookHeaders(options.headers).signature;
2240
- if (options.secret && (options.validateSignature ?? true)) {
2241
- if (!await verifyWebhookSignature(payloadString, signature, options.secret)) return err(new PartnerApiError({
2242
- kind: "auth",
2243
- statusCode: 401,
2244
- message: "Invalid webhook signature"
2245
- }));
2246
- }
2247
- const parsed = typeof payload === "string" ? JSON.parse(payload) : payload;
2248
- return ok(partnerWebhookEnvelopeSchema.parse(parsed));
2249
- }
2250
- async function parseWebhookPayload(payload, options = {}) {
2251
- return unwrapResult(await parseWebhookPayloadResult(payload, options), partnerErrorToException);
2252
- }
2253
- function readWebhookHeaders(headers) {
2254
- if (!headers) return {
2255
- event: null,
2256
- delivery: null,
2257
- contractVersion: null,
2258
- timestamp: null,
2259
- signature: null
2260
- };
2261
- if (headers instanceof Headers) return {
2262
- event: headers.get(WEBHOOK_EVENT_HEADER$1),
2263
- delivery: headers.get(WEBHOOK_DELIVERY_HEADER$1),
2264
- contractVersion: headers.get(WEBHOOK_CONTRACT_VERSION_HEADER$1),
2265
- timestamp: headers.get(WEBHOOK_TIMESTAMP_HEADER$1),
2266
- signature: headers.get(WEBHOOK_SIGNATURE_HEADER$1)
2267
- };
2268
- return {
2269
- event: headers.event ?? null,
2270
- delivery: headers.delivery ?? null,
2271
- contractVersion: headers.contractVersion ?? null,
2272
- timestamp: headers.timestamp ?? null,
2273
- signature: headers.signature ?? null
2274
- };
2275
- }
1
+ import { DEFAULT_SEARCH_TYPE, dateRangeOptionsQuery, searchTypeQuery, sourceInfoQuery, tablesQuery, withDefaultSearchType } from "./hosted-query.mjs";
2
+ import { PartnerApiError, partnerErrorToException, toPartnerError } from "./errors.mjs";
3
+ import { createAnalyticsClient } from "./analytics-client.mjs";
4
+ import { defineGscAnalyzer } from "./analyzer-defs.mjs";
5
+ import { weightedAnonPct } from "./anonymization.mjs";
6
+ import { archetypeSeamSupportsQuery, builderStateToArchetype, extractWireDateRange } from "./archetype-compile.mjs";
7
+ import { analyticsStatusToSyncStatus, findLifecycleSite, lifecycleSiteToSyncStatus, lifecycleSiteToUserSite } from "./lifecycle.mjs";
8
+ import { createPartnerClient } from "./client.mjs";
9
+ import { countryName } from "./country-names.mjs";
10
+ import { CWV_GOOD_CLS, CWV_GOOD_INP, CWV_GOOD_LCP, CWV_POOR_CLS, CWV_POOR_INP, CWV_POOR_LCP, cwvBucket, siteUrlToHostname, truncateQuery } from "./cwv-thresholds.mjs";
11
+ import { gscConsoleUrl } from "./gsc-console-url.mjs";
12
+ import { GSC_STABLE_LATENCY_DAYS } from "./gsc-constants.mjs";
13
+ import { classifyGscError } from "./gsc-error.mjs";
14
+ import { COMPARE_OPTIONS, GSC_COLUMN_OPTIONS, GSC_PERIOD_OPTIONS, GSC_PERIOD_OPTIONS_LONG, PERIOD_PRESETS } from "./gsc-period-presets.mjs";
15
+ import { coerceRowMetrics, positionFor, summarizeDailyRows } from "./gsc-rows.mjs";
16
+ import { issueDetails, issueGroups, severityOrder } from "./indexing-issues.mjs";
17
+ import { compareRange, getGscUnstableCutoffDate, isCustomPeriod, parseCustomPeriod, periodToDateRange, periodToDays } from "./period.mjs";
18
+ import { SITE_TYPE_BASELINE, derivePeerStanding, deriveSiteBaseline, normalizeSiteType, siteTypeBaseline } from "./site-baseline.mjs";
19
+ import { classifySearchConsoleStage } from "./search-console-stage.mjs";
20
+ import { classifyHealthStage, classifyReachStage, classifySiteTriage, reachLivenessRatio } from "./site-triage.mjs";
21
+ import { CANONICAL_WEBHOOK_EVENTS, VALID_WEBHOOK_EVENTS, WEBHOOK_CONTRACT_VERSION, WEBHOOK_CONTRACT_VERSION_HEADER, WEBHOOK_DELIVERY_HEADER, WEBHOOK_EVENT_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, parseWebhookPayload, parseWebhookPayloadResult, readWebhookHeaders, verifyWebhookSignature } from "./webhook.mjs";
2276
22
  export { CANONICAL_WEBHOOK_EVENTS, COMPARE_OPTIONS, CWV_GOOD_CLS, CWV_GOOD_INP, CWV_GOOD_LCP, CWV_POOR_CLS, CWV_POOR_INP, CWV_POOR_LCP, DEFAULT_SEARCH_TYPE, GSC_COLUMN_OPTIONS, GSC_PERIOD_OPTIONS, GSC_PERIOD_OPTIONS_LONG, GSC_STABLE_LATENCY_DAYS, PERIOD_PRESETS, PartnerApiError, SITE_TYPE_BASELINE, VALID_WEBHOOK_EVENTS, WEBHOOK_CONTRACT_VERSION, WEBHOOK_CONTRACT_VERSION_HEADER, WEBHOOK_DELIVERY_HEADER, WEBHOOK_EVENT_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, analyticsStatusToSyncStatus, archetypeSeamSupportsQuery, builderStateToArchetype, classifyGscError, classifyHealthStage, classifyReachStage, classifySearchConsoleStage, classifySiteTriage, coerceRowMetrics, compareRange, countryName, createAnalyticsClient, createPartnerClient, cwvBucket, dateRangeOptionsQuery, defineGscAnalyzer, derivePeerStanding, deriveSiteBaseline, extractWireDateRange, findLifecycleSite, getGscUnstableCutoffDate, gscConsoleUrl, isCustomPeriod, issueDetails, issueGroups, lifecycleSiteToSyncStatus, lifecycleSiteToUserSite, normalizeSiteType, parseCustomPeriod, parseWebhookPayload, parseWebhookPayloadResult, partnerErrorToException, periodToDateRange, periodToDays, positionFor, reachLivenessRatio, readWebhookHeaders, searchTypeQuery, severityOrder, siteTypeBaseline, siteUrlToHostname, sourceInfoQuery, summarizeDailyRows, tablesQuery, toPartnerError, truncateQuery, verifyWebhookSignature, weightedAnonPct, withDefaultSearchType };