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/cli.js +73 -73
- package/dist/cli.js.map +1 -1
- package/dist/{dist-CWH3D29C.js → dist-B9mN7y6S.js} +1 -1
- package/dist/{dist-DzEqoO8B.js → dist-BjB7Lfd6.js} +138 -137
- package/dist/dist-BjB7Lfd6.js.map +1 -0
- package/dist/{esm-CkObTzDg.js → esm-G8B5TPzB.js} +2 -2
- package/dist/{esm-CkObTzDg.js.map → esm-G8B5TPzB.js.map} +1 -1
- package/dist/index.d.ts +222 -79
- package/dist/index.js +701 -243
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +690 -238
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/dist-DzEqoO8B.js.map +0 -1
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-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
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
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
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 (
|
|
9022
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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" &&
|
|
15697
|
-
const availability = servesHtml ? "
|
|
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 =
|
|
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
|
|
18897
|
+
const fetchAllowed = robots.allows(home);
|
|
18685
18898
|
const { sitemapUrls, ...robotsTxt } = robots.robotsTxt;
|
|
18686
|
-
const
|
|
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:
|
|
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)
|
|
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), {
|
|
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), {
|
|
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, {
|
|
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: "
|
|
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: "
|
|
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) => `
|
|
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,
|
|
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\", \"
|
|
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
|
|
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: [
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 <=
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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))
|
|
32240
|
-
|
|
32241
|
-
|
|
32242
|
-
|
|
32243
|
-
|
|
32244
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
37893
|
-
|
|
37894
|
-
|
|
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
|