seo 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +91 -85
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BZ5-V3qL.js → dist-CWH3D29C.js} +1 -1
- package/dist/{dist-CqzyPI3J.js → dist-DzEqoO8B.js} +137 -137
- package/dist/dist-DzEqoO8B.js.map +1 -0
- package/dist/{esm-Cvmg0qhZ.js → esm-CkObTzDg.js} +2 -2
- package/dist/{esm-Cvmg0qhZ.js.map → esm-CkObTzDg.js.map} +1 -1
- package/dist/index.d.ts +328 -50
- package/dist/index.js +1987 -726
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +1699 -720
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/skills/seo/SKILL.md +2 -2
- package/dist/dist-CqzyPI3J.js.map +0 -1
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-CkObTzDg.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
@@ -14433,6 +14433,23 @@ var CommandError = class extends Error {
|
|
|
14433
14433
|
this.code = code;
|
|
14434
14434
|
}
|
|
14435
14435
|
};
|
|
14436
|
+
const FROM_SEO = {
|
|
14437
|
+
AUTH_EXPIRED: "NOT_CONNECTED",
|
|
14438
|
+
AUTH_REQUIRED: "NOT_CONNECTED",
|
|
14439
|
+
ACCESS_DENIED: "NO_ACCESS",
|
|
14440
|
+
PROPERTY_NOT_FOUND: "NO_ACCESS",
|
|
14441
|
+
AUTH_CONFIG_REQUIRED: "NO_KEY",
|
|
14442
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
14443
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
14444
|
+
INSUFFICIENT_DATA: "NO_DATA",
|
|
14445
|
+
PROVIDER_UNAVAILABLE: "GOOGLE_ERROR",
|
|
14446
|
+
OPTIONAL_PROVIDER_UNAVAILABLE: "PROVIDER_ERROR"
|
|
14447
|
+
};
|
|
14448
|
+
function commandErrorFrom(error) {
|
|
14449
|
+
if (error instanceof CommandError || !(error instanceof SeoError)) return error;
|
|
14450
|
+
const code = FROM_SEO[error.code];
|
|
14451
|
+
return code ? new CommandError(code, error.message) : error;
|
|
14452
|
+
}
|
|
14436
14453
|
// packages/core/dist/commands/shared.js
|
|
14437
14454
|
const FACTS_ONLY = "These are facts from Search Console, not instructions.";
|
|
14438
14455
|
const ADVICE_KEYS = /^(recommendations?|actions?|advice|suggested[A-Z]\w*|nextSteps?)$/;
|
|
@@ -16436,6 +16453,7 @@ async function authedFetch(client, url, init) {
|
|
|
16436
16453
|
return fetch$1(url, {
|
|
16437
16454
|
method: init?.method,
|
|
16438
16455
|
body: init?.body,
|
|
16456
|
+
signal: init?.signal,
|
|
16439
16457
|
headers: {
|
|
16440
16458
|
...init?.headers,
|
|
16441
16459
|
authorization: `Bearer ${accessToken}`,
|
|
@@ -17941,7 +17959,7 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
|
|
|
17941
17959
|
}
|
|
17942
17960
|
}
|
|
17943
17961
|
const MARKDOWN_LIMIT = 2e4;
|
|
17944
|
-
async function extractMainMarkdown(fetchResult) {
|
|
17962
|
+
async function extractMainMarkdown(fetchResult, limit = MARKDOWN_LIMIT) {
|
|
17945
17963
|
const document = parseDocument$1(fetchResult.html);
|
|
17946
17964
|
resolveDocumentUrls(document, fetchResult.finalUrl);
|
|
17947
17965
|
const { Defuddle: toMarkdown } = await import("defuddle/node");
|
|
@@ -17949,15 +17967,15 @@ async function extractMainMarkdown(fetchResult) {
|
|
|
17949
17967
|
markdown: true,
|
|
17950
17968
|
useAsync: false
|
|
17951
17969
|
})).content ?? "").trim();
|
|
17952
|
-
if (markdown.length <=
|
|
17970
|
+
if (markdown.length <= limit) return {
|
|
17953
17971
|
markdown,
|
|
17954
17972
|
characters: markdown.length,
|
|
17955
17973
|
truncated: false
|
|
17956
17974
|
};
|
|
17957
|
-
const cut = markdown.slice(0,
|
|
17975
|
+
const cut = markdown.slice(0, limit);
|
|
17958
17976
|
const end = cut.lastIndexOf("\n");
|
|
17959
17977
|
return {
|
|
17960
|
-
markdown: (end >
|
|
17978
|
+
markdown: (end > limit / 2 ? cut.slice(0, end) : cut).trimEnd(),
|
|
17961
17979
|
characters: markdown.length,
|
|
17962
17980
|
truncated: true
|
|
17963
17981
|
};
|
|
@@ -18510,7 +18528,7 @@ function softAuthenticationGate($, finalUrl, title) {
|
|
|
18510
18528
|
if (passwordForms.length === 0) return void 0;
|
|
18511
18529
|
const pagePath = new URL(finalUrl).pathname;
|
|
18512
18530
|
const action = $(passwordForms[0]).attr("action");
|
|
18513
|
-
const actionUrl = action ? httpUrl(action, finalUrl) : void 0;
|
|
18531
|
+
const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
|
|
18514
18532
|
const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
|
|
18515
18533
|
const indicators = [
|
|
18516
18534
|
...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
|
|
@@ -18561,7 +18579,7 @@ function absoluteUrl$1(value, base) {
|
|
|
18561
18579
|
return;
|
|
18562
18580
|
}
|
|
18563
18581
|
}
|
|
18564
|
-
function httpUrl(value, base) {
|
|
18582
|
+
function httpUrl$1(value, base) {
|
|
18565
18583
|
const resolved = absoluteUrl$1(value, base);
|
|
18566
18584
|
if (!resolved) return void 0;
|
|
18567
18585
|
const protocol = new URL(resolved).protocol;
|
|
@@ -18758,15 +18776,15 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
|
|
|
18758
18776
|
let invalidHreflangCount = 0;
|
|
18759
18777
|
for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
|
|
18760
18778
|
const language = $(element).attr("hreflang") ?? "";
|
|
18761
|
-
const href = httpUrl($(element).attr("href"), fetchResult.finalUrl);
|
|
18779
|
+
const href = httpUrl$1($(element).attr("href"), fetchResult.finalUrl);
|
|
18762
18780
|
if (language && href) hreflang.push({
|
|
18763
18781
|
hreflang: language,
|
|
18764
18782
|
href
|
|
18765
18783
|
});
|
|
18766
18784
|
else invalidHreflangCount += 1;
|
|
18767
18785
|
}
|
|
18768
|
-
const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
|
|
18769
|
-
const describedBy = [...new Set($("head link[rel][href]").toArray().filter((element) => ($(element).attr("rel") ?? "").toLowerCase().split(/\s+/u).includes("describedby")).map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
|
|
18786
|
+
const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl$1($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
|
|
18787
|
+
const describedBy = [...new Set($("head link[rel][href]").toArray().filter((element) => ($(element).attr("rel") ?? "").toLowerCase().split(/\s+/u).includes("describedby")).map((element) => httpUrl$1($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
|
|
18770
18788
|
const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
|
|
18771
18789
|
const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
|
|
18772
18790
|
const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
|
|
@@ -19183,11 +19201,11 @@ function recordHostFetch(input) {
|
|
|
19183
19201
|
return snapshotForHost(input.host);
|
|
19184
19202
|
}
|
|
19185
19203
|
// packages/core/dist/fetch/page-fetcher/redirects.js
|
|
19186
|
-
async function fetchWithRedirectChain(url, signal) {
|
|
19204
|
+
async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch) {
|
|
19187
19205
|
const redirectChain = [];
|
|
19188
19206
|
let currentUrl = url;
|
|
19189
19207
|
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
19190
|
-
const response = await
|
|
19208
|
+
const response = await fetch(currentUrl, {
|
|
19191
19209
|
redirect: "manual",
|
|
19192
19210
|
signal
|
|
19193
19211
|
});
|
|
@@ -20425,7 +20443,8 @@ async function inspectUrl(input) {
|
|
|
20425
20443
|
siteUrl: input.siteUrl,
|
|
20426
20444
|
inspectionUrl,
|
|
20427
20445
|
...input.languageCode ? { languageCode: input.languageCode } : {}
|
|
20428
|
-
})
|
|
20446
|
+
}),
|
|
20447
|
+
signal: input.signal
|
|
20429
20448
|
});
|
|
20430
20449
|
} catch (error) {
|
|
20431
20450
|
finalize(reservation, "consumed");
|
|
@@ -23982,6 +24001,15 @@ const crawlAiAvailabilitySchema = z.enum([
|
|
|
23982
24001
|
"rate-limited",
|
|
23983
24002
|
"unreachable"
|
|
23984
24003
|
]);
|
|
24004
|
+
const crawlFetchErrorKindSchema = z.enum([
|
|
24005
|
+
"dns",
|
|
24006
|
+
"tls",
|
|
24007
|
+
"timeout",
|
|
24008
|
+
"redirect-limit",
|
|
24009
|
+
"too-large",
|
|
24010
|
+
"aborted",
|
|
24011
|
+
"unknown"
|
|
24012
|
+
]);
|
|
23985
24013
|
const crawlAiLinkCheckSchema = z.object({
|
|
23986
24014
|
url: z.string().url(),
|
|
23987
24015
|
status: z.number().int().optional(),
|
|
@@ -23997,6 +24025,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
23997
24025
|
contentType: z.string().optional(),
|
|
23998
24026
|
servesHtml: z.boolean().optional(),
|
|
23999
24027
|
error: z.string().optional(),
|
|
24028
|
+
errorKind: crawlFetchErrorKindSchema.optional(),
|
|
24000
24029
|
sitemapUrls: z.array(z.string().url()),
|
|
24001
24030
|
contentSignals: z.array(z.string()).optional(),
|
|
24002
24031
|
botAccess: z.array(z.object({
|
|
@@ -24019,6 +24048,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
24019
24048
|
contentType: z.string().optional(),
|
|
24020
24049
|
servesHtml: z.boolean().optional(),
|
|
24021
24050
|
error: z.string().optional(),
|
|
24051
|
+
errorKind: crawlFetchErrorKindSchema.optional(),
|
|
24022
24052
|
links: z.object({
|
|
24023
24053
|
found: z.number().int().nonnegative(),
|
|
24024
24054
|
checked: z.number().int().nonnegative(),
|
|
@@ -25073,10 +25103,11 @@ function agentReadyIssues(input) {
|
|
|
25073
25103
|
}
|
|
25074
25104
|
// packages/core/dist/analyze/crawler/crawl-control.js
|
|
25075
25105
|
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
25106
|
+
const REQUEST_TIMEOUT_CAP_MS = 5e3;
|
|
25076
25107
|
function abortController(input) {
|
|
25077
25108
|
const controller = new AbortController();
|
|
25078
25109
|
const abort = () => controller.abort();
|
|
25079
|
-
const timer = setTimeout(abort, Math.min(input.timeoutMs,
|
|
25110
|
+
const timer = setTimeout(abort, Math.min(input.timeoutMs, REQUEST_TIMEOUT_CAP_MS));
|
|
25080
25111
|
if (input.signal?.aborted) controller.abort();
|
|
25081
25112
|
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
25082
25113
|
return {
|
|
@@ -25516,6 +25547,405 @@ async function collectCrawlSiteChecks(input) {
|
|
|
25516
25547
|
}]
|
|
25517
25548
|
};
|
|
25518
25549
|
}
|
|
25550
|
+
// packages/core/dist/analyze/monitoring/crawl-page.js
|
|
25551
|
+
function resolvedUrl(href, base) {
|
|
25552
|
+
if (!href) return void 0;
|
|
25553
|
+
try {
|
|
25554
|
+
const url = new URL(href, base);
|
|
25555
|
+
return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
|
|
25556
|
+
} catch {
|
|
25557
|
+
return;
|
|
25558
|
+
}
|
|
25559
|
+
}
|
|
25560
|
+
function hashText(value) {
|
|
25561
|
+
return createHash("sha256").update(value).digest("hex");
|
|
25562
|
+
}
|
|
25563
|
+
function headerValue(headers, name) {
|
|
25564
|
+
const lower = name.toLowerCase();
|
|
25565
|
+
return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
25566
|
+
}
|
|
25567
|
+
function safeResponseHeaders(headers) {
|
|
25568
|
+
const blocked = /* @__PURE__ */ new Set([
|
|
25569
|
+
"authorization",
|
|
25570
|
+
"cookie",
|
|
25571
|
+
"proxy-authorization",
|
|
25572
|
+
"set-cookie",
|
|
25573
|
+
"www-authenticate"
|
|
25574
|
+
]);
|
|
25575
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
|
|
25576
|
+
}
|
|
25577
|
+
function truncate$1(value, max) {
|
|
25578
|
+
return value.length > max ? value.slice(0, max).trimEnd() : value;
|
|
25579
|
+
}
|
|
25580
|
+
function sameDocumentUrl(left, right) {
|
|
25581
|
+
if (!left || !right) return false;
|
|
25582
|
+
try {
|
|
25583
|
+
const a = new URL(left);
|
|
25584
|
+
const b = new URL(right);
|
|
25585
|
+
a.hash = "";
|
|
25586
|
+
b.hash = "";
|
|
25587
|
+
return a.toString() === b.toString();
|
|
25588
|
+
} catch {
|
|
25589
|
+
return left === right;
|
|
25590
|
+
}
|
|
25591
|
+
}
|
|
25592
|
+
function isHtmlContentType(value) {
|
|
25593
|
+
return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
|
|
25594
|
+
}
|
|
25595
|
+
function fetchFailureKind(error, signal) {
|
|
25596
|
+
const record = error;
|
|
25597
|
+
const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
|
|
25598
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25599
|
+
if (signal?.aborted) return "aborted";
|
|
25600
|
+
if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
|
|
25601
|
+
if ([
|
|
25602
|
+
"ENOTFOUND",
|
|
25603
|
+
"EAI_AGAIN",
|
|
25604
|
+
"EAI_FAIL"
|
|
25605
|
+
].includes(code)) return "dns";
|
|
25606
|
+
if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
|
|
25607
|
+
if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
|
|
25608
|
+
return "unknown";
|
|
25609
|
+
}
|
|
25610
|
+
function responseObservation(requestedUrl, fetched, extraction, extractionError) {
|
|
25611
|
+
const observation = {
|
|
25612
|
+
requestedUrl,
|
|
25613
|
+
outcome: "response",
|
|
25614
|
+
finalUrl: fetched.finalUrl,
|
|
25615
|
+
status: fetched.status,
|
|
25616
|
+
contentType: headerValue(fetched.headers, "content-type"),
|
|
25617
|
+
durationMs: fetched.diagnostics.durationMs,
|
|
25618
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
25619
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
25620
|
+
robotsTxt: fetched.robotsTxt
|
|
25621
|
+
};
|
|
25622
|
+
if (extraction === "failed") {
|
|
25623
|
+
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
25624
|
+
return {
|
|
25625
|
+
...observation,
|
|
25626
|
+
extraction,
|
|
25627
|
+
extractionError
|
|
25628
|
+
};
|
|
25629
|
+
}
|
|
25630
|
+
return {
|
|
25631
|
+
...observation,
|
|
25632
|
+
extraction
|
|
25633
|
+
};
|
|
25634
|
+
}
|
|
25635
|
+
function unextractedPage(fetched, contentType) {
|
|
25636
|
+
const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
|
|
25637
|
+
const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
|
|
25638
|
+
const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
|
|
25639
|
+
const knownNonHtml = Boolean(contentType);
|
|
25640
|
+
return {
|
|
25641
|
+
url: fetched.finalUrl,
|
|
25642
|
+
finalUrl: fetched.finalUrl,
|
|
25643
|
+
status: fetched.status,
|
|
25644
|
+
contentType,
|
|
25645
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
25646
|
+
sizeBytes: Buffer.byteLength(fetched.html),
|
|
25647
|
+
usedJs: fetched.usedJs,
|
|
25648
|
+
fetchSource: fetched.diagnostics.source,
|
|
25649
|
+
cacheState: fetched.diagnostics.cache,
|
|
25650
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
25651
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
25652
|
+
blocked: fetched.diagnostics.blocked,
|
|
25653
|
+
robotsTxt: fetched.robotsTxt,
|
|
25654
|
+
xRobotsTag,
|
|
25655
|
+
canonical: canonicalEvidence.selectedUrl,
|
|
25656
|
+
canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
|
|
25657
|
+
canonicalStatus: canonicalEvidence.status,
|
|
25658
|
+
canonicalCandidates: canonicalEvidence.candidates,
|
|
25659
|
+
indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
|
|
25660
|
+
indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
|
|
25661
|
+
declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
|
|
25662
|
+
extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
|
|
25663
|
+
wordCount: 0,
|
|
25664
|
+
contentHash: hashText(fetched.html),
|
|
25665
|
+
outgoingInternalCount: 0,
|
|
25666
|
+
outgoingExternalCount: 0,
|
|
25667
|
+
isHttps: new URL(fetched.finalUrl).protocol === "https:",
|
|
25668
|
+
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
25669
|
+
compression: headerValue(fetched.headers, "content-encoding")
|
|
25670
|
+
};
|
|
25671
|
+
}
|
|
25672
|
+
function statusOnlyPage(fetched) {
|
|
25673
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
25674
|
+
return {
|
|
25675
|
+
url: fetched.finalUrl,
|
|
25676
|
+
finalUrl: fetched.finalUrl,
|
|
25677
|
+
status: fetched.status,
|
|
25678
|
+
contentType,
|
|
25679
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
25680
|
+
auditScope: "status",
|
|
25681
|
+
contentAuditAllowed: false,
|
|
25682
|
+
indexable: false,
|
|
25683
|
+
indexability: "Not evaluated by status-only health probe",
|
|
25684
|
+
declaredIndexability: "unknown",
|
|
25685
|
+
extractionStatus: "not-applicable",
|
|
25686
|
+
wordCount: 0,
|
|
25687
|
+
contentHash: "",
|
|
25688
|
+
outgoingInternalCount: 0,
|
|
25689
|
+
outgoingExternalCount: 0
|
|
25690
|
+
};
|
|
25691
|
+
}
|
|
25692
|
+
function indexabilityReason(page) {
|
|
25693
|
+
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
25694
|
+
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
25695
|
+
if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
|
|
25696
|
+
}
|
|
25697
|
+
async function crawlOne(url, opts = {}) {
|
|
25698
|
+
const startedAt = Date.now();
|
|
25699
|
+
let fetched;
|
|
25700
|
+
try {
|
|
25701
|
+
fetched = await fetchPage(url, opts);
|
|
25702
|
+
} catch (error) {
|
|
25703
|
+
if (error instanceof OriginBackpressureError) return {
|
|
25704
|
+
request: {
|
|
25705
|
+
requestedUrl: url,
|
|
25706
|
+
outcome: "skipped",
|
|
25707
|
+
reason: "origin-backpressure",
|
|
25708
|
+
error: error.message,
|
|
25709
|
+
extraction: "not-applicable"
|
|
25710
|
+
},
|
|
25711
|
+
urls: []
|
|
25712
|
+
};
|
|
25713
|
+
if (error instanceof RobotsAccessError) return {
|
|
25714
|
+
request: {
|
|
25715
|
+
requestedUrl: url,
|
|
25716
|
+
outcome: "skipped",
|
|
25717
|
+
reason: error.reason,
|
|
25718
|
+
robotsTxt: {
|
|
25719
|
+
url: error.evidence.url,
|
|
25720
|
+
allowed: error.evidence.allowed,
|
|
25721
|
+
availability: error.evidence.availability,
|
|
25722
|
+
status: error.evidence.status,
|
|
25723
|
+
error: error.evidence.error,
|
|
25724
|
+
matchedLine: error.evidence.matchedLine
|
|
25725
|
+
},
|
|
25726
|
+
extraction: "not-applicable"
|
|
25727
|
+
},
|
|
25728
|
+
urls: []
|
|
25729
|
+
};
|
|
25730
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25731
|
+
return {
|
|
25732
|
+
request: {
|
|
25733
|
+
requestedUrl: url,
|
|
25734
|
+
outcome: "failure",
|
|
25735
|
+
durationMs: Date.now() - startedAt,
|
|
25736
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
25737
|
+
error: message,
|
|
25738
|
+
extraction: "not-applicable"
|
|
25739
|
+
},
|
|
25740
|
+
urls: [],
|
|
25741
|
+
warning: `${url}: ${message}`
|
|
25742
|
+
};
|
|
25743
|
+
}
|
|
25744
|
+
return snapshotFetchedPage(url, fetched);
|
|
25745
|
+
}
|
|
25746
|
+
async function snapshotFetchedPage(url, fetched) {
|
|
25747
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
25748
|
+
if (fetched.diagnostics.accessBlock) {
|
|
25749
|
+
const page = unextractedPage(fetched, contentType);
|
|
25750
|
+
page.contentAuditAllowed = false;
|
|
25751
|
+
page.indexable = false;
|
|
25752
|
+
page.indexability = "Crawler access blocked";
|
|
25753
|
+
page.declaredIndexability = "unknown";
|
|
25754
|
+
page.extractionStatus = "not-applicable";
|
|
25755
|
+
return {
|
|
25756
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
25757
|
+
page,
|
|
25758
|
+
urls: []
|
|
25759
|
+
};
|
|
25760
|
+
}
|
|
25761
|
+
if (!isHtmlContentType(contentType)) return {
|
|
25762
|
+
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
25763
|
+
page: unextractedPage(fetched, contentType),
|
|
25764
|
+
urls: []
|
|
25765
|
+
};
|
|
25766
|
+
try {
|
|
25767
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
25768
|
+
const crawlerEvidence = extracted.crawlerEvidence;
|
|
25769
|
+
if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
|
|
25770
|
+
const h1 = crawlerEvidence.h1;
|
|
25771
|
+
const uniqueInternalLinks = crawlerEvidence.internalLinks;
|
|
25772
|
+
const uniqueExternalLinks = crawlerEvidence.externalLinks;
|
|
25773
|
+
const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
|
|
25774
|
+
const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
|
|
25775
|
+
const page = {
|
|
25776
|
+
url: extracted.finalUrl,
|
|
25777
|
+
finalUrl: extracted.finalUrl,
|
|
25778
|
+
status: fetched.status,
|
|
25779
|
+
contentType,
|
|
25780
|
+
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
25781
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
25782
|
+
sizeBytes: Buffer.byteLength(fetched.html),
|
|
25783
|
+
usedJs: fetched.usedJs,
|
|
25784
|
+
fetchSource: fetched.diagnostics.source,
|
|
25785
|
+
cacheState: fetched.diagnostics.cache,
|
|
25786
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
25787
|
+
blocked: fetched.diagnostics.blocked,
|
|
25788
|
+
robotsTxt: fetched.robotsTxt,
|
|
25789
|
+
title: extracted.title,
|
|
25790
|
+
metaDescription: extracted.metaDescription,
|
|
25791
|
+
canonical,
|
|
25792
|
+
canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
|
|
25793
|
+
canonicalStatus: extracted.canonicalEvidence?.status,
|
|
25794
|
+
canonicalCandidates: extracted.canonicalEvidence?.candidates,
|
|
25795
|
+
metaRobots: extracted.metaRobots,
|
|
25796
|
+
xRobotsTag: extracted.xRobotsTag,
|
|
25797
|
+
h1,
|
|
25798
|
+
h1Count: crawlerEvidence.h1Count,
|
|
25799
|
+
h2Count: crawlerEvidence.h2Count,
|
|
25800
|
+
h3Count: crawlerEvidence.h3Count,
|
|
25801
|
+
indexable: false,
|
|
25802
|
+
extractionStatus: "complete",
|
|
25803
|
+
wordCount: extracted.wordCount,
|
|
25804
|
+
contentExtraction: extracted.contentExtraction,
|
|
25805
|
+
warnings: [
|
|
25806
|
+
...extracted.warnings,
|
|
25807
|
+
...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
|
|
25808
|
+
...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
|
|
25809
|
+
...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
|
|
25810
|
+
...extracted.canonicalEvidence?.status === "streamed-outside-head" ? ["Checked the canonical target that Next.js streamed into the body of this response. Google documents that it only accepts canonical links in the head."] : []
|
|
25811
|
+
],
|
|
25812
|
+
contentHash: hashText([
|
|
25813
|
+
extracted.title,
|
|
25814
|
+
extracted.metaDescription,
|
|
25815
|
+
h1,
|
|
25816
|
+
extracted.canonical,
|
|
25817
|
+
extracted.contentText
|
|
25818
|
+
].join("\n")),
|
|
25819
|
+
mainContentHash: hashText(extracted.contentText),
|
|
25820
|
+
textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
|
|
25821
|
+
contentSample: truncate$1(extracted.contentText, 300),
|
|
25822
|
+
contentSketch: crawlerEvidence.contentSketch,
|
|
25823
|
+
softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
|
|
25824
|
+
metaRefresh: crawlerEvidence.metaRefresh,
|
|
25825
|
+
tabbedContent: crawlerEvidence.tabbedContent,
|
|
25826
|
+
lang: extracted.lang,
|
|
25827
|
+
hasViewport: extracted.hasViewport,
|
|
25828
|
+
isHttps: new URL(extracted.finalUrl).protocol === "https:",
|
|
25829
|
+
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
25830
|
+
compression: headerValue(fetched.headers, "content-encoding"),
|
|
25831
|
+
hreflang: extracted.hreflang,
|
|
25832
|
+
mixedContentCount: extracted.mixedContentUrls.length,
|
|
25833
|
+
mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
|
|
25834
|
+
imagesTotal: extracted.imagesTotal,
|
|
25835
|
+
imagesMissingAlt: extracted.imagesMissingAlt,
|
|
25836
|
+
oversizedImageCandidates: extracted.oversizedImageCandidates,
|
|
25837
|
+
outgoingInternalCount: uniqueInternalLinks.length,
|
|
25838
|
+
outgoingExternalCount: uniqueExternalLinks.length,
|
|
25839
|
+
sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
|
|
25840
|
+
sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
|
|
25841
|
+
internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
|
|
25842
|
+
externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
|
|
25843
|
+
markdownAlternates: extracted.markdownAlternates,
|
|
25844
|
+
describedBy: extracted.describedBy,
|
|
25845
|
+
schemaTypes: extracted.schemaTypes,
|
|
25846
|
+
structuredDataFormats: extracted.structuredDataFormats ?? [],
|
|
25847
|
+
googleRichResults: richResults.assessments,
|
|
25848
|
+
googleRichResultsSelection: richResults.selection,
|
|
25849
|
+
schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
|
|
25850
|
+
schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
|
|
25851
|
+
invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
|
|
25852
|
+
socialProfileLinks: crawlerEvidence.socialProfileLinks,
|
|
25853
|
+
invalidJsonLdCount: extracted.invalidJsonLdCount,
|
|
25854
|
+
invalidJsonLdSamples: extracted.invalidJsonLdSamples,
|
|
25855
|
+
unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
|
|
25856
|
+
openGraphTitle: extracted.openGraph["og:title"],
|
|
25857
|
+
openGraphDescription: extracted.openGraph["og:description"],
|
|
25858
|
+
openGraphImage: extracted.openGraph["og:image"],
|
|
25859
|
+
twitterCard: extracted.twitter["twitter:card"],
|
|
25860
|
+
author: extracted.author,
|
|
25861
|
+
hasDate: extracted.hasDate,
|
|
25862
|
+
geo: {
|
|
25863
|
+
semanticHtml: extracted.semanticHtml,
|
|
25864
|
+
structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
|
|
25865
|
+
hasAuthor: extracted.hasAuthor,
|
|
25866
|
+
hasDate: extracted.hasDate,
|
|
25867
|
+
questionHeadings: extracted.questionHeadings,
|
|
25868
|
+
listCount: extracted.listCount,
|
|
25869
|
+
tableCount: extracted.tableCount,
|
|
25870
|
+
structuredBlocks: extracted.structuredBlocks,
|
|
25871
|
+
answerable: extracted.answerable,
|
|
25872
|
+
hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
|
|
25873
|
+
hasQapageSchema: extracted.schemaTypes.includes("QAPage")
|
|
25874
|
+
}
|
|
25875
|
+
};
|
|
25876
|
+
page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
|
|
25877
|
+
page.indexable = !page.indexability;
|
|
25878
|
+
page.declaredIndexability = page.metaRefresh ? "meta-refresh" : hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
|
|
25879
|
+
return {
|
|
25880
|
+
request: responseObservation(url, fetched, "complete"),
|
|
25881
|
+
page,
|
|
25882
|
+
urls: uniqueInternalLinks
|
|
25883
|
+
};
|
|
25884
|
+
} catch (error) {
|
|
25885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25886
|
+
return {
|
|
25887
|
+
request: responseObservation(url, fetched, "failed", message),
|
|
25888
|
+
urls: [],
|
|
25889
|
+
warning: `${url}: content extraction failed: ${message}`
|
|
25890
|
+
};
|
|
25891
|
+
}
|
|
25892
|
+
}
|
|
25893
|
+
async function crawlStatusOnly(url, opts = {}) {
|
|
25894
|
+
const startedAt = Date.now();
|
|
25895
|
+
try {
|
|
25896
|
+
const fetched = await fetchPageStatus(url, {
|
|
25897
|
+
...opts,
|
|
25898
|
+
js: "off",
|
|
25899
|
+
refresh: true
|
|
25900
|
+
});
|
|
25901
|
+
return {
|
|
25902
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
25903
|
+
page: statusOnlyPage(fetched),
|
|
25904
|
+
urls: []
|
|
25905
|
+
};
|
|
25906
|
+
} catch (error) {
|
|
25907
|
+
if (error instanceof OriginBackpressureError) return {
|
|
25908
|
+
request: {
|
|
25909
|
+
requestedUrl: url,
|
|
25910
|
+
outcome: "skipped",
|
|
25911
|
+
reason: "origin-backpressure",
|
|
25912
|
+
error: error.message,
|
|
25913
|
+
extraction: "not-applicable"
|
|
25914
|
+
},
|
|
25915
|
+
urls: []
|
|
25916
|
+
};
|
|
25917
|
+
if (error instanceof RobotsAccessError) return {
|
|
25918
|
+
request: {
|
|
25919
|
+
requestedUrl: url,
|
|
25920
|
+
outcome: "skipped",
|
|
25921
|
+
reason: error.reason,
|
|
25922
|
+
robotsTxt: {
|
|
25923
|
+
url: error.evidence.url,
|
|
25924
|
+
allowed: error.evidence.allowed,
|
|
25925
|
+
availability: error.evidence.availability,
|
|
25926
|
+
status: error.evidence.status,
|
|
25927
|
+
error: error.evidence.error,
|
|
25928
|
+
matchedLine: error.evidence.matchedLine
|
|
25929
|
+
},
|
|
25930
|
+
extraction: "not-applicable"
|
|
25931
|
+
},
|
|
25932
|
+
urls: []
|
|
25933
|
+
};
|
|
25934
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25935
|
+
return {
|
|
25936
|
+
request: {
|
|
25937
|
+
requestedUrl: url,
|
|
25938
|
+
outcome: "failure",
|
|
25939
|
+
durationMs: Date.now() - startedAt,
|
|
25940
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
25941
|
+
error: message,
|
|
25942
|
+
extraction: "not-applicable"
|
|
25943
|
+
},
|
|
25944
|
+
urls: [],
|
|
25945
|
+
warning: `${url}: ${message}`
|
|
25946
|
+
};
|
|
25947
|
+
}
|
|
25948
|
+
}
|
|
25519
25949
|
// packages/core/dist/analyze/crawler/site-crawl-ai-signals.js
|
|
25520
25950
|
const MAX_AUXILIARY_RESPONSE_BYTES = 2097152;
|
|
25521
25951
|
const LLMS_LINK_LIMIT = 20;
|
|
@@ -25559,8 +25989,21 @@ function availabilityOf(status) {
|
|
|
25559
25989
|
if (status >= 400 && status < 500) return "absent";
|
|
25560
25990
|
return "unreachable";
|
|
25561
25991
|
}
|
|
25562
|
-
function errorText(error) {
|
|
25563
|
-
|
|
25992
|
+
function errorText$1(error) {
|
|
25993
|
+
if (!(error instanceof Error)) return String(error);
|
|
25994
|
+
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
25995
|
+
return cause && !error.message.includes(cause) ? `${error.message}: ${cause}` : error.message;
|
|
25996
|
+
}
|
|
25997
|
+
function requestError$1(error, timeoutMs, signal) {
|
|
25998
|
+
const errorKind = fetchErrorKind(error, signal);
|
|
25999
|
+
return {
|
|
26000
|
+
error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText$1(error),
|
|
26001
|
+
errorKind
|
|
26002
|
+
};
|
|
26003
|
+
}
|
|
26004
|
+
function fetchErrorKind(error, signal) {
|
|
26005
|
+
if (error instanceof ResponseSizeLimitError) return "too-large";
|
|
26006
|
+
return fetchFailureKind(error, signal);
|
|
25564
26007
|
}
|
|
25565
26008
|
async function get$1(input, url, headers, readBody = true) {
|
|
25566
26009
|
const controller = abortController({
|
|
@@ -25591,7 +26034,7 @@ async function get$1(input, url, headers, readBody = true) {
|
|
|
25591
26034
|
availability: "unreachable",
|
|
25592
26035
|
contentType: "",
|
|
25593
26036
|
text: "",
|
|
25594
|
-
error
|
|
26037
|
+
...requestError$1(error, input.timeoutMs, input.signal)
|
|
25595
26038
|
};
|
|
25596
26039
|
} finally {
|
|
25597
26040
|
controller.cleanup();
|
|
@@ -25618,22 +26061,29 @@ function llmsLinks(text, base) {
|
|
|
25618
26061
|
} catch {}
|
|
25619
26062
|
return links;
|
|
25620
26063
|
}
|
|
25621
|
-
async function
|
|
26064
|
+
async function readLlmsTxt(input) {
|
|
25622
26065
|
const result = await get$1(input, new URL("/llms.txt", input.url).toString());
|
|
25623
26066
|
const servesHtml = result.availability === "available" && isHtml(result.contentType);
|
|
25624
26067
|
const availability = servesHtml ? "absent" : result.availability;
|
|
25625
26068
|
const exists = availability === "available";
|
|
25626
|
-
|
|
25627
|
-
|
|
25628
|
-
|
|
25629
|
-
|
|
25630
|
-
|
|
25631
|
-
|
|
25632
|
-
|
|
25633
|
-
|
|
26069
|
+
return {
|
|
26070
|
+
llmsTxt: {
|
|
26071
|
+
url: result.url,
|
|
26072
|
+
exists,
|
|
26073
|
+
availability,
|
|
26074
|
+
...result.status === void 0 ? {} : { status: result.status },
|
|
26075
|
+
...result.contentType ? { contentType: result.contentType } : {},
|
|
26076
|
+
...servesHtml ? { servesHtml } : {},
|
|
26077
|
+
...result.error ? { error: result.error } : {},
|
|
26078
|
+
...result.errorKind ? { errorKind: result.errorKind } : {}
|
|
26079
|
+
},
|
|
26080
|
+
text: exists ? result.text : ""
|
|
25634
26081
|
};
|
|
25635
|
-
|
|
25636
|
-
|
|
26082
|
+
}
|
|
26083
|
+
async function checkLlmsTxt(input) {
|
|
26084
|
+
const { llmsTxt: base, text } = await readLlmsTxt(input);
|
|
26085
|
+
if (!base.exists) return base;
|
|
26086
|
+
const found = llmsLinks(text, base.url);
|
|
25637
26087
|
const broken = [];
|
|
25638
26088
|
let checked = 0;
|
|
25639
26089
|
for (const url of found.slice(0, LLMS_LINK_LIMIT)) {
|
|
@@ -25683,13 +26133,20 @@ function sitemapUrlsFromRobots(text) {
|
|
|
25683
26133
|
return [...urls];
|
|
25684
26134
|
}
|
|
25685
26135
|
async function checkRobotsAiAccess(input) {
|
|
25686
|
-
const
|
|
26136
|
+
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
26137
|
+
const result = await get$1(input, robotsUrl);
|
|
25687
26138
|
const servesHtml = result.availability === "available" && isHtml(result.contentType);
|
|
25688
26139
|
const availability = servesHtml ? "absent" : result.availability;
|
|
25689
26140
|
const exists = availability === "available";
|
|
25690
26141
|
const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
|
|
25691
26142
|
const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
|
|
25692
|
-
const parsed = parseRobots(
|
|
26143
|
+
const parsed = parseRobots(robotsUrl, exists ? result.text : "");
|
|
26144
|
+
const ruleHosts = /* @__PURE__ */ new Set([new URL(robotsUrl).host, new URL(result.url).host]);
|
|
26145
|
+
const isAllowed = (url, userAgent) => {
|
|
26146
|
+
const target = new URL(url);
|
|
26147
|
+
const local = ruleHosts.has(target.host) ? new URL(`${target.pathname}${target.search}`, robotsUrl).toString() : url;
|
|
26148
|
+
return parsed.isAllowed(local, userAgent) ?? true;
|
|
26149
|
+
};
|
|
25693
26150
|
return {
|
|
25694
26151
|
robotsTxt: {
|
|
25695
26152
|
url: result.url,
|
|
@@ -25699,17 +26156,18 @@ async function checkRobotsAiAccess(input) {
|
|
|
25699
26156
|
...result.contentType ? { contentType: result.contentType } : {},
|
|
25700
26157
|
...servesHtml ? { servesHtml } : {},
|
|
25701
26158
|
...unknown ? { error: result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
|
|
26159
|
+
...result.errorKind ? { errorKind: result.errorKind } : {},
|
|
25702
26160
|
sitemapUrls: exists ? sitemapUrlsFromRobots(result.text) : [],
|
|
25703
26161
|
...unknown ? {} : { contentSignals: exists ? contentSignalsFromRobots(result.text) : [] },
|
|
25704
26162
|
botAccess: AI_BOTS.map(([userAgent, purpose]) => ({
|
|
25705
26163
|
userAgent,
|
|
25706
26164
|
purpose,
|
|
25707
|
-
allowed: unknown ? null :
|
|
26165
|
+
allowed: unknown ? null : isAllowed(input.url, userAgent),
|
|
25708
26166
|
declared: declared.has(userAgent.toLowerCase()),
|
|
25709
26167
|
coveredByWildcard: declared.has("*")
|
|
25710
26168
|
}))
|
|
25711
26169
|
},
|
|
25712
|
-
allows: (url) => unknown ? null :
|
|
26170
|
+
allows: (url) => unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
|
|
25713
26171
|
};
|
|
25714
26172
|
}
|
|
25715
26173
|
async function checkAgentResources(input) {
|
|
@@ -26083,405 +26541,6 @@ async function crawlDataSources(input) {
|
|
|
26083
26541
|
});
|
|
26084
26542
|
return dataSources;
|
|
26085
26543
|
}
|
|
26086
|
-
// packages/core/dist/analyze/monitoring/crawl-page.js
|
|
26087
|
-
function resolvedUrl(href, base) {
|
|
26088
|
-
if (!href) return void 0;
|
|
26089
|
-
try {
|
|
26090
|
-
const url = new URL(href, base);
|
|
26091
|
-
return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
|
|
26092
|
-
} catch {
|
|
26093
|
-
return;
|
|
26094
|
-
}
|
|
26095
|
-
}
|
|
26096
|
-
function hashText(value) {
|
|
26097
|
-
return createHash("sha256").update(value).digest("hex");
|
|
26098
|
-
}
|
|
26099
|
-
function headerValue(headers, name) {
|
|
26100
|
-
const lower = name.toLowerCase();
|
|
26101
|
-
return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
26102
|
-
}
|
|
26103
|
-
function safeResponseHeaders(headers) {
|
|
26104
|
-
const blocked = /* @__PURE__ */ new Set([
|
|
26105
|
-
"authorization",
|
|
26106
|
-
"cookie",
|
|
26107
|
-
"proxy-authorization",
|
|
26108
|
-
"set-cookie",
|
|
26109
|
-
"www-authenticate"
|
|
26110
|
-
]);
|
|
26111
|
-
return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
|
|
26112
|
-
}
|
|
26113
|
-
function truncate$1(value, max) {
|
|
26114
|
-
return value.length > max ? value.slice(0, max).trimEnd() : value;
|
|
26115
|
-
}
|
|
26116
|
-
function sameDocumentUrl(left, right) {
|
|
26117
|
-
if (!left || !right) return false;
|
|
26118
|
-
try {
|
|
26119
|
-
const a = new URL(left);
|
|
26120
|
-
const b = new URL(right);
|
|
26121
|
-
a.hash = "";
|
|
26122
|
-
b.hash = "";
|
|
26123
|
-
return a.toString() === b.toString();
|
|
26124
|
-
} catch {
|
|
26125
|
-
return left === right;
|
|
26126
|
-
}
|
|
26127
|
-
}
|
|
26128
|
-
function isHtmlContentType(value) {
|
|
26129
|
-
return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
|
|
26130
|
-
}
|
|
26131
|
-
function fetchFailureKind(error, signal) {
|
|
26132
|
-
const record = error;
|
|
26133
|
-
const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
|
|
26134
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
26135
|
-
if (signal?.aborted) return "aborted";
|
|
26136
|
-
if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
|
|
26137
|
-
if ([
|
|
26138
|
-
"ENOTFOUND",
|
|
26139
|
-
"EAI_AGAIN",
|
|
26140
|
-
"EAI_FAIL"
|
|
26141
|
-
].includes(code)) return "dns";
|
|
26142
|
-
if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
|
|
26143
|
-
if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
|
|
26144
|
-
return "unknown";
|
|
26145
|
-
}
|
|
26146
|
-
function responseObservation(requestedUrl, fetched, extraction, extractionError) {
|
|
26147
|
-
const observation = {
|
|
26148
|
-
requestedUrl,
|
|
26149
|
-
outcome: "response",
|
|
26150
|
-
finalUrl: fetched.finalUrl,
|
|
26151
|
-
status: fetched.status,
|
|
26152
|
-
contentType: headerValue(fetched.headers, "content-type"),
|
|
26153
|
-
durationMs: fetched.diagnostics.durationMs,
|
|
26154
|
-
redirectChain: fetched.diagnostics.redirectChain,
|
|
26155
|
-
accessBlock: fetched.diagnostics.accessBlock,
|
|
26156
|
-
robotsTxt: fetched.robotsTxt
|
|
26157
|
-
};
|
|
26158
|
-
if (extraction === "failed") {
|
|
26159
|
-
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
26160
|
-
return {
|
|
26161
|
-
...observation,
|
|
26162
|
-
extraction,
|
|
26163
|
-
extractionError
|
|
26164
|
-
};
|
|
26165
|
-
}
|
|
26166
|
-
return {
|
|
26167
|
-
...observation,
|
|
26168
|
-
extraction
|
|
26169
|
-
};
|
|
26170
|
-
}
|
|
26171
|
-
function unextractedPage(fetched, contentType) {
|
|
26172
|
-
const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
|
|
26173
|
-
const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
|
|
26174
|
-
const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
|
|
26175
|
-
const knownNonHtml = Boolean(contentType);
|
|
26176
|
-
return {
|
|
26177
|
-
url: fetched.finalUrl,
|
|
26178
|
-
finalUrl: fetched.finalUrl,
|
|
26179
|
-
status: fetched.status,
|
|
26180
|
-
contentType,
|
|
26181
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
26182
|
-
sizeBytes: Buffer.byteLength(fetched.html),
|
|
26183
|
-
usedJs: fetched.usedJs,
|
|
26184
|
-
fetchSource: fetched.diagnostics.source,
|
|
26185
|
-
cacheState: fetched.diagnostics.cache,
|
|
26186
|
-
fetchDiagnostics: fetched.diagnostics,
|
|
26187
|
-
accessBlock: fetched.diagnostics.accessBlock,
|
|
26188
|
-
blocked: fetched.diagnostics.blocked,
|
|
26189
|
-
robotsTxt: fetched.robotsTxt,
|
|
26190
|
-
xRobotsTag,
|
|
26191
|
-
canonical: canonicalEvidence.selectedUrl,
|
|
26192
|
-
canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
|
|
26193
|
-
canonicalStatus: canonicalEvidence.status,
|
|
26194
|
-
canonicalCandidates: canonicalEvidence.candidates,
|
|
26195
|
-
indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
|
|
26196
|
-
indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
|
|
26197
|
-
declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
|
|
26198
|
-
extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
|
|
26199
|
-
wordCount: 0,
|
|
26200
|
-
contentHash: hashText(fetched.html),
|
|
26201
|
-
outgoingInternalCount: 0,
|
|
26202
|
-
outgoingExternalCount: 0,
|
|
26203
|
-
isHttps: new URL(fetched.finalUrl).protocol === "https:",
|
|
26204
|
-
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
26205
|
-
compression: headerValue(fetched.headers, "content-encoding")
|
|
26206
|
-
};
|
|
26207
|
-
}
|
|
26208
|
-
function statusOnlyPage(fetched) {
|
|
26209
|
-
const contentType = headerValue(fetched.headers, "content-type");
|
|
26210
|
-
return {
|
|
26211
|
-
url: fetched.finalUrl,
|
|
26212
|
-
finalUrl: fetched.finalUrl,
|
|
26213
|
-
status: fetched.status,
|
|
26214
|
-
contentType,
|
|
26215
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
26216
|
-
auditScope: "status",
|
|
26217
|
-
contentAuditAllowed: false,
|
|
26218
|
-
indexable: false,
|
|
26219
|
-
indexability: "Not evaluated by status-only health probe",
|
|
26220
|
-
declaredIndexability: "unknown",
|
|
26221
|
-
extractionStatus: "not-applicable",
|
|
26222
|
-
wordCount: 0,
|
|
26223
|
-
contentHash: "",
|
|
26224
|
-
outgoingInternalCount: 0,
|
|
26225
|
-
outgoingExternalCount: 0
|
|
26226
|
-
};
|
|
26227
|
-
}
|
|
26228
|
-
function indexabilityReason(page) {
|
|
26229
|
-
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
26230
|
-
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
26231
|
-
if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
|
|
26232
|
-
}
|
|
26233
|
-
async function crawlOne(url, opts = {}) {
|
|
26234
|
-
const startedAt = Date.now();
|
|
26235
|
-
let fetched;
|
|
26236
|
-
try {
|
|
26237
|
-
fetched = await fetchPage(url, opts);
|
|
26238
|
-
} catch (error) {
|
|
26239
|
-
if (error instanceof OriginBackpressureError) return {
|
|
26240
|
-
request: {
|
|
26241
|
-
requestedUrl: url,
|
|
26242
|
-
outcome: "skipped",
|
|
26243
|
-
reason: "origin-backpressure",
|
|
26244
|
-
error: error.message,
|
|
26245
|
-
extraction: "not-applicable"
|
|
26246
|
-
},
|
|
26247
|
-
urls: []
|
|
26248
|
-
};
|
|
26249
|
-
if (error instanceof RobotsAccessError) return {
|
|
26250
|
-
request: {
|
|
26251
|
-
requestedUrl: url,
|
|
26252
|
-
outcome: "skipped",
|
|
26253
|
-
reason: error.reason,
|
|
26254
|
-
robotsTxt: {
|
|
26255
|
-
url: error.evidence.url,
|
|
26256
|
-
allowed: error.evidence.allowed,
|
|
26257
|
-
availability: error.evidence.availability,
|
|
26258
|
-
status: error.evidence.status,
|
|
26259
|
-
error: error.evidence.error,
|
|
26260
|
-
matchedLine: error.evidence.matchedLine
|
|
26261
|
-
},
|
|
26262
|
-
extraction: "not-applicable"
|
|
26263
|
-
},
|
|
26264
|
-
urls: []
|
|
26265
|
-
};
|
|
26266
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
26267
|
-
return {
|
|
26268
|
-
request: {
|
|
26269
|
-
requestedUrl: url,
|
|
26270
|
-
outcome: "failure",
|
|
26271
|
-
durationMs: Date.now() - startedAt,
|
|
26272
|
-
failureKind: fetchFailureKind(error, opts.signal),
|
|
26273
|
-
error: message,
|
|
26274
|
-
extraction: "not-applicable"
|
|
26275
|
-
},
|
|
26276
|
-
urls: [],
|
|
26277
|
-
warning: `${url}: ${message}`
|
|
26278
|
-
};
|
|
26279
|
-
}
|
|
26280
|
-
return snapshotFetchedPage(url, fetched);
|
|
26281
|
-
}
|
|
26282
|
-
async function snapshotFetchedPage(url, fetched) {
|
|
26283
|
-
const contentType = headerValue(fetched.headers, "content-type");
|
|
26284
|
-
if (fetched.diagnostics.accessBlock) {
|
|
26285
|
-
const page = unextractedPage(fetched, contentType);
|
|
26286
|
-
page.contentAuditAllowed = false;
|
|
26287
|
-
page.indexable = false;
|
|
26288
|
-
page.indexability = "Crawler access blocked";
|
|
26289
|
-
page.declaredIndexability = "unknown";
|
|
26290
|
-
page.extractionStatus = "not-applicable";
|
|
26291
|
-
return {
|
|
26292
|
-
request: responseObservation(url, fetched, "not-applicable"),
|
|
26293
|
-
page,
|
|
26294
|
-
urls: []
|
|
26295
|
-
};
|
|
26296
|
-
}
|
|
26297
|
-
if (!isHtmlContentType(contentType)) return {
|
|
26298
|
-
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
26299
|
-
page: unextractedPage(fetched, contentType),
|
|
26300
|
-
urls: []
|
|
26301
|
-
};
|
|
26302
|
-
try {
|
|
26303
|
-
const extracted = await extractPage(fetched, "crawler");
|
|
26304
|
-
const crawlerEvidence = extracted.crawlerEvidence;
|
|
26305
|
-
if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
|
|
26306
|
-
const h1 = crawlerEvidence.h1;
|
|
26307
|
-
const uniqueInternalLinks = crawlerEvidence.internalLinks;
|
|
26308
|
-
const uniqueExternalLinks = crawlerEvidence.externalLinks;
|
|
26309
|
-
const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
|
|
26310
|
-
const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
|
|
26311
|
-
const page = {
|
|
26312
|
-
url: extracted.finalUrl,
|
|
26313
|
-
finalUrl: extracted.finalUrl,
|
|
26314
|
-
status: fetched.status,
|
|
26315
|
-
contentType,
|
|
26316
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
26317
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
26318
|
-
sizeBytes: Buffer.byteLength(fetched.html),
|
|
26319
|
-
usedJs: fetched.usedJs,
|
|
26320
|
-
fetchSource: fetched.diagnostics.source,
|
|
26321
|
-
cacheState: fetched.diagnostics.cache,
|
|
26322
|
-
fetchDiagnostics: fetched.diagnostics,
|
|
26323
|
-
blocked: fetched.diagnostics.blocked,
|
|
26324
|
-
robotsTxt: fetched.robotsTxt,
|
|
26325
|
-
title: extracted.title,
|
|
26326
|
-
metaDescription: extracted.metaDescription,
|
|
26327
|
-
canonical,
|
|
26328
|
-
canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
|
|
26329
|
-
canonicalStatus: extracted.canonicalEvidence?.status,
|
|
26330
|
-
canonicalCandidates: extracted.canonicalEvidence?.candidates,
|
|
26331
|
-
metaRobots: extracted.metaRobots,
|
|
26332
|
-
xRobotsTag: extracted.xRobotsTag,
|
|
26333
|
-
h1,
|
|
26334
|
-
h1Count: crawlerEvidence.h1Count,
|
|
26335
|
-
h2Count: crawlerEvidence.h2Count,
|
|
26336
|
-
h3Count: crawlerEvidence.h3Count,
|
|
26337
|
-
indexable: false,
|
|
26338
|
-
extractionStatus: "complete",
|
|
26339
|
-
wordCount: extracted.wordCount,
|
|
26340
|
-
contentExtraction: extracted.contentExtraction,
|
|
26341
|
-
warnings: [
|
|
26342
|
-
...extracted.warnings,
|
|
26343
|
-
...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
|
|
26344
|
-
...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
|
|
26345
|
-
...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
|
|
26346
|
-
...extracted.canonicalEvidence?.status === "streamed-outside-head" ? ["Checked the canonical target that Next.js streamed into the body of this response. Google documents that it only accepts canonical links in the head."] : []
|
|
26347
|
-
],
|
|
26348
|
-
contentHash: hashText([
|
|
26349
|
-
extracted.title,
|
|
26350
|
-
extracted.metaDescription,
|
|
26351
|
-
h1,
|
|
26352
|
-
extracted.canonical,
|
|
26353
|
-
extracted.contentText
|
|
26354
|
-
].join("\n")),
|
|
26355
|
-
mainContentHash: hashText(extracted.contentText),
|
|
26356
|
-
textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
|
|
26357
|
-
contentSample: truncate$1(extracted.contentText, 300),
|
|
26358
|
-
contentSketch: crawlerEvidence.contentSketch,
|
|
26359
|
-
softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
|
|
26360
|
-
metaRefresh: crawlerEvidence.metaRefresh,
|
|
26361
|
-
tabbedContent: crawlerEvidence.tabbedContent,
|
|
26362
|
-
lang: extracted.lang,
|
|
26363
|
-
hasViewport: extracted.hasViewport,
|
|
26364
|
-
isHttps: new URL(extracted.finalUrl).protocol === "https:",
|
|
26365
|
-
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
26366
|
-
compression: headerValue(fetched.headers, "content-encoding"),
|
|
26367
|
-
hreflang: extracted.hreflang,
|
|
26368
|
-
mixedContentCount: extracted.mixedContentUrls.length,
|
|
26369
|
-
mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
|
|
26370
|
-
imagesTotal: extracted.imagesTotal,
|
|
26371
|
-
imagesMissingAlt: extracted.imagesMissingAlt,
|
|
26372
|
-
oversizedImageCandidates: extracted.oversizedImageCandidates,
|
|
26373
|
-
outgoingInternalCount: uniqueInternalLinks.length,
|
|
26374
|
-
outgoingExternalCount: uniqueExternalLinks.length,
|
|
26375
|
-
sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
|
|
26376
|
-
sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
|
|
26377
|
-
internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
|
|
26378
|
-
externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
|
|
26379
|
-
markdownAlternates: extracted.markdownAlternates,
|
|
26380
|
-
describedBy: extracted.describedBy,
|
|
26381
|
-
schemaTypes: extracted.schemaTypes,
|
|
26382
|
-
structuredDataFormats: extracted.structuredDataFormats ?? [],
|
|
26383
|
-
googleRichResults: richResults.assessments,
|
|
26384
|
-
googleRichResultsSelection: richResults.selection,
|
|
26385
|
-
schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
|
|
26386
|
-
schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
|
|
26387
|
-
invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
|
|
26388
|
-
socialProfileLinks: crawlerEvidence.socialProfileLinks,
|
|
26389
|
-
invalidJsonLdCount: extracted.invalidJsonLdCount,
|
|
26390
|
-
invalidJsonLdSamples: extracted.invalidJsonLdSamples,
|
|
26391
|
-
unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
|
|
26392
|
-
openGraphTitle: extracted.openGraph["og:title"],
|
|
26393
|
-
openGraphDescription: extracted.openGraph["og:description"],
|
|
26394
|
-
openGraphImage: extracted.openGraph["og:image"],
|
|
26395
|
-
twitterCard: extracted.twitter["twitter:card"],
|
|
26396
|
-
author: extracted.author,
|
|
26397
|
-
hasDate: extracted.hasDate,
|
|
26398
|
-
geo: {
|
|
26399
|
-
semanticHtml: extracted.semanticHtml,
|
|
26400
|
-
structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
|
|
26401
|
-
hasAuthor: extracted.hasAuthor,
|
|
26402
|
-
hasDate: extracted.hasDate,
|
|
26403
|
-
questionHeadings: extracted.questionHeadings,
|
|
26404
|
-
listCount: extracted.listCount,
|
|
26405
|
-
tableCount: extracted.tableCount,
|
|
26406
|
-
structuredBlocks: extracted.structuredBlocks,
|
|
26407
|
-
answerable: extracted.answerable,
|
|
26408
|
-
hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
|
|
26409
|
-
hasQapageSchema: extracted.schemaTypes.includes("QAPage")
|
|
26410
|
-
}
|
|
26411
|
-
};
|
|
26412
|
-
page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
|
|
26413
|
-
page.indexable = !page.indexability;
|
|
26414
|
-
page.declaredIndexability = page.metaRefresh ? "meta-refresh" : hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
|
|
26415
|
-
return {
|
|
26416
|
-
request: responseObservation(url, fetched, "complete"),
|
|
26417
|
-
page,
|
|
26418
|
-
urls: uniqueInternalLinks
|
|
26419
|
-
};
|
|
26420
|
-
} catch (error) {
|
|
26421
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
26422
|
-
return {
|
|
26423
|
-
request: responseObservation(url, fetched, "failed", message),
|
|
26424
|
-
urls: [],
|
|
26425
|
-
warning: `${url}: content extraction failed: ${message}`
|
|
26426
|
-
};
|
|
26427
|
-
}
|
|
26428
|
-
}
|
|
26429
|
-
async function crawlStatusOnly(url, opts = {}) {
|
|
26430
|
-
const startedAt = Date.now();
|
|
26431
|
-
try {
|
|
26432
|
-
const fetched = await fetchPageStatus(url, {
|
|
26433
|
-
...opts,
|
|
26434
|
-
js: "off",
|
|
26435
|
-
refresh: true
|
|
26436
|
-
});
|
|
26437
|
-
return {
|
|
26438
|
-
request: responseObservation(url, fetched, "not-applicable"),
|
|
26439
|
-
page: statusOnlyPage(fetched),
|
|
26440
|
-
urls: []
|
|
26441
|
-
};
|
|
26442
|
-
} catch (error) {
|
|
26443
|
-
if (error instanceof OriginBackpressureError) return {
|
|
26444
|
-
request: {
|
|
26445
|
-
requestedUrl: url,
|
|
26446
|
-
outcome: "skipped",
|
|
26447
|
-
reason: "origin-backpressure",
|
|
26448
|
-
error: error.message,
|
|
26449
|
-
extraction: "not-applicable"
|
|
26450
|
-
},
|
|
26451
|
-
urls: []
|
|
26452
|
-
};
|
|
26453
|
-
if (error instanceof RobotsAccessError) return {
|
|
26454
|
-
request: {
|
|
26455
|
-
requestedUrl: url,
|
|
26456
|
-
outcome: "skipped",
|
|
26457
|
-
reason: error.reason,
|
|
26458
|
-
robotsTxt: {
|
|
26459
|
-
url: error.evidence.url,
|
|
26460
|
-
allowed: error.evidence.allowed,
|
|
26461
|
-
availability: error.evidence.availability,
|
|
26462
|
-
status: error.evidence.status,
|
|
26463
|
-
error: error.evidence.error,
|
|
26464
|
-
matchedLine: error.evidence.matchedLine
|
|
26465
|
-
},
|
|
26466
|
-
extraction: "not-applicable"
|
|
26467
|
-
},
|
|
26468
|
-
urls: []
|
|
26469
|
-
};
|
|
26470
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
26471
|
-
return {
|
|
26472
|
-
request: {
|
|
26473
|
-
requestedUrl: url,
|
|
26474
|
-
outcome: "failure",
|
|
26475
|
-
durationMs: Date.now() - startedAt,
|
|
26476
|
-
failureKind: fetchFailureKind(error, opts.signal),
|
|
26477
|
-
error: message,
|
|
26478
|
-
extraction: "not-applicable"
|
|
26479
|
-
},
|
|
26480
|
-
urls: [],
|
|
26481
|
-
warning: `${url}: ${message}`
|
|
26482
|
-
};
|
|
26483
|
-
}
|
|
26484
|
-
}
|
|
26485
26544
|
// packages/core/dist/analyze/monitoring/sitemaps.js
|
|
26486
26545
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
26487
26546
|
const LASTMOD_SAMPLE_LIMIT = 10;
|
|
@@ -36533,16 +36592,83 @@ async function indexCoverageSignals(input, dependencies = defaultDependencies$1)
|
|
|
36533
36592
|
}
|
|
36534
36593
|
};
|
|
36535
36594
|
}
|
|
36536
|
-
|
|
36537
|
-
|
|
36595
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
36596
|
+
301,
|
|
36597
|
+
302,
|
|
36598
|
+
303,
|
|
36599
|
+
307,
|
|
36600
|
+
308
|
|
36601
|
+
]);
|
|
36602
|
+
function errorText(error) {
|
|
36603
|
+
if (!(error instanceof Error)) return String(error);
|
|
36604
|
+
const cause = error.cause;
|
|
36605
|
+
const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
|
|
36606
|
+
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
|
|
36607
|
+
}
|
|
36608
|
+
function httpUrl(value, base) {
|
|
36538
36609
|
try {
|
|
36539
|
-
const
|
|
36540
|
-
|
|
36541
|
-
|
|
36610
|
+
const url = new URL(value, base);
|
|
36611
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
36612
|
+
url.hash = "";
|
|
36613
|
+
return url.toString();
|
|
36542
36614
|
} catch {
|
|
36543
|
-
return;
|
|
36615
|
+
return null;
|
|
36616
|
+
}
|
|
36617
|
+
}
|
|
36618
|
+
async function traceRedirects(url, options = {}) {
|
|
36619
|
+
const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
|
|
36620
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
36621
|
+
const request = options.fetch ?? ((to, init) => fetch(to, init));
|
|
36622
|
+
const steps = [];
|
|
36623
|
+
const end = (final_url, final_status, outcome, error) => ({
|
|
36624
|
+
url,
|
|
36625
|
+
steps,
|
|
36626
|
+
hops: steps.filter((step) => step.location !== null).length,
|
|
36627
|
+
final_url,
|
|
36628
|
+
final_status,
|
|
36629
|
+
outcome,
|
|
36630
|
+
...error ? { error } : {}
|
|
36631
|
+
});
|
|
36632
|
+
const start = httpUrl(url);
|
|
36633
|
+
if (!start) return end(url, null, "error", "Not an http or https URL.");
|
|
36634
|
+
let current = start;
|
|
36635
|
+
const seen = /* @__PURE__ */ new Set();
|
|
36636
|
+
while (true) {
|
|
36637
|
+
seen.add(current);
|
|
36638
|
+
const startedAt = Date.now();
|
|
36639
|
+
let response;
|
|
36640
|
+
try {
|
|
36641
|
+
response = await request(current, {
|
|
36642
|
+
redirect: "manual",
|
|
36643
|
+
headers: {
|
|
36644
|
+
"user-agent": SEO_CRAWLER_USER_AGENT,
|
|
36645
|
+
accept: "text/html,application/xhtml+xml,*/*;q=0.8"
|
|
36646
|
+
},
|
|
36647
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
36648
|
+
});
|
|
36649
|
+
} catch (error) {
|
|
36650
|
+
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));
|
|
36651
|
+
}
|
|
36652
|
+
const ms = Date.now() - startedAt;
|
|
36653
|
+
const header = response.headers.get("location");
|
|
36654
|
+
const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
|
|
36655
|
+
const location = redirects ? httpUrl(header, current) : null;
|
|
36656
|
+
if (options.onResponse) await options.onResponse(response, current);
|
|
36657
|
+
await response.body?.cancel().catch(() => void 0);
|
|
36658
|
+
steps.push({
|
|
36659
|
+
url: current,
|
|
36660
|
+
status: response.status,
|
|
36661
|
+
location,
|
|
36662
|
+
ms
|
|
36663
|
+
});
|
|
36664
|
+
if (!redirects) return end(current, response.status, "done");
|
|
36665
|
+
if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
|
|
36666
|
+
if (seen.has(location)) return end(current, response.status, "loop");
|
|
36667
|
+
if (steps.length > maxHops) return end(current, response.status, "too_many");
|
|
36668
|
+
current = location;
|
|
36544
36669
|
}
|
|
36545
36670
|
}
|
|
36671
|
+
// packages/core/dist/analyze/monitoring/redirect-trace.js
|
|
36546
36672
|
function redirectIssue(input) {
|
|
36547
36673
|
const issues = [];
|
|
36548
36674
|
const finalStatus = input.chain.at(-1)?.status ?? 0;
|
|
@@ -36557,55 +36683,28 @@ function redirectIssue(input) {
|
|
|
36557
36683
|
return [...new Set(issues)];
|
|
36558
36684
|
}
|
|
36559
36685
|
async function redirectTrace(input) {
|
|
36560
|
-
const maxHops = input.maxHops ?? 10;
|
|
36561
36686
|
const warnings = [];
|
|
36562
|
-
const
|
|
36563
|
-
|
|
36564
|
-
|
|
36565
|
-
|
|
36566
|
-
|
|
36567
|
-
|
|
36568
|
-
|
|
36569
|
-
|
|
36570
|
-
|
|
36571
|
-
|
|
36572
|
-
|
|
36573
|
-
|
|
36574
|
-
|
|
36575
|
-
|
|
36576
|
-
|
|
36577
|
-
|
|
36578
|
-
redirect: "manual",
|
|
36579
|
-
signal: controller.signal
|
|
36580
|
-
});
|
|
36581
|
-
const location = response.headers.get("location") ?? void 0;
|
|
36582
|
-
const nextUrl = location ? resolveNextUrl(location, currentUrl) : void 0;
|
|
36583
|
-
chain.push({
|
|
36584
|
-
url: currentUrl,
|
|
36585
|
-
status: response.status,
|
|
36586
|
-
location,
|
|
36587
|
-
nextUrl,
|
|
36588
|
-
durationMs: Date.now() - startedAt
|
|
36589
|
-
});
|
|
36590
|
-
if (response.status < 300 || response.status >= 400) break;
|
|
36591
|
-
if (!nextUrl) break;
|
|
36592
|
-
currentUrl = nextUrl;
|
|
36593
|
-
} catch (error) {
|
|
36594
|
-
warnings.push(`${currentUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
36595
|
-
break;
|
|
36596
|
-
} finally {
|
|
36597
|
-
clearTimeout(timer);
|
|
36598
|
-
}
|
|
36599
|
-
}
|
|
36600
|
-
const lastStep = chain.at(-1);
|
|
36601
|
-
if (lastStep && lastStep.status >= 300 && lastStep.status < 400 && chain.length > maxHops) stoppedForLimit = true;
|
|
36602
|
-
const finalUrl = lastStep?.nextUrl ?? lastStep?.url ?? currentUrl;
|
|
36603
|
-
const final = await crawlOne(finalUrl, {
|
|
36687
|
+
const trace = await traceRedirects(new URL(input.url).toString(), {
|
|
36688
|
+
maxHops: input.maxHops,
|
|
36689
|
+
timeoutMs: input.timeoutMs
|
|
36690
|
+
});
|
|
36691
|
+
const chain = trace.steps.map((step) => ({
|
|
36692
|
+
url: step.url,
|
|
36693
|
+
status: step.status,
|
|
36694
|
+
...step.location ? {
|
|
36695
|
+
location: step.location,
|
|
36696
|
+
nextUrl: step.location
|
|
36697
|
+
} : {},
|
|
36698
|
+
durationMs: step.ms
|
|
36699
|
+
}));
|
|
36700
|
+
if (trace.error) warnings.push(`${trace.final_url}: ${trace.error}`);
|
|
36701
|
+
const finalUrl = trace.final_url;
|
|
36702
|
+
const final = trace.outcome === "done" ? await crawlOne(finalUrl, {
|
|
36604
36703
|
refresh: input.refresh,
|
|
36605
36704
|
js: input.js ?? "auto"
|
|
36606
|
-
});
|
|
36607
|
-
if (final
|
|
36608
|
-
const finalPage = final
|
|
36705
|
+
}) : void 0;
|
|
36706
|
+
if (final?.warning) warnings.push(final.warning);
|
|
36707
|
+
const finalPage = final?.page ? {
|
|
36609
36708
|
title: final.page.title,
|
|
36610
36709
|
canonical: final.page.canonical,
|
|
36611
36710
|
metaRobots: final.page.metaRobots,
|
|
@@ -36617,16 +36716,18 @@ async function redirectTrace(input) {
|
|
|
36617
36716
|
finalIndexable: finalPage?.indexable,
|
|
36618
36717
|
canonical: finalPage?.canonical,
|
|
36619
36718
|
finalUrl,
|
|
36620
|
-
stoppedForLoop,
|
|
36621
|
-
stoppedForLimit
|
|
36719
|
+
stoppedForLoop: trace.outcome === "loop",
|
|
36720
|
+
stoppedForLimit: trace.outcome === "too_many"
|
|
36622
36721
|
});
|
|
36623
36722
|
return {
|
|
36624
36723
|
url: input.url,
|
|
36625
36724
|
finalUrl,
|
|
36626
36725
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
36627
36726
|
summary: {
|
|
36628
|
-
hops:
|
|
36629
|
-
finalStatus:
|
|
36727
|
+
hops: trace.hops,
|
|
36728
|
+
finalStatus: trace.final_status,
|
|
36729
|
+
outcome: trace.outcome,
|
|
36730
|
+
...trace.error ? { error: trace.error } : {},
|
|
36630
36731
|
finalIndexable: finalPage?.indexable,
|
|
36631
36732
|
issues
|
|
36632
36733
|
},
|
|
@@ -40425,7 +40526,7 @@ function extractText(source) {
|
|
|
40425
40526
|
flush();
|
|
40426
40527
|
return finish(x, blocks);
|
|
40427
40528
|
}
|
|
40428
|
-
function extractMetaLines(source) {
|
|
40529
|
+
function extractMetaLines(source, metaKind) {
|
|
40429
40530
|
const x = new Extractor(source);
|
|
40430
40531
|
const blocks = [];
|
|
40431
40532
|
for (let line = 0; line < x.starts.length; line++) {
|
|
@@ -40437,7 +40538,7 @@ function extractMetaLines(source) {
|
|
|
40437
40538
|
const from = start + (label ? label[0].length : 0);
|
|
40438
40539
|
const built = decodeValue(source, from, end, false);
|
|
40439
40540
|
const value = source.slice(from, end).trim();
|
|
40440
|
-
const kind = label ? label[1]?.toLowerCase() : value.length <= 70 ? "title" : "description";
|
|
40541
|
+
const kind = label ? label[1]?.toLowerCase() : metaKind ? metaKind : value.length <= 70 ? "title" : "description";
|
|
40441
40542
|
const block = makeBlock(kind, built, { field: kind });
|
|
40442
40543
|
if (block) blocks.push(block);
|
|
40443
40544
|
}
|
|
@@ -40483,8 +40584,8 @@ function formatFromPath(path) {
|
|
|
40483
40584
|
function defaultPreset(format) {
|
|
40484
40585
|
return format === "html" || format === "astro" ? "landing" : "blog";
|
|
40485
40586
|
}
|
|
40486
|
-
function extract(source, format, preset) {
|
|
40487
|
-
const extracted = preset === "meta" && format === "text" ? extractMetaLines(source) : format === "md" || format === "mdx" ? extractMarkdown(source, format === "mdx") : format === "html" || format === "astro" ? extractHtml(source, format === "astro") : extractText(source);
|
|
40587
|
+
function extract(source, format, preset, metaKind) {
|
|
40588
|
+
const extracted = preset === "meta" && format === "text" ? extractMetaLines(source, metaKind) : format === "md" || format === "mdx" ? extractMarkdown(source, format === "mdx") : format === "html" || format === "astro" ? extractHtml(source, format === "astro") : extractText(source);
|
|
40488
40589
|
return {
|
|
40489
40590
|
source,
|
|
40490
40591
|
format,
|
|
@@ -41146,6 +41247,153 @@ function runPhraseRules(ctx) {
|
|
|
41146
41247
|
}
|
|
41147
41248
|
}
|
|
41148
41249
|
}
|
|
41250
|
+
// packages/core/dist/serp/browser.js
|
|
41251
|
+
const SERP_PIXEL_BUDGETS = {
|
|
41252
|
+
title: {
|
|
41253
|
+
desktop: 600,
|
|
41254
|
+
mobile: 560
|
|
41255
|
+
},
|
|
41256
|
+
description: {
|
|
41257
|
+
desktop: 920,
|
|
41258
|
+
mobile: 680
|
|
41259
|
+
}
|
|
41260
|
+
};
|
|
41261
|
+
const FONT_PX = {
|
|
41262
|
+
title: 20,
|
|
41263
|
+
description: 14
|
|
41264
|
+
};
|
|
41265
|
+
const ASCII = [
|
|
41266
|
+
278,
|
|
41267
|
+
278,
|
|
41268
|
+
355,
|
|
41269
|
+
556,
|
|
41270
|
+
556,
|
|
41271
|
+
889,
|
|
41272
|
+
667,
|
|
41273
|
+
191,
|
|
41274
|
+
333,
|
|
41275
|
+
333,
|
|
41276
|
+
389,
|
|
41277
|
+
584,
|
|
41278
|
+
278,
|
|
41279
|
+
333,
|
|
41280
|
+
278,
|
|
41281
|
+
278,
|
|
41282
|
+
556,
|
|
41283
|
+
556,
|
|
41284
|
+
556,
|
|
41285
|
+
556,
|
|
41286
|
+
556,
|
|
41287
|
+
556,
|
|
41288
|
+
556,
|
|
41289
|
+
556,
|
|
41290
|
+
556,
|
|
41291
|
+
556,
|
|
41292
|
+
278,
|
|
41293
|
+
278,
|
|
41294
|
+
584,
|
|
41295
|
+
584,
|
|
41296
|
+
584,
|
|
41297
|
+
556,
|
|
41298
|
+
1015,
|
|
41299
|
+
667,
|
|
41300
|
+
667,
|
|
41301
|
+
722,
|
|
41302
|
+
722,
|
|
41303
|
+
667,
|
|
41304
|
+
611,
|
|
41305
|
+
778,
|
|
41306
|
+
722,
|
|
41307
|
+
278,
|
|
41308
|
+
500,
|
|
41309
|
+
667,
|
|
41310
|
+
556,
|
|
41311
|
+
833,
|
|
41312
|
+
722,
|
|
41313
|
+
778,
|
|
41314
|
+
667,
|
|
41315
|
+
778,
|
|
41316
|
+
722,
|
|
41317
|
+
667,
|
|
41318
|
+
611,
|
|
41319
|
+
722,
|
|
41320
|
+
667,
|
|
41321
|
+
944,
|
|
41322
|
+
667,
|
|
41323
|
+
667,
|
|
41324
|
+
611,
|
|
41325
|
+
278,
|
|
41326
|
+
278,
|
|
41327
|
+
278,
|
|
41328
|
+
469,
|
|
41329
|
+
556,
|
|
41330
|
+
333,
|
|
41331
|
+
556,
|
|
41332
|
+
556,
|
|
41333
|
+
500,
|
|
41334
|
+
556,
|
|
41335
|
+
556,
|
|
41336
|
+
278,
|
|
41337
|
+
556,
|
|
41338
|
+
556,
|
|
41339
|
+
222,
|
|
41340
|
+
222,
|
|
41341
|
+
500,
|
|
41342
|
+
222,
|
|
41343
|
+
833,
|
|
41344
|
+
556,
|
|
41345
|
+
556,
|
|
41346
|
+
556,
|
|
41347
|
+
556,
|
|
41348
|
+
333,
|
|
41349
|
+
500,
|
|
41350
|
+
278,
|
|
41351
|
+
556,
|
|
41352
|
+
500,
|
|
41353
|
+
722,
|
|
41354
|
+
500,
|
|
41355
|
+
500,
|
|
41356
|
+
500,
|
|
41357
|
+
334,
|
|
41358
|
+
260,
|
|
41359
|
+
334,
|
|
41360
|
+
584
|
|
41361
|
+
];
|
|
41362
|
+
const OTHER = {
|
|
41363
|
+
"‘": 222,
|
|
41364
|
+
"’": 222,
|
|
41365
|
+
"“": 333,
|
|
41366
|
+
"”": 333,
|
|
41367
|
+
"–": 556,
|
|
41368
|
+
"—": 1e3,
|
|
41369
|
+
"…": 1e3,
|
|
41370
|
+
"•": 350,
|
|
41371
|
+
"·": 278,
|
|
41372
|
+
"©": 737,
|
|
41373
|
+
"®": 737,
|
|
41374
|
+
"™": 1e3,
|
|
41375
|
+
"£": 556,
|
|
41376
|
+
"€": 556,
|
|
41377
|
+
"°": 400,
|
|
41378
|
+
"×": 584
|
|
41379
|
+
};
|
|
41380
|
+
const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
41381
|
+
const AVERAGE = 556;
|
|
41382
|
+
function units(char) {
|
|
41383
|
+
const code = char.codePointAt(0) ?? 0;
|
|
41384
|
+
if (code >= 32 && code <= 126) return ASCII[code - 32] ?? AVERAGE;
|
|
41385
|
+
const known = OTHER[char];
|
|
41386
|
+
if (known !== void 0) return known;
|
|
41387
|
+
if (WIDE.test(char)) return 1e3;
|
|
41388
|
+
const base = char.normalize("NFD").codePointAt(0) ?? 0;
|
|
41389
|
+
if (base >= 32 && base <= 126) return ASCII[base - 32] ?? AVERAGE;
|
|
41390
|
+
return AVERAGE;
|
|
41391
|
+
}
|
|
41392
|
+
function serpTextWidth(value, kind) {
|
|
41393
|
+
let total = 0;
|
|
41394
|
+
for (const char of value) total += units(char);
|
|
41395
|
+
return total * FONT_PX[kind] / 1e3;
|
|
41396
|
+
}
|
|
41149
41397
|
// packages/core/dist/lint/rules-seo.js
|
|
41150
41398
|
const SEO_RULES = {
|
|
41151
41399
|
dash: {
|
|
@@ -41247,7 +41495,8 @@ function lengths(ctx) {
|
|
|
41247
41495
|
const length = block.text.length;
|
|
41248
41496
|
if (length >= low && length <= high) continue;
|
|
41249
41497
|
const what = title ? "title" : "description";
|
|
41250
|
-
|
|
41498
|
+
const pixels = Math.ceil(serpTextWidth(block.text, what));
|
|
41499
|
+
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.");
|
|
41251
41500
|
}
|
|
41252
41501
|
}
|
|
41253
41502
|
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;
|
|
@@ -41854,6 +42103,7 @@ const LINT_PRESETS = [
|
|
|
41854
42103
|
"docs",
|
|
41855
42104
|
"meta"
|
|
41856
42105
|
];
|
|
42106
|
+
const LINT_META_KINDS = ["title", "description"];
|
|
41857
42107
|
const LINT_FORMATS = [
|
|
41858
42108
|
"md",
|
|
41859
42109
|
"mdx",
|
|
@@ -41861,6 +42111,11 @@ const LINT_FORMATS = [
|
|
|
41861
42111
|
"astro",
|
|
41862
42112
|
"text"
|
|
41863
42113
|
];
|
|
42114
|
+
const LINT_CATEGORIES = [
|
|
42115
|
+
"ai",
|
|
42116
|
+
"copy",
|
|
42117
|
+
"seo"
|
|
42118
|
+
];
|
|
41864
42119
|
const LINT_MAX_CHARS = 2e5;
|
|
41865
42120
|
// packages/core/dist/lint/lint.js
|
|
41866
42121
|
const LINT_RULES = [
|
|
@@ -41875,6 +42130,15 @@ const LINT_RULES = [
|
|
|
41875
42130
|
...Object.values(SEO_RULES)
|
|
41876
42131
|
];
|
|
41877
42132
|
const AI_RULES = new Set(LINT_RULES.filter((rule) => rule.family === "ai").map((rule) => rule.id));
|
|
42133
|
+
const RULE_CATEGORY = new Map(LINT_RULES.map((rule) => [rule.id, rule.family]));
|
|
42134
|
+
const LENS_CATEGORY = {
|
|
42135
|
+
contrast: "ai",
|
|
42136
|
+
metaphor: "ai",
|
|
42137
|
+
staccato: "ai",
|
|
42138
|
+
anaphora: "ai",
|
|
42139
|
+
"mic-drop": "ai"
|
|
42140
|
+
};
|
|
42141
|
+
const lensCategory = (lens) => LENS_CATEGORY[lens] ?? "copy";
|
|
41878
42142
|
function position(doc, block, index) {
|
|
41879
42143
|
const offset = block.map[Math.min(index, block.map.length - 1)] ?? 0;
|
|
41880
42144
|
const line = lineOf(doc.lineStarts, offset);
|
|
@@ -41901,7 +42165,7 @@ function lintText(text, options = {}) {
|
|
|
41901
42165
|
if (text.length > 2e5) throw new RangeError(`The text is ${text.length.toLocaleString("en-US")} characters; the most one check reads is ${LINT_MAX_CHARS.toLocaleString("en-US")}.`);
|
|
41902
42166
|
const format = options.format ?? "md";
|
|
41903
42167
|
const preset = options.preset ?? defaultPreset(format);
|
|
41904
|
-
const doc = extract(text, format, preset);
|
|
42168
|
+
const doc = extract(text, format, preset, options.metaKind);
|
|
41905
42169
|
const ctx = new Context(doc, preset, LINT_RULES);
|
|
41906
42170
|
runStructureRules(ctx);
|
|
41907
42171
|
runPhraseRules(ctx);
|
|
@@ -41916,7 +42180,10 @@ function lintText(text, options = {}) {
|
|
|
41916
42180
|
const lower = quote.toLowerCase();
|
|
41917
42181
|
return !phrases.some((phrase) => lower.includes(phrase));
|
|
41918
42182
|
};
|
|
41919
|
-
const
|
|
42183
|
+
const only = options.only?.length ? new Set(options.only) : null;
|
|
42184
|
+
const checks = (category) => !only || only.has(category);
|
|
42185
|
+
const ruleCategory = (rule) => RULE_CATEGORY.get(rule) ?? "copy";
|
|
42186
|
+
const hits = dedupe(ctx.hits).filter((hit) => checks(ruleCategory(hit.rule)));
|
|
41920
42187
|
const findings = [];
|
|
41921
42188
|
for (const hit of hits) {
|
|
41922
42189
|
const at = position(doc, hit.block, hit.start);
|
|
@@ -41924,6 +42191,7 @@ function lintText(text, options = {}) {
|
|
|
41924
42191
|
if (!allowed(hit.rule, at.line, quote)) continue;
|
|
41925
42192
|
findings.push({
|
|
41926
42193
|
rule: hit.rule,
|
|
42194
|
+
category: ruleCategory(hit.rule),
|
|
41927
42195
|
level: hit.level,
|
|
41928
42196
|
line: at.line,
|
|
41929
42197
|
column: at.column,
|
|
@@ -41932,12 +42200,13 @@ function lintText(text, options = {}) {
|
|
|
41932
42200
|
fix: hit.fix
|
|
41933
42201
|
});
|
|
41934
42202
|
}
|
|
41935
|
-
for (const found of text.matchAll(RAW_LEAKS)) {
|
|
42203
|
+
for (const found of checks("ai") ? text.matchAll(RAW_LEAKS) : []) {
|
|
41936
42204
|
const offset = found.index ?? 0;
|
|
41937
42205
|
const line = lineOf(doc.lineStarts, offset);
|
|
41938
42206
|
if (!allowed("chatbot-residue", line + 1, found[0])) continue;
|
|
41939
42207
|
findings.push({
|
|
41940
42208
|
rule: "chatbot-residue",
|
|
42209
|
+
category: "ai",
|
|
41941
42210
|
level: "error",
|
|
41942
42211
|
line: line + 1,
|
|
41943
42212
|
column: offset - (doc.lineStarts[line] ?? 0) + 1,
|
|
@@ -41949,6 +42218,7 @@ function lintText(text, options = {}) {
|
|
|
41949
42218
|
const review = [];
|
|
41950
42219
|
const seen = /* @__PURE__ */ new Set();
|
|
41951
42220
|
for (const item of ctx.reviews) {
|
|
42221
|
+
if (!checks(lensCategory(item.lens))) continue;
|
|
41952
42222
|
if (hits.some((hit) => overlaps(item, hit))) continue;
|
|
41953
42223
|
const at = position(doc, item.block, item.start);
|
|
41954
42224
|
const quote = excerpt(item.block, item.start, item.end);
|
|
@@ -41957,6 +42227,7 @@ function lintText(text, options = {}) {
|
|
|
41957
42227
|
seen.add(key);
|
|
41958
42228
|
review.push({
|
|
41959
42229
|
lens: item.lens,
|
|
42230
|
+
category: lensCategory(item.lens),
|
|
41960
42231
|
line: at.line,
|
|
41961
42232
|
column: at.column,
|
|
41962
42233
|
quote,
|
|
@@ -41966,17 +42237,25 @@ function lintText(text, options = {}) {
|
|
|
41966
42237
|
const order = (a, b) => a.line - b.line || a.column - b.column;
|
|
41967
42238
|
findings.sort((a, b) => order(a, b) || a.rule.localeCompare(b.rule) || a.excerpt.localeCompare(b.excerpt));
|
|
41968
42239
|
review.sort((a, b) => order(a, b) || a.lens.localeCompare(b.lens) || a.quote.localeCompare(b.quote));
|
|
42240
|
+
const categories = Object.fromEntries(LINT_CATEGORIES.map((category) => [category, findings.filter((finding) => finding.category === category).length]));
|
|
41969
42241
|
return {
|
|
41970
42242
|
format,
|
|
41971
42243
|
preset,
|
|
41972
42244
|
words: ctx.words,
|
|
41973
42245
|
errors: findings.filter((finding) => finding.level === "error").length,
|
|
41974
42246
|
warnings: findings.filter((finding) => finding.level === "warning").length,
|
|
42247
|
+
categories,
|
|
42248
|
+
...only ? { only: LINT_CATEGORIES.filter((category) => only.has(category)) } : {},
|
|
41975
42249
|
findings,
|
|
41976
42250
|
review
|
|
41977
42251
|
};
|
|
41978
42252
|
}
|
|
41979
42253
|
const plural$2 = (count, word) => `${count.toLocaleString("en-US")} ${word}${count === 1 ? "" : "s"}`;
|
|
42254
|
+
const CATEGORY_NAMES = {
|
|
42255
|
+
ai: "AI patterns",
|
|
42256
|
+
copy: "copy",
|
|
42257
|
+
seo: "SEO"
|
|
42258
|
+
};
|
|
41980
42259
|
function lintReportText(files) {
|
|
41981
42260
|
const lines = [];
|
|
41982
42261
|
let errors = 0;
|
|
@@ -41991,7 +42270,12 @@ function lintReportText(files) {
|
|
|
41991
42270
|
errors += result.errors;
|
|
41992
42271
|
warnings += result.warnings;
|
|
41993
42272
|
reviews += result.review.length;
|
|
41994
|
-
|
|
42273
|
+
const only = result.only ? `, only ${result.only.map((category) => CATEGORY_NAMES[category]).join(" and ")}` : "";
|
|
42274
|
+
const counted = LINT_CATEGORIES.filter((category) => result.categories[category] > 0);
|
|
42275
|
+
const split = counted.length ? ` (${counted.map((category) => `${category} ${result.categories[category].toLocaleString("en-US")}`).join(", ")})` : "";
|
|
42276
|
+
lines.push(`${file.path} (${result.preset}, ${plural$2(result.words, "word")}${only}): ${plural$2(result.errors, "error")}, ${plural$2(result.warnings, "warning")}${split}, ${result.review.length.toLocaleString("en-US")} to review`);
|
|
42277
|
+
if (file.note) lines.push(` ${file.note}`);
|
|
42278
|
+
if (file.partial) lines.push(` Partly checked: ${file.partial}`);
|
|
41995
42279
|
for (const finding of result.findings) lines.push(` ${finding.line}:${finding.column} ${finding.level} ${finding.rule} "${finding.excerpt}"`, ` ${finding.message}`, ` Fix: ${finding.fix}`);
|
|
41996
42280
|
if (result.review.length) {
|
|
41997
42281
|
lines.push(" To review (your call):");
|
|
@@ -42000,7 +42284,14 @@ function lintReportText(files) {
|
|
|
42000
42284
|
lines.push("");
|
|
42001
42285
|
}
|
|
42002
42286
|
const checked = files.filter((file) => "result" in file).length;
|
|
42003
|
-
|
|
42287
|
+
const skipped = files.length - checked;
|
|
42288
|
+
const partial = files.filter((file) => "partial" in file && file.partial).length;
|
|
42289
|
+
if (files.length > 1 || skipped || partial) {
|
|
42290
|
+
const gaps = [skipped ? `${skipped.toLocaleString("en-US")} not checked` : "", partial ? `${partial.toLocaleString("en-US")} partly checked` : ""].filter(Boolean);
|
|
42291
|
+
lines.push(`${plural$2(checked, "file")} checked${gaps.length ? ` (${gaps.join(", ")})` : ""}: ${plural$2(errors, "error")}, ${plural$2(warnings, "warning")}, ${reviews.toLocaleString("en-US")} to review.`);
|
|
42292
|
+
}
|
|
42293
|
+
if (!checked) lines.push("Nothing was checked, so this is not a clean result. The reason is beside each file above.");
|
|
42294
|
+
else if (skipped || partial) lines.push("Not an all-clear: the totals cover only the text that was checked.");
|
|
42004
42295
|
if (reviews) lines.push("Errors and warnings each come with a fix. Review items are judgement calls: change a line only when it fails the question.");
|
|
42005
42296
|
return lines.join("\n").trimEnd();
|
|
42006
42297
|
}
|
|
@@ -42224,7 +42515,114 @@ async function aiQueriesReport(input, searchAnalytics) {
|
|
|
42224
42515
|
]
|
|
42225
42516
|
};
|
|
42226
42517
|
}
|
|
42518
|
+
// packages/core/dist/commands/research-ahrefs.js
|
|
42519
|
+
async function ahrefs(key, input, missed, totals) {
|
|
42520
|
+
const where = targetParts(input).ahrefs;
|
|
42521
|
+
const get = async (path, params) => {
|
|
42522
|
+
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
42523
|
+
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
42524
|
+
return readJson(await call("ahrefs", new Request(url, { headers: {
|
|
42525
|
+
authorization: `Bearer ${key}`,
|
|
42526
|
+
accept: "application/json"
|
|
42527
|
+
} })), "Ahrefs");
|
|
42528
|
+
};
|
|
42529
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
42530
|
+
if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), missed, async (seed) => {
|
|
42531
|
+
return ((await get("keywords-explorer/matching-terms", {
|
|
42532
|
+
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
42533
|
+
country: input.country,
|
|
42534
|
+
keywords: seed,
|
|
42535
|
+
limit: String(input.limit),
|
|
42536
|
+
order_by: "volume:desc"
|
|
42537
|
+
})).keywords ?? []).map((row) => ({
|
|
42538
|
+
seed,
|
|
42539
|
+
...flattenIntents(row)
|
|
42540
|
+
}));
|
|
42541
|
+
});
|
|
42542
|
+
if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
|
|
42543
|
+
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
42544
|
+
country: input.country,
|
|
42545
|
+
keywords: input.keywords.slice(0, 100).join(",")
|
|
42546
|
+
})).keywords ?? []).map(flattenIntents);
|
|
42547
|
+
if (input.action === "ranked_keywords") {
|
|
42548
|
+
const [data, metrics] = await Promise.all([get("site-explorer/organic-keywords", {
|
|
42549
|
+
select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
|
|
42550
|
+
...where,
|
|
42551
|
+
country: input.country,
|
|
42552
|
+
date: today,
|
|
42553
|
+
limit: String(input.limit),
|
|
42554
|
+
order_by: "sum_traffic:desc"
|
|
42555
|
+
}), get("site-explorer/metrics", {
|
|
42556
|
+
...where,
|
|
42557
|
+
country: input.country,
|
|
42558
|
+
date: today
|
|
42559
|
+
})]);
|
|
42560
|
+
const total = metrics.metrics;
|
|
42561
|
+
if (typeof total?.org_traffic === "number") totals.siteTotal = {
|
|
42562
|
+
metric: "org_traffic",
|
|
42563
|
+
traffic: total.org_traffic,
|
|
42564
|
+
keywords: typeof total.org_keywords === "number" ? total.org_keywords : null
|
|
42565
|
+
};
|
|
42566
|
+
return data.keywords ?? [];
|
|
42567
|
+
}
|
|
42568
|
+
if (input.action === "top_pages") return (await get("site-explorer/top-pages", {
|
|
42569
|
+
select: "url,sum_traffic,keywords,top_keyword,top_keyword_volume,top_keyword_best_position,referring_domains",
|
|
42570
|
+
...where,
|
|
42571
|
+
country: input.country,
|
|
42572
|
+
date: today,
|
|
42573
|
+
limit: String(input.limit),
|
|
42574
|
+
order_by: "sum_traffic:desc"
|
|
42575
|
+
})).pages ?? [];
|
|
42576
|
+
if (input.action === "pages_by_backlinks") return (await get("site-explorer/pages-by-backlinks", {
|
|
42577
|
+
select: "url_to,title_target,http_code_target,refdomains_target,links_to_target,dofollow_to_target,first_seen_link",
|
|
42578
|
+
...where,
|
|
42579
|
+
history: "live",
|
|
42580
|
+
limit: String(input.limit),
|
|
42581
|
+
order_by: "refdomains_target:desc"
|
|
42582
|
+
})).pages ?? [];
|
|
42583
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
|
|
42584
|
+
return ((await get("serp-overview/serp-overview", {
|
|
42585
|
+
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
42586
|
+
keyword,
|
|
42587
|
+
country: input.country,
|
|
42588
|
+
top_positions: String(serpDepth(input))
|
|
42589
|
+
})).positions ?? []).map((row) => ({
|
|
42590
|
+
keyword,
|
|
42591
|
+
...row
|
|
42592
|
+
}));
|
|
42593
|
+
});
|
|
42594
|
+
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
42595
|
+
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
42596
|
+
...where,
|
|
42597
|
+
history: "live",
|
|
42598
|
+
limit: String(input.limit),
|
|
42599
|
+
order_by: "domain_rating:desc"
|
|
42600
|
+
})).refdomains ?? [];
|
|
42601
|
+
return (await get("site-explorer/organic-competitors", {
|
|
42602
|
+
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
42603
|
+
target: input.target,
|
|
42604
|
+
country: input.country,
|
|
42605
|
+
date: today,
|
|
42606
|
+
mode: "subdomains",
|
|
42607
|
+
limit: String(input.limit),
|
|
42608
|
+
order_by: "keywords_common:desc"
|
|
42609
|
+
})).competitors ?? [];
|
|
42610
|
+
}
|
|
42611
|
+
function flattenIntents(row) {
|
|
42612
|
+
const intents = row.intents;
|
|
42613
|
+
if (!intents || typeof intents !== "object") return row;
|
|
42614
|
+
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
42615
|
+
return {
|
|
42616
|
+
...row,
|
|
42617
|
+
intents: names.join(" ") || null
|
|
42618
|
+
};
|
|
42619
|
+
}
|
|
42227
42620
|
// packages/core/dist/commands/research-providers.js
|
|
42621
|
+
const RESEARCH_SCOPES = [
|
|
42622
|
+
"domain",
|
|
42623
|
+
"prefix",
|
|
42624
|
+
"exact"
|
|
42625
|
+
];
|
|
42228
42626
|
function need(headers, provider) {
|
|
42229
42627
|
const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
|
|
42230
42628
|
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
|
|
@@ -42260,8 +42658,17 @@ async function call(provider, request) {
|
|
|
42260
42658
|
}
|
|
42261
42659
|
}
|
|
42262
42660
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
42661
|
+
const KEYWORD_ACTIONS = [
|
|
42662
|
+
"keyword_metrics",
|
|
42663
|
+
"keyword_ideas",
|
|
42664
|
+
"serp",
|
|
42665
|
+
"serp_overlap"
|
|
42666
|
+
];
|
|
42667
|
+
const SERP_KEYWORDS = 5;
|
|
42668
|
+
const OVERLAP_KEYWORDS = 10;
|
|
42669
|
+
const OVERLAP_DEPTH = 10;
|
|
42263
42670
|
function requireInput(input) {
|
|
42264
|
-
const needsKeywords =
|
|
42671
|
+
const needsKeywords = KEYWORD_ACTIONS.includes(input.action);
|
|
42265
42672
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
42266
42673
|
if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
|
|
42267
42674
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
@@ -42282,16 +42689,19 @@ async function eachKeyword(keywords, missed, rowsFor) {
|
|
|
42282
42689
|
async function research(provider, headers, input) {
|
|
42283
42690
|
requireInput(input);
|
|
42284
42691
|
if (input.action === "link_gap") return linkGap(provider, headers, input);
|
|
42692
|
+
if (input.action === "serp_overlap") return serpOverlap(provider, headers, input);
|
|
42285
42693
|
const keys = need(headers, provider);
|
|
42286
42694
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
42287
42695
|
const missed = [];
|
|
42288
|
-
const
|
|
42696
|
+
const totals = {};
|
|
42697
|
+
const rows = await ahrefs(keys[0] ?? "", input, missed, totals);
|
|
42289
42698
|
return {
|
|
42290
42699
|
rows,
|
|
42291
42700
|
costUsd: null,
|
|
42292
42701
|
partial: false,
|
|
42293
42702
|
missed,
|
|
42294
|
-
noData: withoutData(input, rows, missed, 100)
|
|
42703
|
+
noData: withoutData(input, rows, missed, 100),
|
|
42704
|
+
...totals
|
|
42295
42705
|
};
|
|
42296
42706
|
}
|
|
42297
42707
|
function countryName(code) {
|
|
@@ -42391,13 +42801,16 @@ async function dataForSeo(keys, input) {
|
|
|
42391
42801
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
42392
42802
|
partial: spent.partial,
|
|
42393
42803
|
missed: spent.missed,
|
|
42394
|
-
noData: withoutData(input, rows, spent.missed, 700)
|
|
42804
|
+
noData: withoutData(input, rows, spent.missed, 700),
|
|
42805
|
+
...spent.available !== void 0 ? { available: spent.available } : {},
|
|
42806
|
+
...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
|
|
42807
|
+
...spent.rankFailed ? { rankFailed: true } : {}
|
|
42395
42808
|
};
|
|
42396
42809
|
}
|
|
42397
42810
|
function withoutData(input, rows, missed, keywordLimit) {
|
|
42398
42811
|
if (input.action === "keyword_metrics" || input.action === "serp") {
|
|
42399
|
-
const max = input.action === "serp" ?
|
|
42400
|
-
const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
42812
|
+
const max = input.action === "serp" ? serpKeywords(input) : keywordLimit;
|
|
42813
|
+
const answered = new Set(rows.filter((row) => input.action === "serp" || (row.search_volume ?? row.volume ?? null) !== null).map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
42401
42814
|
const failed = new Set(missed);
|
|
42402
42815
|
return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
|
|
42403
42816
|
}
|
|
@@ -42405,8 +42818,14 @@ function withoutData(input, rows, missed, keywordLimit) {
|
|
|
42405
42818
|
}
|
|
42406
42819
|
async function dataForSeoRows(keys, input, spent) {
|
|
42407
42820
|
const language = dataForSeoLanguage(input.country);
|
|
42408
|
-
if (!language &&
|
|
42821
|
+
if (!language && ![
|
|
42822
|
+
"serp",
|
|
42823
|
+
"backlinks",
|
|
42824
|
+
"pages_by_backlinks"
|
|
42825
|
+
].includes(input.action)) throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
42409
42826
|
const market = dataForSeoMarket(input.country, language);
|
|
42827
|
+
const where = targetParts(input);
|
|
42828
|
+
let header = null;
|
|
42410
42829
|
const post = async (path, task, attempts = 1) => {
|
|
42411
42830
|
const done = await dataForSeoTask(keys, path, {
|
|
42412
42831
|
...market,
|
|
@@ -42417,6 +42836,9 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42417
42836
|
});
|
|
42418
42837
|
spent.cost += done.cost;
|
|
42419
42838
|
spent.partial ||= done.partial;
|
|
42839
|
+
header = done.result;
|
|
42840
|
+
const total = done.result?.total_count;
|
|
42841
|
+
if (typeof total === "number") spent.available = total;
|
|
42420
42842
|
if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
|
|
42421
42843
|
return done.result?.items ?? [];
|
|
42422
42844
|
};
|
|
@@ -42430,47 +42852,133 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42430
42852
|
competition: "keyword_info.competition",
|
|
42431
42853
|
main_intent: "search_intent_info.main_intent"
|
|
42432
42854
|
}));
|
|
42433
|
-
if (input.action === "
|
|
42434
|
-
|
|
42855
|
+
if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (seed) => {
|
|
42856
|
+
return (await post("v3/dataforseo_labs/google/keyword_suggestions/live", {
|
|
42857
|
+
keyword: seed,
|
|
42858
|
+
include_seed_keyword: true,
|
|
42859
|
+
limit: input.limit,
|
|
42860
|
+
order_by: ["keyword_info.search_volume,desc"]
|
|
42861
|
+
})).map((item) => ({
|
|
42862
|
+
seed,
|
|
42863
|
+
...pick(item, {
|
|
42864
|
+
keyword: "keyword",
|
|
42865
|
+
search_volume: "keyword_info.search_volume",
|
|
42866
|
+
keyword_difficulty: "keyword_properties.keyword_difficulty",
|
|
42867
|
+
cpc: "keyword_info.cpc",
|
|
42868
|
+
main_intent: "search_intent_info.main_intent"
|
|
42869
|
+
})
|
|
42870
|
+
}));
|
|
42871
|
+
});
|
|
42872
|
+
if (input.action === "ranked_keywords") {
|
|
42873
|
+
const items = await post("v3/dataforseo_labs/google/ranked_keywords/live", {
|
|
42874
|
+
target: where.scope === "exact" ? where.page : where.host,
|
|
42875
|
+
limit: input.limit,
|
|
42876
|
+
order_by: ["ranked_serp_element.serp_item.etv,desc"],
|
|
42877
|
+
...where.scope === "prefix" ? { filters: [
|
|
42878
|
+
"ranked_serp_element.serp_item.relative_url",
|
|
42879
|
+
"like",
|
|
42880
|
+
`${where.path}%`
|
|
42881
|
+
] } : {}
|
|
42882
|
+
});
|
|
42883
|
+
const organic = get(header, "metrics.organic");
|
|
42884
|
+
const etv = get(organic, "etv");
|
|
42885
|
+
if (typeof etv === "number") {
|
|
42886
|
+
const count = get(organic, "count");
|
|
42887
|
+
spent.siteTotal = {
|
|
42888
|
+
metric: "etv",
|
|
42889
|
+
traffic: Math.round(etv),
|
|
42890
|
+
keywords: typeof count === "number" ? count : null
|
|
42891
|
+
};
|
|
42892
|
+
}
|
|
42893
|
+
return items.map((item) => pick(item, {
|
|
42894
|
+
keyword: "keyword_data.keyword",
|
|
42895
|
+
position: "ranked_serp_element.serp_item.rank_absolute",
|
|
42896
|
+
url: "ranked_serp_element.serp_item.url",
|
|
42897
|
+
search_volume: "keyword_data.keyword_info.search_volume",
|
|
42898
|
+
etv: "ranked_serp_element.serp_item.etv",
|
|
42899
|
+
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
42900
|
+
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
42901
|
+
}));
|
|
42902
|
+
}
|
|
42903
|
+
if (input.action === "serp") {
|
|
42904
|
+
const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
|
|
42905
|
+
return (await post("v3/serp/google/organic/live/advanced", {
|
|
42906
|
+
keyword,
|
|
42907
|
+
depth: serpDepth(input)
|
|
42908
|
+
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
42909
|
+
const row = pick(item, {
|
|
42910
|
+
position: "rank_absolute",
|
|
42911
|
+
url: "url",
|
|
42912
|
+
domain: "domain",
|
|
42913
|
+
title: "title"
|
|
42914
|
+
});
|
|
42915
|
+
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
42916
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
42917
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
42918
|
+
row.url = null;
|
|
42919
|
+
row.domain = site?.[1] ?? null;
|
|
42920
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
42921
|
+
}
|
|
42922
|
+
return {
|
|
42923
|
+
keyword,
|
|
42924
|
+
...row
|
|
42925
|
+
};
|
|
42926
|
+
});
|
|
42927
|
+
});
|
|
42928
|
+
return input.noRank ? rows : withDomainRank(keys, rows, spent);
|
|
42929
|
+
}
|
|
42930
|
+
if (input.action === "top_pages") return (await post("v3/dataforseo_labs/google/relevant_pages/live", {
|
|
42931
|
+
target: where.host,
|
|
42435
42932
|
limit: input.limit,
|
|
42436
|
-
order_by: ["
|
|
42933
|
+
order_by: ["metrics.organic.etv,desc"],
|
|
42934
|
+
...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
|
|
42935
|
+
"page_address",
|
|
42936
|
+
"like",
|
|
42937
|
+
likePrefix(where.host, where.path)
|
|
42938
|
+
] : [
|
|
42939
|
+
"page_address",
|
|
42940
|
+
"=",
|
|
42941
|
+
where.page
|
|
42942
|
+
] }
|
|
42437
42943
|
})).map((item) => pick(item, {
|
|
42438
|
-
|
|
42439
|
-
|
|
42440
|
-
|
|
42441
|
-
|
|
42442
|
-
|
|
42443
|
-
|
|
42444
|
-
|
|
42944
|
+
url: "page_address",
|
|
42945
|
+
etv: "metrics.organic.etv",
|
|
42946
|
+
keywords: "metrics.organic.count",
|
|
42947
|
+
pos_1: "metrics.organic.pos_1",
|
|
42948
|
+
pos_2_3: "metrics.organic.pos_2_3",
|
|
42949
|
+
pos_4_10: "metrics.organic.pos_4_10"
|
|
42950
|
+
}));
|
|
42951
|
+
if (input.action === "pages_by_backlinks") return (await post("v3/backlinks/domain_pages/live", {
|
|
42952
|
+
target: where.host,
|
|
42953
|
+
limit: input.limit,
|
|
42954
|
+
order_by: ["page_summary.referring_domains,desc"],
|
|
42955
|
+
...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
|
|
42956
|
+
"page",
|
|
42957
|
+
"like",
|
|
42958
|
+
likePrefix(where.host, where.path)
|
|
42959
|
+
] : [
|
|
42960
|
+
"page",
|
|
42961
|
+
"=",
|
|
42962
|
+
where.page
|
|
42963
|
+
] }
|
|
42964
|
+
})).map((item) => pick(item, {
|
|
42965
|
+
url: "page",
|
|
42966
|
+
status: "status_code",
|
|
42967
|
+
title: "meta.title",
|
|
42968
|
+
referring_domains: "page_summary.referring_domains",
|
|
42969
|
+
backlinks: "page_summary.backlinks",
|
|
42970
|
+
rank: "page_summary.rank",
|
|
42971
|
+
first_seen: "page_summary.first_seen"
|
|
42445
42972
|
}));
|
|
42446
|
-
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
|
|
42447
|
-
return (await post("v3/serp/google/organic/live/advanced", {
|
|
42448
|
-
keyword,
|
|
42449
|
-
depth: Math.min(input.limit, 100)
|
|
42450
|
-
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
42451
|
-
const row = pick(item, {
|
|
42452
|
-
position: "rank_absolute",
|
|
42453
|
-
url: "url",
|
|
42454
|
-
domain: "domain",
|
|
42455
|
-
title: "title"
|
|
42456
|
-
});
|
|
42457
|
-
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
42458
|
-
const crumb = String(item.breadcrumb ?? "");
|
|
42459
|
-
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
42460
|
-
row.url = null;
|
|
42461
|
-
row.domain = site?.[1] ?? null;
|
|
42462
|
-
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
42463
|
-
}
|
|
42464
|
-
return {
|
|
42465
|
-
keyword,
|
|
42466
|
-
...row
|
|
42467
|
-
};
|
|
42468
|
-
});
|
|
42469
|
-
});
|
|
42470
42973
|
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
42471
|
-
target:
|
|
42974
|
+
target: where.scope === "exact" ? where.page : where.host,
|
|
42472
42975
|
limit: input.limit,
|
|
42473
|
-
order_by: ["rank,desc"]
|
|
42976
|
+
order_by: ["rank,desc"],
|
|
42977
|
+
...where.scope === "prefix" ? { backlinks_filters: [
|
|
42978
|
+
"url_to",
|
|
42979
|
+
"like",
|
|
42980
|
+
likePrefix(where.host, where.path)
|
|
42981
|
+
] } : {}
|
|
42474
42982
|
})).map((item) => pick(item, {
|
|
42475
42983
|
domain: "domain",
|
|
42476
42984
|
rank: "rank",
|
|
@@ -42489,6 +42997,87 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42489
42997
|
organic_etv: "full_domain_metrics.organic.etv"
|
|
42490
42998
|
}));
|
|
42491
42999
|
}
|
|
43000
|
+
async function withDomainRank(keys, rows, spent) {
|
|
43001
|
+
const domains = [...new Set(rows.map((row) => String(row.domain ?? "")).filter(Boolean))].slice(0, 1e3);
|
|
43002
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
43003
|
+
if (domains.length) try {
|
|
43004
|
+
const done = await dataForSeoTask(keys, "v3/backlinks/bulk_ranks/live", {
|
|
43005
|
+
targets: domains,
|
|
43006
|
+
rank_scale: "one_hundred"
|
|
43007
|
+
});
|
|
43008
|
+
spent.cost += done.cost;
|
|
43009
|
+
const items = done.result?.items ?? [];
|
|
43010
|
+
for (const item of items) if (item.target) ranks.set(item.target, item.rank ?? null);
|
|
43011
|
+
} catch (error) {
|
|
43012
|
+
spent.cost += error.cost ?? 0;
|
|
43013
|
+
spent.rankFailed = true;
|
|
43014
|
+
}
|
|
43015
|
+
return rows.map((row) => {
|
|
43016
|
+
const next = {};
|
|
43017
|
+
for (const [name, value] of Object.entries(row)) {
|
|
43018
|
+
next[name] = value;
|
|
43019
|
+
if (name === "domain") next.domain_rank = ranks.get(String(value ?? "")) ?? null;
|
|
43020
|
+
}
|
|
43021
|
+
return next;
|
|
43022
|
+
});
|
|
43023
|
+
}
|
|
43024
|
+
const siteOf = (url) => {
|
|
43025
|
+
try {
|
|
43026
|
+
return new URL(String(url)).hostname;
|
|
43027
|
+
} catch {
|
|
43028
|
+
return "";
|
|
43029
|
+
}
|
|
43030
|
+
};
|
|
43031
|
+
const serpKeywords = (input) => input.action === "serp_overlap" ? OVERLAP_KEYWORDS : SERP_KEYWORDS;
|
|
43032
|
+
const serpDepth = (input) => Math.max(1, Math.min(input.depth ?? 20, 100));
|
|
43033
|
+
async function serpOverlap(provider, headers, input) {
|
|
43034
|
+
const serps = await research(provider, headers, {
|
|
43035
|
+
...input,
|
|
43036
|
+
action: "serp",
|
|
43037
|
+
depth: OVERLAP_DEPTH,
|
|
43038
|
+
limit: OVERLAP_DEPTH,
|
|
43039
|
+
noRank: true
|
|
43040
|
+
});
|
|
43041
|
+
const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
|
|
43042
|
+
const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
|
|
43043
|
+
const top = new Map(keywords.map((keyword) => {
|
|
43044
|
+
const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
|
|
43045
|
+
const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
|
|
43046
|
+
const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
|
|
43047
|
+
return [keyword, {
|
|
43048
|
+
results: rows.length,
|
|
43049
|
+
pages,
|
|
43050
|
+
sites
|
|
43051
|
+
}];
|
|
43052
|
+
}));
|
|
43053
|
+
const rows = [];
|
|
43054
|
+
for (const [i, a] of keywords.entries()) for (const b of keywords.slice(i + 1)) {
|
|
43055
|
+
const left = top.get(a);
|
|
43056
|
+
const right = top.get(b);
|
|
43057
|
+
if (!left || !right) continue;
|
|
43058
|
+
rows.push({
|
|
43059
|
+
keyword_a: a,
|
|
43060
|
+
keyword_b: b,
|
|
43061
|
+
shared_pages: [...left.pages].filter((url) => right.pages.has(url)).length,
|
|
43062
|
+
shared_sites: [...left.sites].filter((site) => right.sites.has(site)).length,
|
|
43063
|
+
pages_compared_a: left.pages.size,
|
|
43064
|
+
pages_compared_b: right.pages.size,
|
|
43065
|
+
results_a: left.results,
|
|
43066
|
+
results_b: right.results
|
|
43067
|
+
});
|
|
43068
|
+
}
|
|
43069
|
+
rows.sort((x, y) => Number(y.shared_pages) - Number(x.shared_pages) || Number(y.shared_sites) - Number(x.shared_sites) || String(x.keyword_a).localeCompare(String(y.keyword_a)) || String(x.keyword_b).localeCompare(String(y.keyword_b)));
|
|
43070
|
+
const results = [...top.values()];
|
|
43071
|
+
return {
|
|
43072
|
+
...serps,
|
|
43073
|
+
rows,
|
|
43074
|
+
compared: {
|
|
43075
|
+
results: results.reduce((sum, item) => sum + item.results, 0),
|
|
43076
|
+
withPage: results.reduce((sum, item) => sum + item.pages.size, 0),
|
|
43077
|
+
withSite: results.reduce((sum, item) => sum + item.sites.size, 0)
|
|
43078
|
+
}
|
|
43079
|
+
};
|
|
43080
|
+
}
|
|
42492
43081
|
const GAP_DOMAINS = 1e3;
|
|
42493
43082
|
const GAP_COMPETITORS = 5;
|
|
42494
43083
|
const SHORTENERS = [
|
|
@@ -42530,6 +43119,7 @@ async function linkGap(provider, headers, input) {
|
|
|
42530
43119
|
...input,
|
|
42531
43120
|
action: "backlinks",
|
|
42532
43121
|
target: site,
|
|
43122
|
+
scope: "domain",
|
|
42533
43123
|
limit: GAP_DOMAINS
|
|
42534
43124
|
});
|
|
42535
43125
|
cost += got.costUsd ?? 0;
|
|
@@ -42576,77 +43166,49 @@ async function linkGap(provider, headers, input) {
|
|
|
42576
43166
|
...linkLists.length ? { leftOut: linkLists } : {}
|
|
42577
43167
|
};
|
|
42578
43168
|
}
|
|
42579
|
-
function isPage(target) {
|
|
42580
|
-
return /\/./.test(bareTarget(target));
|
|
42581
|
-
}
|
|
42582
43169
|
function bareTarget(target) {
|
|
42583
43170
|
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
42584
43171
|
}
|
|
42585
|
-
|
|
42586
|
-
|
|
42587
|
-
|
|
42588
|
-
|
|
42589
|
-
|
|
42590
|
-
|
|
42591
|
-
|
|
42592
|
-
|
|
42593
|
-
|
|
42594
|
-
const
|
|
42595
|
-
|
|
42596
|
-
|
|
42597
|
-
|
|
42598
|
-
|
|
42599
|
-
|
|
42600
|
-
|
|
42601
|
-
|
|
42602
|
-
|
|
42603
|
-
|
|
42604
|
-
|
|
42605
|
-
|
|
42606
|
-
|
|
42607
|
-
|
|
42608
|
-
|
|
42609
|
-
|
|
42610
|
-
|
|
42611
|
-
|
|
42612
|
-
|
|
42613
|
-
|
|
42614
|
-
top_positions: String(Math.min(input.limit, 100))
|
|
42615
|
-
})).positions ?? []).map((row) => ({
|
|
42616
|
-
keyword,
|
|
42617
|
-
...row
|
|
42618
|
-
}));
|
|
42619
|
-
});
|
|
42620
|
-
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
42621
|
-
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
42622
|
-
target: input.target,
|
|
42623
|
-
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
42624
|
-
history: "live",
|
|
42625
|
-
limit: String(input.limit),
|
|
42626
|
-
order_by: "domain_rating:desc"
|
|
42627
|
-
})).refdomains ?? [];
|
|
42628
|
-
return (await get("site-explorer/organic-competitors", {
|
|
42629
|
-
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
42630
|
-
target: input.target,
|
|
42631
|
-
country: input.country,
|
|
42632
|
-
date: today,
|
|
42633
|
-
mode: "subdomains",
|
|
42634
|
-
limit: String(input.limit),
|
|
42635
|
-
order_by: "keywords_common:desc"
|
|
42636
|
-
})).competitors ?? [];
|
|
42637
|
-
}
|
|
42638
|
-
function flattenIntents(row) {
|
|
42639
|
-
const intents = row.intents;
|
|
42640
|
-
if (!intents || typeof intents !== "object") return row;
|
|
42641
|
-
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
42642
|
-
return {
|
|
42643
|
-
...row,
|
|
42644
|
-
intents: names.join(" ") || null
|
|
43172
|
+
function researchScope(target, scope) {
|
|
43173
|
+
if (scope) return scope;
|
|
43174
|
+
const bare = target.trim().replace(/^https?:\/\//, "");
|
|
43175
|
+
if (!/\/./.test(bare)) return "domain";
|
|
43176
|
+
return bare.endsWith("/") ? "prefix" : "exact";
|
|
43177
|
+
}
|
|
43178
|
+
function targetParts(input) {
|
|
43179
|
+
const scope = researchScope(input.target, input.scope);
|
|
43180
|
+
const bare = input.target.trim().replace(/^https?:\/\//, "");
|
|
43181
|
+
const slash = bare.indexOf("/");
|
|
43182
|
+
const host = (slash === -1 ? bare : bare.slice(0, slash)).toLowerCase();
|
|
43183
|
+
let path = slash === -1 ? "/" : bare.slice(slash);
|
|
43184
|
+
if (scope === "prefix" && !path.endsWith("/")) path += "/";
|
|
43185
|
+
const scheme = /^http:\/\//.test(input.target.trim()) ? "http" : "https";
|
|
43186
|
+
return {
|
|
43187
|
+
scope,
|
|
43188
|
+
host,
|
|
43189
|
+
path,
|
|
43190
|
+
page: `${scheme}://${host}${path}`,
|
|
43191
|
+
ahrefs: scope === "domain" ? {
|
|
43192
|
+
target: host,
|
|
43193
|
+
mode: "subdomains"
|
|
43194
|
+
} : scope === "prefix" ? {
|
|
43195
|
+
target: `${host}${path}`,
|
|
43196
|
+
mode: "prefix"
|
|
43197
|
+
} : {
|
|
43198
|
+
target: `${host}${path}`,
|
|
43199
|
+
mode: "exact"
|
|
43200
|
+
}
|
|
42645
43201
|
};
|
|
42646
43202
|
}
|
|
43203
|
+
const likePrefix = (host, path) => `%${host}${path}%`;
|
|
42647
43204
|
function rowsText(input) {
|
|
42648
43205
|
const total = input.rows.length;
|
|
42649
|
-
if (input.detail === "full")
|
|
43206
|
+
if (input.detail === "full") {
|
|
43207
|
+
const page = input.rows.slice(input.offset, input.offset + input.limit);
|
|
43208
|
+
const end = input.offset + page.length;
|
|
43209
|
+
const cut = end < total ? `\n\nRows ${input.offset + 1} to ${end} of ${total}. More: ${input.more ? input.more(end).replace("detail \"rows\"", "detail \"full\"").replace("--detail rows", "--detail full") : `detail "full", offset ${end}`}.` : "";
|
|
43210
|
+
return `${input.title}\n\n${JSON.stringify(page)}${cut}`;
|
|
43211
|
+
}
|
|
42650
43212
|
const size = input.detail === "summary" ? Math.min(input.limit, 25) : input.limit;
|
|
42651
43213
|
const page = input.rows.slice(input.offset, input.offset + size);
|
|
42652
43214
|
const columns = [...new Set(page.flatMap((row) => Object.keys(row)))];
|
|
@@ -42658,6 +43220,14 @@ function rowsText(input) {
|
|
|
42658
43220
|
end < total ? `More: ${input.more ? input.more(end) : `detail "rows", offset ${end}`}.` : ""
|
|
42659
43221
|
].filter(Boolean).join("\n\n");
|
|
42660
43222
|
}
|
|
43223
|
+
const SPAM_TLDS = /\.(xyz|top|click|buzz|icu|cfd|sbs|monster|rest|lol)$/;
|
|
43224
|
+
function spamHint(query) {
|
|
43225
|
+
const text = query.trim().toLowerCase();
|
|
43226
|
+
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(text) && SPAM_TLDS.test(text)) return "host name on a TLD common in referral spam";
|
|
43227
|
+
if (/^\d+: /.test(text) || /\btest keyword\b/.test(text)) return "rank tracker test query";
|
|
43228
|
+
if ((text.match(/[=,]/g) ?? []).length >= 6 && /"/.test(text)) return "pasted spreadsheet or tool export";
|
|
43229
|
+
return null;
|
|
43230
|
+
}
|
|
42661
43231
|
const DIMENSIONS = [
|
|
42662
43232
|
"query",
|
|
42663
43233
|
"page",
|
|
@@ -42728,6 +43298,14 @@ async function runRows(input, deps) {
|
|
|
42728
43298
|
}
|
|
42729
43299
|
rows.sort((a, b) => dateAt >= 0 ? String(a.date).localeCompare(String(b.date)) : Number(b.clicks ?? 0) - Number(a.clicks ?? 0));
|
|
42730
43300
|
const notes = [];
|
|
43301
|
+
if (dimensions.indexOf("query") >= 0) {
|
|
43302
|
+
const flagged = rows.filter((row) => spamHint(String(row.query ?? "")));
|
|
43303
|
+
if (flagged.length) {
|
|
43304
|
+
for (const row of rows) row.spam_hint = spamHint(String(row.query ?? ""));
|
|
43305
|
+
const impressions = flagged.reduce((sum, row) => sum + Number(row.impressions ?? 0), 0);
|
|
43306
|
+
notes.push(`${flagged.length} of ${rows.length} queries look like spam or tool noise (spam_hint, a heuristic over the query text: host names on spam-heavy TLDs, rank tracker test queries, pasted exports), with ${impressions} impressions. They stay in the rows and the totals; Google may or may not count them as real searches.`);
|
|
43307
|
+
}
|
|
43308
|
+
}
|
|
42731
43309
|
if (!dimensions.includes("query") && !dimensions.includes("page")) {
|
|
42732
43310
|
const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
|
42733
43311
|
notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
|
|
@@ -42843,24 +43421,29 @@ const hostOf$2 = (url) => {
|
|
|
42843
43421
|
return null;
|
|
42844
43422
|
}
|
|
42845
43423
|
};
|
|
43424
|
+
const PLACEHOLDERS = [
|
|
43425
|
+
"example.com",
|
|
43426
|
+
"example.org",
|
|
43427
|
+
"example.net"
|
|
43428
|
+
];
|
|
43429
|
+
const PLACEHOLDER = /^your-?(domain|site|url|website|company|blog)(\.[a-z]+)?$/;
|
|
43430
|
+
const isPlaceholder = (host) => PLACEHOLDER.test(host) || PLACEHOLDERS.some((name) => host === name || host.endsWith(`.${name}`));
|
|
42846
43431
|
const NOT_SOURCES = [
|
|
42847
43432
|
"dataforseo.com",
|
|
42848
43433
|
"images.openai.com",
|
|
42849
43434
|
"gstatic.com",
|
|
42850
43435
|
"googleusercontent.com",
|
|
42851
|
-
"ggpht.com"
|
|
42852
|
-
"example.com",
|
|
42853
|
-
"example.org",
|
|
42854
|
-
"example.net"
|
|
43436
|
+
"ggpht.com"
|
|
42855
43437
|
];
|
|
42856
43438
|
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
|
|
42857
43439
|
const isSource = (url) => {
|
|
42858
43440
|
const host = hostOf$2(url);
|
|
42859
|
-
if (!host || IMAGE.test(url)) return false;
|
|
43441
|
+
if (!host || IMAGE.test(url) || isPlaceholder(host)) return false;
|
|
42860
43442
|
return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
|
|
42861
43443
|
};
|
|
42862
43444
|
const empty = {
|
|
42863
43445
|
cited_url: null,
|
|
43446
|
+
mentioned_as: null,
|
|
42864
43447
|
sources: [],
|
|
42865
43448
|
model: null,
|
|
42866
43449
|
answered_at: null
|
|
@@ -42882,12 +43465,14 @@ function judge(engine, task, site) {
|
|
|
42882
43465
|
...empty,
|
|
42883
43466
|
answered_at
|
|
42884
43467
|
};
|
|
42885
|
-
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
42886
|
-
const all = [...shown.sources, ...linked];
|
|
42887
|
-
const
|
|
43468
|
+
const linked = (shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? []).map((url) => url.replace(/[`'"‘’“”()[\]{}<>.,;:!?*]+$/, ""));
|
|
43469
|
+
const all = [...shown.sources, ...linked].filter(isSource);
|
|
43470
|
+
const onTarget = (url) => site.domains.some((domain) => onSite(url, domain));
|
|
43471
|
+
const cited = all.find(onTarget) ?? null;
|
|
42888
43472
|
const base = {
|
|
42889
43473
|
cited_url: cited,
|
|
42890
|
-
|
|
43474
|
+
mentioned_as: null,
|
|
43475
|
+
sources: [...new Set(all.filter((url) => !onTarget(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
|
|
42891
43476
|
model: shown.model,
|
|
42892
43477
|
answered_at
|
|
42893
43478
|
};
|
|
@@ -42896,9 +43481,12 @@ function judge(engine, task, site) {
|
|
|
42896
43481
|
...base
|
|
42897
43482
|
};
|
|
42898
43483
|
const text = prose(shown.markdown);
|
|
43484
|
+
const lower = text.toLowerCase();
|
|
43485
|
+
const mentioned_as = site.names.find((name) => lower.includes(name)) ?? site.terms.find((term) => isBrandQuery(text, [term])) ?? null;
|
|
42899
43486
|
return {
|
|
42900
|
-
result:
|
|
42901
|
-
...base
|
|
43487
|
+
result: mentioned_as ? "mentioned" : "neither",
|
|
43488
|
+
...base,
|
|
43489
|
+
mentioned_as
|
|
42902
43490
|
};
|
|
42903
43491
|
}
|
|
42904
43492
|
async function marketsFor(keys, country) {
|
|
@@ -42926,9 +43514,22 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42926
43514
|
const keys = dataForSeoKeys(headers);
|
|
42927
43515
|
const domain = registrableDomain(input.site)?.domain;
|
|
42928
43516
|
if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
|
|
43517
|
+
const domains = [domain];
|
|
43518
|
+
for (const value of input.also ?? []) {
|
|
43519
|
+
const other = registrableDomain(value)?.domain;
|
|
43520
|
+
if (!other) throw new CommandError("INVALID_INPUT", `ai_answers takes domains in also, not "${value}".`);
|
|
43521
|
+
if (!domains.includes(other)) domains.push(other);
|
|
43522
|
+
}
|
|
43523
|
+
if (domains.length > 5) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 4 other domains in also.`);
|
|
43524
|
+
const names = domains.filter((name) => !isPlaceholder(name));
|
|
43525
|
+
const brand = [...new Set((input.brand ?? []).map((b) => b.trim()).filter(Boolean))];
|
|
42929
43526
|
const site = {
|
|
42930
|
-
|
|
42931
|
-
|
|
43527
|
+
domains,
|
|
43528
|
+
names,
|
|
43529
|
+
terms: [...new Set(brand.length ? brand.flatMap((name) => deriveBrandTerms({
|
|
43530
|
+
name,
|
|
43531
|
+
siteUrl: ""
|
|
43532
|
+
})) : names.flatMap((name) => deriveBrandTerms({ siteUrl: name })))]
|
|
42932
43533
|
};
|
|
42933
43534
|
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
42934
43535
|
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
@@ -43056,8 +43657,9 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
43056
43657
|
`Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}, not ready ${count("pending")}. DataForSEO cost: $${cost}.`,
|
|
43057
43658
|
...left.length ? [`${left.length === 1 ? "One answer wasn't" : `${left.length} answers weren't`} ready yet. They're paid for: ask ai_answers again with the same target and tasks ${JSON.stringify(left)} in a minute to read them at no cost (DataForSEO keeps them for 30 days).`] : [],
|
|
43058
43659
|
...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
|
|
43660
|
+
...domains.filter((name) => !names.includes(name)).map((name) => `${name} is a placeholder domain that answers write into examples, so no answer counts as citing or mentioning it.`),
|
|
43059
43661
|
"Each row is one sample of an answer that changes from run to run, not a ranking.",
|
|
43060
|
-
`cited: the answer lists or links a page on ${
|
|
43662
|
+
`cited: the answer lists or links a page on ${domains.join(", ")} or a subdomain. mentioned: no such source, but the answer text names ${[...names, ...site.terms].join(" or ") || "nothing to match"}, a text match (brand names ${brand.length ? "as given" : "from the domain names; pass brand names to match others"}); mentioned_as is the one found. no_answer: the engine showed no AI answer for the question. sources: the other sites the answer cites, up to ${MAX_SOURCES}. model: the model DataForSEO reports for the answer, when it reports one.`,
|
|
43061
43663
|
`Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
|
|
43062
43664
|
] : []];
|
|
43063
43665
|
const shown = rows.map((row) => ({
|
|
@@ -43066,6 +43668,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
43066
43668
|
engine: row.engine,
|
|
43067
43669
|
result: row.result,
|
|
43068
43670
|
cited_url: row.cited_url,
|
|
43671
|
+
mentioned_as: row.mentioned_as,
|
|
43069
43672
|
sources: row.sources.join(", "),
|
|
43070
43673
|
model: row.model,
|
|
43071
43674
|
answered_at: row.answered_at,
|
|
@@ -43093,6 +43696,9 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
43093
43696
|
...range
|
|
43094
43697
|
},
|
|
43095
43698
|
questions: [...new Set(rows.map((row) => row.question))],
|
|
43699
|
+
domains,
|
|
43700
|
+
brandTerms: site.terms,
|
|
43701
|
+
brandTermsFrom: brand.length ? "given" : "domains",
|
|
43096
43702
|
costUsd: cost,
|
|
43097
43703
|
pendingTasks: left,
|
|
43098
43704
|
tasks: refs,
|
|
@@ -44543,7 +45149,7 @@ function labelOf(e) {
|
|
|
44543
45149
|
};
|
|
44544
45150
|
if (!last) return {
|
|
44545
45151
|
label: "fix",
|
|
44546
|
-
why: `The redirects loop or pass
|
|
45152
|
+
why: `The redirects loop or pass 10 hops (${hops.map((hop) => hop.status).join(" > ")}).`
|
|
44547
45153
|
};
|
|
44548
45154
|
if (last.error) return {
|
|
44549
45155
|
label: "check",
|
|
@@ -44613,7 +45219,7 @@ function labelOf(e) {
|
|
|
44613
45219
|
why: `${coverage.startsWith("Crawled") ? "Google crawled it but has not indexed it, and gives no further reason" : coverage.startsWith("Discovered") ? "Google knows the URL but has not crawled it yet" : "Google has no record of this URL"}.${e.inSitemap ? " The sitemap lists it." : ""} It returns ${end} now.`
|
|
44614
45220
|
};
|
|
44615
45221
|
const robots = last.robots ? ` with robots "${last.robots}"` : " with no robots meta tag or X-Robots-Tag header";
|
|
44616
|
-
if (/noindex/i.test(coverage) &&
|
|
45222
|
+
if (/noindex/i.test(coverage) && last.google_noindex === false) return {
|
|
44617
45223
|
label: "check",
|
|
44618
45224
|
why: `Google saw noindex when it last crawled${status.last_crawl ? ` (${status.last_crawl.slice(0, 10)})` : ""}, but it ${now}${robots}, so Google's reason may be out of date.${e.inSitemap ? " The sitemap lists it." : ""}`
|
|
44619
45225
|
};
|
|
@@ -44639,7 +45245,7 @@ function labelOf(e) {
|
|
|
44639
45245
|
};
|
|
44640
45246
|
return {
|
|
44641
45247
|
label: "check",
|
|
44642
|
-
why:
|
|
45248
|
+
why: last.google_noindex ? `${via}${robots} and the sitemap does not list it. Whether it should be indexed is the site's call.` : `${via}${robots}. No rule here settles this reason.`
|
|
44643
45249
|
};
|
|
44644
45250
|
}
|
|
44645
45251
|
function indexingRow(e, sources, inspectedAt) {
|
|
@@ -45507,38 +46113,79 @@ async function markIssues(input, deps) {
|
|
|
45507
46113
|
};
|
|
45508
46114
|
}
|
|
45509
46115
|
// packages/core/dist/commands/lint.js
|
|
45510
|
-
async function
|
|
46116
|
+
async function pageSource(url, readPage) {
|
|
46117
|
+
const page = await readPage(url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
|
|
46118
|
+
if ("error" in page) return {
|
|
46119
|
+
path: url,
|
|
46120
|
+
skipped: `The page could not be read (${page.error}).`
|
|
46121
|
+
};
|
|
46122
|
+
if (!page.markdown) return {
|
|
46123
|
+
path: url,
|
|
46124
|
+
skipped: "The page has no main content to read."
|
|
46125
|
+
};
|
|
46126
|
+
return {
|
|
46127
|
+
path: url,
|
|
46128
|
+
text: page.markdown,
|
|
46129
|
+
note: "Checked the main content extracted from the live page (the Markdown `seo page --detail full` shows), not the whole HTML.",
|
|
46130
|
+
...page.truncated ? { partial: `the main content has ${page.characters.toLocaleString("en-US")} characters; the first ${page.markdown.length.toLocaleString("en-US")} were checked.` } : {}
|
|
46131
|
+
};
|
|
46132
|
+
}
|
|
46133
|
+
async function runLint(input, readFiles, readPage) {
|
|
45511
46134
|
const hasText = typeof input.text === "string" && input.text.length > 0;
|
|
45512
46135
|
const hasPaths = Boolean(input.paths?.length);
|
|
45513
|
-
|
|
46136
|
+
const hasUrl = typeof input.url === "string" && input.url.length > 0;
|
|
46137
|
+
if ([
|
|
46138
|
+
hasText,
|
|
46139
|
+
hasPaths,
|
|
46140
|
+
hasUrl
|
|
46141
|
+
].filter(Boolean).length !== 1) throw new CommandError("INVALID_INPUT", readFiles ? "Send one of text, paths to files on this machine, or a url." : "Send the copy to check as text, or a url.");
|
|
45514
46142
|
if (hasPaths && !readFiles) throw new CommandError("INVALID_INPUT", "This server cannot read files. Send the copy as text, or run `seo lint <files>` in the CLI.");
|
|
46143
|
+
if (hasUrl) {
|
|
46144
|
+
if (!/^https?:\/\/[^\s/]+/i.test(input.url)) throw new CommandError("INVALID_INPUT", "url is a full http or https address, such as https://example.com/page.");
|
|
46145
|
+
if (!readPage) throw new CommandError("INVALID_INPUT", `This server cannot read live pages. Run \`seo lint --url ${input.url}\` in the CLI or the local MCP server, or send the page copy as text.`);
|
|
46146
|
+
if (input.format && input.format !== "md") throw new CommandError("INVALID_INPUT", "A url is checked as the Markdown of its main content, so format is md or left out.");
|
|
46147
|
+
}
|
|
45515
46148
|
if (hasText && input.text.length > 2e5) throw new CommandError("INVALID_INPUT", `text is ${input.text.length.toLocaleString("en-US")} characters; the most one check reads is ${LINT_MAX_CHARS.toLocaleString("en-US")}. Send one page at a time.`);
|
|
46149
|
+
const only = input.only?.length ? LINT_CATEGORIES.filter((category) => input.only?.includes(category)) : void 0;
|
|
45516
46150
|
const files = (hasText ? [{
|
|
45517
46151
|
path: "text",
|
|
45518
46152
|
text: input.text
|
|
45519
|
-
}] : await readFiles(input.paths ?? [])).map((source) => {
|
|
46153
|
+
}] : hasUrl ? [await pageSource(input.url, readPage)] : await readFiles(input.paths ?? [])).map((source) => {
|
|
45520
46154
|
if ("skipped" in source) return source;
|
|
45521
|
-
const format = input.format ?? (hasText ? "
|
|
46155
|
+
const format = input.format ?? (hasPaths ? formatFromPath(source.path) : hasText && input.preset === "meta" ? "text" : "md");
|
|
45522
46156
|
return {
|
|
45523
46157
|
path: source.path,
|
|
46158
|
+
...source.note ? { note: source.note } : {},
|
|
46159
|
+
...source.partial ? { partial: source.partial } : {},
|
|
45524
46160
|
result: lintText(source.text, {
|
|
45525
46161
|
format,
|
|
45526
46162
|
preset: input.preset ?? defaultPreset(format),
|
|
45527
46163
|
ignoreRules: input.ignoreRules,
|
|
45528
|
-
ignorePhrases: input.ignorePhrases
|
|
46164
|
+
ignorePhrases: input.ignorePhrases,
|
|
46165
|
+
only,
|
|
46166
|
+
metaKind: input.metaKind
|
|
45529
46167
|
})
|
|
45530
46168
|
};
|
|
45531
46169
|
});
|
|
46170
|
+
const checked = files.flatMap((file) => "result" in file ? [file] : []);
|
|
46171
|
+
const total = (count) => checked.reduce((sum, file) => sum + count(file), 0);
|
|
45532
46172
|
return {
|
|
45533
46173
|
text: lintReportText(files),
|
|
45534
46174
|
data: {
|
|
45535
46175
|
files: files.map((file) => "skipped" in file ? file : {
|
|
45536
46176
|
path: file.path,
|
|
46177
|
+
...file.note ? { note: file.note } : {},
|
|
46178
|
+
...file.partial ? { partial: file.partial } : {},
|
|
45537
46179
|
...file.result
|
|
45538
46180
|
}),
|
|
45539
|
-
errors:
|
|
45540
|
-
warnings:
|
|
45541
|
-
review:
|
|
46181
|
+
errors: total((file) => file.result.errors),
|
|
46182
|
+
warnings: total((file) => file.result.warnings),
|
|
46183
|
+
review: total((file) => file.result.review.length),
|
|
46184
|
+
checked: checked.length,
|
|
46185
|
+
skipped: files.length - checked.length,
|
|
46186
|
+
partial: checked.filter((file) => file.partial).length,
|
|
46187
|
+
categories: Object.fromEntries(LINT_CATEGORIES.map((category) => [category, total((file) => file.result.categories[category])])),
|
|
46188
|
+
...only ? { only } : {}
|
|
45542
46189
|
}
|
|
45543
46190
|
};
|
|
45544
46191
|
}
|
|
@@ -45546,7 +46193,8 @@ async function runLint(input, readFiles) {
|
|
|
45546
46193
|
const MAX_PAGES = 20;
|
|
45547
46194
|
const TIMEOUT_MS$2 = 6e3;
|
|
45548
46195
|
const MAX_BYTES$3 = 1e6;
|
|
45549
|
-
const clean = (value) => value
|
|
46196
|
+
const clean = (value) => value === null ? null : value.trim().replace(/\s+/g, " ");
|
|
46197
|
+
const BOT_META = /^([a-z][a-z0-9_-]*bot(-[a-z]+)?|slurp|yandex|baiduspider)$/;
|
|
45550
46198
|
async function boundedText(response, maxBytes = MAX_BYTES$3) {
|
|
45551
46199
|
if (!response.body) return "";
|
|
45552
46200
|
const reader = response.body.getReader();
|
|
@@ -45563,8 +46211,8 @@ async function boundedText(response, maxBytes = MAX_BYTES$3) {
|
|
|
45563
46211
|
return text;
|
|
45564
46212
|
}
|
|
45565
46213
|
function headFacts(html, url) {
|
|
45566
|
-
let title =
|
|
45567
|
-
let h1 =
|
|
46214
|
+
let title = null;
|
|
46215
|
+
let h1 = null;
|
|
45568
46216
|
let inTitle = false;
|
|
45569
46217
|
let inH1 = false;
|
|
45570
46218
|
let titleDone = false;
|
|
@@ -45572,14 +46220,25 @@ function headFacts(html, url) {
|
|
|
45572
46220
|
let description = null;
|
|
45573
46221
|
let canonical = null;
|
|
45574
46222
|
const robots = [];
|
|
46223
|
+
const google = [];
|
|
45575
46224
|
const parser = new Parser({
|
|
45576
46225
|
onopentag(name, attributes) {
|
|
45577
|
-
if (name === "title" && !titleDone)
|
|
45578
|
-
|
|
46226
|
+
if (name === "title" && !titleDone) {
|
|
46227
|
+
inTitle = true;
|
|
46228
|
+
title ??= "";
|
|
46229
|
+
}
|
|
46230
|
+
if (name === "h1" && !h1Done) {
|
|
46231
|
+
inH1 = true;
|
|
46232
|
+
h1 ??= "";
|
|
46233
|
+
}
|
|
45579
46234
|
if (name === "meta") {
|
|
45580
|
-
const metaName = attributes.name?.toLowerCase();
|
|
45581
|
-
|
|
45582
|
-
if (metaName === "
|
|
46235
|
+
const metaName = attributes.name?.trim().toLowerCase() ?? "";
|
|
46236
|
+
const content = attributes.content?.trim();
|
|
46237
|
+
if (metaName === "description" && description === null) description = attributes.content ?? "";
|
|
46238
|
+
if (content && (metaName === "robots" || BOT_META.test(metaName))) {
|
|
46239
|
+
robots.push(metaName === "robots" ? content : `${metaName}: ${content}`);
|
|
46240
|
+
if (metaName === "robots" || metaName === "googlebot") google.push(content);
|
|
46241
|
+
}
|
|
45583
46242
|
}
|
|
45584
46243
|
if (name === "link" && canonical === null && attributes.rel?.toLowerCase().split(/\s+/).includes("canonical") && attributes.href) try {
|
|
45585
46244
|
canonical = new URL(attributes.href, url).toString();
|
|
@@ -45588,8 +46247,8 @@ function headFacts(html, url) {
|
|
|
45588
46247
|
}
|
|
45589
46248
|
},
|
|
45590
46249
|
ontext(text) {
|
|
45591
|
-
if (inTitle) title
|
|
45592
|
-
if (inH1) h1
|
|
46250
|
+
if (inTitle) title = (title ?? "") + text;
|
|
46251
|
+
if (inH1) h1 = (h1 ?? "") + text;
|
|
45593
46252
|
},
|
|
45594
46253
|
onclosetag(name) {
|
|
45595
46254
|
if (name === "title" && inTitle) {
|
|
@@ -45612,10 +46271,11 @@ function headFacts(html, url) {
|
|
|
45612
46271
|
description: clean(description),
|
|
45613
46272
|
h1: clean(h1),
|
|
45614
46273
|
canonical,
|
|
45615
|
-
robots: robots.length ? robots.join(", ") : null
|
|
46274
|
+
robots: robots.length ? robots.join(", ") : null,
|
|
46275
|
+
google_noindex: hasMetaRobotsDirective(google, "noindex")
|
|
45616
46276
|
};
|
|
45617
46277
|
}
|
|
45618
|
-
async function facts(url) {
|
|
46278
|
+
async function facts(url, given) {
|
|
45619
46279
|
const out = {
|
|
45620
46280
|
url,
|
|
45621
46281
|
status: null,
|
|
@@ -45624,10 +46284,11 @@ async function facts(url) {
|
|
|
45624
46284
|
description: null,
|
|
45625
46285
|
h1: null,
|
|
45626
46286
|
canonical: null,
|
|
45627
|
-
robots: null
|
|
46287
|
+
robots: null,
|
|
46288
|
+
google_noindex: null
|
|
45628
46289
|
};
|
|
45629
46290
|
try {
|
|
45630
|
-
const response = await fetch(url, {
|
|
46291
|
+
const response = given ?? await fetch(url, {
|
|
45631
46292
|
redirect: "manual",
|
|
45632
46293
|
headers: {
|
|
45633
46294
|
"user-agent": SEO_CRAWLER_USER_AGENT,
|
|
@@ -45639,53 +46300,71 @@ async function facts(url) {
|
|
|
45639
46300
|
const location = response.headers.get("location");
|
|
45640
46301
|
if (location) out.redirects_to = new URL(location, url).toString();
|
|
45641
46302
|
const headerRobots = response.headers.get("x-robots-tag");
|
|
46303
|
+
const headerNoindex = hasXRobotsDirective(headerRobots ?? void 0, "noindex");
|
|
45642
46304
|
const auth = response.headers.get("www-authenticate");
|
|
45643
46305
|
if (auth) out.www_authenticate = auth.slice(0, 200);
|
|
45644
46306
|
if (response.status !== 200 || !response.headers.get("content-type")?.includes("html")) {
|
|
45645
46307
|
out.robots = headerRobots;
|
|
46308
|
+
out.google_noindex = headerNoindex;
|
|
45646
46309
|
await response.body?.cancel();
|
|
45647
46310
|
return out;
|
|
45648
46311
|
}
|
|
45649
46312
|
const head = headFacts(await boundedText(response), url);
|
|
45650
46313
|
Object.assign(out, head);
|
|
45651
46314
|
out.robots = [headerRobots, head.robots].filter(Boolean).join(", ") || null;
|
|
46315
|
+
out.google_noindex = headerNoindex || head.google_noindex;
|
|
45652
46316
|
} catch (error) {
|
|
45653
46317
|
out.error = error instanceof Error ? error.message : "fetch failed";
|
|
45654
46318
|
}
|
|
45655
46319
|
return out;
|
|
45656
46320
|
}
|
|
45657
46321
|
function checkPages(urls) {
|
|
45658
|
-
return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
|
|
46322
|
+
return Promise.all(urls.slice(0, MAX_PAGES).map((url) => facts(url)));
|
|
45659
46323
|
}
|
|
45660
|
-
const MAX_HOPS = 5;
|
|
45661
46324
|
const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
|
|
45662
46325
|
async function liveCheck(url) {
|
|
45663
|
-
const
|
|
45664
|
-
const
|
|
45665
|
-
|
|
45666
|
-
|
|
45667
|
-
|
|
45668
|
-
|
|
45669
|
-
|
|
45670
|
-
|
|
45671
|
-
|
|
45672
|
-
|
|
45673
|
-
|
|
45674
|
-
|
|
46326
|
+
const pages = [];
|
|
46327
|
+
const trace = await traceRedirects(url, {
|
|
46328
|
+
timeoutMs: TIMEOUT_MS$2,
|
|
46329
|
+
onResponse: async (response, at) => {
|
|
46330
|
+
pages.push(await facts(at, response));
|
|
46331
|
+
}
|
|
46332
|
+
});
|
|
46333
|
+
const hops = trace.steps.flatMap((step) => {
|
|
46334
|
+
const kind = redirectKind(step.status);
|
|
46335
|
+
return step.location && kind ? [{
|
|
46336
|
+
from: step.url,
|
|
46337
|
+
status: step.status,
|
|
46338
|
+
to: step.location,
|
|
45675
46339
|
kind
|
|
45676
|
-
}
|
|
45677
|
-
|
|
45678
|
-
|
|
45679
|
-
|
|
45680
|
-
|
|
45681
|
-
|
|
45682
|
-
|
|
45683
|
-
|
|
45684
|
-
|
|
46340
|
+
}] : [];
|
|
46341
|
+
});
|
|
46342
|
+
if (trace.outcome === "loop" || trace.outcome === "too_many") return {
|
|
46343
|
+
first: pages[0],
|
|
46344
|
+
hops,
|
|
46345
|
+
last: null
|
|
46346
|
+
};
|
|
46347
|
+
let last = pages.at(-1);
|
|
46348
|
+
if (trace.error) last = trace.final_status !== null && last ? {
|
|
46349
|
+
...last,
|
|
46350
|
+
error: trace.error
|
|
46351
|
+
} : {
|
|
46352
|
+
url: trace.final_url,
|
|
46353
|
+
status: null,
|
|
46354
|
+
redirects_to: null,
|
|
46355
|
+
title: null,
|
|
46356
|
+
description: null,
|
|
46357
|
+
h1: null,
|
|
46358
|
+
canonical: null,
|
|
46359
|
+
robots: null,
|
|
46360
|
+
google_noindex: null,
|
|
46361
|
+
error: trace.error
|
|
46362
|
+
};
|
|
46363
|
+
const lastPage = last;
|
|
45685
46364
|
return {
|
|
45686
|
-
first,
|
|
46365
|
+
first: pages.length > 1 || !trace.error ? pages[0] : lastPage,
|
|
45687
46366
|
hops,
|
|
45688
|
-
last
|
|
46367
|
+
last: lastPage
|
|
45689
46368
|
};
|
|
45690
46369
|
}
|
|
45691
46370
|
function temporaryRedirectNote(pages) {
|
|
@@ -46271,7 +46950,15 @@ function reportUrls(rows, origin) {
|
|
|
46271
46950
|
return found;
|
|
46272
46951
|
}
|
|
46273
46952
|
function pageFactsTable(checked, origin) {
|
|
46274
|
-
const rows = table$1(checked
|
|
46953
|
+
const rows = table$1(checked.map((page) => {
|
|
46954
|
+
const row = { ...page };
|
|
46955
|
+
for (const key of [
|
|
46956
|
+
"title",
|
|
46957
|
+
"description",
|
|
46958
|
+
"h1"
|
|
46959
|
+
]) if (page[key] === "") row[key] = "(empty)";
|
|
46960
|
+
return row;
|
|
46961
|
+
}), [
|
|
46275
46962
|
"url",
|
|
46276
46963
|
"status",
|
|
46277
46964
|
"redirects_to",
|
|
@@ -46499,6 +47186,38 @@ async function runBingReport(input, deps) {
|
|
|
46499
47186
|
data: report
|
|
46500
47187
|
};
|
|
46501
47188
|
}
|
|
47189
|
+
// packages/core/dist/commands/research-cache.js
|
|
47190
|
+
const RESEARCH_TTL_SECONDS = 86400;
|
|
47191
|
+
function workersResearchStore() {
|
|
47192
|
+
const cache = globalThis.caches?.default;
|
|
47193
|
+
if (!cache) return null;
|
|
47194
|
+
const request = (key) => new Request(`https://research-cache.invalid/${key}`);
|
|
47195
|
+
return {
|
|
47196
|
+
get: async (key) => (await cache.match(request(key)))?.text() ?? null,
|
|
47197
|
+
put: (key, value, ttl) => cache.put(request(key), new Response(value, { headers: { "cache-control": `max-age=${ttl}` } }))
|
|
47198
|
+
};
|
|
47199
|
+
}
|
|
47200
|
+
async function researchKey(parts) {
|
|
47201
|
+
const bytes = new TextEncoder().encode(JSON.stringify(parts));
|
|
47202
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
47203
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47204
|
+
}
|
|
47205
|
+
async function readResearch(store, key, limit) {
|
|
47206
|
+
if (!store) return null;
|
|
47207
|
+
try {
|
|
47208
|
+
const text = await store.get(key);
|
|
47209
|
+
if (!text) return null;
|
|
47210
|
+
const saved = JSON.parse(text);
|
|
47211
|
+
const complete = saved.result.rows.length < saved.limit;
|
|
47212
|
+
return saved.limit >= limit || complete ? saved : null;
|
|
47213
|
+
} catch {
|
|
47214
|
+
return null;
|
|
47215
|
+
}
|
|
47216
|
+
}
|
|
47217
|
+
async function saveResearch(store, key, saved) {
|
|
47218
|
+
if (!store) return;
|
|
47219
|
+
await store.put(key, JSON.stringify(saved), RESEARCH_TTL_SECONDS).catch(() => void 0);
|
|
47220
|
+
}
|
|
46502
47221
|
// packages/core/dist/commands/research.js
|
|
46503
47222
|
const RESEARCH_ACTIONS = [
|
|
46504
47223
|
"keyword_metrics",
|
|
@@ -46507,10 +47226,14 @@ const RESEARCH_ACTIONS = [
|
|
|
46507
47226
|
"competitors",
|
|
46508
47227
|
"backlinks",
|
|
46509
47228
|
"link_gap",
|
|
46510
|
-
"ai_answers"
|
|
47229
|
+
"ai_answers",
|
|
47230
|
+
"top_pages",
|
|
47231
|
+
"pages_by_backlinks",
|
|
47232
|
+
"keyword_ideas",
|
|
47233
|
+
"serp_overlap"
|
|
46511
47234
|
];
|
|
46512
47235
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
46513
|
-
const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a
|
|
47236
|
+
const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages, each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
|
|
46514
47237
|
const GENERIC_PLATFORMS = [
|
|
46515
47238
|
"youtube.com",
|
|
46516
47239
|
"facebook.com",
|
|
@@ -46548,47 +47271,108 @@ function realCompetitors(rows, target) {
|
|
|
46548
47271
|
note: `Left out ${gone} ${gone === 1 ? "row" : "rows"}: ${reasons.join(" and ")}. The fixed platform list is ${GENERIC_PLATFORMS.join(", ")}.`
|
|
46549
47272
|
};
|
|
46550
47273
|
}
|
|
47274
|
+
const KEY_HEADER = {
|
|
47275
|
+
dataforseo: "x-dataforseo-login",
|
|
47276
|
+
ahrefs: "x-ahrefs-key"
|
|
47277
|
+
};
|
|
47278
|
+
function chooseProvider(asked, keys) {
|
|
47279
|
+
return asked ?? RESEARCH_PROVIDERS.find((name) => keys.has(KEY_HEADER[name])) ?? "dataforseo";
|
|
47280
|
+
}
|
|
47281
|
+
const VOLUME_ACTIONS = ["keyword_metrics", "keyword_ideas"];
|
|
47282
|
+
const VOLUME_MODEL = {
|
|
47283
|
+
dataforseo: "DataForSEO search_volume is Google Ads' average monthly searches over the last 12 months, rounded into Google's bands.",
|
|
47284
|
+
ahrefs: "Ahrefs volume is Ahrefs' own estimate of average monthly searches, from clickstream data and its keyword database."
|
|
47285
|
+
};
|
|
47286
|
+
const VOLUMES_DIFFER = "The two providers can differ two to eight times for the same keyword, so compare keywords within one provider. A null volume is unknown, not zero.";
|
|
47287
|
+
const TRAFFIC_MODEL = {
|
|
47288
|
+
dataforseo: "DataForSEO etv (estimated traffic volume): each keyword's monthly search volume times DataForSEO's click-through rate for the ranking position, summed over the keywords in DataForSEO's database for this country",
|
|
47289
|
+
ahrefs: "Ahrefs organic traffic (sum_traffic, org_traffic): Ahrefs' estimate of monthly organic visits from its own keyword database, volumes and click-through model, for this country"
|
|
47290
|
+
};
|
|
47291
|
+
const PROVIDERS_DIFFER = "Providers track different keywords and use different click models, so their traffic for the same site or page can differ tenfold or more. Compare a site with itself in one provider, not across providers.";
|
|
46551
47292
|
async function runResearch(input, keys, options) {
|
|
46552
47293
|
const country = input.country ?? "us";
|
|
46553
47294
|
const target = input.target ?? "";
|
|
46554
47295
|
const offset = input.offset ?? 0;
|
|
46555
|
-
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
46556
47296
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
46557
|
-
const
|
|
47297
|
+
const provider = chooseProvider(input.provider, keys);
|
|
47298
|
+
const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
47299
|
+
const scope = [
|
|
47300
|
+
"ranked_keywords",
|
|
47301
|
+
"top_pages",
|
|
47302
|
+
"backlinks",
|
|
47303
|
+
"pages_by_backlinks"
|
|
47304
|
+
].includes(input.action) && target ? researchScope(target, input.scope) : null;
|
|
47305
|
+
const limit = Math.min(1e3, Math.max(100, offset + 50));
|
|
47306
|
+
const request = {
|
|
46558
47307
|
action: input.action,
|
|
46559
47308
|
keywords: input.keywords ?? [],
|
|
46560
47309
|
target,
|
|
47310
|
+
...scope ? { scope } : {},
|
|
46561
47311
|
competitors: input.competitors ?? [],
|
|
46562
47312
|
country,
|
|
46563
|
-
|
|
47313
|
+
...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
|
|
47314
|
+
};
|
|
47315
|
+
const store = options.store ?? workersResearchStore();
|
|
47316
|
+
const key = await researchKey([
|
|
47317
|
+
provider,
|
|
47318
|
+
provider === "dataforseo" ? keys.get("x-dataforseo-login") : keys.get("x-ahrefs-key"),
|
|
47319
|
+
request
|
|
47320
|
+
]);
|
|
47321
|
+
const saved = await readResearch(store, key, limit);
|
|
47322
|
+
const result = saved?.result ?? await research(provider, keys, {
|
|
47323
|
+
...request,
|
|
47324
|
+
limit
|
|
47325
|
+
});
|
|
47326
|
+
if (!saved) await saveResearch(store, key, {
|
|
47327
|
+
limit,
|
|
47328
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47329
|
+
result
|
|
46564
47330
|
});
|
|
47331
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
|
|
47332
|
+
const costUsd = saved ? 0 : paid;
|
|
47333
|
+
const order = new Map(request.keywords.map((word, index) => [word, index]));
|
|
47334
|
+
const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 1e9) - Number(b.position ?? 1e9) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
|
|
46565
47335
|
const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
|
|
46566
47336
|
rows: found,
|
|
46567
47337
|
note: null
|
|
46568
47338
|
};
|
|
46569
47339
|
const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
|
|
46570
47340
|
const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
|
|
46571
|
-
const byDr = input.action === "link_gap" &&
|
|
47341
|
+
const byDr = input.action === "link_gap" && provider === "dataforseo" && drKey;
|
|
46572
47342
|
const rated = byDr ? await sortedByDomainRating(rows, drKey) : await withDomainRating(rows, drKey, offset);
|
|
46573
47343
|
const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
|
|
46574
47344
|
const hasTargetDr = targetDr !== void 0 && targetDr !== null;
|
|
47345
|
+
const detail = input.detail ?? "summary";
|
|
47346
|
+
const size = detail === "full" ? Math.max(rated.rows.length, 1) : 50;
|
|
47347
|
+
const shown = rated.rows.slice(offset, offset + size);
|
|
47348
|
+
const next = offset + shown.length < rated.rows.length ? offset + shown.length : null;
|
|
47349
|
+
const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
|
|
46575
47350
|
return {
|
|
46576
47351
|
text: rowsText({
|
|
46577
|
-
title: `${
|
|
47352
|
+
title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
|
|
46578
47353
|
rows: rated.rows,
|
|
46579
47354
|
offset,
|
|
46580
|
-
limit:
|
|
46581
|
-
detail
|
|
47355
|
+
limit: size,
|
|
47356
|
+
detail,
|
|
46582
47357
|
notes: [
|
|
46583
|
-
`${
|
|
46584
|
-
...
|
|
47358
|
+
`${provider} estimates, in the provider’s own units.`,
|
|
47359
|
+
...compared ? [`Compared: ${compared.withPage} of ${compared.results} top results had a real page, so shared_pages counts only those; ${compared.results - compared.withPage} came back as Google redirect links and could not be compared by page. ${compared.withSite} had a known site for shared_sites. Unique pages and sites are counted per keyword.${compared.withPage < compared.results / 2 && provider === "dataforseo" ? " With this few pages, shared_pages cannot settle one page or several; provider ahrefs returns real pages." : ""}`] : [],
|
|
47360
|
+
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
|
|
47361
|
+
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
47362
|
+
...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
|
|
47363
|
+
...available !== void 0 && available > found.length ? [`Capped: ${provider} has ${available.toLocaleString("en-US")} rows for this; this read the first ${found.length.toLocaleString("en-US")} in its order.`] : [],
|
|
47364
|
+
...!found.length && scope ? [emptyNote(provider, input.action)] : [],
|
|
47365
|
+
...input.action === "serp" && provider === "dataforseo" ? [rankFailed ? "domain_rank is missing: the DataForSEO rank call failed, so it is unknown, not zero." : "domain_rank is DataForSEO's own authority score for the domain, 0 to 100. It is not Ahrefs' Domain Rating."] : [],
|
|
47366
|
+
...input.action === "serp" && !drKey && provider === "dataforseo" ? ["No domain_rating column: Domain Rating by Ahrefs needs an Ahrefs key (or the hosted MCP at https://seoskill.dev/mcp, which adds it)."] : [],
|
|
47367
|
+
...store && rated.rows.length > 50 ? ["The result is kept for a day, so the next pages, and the same rows in full, cost nothing more."] : [],
|
|
47368
|
+
...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
46585
47369
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
46586
|
-
...missed.length ? [`Partial: ${
|
|
46587
|
-
...noData.length ? [noDataNote(input, noData)] : [],
|
|
47370
|
+
...missed.length ? [`Partial: ${provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
47371
|
+
...noData.length ? [noDataNote(provider, input.action, noData)] : [],
|
|
46588
47372
|
...byDr ? [rows.length > GAP_RATED ? `Sorted by Domain Rating for the top ${GAP_RATED} rows by DataForSEO rank; the ${rows.length - GAP_RATED} rows after them follow by rank, with no Domain Rating.` : "Sorted by Domain Rating, then DataForSEO rank."] : [],
|
|
46589
47373
|
...leftOut?.length ? [`Left out ${leftOut.length} link lists, URL shorteners and directories that link to most sites (a guess from the host name): ${leftOut.join(", ")}.`] : [],
|
|
46590
47374
|
...floor ? [`Cut at ${floor.field} ${floor.value}: at least one site has more than 1,000 referring domains, and only its top 1,000 were read, so domains below ${floor.field} ${floor.value} are left out rather than guessed.`] : [],
|
|
46591
|
-
...unresolved ? [unresolvedNote(
|
|
47375
|
+
...unresolved ? [unresolvedNote(unresolved, rows.length)] : [],
|
|
46592
47376
|
...note ? [note] : [],
|
|
46593
47377
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
46594
47378
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -46596,15 +47380,22 @@ async function runResearch(input, keys, options) {
|
|
|
46596
47380
|
more: options.call
|
|
46597
47381
|
}),
|
|
46598
47382
|
data: {
|
|
46599
|
-
provider
|
|
47383
|
+
provider,
|
|
46600
47384
|
action: input.action,
|
|
46601
47385
|
target: target || null,
|
|
47386
|
+
scope,
|
|
47387
|
+
siteTotal: siteTotal ? {
|
|
47388
|
+
...siteTotal,
|
|
47389
|
+
model: TRAFFIC_MODEL[provider]
|
|
47390
|
+
} : null,
|
|
47391
|
+
available: available ?? null,
|
|
46602
47392
|
country,
|
|
46603
47393
|
language,
|
|
46604
47394
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
46605
47395
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
46606
47396
|
leftOut: note,
|
|
46607
47397
|
costUsd,
|
|
47398
|
+
savedAt: saved?.savedAt ?? null,
|
|
46608
47399
|
partial,
|
|
46609
47400
|
missedKeywords: missed,
|
|
46610
47401
|
noDataKeywords: noData,
|
|
@@ -46613,19 +47404,40 @@ async function runResearch(input, keys, options) {
|
|
|
46613
47404
|
cutAt: floor ?? null,
|
|
46614
47405
|
leftOut: leftOut ?? []
|
|
46615
47406
|
} : {},
|
|
47407
|
+
...perKeyword ? { resultsPerKeyword: perKeyword } : {},
|
|
47408
|
+
...compared ? { compared } : {},
|
|
46616
47409
|
total: rated.rows.length,
|
|
46617
47410
|
offset,
|
|
46618
|
-
|
|
47411
|
+
next,
|
|
47412
|
+
rowsLeftOut: rated.rows.length - shown.length,
|
|
47413
|
+
rows: shown
|
|
46619
47414
|
}
|
|
46620
47415
|
};
|
|
46621
47416
|
}
|
|
46622
|
-
|
|
47417
|
+
const LINK_ACTIONS = [
|
|
47418
|
+
"backlinks",
|
|
47419
|
+
"pages_by_backlinks",
|
|
47420
|
+
"link_gap"
|
|
47421
|
+
];
|
|
47422
|
+
const SCOPE_TEXT = {
|
|
47423
|
+
domain: "the whole site with its subdomains",
|
|
47424
|
+
prefix: "every URL under this path",
|
|
47425
|
+
exact: "this page only"
|
|
47426
|
+
};
|
|
47427
|
+
function siteTotalNote(provider, total, shown) {
|
|
47428
|
+
const keywords = total.keywords !== null ? ` from ${total.keywords.toLocaleString("en-US")} keywords` : "";
|
|
47429
|
+
return `Total for the whole target: ${total.metric} ${total.traffic.toLocaleString("en-US")} a month${keywords} (the rows below are ${shown.toLocaleString("en-US")} of them). Model: ${TRAFFIC_MODEL[provider]}. ${PROVIDERS_DIFFER}`;
|
|
47430
|
+
}
|
|
47431
|
+
function emptyNote(provider, action) {
|
|
47432
|
+
return `No rows: ${provider} has no ${action === "backlinks" || action === "pages_by_backlinks" ? "links" : "rankings"} for this target in its database. That does not show the target has none; check the target and scope, or ask the other provider.`;
|
|
47433
|
+
}
|
|
47434
|
+
function noDataNote(provider, action, keywords) {
|
|
46623
47435
|
const list = keywords.join(", ");
|
|
46624
|
-
if (
|
|
46625
|
-
return `No data: ${
|
|
47436
|
+
if (action === "serp") return `No results: ${provider} got an empty results page for ${list}${provider === "dataforseo" ? ", twice" : ""}. Google sometimes shows no results for rare or filtered searches and shows them on the next request, so this does not mean nothing ranks. Ask again later${provider === "dataforseo" ? ", or use provider ahrefs" : ""}.`;
|
|
47437
|
+
return `No data: ${provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
|
|
46626
47438
|
}
|
|
46627
|
-
function unresolvedNote(
|
|
46628
|
-
return `${count} of ${total} results have no exact page:
|
|
47439
|
+
function unresolvedNote(count, total) {
|
|
47440
|
+
return `${count} of ${total} results have no exact page: DataForSEO returned Google redirect links for them. Their domain and url_hint come from Google's breadcrumb, which is shortened. For exact pages, use provider ahrefs.`;
|
|
46629
47441
|
}
|
|
46630
47442
|
const GAP_RATED = 200;
|
|
46631
47443
|
async function sortedByDomainRating(rows, key) {
|
|
@@ -46795,14 +47607,33 @@ async function runPage(input, deps) {
|
|
|
46795
47607
|
}]
|
|
46796
47608
|
});
|
|
46797
47609
|
const full = input.detail === "full";
|
|
46798
|
-
const
|
|
46799
|
-
|
|
46800
|
-
|
|
46801
|
-
|
|
46802
|
-
|
|
46803
|
-
|
|
46804
|
-
|
|
47610
|
+
const pending = /* @__PURE__ */ new Set();
|
|
47611
|
+
const leg = (name, work, done) => {
|
|
47612
|
+
const report = deps.progress;
|
|
47613
|
+
if (!report) return work;
|
|
47614
|
+
pending.add(name);
|
|
47615
|
+
const finish = (line) => {
|
|
47616
|
+
pending.delete(name);
|
|
47617
|
+
report(`${line}.${pending.size ? ` Waiting on ${[...pending].join(", ")}.` : ""}`);
|
|
47618
|
+
};
|
|
47619
|
+
return work.then((value) => {
|
|
47620
|
+
finish(done(value));
|
|
47621
|
+
return value;
|
|
47622
|
+
}, (error) => {
|
|
47623
|
+
finish(`${name} failed`);
|
|
47624
|
+
throw error;
|
|
47625
|
+
});
|
|
47626
|
+
};
|
|
47627
|
+
deps.progress?.(`Checking ${input.url}: the live page, Google index status, sitemaps and Search Console queries.`);
|
|
47628
|
+
const [live, inspection, sitemap, [now, then], markdown] = await Promise.all([
|
|
47629
|
+
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` : ""}`),
|
|
47630
|
+
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})`),
|
|
47631
|
+
leg("sitemaps", sitemapUrls(input.site), (value) => `Sitemaps: ${value.urls.length} URLs from ${value.files} ${value.files === 1 ? "file" : "files"}${value.partial ? " (partial)" : ""}`),
|
|
47632
|
+
leg("Search Console queries", Promise.all([query(range), query(before)]), ([current]) => `Search Console: ${current.rows.length} queries`),
|
|
47633
|
+
full && deps.pageMarkdown ? leg("main content", deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })), () => "Main content read") : null
|
|
46805
47634
|
]);
|
|
47635
|
+
const indexed = inspection.index;
|
|
47636
|
+
const indexError = inspection.error;
|
|
46806
47637
|
const facts = live.first;
|
|
46807
47638
|
const reason = indexed ? indexingRow({
|
|
46808
47639
|
url: input.url,
|
|
@@ -46849,8 +47680,9 @@ async function runPage(input, deps) {
|
|
|
46849
47680
|
title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
46850
47681
|
notes: [
|
|
46851
47682
|
`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"}.` : ""}`,
|
|
46852
|
-
indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` :
|
|
47683
|
+
indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
|
|
46853
47684
|
...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.)`] : [],
|
|
47685
|
+
...indexed && /^URL is unknown to Google$/i.test(indexed.coverage ?? "") ? [unknownNext(input, sitemap.urls.includes(input.url), deps)] : [],
|
|
46854
47686
|
`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.`,
|
|
46855
47687
|
deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
|
|
46856
47688
|
url: input.url,
|
|
@@ -46877,6 +47709,7 @@ async function runPage(input, deps) {
|
|
|
46877
47709
|
comparisonRange: before,
|
|
46878
47710
|
page: facts,
|
|
46879
47711
|
index: indexed,
|
|
47712
|
+
indexError,
|
|
46880
47713
|
reason,
|
|
46881
47714
|
totalQueries: rows.length,
|
|
46882
47715
|
queries: rows.slice(offset, offset + 50),
|
|
@@ -46884,6 +47717,56 @@ async function runPage(input, deps) {
|
|
|
46884
47717
|
}
|
|
46885
47718
|
};
|
|
46886
47719
|
}
|
|
47720
|
+
const INSPECT_TIMEOUT_MS = 6e4;
|
|
47721
|
+
async function inspectWithin(deps, input) {
|
|
47722
|
+
let timer;
|
|
47723
|
+
const stop = new AbortController();
|
|
47724
|
+
const timeout = new Promise((resolve) => {
|
|
47725
|
+
timer = setTimeout(() => {
|
|
47726
|
+
resolve({
|
|
47727
|
+
code: "TIMEOUT",
|
|
47728
|
+
message: `URL Inspection did not answer within ${INSPECT_TIMEOUT_MS / 1e3} seconds.`
|
|
47729
|
+
});
|
|
47730
|
+
stop.abort();
|
|
47731
|
+
}, INSPECT_TIMEOUT_MS);
|
|
47732
|
+
});
|
|
47733
|
+
try {
|
|
47734
|
+
return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => ({
|
|
47735
|
+
index,
|
|
47736
|
+
error: null
|
|
47737
|
+
})), timeout.then((error) => ({
|
|
47738
|
+
index: null,
|
|
47739
|
+
error
|
|
47740
|
+
}))]);
|
|
47741
|
+
} catch (error) {
|
|
47742
|
+
const known = commandErrorFrom(error);
|
|
47743
|
+
return {
|
|
47744
|
+
index: null,
|
|
47745
|
+
error: {
|
|
47746
|
+
code: known instanceof CommandError ? known.code : "GOOGLE_ERROR",
|
|
47747
|
+
message: known instanceof Error ? known.message : String(known)
|
|
47748
|
+
}
|
|
47749
|
+
};
|
|
47750
|
+
} finally {
|
|
47751
|
+
clearTimeout(timer);
|
|
47752
|
+
}
|
|
47753
|
+
}
|
|
47754
|
+
function indexErrorText(error, input, deps) {
|
|
47755
|
+
const again = deps.call("page", { url: input.url });
|
|
47756
|
+
const message = error?.message ?? "No reason was returned.";
|
|
47757
|
+
const why = {
|
|
47758
|
+
RATE_LIMITED: `Google's URL Inspection quota for ${input.site} stopped the check. ${message} Next: run ${again} again once the quota resets, or inspect the URL by hand in Search Console's URL Inspection tool.`,
|
|
47759
|
+
TIMEOUT: `${message} The rest of this report went ahead without it. Next: run ${again} again later, or inspect the URL by hand in Search Console's URL Inspection tool.`,
|
|
47760
|
+
NO_ACCESS: `The connected Google account cannot inspect URLs in ${input.site}. ${message} Next: check the account's access under Settings, Users and permissions in Search Console, or sign in with an account that has the property.`,
|
|
47761
|
+
NOT_CONNECTED: `The Google sign-in did not work. ${message} Next: sign in again, then run ${again}.`,
|
|
47762
|
+
NO_KEY: `Google sign-in is not set up here. ${message}`,
|
|
47763
|
+
INVALID_INPUT: `Google refused the request. ${message} Next: check that the URL belongs to ${input.site}.`
|
|
47764
|
+
};
|
|
47765
|
+
return `Google index status: not available. ${(error && why[error.code]) ?? `URL Inspection failed. ${message} Next: run ${again} again later.`}`;
|
|
47766
|
+
}
|
|
47767
|
+
function unknownNext(input, inSitemap, deps) {
|
|
47768
|
+
return `Next for a page Google does not know: seo cannot submit URLs to Google. Use Request indexing in Search Console's URL Inspection tool for this URL, and ${inSitemap ? "check that the Sitemaps report in Search Console has the sitemap that lists it, with a recent read date" : "add the URL to a sitemap and submit that sitemap in Search Console"}. Google finds pages through links, so ${deps.call("internal-links", { url: input.url })} lists pages that could link here. To see whether other URLs on the site are unknown too, run ${deps.call("index", { site: input.site })}.`;
|
|
47769
|
+
}
|
|
46887
47770
|
function markdownText(markdown, url) {
|
|
46888
47771
|
if (!markdown) return `Main content as Markdown: not on this server; it runs in the CLI and the local MCP server. ${CLI_LINK} Then run \`npx -y seo page --url ${url} --detail full\`.`;
|
|
46889
47772
|
if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
|
|
@@ -47354,9 +48237,12 @@ const listSites = async (surface) => runSites({
|
|
|
47354
48237
|
});
|
|
47355
48238
|
const lintInput = {
|
|
47356
48239
|
text: z.string().max(LINT_MAX_CHARS).optional().describe(`The copy to check: a page, post, title or description, up to ${LINT_MAX_CHARS.toLocaleString("en-US")} characters. Markdown, MDX or HTML as written, so line numbers match.`),
|
|
48240
|
+
url: z.url().optional().describe("Optional instead of text: a live page. Its main content is extracted as Markdown (as seo_page with detail full shows it) and checked with the blog preset. Only the CLI and the local MCP server read pages."),
|
|
47357
48241
|
format: z.enum(LINT_FORMATS).optional().describe("Optional. md (default for text), mdx, html, astro or text. Code, tags and URLs are skipped; captions and titles are checked."),
|
|
47358
|
-
preset: z.enum(LINT_PRESETS).optional().describe("Optional. blog (default for Markdown), landing (default for HTML: stricter on buzzwords, weak links and claims with no number), docs, or meta (one title or description per line)."),
|
|
48242
|
+
preset: z.enum(LINT_PRESETS).optional().describe("Optional. blog (default for Markdown), landing (default for HTML: stricter on buzzwords, weak links and claims with no number), docs, or meta (one title or description per line; start a line with \"title:\" or \"description:\" to say which)."),
|
|
48243
|
+
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."),
|
|
47359
48244
|
ignore_rules: z.array(z.string().min(1).max(60)).max(50).optional().describe("Optional rule ids or review lenses to skip."),
|
|
48245
|
+
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."),
|
|
47360
48246
|
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.")
|
|
47361
48247
|
};
|
|
47362
48248
|
const SHARED_TOOLS = [
|
|
@@ -47515,30 +48401,37 @@ const SHARED_TOOLS = [
|
|
|
47515
48401
|
title: "Keyword, competitor, backlink and AI answer data",
|
|
47516
48402
|
description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits. It uses the key the person saved on their seoskill.dev account (or on this machine for the CLI). Actions: ${RESEARCH_HELP}`,
|
|
47517
48403
|
input: z.object({
|
|
47518
|
-
provider: z.enum(RESEARCH_PROVIDERS),
|
|
48404
|
+
provider: z.enum(RESEARCH_PROVIDERS).optional().describe("Optional. Without it, the saved key; DataForSEO when both are saved."),
|
|
47519
48405
|
action: z.enum(RESEARCH_ACTIONS),
|
|
47520
48406
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
47521
|
-
target: z.string().default("").describe("Domain
|
|
48407
|
+
target: z.string().default("").describe("Domain (the whole site), a path ending in / for every URL under it, such as example.com/blog/, or one page."),
|
|
48408
|
+
scope: z.enum(RESEARCH_SCOPES).optional().describe("Optional: domain, prefix or exact, to read the target another way than its shape says."),
|
|
47522
48409
|
competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
|
|
47523
48410
|
tasks: z.array(z.string().regex(AI_ANSWER_TASK_PATTERN)).max(30).default([]).describe("ai_answers only: the tasks an earlier run returned as not ready, to read back at no cost."),
|
|
48411
|
+
also: z.array(z.string().min(1)).max(4).default([]).describe("ai_answers only: the brand's other domains, such as claude.ai beside claude.com. A citation to any of them counts as citing the target."),
|
|
48412
|
+
brand: z.array(z.string().min(1).max(60)).max(10).default([]).describe("ai_answers only: the brand names that count as a mention, such as Datadog for datadoghq.com. Default: each domain's name."),
|
|
47524
48413
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
48414
|
+
depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
|
|
47525
48415
|
...detail
|
|
47526
48416
|
}),
|
|
47527
48417
|
run: async (surface, input) => {
|
|
47528
48418
|
const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
|
|
47529
48419
|
if (input.action === "ai_answers") {
|
|
47530
|
-
if (input.provider
|
|
48420
|
+
if (input.provider === "ahrefs") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
47531
48421
|
return runAiAnswers({
|
|
47532
48422
|
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
47533
48423
|
country: input.country,
|
|
47534
48424
|
detail: input.detail,
|
|
47535
48425
|
offset: input.offset,
|
|
47536
48426
|
questions: input.keywords,
|
|
47537
|
-
tasks: input.tasks
|
|
47538
|
-
|
|
48427
|
+
tasks: input.tasks,
|
|
48428
|
+
also: input.also,
|
|
48429
|
+
brand: input.brand
|
|
48430
|
+
}, await surface.keys(), surface.deps, (offset, tasks) => `seo_research with the same provider, action, target, also and brand, tasks ${JSON.stringify(tasks)}, detail "rows", offset ${offset}. Passing the tasks reads these answers back at no cost; without them every question is asked and paid for again`);
|
|
47539
48431
|
}
|
|
47540
48432
|
return runResearch(input, await surface.keys(), {
|
|
47541
48433
|
drKey: surface.drKey,
|
|
48434
|
+
store: surface.researchStore,
|
|
47542
48435
|
call: more
|
|
47543
48436
|
});
|
|
47544
48437
|
}
|
|
@@ -47610,20 +48503,26 @@ const SHARED_TOOLS = [
|
|
|
47610
48503
|
name: "seo_lint",
|
|
47611
48504
|
command: "lint",
|
|
47612
48505
|
title: "Check copy for AI writing patterns",
|
|
47613
|
-
description: "Checks landing page copy, blog posts, titles and meta descriptions for patterns common in AI-written text (such as \"isn't X, it's Y\" reframes, \", not X\" contrasts, two-beat fragments, stock idioms and kicker lines), weak copy (dash punctuation, buzzwords, vague link text) and SEO rules (title 30 to 60 characters, description 70 to 160, one H1). Each error and warning has a line, the words, and a concrete fix. Review items are judgement calls for you: read each quote and change it only when it fails the question.
|
|
48506
|
+
description: "Checks landing page copy, blog posts, titles and meta descriptions for patterns common in AI-written text (such as \"isn't X, it's Y\" reframes, \", not X\" contrasts, two-beat fragments, stock idioms and kicker lines), weak copy (dash punctuation, buzzwords, vague link text) and SEO rules (title 30 to 60 characters, description 70 to 160, one H1). Each error and warning has a line, the words, and a concrete fix. Each finding has a category (ai, copy or seo); only limits the check, such as only [\"ai\"] to leave out house style like dashes. Review items are judgement calls for you: read each quote and change it only when it fails the question. A url checks the main content of a live page (CLI and local MCP only). It reads no accounts.",
|
|
47614
48507
|
input: z.object(lintInput),
|
|
47615
48508
|
localInput: z.object({
|
|
47616
48509
|
...lintInput,
|
|
47617
48510
|
paths: z.array(z.string().min(1)).max(20).optional().describe("Optional instead of text: files, folders or globs on this machine (.md, .mdx, .html, .astro, .txt). The format comes from each file name.")
|
|
47618
48511
|
}),
|
|
47619
|
-
run: async (surface, input) =>
|
|
47620
|
-
|
|
47621
|
-
|
|
47622
|
-
|
|
47623
|
-
|
|
47624
|
-
|
|
47625
|
-
|
|
47626
|
-
|
|
48512
|
+
run: async (surface, input) => {
|
|
48513
|
+
const pageMarkdown = surface.deps.pageMarkdown;
|
|
48514
|
+
return runLint({
|
|
48515
|
+
text: input.text,
|
|
48516
|
+
paths: input.paths,
|
|
48517
|
+
url: input.url,
|
|
48518
|
+
format: input.format,
|
|
48519
|
+
preset: input.preset,
|
|
48520
|
+
ignoreRules: input.ignore_rules,
|
|
48521
|
+
ignorePhrases: input.ignore_phrases,
|
|
48522
|
+
only: input.only,
|
|
48523
|
+
metaKind: input.meta_kind
|
|
48524
|
+
}, surface.readFiles, pageMarkdown && ((url) => pageMarkdown(url, 2e5)));
|
|
48525
|
+
}
|
|
47627
48526
|
})
|
|
47628
48527
|
];
|
|
47629
48528
|
function indexFromStore(shared) {
|
|
@@ -47939,10 +48838,11 @@ function nodeCommandDeps(options) {
|
|
|
47939
48838
|
}),
|
|
47940
48839
|
checkPages,
|
|
47941
48840
|
liveCheck,
|
|
47942
|
-
inspect: async (site, url) => indexStatusFrom(await inspectUrl({
|
|
48841
|
+
inspect: async (site, url, signal) => indexStatusFrom(await inspectUrl({
|
|
47943
48842
|
siteUrl: site,
|
|
47944
48843
|
inspectionUrl: url,
|
|
47945
|
-
accountEmail: options.account
|
|
48844
|
+
accountEmail: options.account,
|
|
48845
|
+
signal
|
|
47946
48846
|
})),
|
|
47947
48847
|
inspectionQuota: (site) => urlInspectionQuota({
|
|
47948
48848
|
siteUrl: site,
|
|
@@ -47952,7 +48852,7 @@ function nodeCommandDeps(options) {
|
|
|
47952
48852
|
read: (site, urls) => options.refresh ? /* @__PURE__ */ new Map() : readSavedInspections(site, urls),
|
|
47953
48853
|
save: saveInspection
|
|
47954
48854
|
},
|
|
47955
|
-
pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
|
|
48855
|
+
pageMarkdown: async (url, limit) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh }), limit),
|
|
47956
48856
|
call: options.call,
|
|
47957
48857
|
bing: localBing,
|
|
47958
48858
|
bingConnect: LOCAL_BING_CONNECT,
|
|
@@ -47964,6 +48864,35 @@ function nodeCommandDeps(options) {
|
|
|
47964
48864
|
...cloudLinked() ? { week: cloudWeek } : {}
|
|
47965
48865
|
};
|
|
47966
48866
|
}
|
|
48867
|
+
function localResearchStore(refresh = false) {
|
|
48868
|
+
const at = (key) => ({
|
|
48869
|
+
provider: "research",
|
|
48870
|
+
credentialScope: "-",
|
|
48871
|
+
operation: "research",
|
|
48872
|
+
request: key
|
|
48873
|
+
});
|
|
48874
|
+
return {
|
|
48875
|
+
get: async (key) => {
|
|
48876
|
+
if (refresh) return null;
|
|
48877
|
+
try {
|
|
48878
|
+
return readProviderCache(at(key), z.string())?.data ?? null;
|
|
48879
|
+
} catch {
|
|
48880
|
+
return null;
|
|
48881
|
+
}
|
|
48882
|
+
},
|
|
48883
|
+
put: async (key, value, ttlSeconds) => {
|
|
48884
|
+
try {
|
|
48885
|
+
writeProviderCache(at(key), {
|
|
48886
|
+
data: value,
|
|
48887
|
+
ttlMs: ttlSeconds * 1e3,
|
|
48888
|
+
rowCount: null,
|
|
48889
|
+
sourceCostMicros: null,
|
|
48890
|
+
taskIds: []
|
|
48891
|
+
});
|
|
48892
|
+
} catch {}
|
|
48893
|
+
}
|
|
48894
|
+
};
|
|
48895
|
+
}
|
|
47967
48896
|
const LOCAL_BING_CONNECT = "Connect Bing on your seoskill.dev account and run `seo login`, or run `seo providers bing connect` with an API key from Bing Webmaster Tools (Settings, API access).";
|
|
47968
48897
|
let bing;
|
|
47969
48898
|
function localBing() {
|
|
@@ -48080,6 +49009,14 @@ async function runDoctor(dependencies = {}) {
|
|
|
48080
49009
|
detail: paths.configDir,
|
|
48081
49010
|
fix: existsSync(paths.configDir) ? void 0 : "Run `seo start` or `seo auth setup-client`."
|
|
48082
49011
|
});
|
|
49012
|
+
if (cloudLinked() && !serviceAccount.configured) {
|
|
49013
|
+
checks.push(await accountCheck());
|
|
49014
|
+
return {
|
|
49015
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
49016
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
49017
|
+
checks
|
|
49018
|
+
};
|
|
49019
|
+
}
|
|
48083
49020
|
checks.push({
|
|
48084
49021
|
id: "oauth-client",
|
|
48085
49022
|
label: "Google credentials",
|
|
@@ -48114,6 +49051,32 @@ async function runDoctor(dependencies = {}) {
|
|
|
48114
49051
|
checks
|
|
48115
49052
|
};
|
|
48116
49053
|
}
|
|
49054
|
+
async function accountCheck() {
|
|
49055
|
+
const via = process.env.SEO_TOKEN?.trim() ? "SEO_TOKEN" : "`seo login`";
|
|
49056
|
+
try {
|
|
49057
|
+
const emails = await cloudGoogleEmails();
|
|
49058
|
+
return emails.length ? {
|
|
49059
|
+
id: "google-login",
|
|
49060
|
+
label: "Google login",
|
|
49061
|
+
status: "pass",
|
|
49062
|
+
detail: `Linked to a ${CLOUD_URL} account through ${via}, with Google sign-ins for ${emails.join(", ")}. The local Google client, token and scopes are not used.`
|
|
49063
|
+
} : {
|
|
49064
|
+
id: "google-login",
|
|
49065
|
+
label: "Google login",
|
|
49066
|
+
status: "fail",
|
|
49067
|
+
detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account has no Google sign-in.`,
|
|
49068
|
+
fix: `Connect Google at ${CLOUD_URL}/account, then run \`seo doctor\` again.`
|
|
49069
|
+
};
|
|
49070
|
+
} catch (error) {
|
|
49071
|
+
return {
|
|
49072
|
+
id: "google-login",
|
|
49073
|
+
label: "Google login",
|
|
49074
|
+
status: "fail",
|
|
49075
|
+
detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
49076
|
+
fix: via === "SEO_TOKEN" ? `Make a new token at ${CLOUD_URL}/account and set SEO_TOKEN again.` : "Run `seo login` again."
|
|
49077
|
+
};
|
|
49078
|
+
}
|
|
49079
|
+
}
|
|
48117
49080
|
const MAX_BYTES$1 = LINT_MAX_CHARS * 4;
|
|
48118
49081
|
const EXTENSIONS = /* @__PURE__ */ new Set([
|
|
48119
49082
|
".md",
|
|
@@ -48131,6 +49094,10 @@ const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
48131
49094
|
".astro",
|
|
48132
49095
|
".next"
|
|
48133
49096
|
]);
|
|
49097
|
+
const CODE_TAGS = /(<(script|style|svg)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi;
|
|
49098
|
+
function withoutCode(html) {
|
|
49099
|
+
return html.replace(CODE_TAGS, (_whole, open, _name, body, close) => `${open}${body.replace(/[^\n]/g, "")}${close}`);
|
|
49100
|
+
}
|
|
48134
49101
|
function tooMany() {
|
|
48135
49102
|
return new SeoError("INVALID_INPUT", `More than 100 files match. Point seo lint at fewer files or a smaller folder.`);
|
|
48136
49103
|
}
|
|
@@ -48183,16 +49150,27 @@ async function readLintFiles(inputs, cwd = process.cwd()) {
|
|
|
48183
49150
|
continue;
|
|
48184
49151
|
}
|
|
48185
49152
|
const text = await readFile(path, "utf8");
|
|
48186
|
-
if (text.length
|
|
49153
|
+
if (text.length <= 2e5) {
|
|
49154
|
+
sources.push({
|
|
49155
|
+
path: label,
|
|
49156
|
+
text
|
|
49157
|
+
});
|
|
49158
|
+
continue;
|
|
49159
|
+
}
|
|
49160
|
+
const size = `It has ${text.length.toLocaleString("en-US")} characters`;
|
|
49161
|
+
const html = [".html", ".htm"].includes(extname(path).toLowerCase());
|
|
49162
|
+
const readable = html ? withoutCode(text) : text;
|
|
49163
|
+
if (readable.length > 2e5) {
|
|
48187
49164
|
sources.push({
|
|
48188
49165
|
path: label,
|
|
48189
|
-
skipped:
|
|
49166
|
+
skipped: `${size}${html ? `, ${readable.length.toLocaleString("en-US")} without scripts, styles and SVG` : ""}; seo lint reads up to ${LINT_MAX_CHARS.toLocaleString("en-US")}.`
|
|
48190
49167
|
});
|
|
48191
49168
|
continue;
|
|
48192
49169
|
}
|
|
48193
49170
|
sources.push({
|
|
48194
49171
|
path: label,
|
|
48195
|
-
text
|
|
49172
|
+
text: readable,
|
|
49173
|
+
note: `${size}, so the insides of its script, style and SVG tags were left out first (${readable.length.toLocaleString("en-US")} remain). Line numbers match; a column on a line that held one of those tags can be off.`
|
|
48196
49174
|
});
|
|
48197
49175
|
}
|
|
48198
49176
|
return sources;
|
|
@@ -49470,7 +50448,7 @@ function registerMonitoringTools(server) {
|
|
|
49470
50448
|
refresh,
|
|
49471
50449
|
js: resolveJsOption(js, "auto")
|
|
49472
50450
|
});
|
|
49473
|
-
return toolSuccess(`Traced ${result.summary.hops} hops. Final status ${result.summary.finalStatus}
|
|
50451
|
+
return toolSuccess(`Traced ${result.summary.hops} hops. ${result.summary.finalStatus === null ? `No final response (${result.summary.outcome}: ${result.summary.error ?? "unknown"}).` : `Final status ${result.summary.finalStatus}.`} ${result.summary.issues.length} issues.`, result);
|
|
49474
50452
|
} catch (error) {
|
|
49475
50453
|
return toolError(error);
|
|
49476
50454
|
}
|
|
@@ -51203,6 +52181,7 @@ function registerSharedTools(server, options = {}) {
|
|
|
51203
52181
|
keys: localResearchKeys,
|
|
51204
52182
|
refreshClicky: localRefreshClicky,
|
|
51205
52183
|
drKey: null,
|
|
52184
|
+
researchStore: localResearchStore(),
|
|
51206
52185
|
accounts: localAccounts,
|
|
51207
52186
|
signIn: LOCAL_SIGN_IN.signIn,
|
|
51208
52187
|
connect: "run `seo login` to use your seoskill.dev account (Google, Bing and keys in one place), or `seo auth login`, `seo providers bing connect` and `seo providers dataforseo connect` or `seo providers ahrefs connect` on this machine only.",
|