seo 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-ZQwQYL0U.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-Cyusbw2J.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
@@ -13790,7 +13790,7 @@ async function googleJson(gfetch, url, input) {
13790
13790
  return readJson(response, input.label, { maxBytes: input.maxBytes });
13791
13791
  }
13792
13792
  const GSC = "https://www.googleapis.com/webmasters/v3";
13793
- const PAGE_ROWS = 25e3;
13793
+ const PAGE_ROWS$1 = 25e3;
13794
13794
  async function searchAnalyticsRows(gfetch, site, body, options) {
13795
13795
  const rows = [];
13796
13796
  const { maxRows: requested, ...apiBody } = body;
@@ -13801,7 +13801,7 @@ async function searchAnalyticsRows(gfetch, site, body, options) {
13801
13801
  while (true) {
13802
13802
  const remaining = typeof maxRows === "number" ? maxRows - rows.length : void 0;
13803
13803
  if (typeof remaining === "number" && remaining <= 0) break;
13804
- const rowLimit = Math.min(apiBody.rowLimit ?? PAGE_ROWS, remaining ?? PAGE_ROWS, PAGE_ROWS);
13804
+ const rowLimit = Math.min(apiBody.rowLimit ?? PAGE_ROWS$1, remaining ?? PAGE_ROWS$1, PAGE_ROWS$1);
13805
13805
  const json = await googleJson(gfetch, url, {
13806
13806
  ...options,
13807
13807
  label: "Search Console",
@@ -13878,7 +13878,7 @@ async function runAnalyticsReport(gfetch, propertyId, body, options) {
13878
13878
  //#endregion
13879
13879
  //#region packages/core/dist/bing/api.js
13880
13880
  const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
13881
- const MAX_BYTES$3 = 8e6;
13881
+ const MAX_BYTES$4 = 8e6;
13882
13882
  function bingCall(auth, options) {
13883
13883
  return async (method, params = {}) => {
13884
13884
  const url = new URL(`${BASE}/${method}`);
@@ -13903,16 +13903,16 @@ function bingCall(auth, options) {
13903
13903
  if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
13904
13904
  throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
13905
13905
  }
13906
- return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$3 })).d;
13906
+ return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$4 })).d;
13907
13907
  };
13908
13908
  }
