seo 0.17.0 → 0.17.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as version, t as load } from "./esm-CkObTzDg.js";
2
+ import { n as version, t as load } from "./esm-G8B5TPzB.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto$1, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -72,6 +72,20 @@ const RULE_DEFINITIONS = [
72
72
  suggestedCommands: ["seo audit-page --url <url> --json"]
73
73
  }
74
74
  },
75
+ {
76
+ id: "empty_title",
77
+ title: "Title empty",
78
+ category: "metadata",
79
+ defaultSeverity: "high",
80
+ whyItMatters: "The page has a <title> element with no text. Search engines treat it like a missing title and write their own headline for the result.",
81
+ howToFix: "Fill the title element with a descriptive title that names the page topic. An empty title usually means a template field or CMS value is blank.",
82
+ impactIfIgnored: "The page can look vague in search results, earn lower CTR, and send weaker relevance signals.",
83
+ howToVerify: "Re-run the crawl and confirm the title element has text.",
84
+ agentHints: {
85
+ evidenceFields: ["page.title"],
86
+ suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
87
+ }
88
+ },
75
89
  {
76
90
  id: "title_too_wide",
77
91
  title: "Title may truncate on some devices",
@@ -560,6 +574,16 @@ const RULE_DEFINITIONS = [
560
574
  impactIfIgnored: "Search snippets may be generic or less persuasive, especially on pages that already rank.",
561
575
  howToVerify: "Re-run the crawl and confirm the page has a meta description."
562
576
  },
577
+ {
578
+ id: "empty_meta_description",
579
+ title: "Meta description empty",
580
+ category: "metadata",
581
+ defaultSeverity: "medium",
582
+ whyItMatters: "The page has a meta description tag with no content, so search engines choose their own snippet. An empty tag usually means a template field or CMS value is blank.",
583
+ howToFix: "Fill the content attribute with a concise, unique description, or fix the template value that leaves it blank.",
584
+ impactIfIgnored: "Search snippets may be generic or less persuasive, especially on pages that already rank.",
585
+ howToVerify: "Re-run the crawl and confirm the meta description has content."
586
+ },
563
587
  {
564
588
  id: "meta_description_duplicate",
565
589
  title: "Duplicate meta description",
@@ -1664,6 +1688,28 @@ var GoogleApiError = class extends SeoError {
1664
1688
  this.status = status;
1665
1689
  }
1666
1690
  };
1691
+ const GOOGLE_TIMEOUT_MS = 3e4;
1692
+ const URL_INSPECTION_TIMEOUT_MS = 6e4;
1693
+ async function withinGoogleTimeout(what, timeoutMs, signal, run) {
1694
+ const controller = new AbortController();
1695
+ const fromCaller = () => controller.abort(signal?.reason);
1696
+ if (signal?.aborted) fromCaller();
1697
+ else signal?.addEventListener("abort", fromCaller, { once: true });
1698
+ let timer;
1699
+ const timedOut = new Promise((_, reject) => {
1700
+ timer = setTimeout(() => {
1701
+ const error = new SeoError("PROVIDER_UNAVAILABLE", `${what} did not answer within ${timeoutMs / 1e3} seconds, so the request was stopped. Try again later.`);
1702
+ reject(error);
1703
+ controller.abort(error);
1704
+ }, timeoutMs);
1705
+ });
1706
+ try {
1707
+ return await Promise.race([run(controller.signal), timedOut]);
1708
+ } finally {
1709
+ clearTimeout(timer);
1710
+ signal?.removeEventListener("abort", fromCaller);
1711
+ }
1712
+ }
1667
1713
  function bearerFetch(token) {
1668
1714
  return async (url, init) => fetch(url, {
1669
1715
  ...init,
@@ -1692,12 +1738,15 @@ async function failure(response, label, subject, options) {
1692
1738
  return new GoogleApiError("PROVIDER_UNAVAILABLE", `${label} answered ${status}. ${detail}`.trim(), status);
1693
1739
  }
1694
1740
  async function googleJson(gfetch, url, input) {
1695
- const response = await gfetch(url, {
1696
- method: input.body === void 0 ? "GET" : "POST",
1697
- body: input.body === void 0 ? void 0 : JSON.stringify(input.body)
1741
+ return withinGoogleTimeout(input.subject ? `${input.label} for ${input.subject}` : input.label, input.timeoutMs ?? 3e4, void 0, async (signal) => {
1742
+ const response = await gfetch(url, {
1743
+ method: input.body === void 0 ? "GET" : "POST",
1744
+ body: input.body === void 0 ? void 0 : JSON.stringify(input.body),
1745
+ signal
1746
+ });
1747
+ if (!response.ok) throw await failure(response, input.label, input.subject, input);
1748
+ return readJson(response, input.label, { maxBytes: input.maxBytes });
1698
1749
  });
1699
- if (!response.ok) throw await failure(response, input.label, input.subject, input);
1700
- return readJson(response, input.label, { maxBytes: input.maxBytes });
1701
1750
  }
1702
1751
  const GSC = "https://www.googleapis.com/webmasters/v3";
1703
1752
  const PAGE_ROWS$1 = 25e3;
@@ -1749,6 +1798,7 @@ async function inspectSearchConsoleUrl(gfetch, site, url, options) {
1749
1798
  ...options,
1750
1799
  label: "URL Inspection",
1751
1800
  subject: url,
1801
+ timeoutMs: URL_INSPECTION_TIMEOUT_MS,
1752
1802
  body: {
1753
1803
  siteUrl: site,
1754
1804
  inspectionUrl: url,
@@ -7135,6 +7185,10 @@ function safeText$1(value) {
7135
7185
  const trimmed = value?.replace(/\s+/g, " ").trim();
7136
7186
  return trimmed ? trimmed : void 0;
7137
7187
  }
7188
+ function tagText(element, read) {
7189
+ if (!element.length) return void 0;
7190
+ return read(element)?.replace(/\s+/g, " ").trim() ?? "";
7191
+ }
7138
7192
  function headerValue$3(headers, name) {
7139
7193
  const target = name.toLowerCase();
7140
7194
  return Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
@@ -7403,14 +7457,14 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
7403
7457
  html: sanitizedContentHtml($, fetchResult.html, fetchResult.finalUrl, streamedSegments.resolved > 0)
7404
7458
  }, extractor, dependencies);
7405
7459
  const { text, excerpt } = content;
7406
- const title = safeText$1($("title").first().text());
7460
+ const title = tagText($("title").first(), (element) => element.text());
7407
7461
  const crawlerMetaRefresh = extractor === "crawler" ? metaRefresh($, fetchResult.finalUrl) : void 0;
7408
7462
  if (crawlerMetaRefresh && new URL(crawlerMetaRefresh.targetUrl).origin === url.origin) crawlerInternalLinks.add(crawlerMetaRefresh.targetUrl);
7409
7463
  return {
7410
7464
  url: fetchResult.url,
7411
7465
  finalUrl: fetchResult.finalUrl,
7412
7466
  title,
7413
- metaDescription: safeText$1($("meta[name=\"description\"]").attr("content")),
7467
+ metaDescription: tagText($("meta[name=\"description\"]").first(), (element) => element.attr("content")),
7414
7468
  metaRobots,
7415
7469
  xRobotsTag: safeText$1(headerValue$3(fetchResult.headers, "x-robots-tag")),
7416
7470
  canonical: canonicalEvidence.selectedRaw,
@@ -7770,31 +7824,172 @@ function recordHostFetch(input) {
7770
7824
  }
7771
7825
  return snapshotForHost(input.host);
7772
7826
  }
7773
- // packages/core/src/fetch/page-fetcher/redirects.ts
7774
- async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch) {
7775
- const redirectChain = [];
7776
- let currentUrl = url;
7777
- for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
7778
- const response = await fetch(currentUrl, {
7827
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
7828
+ 301,
7829
+ 302,
7830
+ 303,
7831
+ 307,
7832
+ 308
7833
+ ]);
7834
+ function errorText$1(error) {
7835
+ if (!(error instanceof Error)) return String(error);
7836
+ const cause = error.cause;
7837
+ const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
7838
+ return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
7839
+ }
7840
+ function httpUrl(value, base) {
7841
+ try {
7842
+ const url = new URL(value, base);
7843
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
7844
+ url.hash = "";
7845
+ return url.toString();
7846
+ } catch {
7847
+ return null;
7848
+ }
7849
+ }
7850
+ async function traceRedirects(url, options = {}) {
7851
+ const request = options.fetch ?? ((to, init) => fetch(to, init));
7852
+ const { trace, response } = await followRedirects(url, {
7853
+ maxHops: options.maxHops,
7854
+ timeoutMs: options.timeoutMs,
7855
+ onResponse: options.onResponse,
7856
+ request: (to, signal) => request(to, {
7779
7857
  redirect: "manual",
7858
+ headers: {
7859
+ "user-agent": SEO_CRAWLER_USER_AGENT,
7860
+ accept: "text/html,application/xhtml+xml,*/*;q=0.8"
7861
+ },
7780
7862
  signal
7781
- });
7782
- const location = response.headers.get("location");
7783
- if (response.status < 300 || response.status >= 400 || !location) return {
7784
- response,
7785
- redirectChain
7786
- };
7787
- const nextUrl = new URL(location, currentUrl).toString();
7788
- redirectChain.push({
7789
- url: currentUrl,
7790
- status: response.status,
7791
- location: nextUrl
7792
- });
7793
- await response.body?.cancel().catch(() => void 0);
7794
- currentUrl = nextUrl;
7863
+ })
7864
+ });
7865
+ await response?.body?.cancel().catch(() => void 0);
7866
+ return trace;
7867
+ }
7868
+ async function followRedirects(url, options) {
7869
+ const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
7870
+ const timeoutMs = options.timeoutMs ?? 1e4;
7871
+ const steps = [];
7872
+ const end = (final_url, final_status, outcome, error, extra = {}) => ({
7873
+ trace: {
7874
+ url,
7875
+ steps,
7876
+ hops: steps.filter((step) => step.location !== null).length,
7877
+ final_url,
7878
+ final_status,
7879
+ outcome,
7880
+ ...error ? { error } : {}
7881
+ },
7882
+ response: extra.response ?? null,
7883
+ ..."error" in extra ? { error: extra.error } : {}
7884
+ });
7885
+ const start = httpUrl(url);
7886
+ if (!start) return end(url, null, "error", "Not an http or https URL.");
7887
+ let current = start;
7888
+ const seen = /* @__PURE__ */ new Set();
7889
+ while (true) {
7890
+ seen.add(current);
7891
+ const startedAt = Date.now();
7892
+ const hop = new AbortController();
7893
+ let timedOut = false;
7894
+ const timer = setTimeout(() => {
7895
+ timedOut = true;
7896
+ hop.abort();
7897
+ }, timeoutMs);
7898
+ const signal = options.signal ? AbortSignal.any([options.signal, hop.signal]) : hop.signal;
7899
+ try {
7900
+ let response;
7901
+ try {
7902
+ response = await options.request(current, signal);
7903
+ } catch (error) {
7904
+ if (options.signal?.aborted) throw error;
7905
+ return timedOut || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`, { error }) : end(current, null, "error", errorText$1(error), { error });
7906
+ }
7907
+ const ms = Date.now() - startedAt;
7908
+ const header = response.headers.get("location");
7909
+ const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
7910
+ const location = redirects ? httpUrl(header, current) : null;
7911
+ if (options.onResponse) await options.onResponse(response, current);
7912
+ steps.push({
7913
+ url: current,
7914
+ status: response.status,
7915
+ location,
7916
+ ms
7917
+ });
7918
+ if (!redirects) return end(current, response.status, "done", "", { response });
7919
+ await response.body?.cancel().catch(() => void 0);
7920
+ if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
7921
+ if (seen.has(location)) return end(current, response.status, "loop");
7922
+ if (steps.length > maxHops) return end(current, response.status, "too_many");
7923
+ current = location;
7924
+ } finally {
7925
+ clearTimeout(timer);
7926
+ }
7927
+ }
7928
+ }
7929
+ // packages/core/src/fetch/page-fetcher/redirects.ts
7930
+ var RedirectChainError = class extends Error {
7931
+ kind;
7932
+ url;
7933
+ redirectChain;
7934
+ constructor(kind, url, redirectChain, message, options) {
7935
+ super(message, options);
7936
+ this.kind = kind;
7937
+ this.url = url;
7938
+ this.redirectChain = redirectChain;
7939
+ this.name = "RedirectChainError";
7795
7940
  }
7796
- throw new Error(`Too many redirects for ${url}`);
7941
+ };
7942
+ async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch, options = {}) {
7943
+ const followed = await followRedirects(url, {
7944
+ signal,
7945
+ timeoutMs: options.timeoutMs ?? 1e4,
7946
+ request: (to, hopSignal) => fetch(to, {
7947
+ redirect: "manual",
7948
+ signal: hopSignal
7949
+ })
7950
+ });
7951
+ const redirectChain = followed.trace.steps.flatMap(({ url: from, status, location }) => location === null ? [] : [{
7952
+ url: from,
7953
+ status,
7954
+ location
7955
+ }]);
7956
+ const { trace, response } = followed;
7957
+ if (trace.outcome === "done" && response) return {
7958
+ response,
7959
+ redirectChain
7960
+ };
7961
+ const kind = trace.outcome === "loop" || trace.outcome === "too_many" || trace.outcome === "timeout" ? trace.outcome : "network";
7962
+ throw new RedirectChainError(kind, url, redirectChain, kind === "loop" ? `Redirect loop for ${url}: ${trace.final_url} redirects back to a URL already requested.` : kind === "too_many" ? `Too many redirects for ${url}: still redirecting after 10 redirects.` : trace.error ?? "The request failed.", "error" in followed ? { cause: followed.error } : void 0);
7963
+ }
7964
+ function redirectsWillRepeat(error) {
7965
+ return error instanceof RedirectChainError && (error.kind === "loop" || error.kind === "too_many");
7966
+ }
7967
+ // packages/core/src/fetch/page-fetcher/robots-evidence.ts
7968
+ function pageRobotsEvidence(robots) {
7969
+ return {
7970
+ url: robots.url,
7971
+ allowed: robots.allowed,
7972
+ availability: robots.availability,
7973
+ status: robots.status,
7974
+ error: robots.error,
7975
+ matchedLine: robots.matchedLine
7976
+ };
7977
+ }
7978
+ function diagnosticRobotsEvidence(robots) {
7979
+ return {
7980
+ url: robots.url,
7981
+ cache: robots.cache,
7982
+ allowed: robots.allowed,
7983
+ availability: robots.availability,
7984
+ status: robots.status,
7985
+ error: robots.error
7986
+ };
7987
+ }
7988
+ function robotsTxtIsHtml(contentType, text) {
7989
+ const body = text.replace(/^\uFEFF/, "").trimStart();
7990
+ return body.startsWith("<") || /\btext\/html\b/i.test(contentType) && !/^\s*(?:user-agent|allow|disallow|sitemap)\s*:/im.test(body);
7797
7991
  }
7992
+ const ROBOTS_TXT_HTML_ERROR = "robots.txt returned a web page, not a robots.txt file, so its rules are unknown.";
7798
7993
  // packages/core/src/fetch/page-fetcher/robots.ts
7799
7994
  const MAX_ROBOTS_RESPONSE_BYTES = 1048576;
7800
7995
  var RobotsAccessError = class extends Error {
@@ -7814,7 +8009,16 @@ function cacheableStatus(status) {
7814
8009
  return status >= 200 && status < 300 || status >= 400 && status < 500 && status !== 429;
7815
8010
  }
7816
8011
  function resultFromResponse(input) {
7817
- if (input.status >= 200 && input.status < 300) {
8012
+ const ok = input.status >= 200 && input.status < 300;
8013
+ if (ok && robotsTxtIsHtml(input.contentType, input.text)) return {
8014
+ allowed: true,
8015
+ availability: "unreadable",
8016
+ status: input.status,
8017
+ error: ROBOTS_TXT_HTML_ERROR,
8018
+ cache: input.cache,
8019
+ url: input.robotsUrl
8020
+ };
8021
+ if (ok) {
7818
8022
  const parsed = parseRobots$1(input.robotsUrl, input.text);
7819
8023
  const lineNumber = parsed.getMatchingLineNumber?.(input.targetUrl, SEO_CRAWLER_USER_AGENT);
7820
8024
  const matchedLine = lineNumber ? input.text.split(/\r?\n/)[lineNumber - 1]?.trim() : void 0;
@@ -7848,6 +8052,7 @@ function resultFromSource(source, targetUrl) {
7848
8052
  robotsUrl: source.robotsUrl,
7849
8053
  targetUrl,
7850
8054
  status: source.status,
8055
+ contentType: source.contentType ?? "",
7851
8056
  text: source.text,
7852
8057
  cache: source.cache
7853
8058
  });
@@ -7882,7 +8087,8 @@ async function fetchRobotsSource(origin, refresh, options) {
7882
8087
  signal: controller.signal
7883
8088
  });
7884
8089
  const text = await readBoundedResponseText(response, MAX_ROBOTS_RESPONSE_BYTES, "robots.txt response");
7885
- if (options.writeCache !== false && cacheableStatus(response.status)) writeToCache((database) => {
8090
+ const contentType = response.headers.get("content-type") ?? "";
8091
+ if (options.writeCache !== false && cacheableStatus(response.status) && !(response.status >= 200 && response.status < 300 && robotsTxtIsHtml(contentType, text))) writeToCache((database) => {
7886
8092
  database.prepare(`INSERT OR REPLACE INTO http_cache
7887
8093
  (url_hash, url, status, headers_json, body_blob, etag, fetched_at, expires_at)
7888
8094
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(key, robotsUrl, response.status, "{}", Buffer.from(text), null, Date.now(), Date.now() + 864e5);
@@ -7891,6 +8097,7 @@ async function fetchRobotsSource(origin, refresh, options) {
7891
8097
  return {
7892
8098
  robotsUrl,
7893
8099
  status: response.status,
8100
+ contentType,
7894
8101
  text,
7895
8102
  cache: refresh ? "bypass" : "miss"
7896
8103
  };
@@ -7946,27 +8153,6 @@ function createRobotsSession(input = {}) {
7946
8153
  }
7947
8154
  };
7948
8155
  }
7949
- // packages/core/src/fetch/page-fetcher/robots-evidence.ts
7950
- function pageRobotsEvidence(robots) {
7951
- return {
7952
- url: robots.url,
7953
- allowed: robots.allowed,
7954
- availability: robots.availability,
7955
- status: robots.status,
7956
- error: robots.error,
7957
- matchedLine: robots.matchedLine
7958
- };
7959
- }
7960
- function diagnosticRobotsEvidence(robots) {
7961
- return {
7962
- url: robots.url,
7963
- cache: robots.cache,
7964
- allowed: robots.allowed,
7965
- availability: robots.availability,
7966
- status: robots.status,
7967
- error: robots.error
7968
- };
7969
- }
7970
8156
  // packages/core/src/fetch/page-fetcher/plain.ts
7971
8157
  const MAX_PAGE_RESPONSE_BYTES = 5242880;
7972
8158
  function encodePageFetchCacheEvidence(result) {
@@ -8048,8 +8234,9 @@ async function fetchPlain(url, refresh = false, timeoutMs = 2e4, rate, signal, r
8048
8234
  if (signal?.aborted) controller.abort();
8049
8235
  else signal?.addEventListener("abort", abort, { once: true });
8050
8236
  try {
8051
- return await fetchWithRedirectChain(url, controller.signal);
8237
+ return await fetchWithRedirectChain(url, controller.signal, void 0, { timeoutMs });
8052
8238
  } catch (error) {
8239
+ if (redirectsWillRepeat(error)) throw new AbortError(error);
8053
8240
  if (error instanceof Error && /4\d\d/.test(error.message)) throw new AbortError(error);
8054
8241
  throw error;
8055
8242
  } finally {
@@ -8626,8 +8813,9 @@ async function statusProbe(url, opts) {
8626
8813
  if (opts.signal?.aborted) controller.abort();
8627
8814
  else opts.signal?.addEventListener("abort", abort, { once: true });
8628
8815
  try {
8629
- return await fetchWithRedirectChain(url, controller.signal);
8816
+ return await fetchWithRedirectChain(url, controller.signal, void 0, { timeoutMs: opts.timeoutMs });
8630
8817
  } catch (error) {
8818
+ if (redirectsWillRepeat(error)) throw new AbortError(error);
8631
8819
  if (opts.signal?.aborted) throw new AbortError(error instanceof Error ? error : new Error(String(error)));
8632
8820
  throw error;
8633
8821
  } finally {
@@ -9005,38 +9193,40 @@ async function inspectUrl(input) {
9005
9193
  property: input.siteUrl,
9006
9194
  limit: input.quotaLimit
9007
9195
  });
9008
- let response;
9009
- try {
9010
- response = await authedFetch(client, "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
9011
- method: "POST",
9012
- body: JSON.stringify({
9013
- siteUrl: input.siteUrl,
9014
- inspectionUrl,
9015
- ...input.languageCode ? { languageCode: input.languageCode } : {}
9016
- }),
9017
- signal: input.signal
9018
- });
9019
- } catch (error) {
9196
+ return withinGoogleTimeout(`URL Inspection for ${inspectionUrl}`, URL_INSPECTION_TIMEOUT_MS, input.signal, async (signal) => {
9197
+ let response;
9198
+ try {
9199
+ response = await authedFetch(client, "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
9200
+ method: "POST",
9201
+ body: JSON.stringify({
9202
+ siteUrl: input.siteUrl,
9203
+ inspectionUrl,
9204
+ ...input.languageCode ? { languageCode: input.languageCode } : {}
9205
+ }),
9206
+ signal
9207
+ });
9208
+ } catch (error) {
9209
+ finalize(reservation, "consumed");
9210
+ if (error instanceof SeoError) throw error;
9211
+ throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
9212
+ }
9213
+ if (response.status === 429) {
9214
+ const now = /* @__PURE__ */ new Date();
9215
+ const blockedUntil = retryAfter$1(response, now);
9216
+ finalize(reservation, "exhausted", blockedUntil, now);
9217
+ throw new UrlInspectionQuotaError({
9218
+ property: input.siteUrl,
9219
+ resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
9220
+ used: reservation ? reservation.used + reservation.count : 0,
9221
+ limit: reservation?.limit ?? 2e3,
9222
+ reason: "provider",
9223
+ requestSent: true
9224
+ });
9225
+ }
9020
9226
  finalize(reservation, "consumed");
9021
- if (error instanceof SeoError) throw error;
9022
- throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
9023
- }
9024
- if (response.status === 429) {
9025
- const now = /* @__PURE__ */ new Date();
9026
- const blockedUntil = retryAfter$1(response, now);
9027
- finalize(reservation, "exhausted", blockedUntil, now);
9028
- throw new UrlInspectionQuotaError({
9029
- property: input.siteUrl,
9030
- resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
9031
- used: reservation ? reservation.used + reservation.count : 0,
9032
- limit: reservation?.limit ?? 2e3,
9033
- reason: "provider",
9034
- requestSent: true
9035
- });
9036
- }
9037
- finalize(reservation, "consumed");
9038
- if (!response.ok) throw inspectionResponseError(response, input);
9039
- return await readJson(response, "URL Inspection");
9227
+ if (!response.ok) throw inspectionResponseError(response, input);
9228
+ return await readJson(response, "URL Inspection");
9229
+ });
9040
9230
  }
9041
9231
  // packages/core/src/gsc/client/search-analytics.ts
9042
9232
  function searchDateRange(days) {
@@ -9390,6 +9580,7 @@ function estimateSerpTitleWidth(title) {
9390
9580
  // packages/core/src/analyze/audit-page.ts
9391
9581
  const AUDIT_PAGE_RULE_IDS = [
9392
9582
  "missing_title",
9583
+ "empty_title",
9393
9584
  "title_too_wide",
9394
9585
  "h1_missing",
9395
9586
  "canonical_invalid",
@@ -9409,7 +9600,7 @@ function buildRecommendations(report) {
9409
9600
  const page = report.page;
9410
9601
  if (!page.title) recommendations.push({
9411
9602
  principle: "C.2",
9412
- evidenceRef: "The HTML document has no title element.",
9603
+ evidenceRef: page.title === "" ? "The HTML document has an empty title element." : "The HTML document has no title element.",
9413
9604
  action: "Add a concise, descriptive title that identifies this page.",
9414
9605
  effort: "S",
9415
9606
  confidence: "high"
@@ -9438,7 +9629,15 @@ async function auditPage(input, dependencies = defaultDependencies$12) {
9438
9629
  const page = await extractPage(fetched, input.extractor);
9439
9630
  const h1s = page.headings.filter((heading) => heading.level === 1);
9440
9631
  const issues = [];
9441
- if (!page.title) issues.push({
9632
+ if (page.title === "") issues.push({
9633
+ code: "empty_title",
9634
+ title: "Title empty",
9635
+ detail: "The <title> element has no text.",
9636
+ principle: "C.2",
9637
+ evidenceRef: "HTML document has an empty <title> element.",
9638
+ severity: "high"
9639
+ });
9640
+ else if (!page.title) issues.push({
9442
9641
  code: "missing_title",
9443
9642
  title: "Title missing",
9444
9643
  detail: "No <title> element was found.",
@@ -11687,7 +11886,8 @@ function auditCrawlPages(pages, opts = {}) {
11687
11886
  const title = page.title?.trim();
11688
11887
  const titleWidth = title ? estimateSerpTitleWidth(title) : void 0;
11689
11888
  const duplicateTitle = duplicateTitles.get(normalizedMetadata(title) ?? "");
11690
- if (!title) issues.push(issue$1("missing_title", page));
11889
+ if (title === void 0) issues.push(issue$1("missing_title", page));
11890
+ else if (!title) issues.push(issue$1("empty_title", page));
11691
11891
  else if (titleWidth?.status === "over-reference") issues.push(issue$1("title_too_wide", page, `${titleWidth.estimatedPixels}px`, {
11692
11892
  title,
11693
11893
  ...titleWidth
@@ -11699,7 +11899,8 @@ function auditCrawlPages(pages, opts = {}) {
11699
11899
  }));
11700
11900
  const metaDescription = page.metaDescription?.trim();
11701
11901
  const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
11702
- if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
11902
+ if (metaDescription === void 0) issues.push(issue$1("missing_meta_description", page));
11903
+ else if (!metaDescription) issues.push(issue$1("empty_meta_description", page));
11703
11904
  if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
11704
11905
  metaDescription,
11705
11906
  duplicateCount: duplicateDescription.count,
@@ -13542,13 +13743,15 @@ const crawlAiAvailabilitySchema = z.enum([
13542
13743
  "absent",
13543
13744
  "access-blocked",
13544
13745
  "rate-limited",
13545
- "unreachable"
13746
+ "unreachable",
13747
+ "unreadable"
13546
13748
  ]);
13547
13749
  const crawlFetchErrorKindSchema = z.enum([
13548
13750
  "dns",
13549
13751
  "tls",
13550
13752
  "timeout",
13551
13753
  "redirect-limit",
13754
+ "redirect-loop",
13552
13755
  "too-large",
13553
13756
  "aborted",
13554
13757
  "unknown"
@@ -13721,7 +13924,8 @@ const crawlRobotsEvidenceSchema = z.object({
13721
13924
  "absent",
13722
13925
  "access-blocked",
13723
13926
  "rate-limited",
13724
- "unreachable"
13927
+ "unreachable",
13928
+ "unreadable"
13725
13929
  ]),
13726
13930
  status: z.number().int().optional(),
13727
13931
  error: z.string().optional(),
@@ -13915,7 +14119,8 @@ const crawlPageSnapshotSchema = z.object({
13915
14119
  "absent",
13916
14120
  "access-blocked",
13917
14121
  "rate-limited",
13918
- "unreachable"
14122
+ "unreachable",
14123
+ "unreadable"
13919
14124
  ]),
13920
14125
  status: z.number().int().optional(),
13921
14126
  error: z.string().optional()
@@ -14260,6 +14465,7 @@ const crawlRequestObservationSchema = z.union([
14260
14465
  "tls",
14261
14466
  "timeout",
14262
14467
  "redirect-limit",
14468
+ "redirect-loop",
14263
14469
  "aborted",
14264
14470
  "unknown"
14265
14471
  ]),
@@ -14629,7 +14835,7 @@ function agentReadyIssues(input) {
14629
14835
  const issues = [];
14630
14836
  const blocked = (ai.robotsTxt?.botAccess ?? []).filter((bot) => bot.purpose === "search" && bot.allowed === false);
14631
14837
  if (blocked.length && ai.robotsTxt) issues.push(issue("ai_search_bot_blocked", ai.robotsTxt.url, `robots.txt blocks ${blocked.map((bot) => bot.userAgent).join(", ")} from the start URL.`, { blocked: blocked.map((bot) => bot.userAgent) }));
14632
- if (ai.robotsTxt?.servesHtml) issues.push(issue("robots_txt_html", ai.robotsTxt.url, `/robots.txt returned an HTML page (HTTP ${ai.robotsTxt.status}, ${ai.robotsTxt.contentType}), so it has no rules or sitemap lines.`, {
14838
+ if (ai.robotsTxt?.servesHtml) issues.push(issue("robots_txt_html", ai.robotsTxt.url, `/robots.txt returned an HTML page (HTTP ${ai.robotsTxt.status}, ${ai.robotsTxt.contentType}), not a robots.txt file, so its rules for crawlers are unknown. Google treats it as a file with no valid rules, so the crawl went ahead as Google would.`, {
14633
14839
  status: ai.robotsTxt.status,
14634
14840
  contentType: ai.robotsTxt.contentType
14635
14841
  }));
@@ -15155,6 +15361,12 @@ function fetchFailureKind(error, signal) {
15155
15361
  const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
15156
15362
  const message = error instanceof Error ? error.message : String(error);
15157
15363
  if (signal?.aborted) return "aborted";
15364
+ if (error instanceof RedirectChainError) {
15365
+ if (error.kind === "loop") return "redirect-loop";
15366
+ if (error.kind === "too_many") return "redirect-limit";
15367
+ if (error.kind === "timeout") return "timeout";
15368
+ return error.cause === void 0 ? "unknown" : fetchFailureKind(error.cause, signal);
15369
+ }
15158
15370
  if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
15159
15371
  if ([
15160
15372
  "ENOTFOUND",
@@ -15547,7 +15759,7 @@ function availabilityOf$1(status) {
15547
15759
  if (status >= 400 && status < 500) return "absent";
15548
15760
  return "unreachable";
15549
15761
  }
15550
- function errorText$1(error) {
15762
+ function errorText(error) {
15551
15763
  if (!(error instanceof Error)) return String(error);
15552
15764
  const cause = error.cause instanceof Error ? error.cause.message : "";
15553
15765
  return cause && !error.message.includes(cause) ? `${error.message}: ${cause}` : error.message;
@@ -15555,7 +15767,7 @@ function errorText$1(error) {
15555
15767
  function requestError$1(error, timeoutMs, signal) {
15556
15768
  const errorKind = fetchErrorKind(error, signal);
15557
15769
  return {
15558
- error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText$1(error),
15770
+ error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText(error),
15559
15771
  errorKind
15560
15772
  };
15561
15773
  }
@@ -15693,10 +15905,10 @@ function sitemapUrlsFromRobots(text) {
15693
15905
  async function checkRobotsAiAccess(input) {
15694
15906
  const robotsUrl = new URL("/robots.txt", input.url).toString();
15695
15907
  const result = await get$1(input, robotsUrl);
15696
- const servesHtml = result.availability === "available" && isHtml(result.contentType);
15697
- const availability = servesHtml ? "absent" : result.availability;
15908
+ const servesHtml = result.availability === "available" && robotsTxtIsHtml(result.contentType, result.text);
15909
+ const availability = servesHtml ? "unreadable" : result.availability;
15698
15910
  const exists = availability === "available";
15699
- const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
15911
+ const unknown = availability === "rate-limited" || availability === "unreachable" || availability === "unreadable";
15700
15912
  const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
15701
15913
  const parsed = parseRobots(robotsUrl, exists ? result.text : "");
15702
15914
  const ruleHosts = /* @__PURE__ */ new Set([new URL(robotsUrl).host, new URL(result.url).host]);
@@ -15713,7 +15925,7 @@ async function checkRobotsAiAccess(input) {
15713
15925
  ...result.status === void 0 ? {} : { status: result.status },
15714
15926
  ...result.contentType ? { contentType: result.contentType } : {},
15715
15927
  ...servesHtml ? { servesHtml } : {},
15716
- ...unknown ? { error: result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
15928
+ ...unknown ? { error: servesHtml ? ROBOTS_TXT_HTML_ERROR : result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
15717
15929
  ...result.errorKind ? { errorKind: result.errorKind } : {},
15718
15930
  sitemapUrls: exists ? sitemapUrlsFromRobots(result.text) : [],
15719
15931
  ...unknown ? {} : { contentSignals: exists ? contentSignalsFromRobots(result.text) : [] },
@@ -15725,7 +15937,7 @@ async function checkRobotsAiAccess(input) {
15725
15937
  coveredByWildcard: declared.has("*")
15726
15938
  }))
15727
15939
  },
15728
- allows: (url) => unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
15940
+ allows: (url) => servesHtml ? true : unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
15729
15941
  };
15730
15942
  }
15731
15943
  async function checkAgentResources(input) {
@@ -18578,6 +18790,7 @@ function emptyCounts() {
18578
18790
  "access-blocked": 0,
18579
18791
  "rate-limited": 0,
18580
18792
  unreachable: 0,
18793
+ unreadable: 0,
18581
18794
  failed: 0
18582
18795
  };
18583
18796
  }
@@ -18595,7 +18808,7 @@ async function readHomepage(url, fetch, signal) {
18595
18808
  signal
18596
18809
  });
18597
18810
  try {
18598
- const { response, redirectChain } = await fetchWithRedirectChain(url, controller.signal, fetch);
18811
+ const { response, redirectChain } = await fetchWithRedirectChain(url, controller.signal, fetch, { timeoutMs: SITE_FACTS_TIMEOUT_MS });
18599
18812
  const finalUrl = redirectChain.at(-1)?.location ?? url;
18600
18813
  const accessBlock = detectAccessBlock({
18601
18814
  status: response.status,
@@ -18681,12 +18894,13 @@ async function checkSite(origin, input, fetch) {
18681
18894
  signal: input.signal
18682
18895
  };
18683
18896
  const robots = await checkRobotsAiAccess(signalInput);
18684
- const homepageAllowed = robots.allows(home);
18897
+ const fetchAllowed = robots.allows(home);
18685
18898
  const { sitemapUrls, ...robotsTxt } = robots.robotsTxt;
18686
- const homepage = input.respectRobots !== false && homepageAllowed !== true ? {
18899
+ const homepageAllowed = robotsTxt.servesHtml ? null : fetchAllowed;
18900
+ const homepage = input.respectRobots !== false && fetchAllowed !== true ? {
18687
18901
  url: home,
18688
18902
  outcome: "skipped",
18689
- skipReason: homepageAllowed === false ? "robots-disallowed" : "robots-unknown"
18903
+ skipReason: fetchAllowed === false ? "robots-disallowed" : "robots-unknown"
18690
18904
  } : await readHomepage(home, fetch, input.signal);
18691
18905
  const llms = await readLlmsTxt(signalInput);
18692
18906
  const h1 = llms.llmsTxt.exists ? llmsH1(llms.text) : void 0;
@@ -26596,82 +26810,6 @@ async function indexCoverageSignals(input, dependencies = defaultDependencies$1)
26596
26810
  }
26597
26811
  };
26598
26812
  }
26599
- const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
26600
- 301,
26601
- 302,
26602
- 303,
26603
- 307,
26604
- 308
26605
- ]);
26606
- function errorText(error) {
26607
- if (!(error instanceof Error)) return String(error);
26608
- const cause = error.cause;
26609
- const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
26610
- return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
26611
- }
26612
- function httpUrl(value, base) {
26613
- try {
26614
- const url = new URL(value, base);
26615
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
26616
- url.hash = "";
26617
- return url.toString();
26618
- } catch {
26619
- return null;
26620
- }
26621
- }
26622
- async function traceRedirects(url, options = {}) {
26623
- const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
26624
- const timeoutMs = options.timeoutMs ?? 1e4;
26625
- const request = options.fetch ?? ((to, init) => fetch(to, init));
26626
- const steps = [];
26627
- const end = (final_url, final_status, outcome, error) => ({
26628
- url,
26629
- steps,
26630
- hops: steps.filter((step) => step.location !== null).length,
26631
- final_url,
26632
- final_status,
26633
- outcome,
26634
- ...error ? { error } : {}
26635
- });
26636
- const start = httpUrl(url);
26637
- if (!start) return end(url, null, "error", "Not an http or https URL.");
26638
- let current = start;
26639
- const seen = /* @__PURE__ */ new Set();
26640
- while (true) {
26641
- seen.add(current);
26642
- const startedAt = Date.now();
26643
- let response;
26644
- try {
26645
- response = await request(current, {
26646
- redirect: "manual",
26647
- headers: {
26648
- "user-agent": SEO_CRAWLER_USER_AGENT,
26649
- accept: "text/html,application/xhtml+xml,*/*;q=0.8"
26650
- },
26651
- signal: AbortSignal.timeout(timeoutMs)
26652
- });
26653
- } catch (error) {
26654
- return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`) : end(current, null, "error", errorText(error));
26655
- }
26656
- const ms = Date.now() - startedAt;
26657
- const header = response.headers.get("location");
26658
- const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
26659
- const location = redirects ? httpUrl(header, current) : null;
26660
- if (options.onResponse) await options.onResponse(response, current);
26661
- await response.body?.cancel().catch(() => void 0);
26662
- steps.push({
26663
- url: current,
26664
- status: response.status,
26665
- location,
26666
- ms
26667
- });
26668
- if (!redirects) return end(current, response.status, "done");
26669
- if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
26670
- if (seen.has(location)) return end(current, response.status, "loop");
26671
- if (steps.length > maxHops) return end(current, response.status, "too_many");
26672
- current = location;
26673
- }
26674
- }
26675
26813
  // packages/core/src/analyze/monitoring/redirect-trace.ts
26676
26814
  function redirectIssue(input) {
26677
26815
  const issues = [];
@@ -30010,7 +30148,19 @@ function makeBlock(kind, built, extra = {}) {
30010
30148
  text.pop();
30011
30149
  map.pop();
30012
30150
  }
30013
- if (!text.length) return null;
30151
+ if (!text.length) {
30152
+ if (extra.at === void 0 || kind !== "title" && kind !== "description") return null;
30153
+ map.push(extra.at);
30154
+ return {
30155
+ kind,
30156
+ text: "",
30157
+ norm: "",
30158
+ map,
30159
+ field: extra.field,
30160
+ section: 0,
30161
+ links: []
30162
+ };
30163
+ }
30014
30164
  const joined = text.join("");
30015
30165
  const links = [];
30016
30166
  for (const link of extra.links ?? []) {
@@ -30302,7 +30452,8 @@ var Extractor = class {
30302
30452
  }
30303
30453
  const block = makeBlock(kind, decodeValue(source, at, at + value.length, Boolean(quoted)), {
30304
30454
  field: key,
30305
- level: kind === "heading" ? 1 : void 0
30455
+ level: kind === "heading" ? 1 : void 0,
30456
+ at
30306
30457
  });
30307
30458
  if (block) this.props.push({
30308
30459
  at,
@@ -30625,7 +30776,10 @@ function extractHtml(source, astro) {
30625
30776
  const content = tag.attrs.get("content");
30626
30777
  const value = name ? source.slice(name.start, name.end) : "";
30627
30778
  if (content && /^description$/i.test(value)) {
30628
- const block = makeBlock("description", decodeValue(source, content.start, content.end, false), { field: "description" });
30779
+ const block = makeBlock("description", decodeValue(source, content.start, content.end, false), {
30780
+ field: "description",
30781
+ at: content.start
30782
+ });
30629
30783
  if (block) x.props.push({
30630
30784
  at: content.start,
30631
30785
  block
@@ -30635,7 +30789,10 @@ function extractHtml(source, astro) {
30635
30789
  if (lower === "title" && !tag.closing) {
30636
30790
  const close = source.toLowerCase().indexOf("</title", tag.end);
30637
30791
  const stop = close < 0 ? n : close;
30638
- const block = makeBlock("title", decodeValue(source, tag.end, stop, false), { field: "title" });
30792
+ const block = makeBlock("title", decodeValue(source, tag.end, stop, false), {
30793
+ field: "title",
30794
+ at: tag.end
30795
+ });
30639
30796
  if (block) x.props.push({
30640
30797
  at: tag.end,
30641
30798
  block
@@ -30749,7 +30906,10 @@ function extractMetaLines(source, metaKind) {
30749
30906
  const built = decodeValue(source, from, end, false);
30750
30907
  const value = source.slice(from, end).trim();
30751
30908
  const kind = label ? label[1]?.toLowerCase() : metaKind ? metaKind : value.length <= 70 ? "title" : "description";
30752
- const block = makeBlock(kind, built, { field: kind });
30909
+ const block = makeBlock(kind, built, {
30910
+ field: kind,
30911
+ ...label ? { at: from } : {}
30912
+ });
30753
30913
  if (block) blocks.push(block);
30754
30914
  }
30755
30915
  return finish(x, blocks);
@@ -30903,6 +31063,16 @@ function insideQuotes(text, start, end) {
30903
31063
  if (open <= start && i >= end - 1) return true;
30904
31064
  open = -1;
30905
31065
  }
31066
+ const opener = text.lastIndexOf("'", start);
31067
+ if (opener >= 0 && start - opener <= 80 && (opener === 0 || /[\s([]/.test(text[opener - 1] ?? "")) && /[A-Za-z]/.test(text[opener + 1] ?? "")) {
31068
+ const closer = /'(?![A-Za-z])/g;
31069
+ closer.lastIndex = Math.max(end - 1, opener + 1);
31070
+ const found = closer.exec(text);
31071
+ if (found && found.index - end <= 80) {
31072
+ const between = text.slice(opener + 1, found.index);
31073
+ if (!/'\s/.test(between.slice(0, Math.max(0, start - opener - 1)))) return true;
31074
+ }
31075
+ }
30906
31076
  return false;
30907
31077
  }
30908
31078
  var Context = class {
@@ -31016,7 +31186,12 @@ const PLAIN = {
31016
31186
  seamless: "smooth, simple (or say what does not break)",
31017
31187
  robust: "reliable, strong (or say what it survives)",
31018
31188
  holistic: "whole, complete",
31019
- utilize: "use"
31189
+ utilize: "use",
31190
+ breathtaking: "say what you see",
31191
+ stunning: "say what you see",
31192
+ enchanting: "say what you see",
31193
+ "must-visit": "say why to go",
31194
+ unforgettable: "say what stays with you"
31020
31195
  };
31021
31196
  function plainFor(word) {
31022
31197
  const lower = word.toLowerCase();
@@ -31084,12 +31259,13 @@ const PHRASE_RULES = [
31084
31259
  id: "throat-clearing",
31085
31260
  family: "ai",
31086
31261
  level: "error",
31087
- description: "Openers that announce a point instead of making it: \"Here's the thing\", \"The truth is\", \"Let that sink in\".",
31262
+ description: "Openers that announce a point instead of making it: \"Here's the thing\", \"The truth is\", \"The real question is\", \"Let that sink in\".",
31088
31263
  patterns: [
31089
31264
  /\bhere's the (?:thing|deal|kicker|catch|twist|truth|secret|reality)\b/gi,
31090
31265
  /(?<!\b(?:what|how|whatever|whether|where|why|who|if)\s)\b(?:the (?:uncomfortable |honest |hard |brutal |simple |real )?truth is|the reality is(?: that)?|the thing is|the honest answer(?: is)?)\b/gi,
31091
31266
  /\b(?:let me be (?:clear|honest|blunt)|let's be (?:clear|honest|real)|real talk|make no mistake|let that sink in|read that again)\b/gi,
31092
- /\bfull stop\.\s*$/gim
31267
+ /\bfull stop\.\s*$/gim,
31268
+ /\bthe (?:real|bigger|more interesting|only|big) question (?:now )?is\b|\bthe question now is\b/gi
31093
31269
  ],
31094
31270
  message: "This announces a point instead of making it.",
31095
31271
  fix: cut
@@ -31107,7 +31283,7 @@ const PHRASE_RULES = [
31107
31283
  /\b(?:a problem nobody talks about|the insight everyone's missing|most people don't reali[sz]e)\b/gi
31108
31284
  ],
31109
31285
  message: "It claims a hidden insight instead of showing the finding.",
31110
- fix: "Lead with the finding itself and the number behind it."
31286
+ fix: "Cut the setup and state the finding itself."
31111
31287
  },
31112
31288
  {
31113
31289
  id: "significance-inflation",
@@ -31126,10 +31302,16 @@ const PHRASE_RULES = [
31126
31302
  /\ban? (?:meaningful|significant|major|big|bold|giant|monumental|crucial|pivotal|huge) step (?:towards?|forward)\b/gi,
31127
31303
  /\bwatershed moment\b/gi,
31128
31304
  /\bhas emerged as (?:a|the|one of) (?:leading|key|major|critical|essential|pivotal|prominent|dominant)\b/gi,
31129
- /\bextends? far beyond\b/gi
31305
+ /\bextends? far beyond\b/gi,
31306
+ /\b(?:is|are|was) (?:just|only|merely) the (?:beginning|start)\b/gi,
31307
+ /\bmakes? (?:a|the) (?:real|big|huge|meaningful|lasting|tangible|world of) difference\b/gi,
31308
+ /\b(?:a|another) (?:major|key|significant|important|huge|big|proud) milestone\b/gi,
31309
+ /\bbuilding a better (?:future|world|tomorrow)\b/gi,
31310
+ /\bthe implications are (?:profound|significant|enormous|huge|far-reaching)\b/gi,
31311
+ /\b(?:is|was) an (?:event|experience|attraction) in itself\b/gi
31130
31312
  ],
31131
31313
  message: "It inflates importance without saying what changed.",
31132
- fix: "Name the concrete effect: what changed, for whom, and by how much."
31314
+ fix: "Cut it, or name the concrete effect the text already states. Do not add a number that is not there."
31133
31315
  },
31134
31316
  {
31135
31317
  id: "participle-tail",
@@ -31137,8 +31319,8 @@ const PHRASE_RULES = [
31137
31319
  level: "error",
31138
31320
  description: "A sentence that ends on \", highlighting/underscoring/showcasing ...\" commentary.",
31139
31321
  kinds: PROSE,
31140
- patterns: [/,\s+(?:highlighting|underscoring|emphasi[sz]ing|showcasing|symboli[sz]ing|solidifying|cementing|signall?ing|reinforcing|fostering|cultivating|paving the way|setting the stage|demonstrating|illustrating|reflecting|ensuring|marking|contributing to)\b[^.!?]{0,120}[.!?]?/gi],
31141
- levelFor: (match) => /^,\s+(?:reflecting|ensuring|marking|contributing|demonstrating|illustrating)/i.test(match.text) ? "warning" : void 0,
31322
+ patterns: [/,\s+(?:highlighting|underscoring|emphasi[sz]ing|showcasing|symboli[sz]ing|solidifying|cementing|signall?ing|reinforcing|fostering|cultivating|paving the way|setting the stage|demonstrating|illustrating|reflecting|ensuring|marking|contributing to)\b[^.!?]{0,120}[.!?]?/gi, /,\s+(?:giving (?:users|teams|customers|small teams|every \w+|people) (?:a|an|the|more)\b|freeing (?:you|them|up)\b|making it (?:easy|easier|simple|simpler|effortless|possible) (?:for|to)\b|creating an? (?:[\w-]+ )?(?:experience|panorama|atmosphere|sense)\b|delivering (?:real-time|accurate|predictions|reliable|a seamless|an intuitive)\b)[^.!?]{0,120}[.!?]?/gi],
31323
+ levelFor: (match) => /^,\s+(?:reflecting|ensuring|marking|contributing|demonstrating|illustrating|giving|freeing|making|creating|delivering)/i.test(match.text) ? "warning" : void 0,
31142
31324
  message: "The \", highlighting ...\" tail comments on the sentence instead of adding a fact.",
31143
31325
  fix: "End the sentence at the comma. If the tail holds a real fact, give it its own sentence with a subject and verb."
31144
31326
  },
@@ -31146,7 +31328,7 @@ const PHRASE_RULES = [
31146
31328
  id: "ai-vocabulary",
31147
31329
  family: "ai",
31148
31330
  level: "error",
31149
- description: "Words far more common in AI text than in human writing: delve, tapestry, testament, showcase, meticulous, realm.",
31331
+ description: "Words far more common in AI text than in human writing: delve, tapestry, testament, showcase, meticulous, realm. One alone is a warning; two or more in a piece are errors.",
31150
31332
  patterns: [
31151
31333
  /\bdelv(?:e|es|ed|ing)\b/gi,
31152
31334
  /\b(?:rich )?tapestry\b/gi,
@@ -31169,7 +31351,9 @@ const PHRASE_RULES = [
31169
31351
  /\bbustling\b/gi,
31170
31352
  /\bsynerg(?:y|ies)\b/gi,
31171
31353
  /\brevolutioni[sz](?:e|es|ed|ing)\b/gi,
31172
- /\bnavigat(?:e|ing) the complexit(?:y|ies)\b/gi
31354
+ /\bnavigat(?:e|ing) the complexit(?:y|ies)\b/gi,
31355
+ /\bbreathtaking(?:ly)?\b|\bmust-visit\b|\bunforgettable\b/gi,
31356
+ /\bstunning(?:ly)?\b|\benchanting\b/gi
31173
31357
  ],
31174
31358
  message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
31175
31359
  fix: (match) => `Use ${plainFor(match.text.replace(/^(?:rich|a) /i, ""))}.`
@@ -31211,7 +31395,7 @@ const PHRASE_RULES = [
31211
31395
  /\bbattle-tested\b|\bbulletproof\b|\bunparalleled\b|\bunmatched\b/gi
31212
31396
  ],
31213
31397
  message: (match) => `"${match.text}" claims quality without showing it.`,
31214
- fix: (match) => `Replace it with the proof: ${plainFor(match.text)}.`
31398
+ fix: (match) => `Cut it, or use what the text already shows (${plainFor(match.text)}). Do not add a claim the text does not make.`
31215
31399
  },
31216
31400
  {
31217
31401
  id: "cliche",
@@ -31245,11 +31429,13 @@ const PHRASE_RULES = [
31245
31429
  /\bpaves? the way\b/gi,
31246
31430
  /\bnorth star\b|\btable stakes\b|\bneedle in a haystack\b/gi,
31247
31431
  /\bnot a rounding error\b/gi,
31432
+ /\bpunch(?:es|ing)? (?:well )?above (?:its|their|his|her) weight\b/gi,
31433
+ /\b(?:thrilled|delighted) to (?:announce|share|introduce|unveil)\b/gi,
31248
31434
  /\bthe (?:whole|entire) point\b/gi,
31249
31435
  /,? which is (?:the (?:whole |real |entire )?point|exactly the point|kind of the point)\b/gi
31250
31436
  ],
31251
31437
  message: (match) => `"${match.text}" is a stock phrase that would fit any page.`,
31252
- fix: "Say the specific thing in literal words, with the number or name that makes it true here."
31438
+ fix: "Say the specific thing in literal words, using a fact the text already gives, or cut it."
31253
31439
  },
31254
31440
  {
31255
31441
  id: "filler",
@@ -31294,14 +31480,19 @@ const PHRASE_RULES = [
31294
31480
  id: "generic-closer",
31295
31481
  family: "ai",
31296
31482
  level: "error",
31297
- description: "Tidy endings: \"In conclusion\", \"the future looks bright\", \"only time will tell\".",
31483
+ description: "Tidy endings: \"In conclusion\", \"the future looks bright\", \"Here's to the next chapter\", \"Let's build something extraordinary\".",
31298
31484
  patterns: [
31299
31485
  /\bthe future (?:of \w+ )?(?:looks bright|is here|is now|starts now|is bright)\b/gi,
31300
31486
  /\bonly time will tell\b|\bone thing is (?:certain|clear)\b/gi,
31301
31487
  /\bexciting times (?:lie )?ahead\b|\bas we move forward\b/gi,
31302
31488
  /(?:^|[.!?]\s)(?:In conclusion|In summary|To sum (?:it )?up|To summari[sz]e|All in all|In closing)\b/gi,
31303
31489
  /\bthe possibilities are endless\b|\bthe rest is history\b/gi,
31304
- /\bbecause (?:you|your team) deserves? (?:better|more|the best)\b/gi
31490
+ /\bbecause (?:you|your team) deserves? (?:better|more|the best)\b/gi,
31491
+ /(?:^|[.!?]\s)Here's to (?:the|what|a|an|our|your|many|more|another|new)\b[^.!?]{0,60}[.!?]/g,
31492
+ /\blet's (?:build|make|create|shape) something (?:extraordinary|remarkable|amazing|great|special|incredible|beautiful|meaningful)\b/gi,
31493
+ /\bwe'd love (?:to hear )?your (?:feedback|thoughts)\b/gi,
31494
+ /\bthe way it (?:should|was meant to) (?:be|work)\b/gi,
31495
+ /(?:^|[.!?]\s)See you (?:there|soon|at the \w+|on the \w+)[.!]/g
31305
31496
  ],
31306
31497
  message: "A stock closing line that adds nothing the page has not said.",
31307
31498
  fix: "End on the next step, or on the last concrete fact."
@@ -31316,6 +31507,7 @@ const PHRASE_RULES = [
31316
31507
  /\bthat's when (?:everything|it all) changed\b/gi,
31317
31508
  /\b(?:then )?something (?:interesting|unexpected) happened\b/gi,
31318
31509
  /\b(?:this|here) is where (?:things|it) gets? (?:interesting|tricky|fun)\b|\bhere's where it gets (?:interesting|tricky)\b/gi,
31510
+ /\b(?:this|that) is where (?:[\w-]+ ){1,3}(?:shines?|excels?|stands? out)\b/gi,
31319
31511
  /\bflipped the script\b|\brewrote the playbook\b|\bchanged the game\b|\bplot twist\b/gi
31320
31512
  ],
31321
31513
  message: "A staged turn that tells the reader to feel surprised.",
@@ -31351,8 +31543,9 @@ const PHRASE_RULES = [
31351
31543
  /\b(?:studies|research|surveys) (?:show|shows|suggest|suggests|indicate|indicates|proves?)\b/gi,
31352
31544
  /\bit is (?:widely|generally|commonly) (?:believed|accepted|known|agreed)\b/gi
31353
31545
  ],
31546
+ skip: (match) => /^[A-Z]\w* [A-Z]/.test(match.text),
31354
31547
  message: "A claim credited to nobody in particular.",
31355
- fix: "Name the source and link it, or cut the claim."
31548
+ fix: "Name the source if the text has one. Otherwise cut the claim or say it as your own view."
31356
31549
  },
31357
31550
  {
31358
31551
  id: "hedge-stack",
@@ -31369,7 +31562,11 @@ const PHRASE_RULES = [
31369
31562
  level: "error",
31370
31563
  kinds: PROSE,
31371
31564
  description: "A label and colon used as a drumroll: \"The best part: it learns.\"",
31372
- patterns: [/(?:^|[.!?]\s+)(?:The|One|My|Here's the|And the|But the)\s+(?:\w+\s+){0,3}(?:part|thing|secret|trick|catch|kicker|truth|twist|takeaway|lesson|upshot|irony|punchline):\s+\S/g],
31565
+ patterns: [
31566
+ /(?:^|[.!?]\s+)(?:The|One|My|Here's the|And the|But the)\s+(?:\w+\s+){0,3}(?:part|thing|secret|trick|catch|kicker|truth|twist|takeaway|lesson|upshot|irony|punchline):\s+\S/g,
31567
+ /\bthe (?:idea|answer|fix|plan|rule|math|process|premise|concept) is simple:/gi,
31568
+ /\bthe (?:results?|answer|choice|difference) (?:was|were|became) (?:clear|obvious|striking)\b/gi
31569
+ ],
31373
31570
  message: "A colon reveal builds suspense the content does not need.",
31374
31571
  fix: "Say it straight: drop the label and keep what follows the colon."
31375
31572
  },
@@ -31377,12 +31574,13 @@ const PHRASE_RULES = [
31377
31574
  id: "copula-avoidance",
31378
31575
  family: "ai",
31379
31576
  level: "error",
31380
- description: "\"Serves as\", \"stands as\", \"boasts\" where \"is\" or \"has\" would do.",
31577
+ description: "\"Serves as\", \"stands as\", \"boasts\", \"represents a significant\" where \"is\" or \"has\" would do.",
31381
31578
  patterns: [
31382
31579
  /\bstands? as (?:a|an|the)\b/gi,
31383
31580
  /\bboasts? (?:a|an|the|over|more|\d)/gi,
31384
31581
  /\bholds? the distinction of\b/gi,
31385
- /\bserves? as (?:a|an|the)\b/gi
31582
+ /\bserves? as (?:a|an|the)\b/gi,
31583
+ /\brepresents? (?:a|an) (?:significant|major|big|huge|real|smart|key|mature|important|meaningful|proven)\b|\brepresents? (?:our|their|its|a) (?:commitment|dedication)\b/gi
31386
31584
  ],
31387
31585
  levelFor: (match) => /^serves?/i.test(match.text) ? "warning" : void 0,
31388
31586
  message: (match) => `"${match.text.trim()}" dodges a plain "is" or "has".`,
@@ -31401,7 +31599,7 @@ const PHRASE_RULES = [
31401
31599
  /\ball-in-one\b/gi,
31402
31600
  /\bin (?:just )?a few (?:clicks|minutes|seconds)\b/gi,
31403
31601
  /\bjoin (?:thousands|millions|hundreds) of (?:happy|satisfied|other)?\b/gi,
31404
- /\bin seconds,? not (?:hours|days|weeks)\b/gi,
31602
+ /\bin (?:seconds|minutes|hours|days),? not (?:minutes|hours|days|weeks|months)\b/gi,
31405
31603
  /\bmore than just an?\b/gi,
31406
31604
  /\bthat's where \w+ comes? in\b/gi,
31407
31605
  /\btrusted by (?:industry leaders|thousands|teams worldwide|the best)\b/gi,
@@ -31409,7 +31607,7 @@ const PHRASE_RULES = [
31409
31607
  ],
31410
31608
  levelFor: (match) => /the power of/i.test(match.text) ? "warning" : void 0,
31411
31609
  message: (match) => `"${match.text}" is a stock landing-page line that fits any product.`,
31412
- fix: "Say what the product does for this reader, with a number or an example."
31610
+ fix: "Cut the phrase, or replace it with a fact the text already gives, such as a number or a feature named elsewhere on the page. Do not add a new claim."
31413
31611
  },
31414
31612
  {
31415
31613
  id: "stranded-negation",
@@ -31424,12 +31622,63 @@ const PHRASE_RULES = [
31424
31622
  ],
31425
31623
  message: "A two-word denial used for rhythm. It makes the reader wait.",
31426
31624
  fix: "Join it to the sentence before, such as \"Nothing technical was holding these pages back.\""
31625
+ },
31626
+ {
31627
+ id: "strawman",
31628
+ family: "ai",
31629
+ level: "error",
31630
+ kinds: PROSE,
31631
+ description: "An answer to an objection nobody raised: \"It isn't perfect.\", \"doesn't have to be stressful\", \"None of this means\", \"This isn't about\".",
31632
+ patterns: [
31633
+ /(?:^|[.!?:]\s+)(?:It|This|That|Nothing|No \w+)(?:'s| is|'s not| isn't| is not| wasn't| was not) (?:not )?perfect\.(?=\s|$)/g,
31634
+ /\b(?:doesn't|does not|don't|do not|needn't|need not) (?:have to |need to )?be (?:so )?(?:stressful|hard|complicated|difficult|painful|expensive|overwhelming|a chore|scary|intimidating|daunting|confusing|a headache)\b/gi,
31635
+ /(?:^|[.!?]\s)None of this means\b/g,
31636
+ /(?:^|[.!?]\s)(?:This|That|It)(?: isn't| is not|'s not) (?:just |really |only )?about\b/g,
31637
+ /\b(?:isn't|is not|ain't) rocket science\b/gi
31638
+ ],
31639
+ message: "It answers a doubt the reader did not raise.",
31640
+ fix: "Cut it, or state the limit or the point plainly as a fact."
31641
+ },
31642
+ {
31643
+ id: "cta-question",
31644
+ family: "ai",
31645
+ level: "error",
31646
+ kinds: [
31647
+ "heading",
31648
+ "paragraph",
31649
+ "prop",
31650
+ "label"
31651
+ ],
31652
+ description: "A sales question used as a heading or closing line: \"Ready to Simplify Your Practice Finances?\", \"Tired of spreadsheets?\".",
31653
+ patterns: [/(?:^|[.!?]\s+)(?:Ready (?:to|for)|Tired of|Struggling (?:to|with)|Sick of|Fed up with)\b[^.!?]{1,80}\?/g],
31654
+ message: "A sales question that asks the reader something only to answer it with a pitch.",
31655
+ fix: "Say the offer or the next step as a plain statement, such as \"Start a free trial\" or the price."
31427
31656
  }
31428
31657
  ];
31658
+ const RIDER = /,\s+(?:allowing|making|leaving|turning|giving|creating|delivering|letting|enabling|helping|offering|providing|bringing|adding|keeping|drifting|tumbling|converting|resulting|saving|freeing|ensuring|reflecting)\s[^.!?;]{0,120}/gi;
31659
+ function riderDensity(ctx) {
31660
+ const rule = PHRASE_RULES.find((entry) => entry.id === "participle-tail");
31661
+ if (ctx.level(rule) === "off") return;
31662
+ const found = [];
31663
+ for (const block of ctx.blocks(PROSE)) {
31664
+ RIDER.lastIndex = 0;
31665
+ for (const match of block.norm.matchAll(RIDER)) {
31666
+ const start = match.index ?? 0;
31667
+ found.push({
31668
+ block,
31669
+ start,
31670
+ end: start + match[0].length
31671
+ });
31672
+ }
31673
+ }
31674
+ if (found.length < 2 || found.length * 250 < ctx.proseWords) return;
31675
+ for (const item of found) ctx.hit(rule, item.block, item.start, item.end, `${found.length} ", -ing" riders in ${ctx.proseWords.toLocaleString("en-US")} words. Tacking a result onto each fact is a common AI habit.`, "End the sentence at the comma. If the rider holds a fact the text needs, give it its own sentence with a subject and verb.", "warning");
31676
+ }
31429
31677
  function quoted(text, start, end) {
31430
31678
  return (text[start - 1] === "\"" || text[start - 1] === "'") && /^[.,;:!?]?["']/.test(text.slice(end, end + 2));
31431
31679
  }
31432
31680
  function runPhraseRules(ctx) {
31681
+ const before = ctx.hits.length;
31433
31682
  for (const rule of PHRASE_RULES) {
31434
31683
  if (ctx.level(rule) === "off") continue;
31435
31684
  for (const block of ctx.blocks(rule.kinds ?? ALL)) {
@@ -31456,6 +31705,9 @@ function runPhraseRules(ctx) {
31456
31705
  }
31457
31706
  }
31458
31707
  }
31708
+ riderDensity(ctx);
31709
+ const vocabulary = ctx.hits.slice(before).filter((hit) => hit.rule === "ai-vocabulary");
31710
+ if (vocabulary.length === 1) for (const hit of vocabulary) hit.level = "warning";
31459
31711
  }
31460
31712
  // packages/core/src/serp/browser.ts
31461
31713
  const SERP_PIXEL_BUDGETS = {
@@ -31610,7 +31862,7 @@ const SEO_RULES = {
31610
31862
  id: "dash",
31611
31863
  family: "copy",
31612
31864
  level: "error",
31613
- description: "Em dashes, en dashes and spaced hyphens used as dashes in copy."
31865
+ description: "Em dashes, en dashes and spaced hyphens used as dashes in copy. A separator in a title, such as \"Pricing - Example\", is left alone."
31614
31866
  },
31615
31867
  titleLength: {
31616
31868
  id: "title-length",
@@ -31649,7 +31901,7 @@ const SEO_RULES = {
31649
31901
  id: "emoji",
31650
31902
  family: "copy",
31651
31903
  level: "warning",
31652
- description: "Emoji at the start of headings or list items."
31904
+ description: "Emoji at the start of headings or list items, and decorative emoji such as 🎉 or 🚀 anywhere."
31653
31905
  },
31654
31906
  exclamation: {
31655
31907
  id: "exclamation",
@@ -31687,6 +31939,7 @@ function dash(ctx) {
31687
31939
  for (const block of ctx.blocks(ALL)) {
31688
31940
  re.lastIndex = 0;
31689
31941
  for (const found of block.norm.matchAll(re)) {
31942
+ if ((block.kind === "title" || block.kind === "label") && /^\s+\S*\s+$/.test(found[0])) continue;
31690
31943
  const start = (found.index ?? 0) + (found[1] ? 1 : 0);
31691
31944
  const end = (found.index ?? 0) + found[0].length;
31692
31945
  const range = Boolean(found[1]);
@@ -31705,11 +31958,15 @@ function lengths(ctx) {
31705
31958
  const length = block.text.length;
31706
31959
  if (length >= low && length <= high) continue;
31707
31960
  const what = title ? "title" : "description";
31961
+ if (!length) {
31962
+ ctx.hit(rule, block, 0, 0, `The ${what} is empty.`, title ? "Write a title that names the page, in 30 to 60 characters." : "Write one or two sentences that say what the page contains and who it is for, in 70 to 160 characters.", "error");
31963
+ continue;
31964
+ }
31708
31965
  const pixels = Math.ceil(serpTextWidth(block.text, what));
31709
31966
  ctx.hit(rule, block, 0, block.text.length, `The ${what} is ${length} characters, about ${pixels} pixels wide. Aim for ${low} to ${high} characters; Google shows about ${SERP_PIXEL_BUDGETS[what].desktop} pixels of a ${what} on desktop.`, length < low ? title ? "Add the words people search for, or one true reason to click, such as \"free\" or \"for any site\"." : "Say what the page contains and who it is for in one or two sentences." : title ? "Cut words after the main topic. Search results cut long titles off at about 60 characters." : "Cut it to one specific sentence. Search results cut descriptions off at about 160 characters.");
31710
31967
  }
31711
31968
  }
31712
- const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today)/i;
31969
+ const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today|(?:the )?(?:challenge|solution|results?)$|at a glance$|first impressions$|a note of thanks$)/i;
31713
31970
  function headings(ctx) {
31714
31971
  const list = ctx.blocks(["heading"]);
31715
31972
  const h1s = list.filter((block) => block.level === 1);
@@ -31723,7 +31980,17 @@ function headings(ctx) {
31723
31980
  }
31724
31981
  }
31725
31982
  const EMOJI = /^\p{Extended_Pictographic}/u;
31983
+ const DECORATION = /[\u{1F389}\u{1F680}\u{2728}\u{1F525}\u{1F4A1}\u{1F64C}\u{1F44F}\u{1F4AA}\u{1F3AF}\u{1F449}]/gu;
31726
31984
  function emoji(ctx) {
31985
+ for (const block of ctx.blocks(ALL)) {
31986
+ if (block.kind === "heading" || block.kind === "list") {
31987
+ if (EMOJI.test(block.text)) continue;
31988
+ }
31989
+ for (const found of block.text.matchAll(DECORATION)) {
31990
+ const at = found.index ?? 0;
31991
+ ctx.hit(SEO_RULES.emoji, block, at, at + found[0].length, `A decorative ${found[0]} emoji.`, "Remove it. The words should carry the point.");
31992
+ }
31993
+ }
31727
31994
  for (const block of ctx.blocks(["heading", "list"])) if (EMOJI.test(block.text)) ctx.hit(SEO_RULES.emoji, block, 0, Math.min(block.text.length, 40), `An emoji opens this ${block.kind === "heading" ? "heading" : "list item"}.`, "Remove it. The words should carry the point.");
31728
31995
  }
31729
31996
  function exclamation(ctx) {
@@ -31801,7 +32068,7 @@ function reviews(ctx) {
31801
32068
  PROOF.lastIndex = 0;
31802
32069
  for (const found of block.norm.matchAll(PROOF)) ctx.review("proof", block, found.index ?? 0, (found.index ?? 0) + found[0].length, `Is there a source for "${found[0]}" on the page? Link it or say where the number comes from.`);
31803
32070
  }
31804
- if (block.kind === "paragraph" && block.endsSection && sentences.length === 1 && words$2(block.text).length <= 10) ctx.review("mic-drop", block, 0, block.text.length, "Does this closing line add a fact? If it only restates the paragraph above, cut it.");
32071
+ if (block.kind === "paragraph" && block.endsSection && sentences.length === 1 && words$2(block.text).length <= 10 && words$2(block.text).filter((word) => /[a-z]/i.test(word)).length >= 3 && /[.!?]["')]?$/.test(block.text)) ctx.review("mic-drop", block, 0, block.text.length, "Does this closing line add a fact? If it only restates the paragraph above, cut it.");
31805
32072
  }
31806
32073
  if (ctx.preset === "landing" || ctx.preset === "meta") for (const block of ctx.blocks([
31807
32074
  "title",
@@ -31809,6 +32076,7 @@ function reviews(ctx) {
31809
32076
  "heading"
31810
32077
  ])) {
31811
32078
  if (block.kind === "heading" && block.level !== 1) continue;
32079
+ if (!block.text) continue;
31812
32080
  const later = words$2(block.text).slice(1);
31813
32081
  if (/\d/.test(block.text) || later.some((word) => /^[A-Z]/.test(word))) continue;
31814
32082
  ctx.review("swap-test", block, 0, block.text.length, "Would this line work unchanged on a competitor's page? If so, add the product, number or result only you can claim.");
@@ -31862,7 +32130,7 @@ const STRUCTURE_RULES = {
31862
32130
  id: "pithy-fragment",
31863
32131
  family: "ai",
31864
32132
  level: "error",
31865
- description: "A verbless two-beat fragment: \"Same essay, two titles.\" \"Simple, but powerful.\""
32133
+ description: "Verbless slogan fragments: \"Same essay, two titles.\", \"Every month. No exceptions.\", \"$299 per month. That's it.\""
31866
32134
  },
31867
32135
  stockCloser: {
31868
32136
  id: "stock-closer",
@@ -31886,7 +32154,7 @@ const STRUCTURE_RULES = {
31886
32154
  id: "anaphora",
31887
32155
  family: "ai",
31888
32156
  level: "error",
31889
- description: "Three or more very short sentences in a row that start with \"No\", \"It's\", \"They\", \"We\" or \"You\": \"No cookies. No tracking. No banners.\""
32157
+ description: "Short sentences in a row that start with the same word: two or more \"No ...\" sentences, or three or more starting \"It's\", \"They\", \"We\" or \"You\"."
31890
32158
  },
31891
32159
  vocabularyCluster: {
31892
32160
  id: "ai-vocabulary-cluster",
@@ -31899,7 +32167,7 @@ const STRUCTURE_RULES = {
31899
32167
  family: "ai",
31900
32168
  level: "warning",
31901
32169
  presets: { docs: "off" },
31902
- description: "Lists of three, more often than about one per 100 words, or three abstract nouns in a row."
32170
+ description: "Formula triads: three parallel clauses (\"It is cheap, it is reversible, and it works\"), three items that open with the same word, three adjectives after \"is\", or three abstract nouns. Plain lists of three are left alone."
31903
32171
  },
31904
32172
  inventedLabel: {
31905
32173
  id: "invented-label",
@@ -31913,6 +32181,16 @@ const STRUCTURE_RULES = {
31913
32181
  level: "warning",
31914
32182
  description: "Repeated \"What makes this work is ...\" or \"The problem is that ...\" setups."
31915
32183
  },
32184
+ boldEmphasis: {
32185
+ id: "bold-emphasis",
32186
+ family: "ai",
32187
+ level: "warning",
32188
+ presets: {
32189
+ docs: "off",
32190
+ meta: "off"
32191
+ },
32192
+ description: "Bold used for emphasis more than about once per 100 words of prose."
32193
+ },
31916
32194
  boldLabels: {
31917
32195
  id: "bold-label-bullets",
31918
32196
  family: "ai",
@@ -31971,6 +32249,11 @@ function negativeParallelism(ctx) {
31971
32249
  const from = restate ? end - restate[0].length : end;
31972
32250
  const second = block.text.slice(from, after.end).trim();
31973
32251
  const fix = second ? `Drop the "isn't" half and state the claim: "${second.charAt(0).toUpperCase()}${second.slice(1)}"` : "Drop the \"isn't\" half and state the claim once.";
32252
+ const concession = (/,\s*but\s+(?:[\w']+\s*){1,3}$/i.test(found[0]) || /^(?:while|although|though|even though)\b/i.test(subject)) && kind !== "reveal";
32253
+ if (level === "warning" && firstWords > 12 || concession) {
32254
+ ctx.review("contrast", block, sentence.start, Math.max(after.end, end), "Does this \"isn't X ... it's Y\" correct something a reader would really think? If not, state the claim once.");
32255
+ continue;
32256
+ }
31974
32257
  ctx.hit(rule, block, sentence.start, Math.max(after.end, end), "An \"isn't X, it's Y\" reframe argues with a claim nobody made.", fix, ctx.level(rule) === "off" ? void 0 : level);
31975
32258
  }
31976
32259
  }
@@ -32003,13 +32286,20 @@ function gerund(word) {
32003
32286
  const lower = word.toLowerCase();
32004
32287
  return lower.length > 4 && lower.endsWith("ing") && !NOT_GERUND.has(lower);
32005
32288
  }
32289
+ const NOT_JUST = /,\s+not (?:just|merely|simply) [^,.;:!?]{1,50}[.!?]?$|\bnot (?:just|merely|simply) [^,.;:!?]{1,50},? but\b/i;
32006
32290
  function contrastReframe(ctx) {
32007
32291
  const rule = STRUCTURE_RULES.contrastReframe;
32008
32292
  if (ctx.level(rule) === "off") return;
32009
32293
  for (const block of ctx.blocks([...SENTENCE_KINDS, "heading"])) for (const sentence of ctx.sentences(block)) {
32010
32294
  const text = sentence.text;
32295
+ const just = NOT_JUST.exec(text);
32296
+ if (just && (just[0].startsWith(",") || words$2(text).length <= 16) && !insideQuotes(block.norm, sentence.start + just.index, sentence.start + just.index + just[0].length)) {
32297
+ ctx.hit(rule, block, sentence.start, sentence.end, "A \"not just X\" contrast inflates the claim instead of stating it.", "Say what it is or does in one plain clause, and drop the \"not just\" half.");
32298
+ continue;
32299
+ }
32011
32300
  for (const found of text.matchAll(/,\s+not\s+|\s+rather than\s+/gi)) {
32012
32301
  const at = found.index ?? 0;
32302
+ if (insideQuotes(block.norm, sentence.start + at, sentence.start + at + found[0].length)) continue;
32013
32303
  const rather = /rather/i.test(found[0]);
32014
32304
  const left = text.slice(0, at);
32015
32305
  const rightAll = text.slice(at + found[0].length);
@@ -32054,8 +32344,13 @@ function heresAnnouncement(ctx) {
32054
32344
  if (!/^(?:And |So |But )?Here(?:'s| is| are) (?:the|what|why|how|who|where|when|a|an|my|our|some|two|three|four|five)\b/i.test(text) || /^(?:And |So |But )?Here's the (?:thing|deal|kicker|catch|twist|truth|secret|reality)\b/i.test(text)) continue;
32055
32345
  const count = words$2(text).length;
32056
32346
  const colon = text.endsWith(":");
32347
+ const sentences = ctx.sentences(block);
32348
+ if (sentence === sentences[sentences.length - 1]) {
32349
+ const after = ctx.doc.source.slice((block.map[block.map.length - 1] ?? 0) + 1).replace(/^[^\n]*\n/, "").trimStart();
32350
+ if (/^(?:!\[|<(?:img|figure|picture|video|iframe|blockquote|table)\b|>|\|)/i.test(after)) continue;
32351
+ }
32057
32352
  let level = null;
32058
- if (colon && count <= 4) level = "error";
32353
+ if (colon && count <= 5) level = "error";
32059
32354
  else if (!colon && !text.endsWith("?")) {
32060
32355
  if (count <= 9) level = "error";
32061
32356
  else if (count <= 16) level = "warning";
@@ -32073,9 +32368,29 @@ const TWO_BEAT = [
32073
32368
  const GREETING = /^(?:hi|hello|hey|thanks|thank you|cheers|dear|yes|no|ok|okay|sure|well|oh|example|note|tip|warning|update|step \d)\b/i;
32074
32369
  const LEADING_CLAUSE = /^(?:without|with|for|in|on|at|after|before|if|when|once|since|until|unless|by|from|to|then|also|first|next|finally|instead|otherwise|please)\b/i;
32075
32370
  const MONTH = /\b(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i;
32371
+ const KICKER = /^(?:That's it|That's all|No exceptions|No catch|No strings attached|No surprises|No asterisks|Period|Full stop|Simple as that|It's that simple|That simple|Nothing more|Nothing else)[.!]$/i;
32372
+ function slogan(text) {
32373
+ const list = words$2(text);
32374
+ return /[.!]$/.test(text) && !/^Not\b/.test(text) && list.length >= 1 && list.length <= 4 && !GREETING.test(text) && !MONTH.test(text) && !list.every((word) => /^[A-Z0-9]/.test(word)) && verbless(text);
32375
+ }
32376
+ function fragmentRuns(ctx, rule) {
32377
+ for (const block of ctx.blocks(RHYTHM_KINDS)) {
32378
+ const sentences = ctx.sentences(block);
32379
+ for (let i = 1; i < sentences.length; i++) {
32380
+ const before = sentences[i - 1];
32381
+ const sentence = sentences[i];
32382
+ const kicker = KICKER.test(sentence.text) && words$2(before.text).length <= 8;
32383
+ const pair = slogan(before.text) && slogan(sentence.text);
32384
+ if (!kicker && !pair) continue;
32385
+ ctx.hit(rule, block, before.start, sentence.end, kicker ? `"${sentence.text}" is a kicker added for rhythm after a short line.` : "Two verbless fragments in a row read as a slogan, not a sentence.", "Join them into one plain sentence with a verb, such as \"Books close within 48 hours of month end, every month,\" or cut the kicker.");
32386
+ i++;
32387
+ }
32388
+ }
32389
+ }
32076
32390
  function pithyFragment(ctx) {
32077
32391
  const rule = STRUCTURE_RULES.pithyFragment;
32078
32392
  if (ctx.level(rule) === "off") return;
32393
+ fragmentRuns(ctx, rule);
32079
32394
  for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
32080
32395
  const text = sentence.text;
32081
32396
  if (!/[.!]$/.test(text)) continue;
@@ -32114,7 +32429,7 @@ const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|ri
32114
32429
  function figurativeWorth(ctx) {
32115
32430
  const rule = STRUCTURE_RULES.figurativeWorth;
32116
32431
  const strong = new RegExp(`\\b(?:${FIGURATIVE_NOUNS}) worth \\w+ing\\b`, "gi");
32117
- const reader = /\bworth (?:noting|mentioning|knowing|highlighting|calling out|pointing out|flagging|a (?:closer |second )?look|your time|the (?:wait|hype|effort|read))\b/gi;
32432
+ const reader = /\bworth (?:noting|mentioning|knowing|highlighting|calling out|pointing out|flagging|a (?:closer |second )?look)\b/gi;
32118
32433
  const any = /\b(\w+) worth (\w+ing)\b/gi;
32119
32434
  for (const block of ctx.blocks([
32120
32435
  ...RHYTHM_KINDS,
@@ -32166,7 +32481,9 @@ function anaphora(ctx) {
32166
32481
  const first = words$2(sentences[i].text)[0]?.toLowerCase();
32167
32482
  let j = i + 1;
32168
32483
  while (j < sentences.length && first && words$2(sentences[j].text)[0]?.toLowerCase() === first) j++;
32169
- if (j - i >= 3 && first && ![
32484
+ const run = sentences.slice(i, j);
32485
+ if (first === "no" && j - i >= 2 && run.every((sentence) => /^No (?!one\b|matter\b|doubt\b|longer\b|way\b|wonder\b)[a-z$\d]/.test(sentence.text) && words$2(sentence.text).length <= 12)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} sentences in a row start with "No", a drumbeat of things the product is not.`, "Say what it is in one sentence, and keep only the one \"no\" a reader needs, such as \"One flat monthly fee with no tiers.\"");
32486
+ else if (j - i >= 3 && first && ![
32170
32487
  "the",
32171
32488
  "a",
32172
32489
  "an",
@@ -32177,7 +32494,7 @@ function anaphora(ctx) {
32177
32494
  "it",
32178
32495
  "this"
32179
32496
  ].includes(first)) {
32180
- if (sentences.slice(i, j).every((sentence) => words$2(sentence.text).length <= 7) && /^(?:it's|no|they|we|you)$/.test(first)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} short sentences in a row start with "${first}".`, "Merge them into one sentence or a short list.");
32497
+ if (run.every((sentence) => words$2(sentence.text).length <= 7) && /^(?:it's|they|we|you)$/.test(first)) ctx.hit(rule, block, sentences[i].start, sentences[j - 1].end, `${j - i} short sentences in a row start with "${first}".`, "Merge them into one sentence or a short list.");
32181
32498
  else ctx.review("anaphora", block, sentences[i].start, sentences[j - 1].end, `${j - i} sentences in a row start with "${first}". Does the repeat help the reader follow a real list, or is it there for rhythm?`);
32182
32499
  }
32183
32500
  i = j;
@@ -32218,30 +32535,66 @@ function vocabularyCluster(ctx) {
32218
32535
  }
32219
32536
  }
32220
32537
  }
32221
- const TRIAD = /\b[\w'-]+(?: [\w'-]+){0,2}, [\w'-]+(?: [\w'-]+){0,2},? and [\w'-]+\b/g;
32538
+ const SERIES = /\b[\w'$-]+(?: [\w'$-]+){0,5}(?:, [\w'$-]+(?: [\w'$-]+){0,5}){1,3},? (?:and|or) [\w'$-]+(?: [\w'$-]+){0,5}/g;
32222
32539
  const ABSTRACT_TRIAD = /\b[a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism), (?:and )?[a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism),? and [a-z]+(?:tion|sion|ity|ness|ment|ence|ance|ism)\b/gi;
32540
+ const ECHO = new Set("to our your their my his her its every each more less no it they we you".split(" "));
32541
+ const COPULA = /\b(?:is|are|was|were|'s|feels?|felt|looks?|stays?|remains?|seems?)\s+$/i;
32542
+ function formulaTriad(items, before) {
32543
+ const firsts = items.map((item) => words$2(item)[0]?.toLowerCase() ?? "");
32544
+ const echo = firsts[0] ?? "";
32545
+ if (ECHO.has(echo) && firsts.every((first) => first === echo)) return `Each item opens with "${echo}".`;
32546
+ if (items.every((item) => /^(?:the|their|our|your|its|it|they|we|you|he|she)\b/i.test(item) && words$2(item).length >= 3 && words$2(item).length <= 7 && !verbless(item))) return "Three parallel clauses in a row.";
32547
+ if (items.every((item) => /^[a-z]/.test(item) && words$2(item).length <= 2 && !PREPOSITIONS.has(words$2(item)[0] ?? "")) && COPULA.test(before)) return "A string of adjectives after \"is\".";
32548
+ if (items.every((item) => /^\w+ed\b/i.test(item) && words$2(item).length <= 3)) return "A string of past participles.";
32549
+ return null;
32550
+ }
32223
32551
  function triads(ctx) {
32224
32552
  const rule = STRUCTURE_RULES.triad;
32225
32553
  if (ctx.level(rule) === "off") return;
32226
- const found = [];
32227
32554
  for (const block of ctx.blocks([
32228
32555
  "paragraph",
32229
32556
  "quote",
32230
- "prop"
32557
+ "prop",
32558
+ "list"
32231
32559
  ])) {
32232
- TRIAD.lastIndex = 0;
32233
- for (const match of block.norm.matchAll(TRIAD)) found.push({
32234
- block,
32235
- start: match.index ?? 0,
32236
- end: (match.index ?? 0) + match[0].length
32237
- });
32560
+ const taken = [];
32238
32561
  ABSTRACT_TRIAD.lastIndex = 0;
32239
- for (const match of block.norm.matchAll(ABSTRACT_TRIAD)) ctx.hit(rule, block, match.index ?? 0, (match.index ?? 0) + match[0].length, "Three abstract nouns in a row sound weighty but say little.", "Keep the one that matters and give an example of it.");
32240
- }
32241
- const limit = Math.max(3, Math.ceil(ctx.proseWords / 100));
32242
- if (found.length > limit) {
32243
- const third = found[limit];
32244
- ctx.hit(rule, third.block, third.start, third.end, `${found.length} lists of three in ${ctx.proseWords.toLocaleString("en-US")} words of prose. More than about one per 100 words sounds formulaic.`, "Turn some of them into two items, four items or a sentence that ranks them.");
32562
+ for (const match of block.norm.matchAll(ABSTRACT_TRIAD)) {
32563
+ const start = match.index ?? 0;
32564
+ taken.push([start, start + match[0].length]);
32565
+ ctx.hit(rule, block, start, start + match[0].length, "Three abstract nouns in a row sound weighty but say little.", "Keep the one that matters and give an example of it.");
32566
+ }
32567
+ for (const sentence of ctx.sentences(block)) {
32568
+ SERIES.lastIndex = 0;
32569
+ for (const match of sentence.text.matchAll(SERIES)) {
32570
+ let from = match.index ?? 0;
32571
+ const raw = match[0].split(/,\s*(?:(?:and|or)\s+)?|\s+(?:and|or)\s+(?=[^,]*$)/);
32572
+ if (raw.length < 3) continue;
32573
+ const items = [...raw];
32574
+ const head = raw[0] ?? "";
32575
+ const lead = /^.*\b(?:is|are|was|were|'s|feels?|looks?|stays?|remains?|seems?|of|for|to|with|by|from|on|in|like|including|as)\s+/i.exec(head);
32576
+ if (lead && words$2(head.slice(lead[0].length)).length) {
32577
+ items[0] = head.slice(lead[0].length);
32578
+ from += lead[0].length;
32579
+ }
32580
+ const end = sentence.start + (match.index ?? 0) + match[0].length;
32581
+ const before = block.norm.slice(sentence.start, sentence.start + from);
32582
+ const echo = words$2(raw[1] ?? "")[0]?.toLowerCase() ?? "";
32583
+ const inFirst = new RegExp(`\\b${echo}\\b(?!.*\\b${echo}\\b)`, "i").exec(head);
32584
+ if (ECHO.has(echo) && inFirst && inFirst.index > 0) {
32585
+ items[0] = head.slice(inFirst.index);
32586
+ from = (match.index ?? 0) + inFirst.index;
32587
+ }
32588
+ const plain = formulaTriad(raw, "");
32589
+ const why = plain ?? formulaTriad(items, before);
32590
+ if (!why) continue;
32591
+ if (plain) from = match.index ?? 0;
32592
+ const start = sentence.start + from;
32593
+ if (taken.some(([a, b]) => start < b && end > a)) continue;
32594
+ taken.push([start, end]);
32595
+ ctx.hit(rule, block, start, end, `A list of ${items.length} that reads as a formula. ${why}`, "Keep the items the reader needs. If all of them are needed, write them plainly or as two sentences; if one was added for rhythm, cut it.");
32596
+ }
32597
+ }
32245
32598
  }
32246
32599
  }
32247
32600
  function countedPatterns(ctx) {
@@ -32292,6 +32645,42 @@ function boldLabels(ctx) {
32292
32645
  }
32293
32646
  report();
32294
32647
  }
32648
+ function boldEmphasis(ctx) {
32649
+ const rule = STRUCTURE_RULES.boldEmphasis;
32650
+ if (ctx.level(rule) === "off") return;
32651
+ const markdown = ctx.doc.format === "md" || ctx.doc.format === "mdx";
32652
+ const re = markdown ? /(\*\*|__)(?=\S)[^*_\n]+?\1/g : /<(strong|b)\b[^>]*>[^<]{1,200}<\/\1>/gi;
32653
+ const prose = ctx.blocks([
32654
+ "paragraph",
32655
+ "list",
32656
+ "quote",
32657
+ "prop"
32658
+ ]);
32659
+ const spans = [];
32660
+ for (const found of ctx.doc.source.matchAll(re)) {
32661
+ const open = markdown ? 2 : found[0].indexOf(">") + 1;
32662
+ const from = (found.index ?? 0) + open;
32663
+ const to = (found.index ?? 0) + found[0].length - (markdown ? 2 : found[0].length - found[0].lastIndexOf("<"));
32664
+ for (const block of prose) {
32665
+ const first = block.map[0] ?? 0;
32666
+ const last = block.map[block.map.length - 1] ?? 0;
32667
+ if (from < first || from > last) continue;
32668
+ const start = block.map.indexOf(from);
32669
+ if (start <= 0 && block.kind === "list") break;
32670
+ if (start < 0) break;
32671
+ let end = start;
32672
+ while (end < block.map.length && (block.map[end] ?? 0) < to) end++;
32673
+ spans.push({
32674
+ block,
32675
+ start,
32676
+ end
32677
+ });
32678
+ break;
32679
+ }
32680
+ }
32681
+ if (spans.length < 3 || spans.length * 100 <= ctx.proseWords) return;
32682
+ for (const span of spans) ctx.hit(rule, span.block, span.start, span.end, `${spans.length} bold phrases in ${ctx.proseWords.toLocaleString("en-US")} words of prose. Bold this often stops meaning "important".`, "Remove the bold here. Keep bold, if any, for the one thing a skimming reader must not miss.");
32683
+ }
32295
32684
  function runStructureRules(ctx) {
32296
32685
  negativeParallelism(ctx);
32297
32686
  contrastReframe(ctx);
@@ -32305,6 +32694,7 @@ function runStructureRules(ctx) {
32305
32694
  triads(ctx);
32306
32695
  countedPatterns(ctx);
32307
32696
  boldLabels(ctx);
32697
+ boldEmphasis(ctx);
32308
32698
  }
32309
32699
  // packages/core/src/lint/types.ts
32310
32700
  const LINT_PRESETS = [
@@ -32339,6 +32729,37 @@ const LINT_RULES = [
32339
32729
  })),
32340
32730
  ...Object.values(SEO_RULES)
32341
32731
  ];
32732
+ const LINT_REVIEW_LENSES = {
32733
+ contrast: "A \", not X\" tail that may or may not be a needed correction.",
32734
+ metaphor: "A figure of speech that may not fit, such as a thing that \"waits\" or \"wants\".",
32735
+ evidence: "A superlative with no number or source nearby.",
32736
+ "mic-drop": "A short closing line that may only restate the paragraph.",
32737
+ "swap-test": "A headline or description that could sit on a competitor’s page unchanged.",
32738
+ proof: "A customer count or percentage that needs a source.",
32739
+ staccato: "Three or more very short sentences in a row that may be a drumbeat.",
32740
+ anaphora: "Several sentences in a row that open with the same word, for rhythm."
32741
+ };
32742
+ function lintRulesText(only) {
32743
+ const keep = (category) => !only?.length || only.includes(category);
32744
+ const lines = [];
32745
+ for (const category of LINT_CATEGORIES) {
32746
+ if (!keep(category)) continue;
32747
+ lines.push(`${CATEGORY_NAMES[category]} (${category})`);
32748
+ for (const rule of LINT_RULES) {
32749
+ if (rule.family !== category) continue;
32750
+ const presets = Object.entries(rule.presets ?? {}).map(([preset, level]) => `${preset} ${level}`).join(", ");
32751
+ lines.push(` ${rule.id} ${rule.level}${presets ? ` (${presets})` : ""}`, ` ${rule.description}`);
32752
+ }
32753
+ const lenses = Object.entries(LINT_REVIEW_LENSES).filter(([lens]) => lensCategory(lens) === category);
32754
+ if (lenses.length) {
32755
+ lines.push(" Review lenses (judgement calls, never errors):");
32756
+ for (const [lens, description] of lenses) lines.push(` ${lens}`, ` ${description}`);
32757
+ }
32758
+ lines.push("");
32759
+ }
32760
+ lines.push("Skip a rule with --ignore <id>, or one line with a <!-- seo-lint-ignore <id> --> comment above it.");
32761
+ return lines.join("\n");
32762
+ }
32342
32763
  const AI_RULES = new Set(LINT_RULES.filter((rule) => rule.family === "ai").map((rule) => rule.id));
32343
32764
  const RULE_CATEGORY = new Map(LINT_RULES.map((rule) => [rule.id, rule.family]));
32344
32765
  const LENS_CATEGORY = {
@@ -34446,16 +34867,18 @@ async function analyticsBeside(access, site, range, before, pages) {
34446
34867
  }
34447
34868
  // packages/core/src/commands/audit.ts
34448
34869
  const AUDIT_SECTION = "weekly-audit";
34449
- 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, tags that JavaScript changes), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
34870
+ const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing, empty or duplicate titles and descriptions, missing H1s, noindex for Google, canonicals to another page, sitemap URLs that do not return 200, thin pages, tags that JavaScript changes), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
34450
34871
  const AUDIT_RULES = {
34451
34872
  link_to_error: "Internal links to a page that returns an error",
34452
34873
  link_to_redirect: "Internal links to a redirect",
34453
34874
  link_failed: "Internal links to a page that could not be fetched",
34454
34875
  title_missing: "Pages with no title",
34876
+ title_empty: "Pages with an empty title",
34455
34877
  title_duplicate: "Titles used by more than one page",
34456
34878
  description_missing: "Pages with no meta description",
34879
+ description_empty: "Pages with an empty meta description",
34457
34880
  h1_missing: "Pages with no H1",
34458
- noindex: "Pages that ask not to be indexed",
34881
+ noindex: "Pages that ask Google not to index them",
34459
34882
  canonical_elsewhere: "Pages whose canonical is another URL",
34460
34883
  sitemap_url_not_ok: "Sitemap URLs that do not return 200",
34461
34884
  sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
@@ -36288,6 +36711,19 @@ async function pageSource(url, readPage) {
36288
36711
  };
36289
36712
  }
36290
36713
  async function runLint(input, readFiles, readPage) {
36714
+ if (input.rules) {
36715
+ const only = input.only?.length ? input.only : void 0;
36716
+ return {
36717
+ text: lintRulesText(only),
36718
+ data: { rules: LINT_RULES.filter((rule) => !only || only.includes(rule.family)).map(({ id, family, level, presets, description }) => ({
36719
+ id,
36720
+ category: family,
36721
+ level,
36722
+ ...presets ? { presets } : {},
36723
+ description
36724
+ })) }
36725
+ };
36726
+ }
36291
36727
  const hasText = typeof input.text === "string" && input.text.length > 0;
36292
36728
  const hasPaths = Boolean(input.paths?.length);
36293
36729
  const hasUrl = typeof input.url === "string" && input.url.length > 0;
@@ -37784,13 +38220,14 @@ async function runPage(input, deps) {
37784
38220
  deps.progress?.(`Checking ${input.url}: the live page, Google index status, sitemaps and Search Console queries.`);
37785
38221
  const [live, inspection, sitemap, [now, then], markdown] = await Promise.all([
37786
38222
  leg("live page", deps.liveCheck(input.url), (value) => `Live page: ${value.first.status ?? value.first.error ?? "no answer"}${value.hops.length ? ` and ${value.hops.length} redirects` : ""}`),
37787
- leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}` : `Google index status: not available (${value.error?.code})`),
38223
+ leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}${value.savedAt ? ` (saved from ${value.savedAt})` : ""}` : `Google index status: not available (${value.error?.code})`),
37788
38224
  leg("sitemaps", sitemapUrls(input.site), (value) => `Sitemaps: ${value.urls.length} URLs from ${value.files} ${value.files === 1 ? "file" : "files"}${value.partial ? " (partial)" : ""}`),
37789
38225
  leg("Search Console queries", Promise.all([query(range), query(before)]), ([current]) => `Search Console: ${current.rows.length} queries`),
37790
38226
  full && deps.pageMarkdown ? leg("main content", deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })), () => "Main content read") : null
37791
38227
  ]);
37792
38228
  const indexed = inspection.index;
37793
38229
  const indexError = inspection.error;
38230
+ const savedAt = inspection.savedAt;
37794
38231
  const facts = live.first;
37795
38232
  const reason = indexed ? indexingRow({
37796
38233
  url: input.url,
@@ -37837,7 +38274,7 @@ async function runPage(input, deps) {
37837
38274
  title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
37838
38275
  notes: [
37839
38276
  `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"}.` : ""}`,
37840
- indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
38277
+ indexed ? `Google index status${savedAt ? ` (from the inspection saved at ${savedAt}, within the last 7 days, so no quota was used; in the CLI, add --refresh to ask Google again)` : ""}:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
37841
38278
  ...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.)`] : [],
37842
38279
  ...indexed && /^URL is unknown to Google$/i.test(indexed.coverage ?? "") ? [unknownNext(input, sitemap.urls.includes(input.url), deps)] : [],
37843
38280
  `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.`,
@@ -37867,6 +38304,7 @@ async function runPage(input, deps) {
37867
38304
  page: facts,
37868
38305
  index: indexed,
37869
38306
  indexError,
38307
+ indexInspectedAt: savedAt,
37870
38308
  reason,
37871
38309
  totalQueries: rows.length,
37872
38310
  queries: rows.slice(offset, offset + 50),
@@ -37876,6 +38314,14 @@ async function runPage(input, deps) {
37876
38314
  }
37877
38315
  const INSPECT_TIMEOUT_MS = 6e4;
37878
38316
  async function inspectWithin(deps, input) {
38317
+ try {
38318
+ const saved = deps.savedInspections?.read(input.site, [input.url]).get(input.url);
38319
+ if (saved) return {
38320
+ index: saved.status,
38321
+ error: null,
38322
+ savedAt: saved.inspectedAt
38323
+ };
38324
+ } catch {}
37879
38325
  let timer;
37880
38326
  const stop = new AbortController();
37881
38327
  const timeout = new Promise((resolve) => {
@@ -37888,17 +38334,25 @@ async function inspectWithin(deps, input) {
37888
38334
  }, INSPECT_TIMEOUT_MS);
37889
38335
  });
37890
38336
  try {
37891
- return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => ({
37892
- index,
37893
- error: null
37894
- })), timeout.then((error) => ({
38337
+ return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => {
38338
+ try {
38339
+ deps.savedInspections?.save(input.site, input.url, index);
38340
+ } catch {}
38341
+ return {
38342
+ index,
38343
+ error: null,
38344
+ savedAt: null
38345
+ };
38346
+ }), timeout.then((error) => ({
37895
38347
  index: null,
37896
- error
38348
+ error,
38349
+ savedAt: null
37897
38350
  }))]);
37898
38351
  } catch (error) {
37899
38352
  const known = commandErrorFrom(error);
37900
38353
  return {
37901
38354
  index: null,
38355
+ savedAt: null,
37902
38356
  error: {
37903
38357
  code: known instanceof CommandError ? known.code : "GOOGLE_ERROR",
37904
38358
  message: known instanceof Error ? known.message : String(known)
@@ -38371,7 +38825,8 @@ const lintInput = {
38371
38825
  meta_kind: z.enum(LINT_META_KINDS).optional().describe("Optional, with the meta preset: title or description, what a line with no \"title:\" or \"description:\" label is. Default: a line up to 70 characters is a title, a longer one a description."),
38372
38826
  ignore_rules: z.array(z.string().min(1).max(60)).max(50).optional().describe("Optional rule ids or review lenses to skip."),
38373
38827
  only: z.array(z.enum(LINT_CATEGORIES)).max(LINT_CATEGORIES.length).optional().describe("Optional categories to check: ai (patterns common in AI-written text), copy (weak copy and house style, such as dashes) or seo (titles, descriptions, headings). Default: all."),
38374
- ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching.")
38828
+ ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching."),
38829
+ rules: z.boolean().optional().describe("Optional. true lists every rule with its id, category, level and description instead of checking text; only limits the list, such as only [\"ai\"].")
38375
38830
  };
38376
38831
  const SHARED_TOOLS = [
38377
38832
  tool({
@@ -38648,7 +39103,8 @@ const SHARED_TOOLS = [
38648
39103
  ignoreRules: input.ignore_rules,
38649
39104
  ignorePhrases: input.ignore_phrases,
38650
39105
  only: input.only,
38651
- metaKind: input.meta_kind
39106
+ metaKind: input.meta_kind,
39107
+ rules: input.rules
38652
39108
  }, surface.readFiles, pageMarkdown && ((url) => pageMarkdown(url, 2e5)));
38653
39109
  }
38654
39110
  })
@@ -40736,9 +41192,11 @@ const localCredentialsProvider = new LocalCredentialsProvider();
40736
41192
  // packages/core/src/site-check/rules.ts
40737
41193
  const CRAWLER_RULES = {
40738
41194
  missing_title: page("fail", "Give the page a title."),
41195
+ empty_title: page("fail", "Fill in the empty title."),
40739
41196
  title_duplicate: page("fail", "Give every page its own title."),
40740
41197
  title_too_wide: page("warn", "Put the important words first in the title."),
40741
41198
  missing_meta_description: page("fail", "Give the page a meta description."),
41199
+ empty_meta_description: page("fail", "Fill in the empty meta description."),
40742
41200
  meta_description_duplicate: page("fail", "Give every page its own meta description."),
40743
41201
  canonical_missing: page("fail", "Add a canonical link to the page URL."),
40744
41202
  canonical_invalid: page("fail", "Fix the canonical URL."),
@@ -41485,6 +41943,6 @@ function isoDate(daysAgo = 0) {
41485
41943
  date.setUTCDate(date.getUTCDate() - daysAgo);
41486
41944
  return date.toISOString().slice(0, 10);
41487
41945
  }
41488
- export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, SITE_FACTS_DEFAULT_CONCURRENCY, SITE_FACTS_MAX_CONCURRENCY, SITE_FACTS_MAX_SITES, SITE_FACTS_TIMEOUT_MS, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, checkSites, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlCoverageCaveat, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchFailureKind, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, localResearchStore, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
41946
+ export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GOOGLE_TIMEOUT_MS, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, SITE_FACTS_DEFAULT_CONCURRENCY, SITE_FACTS_MAX_CONCURRENCY, SITE_FACTS_MAX_SITES, SITE_FACTS_TIMEOUT_MS, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, URL_INSPECTION_TIMEOUT_MS, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, checkSites, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlCoverageCaveat, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchFailureKind, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, localResearchStore, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, withinGoogleTimeout, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
41489
41947
 
41490
41948
  //# sourceMappingURL=index.js.map