13909
- function day(value) {
13909
+ function day$2(value) {
13910
13910
  if (typeof value !== "string") return null;
13911
13911
  const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
13912
13912
  const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
13913
13913
  return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
13914
13914
  }
13915
- const count$1 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13915
+ const count$2 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13916
13916
  function list$1(data, label) {
13917
13917
  if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
13918
13918
  return data.filter((item) => Boolean(item) && typeof item === "object");
@@ -13927,9 +13927,9 @@ async function bingTraffic(call, site) {
13927
13927
  const rows = [];
13928
13928
  let invalidRows = 0;
13929
13929
  for (const row of list$1(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
13930
- const date = day(row.Date);
13931
- const clicks = count$1(row.Clicks);
13932
- const impressions = count$1(row.Impressions);
13930
+ const date = day$2(row.Date);
13931
+ const clicks = count$2(row.Clicks);
13932
+ const impressions = count$2(row.Impressions);
13933
13933
  if (!date || clicks === null || impressions === null) {
13934
13934
  invalidRows += 1;
13935
13935
  continue;
@@ -13950,11 +13950,11 @@ async function keyed(call, method, params) {
13950
13950
  const rows = [];
13951
13951
  let invalidRows = 0;
13952
13952
  for (const row of list$1(await call(method, params), "row list")) {
13953
- const date = day(row.Date);
13953
+ const date = day$2(row.Date);
13954
13954
  const key = typeof row.Query === "string" ? row.Query.trim() : "";
13955
- const clicks = count$1(row.Clicks);
13956
- const impressions = count$1(row.Impressions);
13957
- const position = count$1(row.AvgImpressionPosition);
13955
+ const clicks = count$2(row.Clicks);
13956
+ const impressions = count$2(row.Impressions);
13957
+ const position = count$2(row.AvgImpressionPosition);
13958
13958
  if (!date || !key || clicks === null || impressions === null) {
13959
13959
  invalidRows += 1;
13960
13960
  continue;
@@ -14601,7 +14601,7 @@ const METRIC_COLUMNS = [
14601
14601
  "score",
14602
14602
  "priority.score"
14603
14603
  ];
14604
- const SUMMARY_ROWS$1 = 10;
14604
+ const SUMMARY_ROWS$2 = 10;
14605
14605
  const CELL_MAX = 80;
14606
14606
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14607
14607
  const isPrimitive = (value) => value === null || [
@@ -14672,7 +14672,7 @@ function cell(value, origin, max = CELL_MAX) {
14672
14672
  text = text.replaceAll("|", "\\|").replaceAll("\n", " ");
14673
14673
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
14674
14674
  }
14675
- function table(rows, columns, origin, max = CELL_MAX) {
14675
+ function table$1(rows, columns, origin, max = CELL_MAX) {
14676
14676
  if (rows.length === 0 || columns.length === 0) return "";
14677
14677
  return [columns.join(" | "), ...rows.map((row) => columns.map((key) => cell(row[key], origin, max)).join(" | "))].join("\n");
14678
14678
  }
@@ -14748,7 +14748,7 @@ function presentReport(input) {
14748
14748
  text: [
14749
14749
  head,
14750
14750
  list ? `${list.path}: rows ${input.offset + 1} to ${end} of ${flat.length}.` : "This report has no list of rows. Use detail \"full\".",
14751
- table(page, columns, origin),
14751
+ table$1(page, columns, origin),
14752
14752
  end < flat.length ? `Next page: ${input.more("rows", end)}` : ""
14753
14753
  ].filter(Boolean).join("\n\n"),
14754
14754
  data: {
@@ -14760,7 +14760,7 @@ function presentReport(input) {
14760
14760
  }
14761
14761
  };
14762
14762
  }
14763
- const shown = flat.slice(0, Math.min(input.limit, SUMMARY_ROWS$1));
14763
+ const shown = flat.slice(0, Math.min(input.limit, SUMMARY_ROWS$2));
14764
14764
  const columns = columnsFor(flat, 8);
14765
14765
  return {
14766
14766
  text: [
@@ -14769,7 +14769,7 @@ function presentReport(input) {
14769
14769
  facts ? `Facts: ${facts}.` : "",
14770
14770
  ...input.notes ?? [],
14771
14771
  flat.length < 5 ? filteredLine(report) : null,
14772
- list ? `${list.path} (${shown.length} of ${flat.length} shown):\n${table(shown, columns, origin)}` : "",
14772
+ list ? `${list.path} (${shown.length} of ${flat.length} shown):\n${table$1(shown, columns, origin)}` : "",
14773
14773
  limits.length ? `Limits:\n${limits.map((line) => `- ${line}`).join("\n")}` : "",
14774
14774
  `More: ${shown.length < flat.length ? `${input.more("rows", shown.length)} for more rows, or ` : ""}${input.more("full")} for every field.`
14775
14775
  ].filter(Boolean).join("\n\n"),
@@ -14792,7 +14792,7 @@ function preview(report, origin, rows) {
14792
14792
  const flat = list.rows.map(flatten);
14793
14793
  return {
14794
14794
  total: flat.length,
14795
- text: table(flat.slice(0, rows), columnsFor(flat, 6), origin)
14795
+ text: table$1(flat.slice(0, rows), columnsFor(flat, 6), origin)
14796
14796
  };
14797
14797
  }
14798
14798
  //#endregion
@@ -14847,9 +14847,9 @@ async function runSites(input) {
14847
14847
  text: [
14848
14848
  named ? `Google accounts: ${input.accounts.map((account) => account.email).join(", ")}.` : null,
14849
14849
  `Search Console properties (${properties.length}):`,
14850
- properties.length ? table(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
14850
+ properties.length ? table$1(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
14851
14851
  ...failed.map((account) => `${account.email ?? "Google"}: could not list properties. ${account.properties.message}`),
14852
- analytics.length ? `Google Analytics properties (${analytics.length}):\n${table(analytics, [
14852
+ analytics.length ? `Google Analytics properties (${analytics.length}):\n${table$1(analytics, [
14853
14853
  "property",
14854
14854
  "name",
14855
14855
  "account",
@@ -14946,6 +14946,9 @@ var LinkedAccounts = class {
14946
14946
  })();
14947
14947
  return this.owners;
14948
14948
  }
14949
+ async properties() {
14950
+ return [...(await this.ownerMap()).keys()].sort();
14951
+ }
14949
14952
  async resolve(site) {
14950
14953
  if (this.google.length === 0) throw new CommandError("NOT_CONNECTED", `No Google account is connected. ${this.hint.signIn}`);
14951
14954
  const found = matchProperty([...(await this.ownerMap()).keys()], site);
@@ -16412,6 +16415,7 @@ const MEBIBYTE$2 = 1024 * 1024;
16412
16415
  const CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
16413
16416
  const CACHE_LIMITS = {
16414
16417
  gsc_cache: 48 * MEBIBYTE$2,
16418
+ url_inspection_cache: 16 * MEBIBYTE$2,
16415
16419
  ga4_cache: 24 * MEBIBYTE$2,
16416
16420
  provider_cache: 32 * MEBIBYTE$2,
16417
16421
  http_cache: 128 * MEBIBYTE$2,
@@ -16429,6 +16433,16 @@ const CACHE_POLICIES = [
16429
16433
  "response_json"
16430
16434
  ]
16431
16435
  },
16436
+ {
16437
+ table: "url_inspection_cache",
16438
+ keys: ["site_url", "url"],
16439
+ timestamp: "fetched_at",
16440
+ sizeColumns: [
16441
+ "site_url",
16442
+ "url",
16443
+ "status_json"
16444
+ ]
16445
+ },
16432
16446
  {
16433
16447
  table: "ga4_cache",
16434
16448
  keys: ["property_id", "query_hash"],
@@ -16732,6 +16746,17 @@ CREATE INDEX IF NOT EXISTS idx_url_inspection_reservations_minute
16732
16746
  credential_key, property_site_url, created_at
16733
16747
  );
16734
16748
 
16749
+ CREATE TABLE IF NOT EXISTS url_inspection_cache (
16750
+ site_url TEXT NOT NULL,
16751
+ url TEXT NOT NULL,
16752
+ status_json TEXT NOT NULL,
16753
+ fetched_at INTEGER NOT NULL,
16754
+ expires_at INTEGER NOT NULL,
16755
+ PRIMARY KEY(site_url, url)
16756
+ ) WITHOUT ROWID;
16757
+ CREATE INDEX IF NOT EXISTS idx_url_inspection_cache_expires
16758
+ ON url_inspection_cache(expires_at);
16759
+
16735
16760
  CREATE TABLE IF NOT EXISTS performance_reports (
16736
16761
  id TEXT PRIMARY KEY,
16737
16762
  url TEXT NOT NULL,
@@ -17151,11 +17176,11 @@ function analyzeAiReferralRows(input) {
17151
17176
  }
17152
17177
  //#endregion
17153
17178
  //#region packages/core/dist/analyze/ai-referrals-query.js
17154
- const PAGE_SIZE$3 = 25e3;
17179
+ const PAGE_SIZE$4 = 25e3;
17155
17180
  async function fetchAiReferralRows(input) {
17156
- const pageSize = input.pageSize ?? PAGE_SIZE$3;
17181
+ const pageSize = input.pageSize ?? PAGE_SIZE$4;
17157
17182
  if (!Number.isInteger(input.maxRows) || input.maxRows < 1) throw new Error("maxRows must be a positive whole number.");
17158
- if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > PAGE_SIZE$3) throw new Error(`pageSize must be a whole number between 1 and ${PAGE_SIZE$3}.`);
17183
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > PAGE_SIZE$4) throw new Error(`pageSize must be a whole number between 1 and ${PAGE_SIZE$4}.`);
17159
17184
  const rows = [];
17160
17185
  const warnings = /* @__PURE__ */ new Set();
17161
17186
  const retainedKeys = /* @__PURE__ */ new Set();
@@ -17262,7 +17287,7 @@ function resultLimit(input) {
17262
17287
  if (!Number.isInteger(value) || value < 1 || value > MAX_RESULT_LIMIT) throw new SeoError("INVALID_INPUT", `resultLimit must be a whole number between 1 and ${MAX_RESULT_LIMIT}.`);
17263
17288
  return value;
17264
17289
  }
17265
- function evidence(result) {
17290
+ function evidence$1(result) {
17266
17291
  return {
17267
17292
  status: result.truncated || result.warnings.length ? "partial" : "complete",
17268
17293
  calls: result.calls,
@@ -17365,7 +17390,7 @@ async function aiReferralsReport(input, dependencies = {}) {
17365
17390
  label: "AI referral landing-page detail query",
17366
17391
  query
17367
17392
  });
17368
- detailEvidence = evidence(detailResult);
17393
+ detailEvidence = evidence$1(detailResult);
17369
17394
  } catch (error) {
17370
17395
  detailEvidence = unavailableEvidence(`The landing-page detail query failed: ${error instanceof Error ? error.message : String(error)}`);
17371
17396
  }
@@ -17383,7 +17408,7 @@ async function aiReferralsReport(input, dependencies = {}) {
17383
17408
  query
17384
17409
  });
17385
17410
  totalUsers = totalUsersFromRows(usersResult.rows) ?? null;
17386
- totalUsersEvidence = evidence(usersResult);
17411
+ totalUsersEvidence = evidence$1(usersResult);
17387
17412
  if (totalUsers === null) totalUsersEvidence = unavailableEvidence("The de-duplicated total-users query returned no valid value.");
17388
17413
  } catch (error) {
17389
17414
  totalUsersEvidence = unavailableEvidence(`The de-duplicated total-users query failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -17398,7 +17423,7 @@ async function aiReferralsReport(input, dependencies = {}) {
17398
17423
  sourceRows: sourceResult.rows,
17399
17424
  detailRows: detailResult?.rows ?? [],
17400
17425
  totalUsers,
17401
- sourceDiscovery: evidence(sourceResult),
17426
+ sourceDiscovery: evidence$1(sourceResult),
17402
17427
  detail: detailEvidence,
17403
17428
  totalUsersEvidence
17404
17429
  });
@@ -20230,6 +20255,19 @@ function reserveUrlInspectionQuota(input) {
20230
20255
  if ("error" in result) throw result.error;
20231
20256
  return result.reservation;
20232
20257
  }
20258
+ function readUrlInspectionQuota(input) {
20259
+ const database = input.database ?? getDb();
20260
+ const now = input.now ?? /* @__PURE__ */ new Date();
20261
+ const bucket = database.prepare(`SELECT limit_count, used_count, reserved_count, blocked_until
20262
+ FROM url_inspection_quota_buckets
20263
+ WHERE credential_key = ? AND property_site_url = ? AND quota_date = ?`).get(input.credentialKey, input.property, urlInspectionQuotaDate(now));
20264
+ const limit = bucket?.limit_count ?? 2e3;
20265
+ return {
20266
+ used: (bucket?.blocked_until ?? 0) > now.getTime() ? limit : Math.min(limit, (bucket?.used_count ?? 0) + (bucket?.reserved_count ?? 0)),
20267
+ limit,
20268
+ resetAt: urlInspectionQuotaResetAt(now).toISOString()
20269
+ };
20270
+ }
20233
20271
  function finalizeUrlInspectionQuota(input) {
20234
20272
  const database = input.database ?? getDb();
20235
20273
  const now = input.now ?? /* @__PURE__ */ new Date();
@@ -20274,10 +20312,36 @@ function inspectionResponseError(response, input) {
20274
20312
  if (response.status >= 500) return new SeoError("PROVIDER_UNAVAILABLE", `Google URL Inspection is unavailable (${response.status}). Try again later.`);
20275
20313
  return new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection failed with ${response.status}.`);
20276
20314
  }
20315
+ async function urlInspectionQuota(input) {
20316
+ const { quotaIdentity } = await getAuthorized(input.accountEmail ?? await cloudAccountFor(input.siteUrl));
20317
+ return readUrlInspectionQuota({
20318
+ credentialKey: credentialKey(quotaIdentity),
20319
+ property: input.siteUrl
20320
+ });
20321
+ }
20322
+ function reserveOrSkip(input) {
20323
+ try {
20324
+ return reserveUrlInspectionQuota(input);
20325
+ } catch (error) {
20326
+ if (error instanceof SeoError) throw error;
20327
+ return;
20328
+ }
20329
+ }
20330
+ function finalize(reservation, outcome, blockedUntil, now) {
20331
+ if (!reservation) return;
20332
+ try {
20333
+ finalizeUrlInspectionQuota({
20334
+ reservation,
20335
+ outcome,
20336
+ blockedUntil,
20337
+ now
20338
+ });
20339
+ } catch {}
20340
+ }
20277
20341
  async function inspectUrl(input) {
20278
20342
  const inspectionUrl = assertUrlMatchesGscProperty(input.siteUrl, input.inspectionUrl);
20279
20343
  const { client, quotaIdentity } = await getAuthorized(input.accountEmail ?? await cloudAccountFor(input.siteUrl));
20280
- const reservation = reserveUrlInspectionQuota({
20344
+ const reservation = reserveOrSkip({
20281
20345
  credentialKey: credentialKey(quotaIdentity),
20282
20346
  property: input.siteUrl,
20283
20347
  limit: input.quotaLimit
@@ -20293,35 +20357,24 @@ async function inspectUrl(input) {
20293
20357
  })
20294
20358
  });
20295
20359
  } catch (error) {
20296
- finalizeUrlInspectionQuota({
20297
- reservation,
20298
- outcome: "consumed"
20299
- });
20360
+ finalize(reservation, "consumed");
20300
20361
  if (error instanceof SeoError) throw error;
20301
20362
  throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
20302
20363
  }
20303
20364
  if (response.status === 429) {
20304
20365
  const now = /* @__PURE__ */ new Date();
20305
20366
  const blockedUntil = retryAfter$1(response, now);
20306
- finalizeUrlInspectionQuota({
20307
- reservation,
20308
- outcome: "exhausted",
20309
- blockedUntil,
20310
- now
20311
- });
20367
+ finalize(reservation, "exhausted", blockedUntil, now);
20312
20368
  throw new UrlInspectionQuotaError({
20313
20369
  property: input.siteUrl,
20314
- resetAt: (blockedUntil ?? new Date(reservation.resetAt)).toISOString(),
20315
- used: reservation.used + reservation.count,
20316
- limit: reservation.limit,
20370
+ resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
20371
+ used: reservation ? reservation.used + reservation.count : 0,
20372
+ limit: reservation?.limit ?? 2e3,
20317
20373
  reason: "provider",
20318
20374
  requestSent: true
20319
20375
  });
20320
20376
  }
20321
- finalizeUrlInspectionQuota({
20322
- reservation,
20323
- outcome: "consumed"
20324
- });
20377
+ finalize(reservation, "consumed");
20325
20378
  if (!response.ok) throw inspectionResponseError(response, input);
20326
20379
  return await readJson(response, "URL Inspection");
20327
20380
  }
@@ -22120,7 +22173,7 @@ const actionPriority = new Map([
22120
22173
  "unverified",
22121
22174
  "covered"
22122
22175
  ].map((type, index) => [type, index]));
22123
- function plural$6(count, singular, pluralLabel) {
22176
+ function plural$7(count, singular, pluralLabel) {
22124
22177
  return count === 1 ? singular : pluralLabel;
22125
22178
  }
22126
22179
  function actionable(item) {
@@ -22154,7 +22207,7 @@ function pageOpportunityVerdict(input) {
22154
22207
  if (focus === "unverified") return "GSC visibility exists, but the page was not verified and no specific change is justified yet.";
22155
22208
  if (focus === "covered") return "The verified page covers the sampled queries and CTR is not materially below its directional benchmark.";
22156
22209
  const count = analysis.summary.opportunities;
22157
- const prefix = `${count} ${plural$6(count, "actionable observation", "actionable observations")} found.`;
22210
+ const prefix = `${count} ${plural$7(count, "actionable observation", "actionable observations")} found.`;
22158
22211
  if (focus === "content-gap") return `${prefix} Verified body coverage is the main issue; confirm intent before expanding the page.`;
22159
22212
  if (focus === "serp-framing") return `${prefix} The body is relevant, but its title, meta description, or H1 framing is weak.`;
22160
22213
  if (focus === "ranking") return `${prefix} Most upside requires stronger relevance or authority; no CTR-only lift is claimed for page-two queries.`;
@@ -22169,7 +22222,7 @@ function pageOpportunityRecommendations(analysis) {
22169
22222
  }
22170
22223
  const recommendations = [`Start with "${top.query}". ${top.recommendation}`];
22171
22224
  const technical = actions.filter((item) => item.opportunityType === "technical-check").length;
22172
- if (technical) recommendations.push(`Resolve the technical evidence affecting ${technical} ${plural$6(technical, "row", "rows")} before content work.`);
22225
+ if (technical) recommendations.push(`Resolve the technical evidence affecting ${technical} ${plural$7(technical, "row", "rows")} before content work.`);
22173
22226
  const gaps = actions.filter((item) => item.opportunityType === "content-gap").length;
22174
22227
  if (gaps >= 3) recommendations.push(`Group the ${gaps} verified content gaps into useful sections rather than adding one thin paragraph per query.`);
22175
22228
  return recommendations;
@@ -25244,7 +25297,7 @@ function availabilityOf(status) {
25244
25297
  function errorText(error) {
25245
25298
  return error instanceof Error ? error.message : String(error);
25246
25299
  }
25247
- async function get(input, url, headers, readBody = true) {
25300
+ async function get$1(input, url, headers, readBody = true) {
25248
25301
  const controller = abortController({
25249
25302
  timeoutMs: input.timeoutMs,
25250
25303
  signal: input.signal
@@ -25301,7 +25354,7 @@ function llmsLinks(text, base) {
25301
25354
  return links;
25302
25355
  }
25303
25356
  async function checkLlmsTxt(input) {
25304
- const result = await get(input, new URL("/llms.txt", input.url).toString());
25357
+ const result = await get$1(input, new URL("/llms.txt", input.url).toString());
25305
25358
  const availability = result.availability === "available" && isHtml(result.contentType) ? "absent" : result.availability;
25306
25359
  const exists = availability === "available";
25307
25360
  const base = {
@@ -25318,7 +25371,7 @@ async function checkLlmsTxt(input) {
25318
25371
  for (const url of found.slice(0, LLMS_LINK_LIMIT)) {
25319
25372
  if (input.signal?.aborted) break;
25320
25373
  checked += 1;
25321
- const link = brokenLink(await get(input, url, void 0, false), false);
25374
+ const link = brokenLink(await get$1(input, url, void 0, false), false);
25322
25375
  if (link) broken.push(link);
25323
25376
  }
25324
25377
  return {
@@ -25362,7 +25415,7 @@ function sitemapUrlsFromRobots(text) {
25362
25415
  return [...urls];
25363
25416
  }
25364
25417
  async function checkRobotsAiAccess(input) {
25365
- const result = await get(input, new URL("/robots.txt", input.url).toString());
25418
+ const result = await get$1(input, new URL("/robots.txt", input.url).toString());
25366
25419
  const exists = result.availability === "available";
25367
25420
  const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
25368
25421
  const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
@@ -25391,7 +25444,7 @@ async function checkAgentResources(input) {
25391
25444
  const resources = [];
25392
25445
  for (const path of AGENT_RESOURCE_PATHS) {
25393
25446
  if (input.signal?.aborted) break;
25394
- const result = await get(input, new URL(path, input.url).toString());
25447
+ const result = await get$1(input, new URL(path, input.url).toString());
25395
25448
  const exists = result.availability === "available" && !isHtml(result.contentType);
25396
25449
  let validJson;
25397
25450
  if (exists && /\bjson\b/i.test(result.contentType)) try {
@@ -25411,7 +25464,7 @@ async function checkAgentResources(input) {
25411
25464
  return resources;
25412
25465
  }
25413
25466
  async function checkMarkdownNegotiation(input) {
25414
- const result = await get(input, input.url, { accept: "text/markdown" }, false);
25467
+ const result = await get$1(input, input.url, { accept: "text/markdown" }, false);
25415
25468
  const vary = result.headers?.get("vary") ?? "";
25416
25469
  return {
25417
25470
  url: result.url,
@@ -25458,7 +25511,7 @@ async function checkMarkdownCopies(input) {
25458
25511
  const url = page.markdownAlternates?.[0];
25459
25512
  if (!url || !input.allows(url)) continue;
25460
25513
  checked += 1;
25461
- const result = await get({
25514
+ const result = await get$1({
25462
25515
  ...input,
25463
25516
  url
25464
25517
  }, url);
@@ -27223,7 +27276,7 @@ const installedPackageJsonSchema = z.object({
27223
27276
  function packageDirectory(packagesDir, packageName) {
27224
27277
  return join(packagesDir, "node_modules", ...packageName.split("/"));
27225
27278
  }
27226
- function inside(parent, child) {
27279
+ function inside$1(parent, child) {
27227
27280
  const path = relative(parent, child);
27228
27281
  return path === "" || !path.startsWith("..") && !isAbsolute(path);
27229
27282
  }
@@ -27255,14 +27308,14 @@ async function loadOne(installed, registry, options) {
27255
27308
  async function loadProviderPackage(input, options) {
27256
27309
  const importModule = options.importModule ?? ((url) => import(url));
27257
27310
  const packageDir = realpathSync(packageDirectory(options.packagesDir, input.package));
27258
- if (!inside(realpathSync(options.packagesDir), packageDir)) throw new Error("Installed provider package resolves outside the provider directory.");
27311
+ if (!inside$1(realpathSync(options.packagesDir), packageDir)) throw new Error("Installed provider package resolves outside the provider directory.");
27259
27312
  const packageJsonPath = join(packageDir, "package.json");
27260
27313
  const packageJson = installedPackageJsonSchema.parse(JSON.parse(readFileSync(packageJsonPath, "utf8")));
27261
27314
  if (packageJson.name !== input.package || packageJson.version !== input.version) throw new Error("Installed provider package does not match its saved record.");
27262
27315
  const packageRegistry = new ProviderExtensionRegistry();
27263
27316
  for (const entry of packageJson.seo.providers) {
27264
27317
  const entryPath = realpathSync(resolve(dirname(packageJsonPath), entry));
27265
- if (!inside(packageDir, entryPath)) throw new Error("Provider entry point resolves outside its package.");
27318
+ if (!inside$1(packageDir, entryPath)) throw new Error("Provider entry point resolves outside its package.");
27266
27319
  await providerActivate((await importModule(`${pathToFileURL(entryPath).href}?seo-provider=${encodeURIComponent(input.version)}`)).default)(packageRegistry.hostFor({
27267
27320
  package: input.package,
27268
27321
  version: input.version
@@ -27298,7 +27351,7 @@ async function loadInstalledProviderExtensions(options = {}) {
27298
27351
  const DEFAULT_MAX_REQUESTS = 8;
27299
27352
  const MAX_RESPONSE_BYTES$1 = 10 * 1024 * 1024;
27300
27353
  const MAX_REQUEST_BODY_BYTES = 64 * 1024;
27301
- const TIMEOUT_MS$2 = 15e3;
27354
+ const TIMEOUT_MS$3 = 15e3;
27302
27355
  function normalizeProviderExtensionAccount(provider, account) {
27303
27356
  const fields = provider.connection.fields.filter((field) => field.kind === "account");
27304
27357
  const allowed = new Set(fields.map((field) => field.id));
@@ -27345,7 +27398,7 @@ function runtimeForProvider(provider, options = {}) {
27345
27398
  },
27346
27399
  fetch: providerFetch,
27347
27400
  maxResponseBytes: MAX_RESPONSE_BYTES$1,
27348
- timeoutMs: TIMEOUT_MS$2,
27401
+ timeoutMs: TIMEOUT_MS$3,
27349
27402
  retry: (input.method ?? "GET") === "GET" ? "safe" : "never",
27350
27403
  schema: z.unknown()
27351
27404
  });
@@ -28908,7 +28961,7 @@ function analyzeCtrUnderperformersFromRows(input) {
28908
28961
  const MAX_GSC_ROWS$3 = 1e5;
28909
28962
  const DEFAULT_DAYS$3 = 28;
28910
28963
  const MAX_DAYS$2 = 548;
28911
- function plural$5(count, singular, pluralLabel = `${singular}s`) {
28964
+ function plural$6(count, singular, pluralLabel = `${singular}s`) {
28912
28965
  return count === 1 ? singular : pluralLabel;
28913
28966
  }
28914
28967
  async function ctrUnderperformersReportWith(input, dependencies) {
@@ -29011,11 +29064,11 @@ async function ctrUnderperformersReportWith(input, dependencies) {
29011
29064
  minImpressions,
29012
29065
  limit,
29013
29066
  brandFiltering: input.includeBrand ? "included" : "excluded",
29014
- verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$5(selection.eligibleUnderperformers, "underperformer")} found in retained rows, with a calculated heuristic shortfall of ${totalClickShortfall.toFixed(0)} clicks for this window. Start review with "${top.query}" because it has the largest gap.` : dataStatus === "partial" ? "No material CTR underperformers remained in the validated retained rows, but partial evidence prevents an all-clear." : `No high-impression rows at average position below 4 in the retained rows are materially below the heuristic CTR benchmark.`
29067
+ verdict: top ? `${selection.eligibleUnderperformers} CTR ${plural$6(selection.eligibleUnderperformers, "underperformer")} found in retained rows, with a calculated heuristic shortfall of ${totalClickShortfall.toFixed(0)} clicks for this window. Start review with "${top.query}" because it has the largest gap.` : dataStatus === "partial" ? "No material CTR underperformers remained in the validated retained rows, but partial evidence prevents an all-clear." : `No high-impression rows at average position below 4 in the retained rows are materially below the heuristic CTR benchmark.`
29015
29068
  },
29016
29069
  items,
29017
29070
  caveats: [
29018
- `Date window: ${range.startDate} to ${range.endDate} (${days} ${plural$5(days, "day")}), using final GSC data where available.`,
29071
+ `Date window: ${range.startDate} to ${range.endDate} (${days} ${plural$6(days, "day")}), using final GSC data where available.`,
29019
29072
  `Brand queries: ${input.includeBrand ? "included" : "excluded"}. Brand terms used: ${brand.terms.join(", ") || "none"} (${brand.source === "provided" ? "provided" : "host forms, plus the bare domain stem only when Search Console shows the site is its navigational answer"}).`,
29020
29073
  `Only rows at average position below 4 with at least ${minImpressions} impressions were checked. Rows at position 4 to 10 belong to quick-wins, which uses the same site CTR curve.`,
29021
29074
  minImpressionsCaveat({
@@ -29023,13 +29076,13 @@ async function ctrUnderperformersReportWith(input, dependencies) {
29023
29076
  belowMinimumRows: selection.belowMinimumRows
29024
29077
  }),
29025
29078
  "Expected CTR uses the site CTR curve from all page-one rows (75th percentile of qualified URL CTRs per position) when enough peer data exists, otherwise the fallback position curve.",
29026
- ...selection.fragmentRows ? [`${selection.fragmentRows} source ${plural$5(selection.fragmentRows, "row")} with a #fragment URL ${selection.fragmentRows === 1 ? "was" : "were"} merged into the base URL.`] : [],
29027
- ...selection.flaggedRows ? [`${selection.flaggedRows} eligible ${plural$5(selection.flaggedRows, "row")} carry heuristic query flags (likely-automated or navigational-other) and rank after unflagged rows.`] : [],
29079
+ ...selection.fragmentRows ? [`${selection.fragmentRows} source ${plural$6(selection.fragmentRows, "row")} with a #fragment URL ${selection.fragmentRows === 1 ? "was" : "were"} merged into the base URL.`] : [],
29080
+ ...selection.flaggedRows ? [`${selection.flaggedRows} eligible ${plural$6(selection.flaggedRows, "row")} carry heuristic query flags (likely-automated or navigational-other) and rank after unflagged rows.`] : [],
29028
29081
  "CTR benchmarks and calculated click shortfalls are directional heuristics, not forecasts or proof that clicks are available. Validate the search intent and live SERP before editing.",
29029
- ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$5(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
29030
- ...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$5(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
29082
+ ...selection.invalidRows ? [`${selection.invalidRows} invalid provider ${plural$6(selection.invalidRows, "row")} ${selection.invalidRows === 1 ? "was" : "were"} excluded, so this report is partial.`] : [],
29083
+ ...selection.duplicateRows ? [`${selection.duplicateRows} repeated query/page ${plural$6(selection.duplicateRows, "row")} ${selection.duplicateRows === 1 ? "was" : "were"} aggregated before CTR and average position were calculated.`] : [],
29031
29084
  ...possiblyTruncated ? [`The retained-row limit of ${MAX_GSC_ROWS$3} was reached, so lower-ranked source rows may be absent.`] : [],
29032
- ...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$5(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
29085
+ ...selection.limitedUnderperformers ? [`${selection.limitedUnderperformers} eligible ${plural$6(selection.limitedUnderperformers, "underperformer")} ${selection.limitedUnderperformers === 1 ? "was" : "were"} omitted by the output limit.`] : []
29033
29086
  ],
29034
29087
  recommendations: top ? [
29035
29088
  `Review the live SERP and displayed snippet for "${top.query}" first. Test the title or meta description only if the observed framing does not match the query intent.`,
@@ -29049,11 +29102,11 @@ function ctrUnderperformersReport(input, dependencies = defaultDependencies$9) {
29049
29102
  }
29050
29103
  //#endregion
29051
29104
  //#region packages/core/dist/phrasing.js
29052
- function plural$4(count, singular, pluralLabel = `${singular}s`) {
29105
+ function plural$5(count, singular, pluralLabel = `${singular}s`) {
29053
29106
  return count === 1 ? singular : pluralLabel;
29054
29107
  }
29055
29108
  function countLabel(count, singular, pluralLabel = `${singular}s`) {
29056
- return `${count.toLocaleString("en-GB")} ${plural$4(count, singular, pluralLabel)}`;
29109
+ return `${count.toLocaleString("en-GB")} ${plural$5(count, singular, pluralLabel)}`;
29057
29110
  }
29058
29111
  //#endregion
29059
29112
  //#region packages/core/dist/analyze/diagnosis-priorities.js
@@ -32812,7 +32865,7 @@ function anomalyEvidence(anomaly) {
32812
32865
  if (anomaly.direction === "normal") return `${formatMetric(anomaly.metric)} changed ${changePhrase} (${anomaly.baselineMean.toLocaleString("en-GB")} -> ${anomaly.comparisonMean.toLocaleString("en-GB")} per observed day${anomaly.zScore === null ? "; the baseline had no observed variance" : `, z ${anomaly.zScore}`}).`;
32813
32866
  return `${formatMetric(anomaly.metric)} ${direction} ${changePhrase} (${anomaly.baselineMean.toLocaleString("en-GB")} -> ${anomaly.comparisonMean.toLocaleString("en-GB")} per observed day${anomaly.zScore === null ? "; outside the flat observed baseline" : `, z ${anomaly.zScore}`}).`;
32814
32867
  }
32815
- function plural$3(count, singular, pluralLabel = `${singular}s`) {
32868
+ function plural$4(count, singular, pluralLabel = `${singular}s`) {
32816
32869
  return count === 1 ? singular : pluralLabel;
32817
32870
  }
32818
32871
  function strongestSignal(anomalies) {
@@ -32868,13 +32921,13 @@ function interpretUpdateCorrelation(input) {
32868
32921
  summary,
32869
32922
  evidence: [
32870
32923
  ...input.anomalies.map(anomalyEvidence),
32871
- hasUpdate ? `${input.overlappingUpdates.length} official Google Ranking update ${plural$3(input.overlappingUpdates.length, "window")} overlapped the change date or comparison window: ${updateNames.join(", ")}.` : "No official Google Ranking update windows overlapped the change date or comparison window.",
32924
+ hasUpdate ? `${input.overlappingUpdates.length} official Google Ranking update ${plural$4(input.overlappingUpdates.length, "window")} overlapped the change date or comparison window: ${updateNames.join(", ")}.` : "No official Google Ranking update windows overlapped the change date or comparison window.",
32872
32925
  largeMovement ? "Both clicks and impressions moved sharply in the same direction." : "Clicks and impressions did not both meet the sharp-movement evidence threshold.",
32873
32926
  hasConfounders ? `Known overlapping site changes: ${confounders.map((confounder) => confounder.title).join("; ")}.` : "No overlapping site changes were supplied."
32874
32927
  ],
32875
32928
  caveats: [
32876
32929
  `GSC window: ${input.days} days; recent comparison window: ${input.recentDays} days.`,
32877
- `Update overlap padding: ${input.paddingDays} ${plural$3(input.paddingDays, "day")} either side of the step-change date, or of the recent comparison window when no step change was found.`,
32930
+ `Update overlap padding: ${input.paddingDays} ${plural$4(input.paddingDays, "day")} either side of the step-change date, or of the recent comparison window when no step change was found.`,
32878
32931
  input.refresh ? "Data freshness: cache bypassed for GSC data." : "Data freshness: local GSC cache may have been used.",
32879
32932
  "Source: official Google Search Status Dashboard Ranking incidents only; third-party volatility and unconfirmed chatter are not included.",
32880
32933
  "An update-window overlap cannot establish causation, even when movement is large and no known site change was recorded.",
@@ -33042,7 +33095,7 @@ function analyzeTrafficAnomalyRows(input) {
33042
33095
  if (step && changeDate) {
33043
33096
  const previousDate = aggregated.rows[step.index - 1]?.keys[0];
33044
33097
  const gap = previousDate ? calendarDays(previousDate, changeDate) - 2 : 0;
33045
- stepCaveats.push(`${formatMetric(metric)} changed level on ${changeDate}, so its baseline is every observed day before that date and its comparison is every observed day from it.`, ...gap > 0 ? [`The ${metric} change date follows ${gap} missing ${plural$3(gap, "day")}; the change may have started on one of them.`] : []);
33098
+ stepCaveats.push(`${formatMetric(metric)} changed level on ${changeDate}, so its baseline is every observed day before that date and its comparison is every observed day from it.`, ...gap > 0 ? [`The ${metric} change date follows ${gap} missing ${plural$4(gap, "day")}; the change may have started on one of them.`] : []);
33046
33099
  return compareSegments({
33047
33100
  site: input.site,
33048
33101
  metric,
@@ -33073,9 +33126,9 @@ function analyzeTrafficAnomalyRows(input) {
33073
33126
  const comparisonMissingDays = input.recentDays - comparisonRows.length;
33074
33127
  const missingDays = expectedDays - aggregated.rows.length;
33075
33128
  const caveats = [
33076
- ...missingDays > 0 ? [`${missingDays} requested calendar ${plural$3(missingDays, "day")} had no returned date aggregate and ${missingDays === 1 ? "was" : "were"} not filled with zeros.`] : [],
33077
- ...aggregated.invalidRows > 0 ? [`${aggregated.invalidRows} invalid provider ${plural$3(aggregated.invalidRows, "row")} ${aggregated.invalidRows === 1 ? "was" : "were"} excluded.`] : [],
33078
- ...aggregated.duplicateRows > 0 ? [`${aggregated.duplicateRows} duplicate date ${plural$3(aggregated.duplicateRows, "row")} ${aggregated.duplicateRows === 1 ? "was" : "were"} aggregated before analysis.`] : [],
33129
+ ...missingDays > 0 ? [`${missingDays} requested calendar ${plural$4(missingDays, "day")} had no returned date aggregate and ${missingDays === 1 ? "was" : "were"} not filled with zeros.`] : [],
33130
+ ...aggregated.invalidRows > 0 ? [`${aggregated.invalidRows} invalid provider ${plural$4(aggregated.invalidRows, "row")} ${aggregated.invalidRows === 1 ? "was" : "were"} excluded.`] : [],
33131
+ ...aggregated.duplicateRows > 0 ? [`${aggregated.duplicateRows} duplicate date ${plural$4(aggregated.duplicateRows, "row")} ${aggregated.duplicateRows === 1 ? "was" : "were"} aggregated before analysis.`] : [],
33079
33132
  ...stepCaveats
33080
33133
  ];
33081
33134
  return {
@@ -34156,7 +34209,7 @@ const VOID = /* @__PURE__ */ new Set([
34156
34209
  "col",
34157
34210
  "embed"
34158
34211
  ]);
34159
- const ENTITIES = {
34212
+ const ENTITIES$1 = {
34160
34213
  amp: "&",
34161
34214
  lt: "<",
34162
34215
  gt: ">",
@@ -34181,7 +34234,7 @@ function decode(text) {
34181
34234
  const point = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
34182
34235
  return point > 0 && point <= 1114111 ? String.fromCodePoint(point) : "";
34183
34236
  }
34184
- return ENTITIES[code.toLowerCase()] ?? match;
34237
+ return ENTITIES$1[code.toLowerCase()] ?? match;
34185
34238
  });
34186
34239
  }
34187
34240
  const clean$1 = (value) => value.split(A_OPEN).join("").replace(/\s+/g, " ").trim();
@@ -34421,7 +34474,7 @@ var SentenceStore = class SentenceStore {
34421
34474
  });
34422
34475
  }
34423
34476
  };
34424
- const bareHost = (host) => host.toLowerCase().replace(/^www\./, "");
34477
+ const bareHost$1 = (host) => host.toLowerCase().replace(/^www\./, "");
34425
34478
  function crawlUrl(value, base) {
34426
34479
  try {
34427
34480
  const url = base ? new URL(value, base) : new URL(value);
@@ -34487,10 +34540,10 @@ async function crawlForLinks(input) {
34487
34540
  async function crawl(input, robots, sentences, renderer, warnings) {
34488
34541
  const start = crawlUrl(input.startUrl) ?? input.startUrl;
34489
34542
  let origin = new URL(start).origin;
34490
- const site = bareHost(new URL(start).host);
34543
+ const site = bareHost$1(new URL(start).host);
34491
34544
  const sameSite = (url) => {
34492
34545
  try {
34493
- return bareHost(new URL(url).host) === site;
34546
+ return bareHost$1(new URL(url).host) === site;
34494
34547
  } catch {
34495
34548
  return false;
34496
34549
  }
@@ -34984,15 +35037,15 @@ function internalLinksReportRange(input, fallbackDays, now) {
34984
35037
  days: Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / DAY$1) + 1
34985
35038
  };
34986
35039
  }
34987
- const plural$2 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
35040
+ const plural$3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
34988
35041
  function internalLinksVerdict(report) {
34989
35042
  const { counts } = report;
34990
35043
  if (report.dataStatus === "blocked") return "robots.txt does not allow this crawler to read the site, so nothing was checked.";
34991
35044
  if (report.dataStatus === "empty") return "No HTML page could be crawled, so there is nothing to link.";
34992
35045
  if (report.dataStatus === "target-not-crawled") return "The target page could not be crawled as its own page (it failed, left the site, or names another page as canonical). Check it with seo page first.";
34993
35046
  if (report.dataStatus === "target-not-linkable") return `The target is ${report.target?.noindex ? "noindex" : `a ${report.target?.type?.replaceAll("_", " ")} page`}, so no links to it are suggested.`;
34994
- const judged = report.judge.status === "used" || report.judge.status === "partial" ? `${counts.strong} strong and ${counts.possible} possible link${counts.strong + counts.possible === 1 ? "" : "s"} judged by ${report.judge.model}${counts.heuristic ? `, plus ${plural$2(counts.heuristic, "heuristic link")} where a request failed` : ""}` : `${plural$2(counts.heuristic, "heuristic link")} (no decision model)`;
34995
- const scope = report.mode === "url" ? `to ${report.target?.url}` : `across ${plural$2(counts.pagesCrawled, "page")}`;
35047
+ const judged = report.judge.status === "used" || report.judge.status === "partial" ? `${counts.strong} strong and ${counts.possible} possible link${counts.strong + counts.possible === 1 ? "" : "s"} judged by ${report.judge.model}${counts.heuristic ? `, plus ${plural$3(counts.heuristic, "heuristic link")} where a request failed` : ""}` : `${plural$3(counts.heuristic, "heuristic link")} (no decision model)`;
35048
+ const scope = report.mode === "url" ? `to ${report.target?.url}` : `across ${plural$3(counts.pagesCrawled, "page")}`;
34996
35049
  const capped = report.crawl.capReached ? ` The crawl stopped at its limit of ${report.crawl.pageLimit} pages, so pages beyond it were not checked.` : "";
34997
35050
  return `${judged} ${scope}${counts.heldBack ? `; ${counts.heldBack} held back by the evidence floors` : ""}.${capped}`;
34998
35051
  }
@@ -37430,14 +37483,14 @@ function* retainedQueryClusterRows(input) {
37430
37483
  };
37431
37484
  }
37432
37485
  }
37433
- function plural$1(count, singular, pluralLabel = `${singular}s`) {
37486
+ function plural$2(count, singular, pluralLabel = `${singular}s`) {
37434
37487
  return count === 1 ? singular : pluralLabel;
37435
37488
  }
37436
37489
  function reportVerdict(input) {
37437
37490
  if (!input.clusters.length) return "No query clusters were generated for this scope.";
37438
37491
  if (input.highOpportunityClusters > 0) {
37439
37492
  const verb = input.highOpportunityClusters === 1 ? "has" : "have";
37440
- return `${input.highOpportunityClusters} ${plural$1(input.highOpportunityClusters, "cluster")} ${verb} enough demand and weak enough performance to review first.`;
37493
+ return `${input.highOpportunityClusters} ${plural$2(input.highOpportunityClusters, "cluster")} ${verb} enough demand and weak enough performance to review first.`;
37441
37494
  }
37442
37495
  return "No obvious high-opportunity clusters were found. Use this report for content structure and intent mapping rather than urgent fixes.";
37443
37496
  }
@@ -37578,7 +37631,7 @@ function movementLine(report) {
37578
37631
  }
37579
37632
  function countPhrase(count, singular, pluralLabel = `${singular}s`) {
37580
37633
  if (count === 0) return `no ${pluralLabel}`;
37581
- return `${count} ${plural(count, singular, pluralLabel)}`;
37634
+ return `${count} ${plural$1(count, singular, pluralLabel)}`;
37582
37635
  }
37583
37636
  function headlineLine(report) {
37584
37637
  const parts = [
@@ -37633,7 +37686,7 @@ function decayDiagnosisLabel(diagnosis) {
37633
37686
  if (diagnosis === "lost_clicks") return "clicks dropped without one dominant signal";
37634
37687
  return diagnosis.replaceAll("_", " ");
37635
37688
  }
37636
- function plural(count, singular, pluralLabel = `${singular}s`) {
37689
+ function plural$1(count, singular, pluralLabel = `${singular}s`) {
37637
37690
  return count === 1 ? singular : pluralLabel;
37638
37691
  }
37639
37692
  function decayClusterLine(report) {
@@ -37641,7 +37694,7 @@ function decayClusterLine(report) {
37641
37694
  if (!group) return "No material decay cluster stood out.";
37642
37695
  const diagnosis = decayDiagnosisLabel(group.diagnosis);
37643
37696
  const samples = group.sampleQueries.slice(0, 2).join("; ");
37644
- return `Top decay cluster: ${group.count} ${plural(group.count, "finding")} in the ${group.template.label} template (${diagnosis}), ${group.totalClickLoss.toFixed(0)} fewer observed clicks across rows retained in both windows. Action: ${group.recommendation}${samples ? ` Example queries: ${samples}.` : ""}`;
37697
+ return `Top decay cluster: ${group.count} ${plural$1(group.count, "finding")} in the ${group.template.label} template (${diagnosis}), ${group.totalClickLoss.toFixed(0)} fewer observed clicks across rows retained in both windows. Action: ${group.recommendation}${samples ? ` Example queries: ${samples}.` : ""}`;
37645
37698
  }
37646
37699
  function cannibalSuppressionLine(report) {
37647
37700
  const suppressed = report.cannibalization.selection.suppressedQueries;
@@ -37659,21 +37712,21 @@ function contentOpportunityBullets(report) {
37659
37712
  if (unavailableSections.length) bullets.push(`Could not assess ${unavailableSections.join(", ")} opportunities in this run; see report caveats.`);
37660
37713
  if (report.summary.decayItems) {
37661
37714
  const top = report.decay.items[0];
37662
- bullets.push(top ? `${report.summary.decayItems} observed retained query/page ${plural(report.summary.decayItems, "decline")} need review. Start with "${top.query}" on ${top.url}; it had ${top.clickLoss.toFixed(0)} fewer clicks.` : `${report.summary.decayItems} observed retained query/page ${plural(report.summary.decayItems, "decline")} need review.`);
37715
+ bullets.push(top ? `${report.summary.decayItems} observed retained query/page ${plural$1(report.summary.decayItems, "decline")} need review. Start with "${top.query}" on ${top.url}; it had ${top.clickLoss.toFixed(0)} fewer clicks.` : `${report.summary.decayItems} observed retained query/page ${plural$1(report.summary.decayItems, "decline")} need review.`);
37663
37716
  bullets.push(decayClusterLine(report));
37664
37717
  }
37665
37718
  if (report.summary.cannibalItems) {
37666
37719
  const top = report.cannibalization.items[0];
37667
- bullets.push(top ? `${report.summary.cannibalItems} multi-URL query ${plural(report.summary.cannibalItems, "candidate")} need intent and technical review. Start with "${top.query}" and decide whether ${top.pages.length} ranking URLs answer the same intent.` : `${report.summary.cannibalItems} multi-URL query ${plural(report.summary.cannibalItems, "candidate")} need intent and technical review.`);
37720
+ bullets.push(top ? `${report.summary.cannibalItems} multi-URL query ${plural$1(report.summary.cannibalItems, "candidate")} need intent and technical review. Start with "${top.query}" and decide whether ${top.pages.length} ranking URLs answer the same intent.` : `${report.summary.cannibalItems} multi-URL query ${plural$1(report.summary.cannibalItems, "candidate")} need intent and technical review.`);
37668
37721
  }
37669
37722
  if (report.cannibalization.selection.suppressedQueries) bullets.push(cannibalSuppressionLine(report));
37670
37723
  if (report.summary.strikingDistanceItems) {
37671
37724
  const top = report.strikingDistance.items[0];
37672
- bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
37725
+ bullets.push(top ? `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural$1(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20. Start by investigating "${top.query}" on ${top.url}; it averages position ${top.position.toFixed(1)} with ${top.impressions.toFixed(0)} impressions.` : `${countLabel(report.summary.strikingDistanceItems, "query/page candidate")} ${plural$1(report.summary.strikingDistanceItems, "has", "have")} an average GSC position above 10 and at most 20.`);
37673
37726
  }
37674
37727
  if (!skippedReason(report, "quick-win opportunities") && report.summary.quickWinItems) {
37675
37728
  const top = report.quickWins.items[0];
37676
- bullets.push(top ? `${report.summary.quickWinItems} retained query/page ${plural(report.summary.quickWinItems, "candidate")} fell below a site-peer or versioned heuristic CTR target. Start with "${top.query}" on ${top.url}; it had ${top.impressions.toFixed(0)} impressions at average position ${top.position.toFixed(1)}.` : `${report.summary.quickWinItems} CTR-target ${plural(report.summary.quickWinItems, "candidate")} need review.`);
37729
+ bullets.push(top ? `${report.summary.quickWinItems} retained query/page ${plural$1(report.summary.quickWinItems, "candidate")} fell below a site-peer or versioned heuristic CTR target. Start with "${top.query}" on ${top.url}; it had ${top.impressions.toFixed(0)} impressions at average position ${top.position.toFixed(1)}.` : `${report.summary.quickWinItems} CTR-target ${plural$1(report.summary.quickWinItems, "candidate")} need review.`);
37677
37730
  }
37678
37731
  const templateLine = templateOpportunityLine(report);
37679
37732
  if (!templateLine.startsWith("No dominant")) bullets.push(templateLine);
@@ -38033,10 +38086,10 @@ function applyVerification(item, coverage) {
38033
38086
  function verdict(report) {
38034
38087
  if (report.dataStatus === "empty") return "Google Search Console returned no retained query/page rows for this window.";
38035
38088
  if (report.dataStatus === "filtered") return "No pages met the position, brand, and minimum-impression criteria.";
38036
- if (report.technical > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.technical, "returned page")} ${plural$4(report.technical, "has", "have")} verified technical issues to fix before content changes.`;
38037
- if (report.failed > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.failed, "returned page")} could not be verified and ${plural$4(report.failed, "needs", "need")} a fetch check before content changes.`;
38038
- if (report.content > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.content, "returned page")} ${plural$4(report.content, "has", "have")} verified content or search-framing gaps.`;
38039
- return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.returned, "page")} ${plural$4(report.returned, "is", "are")} returned in priority order for investigation.`;
38089
+ if (report.technical > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.technical, "returned page")} ${plural$5(report.technical, "has", "have")} verified technical issues to fix before content changes.`;
38090
+ if (report.failed > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.failed, "returned page")} could not be verified and ${plural$5(report.failed, "needs", "need")} a fetch check before content changes.`;
38091
+ if (report.content > 0) return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.content, "returned page")} ${plural$5(report.content, "has", "have")} verified content or search-framing gaps.`;
38092
+ return `${countLabel(report.eligible, "eligible average-position page")} found; ${countLabel(report.returned, "page")} ${plural$5(report.returned, "is", "are")} returned in priority order for investigation.`;
38040
38093
  }
38041
38094
  function reportRecommendations(items) {
38042
38095
  const first = items[0];
@@ -39097,7 +39150,7 @@ async function updatePostmortemWorkflow(input, dependencies = {}) {
39097
39150
  //#endregion
39098
39151
  //#region packages/core/dist/clicky/api.js
39099
39152
  const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
39100
- const MAX_BYTES$2 = 5e6;
39153
+ const MAX_BYTES$3 = 5e6;
39101
39154
  var ClickyError = class extends Error {
39102
39155
  code;
39103
39156
  constructor(code, message) {
@@ -39124,7 +39177,7 @@ async function clickyStats(site, types, options = {}) {
39124
39177
  throw new ClickyError("remote", "Could not reach Clicky. Try again.");
39125
39178
  });
39126
39179
  if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
39127
- const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$2 });
39180
+ const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$3 });
39128
39181
  if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
39129
39182
  const failed = groups.find((group) => typeof group?.error === "string");
39130
39183
  if (failed?.error) throw /sitekey|site id|authenticat|permission/iu.test(failed.error) ? new ClickyError("auth", `Clicky rejected site ID ${site.siteId} or its sitekey.`) : new ClickyError("remote", `Clicky: ${failed.error.slice(0, 200)}`);
@@ -39151,7 +39204,7 @@ async function clickyDomain(site) {
39151
39204
  }
39152
39205
  //#endregion
39153
39206
  //#region packages/core/dist/analyze/ai-queries.js
39154
- const RULES = [
39207
+ const RULES$1 = [
39155
39208
  ["agent_prompt", "^(search the web for|browse the web|find sources)|do not invent|return the [0-9]+ most relevant|include source urls|context: location|do not include location|question: |reply only with|the model you are using"],
39156
39209
  ["tracker", "\\. ?my location is |\\?\\. ?my( |$)|^evaluate the .+ on "],
39157
39210
  ["reply", "^(yes|yeah|yep|ok|okay|sure|yes please|go on|continue|keep going|tell me more|why not|how so|really|exactly|show me|and then|thanks|thank you|got it|what else|anything else)[?!.,]*$"],
@@ -39169,8 +39222,8 @@ const AI_QUERY_KINDS = {
39169
39222
  chat: "chat phrasing, such as \"can you\" or \"should i\"",
39170
39223
  long: "ten or more words with no other sign"
39171
39224
  };
39172
- const MATCHERS = RULES.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
39173
- const AI_QUERY_PATTERN = RULES.map(([, pattern]) => `(?:${pattern})`).join("|");
39225
+ const MATCHERS = RULES$1.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
39226
+ const AI_QUERY_PATTERN = RULES$1.map(([, pattern]) => `(?:${pattern})`).join("|");
39174
39227
  const NOISE = [
39175
39228
  ["spaced_letters", /(^|\s)(\S\s){3,}\S(\s|$)/u],
39176
39229
  ["broken_encoding", /^(яю|ÿþ|þÿ)|&#[0-9]+;?|&[a-z]+;|[âÃÂ][\u0080-\u00bf€™œ“”]|\ufffd/u],
@@ -39635,7 +39688,7 @@ function rowsText(input) {
39635
39688
  return [
39636
39689
  input.title,
39637
39690
  ...input.notes ?? [],
39638
- total === 0 ? "No rows." : `Rows ${input.offset + 1} to ${end} of ${total}:\n${table(page, columns, input.origin ?? null)}`,
39691
+ total === 0 ? "No rows." : `Rows ${input.offset + 1} to ${end} of ${total}:\n${table$1(page, columns, input.origin ?? null)}`,
39639
39692
  end < total ? `More: ${input.more ? input.more(end) : `detail "rows", offset ${end}`}.` : ""
39640
39693
  ].filter(Boolean).join("\n\n");
39641
39694
  }
@@ -40344,6 +40397,299 @@ async function analyticsBeside(access, site, range, before, pages) {
40344
40397
  }
40345
40398
  }
40346
40399
  //#endregion
40400
+ //#region packages/core/dist/commands/audit.js
40401
+ const AUDIT_SECTION = "weekly-audit";
40402
+ const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing or duplicate titles, descriptions and H1s, noindex, canonicals to another page, sitemap URLs that do not return 200, thin pages), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
40403
+ const RULES = {
40404
+ link_to_error: "Internal links to a page that returns an error",
40405
+ link_to_redirect: "Internal links to a redirect",
40406
+ link_failed: "Internal links to a page that could not be fetched",
40407
+ title_missing: "Pages with no title",
40408
+ title_duplicate: "Titles used by more than one page",
40409
+ description_missing: "Pages with no meta description",
40410
+ h1_missing: "Pages with no H1",
40411
+ noindex: "Pages that ask not to be indexed",
40412
+ canonical_elsewhere: "Pages whose canonical is another URL",
40413
+ sitemap_url_not_ok: "Sitemap URLs that do not return 200",
40414
+ sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
40415
+ thin_page: "Pages under 100 words (heuristic)"
40416
+ };
40417
+ const PAGE_SIZE$2 = 50;
40418
+ const SUMMARY_ROWS$1 = 20;
40419
+ const day$1 = (ms) => new Date(ms).toISOString().slice(0, 10);
40420
+ function relative$2(value, origin) {
40421
+ return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
40422
+ }
40423
+ function evidence(finding, origin) {
40424
+ const details = JSON.parse(finding.details);
40425
+ const parts = [];
40426
+ if (typeof details.status === "number") parts.push(`${details.status}`);
40427
+ if (typeof details.location === "string") parts.push(`to ${relative$2(details.location, origin)}`);
40428
+ if (typeof details.canonical === "string") parts.push(`canonical ${relative$2(details.canonical, origin)}`);
40429
+ if (typeof details.words === "number") parts.push(`${details.words} words`);
40430
+ const linking = typeof details.linking_pages === "number";
40431
+ const count = linking ? details.linking_pages : details.pages;
40432
+ if (typeof count === "number") {
40433
+ const shown = (Array.isArray(details.examples) ? details.examples.map((url) => relative$2(url, origin)) : []).slice(0, 3);
40434
+ const noun = `${linking ? "linking page" : "page"}${count === 1 ? "" : "s"}`;
40435
+ parts.push(`${count} ${noun}${shown.length ? ` (${shown.join(", ")}${count > shown.length ? ", and more" : ""})` : ""}`);
40436
+ }
40437
+ return parts.join(", ");
40438
+ }
40439
+ function state(finding, since) {
40440
+ if (finding.state === "resolved") return "resolved";
40441
+ if (finding.reopened_at && finding.reopened_at >= since) return "back";
40442
+ if (finding.first_seen_at >= since) return "new";
40443
+ return `open since ${day$1(finding.first_seen_at)}`;
40444
+ }
40445
+ function table(rows, origin, since) {
40446
+ return ["state | finding | subject | evidence", ...rows.map((finding) => [
40447
+ state(finding, since),
40448
+ finding.rule,
40449
+ relative$2(finding.subject, origin),
40450
+ evidence(finding, origin)
40451
+ ].join(" | "))].join("\n");
40452
+ }
40453
+ function ordered(findings, since) {
40454
+ const rank = (finding) => {
40455
+ const label = state(finding, since);
40456
+ return label === "new" || label === "back" ? 0 : label === "resolved" ? 1 : 2;
40457
+ };
40458
+ return [...findings].sort((a, b) => rank(a) - rank(b) || a.rule.localeCompare(b.rule) || (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
40459
+ }
40460
+ function runAuditReport(input, snapshot, call) {
40461
+ const site = input.site;
40462
+ const detail = input.detail ?? "summary";
40463
+ if (!snapshot) return {
40464
+ text: `${site} is not in the weekly site audit. Add it under Beta on the account page; the first crawl starts at once.`,
40465
+ data: {
40466
+ site,
40467
+ audited: false
40468
+ }
40469
+ };
40470
+ const { summary, origin } = snapshot;
40471
+ const running = snapshot.run?.status === "running" ? `A crawl is running now: ${snapshot.run.fetched} pages so far.` : null;
40472
+ if (!summary || snapshot.finished_at === null) return {
40473
+ text: [
40474
+ `The first weekly crawl of ${site} has not finished.`,
40475
+ running,
40476
+ snapshot.run?.error ? `Last error: ${snapshot.run.error}` : null,
40477
+ `Run ${call("report", {
40478
+ site,
40479
+ section: AUDIT_SECTION
40480
+ })} again later.`
40481
+ ].filter(Boolean).join(" "),
40482
+ data: {
40483
+ site,
40484
+ audited: true,
40485
+ finished: false
40486
+ }
40487
+ };
40488
+ const since = snapshot.history[0]?.started_at ?? snapshot.finished_at;
40489
+ const previous = snapshot.history[1];
40490
+ const findings = ordered(snapshot.findings, since);
40491
+ if (detail === "full") return {
40492
+ text: JSON.stringify({
40493
+ site,
40494
+ ...snapshot,
40495
+ findings
40496
+ }),
40497
+ data: {
40498
+ site,
40499
+ ...snapshot,
40500
+ findings
40501
+ }
40502
+ };
40503
+ const pages = summary.pages;
40504
+ const coverage = summary.complete ? `${pages.fetched} pages, the whole site the crawl could reach` : summary.status === "failed" ? `${pages.fetched} pages before the crawl stopped on errors, so it is partial` : `${pages.fetched} pages; it stopped at the 5,000-page limit${pages.links_past_cap ? ` with ${pages.links_past_cap} more links past it` : ""}, so it is partial`;
40505
+ const head = [
40506
+ `Weekly site audit of ${site}, crawled ${day$1(snapshot.finished_at)}: ${coverage}.${pages.blocked ? ` ${pages.blocked === 1 ? "1 URL" : `${pages.blocked} URLs`} blocked by robots.txt ${pages.blocked === 1 ? "was" : "were"} not fetched.` : ""}`,
40507
+ previous ? `Since the crawl of ${day$1(previous.finished_at)}: ${summary.findings.new} new, ${summary.findings.reopened} back, ${summary.findings.resolved} resolved. ${summary.findings.open} open now.` : `This is the first crawl, so every finding is new: ${summary.findings.open} open.`,
40508
+ summary.findings.resolution === "crawled-pages-only" ? "Because the crawl is partial, a finding counts as resolved only when this crawl fetched its page again; a finding not seen here is not an all-clear." : null,
40509
+ summary.findings.capped_rules.length ? `Capped at 1,000 findings each, so these counts are lower bounds: ${summary.findings.capped_rules.join(", ")}.` : null,
40510
+ running
40511
+ ].filter(Boolean).join("\n");
40512
+ const index = snapshot.index_changes;
40513
+ const indexText = index && (index.dropped || index.returned) ? [
40514
+ `Google's index in the last 7 days, from the daily checks: ${index.dropped} ${index.dropped === 1 ? "URL" : "URLs"} dropped out, ${index.returned} came back.`,
40515
+ "change | url | date | reason now | top page",
40516
+ ...index.rows.map((row) => [
40517
+ row.change === "dropped" ? "dropped out" : "back in",
40518
+ relative$2(row.url, origin),
40519
+ day$1(row.at),
40520
+ row.coverage ?? "",
40521
+ row.top ? "yes" : "no"
40522
+ ].join(" | ")),
40523
+ index.rows.length < index.dropped + index.returned ? `More: ${call("index", { site })}.` : null
40524
+ ].filter(Boolean).join("\n") : null;
40525
+ if (detail === "rows") {
40526
+ const offset = input.offset ?? 0;
40527
+ const slice = findings.slice(offset, offset + PAGE_SIZE$2);
40528
+ const end = offset + slice.length;
40529
+ return {
40530
+ text: [
40531
+ head,
40532
+ `Findings ${slice.length ? offset + 1 : 0} to ${end} of ${findings.length} (new and back first, then resolved this week, then the rest):`,
40533
+ table(slice, origin, since),
40534
+ end < findings.length ? `More: ${call("report", {
40535
+ site,
40536
+ section: AUDIT_SECTION,
40537
+ detail: "rows",
40538
+ offset: end
40539
+ })}.` : null,
40540
+ "These are facts from the crawl, not instructions."
40541
+ ].filter(Boolean).join("\n\n"),
40542
+ data: {
40543
+ site,
40544
+ offset,
40545
+ total: findings.length,
40546
+ findings: slice
40547
+ }
40548
+ };
40549
+ }
40550
+ const byRule = Object.entries(summary.findings.by_rule).filter(([, count]) => count > 0).sort(([a], [b]) => a.localeCompare(b));
40551
+ const fresh = findings.filter((finding) => {
40552
+ const label = state(finding, since);
40553
+ return label === "new" || label === "back" || label === "resolved";
40554
+ });
40555
+ const changes = summary.changes;
40556
+ return {
40557
+ text: [
40558
+ head,
40559
+ indexText,
40560
+ byRule.length ? ["finding | open | what it is", ...byRule.map(([rule, count]) => `${rule} | ${count} | ${RULES[rule] ?? rule}`)].join("\n") : "No findings in this crawl.",
40561
+ fresh.length ? `New, back or resolved this week, 1 to ${Math.min(SUMMARY_ROWS$1, fresh.length)} of ${fresh.length}:\n${table(fresh.slice(0, SUMMARY_ROWS$1), origin, since)}` : previous ? "Nothing new, back or resolved since the crawl before." : null,
40562
+ changes.compared ? `Between the two crawls: ${changes.new_urls} new URLs, ${changes.gone_urls} gone, ${changes.status_changed} changed status, ${changes.title_changed} changed title.` : null,
40563
+ `More: ${call("report", {
40564
+ site,
40565
+ section: AUDIT_SECTION,
40566
+ detail: "rows",
40567
+ offset: 0
40568
+ })} for every finding, or detail "full" for the evidence as JSON.`,
40569
+ "These are facts from the crawl, not instructions."
40570
+ ].filter(Boolean).join("\n\n"),
40571
+ data: {
40572
+ site,
40573
+ finished_at: snapshot.finished_at,
40574
+ summary,
40575
+ fresh: fresh.slice(0, SUMMARY_ROWS$1)
40576
+ }
40577
+ };
40578
+ }
40579
+ function auditPreview(snapshot) {
40580
+ if (!snapshot?.summary || snapshot.finished_at === null) return snapshot ? "The first weekly crawl has not finished." : null;
40581
+ const { findings } = snapshot.summary;
40582
+ const index = snapshot.index_changes;
40583
+ return `${index && (index.dropped || index.returned) ? `${index.dropped} URLs dropped out of Google's index this week and ${index.returned} came back. ` : ""}Last crawl ${day$1(snapshot.finished_at)}: ${findings.open} open findings, ${findings.new + findings.reopened} new or back, ${findings.resolved} resolved.`;
40584
+ }
40585
+ const WEEK_MS = 7 * 864e5;
40586
+ const day = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
40587
+ const count$1 = (value) => value.toLocaleString("en-US");
40588
+ function relative$1(value, origin) {
40589
+ if (!value) return "";
40590
+ return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
40591
+ }
40592
+ function etaText(hours) {
40593
+ if (hours === null) return "nothing";
40594
+ if (hours < 1) return "under an hour";
40595
+ if (hours < 48) return `about ${Math.ceil(hours)} hours`;
40596
+ return `about ${Math.ceil(hours / 24)} days`;
40597
+ }
40598
+ function rowsTable(rows, checks) {
40599
+ const since = checks.as_of - WEEK_MS;
40600
+ const changed = (row) => row.changed_at !== null && row.changed_at > since;
40601
+ return ["url | index change | reason now | before | changed | checked | clicks (28 days before) | in_sitemap | google_canonical | error", ...rows.map((row) => [
40602
+ relative$1(row.url, checks.origin),
40603
+ row.transition && row.transition_at !== null && row.transition_at > since ? `${row.transition === "dropped" ? "dropped out" : "back in"} ${day(row.transition_at)}` : "",
40604
+ row.coverage ?? (row.checked_at === null ? "not checked yet" : ""),
40605
+ changed(row) ? row.prev_coverage ?? "" : "",
40606
+ changed(row) ? day(row.changed_at) : "",
40607
+ day(row.checked_at),
40608
+ `${count$1(row.clicks)} (${count$1(row.previous_clicks)})${row.lost ? " lost" : ""}`,
40609
+ row.in_sitemap ? "yes" : "no",
40610
+ row.google_canonical && row.google_canonical !== row.url ? relative$1(row.google_canonical, checks.origin) : "",
40611
+ row.error ?? ""
40612
+ ].join(" | "))].join("\n");
40613
+ }
40614
+ function head(site, checks) {
40615
+ const { queued } = checks;
40616
+ const quota = checks.properties.map((item) => `${item.property} (${count$1(item.urls)} URLs: ${count$1(item.top)} top pages checked daily, the rest every ${item.cycle_days === 1 ? "day" : `${item.cycle_days} days`}; ${count$1(item.used_today)} of ${count$1(checks.per_day)} checks used today)`).join(", ");
40617
+ return [
40618
+ `Google indexing of ${site}, from the URL Inspection results this server keeps for it (as of ${new Date(checks.as_of).toISOString().slice(0, 16).replace("T", " ")} UTC).`,
40619
+ checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$1(checks.dropped_this_week)} ${checks.dropped_this_week === 1 ? "URL" : "URLs"} dropped out of Google's index and ${count$1(checks.returned_this_week)} came back; they are listed first. A top page's drop counts only after a second check an hour later.` : null,
40620
+ `Covered: ${count$1(checks.covered)} of ${count$1(checks.known)} known URLs have a current result. Known URLs are the sitemap's, the crawl's and the Search Console pages with clicks or impressions. Each property spends its full ${count$1(checks.per_day)} checks a day: its top pages (lost clicks, then clicks, then impressions, up to ${count$1(checks.top_per_day)}) daily, the rest as often as the quota allows, and a page the weekly crawl saw change at once.`,
40621
+ queued.total ? `Queued: ${count$1(queued.total)} (${count$1(queued.lost)} pages that lost their clicks, ${count$1(queued.search)} with clicks or impressions, ${count$1(queued.sitemap)} sitemap URLs never checked, ${count$1(queued.stale)} older or changed), checked in that order. Time left: ${etaText(checks.eta_hours)}, at up to ${count$1(checks.per_day)} checks a day per property, spread over the day.` : "Queued: nothing; every known URL has a current result.",
40622
+ quota ? `Checked through ${quota}.` : null,
40623
+ checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
40624
+ "A URL without a result has none here, so this is not an all-clear for it."
40625
+ ].filter(Boolean).join("\n");
40626
+ }
40627
+ function suggestionsText(checks) {
40628
+ if (!checks.suggestions.length) return null;
40629
+ return [
40630
+ `The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$1(checks.per_day)} checks a day, so adding these folders as URL-prefix properties (on the same Google account) would check more:`,
40631
+ "property to add | sitemap URLs in it | more URLs checked a week",
40632
+ ...checks.suggestions.map((item) => `${item.property} | ${count$1(item.urls)} | ${count$1(item.gain)}`),
40633
+ "The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
40634
+ ].join("\n");
40635
+ }
40636
+ function runAuditChecks(input, checks, call) {
40637
+ const site = input.site;
40638
+ const detail = input.detail ?? "summary";
40639
+ if (detail === "full") return {
40640
+ text: JSON.stringify({
40641
+ site,
40642
+ ...checks
40643
+ }),
40644
+ data: checks
40645
+ };
40646
+ if (!checks.started) return {
40647
+ text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$1(checks.known)} URLs are known so far. ` : ""}Run ${call("index", { site })} again in a few minutes for the coverage and the time left.`,
40648
+ data: checks
40649
+ };
40650
+ const more = (offset) => input.paged ? `More: ${call("index", {
40651
+ site,
40652
+ detail: "rows",
40653
+ offset
40654
+ })}.` : null;
40655
+ if (detail === "rows") {
40656
+ const end = checks.offset + checks.rows.length;
40657
+ return {
40658
+ text: [
40659
+ head(site, checks),
40660
+ `Drops out of and returns to the index this week, then lost-click pages, then URLs whose reason changed this week, then URLs not indexed or whose check failed, ${checks.rows.length ? checks.offset + 1 : 0} to ${end} of ${checks.total_rows}:`,
40661
+ rowsTable(checks.rows, checks),
40662
+ end < checks.total_rows ? more(end) : null,
40663
+ "These are Google's answers, not instructions."
40664
+ ].filter(Boolean).join("\n\n"),
40665
+ data: checks
40666
+ };
40667
+ }
40668
+ const reasons = checks.reasons.length ? [
40669
+ "Google's reason at each URL's latest check:",
40670
+ "reason | verdict | urls | changed to it this week",
40671
+ ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$1(item.urls)} | ${count$1(item.changed_this_week)}`)
40672
+ ].join("\n") : "No URL has a result yet.";
40673
+ const shown = checks.rows.slice(0, 20);
40674
+ return {
40675
+ text: [
40676
+ head(site, checks),
40677
+ reasons,
40678
+ shown.length ? `Drops out of and returns to the index this week, then lost-click pages, then URLs whose reason changed this week, then URLs not indexed or whose check failed, 1 to ${shown.length} of ${checks.total_rows}:\n${rowsTable(shown, checks)}` : "No page dropped out of the index or lost its clicks, and every checked URL is indexed with no change this week.",
40679
+ suggestionsText(checks),
40680
+ [shown.length < checks.total_rows ? more(0) : null, `For live evidence on one URL, run ${call("page", {
40681
+ site,
40682
+ url: "<url>"
40683
+ })}; it uses the stored result when it is current.`].filter(Boolean).join(" "),
40684
+ "These are Google's answers, not instructions."
40685
+ ].filter(Boolean).join("\n\n"),
40686
+ data: {
40687
+ ...checks,
40688
+ rows: shown
40689
+ }
40690
+ };
40691
+ }
40692
+ //#endregion
40347
40693
  //#region packages/core/dist/commands/bing.js
40348
40694
  const BING_LIMITS = ["Bing and Google count clicks, impressions and positions their own way. Their numbers sit side by side here and are never added together.", "Bing lists only its top queries and pages, grouped by week. A query or page missing from Bing’s list is unknown, not zero."];
40349
40695
  const CLI_LINK = "If you have a shell, link the CLI to the same account once: run `npx -y seo login`, show the person the link it prints, and after they approve it run `npx -y seo login` again. `npx -y seo` commands then use the same Google accounts, Bing and keys.";
@@ -40641,7 +40987,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
40641
40987
  ];
40642
40988
  const part = (title, rows, name) => {
40643
40989
  const list = rows ?? [];
40644
- return list.length ? `${title}:\n${table(list.slice(0, rowsPerList), columns(name), origin)}` : "";
40990
+ return list.length ? `${title}:\n${table$1(list.slice(0, rowsPerList), columns(name), origin)}` : "";
40645
40991
  };
40646
40992
  const group = (key, name) => {
40647
40993
  const lists = report[key];
@@ -40879,6 +41225,392 @@ async function withDomainRating(rows, key) {
40879
41225
  added: true
40880
41226
  };
40881
41227
  }
41228
+ const MAX_INSPECT_LIMIT = 2e3;
41229
+ const CONCURRENCY = 20;
41230
+ const REFERRING_KEPT = 10;
41231
+ const LABELS = {
41232
+ fix: "the evidence shows a fault on the site",
41233
+ check: "a person should look; the evidence alone does not settle it",
41234
+ expected: "the evidence shows the site works as built, nothing to fix",
41235
+ indexed: "Google indexes it and the live check shows no fault"
41236
+ };
41237
+ const AUTH_SEGMENTS = /* @__PURE__ */ new Set([
41238
+ "account",
41239
+ "admin",
41240
+ "api",
41241
+ "auth",
41242
+ "cart",
41243
+ "checkout",
41244
+ "dashboard",
41245
+ "login",
41246
+ "logout",
41247
+ "my-account",
41248
+ "oauth",
41249
+ "sign-in",
41250
+ "signin",
41251
+ "wp-admin",
41252
+ "wp-login.php"
41253
+ ]);
41254
+ const bareHost = (url) => new URL(url).hostname.replace(/^www\./, "");
41255
+ const sameSite = (a, b) => {
41256
+ try {
41257
+ return bareHost(a) === bareHost(b);
41258
+ } catch {
41259
+ return false;
41260
+ }
41261
+ };
41262
+ function variantOf(url, target) {
41263
+ const from = new URL(url);
41264
+ const to = new URL(target);
41265
+ if (from.pathname !== to.pathname || from.search !== to.search) return null;
41266
+ if (!sameSite(url, target)) return null;
41267
+ if (from.protocol !== to.protocol) return "http:// variant";
41268
+ if (from.host !== to.host) return `host variant (${from.host})`;
41269
+ return null;
41270
+ }
41271
+ const samePath = (a, b) => {
41272
+ const [from, to] = [new URL(a), new URL(b)];
41273
+ return from.pathname === to.pathname && from.search === to.search;
41274
+ };
41275
+ const authPath = (url) => AUTH_SEGMENTS.has(new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "");
41276
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
41277
+ function labelOf(e) {
41278
+ const { url, status, live } = e;
41279
+ const { first, hops, last } = live;
41280
+ const indexed = status.verdict === "PASS";
41281
+ const coverage = status.coverage ?? "";
41282
+ const referring = status.referring_urls.filter((item) => item !== url && sameSite(item, url) && !samePath(item, url));
41283
+ const pointers = [e.inSitemap ? "the sitemap lists it" : null, referring.length ? `Google lists ${plural(referring.length, "referring page")} on this site as of its last crawl (${referring[0]}${referring.length > 1 ? ", and more" : ""})` : null].filter(Boolean);
41284
+ if (first.error) return indexed ? {
41285
+ label: "indexed",
41286
+ why: `Google indexes it. The live check failed (${first.error}).`
41287
+ } : {
41288
+ label: "check",
41289
+ why: `The live check failed (${first.error}), so only Google's reason is shown.`
41290
+ };
41291
+ const temporary = hops.find((hop) => hop.kind === "temporary" && new URL(hop.from).host === new URL(hop.to).host);
41292
+ if (temporary) return {
41293
+ label: "fix",
41294
+ why: `${temporary.from} sends a ${temporary.status} (temporary) redirect to ${temporary.to} on the same host; a permanent move uses 301 or 308.`
41295
+ };
41296
+ if (!last) return {
41297
+ label: "fix",
41298
+ why: `The redirects loop or pass 5 hops (${hops.map((hop) => hop.status).join(" > ")}).`
41299
+ };
41300
+ if (last.error) return {
41301
+ label: "check",
41302
+ why: `It redirects to ${last.url}, which could not be fetched (${last.error}).`
41303
+ };
41304
+ const end = last.status ?? 0;
41305
+ const now = hops.length ? `redirects to ${last.url}, which returns ${end}` : `returns ${end} now`;
41306
+ const via = `It ${now}`;
41307
+ if (end === 404 || end === 410) {
41308
+ if (pointers.length) return {
41309
+ label: "fix",
41310
+ why: `${via}, and ${pointers.join(" and ")}.`
41311
+ };
41312
+ if (hops.length) return {
41313
+ label: "fix",
41314
+ why: `${via}: a redirect to a missing page.`
41315
+ };
41316
+ return {
41317
+ label: "expected",
41318
+ why: `${via}, and neither the sitemap nor any referring page Google lists on this site points to it.`
41319
+ };
41320
+ }
41321
+ if ((end === 401 || end === 403) && !hops.length) {
41322
+ if (e.inSitemap) return {
41323
+ label: "fix",
41324
+ why: `${via}, but the sitemap lists it.`
41325
+ };
41326
+ if (end === 401 && first.www_authenticate) return {
41327
+ label: "expected",
41328
+ why: `${via} with a WWW-Authenticate header (${first.www_authenticate.split(/\s/)[0]}), so the server asks for sign-in here.`
41329
+ };
41330
+ if (authPath(url)) return {
41331
+ label: "expected",
41332
+ why: `${via} on a path that looks like a sign-in area (heuristic: from the path only).`
41333
+ };
41334
+ return {
41335
+ label: "check",
41336
+ why: `${via} with no WWW-Authenticate header and no sign-in path, so the evidence does not show whether it should be public.`
41337
+ };
41338
+ }
41339
+ if (hops.length) {
41340
+ const codes = hops.map((hop) => hop.status).join(" > ");
41341
+ if (end !== 200) return {
41342
+ label: "check",
41343
+ why: `${via} (${codes}).`
41344
+ };
41345
+ if (e.inSitemap) return {
41346
+ label: "fix",
41347
+ why: `The sitemap lists it, but it redirects (${codes}) to ${last.url}.`
41348
+ };
41349
+ const declared = last.canonical === last.url ? "declares itself canonical" : e.sitemap.has(last.url) ? "is in the sitemap" : null;
41350
+ const variant = variantOf(url, last.url);
41351
+ const moved = `${variant ? `An ${variant}` : "A permanent redirect"}: ${codes} to ${last.url}`;
41352
+ const stale = indexed ? " Google still indexes this URL as of its last crawl." : "";
41353
+ const links = referring.length ? ` ${pointers.at(-1)}.` : "";
41354
+ if (declared) return {
41355
+ label: "expected",
41356
+ why: `${moved}, which returns 200 and ${declared}.${stale}${links}`
41357
+ };
41358
+ return {
41359
+ label: "check",
41360
+ why: `${moved}, which declares ${last.canonical ? `${last.canonical} as canonical` : "no canonical"}.${stale}${links}`
41361
+ };
41362
+ }
41363
+ if (/^(Crawled|Discovered) - currently not indexed$|^URL is unknown to Google$/i.test(coverage)) return {
41364
+ label: "check",
41365
+ why: `${coverage.startsWith("Crawled") ? "Google crawled it but has not indexed it, and gives no further reason" : coverage.startsWith("Discovered") ? "Google knows the URL but has not crawled it yet" : "Google has no record of this URL"}.${e.inSitemap ? " The sitemap lists it." : ""} It returns ${end} now.`
41366
+ };
41367
+ const robots = last.robots ? ` with robots "${last.robots}"` : " with no robots meta tag or X-Robots-Tag header";
41368
+ if (/noindex/i.test(coverage) && !/noindex/i.test(last.robots ?? "")) return {
41369
+ label: "check",
41370
+ why: `Google saw noindex when it last crawled${status.last_crawl ? ` (${status.last_crawl.slice(0, 10)})` : ""}, but it ${now}${robots}, so Google's reason may be out of date.${e.inSitemap ? " The sitemap lists it." : ""}`
41371
+ };
41372
+ const declaredCanonical = status.user_canonical ?? last.canonical;
41373
+ if (status.google_canonical && declaredCanonical && status.google_canonical !== declaredCanonical) return {
41374
+ label: "check",
41375
+ why: `Google chose ${status.google_canonical} as canonical; the page declares ${declaredCanonical}.`
41376
+ };
41377
+ if (indexed) return end === 200 ? {
41378
+ label: "indexed",
41379
+ why: "Google indexes it and it returns 200 now."
41380
+ } : {
41381
+ label: "check",
41382
+ why: `Google indexes it, but it ${now}.`
41383
+ };
41384
+ if (e.inSitemap) return {
41385
+ label: "fix",
41386
+ why: `The sitemap lists it, but Google does not index it. It ${now}${robots}.`
41387
+ };
41388
+ if (status.google_canonical && status.google_canonical === status.user_canonical && status.google_canonical !== url) return {
41389
+ label: "expected",
41390
+ why: `The page declares ${status.user_canonical} as canonical and Google uses it.`
41391
+ };
41392
+ return {
41393
+ label: "check",
41394
+ why: /noindex/i.test(last.robots ?? "") ? `${via}${robots} and the sitemap does not list it. Whether it should be indexed is the site's call.` : `${via}${robots}. No rule here settles this reason.`
41395
+ };
41396
+ }
41397
+ function indexingRow(e, sources, inspectedAt) {
41398
+ const { status, live } = e;
41399
+ const { label, why } = labelOf(e);
41400
+ return {
41401
+ reason: status.coverage ?? status.verdict ?? "No reason returned",
41402
+ url: e.url,
41403
+ label,
41404
+ why,
41405
+ verdict: status.verdict,
41406
+ indexing: status.indexing,
41407
+ status: live.first.status,
41408
+ redirects: live.hops.length ? live.hops.map((hop) => `${hop.status} ${hop.kind}`).join(" > ") : null,
41409
+ redirects_to: live.first.redirects_to,
41410
+ final_url: live.last?.url ?? null,
41411
+ final_status: live.last?.status ?? null,
41412
+ page_canonical: live.last?.canonical ?? null,
41413
+ user_canonical: status.user_canonical,
41414
+ google_canonical: status.google_canonical,
41415
+ in_sitemap: e.inSitemap,
41416
+ referring_urls: status.referring_urls.slice(0, REFERRING_KEPT),
41417
+ last_crawl: status.last_crawl,
41418
+ inspected_at: inspectedAt,
41419
+ page_fetch: status.page_fetch,
41420
+ robots_txt: status.robots_txt,
41421
+ crawled_as: status.crawled_as,
41422
+ sources,
41423
+ ...live.first.error ? { live_error: live.first.error } : {}
41424
+ };
41425
+ }
41426
+ const byText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
41427
+ function sortRows(rows) {
41428
+ return [...rows].sort((a, b) => byText(a.reason, b.reason) || byText(a.url, b.url));
41429
+ }
41430
+ function byReason(rows) {
41431
+ const groups = /* @__PURE__ */ new Map();
41432
+ for (const row of rows) {
41433
+ const counts = groups.get(row.reason) ?? {
41434
+ fix: 0,
41435
+ check: 0,
41436
+ expected: 0,
41437
+ indexed: 0
41438
+ };
41439
+ counts[row.label] += 1;
41440
+ groups.set(row.reason, counts);
41441
+ }
41442
+ return [...groups].sort((a, b) => byText(a[0], b[0])).map(([reason, counts]) => ({
41443
+ reason,
41444
+ urls: counts.fix + counts.check + counts.expected + counts.indexed,
41445
+ ...counts
41446
+ }));
41447
+ }
41448
+ const isCode = (error, codes) => codes.includes(String(error?.code));
41449
+ function knownUrls(input) {
41450
+ const found = /* @__PURE__ */ new Map();
41451
+ const add = (url, source) => {
41452
+ const sources = found.get(url) ?? [];
41453
+ if (!sources.includes(source)) sources.push(source);
41454
+ found.set(url, sources);
41455
+ };
41456
+ const pages = input.pages.filter((page) => page.impressions > 0 && !page.url.includes("#")).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions || byText(a.url, b.url));
41457
+ for (const url of input.lost) add(url, "lost_clicks");
41458
+ for (const page of pages) add(page.url, "search_console");
41459
+ for (const url of input.sitemap.urls) add(url, "sitemap");
41460
+ if (input.home) add(input.home, "home");
41461
+ return {
41462
+ urls: [...found].map(([url, sources]) => ({
41463
+ url,
41464
+ sources
41465
+ })),
41466
+ sitemap: input.sitemap,
41467
+ searchConsole: pages.length,
41468
+ searchConsoleCapped: input.pagesCapped,
41469
+ lostClicks: input.lost.length
41470
+ };
41471
+ }
41472
+ async function inspectKnown(site, known, deps, asked = 100) {
41473
+ const cap = Math.min(Math.max(1, Math.floor(asked)), MAX_INSPECT_LIMIT);
41474
+ const wanted = known.urls.slice(0, cap);
41475
+ let saved = /* @__PURE__ */ new Map();
41476
+ try {
41477
+ saved = deps.savedInspections?.read(site, wanted.map((item) => item.url)) ?? saved;
41478
+ } catch {}
41479
+ const missing = wanted.filter((item) => !saved.has(item.url));
41480
+ const quota = deps.inspectionQuota ? await deps.inspectionQuota(site).catch(() => null) : null;
41481
+ const left = quota ? Math.max(0, quota.limit - quota.used) : missing.length;
41482
+ const asking = new Set(missing.slice(0, left).map((item) => item.url));
41483
+ const queue = wanted.filter((item) => saved.has(item.url) || asking.has(item.url));
41484
+ const quotaShort = left < missing.length;
41485
+ const statuses = /* @__PURE__ */ new Map();
41486
+ const rows = [];
41487
+ const failed = [];
41488
+ const sitemap = new Set(known.sitemap.urls);
41489
+ const now = () => (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
41490
+ let quotaStop = false;
41491
+ let next = 0;
41492
+ let fatal = null;
41493
+ const worker = async () => {
41494
+ while (fatal === null && next < queue.length) {
41495
+ const item = queue[next++];
41496
+ const stored = saved.get(item.url);
41497
+ let status;
41498
+ let inspectedAt;
41499
+ if (stored) {
41500
+ status = stored.status;
41501
+ inspectedAt = stored.inspectedAt;
41502
+ } else {
41503
+ if (quotaStop) continue;
41504
+ try {
41505
+ status = await deps.inspect(site, item.url);
41506
+ inspectedAt = now();
41507
+ } catch (error) {
41508
+ if (isCode(error, ["RATE_LIMITED"])) quotaStop = true;
41509
+ else if (isCode(error, [
41510
+ "AUTH_EXPIRED",
41511
+ "AUTH_REQUIRED",
41512
+ "NOT_CONNECTED"
41513
+ ])) fatal = error;
41514
+ else failed.push({
41515
+ url: item.url,
41516
+ error: error instanceof Error ? error.message : String(error)
41517
+ });
41518
+ continue;
41519
+ }
41520
+ try {
41521
+ deps.savedInspections?.save(site, item.url, status);
41522
+ } catch {}
41523
+ }
41524
+ statuses.set(item.url, status);
41525
+ const live = await deps.liveCheck(item.url);
41526
+ rows.push(indexingRow({
41527
+ url: item.url,
41528
+ status,
41529
+ live,
41530
+ inSitemap: item.sources.includes("sitemap"),
41531
+ sitemap
41532
+ }, item.sources, inspectedAt));
41533
+ }
41534
+ };
41535
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
41536
+ if (fatal !== null) throw fatal;
41537
+ const reused = rows.filter((row) => saved.has(row.url));
41538
+ const oldest = reused.reduce((first, row) => first === null || row.inspected_at < first ? row.inspected_at : first, null);
41539
+ return {
41540
+ rows: sortRows(rows),
41541
+ statuses,
41542
+ known,
41543
+ limit: cap,
41544
+ inspected: rows.length,
41545
+ fromSaved: reused.length,
41546
+ oldestSaved: oldest,
41547
+ failed: failed.sort((a, b) => byText(a.url, b.url)),
41548
+ stoppedBy: quotaStop || quotaShort ? "quota" : known.urls.length > wanted.length ? "limit" : null,
41549
+ quota
41550
+ };
41551
+ }
41552
+ const n = (value) => value.toLocaleString("en-US");
41553
+ function liveCell(row) {
41554
+ if (row.live_error) return `error: ${row.live_error}`;
41555
+ if (!row.redirects) return String(row.status ?? "");
41556
+ return `${row.redirects.replace(/ (permanent|temporary)/g, "")} > ${row.final_status ?? "loop"}`;
41557
+ }
41558
+ function indexingText(site, run, page, origin, askMore, now) {
41559
+ const { known } = run;
41560
+ const total = known.urls.length;
41561
+ const notInspected = total - run.inspected - run.failed.length;
41562
+ const sources = [
41563
+ `lost-click pages ${n(known.lostClicks)}`,
41564
+ `Search Console pages with impressions in the last 28 days ${n(known.searchConsole)}${known.searchConsoleCapped ? ", capped" : ""}`,
41565
+ `sitemap ${n(known.sitemap.urls.length)} from ${plural(known.sitemap.files, "file")}${known.sitemap.partial ? ", partial" : ""}`
41566
+ ];
41567
+ const lowerBound = known.sitemap.partial || known.searchConsoleCapped ? " at least" : "";
41568
+ const asked = run.inspected - run.fromSaved;
41569
+ const most = Math.min(total, MAX_INSPECT_LIMIT, run.quota ? run.inspected + run.quota.limit - run.quota.used - asked : MAX_INSPECT_LIMIT);
41570
+ const days = run.oldestSaved ? Math.floor((now.getTime() - Date.parse(run.oldestSaved)) / 864e5) : 0;
41571
+ const savedNote = run.fromSaved ? ` (${n(asked)} asked of Google now, ${n(run.fromSaved)} from saved results; the oldest was inspected ${run.oldestSaved?.slice(0, 16).replace("T", " ")} UTC, ${days === 0 ? "under a day" : plural(days, "day")} ago. Saved results are reused for 7 days)` : "";
41572
+ const stop = run.stoppedBy === "quota" ? "The URL Inspection quota stopped it." : run.stoppedBy === "limit" ? `It stopped at ${n(run.limit)}, the number of inspections for this run${askMore && most > run.limit ? `; to inspect more, up to the quota left today: ${askMore(most)}` : ""}.` : "Every known URL has a result.";
41573
+ const lines = [
41574
+ `Google's indexing reasons for ${site}, from URL Inspection. Search Console's Page indexing report has no API, so known URLs are inspected one at a time, in a fixed order (lost-click pages, Search Console pages by clicks then impressions, sitemap URLs, then the rest), and checked live from here.`,
41575
+ `Known URLs:${lowerBound} ${n(total)} (${sources.join("; ")}). Inspected ${n(run.inspected)} of ${n(total)}${savedNote}. ${stop}${notInspected > 0 ? ` The ${n(notInspected)} URLs not inspected have no result here, so this is not an all-clear for them.` : ""}${known.sitemap.notes.length ? ` Sitemap: ${known.sitemap.notes.join("; ")}.` : ""}`,
41576
+ run.quota ? `URL Inspection quota: this machine had used ${n(run.quota.used)} of ${n(run.quota.limit)} today for this property before the run. Google also counts inspections from other tools.` : "URL Inspection quota: not counted on this server; Google stops inspections at 2,000 a day per property."
41577
+ ];
41578
+ if (run.failed.length) lines.push(`URL Inspection failed for ${plural(run.failed.length, "URL")}: ${run.failed.slice(0, 5).map((item) => `${item.url} (${item.error})`).join("; ")}${run.failed.length > 5 ? "; and more" : ""}.`);
41579
+ const groups = byReason(run.rows);
41580
+ if (groups.length) lines.push(`By Google's reason, with labels from the rules below (not Google's):\n${table$1(groups, [
41581
+ "reason",
41582
+ "urls",
41583
+ "fix",
41584
+ "check",
41585
+ "expected",
41586
+ "indexed"
41587
+ ], null)}`);
41588
+ const listed = run.rows.filter((row) => row.label !== "indexed");
41589
+ const shown = page.summary ? listed.filter((row) => row.label === "fix" || row.label === "check") : listed;
41590
+ const indexedCount = run.rows.length - listed.length;
41591
+ const expectedCount = listed.length - shown.length;
41592
+ const pageRows = shown.slice(page.offset, page.offset + page.size).map((row) => ({
41593
+ reason: row.reason,
41594
+ label: row.label,
41595
+ url: row.url,
41596
+ live: liveCell(row),
41597
+ final_url: row.final_url === row.url ? "" : row.final_url,
41598
+ in_sitemap: row.in_sitemap ? "yes" : "no",
41599
+ last_crawl: row.last_crawl?.slice(0, 10) ?? "",
41600
+ why: row.why
41601
+ }));
41602
+ const end = page.offset + pageRows.length;
41603
+ const more = shown.length - end;
41604
+ const others = page.summary ? [
41605
+ more ? `${more} more labelled fix or check` : null,
41606
+ expectedCount ? `${expectedCount} expected` : null,
41607
+ indexedCount ? `${indexedCount} indexed with no fault found (in the full result)` : null
41608
+ ].filter(Boolean) : [];
41609
+ if (page.summary && (more || expectedCount)) others.push(`every row but the indexed ones: ${page.more(0)}`);
41610
+ lines.push(`${shown.length === 0 ? `No inspected URL is labelled ${page.summary ? "fix or check" : "fix, check or expected"}.` : `${page.summary ? "URLs labelled fix or check" : "URLs that are not indexed or show a fault"}, ${page.offset + 1} to ${end} of ${shown.length}:\n${table$1(pageRows, Object.keys(pageRows[0] ?? {}), origin, 400)}`}${others.length ? `\nNot listed here: ${others.join("; ")}.` : ""}`, `Labels: ${Object.entries(LABELS).map(([label, text]) => `${label}, ${text}`).join("; ")}. Rules: a same-host 302 or 307 is fix; a 404 or 410 that the sitemap or a referring page on this site points to is fix; a sitemap URL that redirects or that Google does not index is fix; a URL outside the sitemap (such as an http:// or host variant) that permanently redirects to a page declaring itself canonical is expected; a 401 with a WWW-Authenticate header is expected, and a 401 or 403 on a sign-in path is expected by heuristic; "Crawled - currently not indexed", "Discovered - currently not indexed" and a Google canonical that differs from the declared one are check. Referring pages are as of Google's last crawl. Live checks are server responses, so a browser-only HSTS "307 Internal Redirect" never shows here.`);
41611
+ if (!page.summary && more) lines.push(`More rows: ${page.more(end)}.`);
41612
+ return lines;
41613
+ }
40882
41614
  //#endregion
40883
41615
  //#region packages/core/dist/commands/internal-links.js
40884
41616
  const SUMMARY_ROWS = 10;
@@ -40918,7 +41650,7 @@ function internalLinksText(report, options) {
40918
41650
  return [
40919
41651
  `Internal links · ${report.mode === "url" ? report.targetUrl : report.site} · ${report.dataStatus}`,
40920
41652
  `Links ${options.offset + 1} to ${end} of ${report.items.length}:`,
40921
- table(page, columns, origin, 120),
41653
+ table$1(page, columns, origin, 120),
40922
41654
  end < report.items.length ? `Next page: ${options.more("rows", end)}` : ""
40923
41655
  ].filter(Boolean).join("\n\n");
40924
41656
  }
@@ -40933,7 +41665,7 @@ function internalLinksText(report, options) {
40933
41665
  `Judge: ${judge.note}`,
40934
41666
  report.target ? `Target: ${report.target.type ?? "type unknown"}, ${report.target.intent ?? "intent unknown"}, ${report.target.inboundLinks} contextual links in.` : "",
40935
41667
  s.heldBack ? `Held back by the evidence floors: ${held}.` : "",
40936
- shown.length ? `Links (${shown.length} of ${report.items.length}):\n${table(shown, columns, origin, 90)}` : "No links passed the rules.",
41668
+ shown.length ? `Links (${shown.length} of ${report.items.length}):\n${table$1(shown, columns, origin, 90)}` : "No links passed the rules.",
40937
41669
  `Limits:\n${report.caveats.filter((line) => line !== judge.note).map((line) => `- ${line}`).join("\n")}`,
40938
41670
  `More: ${shown.length < report.items.length ? `${options.more("rows", shown.length)} for more links, or ` : ""}${options.more("full")} for every field.${report.next ? ` Next: ${report.next}` : ""}`
40939
41671
  ].filter(Boolean).join("\n\n");
@@ -40941,8 +41673,8 @@ function internalLinksText(report, options) {
40941
41673
  //#endregion
40942
41674
  //#region packages/core/dist/commands/page-facts.js
40943
41675
  const MAX_PAGES = 20;
40944
- const TIMEOUT_MS$1 = 6e3;
40945
- const MAX_BYTES$1 = 1e6;
41676
+ const TIMEOUT_MS$2 = 6e3;
41677
+ const MAX_BYTES$2 = 1e6;
40946
41678
  const clean = (value) => value?.trim().replace(/\s+/g, " ") || null;
40947
41679
  async function boundedText(response) {
40948
41680
  if (!response.body) return "";
@@ -40950,7 +41682,7 @@ async function boundedText(response) {
40950
41682
  const decoder = new TextDecoder();
40951
41683
  let text = "";
40952
41684
  let total = 0;
40953
- while (total < MAX_BYTES$1) {
41685
+ while (total < MAX_BYTES$2) {
40954
41686
  const { done, value } = await reader.read();
40955
41687
  if (done) return text + decoder.decode();
40956
41688
  total += value.byteLength;
@@ -41030,12 +41762,14 @@ async function facts(url) {
41030
41762
  "user-agent": SEO_CRAWLER_USER_AGENT,
41031
41763
  accept: "text/html"
41032
41764
  },
41033
- signal: AbortSignal.timeout(TIMEOUT_MS$1)
41765
+ signal: AbortSignal.timeout(TIMEOUT_MS$2)
41034
41766
  });
41035
41767
  out.status = response.status;
41036
41768
  const location = response.headers.get("location");
41037
41769
  if (location) out.redirects_to = new URL(location, url).toString();
41038
41770
  const headerRobots = response.headers.get("x-robots-tag");
41771
+ const auth = response.headers.get("www-authenticate");
41772
+ if (auth) out.www_authenticate = auth.slice(0, 200);
41039
41773
  if (response.status !== 200 || !response.headers.get("content-type")?.includes("html")) {
41040
41774
  out.robots = headerRobots;
41041
41775
  await response.body?.cancel();
@@ -41052,6 +41786,37 @@ async function facts(url) {
41052
41786
  function checkPages(urls) {
41053
41787
  return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
41054
41788
  }
41789
+ const MAX_HOPS = 5;
41790
+ const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
41791
+ async function liveCheck(url) {
41792
+ const first = await facts(url);
41793
+ const hops = [];
41794
+ const seen = /* @__PURE__ */ new Set([url]);
41795
+ let last = first;
41796
+ while (last.redirects_to && last.status !== null) {
41797
+ const kind = redirectKind(last.status);
41798
+ if (!kind) break;
41799
+ const to = last.redirects_to;
41800
+ hops.push({
41801
+ from: last.url,
41802
+ status: last.status,
41803
+ to,
41804
+ kind
41805
+ });
41806
+ if (seen.has(to) || hops.length > MAX_HOPS) return {
41807
+ first,
41808
+ hops,
41809
+ last: null
41810
+ };
41811
+ seen.add(to);
41812
+ last = await facts(to);
41813
+ }
41814
+ return {
41815
+ first,
41816
+ hops,
41817
+ last
41818
+ };
41819
+ }
41055
41820
  function temporaryRedirectNote(pages) {
41056
41821
  const temporary = pages.filter((page) => (page.status === 302 || page.status === 307) && page.redirects_to !== null && new URL(page.redirects_to).host === new URL(page.url).host);
41057
41822
  if (!temporary.length) return null;
@@ -41223,7 +41988,7 @@ function overviewText(report, heading, origin, rowsPerList) {
41223
41988
  "impressionsChange",
41224
41989
  "position"
41225
41990
  ];
41226
- const part = (title, rows) => rows.length ? `${title}:\n${table(rows.slice(0, rowsPerList), columns, origin)}` : "";
41991
+ const part = (title, rows) => rows.length ? `${title}:\n${table$1(rows.slice(0, rowsPerList), columns, origin)}` : "";
41227
41992
  return [
41228
41993
  rowsPerList >= 10 ? part(`Top ${name} by clicks`, group.top ?? []) : "",
41229
41994
  part(`Biggest ${name} click losses`, group.biggestLosses ?? []) || `No ${name} lost clicks.`,
@@ -41234,7 +41999,7 @@ function overviewText(report, heading, origin, rowsPerList) {
41234
41999
  return [
41235
42000
  `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}${report.pageContains ? `, pages whose URL contains "${report.pageContains}"` : ""}.`,
41236
42001
  `Totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change(t.clicksChangePct)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change(t.impressionsChangePct)}), CTR ${t.ctr} (was ${t.previousCtr}), average position ${t.position} (was ${t.previousPosition}).`,
41237
- `Devices:\n${table(report.devices, [
42002
+ `Devices:\n${table$1(report.devices, [
41238
42003
  "device",
41239
42004
  "clicks",
41240
42005
  "previousClicks",
@@ -41371,7 +42136,7 @@ function reportUrls(rows, origin) {
41371
42136
  return found;
41372
42137
  }
41373
42138
  function pageFactsTable(checked, origin) {
41374
- const rows = table(checked, [
42139
+ const rows = table$1(checked, [
41375
42140
  "url",
41376
42141
  "status",
41377
42142
  "redirects_to",
@@ -41402,6 +42167,7 @@ async function allSections(input, deps) {
41402
42167
  if (bingFirst) parts.push(bingFirst.text);
41403
42168
  const ga = await analytics;
41404
42169
  if (ga) parts.push(ga.text);
42170
+ const audit = deps.audit ? auditPreview(await deps.audit.snapshot(input.site)) : null;
41405
42171
  const brand = rest.find((run) => !(run instanceof Error) && brandNote(run) !== null);
41406
42172
  if (brand) parts.push(brandNote(brand));
41407
42173
  const sections = {};
@@ -41422,10 +42188,15 @@ async function allSections(input, deps) {
41422
42188
  id === "traffic-drop" ? listOfUpdates(run.googleUpdates, run.updateWindow) : null
41423
42189
  ].filter(Boolean).join("\n"));
41424
42190
  });
42191
+ if (deps.audit) {
42192
+ parts.push(`## ${AUDIT_SECTION}\n${audit ?? "This site is not in the weekly site audit."}`);
42193
+ sections[AUDIT_SECTION] = { line: audit };
42194
+ }
42195
+ const names = deps.audit ? [...ids, AUDIT_SECTION] : ids;
41425
42196
  parts.push(`More: ${deps.call("report", {
41426
42197
  site: input.site,
41427
42198
  section: "<section>"
41428
- })} for its rows (${ids.join(", ")}). Page sections also check their top 20 URLs live and merge same-site redirects.`);
42199
+ })} for its rows (${names.join(", ")}). Page sections also check their top 20 URLs live and merge same-site redirects.`);
41429
42200
  return {
41430
42201
  text: `${parts.join("\n\n")}\n\n${FACTS_ONLY}`,
41431
42202
  data: {
@@ -41515,6 +42286,11 @@ async function oneSection(input, deps) {
41515
42286
  };
41516
42287
  }
41517
42288
  function runReport$1(input, deps) {
42289
+ if (input.section === "weekly-audit") {
42290
+ const audit = deps.audit;
42291
+ if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
42292
+ return audit.snapshot(input.site).then((snapshot) => runAuditReport(input, snapshot, deps.call));
42293
+ }
41518
42294
  return input.section ? oneSection({
41519
42295
  ...input,
41520
42296
  section: input.section
@@ -41646,17 +42422,137 @@ async function runResearch(input, keys, options) {
41646
42422
  };
41647
42423
  }
41648
42424
  //#endregion
42425
+ //#region packages/core/dist/commands/sitemap-urls.js
42426
+ const MAX_FILES = 20;
42427
+ const MAX_URLS = 5e4;
42428
+ const MAX_BYTES$1 = 2e7;
42429
+ const TIMEOUT_MS$1 = 1e4;
42430
+ async function boundedBytes(body) {
42431
+ if (!body) return /* @__PURE__ */ new Uint8Array();
42432
+ const reader = body.getReader();
42433
+ const chunks = [];
42434
+ let total = 0;
42435
+ while (true) {
42436
+ const { done, value } = await reader.read();
42437
+ if (done) break;
42438
+ total += value.byteLength;
42439
+ if (total > MAX_BYTES$1) {
42440
+ await reader.cancel();
42441
+ return null;
42442
+ }
42443
+ chunks.push(value);
42444
+ }
42445
+ const bytes = new Uint8Array(total);
42446
+ let offset = 0;
42447
+ for (const chunk of chunks) {
42448
+ bytes.set(chunk, offset);
42449
+ offset += chunk.byteLength;
42450
+ }
42451
+ return bytes;
42452
+ }
42453
+ async function get(url) {
42454
+ const response = await fetch(url, {
42455
+ headers: { "user-agent": SEO_CRAWLER_USER_AGENT },
42456
+ signal: AbortSignal.timeout(TIMEOUT_MS$1)
42457
+ });
42458
+ if (!response.ok) {
42459
+ await response.body?.cancel();
42460
+ throw new Error(`answered ${response.status}`);
42461
+ }
42462
+ let bytes = await boundedBytes(response.body);
42463
+ if (bytes && bytes[0] === 31 && bytes[1] === 139) bytes = await boundedBytes(new Response(bytes).body?.pipeThrough(new DecompressionStream("gzip")) ?? null);
42464
+ if (!bytes) throw new Error(`is larger than ${MAX_BYTES$1} bytes`);
42465
+ return new TextDecoder().decode(bytes);
42466
+ }
42467
+ const ENTITIES = {
42468
+ amp: "&",
42469
+ lt: "<",
42470
+ gt: ">",
42471
+ quot: "\"",
42472
+ apos: "'"
42473
+ };
42474
+ function locs(xml) {
42475
+ return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?\s*<\/loc>/gi)].map((match) => (match[1] ?? "").trim().replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => ENTITIES[name] ?? ""));
42476
+ }
42477
+ const inside = (site, url) => {
42478
+ try {
42479
+ return assertUrlMatchesGscProperty(site, url);
42480
+ } catch {
42481
+ return null;
42482
+ }
42483
+ };
42484
+ async function sitemapUrls(site) {
42485
+ const origin = new URL(originOf(site)).origin;
42486
+ const notes = [];
42487
+ const declared = [...(await get(`${origin}/robots.txt`).catch(() => "")).matchAll(/^\s*sitemap:\s*(\S+)/gim)].map((match) => match[1] ?? "").filter(Boolean);
42488
+ const queue = declared.length ? declared : [`${origin}/sitemap.xml`];
42489
+ const seenFiles = /* @__PURE__ */ new Set();
42490
+ const urls = /* @__PURE__ */ new Set();
42491
+ let files = 0;
42492
+ let partial = false;
42493
+ while (queue.length) {
42494
+ const file = queue.shift();
42495
+ if (seenFiles.has(file)) continue;
42496
+ seenFiles.add(file);
42497
+ if (files >= MAX_FILES) {
42498
+ partial = true;
42499
+ notes.push(`stopped at ${MAX_FILES} sitemap files`);
42500
+ break;
42501
+ }
42502
+ files += 1;
42503
+ let xml;
42504
+ try {
42505
+ xml = await get(file);
42506
+ } catch (error) {
42507
+ if (!declared.length && files === 1) {
42508
+ notes.push(`robots.txt names no sitemap and ${file} ${error instanceof Error ? error.message : "could not be read"}`);
42509
+ continue;
42510
+ }
42511
+ partial = true;
42512
+ notes.push(`${file} ${error instanceof Error ? error.message : "could not be read"}`);
42513
+ continue;
42514
+ }
42515
+ if (/<sitemapindex[\s>]/i.test(xml)) {
42516
+ queue.push(...locs(xml));
42517
+ continue;
42518
+ }
42519
+ for (const loc of locs(xml)) {
42520
+ const url = inside(site, loc);
42521
+ if (!url) continue;
42522
+ if (urls.size >= MAX_URLS) {
42523
+ partial = true;
42524
+ notes.push(`stopped at ${MAX_URLS.toLocaleString("en-US")} URLs`);
42525
+ queue.length = 0;
42526
+ break;
42527
+ }
42528
+ urls.add(url);
42529
+ }
42530
+ }
42531
+ return {
42532
+ urls: [...urls],
42533
+ files,
42534
+ partial,
42535
+ notes: [...new Set(notes)]
42536
+ };
42537
+ }
42538
+ //#endregion
41649
42539
  //#region packages/core/dist/commands/site.js
41650
42540
  function indexStatusFrom(result) {
41651
42541
  const status = result?.inspectionResult?.indexStatusResult ?? result?.indexStatusResult ?? {};
41652
42542
  const text = (value) => typeof value === "string" ? value : null;
42543
+ const list = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
41653
42544
  return {
41654
42545
  verdict: text(status.verdict),
41655
42546
  coverage: text(status.coverageState),
41656
42547
  indexing: text(status.indexingState),
41657
42548
  last_crawl: text(status.lastCrawlTime),
41658
42549
  google_canonical: text(status.googleCanonical),
41659
- user_canonical: text(status.userCanonical)
42550
+ user_canonical: text(status.userCanonical),
42551
+ page_fetch: text(status.pageFetchState),
42552
+ robots_txt: text(status.robotsTxtState),
42553
+ crawled_as: text(status.crawledAs),
42554
+ referring_urls: list(status.referringUrls),
42555
+ sitemaps: list(status.sitemap)
41660
42556
  };
41661
42557
  }
41662
42558
  async function runPage(input, deps) {
@@ -41677,13 +42573,22 @@ async function runPage(input, deps) {
41677
42573
  }]
41678
42574
  });
41679
42575
  const full = input.detail === "full";
41680
- const [[facts], indexed, now, then, markdown] = await Promise.all([
41681
- deps.checkPages([input.url]),
41682
- deps.inspect(input.site, input.url),
42576
+ const [live, indexed, sitemap, now, then, markdown] = await Promise.all([
42577
+ deps.liveCheck(input.url),
42578
+ deps.inspect(input.site, input.url).catch(() => null),
42579
+ sitemapUrls(input.site),
41683
42580
  query(range),
41684
42581
  query(before),
41685
42582
  full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
41686
42583
  ]);
42584
+ const facts = live.first;
42585
+ const reason = indexed ? indexingRow({
42586
+ url: input.url,
42587
+ status: indexed,
42588
+ live,
42589
+ inSitemap: sitemap.urls.includes(input.url),
42590
+ sitemap: new Set(sitemap.urls)
42591
+ }, [], (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString()) : null;
41687
42592
  const old = new Map(then.rows.map((row) => [row.keys[0], row]));
41688
42593
  const seen = /* @__PURE__ */ new Set();
41689
42594
  const rows = now.rows.map((row) => {
@@ -41721,8 +42626,9 @@ async function runPage(input, deps) {
41721
42626
  rowsText({
41722
42627
  title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
41723
42628
  notes: [
41724
- `Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
41725
- indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
42629
+ `Live page (fetched now):\n${pageFactsTable([facts], null)}${live.hops.length ? `\nRedirects: ${live.hops.map((hop) => `${hop.status} to ${hop.to}`).join(", ")}${live.last ? `, which returns ${live.last.status ?? live.last.error}` : ", then loop or pass 5 hops"}.` : ""}`,
42630
+ indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
42631
+ ...reason ? [`Google's reason: ${reason.reason}. Label: ${reason.label}. ${reason.why} (Labels are rules over this evidence, not Google's; ${deps.call("index")} explains them.)`] : [],
41726
42632
  `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
41727
42633
  deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
41728
42634
  url: input.url,
@@ -41747,8 +42653,9 @@ async function runPage(input, deps) {
41747
42653
  url: input.url,
41748
42654
  range,
41749
42655
  comparisonRange: before,
41750
- page: facts ?? null,
42656
+ page: facts,
41751
42657
  index: indexed,
42658
+ reason,
41752
42659
  totalQueries: rows.length,
41753
42660
  queries: rows.slice(offset, offset + 50),
41754
42661
  ...full ? { markdown } : {}
@@ -41803,6 +42710,7 @@ const MIN_LOST = 3;
41803
42710
  const CHECKED = 20;
41804
42711
  const INSPECTED = 10;
41805
42712
  const SITE_WIDE_MIN = 5;
42713
+ const PAGE_ROWS = 25e3;
41806
42714
  function indexSummary(inspected, home) {
41807
42715
  const known = inspected.filter((item) => item?.verdict != null);
41808
42716
  const indexed = known.filter((item) => item.verdict === "PASS").length;
@@ -41835,6 +42743,7 @@ function indexSummary(inspected, home) {
41835
42743
  };
41836
42744
  }
41837
42745
  const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED} and the home page.`;
42746
+ const INDEXING_RULE = `Google’s indexing reasons for 100 known URLs a run in a fixed order (lost-click pages, Search Console pages by clicks then impressions, sitemap URLs, then the rest), each checked live and labelled fix, check, expected or indexed from the evidence, sorted by reason then URL.`;
41838
42747
  async function runLostPages(input, deps) {
41839
42748
  const range = dateWindow(input, 28);
41840
42749
  const before = earlierRange(range);
@@ -41842,9 +42751,13 @@ async function runLostPages(input, deps) {
41842
42751
  ...window,
41843
42752
  dimensions: ["page"],
41844
42753
  type: "web",
41845
- maxRows: 25e3
42754
+ maxRows: PAGE_ROWS
41846
42755
  });
41847
- const [now, then] = await Promise.all([read(range), read(before)]);
42756
+ const [now, then, sitemap] = await Promise.all([
42757
+ read(range),
42758
+ read(before),
42759
+ sitemapUrls(input.site)
42760
+ ]);
41848
42761
  const current = new Map(now.rows.map((row) => [row.keys[0], row.clicks]));
41849
42762
  const lost = then.rows.filter((row) => row.clicks >= MIN_LOST && !row.keys[0]?.includes("#")).map((row) => ({
41850
42763
  url: row.keys[0] ?? "",
@@ -41852,48 +42765,107 @@ async function runLostPages(input, deps) {
41852
42765
  clicks: current.get(row.keys[0]) ?? 0
41853
42766
  })).filter((row) => row.clicks <= row.previous_clicks * LOST_SHARE).sort((a, b) => b.previous_clicks - b.clicks - (a.previous_clicks - a.clicks) || a.url.localeCompare(b.url));
41854
42767
  const top = lost.slice(0, CHECKED);
41855
- const homeUrl = top[0] ? `${new URL(top[0].url).origin}/` : null;
41856
- const [facts, indexed, homeStatus] = await Promise.all([
41857
- deps.checkPages(top.map((row) => row.url)),
41858
- Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url))),
41859
- homeUrl && !top.slice(0, INSPECTED).some((row) => row.url === homeUrl) ? deps.inspect(input.site, homeUrl).catch(() => null) : null
41860
- ]);
41861
- const homeIndex = top.slice(0, INSPECTED).findIndex((row) => row.url === homeUrl);
41862
- const summary = indexSummary(homeIndex >= 0 ? indexed.filter((_, index) => index !== homeIndex) : indexed, homeUrl ? {
42768
+ const first = top[0]?.url ?? sitemap.urls[0] ?? now.rows[0]?.keys[0];
42769
+ const homeUrl = first ? `${new URL(first).origin}/` : null;
42770
+ const known = knownUrls({
42771
+ lost: lost.map((row) => row.url),
42772
+ home: homeUrl,
42773
+ sitemap,
42774
+ pages: now.rows.map((row) => ({
42775
+ url: row.keys[0] ?? "",
42776
+ clicks: row.clicks,
42777
+ impressions: row.impressions
42778
+ })),
42779
+ pagesCapped: now.rows.length >= PAGE_ROWS
42780
+ });
42781
+ const [facts, run] = await Promise.all([deps.checkPages(top.map((row) => row.url)), inspectKnown(input.site, known, deps, input.maxUrls)]);
42782
+ const status = (url) => url ? run.statuses.get(url) ?? null : null;
42783
+ const summary = indexSummary(top.slice(0, INSPECTED).filter((row) => row.url !== homeUrl).map((row) => status(row.url)), top.length && homeUrl ? {
41863
42784
  url: homeUrl,
41864
- status: homeIndex >= 0 ? indexed[homeIndex] ?? null : homeStatus
42785
+ status: status(homeUrl)
41865
42786
  } : null);
41866
- const rows = top.map((row, index) => ({
41867
- ...row,
41868
- status: facts[index]?.status ?? null,
41869
- redirects_to: facts[index]?.redirects_to ?? null,
41870
- robots: facts[index]?.robots ?? null,
41871
- canonical: facts[index]?.canonical ?? null,
41872
- verdict: indexed[index]?.verdict ?? null,
41873
- coverage: indexed[index]?.coverage ?? null,
41874
- google_canonical: indexed[index]?.google_canonical ?? null
41875
- }));
41876
- return {
41877
- text: [
41878
- `Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
41879
- `${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
41880
- rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
41881
- summary.text,
41882
- "Live checks are fetched now with no redirects followed. Index status is from URL Inspection; Google allows about 2,000 inspections a day per property.",
41883
- temporaryRedirectNote(rows),
41884
- `Next: ${deps.call("page")} for one URL; ${deps.call("report", {
42787
+ const rows = top.map((row, index) => {
42788
+ const indexed = index < INSPECTED ? status(row.url) : null;
42789
+ return {
42790
+ ...row,
42791
+ status: facts[index]?.status ?? null,
42792
+ redirects_to: facts[index]?.redirects_to ?? null,
42793
+ robots: facts[index]?.robots ?? null,
42794
+ canonical: facts[index]?.canonical ?? null,
42795
+ verdict: indexed?.verdict ?? null,
42796
+ coverage: indexed?.coverage ?? null,
42797
+ google_canonical: indexed?.google_canonical ?? null
42798
+ };
42799
+ });
42800
+ const detail = input.detail ?? "summary";
42801
+ const offset = input.offset ?? 0;
42802
+ const origin = originOf(input.site);
42803
+ const firstFix = run.rows.find((row) => row.label === "fix");
42804
+ const reasons = indexingText(input.site, run, {
42805
+ summary: detail === "summary",
42806
+ offset,
42807
+ size: detail === "summary" ? 25 : 50,
42808
+ more: (next) => deps.call("index", {
42809
+ site: input.site,
42810
+ max_urls: input.maxUrls,
42811
+ detail: "rows",
42812
+ offset: next
42813
+ })
42814
+ }, origin, deps.inspectionQuota ? (count) => deps.call("index", {
42815
+ site: input.site,
42816
+ max_urls: count
42817
+ }) : null, deps.now?.() ?? /* @__PURE__ */ new Date());
42818
+ const lostText = [
42819
+ `Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
42820
+ `${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
42821
+ rows.length ? table$1(rows, Object.keys(rows[0] ?? {}), origin) : "No pages lost their clicks.",
42822
+ summary.text,
42823
+ "Live checks are fetched now with no redirects followed. Index status is from URL Inspection.",
42824
+ temporaryRedirectNote(rows)
42825
+ ];
42826
+ const indexing = {
42827
+ rule: INDEXING_RULE,
42828
+ known: {
42829
+ total: known.urls.length,
42830
+ sitemap: sitemap.urls.length,
42831
+ sitemapFiles: sitemap.files,
42832
+ sitemapPartial: sitemap.partial,
42833
+ searchConsole: known.searchConsole,
42834
+ searchConsoleCapped: known.searchConsoleCapped,
42835
+ lostClicks: known.lostClicks
42836
+ },
42837
+ limit: run.limit,
42838
+ inspected: run.inspected,
42839
+ fromSaved: run.fromSaved,
42840
+ oldestSaved: run.oldestSaved,
42841
+ failed: run.failed,
42842
+ stoppedBy: run.stoppedBy,
42843
+ quota: run.quota,
42844
+ byReason: byReason(run.rows),
42845
+ rows: run.rows
42846
+ };
42847
+ return {
42848
+ text: [...detail === "full" ? [`Google's indexing reasons for ${input.site}, every inspected URL as JSON, sorted by reason then URL:`, JSON.stringify(indexing)] : [
42849
+ ...detail === "summary" ? lostText : [],
42850
+ ...reasons,
42851
+ `To see what changed, keep this run's full result (${deps.call("index", {
42852
+ site: input.site,
42853
+ max_urls: input.maxUrls,
42854
+ detail: "full"
42855
+ })}) and compare it with the next run by url and reason. Inspect the same number each week: the order is fixed, so the same first URLs come back. Where results are saved (the CLI and the local MCP), a URL inspected in the last 7 days is reused; any other URL is inspected again and uses URL Inspection quota.`,
42856
+ `Next: ${firstFix ? deps.call("page", { url: firstFix.url }) : deps.call("page")} for one URL, including a URL from a Search Console email that is not in this set; ${deps.call("report", {
41885
42857
  site: input.site,
41886
42858
  section: "content-decay"
41887
- })} for pages that lost part of their clicks.`,
41888
- FACTS_ONLY
41889
- ].filter(Boolean).join("\n\n"),
42859
+ })} for pages that lost part of their clicks.`
42860
+ ], FACTS_ONLY].filter(Boolean).join("\n\n"),
41890
42861
  data: {
41891
42862
  range,
41892
42863
  comparisonRange: before,
41893
42864
  rule: LOST_PAGES_RULE,
41894
42865
  lostPages: lost.length,
41895
42866
  index: summary.data,
41896
- pages: rows
42867
+ pages: rows,
42868
+ indexing
41897
42869
  }
41898
42870
  };
41899
42871
  }
@@ -42036,16 +43008,26 @@ const SHARED_TOOLS = [
42036
43008
  tool({
42037
43009
  name: "seo_index",
42038
43010
  command: "index",
42039
- title: "Pages that lost their clicks",
42040
- description: `${LOST_PAGES_RULE} It shows pages that broke, redirect, block indexing or dropped out of Google.`,
43011
+ title: "Google indexing reasons and pages that lost their clicks",
43012
+ description: `${INDEXING_RULE} Use it for a first baseline and then weekly: keep the full result and compare by URL and reason to find new reasons. Also: ${LOST_PAGES_RULE}`,
42041
43013
  input: z.object({
42042
43014
  site,
42043
- ...dates
43015
+ ...dates,
43016
+ ...detail
43017
+ }),
43018
+ localInput: z.object({
43019
+ site,
43020
+ ...dates,
43021
+ max_urls: z.number().int().min(1).max(MAX_INSPECT_LIMIT).optional().describe(`URLs to inspect with Google, 100 by default, up to the URL Inspection quota left today.`),
43022
+ ...detail
42044
43023
  }),
42045
43024
  run: async (surface, input) => runLostPages({
42046
43025
  site: await surface.resolveSite(input.site),
42047
43026
  startDate: input.start_date,
42048
- endDate: input.end_date
43027
+ endDate: input.end_date,
43028
+ detail: input.detail,
43029
+ offset: input.offset,
43030
+ maxUrls: input.max_urls
42049
43031
  }, surface.deps)
42050
43032
  }),
42051
43033
  tool({
@@ -42142,15 +43124,54 @@ const SHARED_TOOLS = [
42142
43124
  }
42143
43125
  })
42144
43126
  ];
42145
- async function runSharedTool(tool, surface, input) {
42146
- const parsed = tool.input.safeParse(input ?? {});
43127
+ function indexFromStore(shared) {
43128
+ const paged = "detail" in shared.input.shape;
43129
+ return {
43130
+ ...shared,
43131
+ description: `${shared.description} For a site in the weekly site audit beta it answers at once from the URL Inspection results this server keeps for the site, checked in the background each day, and says how many URLs are covered, how many are queued and the time left.`,
43132
+ run: async (surface, parsed) => {
43133
+ const input = parsed;
43134
+ const audit = surface.deps.audit;
43135
+ if (!audit) return shared.run(surface, parsed);
43136
+ const site = await surface.resolveSite(input.site);
43137
+ const detail = input.detail ?? "summary";
43138
+ const offset = detail === "rows" ? input.offset ?? 0 : 0;
43139
+ const limit = detail === "rows" ? 50 : 20;
43140
+ let checks = await audit.checks(site, offset, limit);
43141
+ if (!checks) {
43142
+ if (await audit.add(site) === "full") {
43143
+ const result = await shared.run(surface, parsed);
43144
+ return {
43145
+ text: `${result.text}\n\n${site} is not in the weekly site audit, and this account already audits the most sites the beta allows, so this ran the usual way. Remove a site under Beta on the account page to keep its checks here.`,
43146
+ data: result.data
43147
+ };
43148
+ }
43149
+ checks = await audit.checks(site, offset, limit);
43150
+ if (!checks) throw new Error(`The audit of ${site} did not start.`);
43151
+ }
43152
+ return runAuditChecks({
43153
+ site,
43154
+ detail,
43155
+ offset,
43156
+ paged
43157
+ }, checks, surface.deps.call);
43158
+ }
43159
+ };
43160
+ }
43161
+ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared) : shared.name === "seo_report" ? {
43162
+ ...shared,
43163
+ description: `${shared.description}\n- ${AUDIT_SECTION}: ${AUDIT_SECTION_TEXT}`,
43164
+ input: shared.input.extend({ section: z.enum([...SECTION_IDS, AUDIT_SECTION]).optional().describe("Optional. Leave it out first; then ask for one section.") })
43165
+ } : shared);
43166
+ async function runSharedTool(tool, surface, input, local = false) {
43167
+ const parsed = (local && tool.localInput || tool.input).safeParse(input ?? {});
42147
43168
  if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
42148
43169
  return tool.run(surface, parsed.data);
42149
43170
  }
42150
43171
  const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools, Clicky and optional keyword accounts, and returns facts with their dates and limits. It gives no advice.
42151
43172
  1. seo_sites: the properties the connected accounts can read.
42152
43173
  2. seo_report: the main report. Run it without a section first; it shows the overview and every section's count and top rows. Then ask for the one section you need.
42153
- 3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data, and whether AI answers cite the site, from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.`;
43174
+ 3. seo_page for one URL, seo_compare for before and after a date, seo_index for Google's indexing reasons and pages that lost their clicks, seo_research for keyword, competitor and backlink data, and whether AI answers cite the site, from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.`;
42154
43175
  const mcpCall = (command, args) => {
42155
43176
  const name = `seo_${command.replace("-", "_")}`;
42156
43177
  const shown = Object.entries(args ?? {}).filter(([, value]) => value !== void 0);
@@ -42164,6 +43185,36 @@ const SKILL_RESOURCE = {
42164
43185
  mimeType: "text/markdown"
42165
43186
  };
42166
43187
  //#endregion
43188
+ //#region packages/core/dist/gsc/client/inspection-cache.js
43189
+ const INSPECTION_FRESH_MS = 7 * 864e5;
43190
+ function readSavedInspections(site, urls, now = Date.now()) {
43191
+ const found = /* @__PURE__ */ new Map();
43192
+ try {
43193
+ const statement = cacheDb().prepare(`SELECT status_json, fetched_at FROM url_inspection_cache
43194
+ WHERE site_url = ? AND url = ? AND fetched_at > ?`);
43195
+ for (const url of urls) {
43196
+ const row = statement.get(site, url, now - INSPECTION_FRESH_MS);
43197
+ if (!row) continue;
43198
+ found.set(url, {
43199
+ status: JSON.parse(row.status_json),
43200
+ inspectedAt: new Date(row.fetched_at).toISOString()
43201
+ });
43202
+ }
43203
+ } catch {
43204
+ return /* @__PURE__ */ new Map();
43205
+ }
43206
+ return found;
43207
+ }
43208
+ function saveInspection(site, url, status, now = Date.now()) {
43209
+ const json = JSON.stringify(status);
43210
+ writeToCache((database) => {
43211
+ database.prepare(`INSERT OR REPLACE INTO url_inspection_cache
43212
+ (site_url, url, status_json, fetched_at, expires_at)
43213
+ VALUES (?, ?, ?, ?, ?)`).run(site, url, json, now, now + INSPECTION_FRESH_MS);
43214
+ noteCacheWrite(Buffer.byteLength(json) + url.length, database);
43215
+ });
43216
+ }
43217
+ //#endregion
42167
43218
  //#region packages/core/dist/providers/api-key-credentials.js
42168
43219
  function createApiKeyCredentialStore(input) {
42169
43220
  const normalize = (value) => {
@@ -42343,16 +43394,19 @@ function nodeCommandDeps(options) {
42343
43394
  accountEmail: options.account
42344
43395
  }),
42345
43396
  checkPages,
42346
- inspect: async (site, url) => {
42347
- try {
42348
- return indexStatusFrom(await inspectUrl({
42349
- siteUrl: site,
42350
- inspectionUrl: url,
42351
- accountEmail: options.account
42352
- }));
42353
- } catch {
42354
- return null;
42355
- }
43397
+ liveCheck,
43398
+ inspect: async (site, url) => indexStatusFrom(await inspectUrl({
43399
+ siteUrl: site,
43400
+ inspectionUrl: url,
43401
+ accountEmail: options.account
43402
+ })),
43403
+ inspectionQuota: (site) => urlInspectionQuota({
43404
+ siteUrl: site,
43405
+ accountEmail: options.account
43406
+ }),
43407
+ savedInspections: {
43408
+ read: (site, urls) => options.refresh ? /* @__PURE__ */ new Map() : readSavedInspections(site, urls),
43409
+ save: saveInspection
42356
43410
  },
42357
43411
  pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
42358
43412
  call: options.call,
@@ -45479,10 +46533,10 @@ function registerSharedTools(server, options = {}) {
45479
46533
  for (const shared of SHARED_TOOLS) server.registerTool(shared.name, {
45480
46534
  title: shared.title,
45481
46535
  description: shared.description,
45482
- inputSchema: shared.input.shape,
46536
+ inputSchema: (shared.localInput ?? shared.input).shape,
45483
46537
  annotations: readOnly
45484
46538
  }, async (input) => counted(shared.command, input, options.telemetry, async () => {
45485
- return text((await runSharedTool(shared, surface, input)).text);
46539
+ return text((await runSharedTool(shared, surface, input, true)).text);
45486
46540
  }));
45487
46541
  server.registerTool("seo_crawl", {
45488
46542
  title: "Crawl a site",