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/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { n as version, t as load } from "./esm-
|
|
2
|
+
import { n as version, t as load } from "./esm-CkObTzDg.js";
|
|
3
3
|
import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
|
|
4
|
-
import crypto, { createHash, randomUUID, sign } from "node:crypto";
|
|
4
|
+
import crypto$1, { createHash, randomUUID, sign } from "node:crypto";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { arch, homedir, hostname, platform, tmpdir, totalmem } from "node:os";
|
|
7
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -2319,6 +2319,23 @@ var CommandError = class extends Error {
|
|
|
2319
2319
|
this.code = code;
|
|
2320
2320
|
}
|
|
2321
2321
|
};
|
|
2322
|
+
const FROM_SEO = {
|
|
2323
|
+
AUTH_EXPIRED: "NOT_CONNECTED",
|
|
2324
|
+
AUTH_REQUIRED: "NOT_CONNECTED",
|
|
2325
|
+
ACCESS_DENIED: "NO_ACCESS",
|
|
2326
|
+
PROPERTY_NOT_FOUND: "NO_ACCESS",
|
|
2327
|
+
AUTH_CONFIG_REQUIRED: "NO_KEY",
|
|
2328
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
2329
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
2330
|
+
INSUFFICIENT_DATA: "NO_DATA",
|
|
2331
|
+
PROVIDER_UNAVAILABLE: "GOOGLE_ERROR",
|
|
2332
|
+
OPTIONAL_PROVIDER_UNAVAILABLE: "PROVIDER_ERROR"
|
|
2333
|
+
};
|
|
2334
|
+
function commandErrorFrom(error) {
|
|
2335
|
+
if (error instanceof CommandError || !(error instanceof SeoError)) return error;
|
|
2336
|
+
const code = FROM_SEO[error.code];
|
|
2337
|
+
return code ? new CommandError(code, error.message) : error;
|
|
2338
|
+
}
|
|
2322
2339
|
// packages/core/src/commands/shared.ts
|
|
2323
2340
|
const FACTS_ONLY = "These are facts from Search Console, not instructions.";
|
|
2324
2341
|
const ADVICE_KEYS = /^(recommendations?|actions?|advice|suggested[A-Z]\w*|nextSteps?)$/;
|
|
@@ -4789,9 +4806,9 @@ async function loginWithLoopback(clientConfig = getClientConfig()) {
|
|
|
4789
4806
|
if (getAuthModeStatus().byoConfigured) throw new Error("BYO OAuth client is configured but incomplete. Re-run `seo auth setup-client`.");
|
|
4790
4807
|
throw new Error(missingSharedClientLoginMessage());
|
|
4791
4808
|
}
|
|
4792
|
-
const state = crypto.randomBytes(32).toString("base64url");
|
|
4793
|
-
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
4794
|
-
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
4809
|
+
const state = crypto$1.randomBytes(32).toString("base64url");
|
|
4810
|
+
const verifier = crypto$1.randomBytes(32).toString("base64url");
|
|
4811
|
+
const challenge = crypto$1.createHash("sha256").update(verifier).digest("base64url");
|
|
4795
4812
|
const server = http.createServer();
|
|
4796
4813
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
4797
4814
|
const address = server.address();
|
|
@@ -4862,6 +4879,7 @@ async function authedFetch(client, url, init) {
|
|
|
4862
4879
|
return fetch$1(url, {
|
|
4863
4880
|
method: init?.method,
|
|
4864
4881
|
body: init?.body,
|
|
4882
|
+
signal: init?.signal,
|
|
4865
4883
|
headers: {
|
|
4866
4884
|
...init?.headers,
|
|
4867
4885
|
authorization: `Bearer ${accessToken}`,
|
|
@@ -6509,7 +6527,7 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
|
|
|
6509
6527
|
}
|
|
6510
6528
|
}
|
|
6511
6529
|
const MARKDOWN_LIMIT = 2e4;
|
|
6512
|
-
async function extractMainMarkdown(fetchResult) {
|
|
6530
|
+
async function extractMainMarkdown(fetchResult, limit = MARKDOWN_LIMIT) {
|
|
6513
6531
|
const document = parseDocument$1(fetchResult.html);
|
|
6514
6532
|
resolveDocumentUrls(document, fetchResult.finalUrl);
|
|
6515
6533
|
const { Defuddle: toMarkdown } = await import("defuddle/node");
|
|
@@ -6517,15 +6535,15 @@ async function extractMainMarkdown(fetchResult) {
|
|
|
6517
6535
|
markdown: true,
|
|
6518
6536
|
useAsync: false
|
|
6519
6537
|
})).content ?? "").trim();
|
|
6520
|
-
if (markdown.length <=
|
|
6538
|
+
if (markdown.length <= limit) return {
|
|
6521
6539
|
markdown,
|
|
6522
6540
|
characters: markdown.length,
|
|
6523
6541
|
truncated: false
|
|
6524
6542
|
};
|
|
6525
|
-
const cut = markdown.slice(0,
|
|
6543
|
+
const cut = markdown.slice(0, limit);
|
|
6526
6544
|
const end = cut.lastIndexOf("\n");
|
|
6527
6545
|
return {
|
|
6528
|
-
markdown: (end >
|
|
6546
|
+
markdown: (end > limit / 2 ? cut.slice(0, end) : cut).trimEnd(),
|
|
6529
6547
|
characters: markdown.length,
|
|
6530
6548
|
truncated: true
|
|
6531
6549
|
};
|
|
@@ -7078,7 +7096,7 @@ function softAuthenticationGate($, finalUrl, title) {
|
|
|
7078
7096
|
if (passwordForms.length === 0) return void 0;
|
|
7079
7097
|
const pagePath = new URL(finalUrl).pathname;
|
|
7080
7098
|
const action = $(passwordForms[0]).attr("action");
|
|
7081
|
-
const actionUrl = action ? httpUrl(action, finalUrl) : void 0;
|
|
7099
|
+
const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
|
|
7082
7100
|
const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
|
|
7083
7101
|
const indicators = [
|
|
7084
7102
|
...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
|
|
@@ -7129,7 +7147,7 @@ function absoluteUrl$1(value, base) {
|
|
|
7129
7147
|
return;
|
|
7130
7148
|
}
|
|
7131
7149
|
}
|
|
7132
|
-
function httpUrl(value, base) {
|
|
7150
|
+
function httpUrl$1(value, base) {
|
|
7133
7151
|
const resolved = absoluteUrl$1(value, base);
|
|
7134
7152
|
if (!resolved) return void 0;
|
|
7135
7153
|
const protocol = new URL(resolved).protocol;
|
|
@@ -7326,15 +7344,15 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
|
|
|
7326
7344
|
let invalidHreflangCount = 0;
|
|
7327
7345
|
for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
|
|
7328
7346
|
const language = $(element).attr("hreflang") ?? "";
|
|
7329
|
-
const href = httpUrl($(element).attr("href"), fetchResult.finalUrl);
|
|
7347
|
+
const href = httpUrl$1($(element).attr("href"), fetchResult.finalUrl);
|
|
7330
7348
|
if (language && href) hreflang.push({
|
|
7331
7349
|
hreflang: language,
|
|
7332
7350
|
href
|
|
7333
7351
|
});
|
|
7334
7352
|
else invalidHreflangCount += 1;
|
|
7335
7353
|
}
|
|
7336
|
-
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();
|
|
7337
|
-
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();
|
|
7354
|
+
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();
|
|
7355
|
+
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();
|
|
7338
7356
|
const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
|
|
7339
7357
|
const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
|
|
7340
7358
|
const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
|
|
@@ -7753,11 +7771,11 @@ function recordHostFetch(input) {
|
|
|
7753
7771
|
return snapshotForHost(input.host);
|
|
7754
7772
|
}
|
|
7755
7773
|
// packages/core/src/fetch/page-fetcher/redirects.ts
|
|
7756
|
-
async function fetchWithRedirectChain(url, signal) {
|
|
7774
|
+
async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch) {
|
|
7757
7775
|
const redirectChain = [];
|
|
7758
7776
|
let currentUrl = url;
|
|
7759
7777
|
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
7760
|
-
const response = await
|
|
7778
|
+
const response = await fetch(currentUrl, {
|
|
7761
7779
|
redirect: "manual",
|
|
7762
7780
|
signal
|
|
7763
7781
|
});
|
|
@@ -8995,7 +9013,8 @@ async function inspectUrl(input) {
|
|
|
8995
9013
|
siteUrl: input.siteUrl,
|
|
8996
9014
|
inspectionUrl,
|
|
8997
9015
|
...input.languageCode ? { languageCode: input.languageCode } : {}
|
|
8998
|
-
})
|
|
9016
|
+
}),
|
|
9017
|
+
signal: input.signal
|
|
8999
9018
|
});
|
|
9000
9019
|
} catch (error) {
|
|
9001
9020
|
finalize(reservation, "consumed");
|
|
@@ -13525,6 +13544,15 @@ const crawlAiAvailabilitySchema = z.enum([
|
|
|
13525
13544
|
"rate-limited",
|
|
13526
13545
|
"unreachable"
|
|
13527
13546
|
]);
|
|
13547
|
+
const crawlFetchErrorKindSchema = z.enum([
|
|
13548
|
+
"dns",
|
|
13549
|
+
"tls",
|
|
13550
|
+
"timeout",
|
|
13551
|
+
"redirect-limit",
|
|
13552
|
+
"too-large",
|
|
13553
|
+
"aborted",
|
|
13554
|
+
"unknown"
|
|
13555
|
+
]);
|
|
13528
13556
|
const crawlAiLinkCheckSchema = z.object({
|
|
13529
13557
|
url: z.string().url(),
|
|
13530
13558
|
status: z.number().int().optional(),
|
|
@@ -13540,6 +13568,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
13540
13568
|
contentType: z.string().optional(),
|
|
13541
13569
|
servesHtml: z.boolean().optional(),
|
|
13542
13570
|
error: z.string().optional(),
|
|
13571
|
+
errorKind: crawlFetchErrorKindSchema.optional(),
|
|
13543
13572
|
sitemapUrls: z.array(z.string().url()),
|
|
13544
13573
|
contentSignals: z.array(z.string()).optional(),
|
|
13545
13574
|
botAccess: z.array(z.object({
|
|
@@ -13562,6 +13591,7 @@ const crawlAiSignalsSchema = z.object({
|
|
|
13562
13591
|
contentType: z.string().optional(),
|
|
13563
13592
|
servesHtml: z.boolean().optional(),
|
|
13564
13593
|
error: z.string().optional(),
|
|
13594
|
+
errorKind: crawlFetchErrorKindSchema.optional(),
|
|
13565
13595
|
links: z.object({
|
|
13566
13596
|
found: z.number().int().nonnegative(),
|
|
13567
13597
|
checked: z.number().int().nonnegative(),
|
|
@@ -14631,10 +14661,11 @@ function agentReadyIssues(input) {
|
|
|
14631
14661
|
}
|
|
14632
14662
|
// packages/core/src/analyze/crawler/crawl-control.ts
|
|
14633
14663
|
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
14664
|
+
const REQUEST_TIMEOUT_CAP_MS = 5e3;
|
|
14634
14665
|
function abortController(input) {
|
|
14635
14666
|
const controller = new AbortController();
|
|
14636
14667
|
const abort = () => controller.abort();
|
|
14637
|
-
const timer = setTimeout(abort, Math.min(input.timeoutMs,
|
|
14668
|
+
const timer = setTimeout(abort, Math.min(input.timeoutMs, REQUEST_TIMEOUT_CAP_MS));
|
|
14638
14669
|
if (input.signal?.aborted) controller.abort();
|
|
14639
14670
|
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
14640
14671
|
return {
|
|
@@ -15074,6 +15105,405 @@ async function collectCrawlSiteChecks(input) {
|
|
|
15074
15105
|
}]
|
|
15075
15106
|
};
|
|
15076
15107
|
}
|
|
15108
|
+
// packages/core/src/analyze/monitoring/crawl-page.ts
|
|
15109
|
+
function resolvedUrl(href, base) {
|
|
15110
|
+
if (!href) return void 0;
|
|
15111
|
+
try {
|
|
15112
|
+
const url = new URL(href, base);
|
|
15113
|
+
return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
|
|
15114
|
+
} catch {
|
|
15115
|
+
return;
|
|
15116
|
+
}
|
|
15117
|
+
}
|
|
15118
|
+
function hashText(value) {
|
|
15119
|
+
return createHash("sha256").update(value).digest("hex");
|
|
15120
|
+
}
|
|
15121
|
+
function headerValue(headers, name) {
|
|
15122
|
+
const lower = name.toLowerCase();
|
|
15123
|
+
return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
15124
|
+
}
|
|
15125
|
+
function safeResponseHeaders(headers) {
|
|
15126
|
+
const blocked = /* @__PURE__ */ new Set([
|
|
15127
|
+
"authorization",
|
|
15128
|
+
"cookie",
|
|
15129
|
+
"proxy-authorization",
|
|
15130
|
+
"set-cookie",
|
|
15131
|
+
"www-authenticate"
|
|
15132
|
+
]);
|
|
15133
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
|
|
15134
|
+
}
|
|
15135
|
+
function truncate$1(value, max) {
|
|
15136
|
+
return value.length > max ? value.slice(0, max).trimEnd() : value;
|
|
15137
|
+
}
|
|
15138
|
+
function sameDocumentUrl(left, right) {
|
|
15139
|
+
if (!left || !right) return false;
|
|
15140
|
+
try {
|
|
15141
|
+
const a = new URL(left);
|
|
15142
|
+
const b = new URL(right);
|
|
15143
|
+
a.hash = "";
|
|
15144
|
+
b.hash = "";
|
|
15145
|
+
return a.toString() === b.toString();
|
|
15146
|
+
} catch {
|
|
15147
|
+
return left === right;
|
|
15148
|
+
}
|
|
15149
|
+
}
|
|
15150
|
+
function isHtmlContentType(value) {
|
|
15151
|
+
return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
|
|
15152
|
+
}
|
|
15153
|
+
function fetchFailureKind(error, signal) {
|
|
15154
|
+
const record = error;
|
|
15155
|
+
const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
|
|
15156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15157
|
+
if (signal?.aborted) return "aborted";
|
|
15158
|
+
if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
|
|
15159
|
+
if ([
|
|
15160
|
+
"ENOTFOUND",
|
|
15161
|
+
"EAI_AGAIN",
|
|
15162
|
+
"EAI_FAIL"
|
|
15163
|
+
].includes(code)) return "dns";
|
|
15164
|
+
if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
|
|
15165
|
+
if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
|
|
15166
|
+
return "unknown";
|
|
15167
|
+
}
|
|
15168
|
+
function responseObservation(requestedUrl, fetched, extraction, extractionError) {
|
|
15169
|
+
const observation = {
|
|
15170
|
+
requestedUrl,
|
|
15171
|
+
outcome: "response",
|
|
15172
|
+
finalUrl: fetched.finalUrl,
|
|
15173
|
+
status: fetched.status,
|
|
15174
|
+
contentType: headerValue(fetched.headers, "content-type"),
|
|
15175
|
+
durationMs: fetched.diagnostics.durationMs,
|
|
15176
|
+
redirectChain: fetched.diagnostics.redirectChain,
|
|
15177
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
15178
|
+
robotsTxt: fetched.robotsTxt
|
|
15179
|
+
};
|
|
15180
|
+
if (extraction === "failed") {
|
|
15181
|
+
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
15182
|
+
return {
|
|
15183
|
+
...observation,
|
|
15184
|
+
extraction,
|
|
15185
|
+
extractionError
|
|
15186
|
+
};
|
|
15187
|
+
}
|
|
15188
|
+
return {
|
|
15189
|
+
...observation,
|
|
15190
|
+
extraction
|
|
15191
|
+
};
|
|
15192
|
+
}
|
|
15193
|
+
function unextractedPage(fetched, contentType) {
|
|
15194
|
+
const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
|
|
15195
|
+
const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
|
|
15196
|
+
const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
|
|
15197
|
+
const knownNonHtml = Boolean(contentType);
|
|
15198
|
+
return {
|
|
15199
|
+
url: fetched.finalUrl,
|
|
15200
|
+
finalUrl: fetched.finalUrl,
|
|
15201
|
+
status: fetched.status,
|
|
15202
|
+
contentType,
|
|
15203
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15204
|
+
sizeBytes: Buffer.byteLength(fetched.html),
|
|
15205
|
+
usedJs: fetched.usedJs,
|
|
15206
|
+
fetchSource: fetched.diagnostics.source,
|
|
15207
|
+
cacheState: fetched.diagnostics.cache,
|
|
15208
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
15209
|
+
accessBlock: fetched.diagnostics.accessBlock,
|
|
15210
|
+
blocked: fetched.diagnostics.blocked,
|
|
15211
|
+
robotsTxt: fetched.robotsTxt,
|
|
15212
|
+
xRobotsTag,
|
|
15213
|
+
canonical: canonicalEvidence.selectedUrl,
|
|
15214
|
+
canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
|
|
15215
|
+
canonicalStatus: canonicalEvidence.status,
|
|
15216
|
+
canonicalCandidates: canonicalEvidence.candidates,
|
|
15217
|
+
indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
|
|
15218
|
+
indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
|
|
15219
|
+
declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
|
|
15220
|
+
extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
|
|
15221
|
+
wordCount: 0,
|
|
15222
|
+
contentHash: hashText(fetched.html),
|
|
15223
|
+
outgoingInternalCount: 0,
|
|
15224
|
+
outgoingExternalCount: 0,
|
|
15225
|
+
isHttps: new URL(fetched.finalUrl).protocol === "https:",
|
|
15226
|
+
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
15227
|
+
compression: headerValue(fetched.headers, "content-encoding")
|
|
15228
|
+
};
|
|
15229
|
+
}
|
|
15230
|
+
function statusOnlyPage(fetched) {
|
|
15231
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
15232
|
+
return {
|
|
15233
|
+
url: fetched.finalUrl,
|
|
15234
|
+
finalUrl: fetched.finalUrl,
|
|
15235
|
+
status: fetched.status,
|
|
15236
|
+
contentType,
|
|
15237
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15238
|
+
auditScope: "status",
|
|
15239
|
+
contentAuditAllowed: false,
|
|
15240
|
+
indexable: false,
|
|
15241
|
+
indexability: "Not evaluated by status-only health probe",
|
|
15242
|
+
declaredIndexability: "unknown",
|
|
15243
|
+
extractionStatus: "not-applicable",
|
|
15244
|
+
wordCount: 0,
|
|
15245
|
+
contentHash: "",
|
|
15246
|
+
outgoingInternalCount: 0,
|
|
15247
|
+
outgoingExternalCount: 0
|
|
15248
|
+
};
|
|
15249
|
+
}
|
|
15250
|
+
function indexabilityReason(page) {
|
|
15251
|
+
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
15252
|
+
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
15253
|
+
if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
|
|
15254
|
+
}
|
|
15255
|
+
async function crawlOne(url, opts = {}) {
|
|
15256
|
+
const startedAt = Date.now();
|
|
15257
|
+
let fetched;
|
|
15258
|
+
try {
|
|
15259
|
+
fetched = await fetchPage(url, opts);
|
|
15260
|
+
} catch (error) {
|
|
15261
|
+
if (error instanceof OriginBackpressureError) return {
|
|
15262
|
+
request: {
|
|
15263
|
+
requestedUrl: url,
|
|
15264
|
+
outcome: "skipped",
|
|
15265
|
+
reason: "origin-backpressure",
|
|
15266
|
+
error: error.message,
|
|
15267
|
+
extraction: "not-applicable"
|
|
15268
|
+
},
|
|
15269
|
+
urls: []
|
|
15270
|
+
};
|
|
15271
|
+
if (error instanceof RobotsAccessError) return {
|
|
15272
|
+
request: {
|
|
15273
|
+
requestedUrl: url,
|
|
15274
|
+
outcome: "skipped",
|
|
15275
|
+
reason: error.reason,
|
|
15276
|
+
robotsTxt: {
|
|
15277
|
+
url: error.evidence.url,
|
|
15278
|
+
allowed: error.evidence.allowed,
|
|
15279
|
+
availability: error.evidence.availability,
|
|
15280
|
+
status: error.evidence.status,
|
|
15281
|
+
error: error.evidence.error,
|
|
15282
|
+
matchedLine: error.evidence.matchedLine
|
|
15283
|
+
},
|
|
15284
|
+
extraction: "not-applicable"
|
|
15285
|
+
},
|
|
15286
|
+
urls: []
|
|
15287
|
+
};
|
|
15288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15289
|
+
return {
|
|
15290
|
+
request: {
|
|
15291
|
+
requestedUrl: url,
|
|
15292
|
+
outcome: "failure",
|
|
15293
|
+
durationMs: Date.now() - startedAt,
|
|
15294
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
15295
|
+
error: message,
|
|
15296
|
+
extraction: "not-applicable"
|
|
15297
|
+
},
|
|
15298
|
+
urls: [],
|
|
15299
|
+
warning: `${url}: ${message}`
|
|
15300
|
+
};
|
|
15301
|
+
}
|
|
15302
|
+
return snapshotFetchedPage(url, fetched);
|
|
15303
|
+
}
|
|
15304
|
+
async function snapshotFetchedPage(url, fetched) {
|
|
15305
|
+
const contentType = headerValue(fetched.headers, "content-type");
|
|
15306
|
+
if (fetched.diagnostics.accessBlock) {
|
|
15307
|
+
const page = unextractedPage(fetched, contentType);
|
|
15308
|
+
page.contentAuditAllowed = false;
|
|
15309
|
+
page.indexable = false;
|
|
15310
|
+
page.indexability = "Crawler access blocked";
|
|
15311
|
+
page.declaredIndexability = "unknown";
|
|
15312
|
+
page.extractionStatus = "not-applicable";
|
|
15313
|
+
return {
|
|
15314
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
15315
|
+
page,
|
|
15316
|
+
urls: []
|
|
15317
|
+
};
|
|
15318
|
+
}
|
|
15319
|
+
if (!isHtmlContentType(contentType)) return {
|
|
15320
|
+
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
15321
|
+
page: unextractedPage(fetched, contentType),
|
|
15322
|
+
urls: []
|
|
15323
|
+
};
|
|
15324
|
+
try {
|
|
15325
|
+
const extracted = await extractPage(fetched, "crawler");
|
|
15326
|
+
const crawlerEvidence = extracted.crawlerEvidence;
|
|
15327
|
+
if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
|
|
15328
|
+
const h1 = crawlerEvidence.h1;
|
|
15329
|
+
const uniqueInternalLinks = crawlerEvidence.internalLinks;
|
|
15330
|
+
const uniqueExternalLinks = crawlerEvidence.externalLinks;
|
|
15331
|
+
const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
|
|
15332
|
+
const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
|
|
15333
|
+
const page = {
|
|
15334
|
+
url: extracted.finalUrl,
|
|
15335
|
+
finalUrl: extracted.finalUrl,
|
|
15336
|
+
status: fetched.status,
|
|
15337
|
+
contentType,
|
|
15338
|
+
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
15339
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15340
|
+
sizeBytes: Buffer.byteLength(fetched.html),
|
|
15341
|
+
usedJs: fetched.usedJs,
|
|
15342
|
+
fetchSource: fetched.diagnostics.source,
|
|
15343
|
+
cacheState: fetched.diagnostics.cache,
|
|
15344
|
+
fetchDiagnostics: fetched.diagnostics,
|
|
15345
|
+
blocked: fetched.diagnostics.blocked,
|
|
15346
|
+
robotsTxt: fetched.robotsTxt,
|
|
15347
|
+
title: extracted.title,
|
|
15348
|
+
metaDescription: extracted.metaDescription,
|
|
15349
|
+
canonical,
|
|
15350
|
+
canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
|
|
15351
|
+
canonicalStatus: extracted.canonicalEvidence?.status,
|
|
15352
|
+
canonicalCandidates: extracted.canonicalEvidence?.candidates,
|
|
15353
|
+
metaRobots: extracted.metaRobots,
|
|
15354
|
+
xRobotsTag: extracted.xRobotsTag,
|
|
15355
|
+
h1,
|
|
15356
|
+
h1Count: crawlerEvidence.h1Count,
|
|
15357
|
+
h2Count: crawlerEvidence.h2Count,
|
|
15358
|
+
h3Count: crawlerEvidence.h3Count,
|
|
15359
|
+
indexable: false,
|
|
15360
|
+
extractionStatus: "complete",
|
|
15361
|
+
wordCount: extracted.wordCount,
|
|
15362
|
+
contentExtraction: extracted.contentExtraction,
|
|
15363
|
+
warnings: [
|
|
15364
|
+
...extracted.warnings,
|
|
15365
|
+
...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
|
|
15366
|
+
...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
|
|
15367
|
+
...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
|
|
15368
|
+
...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."] : []
|
|
15369
|
+
],
|
|
15370
|
+
contentHash: hashText([
|
|
15371
|
+
extracted.title,
|
|
15372
|
+
extracted.metaDescription,
|
|
15373
|
+
h1,
|
|
15374
|
+
extracted.canonical,
|
|
15375
|
+
extracted.contentText
|
|
15376
|
+
].join("\n")),
|
|
15377
|
+
mainContentHash: hashText(extracted.contentText),
|
|
15378
|
+
textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
|
|
15379
|
+
contentSample: truncate$1(extracted.contentText, 300),
|
|
15380
|
+
contentSketch: crawlerEvidence.contentSketch,
|
|
15381
|
+
softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
|
|
15382
|
+
metaRefresh: crawlerEvidence.metaRefresh,
|
|
15383
|
+
tabbedContent: crawlerEvidence.tabbedContent,
|
|
15384
|
+
lang: extracted.lang,
|
|
15385
|
+
hasViewport: extracted.hasViewport,
|
|
15386
|
+
isHttps: new URL(extracted.finalUrl).protocol === "https:",
|
|
15387
|
+
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
15388
|
+
compression: headerValue(fetched.headers, "content-encoding"),
|
|
15389
|
+
hreflang: extracted.hreflang,
|
|
15390
|
+
mixedContentCount: extracted.mixedContentUrls.length,
|
|
15391
|
+
mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
|
|
15392
|
+
imagesTotal: extracted.imagesTotal,
|
|
15393
|
+
imagesMissingAlt: extracted.imagesMissingAlt,
|
|
15394
|
+
oversizedImageCandidates: extracted.oversizedImageCandidates,
|
|
15395
|
+
outgoingInternalCount: uniqueInternalLinks.length,
|
|
15396
|
+
outgoingExternalCount: uniqueExternalLinks.length,
|
|
15397
|
+
sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
|
|
15398
|
+
sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
|
|
15399
|
+
internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
|
|
15400
|
+
externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
|
|
15401
|
+
markdownAlternates: extracted.markdownAlternates,
|
|
15402
|
+
describedBy: extracted.describedBy,
|
|
15403
|
+
schemaTypes: extracted.schemaTypes,
|
|
15404
|
+
structuredDataFormats: extracted.structuredDataFormats ?? [],
|
|
15405
|
+
googleRichResults: richResults.assessments,
|
|
15406
|
+
googleRichResultsSelection: richResults.selection,
|
|
15407
|
+
schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
|
|
15408
|
+
schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
|
|
15409
|
+
invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
|
|
15410
|
+
socialProfileLinks: crawlerEvidence.socialProfileLinks,
|
|
15411
|
+
invalidJsonLdCount: extracted.invalidJsonLdCount,
|
|
15412
|
+
invalidJsonLdSamples: extracted.invalidJsonLdSamples,
|
|
15413
|
+
unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
|
|
15414
|
+
openGraphTitle: extracted.openGraph["og:title"],
|
|
15415
|
+
openGraphDescription: extracted.openGraph["og:description"],
|
|
15416
|
+
openGraphImage: extracted.openGraph["og:image"],
|
|
15417
|
+
twitterCard: extracted.twitter["twitter:card"],
|
|
15418
|
+
author: extracted.author,
|
|
15419
|
+
hasDate: extracted.hasDate,
|
|
15420
|
+
geo: {
|
|
15421
|
+
semanticHtml: extracted.semanticHtml,
|
|
15422
|
+
structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
|
|
15423
|
+
hasAuthor: extracted.hasAuthor,
|
|
15424
|
+
hasDate: extracted.hasDate,
|
|
15425
|
+
questionHeadings: extracted.questionHeadings,
|
|
15426
|
+
listCount: extracted.listCount,
|
|
15427
|
+
tableCount: extracted.tableCount,
|
|
15428
|
+
structuredBlocks: extracted.structuredBlocks,
|
|
15429
|
+
answerable: extracted.answerable,
|
|
15430
|
+
hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
|
|
15431
|
+
hasQapageSchema: extracted.schemaTypes.includes("QAPage")
|
|
15432
|
+
}
|
|
15433
|
+
};
|
|
15434
|
+
page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
|
|
15435
|
+
page.indexable = !page.indexability;
|
|
15436
|
+
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";
|
|
15437
|
+
return {
|
|
15438
|
+
request: responseObservation(url, fetched, "complete"),
|
|
15439
|
+
page,
|
|
15440
|
+
urls: uniqueInternalLinks
|
|
15441
|
+
};
|
|
15442
|
+
} catch (error) {
|
|
15443
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15444
|
+
return {
|
|
15445
|
+
request: responseObservation(url, fetched, "failed", message),
|
|
15446
|
+
urls: [],
|
|
15447
|
+
warning: `${url}: content extraction failed: ${message}`
|
|
15448
|
+
};
|
|
15449
|
+
}
|
|
15450
|
+
}
|
|
15451
|
+
async function crawlStatusOnly(url, opts = {}) {
|
|
15452
|
+
const startedAt = Date.now();
|
|
15453
|
+
try {
|
|
15454
|
+
const fetched = await fetchPageStatus(url, {
|
|
15455
|
+
...opts,
|
|
15456
|
+
js: "off",
|
|
15457
|
+
refresh: true
|
|
15458
|
+
});
|
|
15459
|
+
return {
|
|
15460
|
+
request: responseObservation(url, fetched, "not-applicable"),
|
|
15461
|
+
page: statusOnlyPage(fetched),
|
|
15462
|
+
urls: []
|
|
15463
|
+
};
|
|
15464
|
+
} catch (error) {
|
|
15465
|
+
if (error instanceof OriginBackpressureError) return {
|
|
15466
|
+
request: {
|
|
15467
|
+
requestedUrl: url,
|
|
15468
|
+
outcome: "skipped",
|
|
15469
|
+
reason: "origin-backpressure",
|
|
15470
|
+
error: error.message,
|
|
15471
|
+
extraction: "not-applicable"
|
|
15472
|
+
},
|
|
15473
|
+
urls: []
|
|
15474
|
+
};
|
|
15475
|
+
if (error instanceof RobotsAccessError) return {
|
|
15476
|
+
request: {
|
|
15477
|
+
requestedUrl: url,
|
|
15478
|
+
outcome: "skipped",
|
|
15479
|
+
reason: error.reason,
|
|
15480
|
+
robotsTxt: {
|
|
15481
|
+
url: error.evidence.url,
|
|
15482
|
+
allowed: error.evidence.allowed,
|
|
15483
|
+
availability: error.evidence.availability,
|
|
15484
|
+
status: error.evidence.status,
|
|
15485
|
+
error: error.evidence.error,
|
|
15486
|
+
matchedLine: error.evidence.matchedLine
|
|
15487
|
+
},
|
|
15488
|
+
extraction: "not-applicable"
|
|
15489
|
+
},
|
|
15490
|
+
urls: []
|
|
15491
|
+
};
|
|
15492
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15493
|
+
return {
|
|
15494
|
+
request: {
|
|
15495
|
+
requestedUrl: url,
|
|
15496
|
+
outcome: "failure",
|
|
15497
|
+
durationMs: Date.now() - startedAt,
|
|
15498
|
+
failureKind: fetchFailureKind(error, opts.signal),
|
|
15499
|
+
error: message,
|
|
15500
|
+
extraction: "not-applicable"
|
|
15501
|
+
},
|
|
15502
|
+
urls: [],
|
|
15503
|
+
warning: `${url}: ${message}`
|
|
15504
|
+
};
|
|
15505
|
+
}
|
|
15506
|
+
}
|
|
15077
15507
|
// packages/core/src/analyze/crawler/site-crawl-ai-signals.ts
|
|
15078
15508
|
const MAX_AUXILIARY_RESPONSE_BYTES = 2097152;
|
|
15079
15509
|
const LLMS_LINK_LIMIT = 20;
|
|
@@ -15110,15 +15540,28 @@ const AGENT_RESOURCE_PATHS = [
|
|
|
15110
15540
|
"/.well-known/oauth-authorization-server",
|
|
15111
15541
|
"/.well-known/http-message-signatures-directory"
|
|
15112
15542
|
];
|
|
15113
|
-
function availabilityOf(status) {
|
|
15543
|
+
function availabilityOf$1(status) {
|
|
15114
15544
|
if (status >= 200 && status < 300) return "available";
|
|
15115
15545
|
if (status === 401 || status === 403) return "access-blocked";
|
|
15116
15546
|
if (status === 429) return "rate-limited";
|
|
15117
15547
|
if (status >= 400 && status < 500) return "absent";
|
|
15118
15548
|
return "unreachable";
|
|
15119
15549
|
}
|
|
15120
|
-
function errorText(error) {
|
|
15121
|
-
|
|
15550
|
+
function errorText$1(error) {
|
|
15551
|
+
if (!(error instanceof Error)) return String(error);
|
|
15552
|
+
const cause = error.cause instanceof Error ? error.cause.message : "";
|
|
15553
|
+
return cause && !error.message.includes(cause) ? `${error.message}: ${cause}` : error.message;
|
|
15554
|
+
}
|
|
15555
|
+
function requestError$1(error, timeoutMs, signal) {
|
|
15556
|
+
const errorKind = fetchErrorKind(error, signal);
|
|
15557
|
+
return {
|
|
15558
|
+
error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText$1(error),
|
|
15559
|
+
errorKind
|
|
15560
|
+
};
|
|
15561
|
+
}
|
|
15562
|
+
function fetchErrorKind(error, signal) {
|
|
15563
|
+
if (error instanceof ResponseSizeLimitError) return "too-large";
|
|
15564
|
+
return fetchFailureKind(error, signal);
|
|
15122
15565
|
}
|
|
15123
15566
|
async function get$1(input, url, headers, readBody = true) {
|
|
15124
15567
|
const controller = abortController({
|
|
@@ -15137,7 +15580,7 @@ async function get$1(input, url, headers, readBody = true) {
|
|
|
15137
15580
|
else await response.body?.cancel().catch(() => void 0);
|
|
15138
15581
|
return {
|
|
15139
15582
|
url: response.url || url,
|
|
15140
|
-
availability: availabilityOf(response.status),
|
|
15583
|
+
availability: availabilityOf$1(response.status),
|
|
15141
15584
|
status: response.status,
|
|
15142
15585
|
contentType: response.headers.get("content-type") ?? "",
|
|
15143
15586
|
headers: response.headers,
|
|
@@ -15149,7 +15592,7 @@ async function get$1(input, url, headers, readBody = true) {
|
|
|
15149
15592
|
availability: "unreachable",
|
|
15150
15593
|
contentType: "",
|
|
15151
15594
|
text: "",
|
|
15152
|
-
error
|
|
15595
|
+
...requestError$1(error, input.timeoutMs, input.signal)
|
|
15153
15596
|
};
|
|
15154
15597
|
} finally {
|
|
15155
15598
|
controller.cleanup();
|
|
@@ -15176,22 +15619,29 @@ function llmsLinks(text, base) {
|
|
|
15176
15619
|
} catch {}
|
|
15177
15620
|
return links;
|
|
15178
15621
|
}
|
|
15179
|
-
async function
|
|
15622
|
+
async function readLlmsTxt(input) {
|
|
15180
15623
|
const result = await get$1(input, new URL("/llms.txt", input.url).toString());
|
|
15181
15624
|
const servesHtml = result.availability === "available" && isHtml(result.contentType);
|
|
15182
15625
|
const availability = servesHtml ? "absent" : result.availability;
|
|
15183
15626
|
const exists = availability === "available";
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15187
|
-
|
|
15188
|
-
|
|
15189
|
-
|
|
15190
|
-
|
|
15191
|
-
|
|
15627
|
+
return {
|
|
15628
|
+
llmsTxt: {
|
|
15629
|
+
url: result.url,
|
|
15630
|
+
exists,
|
|
15631
|
+
availability,
|
|
15632
|
+
...result.status === void 0 ? {} : { status: result.status },
|
|
15633
|
+
...result.contentType ? { contentType: result.contentType } : {},
|
|
15634
|
+
...servesHtml ? { servesHtml } : {},
|
|
15635
|
+
...result.error ? { error: result.error } : {},
|
|
15636
|
+
...result.errorKind ? { errorKind: result.errorKind } : {}
|
|
15637
|
+
},
|
|
15638
|
+
text: exists ? result.text : ""
|
|
15192
15639
|
};
|
|
15193
|
-
|
|
15194
|
-
|
|
15640
|
+
}
|
|
15641
|
+
async function checkLlmsTxt$1(input) {
|
|
15642
|
+
const { llmsTxt: base, text } = await readLlmsTxt(input);
|
|
15643
|
+
if (!base.exists) return base;
|
|
15644
|
+
const found = llmsLinks(text, base.url);
|
|
15195
15645
|
const broken = [];
|
|
15196
15646
|
let checked = 0;
|
|
15197
15647
|
for (const url of found.slice(0, LLMS_LINK_LIMIT)) {
|
|
@@ -15241,13 +15691,20 @@ function sitemapUrlsFromRobots(text) {
|
|
|
15241
15691
|
return [...urls];
|
|
15242
15692
|
}
|
|
15243
15693
|
async function checkRobotsAiAccess(input) {
|
|
15244
|
-
const
|
|
15694
|
+
const robotsUrl = new URL("/robots.txt", input.url).toString();
|
|
15695
|
+
const result = await get$1(input, robotsUrl);
|
|
15245
15696
|
const servesHtml = result.availability === "available" && isHtml(result.contentType);
|
|
15246
15697
|
const availability = servesHtml ? "absent" : result.availability;
|
|
15247
15698
|
const exists = availability === "available";
|
|
15248
15699
|
const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
|
|
15249
15700
|
const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
|
|
15250
|
-
const parsed = parseRobots(
|
|
15701
|
+
const parsed = parseRobots(robotsUrl, exists ? result.text : "");
|
|
15702
|
+
const ruleHosts = /* @__PURE__ */ new Set([new URL(robotsUrl).host, new URL(result.url).host]);
|
|
15703
|
+
const isAllowed = (url, userAgent) => {
|
|
15704
|
+
const target = new URL(url);
|
|
15705
|
+
const local = ruleHosts.has(target.host) ? new URL(`${target.pathname}${target.search}`, robotsUrl).toString() : url;
|
|
15706
|
+
return parsed.isAllowed(local, userAgent) ?? true;
|
|
15707
|
+
};
|
|
15251
15708
|
return {
|
|
15252
15709
|
robotsTxt: {
|
|
15253
15710
|
url: result.url,
|
|
@@ -15257,17 +15714,18 @@ async function checkRobotsAiAccess(input) {
|
|
|
15257
15714
|
...result.contentType ? { contentType: result.contentType } : {},
|
|
15258
15715
|
...servesHtml ? { servesHtml } : {},
|
|
15259
15716
|
...unknown ? { error: result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
|
|
15717
|
+
...result.errorKind ? { errorKind: result.errorKind } : {},
|
|
15260
15718
|
sitemapUrls: exists ? sitemapUrlsFromRobots(result.text) : [],
|
|
15261
15719
|
...unknown ? {} : { contentSignals: exists ? contentSignalsFromRobots(result.text) : [] },
|
|
15262
15720
|
botAccess: AI_BOTS.map(([userAgent, purpose]) => ({
|
|
15263
15721
|
userAgent,
|
|
15264
15722
|
purpose,
|
|
15265
|
-
allowed: unknown ? null :
|
|
15723
|
+
allowed: unknown ? null : isAllowed(input.url, userAgent),
|
|
15266
15724
|
declared: declared.has(userAgent.toLowerCase()),
|
|
15267
15725
|
coveredByWildcard: declared.has("*")
|
|
15268
15726
|
}))
|
|
15269
15727
|
},
|
|
15270
|
-
allows: (url) => unknown ? null :
|
|
15728
|
+
allows: (url) => unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
|
|
15271
15729
|
};
|
|
15272
15730
|
}
|
|
15273
15731
|
async function checkAgentResources(input) {
|
|
@@ -15641,405 +16099,6 @@ async function crawlDataSources(input) {
|
|
|
15641
16099
|
});
|
|
15642
16100
|
return dataSources;
|
|
15643
16101
|
}
|
|
15644
|
-
// packages/core/src/analyze/monitoring/crawl-page.ts
|
|
15645
|
-
function resolvedUrl(href, base) {
|
|
15646
|
-
if (!href) return void 0;
|
|
15647
|
-
try {
|
|
15648
|
-
const url = new URL(href, base);
|
|
15649
|
-
return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
|
|
15650
|
-
} catch {
|
|
15651
|
-
return;
|
|
15652
|
-
}
|
|
15653
|
-
}
|
|
15654
|
-
function hashText(value) {
|
|
15655
|
-
return createHash("sha256").update(value).digest("hex");
|
|
15656
|
-
}
|
|
15657
|
-
function headerValue(headers, name) {
|
|
15658
|
-
const lower = name.toLowerCase();
|
|
15659
|
-
return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
|
|
15660
|
-
}
|
|
15661
|
-
function safeResponseHeaders(headers) {
|
|
15662
|
-
const blocked = /* @__PURE__ */ new Set([
|
|
15663
|
-
"authorization",
|
|
15664
|
-
"cookie",
|
|
15665
|
-
"proxy-authorization",
|
|
15666
|
-
"set-cookie",
|
|
15667
|
-
"www-authenticate"
|
|
15668
|
-
]);
|
|
15669
|
-
return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
|
|
15670
|
-
}
|
|
15671
|
-
function truncate$1(value, max) {
|
|
15672
|
-
return value.length > max ? value.slice(0, max).trimEnd() : value;
|
|
15673
|
-
}
|
|
15674
|
-
function sameDocumentUrl(left, right) {
|
|
15675
|
-
if (!left || !right) return false;
|
|
15676
|
-
try {
|
|
15677
|
-
const a = new URL(left);
|
|
15678
|
-
const b = new URL(right);
|
|
15679
|
-
a.hash = "";
|
|
15680
|
-
b.hash = "";
|
|
15681
|
-
return a.toString() === b.toString();
|
|
15682
|
-
} catch {
|
|
15683
|
-
return left === right;
|
|
15684
|
-
}
|
|
15685
|
-
}
|
|
15686
|
-
function isHtmlContentType(value) {
|
|
15687
|
-
return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
|
|
15688
|
-
}
|
|
15689
|
-
function fetchFailureKind(error, signal) {
|
|
15690
|
-
const record = error;
|
|
15691
|
-
const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
|
|
15692
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
15693
|
-
if (signal?.aborted) return "aborted";
|
|
15694
|
-
if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
|
|
15695
|
-
if ([
|
|
15696
|
-
"ENOTFOUND",
|
|
15697
|
-
"EAI_AGAIN",
|
|
15698
|
-
"EAI_FAIL"
|
|
15699
|
-
].includes(code)) return "dns";
|
|
15700
|
-
if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
|
|
15701
|
-
if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
|
|
15702
|
-
return "unknown";
|
|
15703
|
-
}
|
|
15704
|
-
function responseObservation(requestedUrl, fetched, extraction, extractionError) {
|
|
15705
|
-
const observation = {
|
|
15706
|
-
requestedUrl,
|
|
15707
|
-
outcome: "response",
|
|
15708
|
-
finalUrl: fetched.finalUrl,
|
|
15709
|
-
status: fetched.status,
|
|
15710
|
-
contentType: headerValue(fetched.headers, "content-type"),
|
|
15711
|
-
durationMs: fetched.diagnostics.durationMs,
|
|
15712
|
-
redirectChain: fetched.diagnostics.redirectChain,
|
|
15713
|
-
accessBlock: fetched.diagnostics.accessBlock,
|
|
15714
|
-
robotsTxt: fetched.robotsTxt
|
|
15715
|
-
};
|
|
15716
|
-
if (extraction === "failed") {
|
|
15717
|
-
if (!extractionError) throw new Error("Failed extraction observations require an error.");
|
|
15718
|
-
return {
|
|
15719
|
-
...observation,
|
|
15720
|
-
extraction,
|
|
15721
|
-
extractionError
|
|
15722
|
-
};
|
|
15723
|
-
}
|
|
15724
|
-
return {
|
|
15725
|
-
...observation,
|
|
15726
|
-
extraction
|
|
15727
|
-
};
|
|
15728
|
-
}
|
|
15729
|
-
function unextractedPage(fetched, contentType) {
|
|
15730
|
-
const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
|
|
15731
|
-
const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
|
|
15732
|
-
const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
|
|
15733
|
-
const knownNonHtml = Boolean(contentType);
|
|
15734
|
-
return {
|
|
15735
|
-
url: fetched.finalUrl,
|
|
15736
|
-
finalUrl: fetched.finalUrl,
|
|
15737
|
-
status: fetched.status,
|
|
15738
|
-
contentType,
|
|
15739
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15740
|
-
sizeBytes: Buffer.byteLength(fetched.html),
|
|
15741
|
-
usedJs: fetched.usedJs,
|
|
15742
|
-
fetchSource: fetched.diagnostics.source,
|
|
15743
|
-
cacheState: fetched.diagnostics.cache,
|
|
15744
|
-
fetchDiagnostics: fetched.diagnostics,
|
|
15745
|
-
accessBlock: fetched.diagnostics.accessBlock,
|
|
15746
|
-
blocked: fetched.diagnostics.blocked,
|
|
15747
|
-
robotsTxt: fetched.robotsTxt,
|
|
15748
|
-
xRobotsTag,
|
|
15749
|
-
canonical: canonicalEvidence.selectedUrl,
|
|
15750
|
-
canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
|
|
15751
|
-
canonicalStatus: canonicalEvidence.status,
|
|
15752
|
-
canonicalCandidates: canonicalEvidence.candidates,
|
|
15753
|
-
indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
|
|
15754
|
-
indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
|
|
15755
|
-
declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
|
|
15756
|
-
extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
|
|
15757
|
-
wordCount: 0,
|
|
15758
|
-
contentHash: hashText(fetched.html),
|
|
15759
|
-
outgoingInternalCount: 0,
|
|
15760
|
-
outgoingExternalCount: 0,
|
|
15761
|
-
isHttps: new URL(fetched.finalUrl).protocol === "https:",
|
|
15762
|
-
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
15763
|
-
compression: headerValue(fetched.headers, "content-encoding")
|
|
15764
|
-
};
|
|
15765
|
-
}
|
|
15766
|
-
function statusOnlyPage(fetched) {
|
|
15767
|
-
const contentType = headerValue(fetched.headers, "content-type");
|
|
15768
|
-
return {
|
|
15769
|
-
url: fetched.finalUrl,
|
|
15770
|
-
finalUrl: fetched.finalUrl,
|
|
15771
|
-
status: fetched.status,
|
|
15772
|
-
contentType,
|
|
15773
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15774
|
-
auditScope: "status",
|
|
15775
|
-
contentAuditAllowed: false,
|
|
15776
|
-
indexable: false,
|
|
15777
|
-
indexability: "Not evaluated by status-only health probe",
|
|
15778
|
-
declaredIndexability: "unknown",
|
|
15779
|
-
extractionStatus: "not-applicable",
|
|
15780
|
-
wordCount: 0,
|
|
15781
|
-
contentHash: "",
|
|
15782
|
-
outgoingInternalCount: 0,
|
|
15783
|
-
outgoingExternalCount: 0
|
|
15784
|
-
};
|
|
15785
|
-
}
|
|
15786
|
-
function indexabilityReason(page) {
|
|
15787
|
-
if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
|
|
15788
|
-
if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
|
|
15789
|
-
if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
|
|
15790
|
-
}
|
|
15791
|
-
async function crawlOne(url, opts = {}) {
|
|
15792
|
-
const startedAt = Date.now();
|
|
15793
|
-
let fetched;
|
|
15794
|
-
try {
|
|
15795
|
-
fetched = await fetchPage(url, opts);
|
|
15796
|
-
} catch (error) {
|
|
15797
|
-
if (error instanceof OriginBackpressureError) return {
|
|
15798
|
-
request: {
|
|
15799
|
-
requestedUrl: url,
|
|
15800
|
-
outcome: "skipped",
|
|
15801
|
-
reason: "origin-backpressure",
|
|
15802
|
-
error: error.message,
|
|
15803
|
-
extraction: "not-applicable"
|
|
15804
|
-
},
|
|
15805
|
-
urls: []
|
|
15806
|
-
};
|
|
15807
|
-
if (error instanceof RobotsAccessError) return {
|
|
15808
|
-
request: {
|
|
15809
|
-
requestedUrl: url,
|
|
15810
|
-
outcome: "skipped",
|
|
15811
|
-
reason: error.reason,
|
|
15812
|
-
robotsTxt: {
|
|
15813
|
-
url: error.evidence.url,
|
|
15814
|
-
allowed: error.evidence.allowed,
|
|
15815
|
-
availability: error.evidence.availability,
|
|
15816
|
-
status: error.evidence.status,
|
|
15817
|
-
error: error.evidence.error,
|
|
15818
|
-
matchedLine: error.evidence.matchedLine
|
|
15819
|
-
},
|
|
15820
|
-
extraction: "not-applicable"
|
|
15821
|
-
},
|
|
15822
|
-
urls: []
|
|
15823
|
-
};
|
|
15824
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
15825
|
-
return {
|
|
15826
|
-
request: {
|
|
15827
|
-
requestedUrl: url,
|
|
15828
|
-
outcome: "failure",
|
|
15829
|
-
durationMs: Date.now() - startedAt,
|
|
15830
|
-
failureKind: fetchFailureKind(error, opts.signal),
|
|
15831
|
-
error: message,
|
|
15832
|
-
extraction: "not-applicable"
|
|
15833
|
-
},
|
|
15834
|
-
urls: [],
|
|
15835
|
-
warning: `${url}: ${message}`
|
|
15836
|
-
};
|
|
15837
|
-
}
|
|
15838
|
-
return snapshotFetchedPage(url, fetched);
|
|
15839
|
-
}
|
|
15840
|
-
async function snapshotFetchedPage(url, fetched) {
|
|
15841
|
-
const contentType = headerValue(fetched.headers, "content-type");
|
|
15842
|
-
if (fetched.diagnostics.accessBlock) {
|
|
15843
|
-
const page = unextractedPage(fetched, contentType);
|
|
15844
|
-
page.contentAuditAllowed = false;
|
|
15845
|
-
page.indexable = false;
|
|
15846
|
-
page.indexability = "Crawler access blocked";
|
|
15847
|
-
page.declaredIndexability = "unknown";
|
|
15848
|
-
page.extractionStatus = "not-applicable";
|
|
15849
|
-
return {
|
|
15850
|
-
request: responseObservation(url, fetched, "not-applicable"),
|
|
15851
|
-
page,
|
|
15852
|
-
urls: []
|
|
15853
|
-
};
|
|
15854
|
-
}
|
|
15855
|
-
if (!isHtmlContentType(contentType)) return {
|
|
15856
|
-
request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
|
|
15857
|
-
page: unextractedPage(fetched, contentType),
|
|
15858
|
-
urls: []
|
|
15859
|
-
};
|
|
15860
|
-
try {
|
|
15861
|
-
const extracted = await extractPage(fetched, "crawler");
|
|
15862
|
-
const crawlerEvidence = extracted.crawlerEvidence;
|
|
15863
|
-
if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
|
|
15864
|
-
const h1 = crawlerEvidence.h1;
|
|
15865
|
-
const uniqueInternalLinks = crawlerEvidence.internalLinks;
|
|
15866
|
-
const uniqueExternalLinks = crawlerEvidence.externalLinks;
|
|
15867
|
-
const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
|
|
15868
|
-
const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
|
|
15869
|
-
const page = {
|
|
15870
|
-
url: extracted.finalUrl,
|
|
15871
|
-
finalUrl: extracted.finalUrl,
|
|
15872
|
-
status: fetched.status,
|
|
15873
|
-
contentType,
|
|
15874
|
-
responseHeaders: safeResponseHeaders(fetched.headers),
|
|
15875
|
-
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15876
|
-
sizeBytes: Buffer.byteLength(fetched.html),
|
|
15877
|
-
usedJs: fetched.usedJs,
|
|
15878
|
-
fetchSource: fetched.diagnostics.source,
|
|
15879
|
-
cacheState: fetched.diagnostics.cache,
|
|
15880
|
-
fetchDiagnostics: fetched.diagnostics,
|
|
15881
|
-
blocked: fetched.diagnostics.blocked,
|
|
15882
|
-
robotsTxt: fetched.robotsTxt,
|
|
15883
|
-
title: extracted.title,
|
|
15884
|
-
metaDescription: extracted.metaDescription,
|
|
15885
|
-
canonical,
|
|
15886
|
-
canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
|
|
15887
|
-
canonicalStatus: extracted.canonicalEvidence?.status,
|
|
15888
|
-
canonicalCandidates: extracted.canonicalEvidence?.candidates,
|
|
15889
|
-
metaRobots: extracted.metaRobots,
|
|
15890
|
-
xRobotsTag: extracted.xRobotsTag,
|
|
15891
|
-
h1,
|
|
15892
|
-
h1Count: crawlerEvidence.h1Count,
|
|
15893
|
-
h2Count: crawlerEvidence.h2Count,
|
|
15894
|
-
h3Count: crawlerEvidence.h3Count,
|
|
15895
|
-
indexable: false,
|
|
15896
|
-
extractionStatus: "complete",
|
|
15897
|
-
wordCount: extracted.wordCount,
|
|
15898
|
-
contentExtraction: extracted.contentExtraction,
|
|
15899
|
-
warnings: [
|
|
15900
|
-
...extracted.warnings,
|
|
15901
|
-
...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
|
|
15902
|
-
...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
|
|
15903
|
-
...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
|
|
15904
|
-
...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."] : []
|
|
15905
|
-
],
|
|
15906
|
-
contentHash: hashText([
|
|
15907
|
-
extracted.title,
|
|
15908
|
-
extracted.metaDescription,
|
|
15909
|
-
h1,
|
|
15910
|
-
extracted.canonical,
|
|
15911
|
-
extracted.contentText
|
|
15912
|
-
].join("\n")),
|
|
15913
|
-
mainContentHash: hashText(extracted.contentText),
|
|
15914
|
-
textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
|
|
15915
|
-
contentSample: truncate$1(extracted.contentText, 300),
|
|
15916
|
-
contentSketch: crawlerEvidence.contentSketch,
|
|
15917
|
-
softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
|
|
15918
|
-
metaRefresh: crawlerEvidence.metaRefresh,
|
|
15919
|
-
tabbedContent: crawlerEvidence.tabbedContent,
|
|
15920
|
-
lang: extracted.lang,
|
|
15921
|
-
hasViewport: extracted.hasViewport,
|
|
15922
|
-
isHttps: new URL(extracted.finalUrl).protocol === "https:",
|
|
15923
|
-
hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
|
|
15924
|
-
compression: headerValue(fetched.headers, "content-encoding"),
|
|
15925
|
-
hreflang: extracted.hreflang,
|
|
15926
|
-
mixedContentCount: extracted.mixedContentUrls.length,
|
|
15927
|
-
mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
|
|
15928
|
-
imagesTotal: extracted.imagesTotal,
|
|
15929
|
-
imagesMissingAlt: extracted.imagesMissingAlt,
|
|
15930
|
-
oversizedImageCandidates: extracted.oversizedImageCandidates,
|
|
15931
|
-
outgoingInternalCount: uniqueInternalLinks.length,
|
|
15932
|
-
outgoingExternalCount: uniqueExternalLinks.length,
|
|
15933
|
-
sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
|
|
15934
|
-
sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
|
|
15935
|
-
internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
|
|
15936
|
-
externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
|
|
15937
|
-
markdownAlternates: extracted.markdownAlternates,
|
|
15938
|
-
describedBy: extracted.describedBy,
|
|
15939
|
-
schemaTypes: extracted.schemaTypes,
|
|
15940
|
-
structuredDataFormats: extracted.structuredDataFormats ?? [],
|
|
15941
|
-
googleRichResults: richResults.assessments,
|
|
15942
|
-
googleRichResultsSelection: richResults.selection,
|
|
15943
|
-
schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
|
|
15944
|
-
schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
|
|
15945
|
-
invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
|
|
15946
|
-
socialProfileLinks: crawlerEvidence.socialProfileLinks,
|
|
15947
|
-
invalidJsonLdCount: extracted.invalidJsonLdCount,
|
|
15948
|
-
invalidJsonLdSamples: extracted.invalidJsonLdSamples,
|
|
15949
|
-
unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
|
|
15950
|
-
openGraphTitle: extracted.openGraph["og:title"],
|
|
15951
|
-
openGraphDescription: extracted.openGraph["og:description"],
|
|
15952
|
-
openGraphImage: extracted.openGraph["og:image"],
|
|
15953
|
-
twitterCard: extracted.twitter["twitter:card"],
|
|
15954
|
-
author: extracted.author,
|
|
15955
|
-
hasDate: extracted.hasDate,
|
|
15956
|
-
geo: {
|
|
15957
|
-
semanticHtml: extracted.semanticHtml,
|
|
15958
|
-
structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
|
|
15959
|
-
hasAuthor: extracted.hasAuthor,
|
|
15960
|
-
hasDate: extracted.hasDate,
|
|
15961
|
-
questionHeadings: extracted.questionHeadings,
|
|
15962
|
-
listCount: extracted.listCount,
|
|
15963
|
-
tableCount: extracted.tableCount,
|
|
15964
|
-
structuredBlocks: extracted.structuredBlocks,
|
|
15965
|
-
answerable: extracted.answerable,
|
|
15966
|
-
hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
|
|
15967
|
-
hasQapageSchema: extracted.schemaTypes.includes("QAPage")
|
|
15968
|
-
}
|
|
15969
|
-
};
|
|
15970
|
-
page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
|
|
15971
|
-
page.indexable = !page.indexability;
|
|
15972
|
-
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";
|
|
15973
|
-
return {
|
|
15974
|
-
request: responseObservation(url, fetched, "complete"),
|
|
15975
|
-
page,
|
|
15976
|
-
urls: uniqueInternalLinks
|
|
15977
|
-
};
|
|
15978
|
-
} catch (error) {
|
|
15979
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
15980
|
-
return {
|
|
15981
|
-
request: responseObservation(url, fetched, "failed", message),
|
|
15982
|
-
urls: [],
|
|
15983
|
-
warning: `${url}: content extraction failed: ${message}`
|
|
15984
|
-
};
|
|
15985
|
-
}
|
|
15986
|
-
}
|
|
15987
|
-
async function crawlStatusOnly(url, opts = {}) {
|
|
15988
|
-
const startedAt = Date.now();
|
|
15989
|
-
try {
|
|
15990
|
-
const fetched = await fetchPageStatus(url, {
|
|
15991
|
-
...opts,
|
|
15992
|
-
js: "off",
|
|
15993
|
-
refresh: true
|
|
15994
|
-
});
|
|
15995
|
-
return {
|
|
15996
|
-
request: responseObservation(url, fetched, "not-applicable"),
|
|
15997
|
-
page: statusOnlyPage(fetched),
|
|
15998
|
-
urls: []
|
|
15999
|
-
};
|
|
16000
|
-
} catch (error) {
|
|
16001
|
-
if (error instanceof OriginBackpressureError) return {
|
|
16002
|
-
request: {
|
|
16003
|
-
requestedUrl: url,
|
|
16004
|
-
outcome: "skipped",
|
|
16005
|
-
reason: "origin-backpressure",
|
|
16006
|
-
error: error.message,
|
|
16007
|
-
extraction: "not-applicable"
|
|
16008
|
-
},
|
|
16009
|
-
urls: []
|
|
16010
|
-
};
|
|
16011
|
-
if (error instanceof RobotsAccessError) return {
|
|
16012
|
-
request: {
|
|
16013
|
-
requestedUrl: url,
|
|
16014
|
-
outcome: "skipped",
|
|
16015
|
-
reason: error.reason,
|
|
16016
|
-
robotsTxt: {
|
|
16017
|
-
url: error.evidence.url,
|
|
16018
|
-
allowed: error.evidence.allowed,
|
|
16019
|
-
availability: error.evidence.availability,
|
|
16020
|
-
status: error.evidence.status,
|
|
16021
|
-
error: error.evidence.error,
|
|
16022
|
-
matchedLine: error.evidence.matchedLine
|
|
16023
|
-
},
|
|
16024
|
-
extraction: "not-applicable"
|
|
16025
|
-
},
|
|
16026
|
-
urls: []
|
|
16027
|
-
};
|
|
16028
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
16029
|
-
return {
|
|
16030
|
-
request: {
|
|
16031
|
-
requestedUrl: url,
|
|
16032
|
-
outcome: "failure",
|
|
16033
|
-
durationMs: Date.now() - startedAt,
|
|
16034
|
-
failureKind: fetchFailureKind(error, opts.signal),
|
|
16035
|
-
error: message,
|
|
16036
|
-
extraction: "not-applicable"
|
|
16037
|
-
},
|
|
16038
|
-
urls: [],
|
|
16039
|
-
warning: `${url}: ${message}`
|
|
16040
|
-
};
|
|
16041
|
-
}
|
|
16042
|
-
}
|
|
16043
16102
|
// packages/core/src/analyze/monitoring/sitemaps.ts
|
|
16044
16103
|
const INVALID_LOC_SAMPLE_LIMIT = 10;
|
|
16045
16104
|
const LASTMOD_SAMPLE_LIMIT = 10;
|
|
@@ -18491,6 +18550,289 @@ async function crawlSite(input, dependencies) {
|
|
|
18491
18550
|
await renderer?.close();
|
|
18492
18551
|
}
|
|
18493
18552
|
}
|
|
18553
|
+
// packages/core/src/analyze/crawler/site-facts.ts
|
|
18554
|
+
const SITE_FACTS_MAX_SITES = 1e3;
|
|
18555
|
+
const SITE_FACTS_DEFAULT_CONCURRENCY = 8;
|
|
18556
|
+
const SITE_FACTS_MAX_CONCURRENCY = 16;
|
|
18557
|
+
const SITE_FACTS_TIMEOUT_MS = REQUEST_TIMEOUT_CAP_MS;
|
|
18558
|
+
const SITEMAP_URL_LIMIT = 20;
|
|
18559
|
+
const MARKDOWN_ALTERNATE_LIMIT = 5;
|
|
18560
|
+
const TEXT_LIMIT = 300;
|
|
18561
|
+
function siteOrigin(value) {
|
|
18562
|
+
const text = value.trim();
|
|
18563
|
+
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text) ? text : `https://${text}`;
|
|
18564
|
+
try {
|
|
18565
|
+
const url = new URL(withScheme);
|
|
18566
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
18567
|
+
if (!url.hostname || /\s/.test(text)) return void 0;
|
|
18568
|
+
return url.origin;
|
|
18569
|
+
} catch {
|
|
18570
|
+
return;
|
|
18571
|
+
}
|
|
18572
|
+
}
|
|
18573
|
+
const clip$1 = (value) => value === void 0 ? void 0 : value.slice(0, TEXT_LIMIT);
|
|
18574
|
+
function emptyCounts() {
|
|
18575
|
+
return {
|
|
18576
|
+
available: 0,
|
|
18577
|
+
absent: 0,
|
|
18578
|
+
"access-blocked": 0,
|
|
18579
|
+
"rate-limited": 0,
|
|
18580
|
+
unreachable: 0,
|
|
18581
|
+
failed: 0
|
|
18582
|
+
};
|
|
18583
|
+
}
|
|
18584
|
+
function availabilityOf(status) {
|
|
18585
|
+
if (status >= 200 && status < 300) return "available";
|
|
18586
|
+
if (status === 401 || status === 403) return "access-blocked";
|
|
18587
|
+
if (status === 429) return "rate-limited";
|
|
18588
|
+
if (status >= 400 && status < 500) return "absent";
|
|
18589
|
+
return "unreachable";
|
|
18590
|
+
}
|
|
18591
|
+
async function readHomepage(url, fetch, signal) {
|
|
18592
|
+
const startedAt = Date.now();
|
|
18593
|
+
const controller = abortController({
|
|
18594
|
+
timeoutMs: SITE_FACTS_TIMEOUT_MS,
|
|
18595
|
+
signal
|
|
18596
|
+
});
|
|
18597
|
+
try {
|
|
18598
|
+
const { response, redirectChain } = await fetchWithRedirectChain(url, controller.signal, fetch);
|
|
18599
|
+
const finalUrl = redirectChain.at(-1)?.location ?? url;
|
|
18600
|
+
const accessBlock = detectAccessBlock({
|
|
18601
|
+
status: response.status,
|
|
18602
|
+
headers: response.headers
|
|
18603
|
+
});
|
|
18604
|
+
let html = "";
|
|
18605
|
+
if (accessBlock) await response.body?.cancel().catch(() => void 0);
|
|
18606
|
+
else html = await readBoundedResponseText(response, MAX_PAGE_RESPONSE_BYTES, "Home page response");
|
|
18607
|
+
const headers = Object.fromEntries(response.headers.entries());
|
|
18608
|
+
const fetched = {
|
|
18609
|
+
url,
|
|
18610
|
+
finalUrl,
|
|
18611
|
+
status: response.status,
|
|
18612
|
+
headers,
|
|
18613
|
+
html,
|
|
18614
|
+
usedJs: false,
|
|
18615
|
+
diagnostics: {
|
|
18616
|
+
source: "network",
|
|
18617
|
+
cache: "bypass",
|
|
18618
|
+
fetched: true,
|
|
18619
|
+
rendered: false,
|
|
18620
|
+
blocked: Boolean(accessBlock),
|
|
18621
|
+
durationMs: Date.now() - startedAt,
|
|
18622
|
+
retries: 0,
|
|
18623
|
+
rateLimit: {
|
|
18624
|
+
host: new URL(url).host,
|
|
18625
|
+
concurrency: 1,
|
|
18626
|
+
intervalCap: 1,
|
|
18627
|
+
intervalMs: 0
|
|
18628
|
+
},
|
|
18629
|
+
accessBlock,
|
|
18630
|
+
redirectChain
|
|
18631
|
+
},
|
|
18632
|
+
warnings: []
|
|
18633
|
+
};
|
|
18634
|
+
const page = (await snapshotFetchedPage(url, fetched)).page;
|
|
18635
|
+
return {
|
|
18636
|
+
url,
|
|
18637
|
+
outcome: "response",
|
|
18638
|
+
availability: accessBlock ? "access-blocked" : availabilityOf(response.status),
|
|
18639
|
+
status: response.status,
|
|
18640
|
+
finalUrl,
|
|
18641
|
+
...redirectChain.length ? { redirects: redirectChain } : {},
|
|
18642
|
+
...headers["content-type"] ? { contentType: headers["content-type"] } : {},
|
|
18643
|
+
...accessBlock ? { accessBlock: {
|
|
18644
|
+
provider: accessBlock.provider,
|
|
18645
|
+
kind: accessBlock.kind
|
|
18646
|
+
} } : {},
|
|
18647
|
+
responseTimeMs: fetched.diagnostics.durationMs,
|
|
18648
|
+
sizeBytes: new TextEncoder().encode(html).byteLength,
|
|
18649
|
+
...page?.title ? { title: clip$1(page.title) } : {},
|
|
18650
|
+
...page?.metaDescription ? { metaDescription: clip$1(page.metaDescription) } : {},
|
|
18651
|
+
...page?.h1 ? { h1: clip$1(page.h1) } : {},
|
|
18652
|
+
...page?.lang ? { lang: clip$1(page.lang) } : {},
|
|
18653
|
+
...page?.canonical ? { canonical: page.canonical } : {},
|
|
18654
|
+
...page?.metaRobots ? { metaRobots: clip$1(page.metaRobots) } : {},
|
|
18655
|
+
...headers["x-robots-tag"] ? { xRobotsTag: clip$1(headers["x-robots-tag"]) } : {},
|
|
18656
|
+
...page ? { indexable: page.indexable } : {},
|
|
18657
|
+
...page?.indexability ? { indexability: page.indexability } : {},
|
|
18658
|
+
...page?.markdownAlternates?.length ? { markdownAlternates: page.markdownAlternates.slice(0, MARKDOWN_ALTERNATE_LIMIT) } : {},
|
|
18659
|
+
...headers["content-signal"] ? { contentSignal: clip$1(headers["content-signal"]) } : {},
|
|
18660
|
+
linkRelations: linkRelations(headers.link)
|
|
18661
|
+
};
|
|
18662
|
+
} catch (error) {
|
|
18663
|
+
return {
|
|
18664
|
+
url,
|
|
18665
|
+
outcome: "failed",
|
|
18666
|
+
...requestError$1(error, SITE_FACTS_TIMEOUT_MS, signal)
|
|
18667
|
+
};
|
|
18668
|
+
} finally {
|
|
18669
|
+
controller.cleanup();
|
|
18670
|
+
}
|
|
18671
|
+
}
|
|
18672
|
+
function llmsH1(text) {
|
|
18673
|
+
return (text.replace(/^\uFEFF/, "").split(/\r?\n/).find((line) => line.trim())?.match(/^#\s+(.+?)\s*#*\s*$/))?.[1];
|
|
18674
|
+
}
|
|
18675
|
+
async function checkSite(origin, input, fetch) {
|
|
18676
|
+
const home = `${origin}/`;
|
|
18677
|
+
const signalInput = {
|
|
18678
|
+
url: home,
|
|
18679
|
+
timeoutMs: SITE_FACTS_TIMEOUT_MS,
|
|
18680
|
+
fetch,
|
|
18681
|
+
signal: input.signal
|
|
18682
|
+
};
|
|
18683
|
+
const robots = await checkRobotsAiAccess(signalInput);
|
|
18684
|
+
const homepageAllowed = robots.allows(home);
|
|
18685
|
+
const { sitemapUrls, ...robotsTxt } = robots.robotsTxt;
|
|
18686
|
+
const homepage = input.respectRobots !== false && homepageAllowed !== true ? {
|
|
18687
|
+
url: home,
|
|
18688
|
+
outcome: "skipped",
|
|
18689
|
+
skipReason: homepageAllowed === false ? "robots-disallowed" : "robots-unknown"
|
|
18690
|
+
} : await readHomepage(home, fetch, input.signal);
|
|
18691
|
+
const llms = await readLlmsTxt(signalInput);
|
|
18692
|
+
const h1 = llms.llmsTxt.exists ? llmsH1(llms.text) : void 0;
|
|
18693
|
+
return {
|
|
18694
|
+
origin,
|
|
18695
|
+
robotsTxt: {
|
|
18696
|
+
...robotsTxt,
|
|
18697
|
+
sitemapUrls: sitemapUrls.slice(0, SITEMAP_URL_LIMIT),
|
|
18698
|
+
sitemapCount: sitemapUrls.length,
|
|
18699
|
+
homepageAllowed
|
|
18700
|
+
},
|
|
18701
|
+
homepage,
|
|
18702
|
+
llmsTxt: {
|
|
18703
|
+
...llms.llmsTxt,
|
|
18704
|
+
...llms.llmsTxt.exists ? {
|
|
18705
|
+
sizeBytes: new TextEncoder().encode(llms.text).byteLength,
|
|
18706
|
+
startsWithH1: h1 !== void 0,
|
|
18707
|
+
...h1 ? { h1: clip$1(h1) } : {},
|
|
18708
|
+
sameSiteLinks: llmsLinks(llms.text, llms.llmsTxt.url).length
|
|
18709
|
+
} : {}
|
|
18710
|
+
}
|
|
18711
|
+
};
|
|
18712
|
+
}
|
|
18713
|
+
function countAvailability(counts, facts) {
|
|
18714
|
+
if (!facts.availability) return;
|
|
18715
|
+
counts[facts.availability] += 1;
|
|
18716
|
+
if (facts.status === void 0) counts.failed += 1;
|
|
18717
|
+
}
|
|
18718
|
+
async function checkSites(input, dependencies = {}) {
|
|
18719
|
+
const fetch = dependencies.fetch ?? publicHttpFetch;
|
|
18720
|
+
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
18721
|
+
const concurrency = input.concurrency ?? 8;
|
|
18722
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new SeoError("INVALID_INPUT", `Concurrency must be an integer from 1 to 16.`);
|
|
18723
|
+
const entries = [];
|
|
18724
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18725
|
+
let duplicatesRemoved = 0;
|
|
18726
|
+
for (const raw of input.sites) {
|
|
18727
|
+
const value = raw.trim();
|
|
18728
|
+
if (!value) continue;
|
|
18729
|
+
const origin = siteOrigin(value);
|
|
18730
|
+
const key = origin ?? value;
|
|
18731
|
+
if (seen.has(key)) {
|
|
18732
|
+
duplicatesRemoved += 1;
|
|
18733
|
+
continue;
|
|
18734
|
+
}
|
|
18735
|
+
seen.add(key);
|
|
18736
|
+
entries.push({
|
|
18737
|
+
input: value,
|
|
18738
|
+
origin
|
|
18739
|
+
});
|
|
18740
|
+
}
|
|
18741
|
+
if (!entries.length) throw new SeoError("INVALID_INPUT", "No sites given.");
|
|
18742
|
+
if (entries.length > 1e3) throw new SeoError("INVALID_INPUT", `The list has ${entries.length.toLocaleString("en-US")} sites after removing duplicates. Sites mode checks up to ${SITE_FACTS_MAX_SITES.toLocaleString("en-US")} per run, so split the list and run each part.`);
|
|
18743
|
+
const startedAt = Date.now();
|
|
18744
|
+
const summary = {
|
|
18745
|
+
type: "summary",
|
|
18746
|
+
status: "complete",
|
|
18747
|
+
sites: entries.length,
|
|
18748
|
+
checked: 0,
|
|
18749
|
+
invalid: 0,
|
|
18750
|
+
duplicatesRemoved,
|
|
18751
|
+
maxSites: SITE_FACTS_MAX_SITES,
|
|
18752
|
+
concurrency,
|
|
18753
|
+
timeoutMs: SITE_FACTS_TIMEOUT_MS,
|
|
18754
|
+
durationMs: 0,
|
|
18755
|
+
robotsTxt: emptyCounts(),
|
|
18756
|
+
homepage: {
|
|
18757
|
+
...emptyCounts(),
|
|
18758
|
+
skipped: 0
|
|
18759
|
+
},
|
|
18760
|
+
llmsTxt: {
|
|
18761
|
+
...emptyCounts(),
|
|
18762
|
+
startsWithH1: 0
|
|
18763
|
+
},
|
|
18764
|
+
botAccess: []
|
|
18765
|
+
};
|
|
18766
|
+
const bots = /* @__PURE__ */ new Map();
|
|
18767
|
+
let active = 0;
|
|
18768
|
+
const waiting = [];
|
|
18769
|
+
const run = async (entry) => {
|
|
18770
|
+
if (!entry.origin) return {
|
|
18771
|
+
type: "site",
|
|
18772
|
+
input: entry.input,
|
|
18773
|
+
error: "Not an http or https site."
|
|
18774
|
+
};
|
|
18775
|
+
while (active >= concurrency) await new Promise((resolve) => waiting.push(resolve));
|
|
18776
|
+
active += 1;
|
|
18777
|
+
try {
|
|
18778
|
+
if (input.signal?.aborted) return void 0;
|
|
18779
|
+
const checkedAt = now().toISOString();
|
|
18780
|
+
const facts = await checkSite(entry.origin, input, fetch);
|
|
18781
|
+
return {
|
|
18782
|
+
type: "site",
|
|
18783
|
+
input: entry.input,
|
|
18784
|
+
checkedAt,
|
|
18785
|
+
...facts
|
|
18786
|
+
};
|
|
18787
|
+
} finally {
|
|
18788
|
+
active -= 1;
|
|
18789
|
+
waiting.shift()?.();
|
|
18790
|
+
}
|
|
18791
|
+
};
|
|
18792
|
+
const windowSize = concurrency * 4;
|
|
18793
|
+
const window = [];
|
|
18794
|
+
let next = 0;
|
|
18795
|
+
while (next < entries.length || window.length) {
|
|
18796
|
+
while (next < entries.length && window.length < windowSize) {
|
|
18797
|
+
window.push(run(entries[next]));
|
|
18798
|
+
next += 1;
|
|
18799
|
+
}
|
|
18800
|
+
const row = await window.shift();
|
|
18801
|
+
if (!row) continue;
|
|
18802
|
+
if (row.error) summary.invalid += 1;
|
|
18803
|
+
else summary.checked += 1;
|
|
18804
|
+
if (row.robotsTxt) {
|
|
18805
|
+
countAvailability(summary.robotsTxt, row.robotsTxt);
|
|
18806
|
+
for (const bot of row.robotsTxt.botAccess) {
|
|
18807
|
+
const counts = bots.get(bot.userAgent) ?? {
|
|
18808
|
+
userAgent: bot.userAgent,
|
|
18809
|
+
purpose: bot.purpose,
|
|
18810
|
+
allowed: 0,
|
|
18811
|
+
blocked: 0,
|
|
18812
|
+
unknown: 0
|
|
18813
|
+
};
|
|
18814
|
+
if (bot.allowed === true) counts.allowed += 1;
|
|
18815
|
+
else if (bot.allowed === false) counts.blocked += 1;
|
|
18816
|
+
else counts.unknown += 1;
|
|
18817
|
+
bots.set(bot.userAgent, counts);
|
|
18818
|
+
}
|
|
18819
|
+
}
|
|
18820
|
+
if (row.homepage?.outcome === "skipped") summary.homepage.skipped += 1;
|
|
18821
|
+
else if (row.homepage?.outcome === "failed") {
|
|
18822
|
+
summary.homepage.unreachable += 1;
|
|
18823
|
+
summary.homepage.failed += 1;
|
|
18824
|
+
} else if (row.homepage) countAvailability(summary.homepage, row.homepage);
|
|
18825
|
+
if (row.llmsTxt) {
|
|
18826
|
+
countAvailability(summary.llmsTxt, row.llmsTxt);
|
|
18827
|
+
if (row.llmsTxt.startsWithH1) summary.llmsTxt.startsWithH1 += 1;
|
|
18828
|
+
}
|
|
18829
|
+
await input.onRow(row);
|
|
18830
|
+
}
|
|
18831
|
+
if (input.signal?.aborted) summary.status = "cancelled";
|
|
18832
|
+
summary.botAccess = [...bots.values()];
|
|
18833
|
+
summary.durationMs = Date.now() - startedAt;
|
|
18834
|
+
return summary;
|
|
18835
|
+
}
|
|
18494
18836
|
// packages/core/src/brand-evidence.ts
|
|
18495
18837
|
const NAVIGATIONAL_STEM_THRESHOLDS = {
|
|
18496
18838
|
maximumPosition: 2,
|
|
@@ -26254,16 +26596,83 @@ async function indexCoverageSignals(input, dependencies = defaultDependencies$1)
|
|
|
26254
26596
|
}
|
|
26255
26597
|
};
|
|
26256
26598
|
}
|
|
26257
|
-
|
|
26258
|
-
|
|
26599
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
26600
|
+
301,
|
|
26601
|
+
302,
|
|
26602
|
+
303,
|
|
26603
|
+
307,
|
|
26604
|
+
308
|
|
26605
|
+
]);
|
|
26606
|
+
function errorText(error) {
|
|
26607
|
+
if (!(error instanceof Error)) return String(error);
|
|
26608
|
+
const cause = error.cause;
|
|
26609
|
+
const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
|
|
26610
|
+
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
|
|
26611
|
+
}
|
|
26612
|
+
function httpUrl(value, base) {
|
|
26259
26613
|
try {
|
|
26260
|
-
const
|
|
26261
|
-
|
|
26262
|
-
|
|
26614
|
+
const url = new URL(value, base);
|
|
26615
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
26616
|
+
url.hash = "";
|
|
26617
|
+
return url.toString();
|
|
26263
26618
|
} catch {
|
|
26264
|
-
return;
|
|
26619
|
+
return null;
|
|
26620
|
+
}
|
|
26621
|
+
}
|
|
26622
|
+
async function traceRedirects(url, options = {}) {
|
|
26623
|
+
const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
|
|
26624
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
26625
|
+
const request = options.fetch ?? ((to, init) => fetch(to, init));
|
|
26626
|
+
const steps = [];
|
|
26627
|
+
const end = (final_url, final_status, outcome, error) => ({
|
|
26628
|
+
url,
|
|
26629
|
+
steps,
|
|
26630
|
+
hops: steps.filter((step) => step.location !== null).length,
|
|
26631
|
+
final_url,
|
|
26632
|
+
final_status,
|
|
26633
|
+
outcome,
|
|
26634
|
+
...error ? { error } : {}
|
|
26635
|
+
});
|
|
26636
|
+
const start = httpUrl(url);
|
|
26637
|
+
if (!start) return end(url, null, "error", "Not an http or https URL.");
|
|
26638
|
+
let current = start;
|
|
26639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26640
|
+
while (true) {
|
|
26641
|
+
seen.add(current);
|
|
26642
|
+
const startedAt = Date.now();
|
|
26643
|
+
let response;
|
|
26644
|
+
try {
|
|
26645
|
+
response = await request(current, {
|
|
26646
|
+
redirect: "manual",
|
|
26647
|
+
headers: {
|
|
26648
|
+
"user-agent": SEO_CRAWLER_USER_AGENT,
|
|
26649
|
+
accept: "text/html,application/xhtml+xml,*/*;q=0.8"
|
|
26650
|
+
},
|
|
26651
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
26652
|
+
});
|
|
26653
|
+
} catch (error) {
|
|
26654
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`) : end(current, null, "error", errorText(error));
|
|
26655
|
+
}
|
|
26656
|
+
const ms = Date.now() - startedAt;
|
|
26657
|
+
const header = response.headers.get("location");
|
|
26658
|
+
const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
|
|
26659
|
+
const location = redirects ? httpUrl(header, current) : null;
|
|
26660
|
+
if (options.onResponse) await options.onResponse(response, current);
|
|
26661
|
+
await response.body?.cancel().catch(() => void 0);
|
|
26662
|
+
steps.push({
|
|
26663
|
+
url: current,
|
|
26664
|
+
status: response.status,
|
|
26665
|
+
location,
|
|
26666
|
+
ms
|
|
26667
|
+
});
|
|
26668
|
+
if (!redirects) return end(current, response.status, "done");
|
|
26669
|
+
if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
|
|
26670
|
+
if (seen.has(location)) return end(current, response.status, "loop");
|
|
26671
|
+
if (steps.length > maxHops) return end(current, response.status, "too_many");
|
|
26672
|
+
current = location;
|
|
26265
26673
|
}
|
|
26266
26674
|
}
|
|
26675
|
+
// packages/core/src/analyze/monitoring/redirect-trace.ts
|
|
26267
26676
|
function redirectIssue(input) {
|
|
26268
26677
|
const issues = [];
|
|
26269
26678
|
const finalStatus = input.chain.at(-1)?.status ?? 0;
|
|
@@ -26278,55 +26687,28 @@ function redirectIssue(input) {
|
|
|
26278
26687
|
return [...new Set(issues)];
|
|
26279
26688
|
}
|
|
26280
26689
|
async function redirectTrace(input) {
|
|
26281
|
-
const maxHops = input.maxHops ?? 10;
|
|
26282
26690
|
const warnings = [];
|
|
26283
|
-
const
|
|
26284
|
-
|
|
26285
|
-
|
|
26286
|
-
|
|
26287
|
-
|
|
26288
|
-
|
|
26289
|
-
|
|
26290
|
-
|
|
26291
|
-
|
|
26292
|
-
|
|
26293
|
-
|
|
26294
|
-
|
|
26295
|
-
|
|
26296
|
-
|
|
26297
|
-
|
|
26298
|
-
|
|
26299
|
-
redirect: "manual",
|
|
26300
|
-
signal: controller.signal
|
|
26301
|
-
});
|
|
26302
|
-
const location = response.headers.get("location") ?? void 0;
|
|
26303
|
-
const nextUrl = location ? resolveNextUrl(location, currentUrl) : void 0;
|
|
26304
|
-
chain.push({
|
|
26305
|
-
url: currentUrl,
|
|
26306
|
-
status: response.status,
|
|
26307
|
-
location,
|
|
26308
|
-
nextUrl,
|
|
26309
|
-
durationMs: Date.now() - startedAt
|
|
26310
|
-
});
|
|
26311
|
-
if (response.status < 300 || response.status >= 400) break;
|
|
26312
|
-
if (!nextUrl) break;
|
|
26313
|
-
currentUrl = nextUrl;
|
|
26314
|
-
} catch (error) {
|
|
26315
|
-
warnings.push(`${currentUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
26316
|
-
break;
|
|
26317
|
-
} finally {
|
|
26318
|
-
clearTimeout(timer);
|
|
26319
|
-
}
|
|
26320
|
-
}
|
|
26321
|
-
const lastStep = chain.at(-1);
|
|
26322
|
-
if (lastStep && lastStep.status >= 300 && lastStep.status < 400 && chain.length > maxHops) stoppedForLimit = true;
|
|
26323
|
-
const finalUrl = lastStep?.nextUrl ?? lastStep?.url ?? currentUrl;
|
|
26324
|
-
const final = await crawlOne(finalUrl, {
|
|
26691
|
+
const trace = await traceRedirects(new URL(input.url).toString(), {
|
|
26692
|
+
maxHops: input.maxHops,
|
|
26693
|
+
timeoutMs: input.timeoutMs
|
|
26694
|
+
});
|
|
26695
|
+
const chain = trace.steps.map((step) => ({
|
|
26696
|
+
url: step.url,
|
|
26697
|
+
status: step.status,
|
|
26698
|
+
...step.location ? {
|
|
26699
|
+
location: step.location,
|
|
26700
|
+
nextUrl: step.location
|
|
26701
|
+
} : {},
|
|
26702
|
+
durationMs: step.ms
|
|
26703
|
+
}));
|
|
26704
|
+
if (trace.error) warnings.push(`${trace.final_url}: ${trace.error}`);
|
|
26705
|
+
const finalUrl = trace.final_url;
|
|
26706
|
+
const final = trace.outcome === "done" ? await crawlOne(finalUrl, {
|
|
26325
26707
|
refresh: input.refresh,
|
|
26326
26708
|
js: input.js ?? "auto"
|
|
26327
|
-
});
|
|
26328
|
-
if (final
|
|
26329
|
-
const finalPage = final
|
|
26709
|
+
}) : void 0;
|
|
26710
|
+
if (final?.warning) warnings.push(final.warning);
|
|
26711
|
+
const finalPage = final?.page ? {
|
|
26330
26712
|
title: final.page.title,
|
|
26331
26713
|
canonical: final.page.canonical,
|
|
26332
26714
|
metaRobots: final.page.metaRobots,
|
|
@@ -26338,16 +26720,18 @@ async function redirectTrace(input) {
|
|
|
26338
26720
|
finalIndexable: finalPage?.indexable,
|
|
26339
26721
|
canonical: finalPage?.canonical,
|
|
26340
26722
|
finalUrl,
|
|
26341
|
-
stoppedForLoop,
|
|
26342
|
-
stoppedForLimit
|
|
26723
|
+
stoppedForLoop: trace.outcome === "loop",
|
|
26724
|
+
stoppedForLimit: trace.outcome === "too_many"
|
|
26343
26725
|
});
|
|
26344
26726
|
return {
|
|
26345
26727
|
url: input.url,
|
|
26346
26728
|
finalUrl,
|
|
26347
26729
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
26348
26730
|
summary: {
|
|
26349
|
-
hops:
|
|
26350
|
-
finalStatus:
|
|
26731
|
+
hops: trace.hops,
|
|
26732
|
+
finalStatus: trace.final_status,
|
|
26733
|
+
outcome: trace.outcome,
|
|
26734
|
+
...trace.error ? { error: trace.error } : {},
|
|
26351
26735
|
finalIndexable: finalPage?.indexable,
|
|
26352
26736
|
issues
|
|
26353
26737
|
},
|
|
@@ -30352,7 +30736,7 @@ function extractText(source) {
|
|
|
30352
30736
|
flush();
|
|
30353
30737
|
return finish(x, blocks);
|
|
30354
30738
|
}
|
|
30355
|
-
function extractMetaLines(source) {
|
|
30739
|
+
function extractMetaLines(source, metaKind) {
|
|
30356
30740
|
const x = new Extractor(source);
|
|
30357
30741
|
const blocks = [];
|
|
30358
30742
|
for (let line = 0; line < x.starts.length; line++) {
|
|
@@ -30364,7 +30748,7 @@ function extractMetaLines(source) {
|
|
|
30364
30748
|
const from = start + (label ? label[0].length : 0);
|
|
30365
30749
|
const built = decodeValue(source, from, end, false);
|
|
30366
30750
|
const value = source.slice(from, end).trim();
|
|
30367
|
-
const kind = label ? label[1]?.toLowerCase() : value.length <= 70 ? "title" : "description";
|
|
30751
|
+
const kind = label ? label[1]?.toLowerCase() : metaKind ? metaKind : value.length <= 70 ? "title" : "description";
|
|
30368
30752
|
const block = makeBlock(kind, built, { field: kind });
|
|
30369
30753
|
if (block) blocks.push(block);
|
|
30370
30754
|
}
|
|
@@ -30410,8 +30794,8 @@ function formatFromPath(path) {
|
|
|
30410
30794
|
function defaultPreset(format) {
|
|
30411
30795
|
return format === "html" || format === "astro" ? "landing" : "blog";
|
|
30412
30796
|
}
|
|
30413
|
-
function extract(source, format, preset) {
|
|
30414
|
-
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);
|
|
30797
|
+
function extract(source, format, preset, metaKind) {
|
|
30798
|
+
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);
|
|
30415
30799
|
return {
|
|
30416
30800
|
source,
|
|
30417
30801
|
format,
|
|
@@ -31073,6 +31457,153 @@ function runPhraseRules(ctx) {
|
|
|
31073
31457
|
}
|
|
31074
31458
|
}
|
|
31075
31459
|
}
|
|
31460
|
+
// packages/core/src/serp/browser.ts
|
|
31461
|
+
const SERP_PIXEL_BUDGETS = {
|
|
31462
|
+
title: {
|
|
31463
|
+
desktop: 600,
|
|
31464
|
+
mobile: 560
|
|
31465
|
+
},
|
|
31466
|
+
description: {
|
|
31467
|
+
desktop: 920,
|
|
31468
|
+
mobile: 680
|
|
31469
|
+
}
|
|
31470
|
+
};
|
|
31471
|
+
const FONT_PX = {
|
|
31472
|
+
title: 20,
|
|
31473
|
+
description: 14
|
|
31474
|
+
};
|
|
31475
|
+
const ASCII = [
|
|
31476
|
+
278,
|
|
31477
|
+
278,
|
|
31478
|
+
355,
|
|
31479
|
+
556,
|
|
31480
|
+
556,
|
|
31481
|
+
889,
|
|
31482
|
+
667,
|
|
31483
|
+
191,
|
|
31484
|
+
333,
|
|
31485
|
+
333,
|
|
31486
|
+
389,
|
|
31487
|
+
584,
|
|
31488
|
+
278,
|
|
31489
|
+
333,
|
|
31490
|
+
278,
|
|
31491
|
+
278,
|
|
31492
|
+
556,
|
|
31493
|
+
556,
|
|
31494
|
+
556,
|
|
31495
|
+
556,
|
|
31496
|
+
556,
|
|
31497
|
+
556,
|
|
31498
|
+
556,
|
|
31499
|
+
556,
|
|
31500
|
+
556,
|
|
31501
|
+
556,
|
|
31502
|
+
278,
|
|
31503
|
+
278,
|
|
31504
|
+
584,
|
|
31505
|
+
584,
|
|
31506
|
+
584,
|
|
31507
|
+
556,
|
|
31508
|
+
1015,
|
|
31509
|
+
667,
|
|
31510
|
+
667,
|
|
31511
|
+
722,
|
|
31512
|
+
722,
|
|
31513
|
+
667,
|
|
31514
|
+
611,
|
|
31515
|
+
778,
|
|
31516
|
+
722,
|
|
31517
|
+
278,
|
|
31518
|
+
500,
|
|
31519
|
+
667,
|
|
31520
|
+
556,
|
|
31521
|
+
833,
|
|
31522
|
+
722,
|
|
31523
|
+
778,
|
|
31524
|
+
667,
|
|
31525
|
+
778,
|
|
31526
|
+
722,
|
|
31527
|
+
667,
|
|
31528
|
+
611,
|
|
31529
|
+
722,
|
|
31530
|
+
667,
|
|
31531
|
+
944,
|
|
31532
|
+
667,
|
|
31533
|
+
667,
|
|
31534
|
+
611,
|
|
31535
|
+
278,
|
|
31536
|
+
278,
|
|
31537
|
+
278,
|
|
31538
|
+
469,
|
|
31539
|
+
556,
|
|
31540
|
+
333,
|
|
31541
|
+
556,
|
|
31542
|
+
556,
|
|
31543
|
+
500,
|
|
31544
|
+
556,
|
|
31545
|
+
556,
|
|
31546
|
+
278,
|
|
31547
|
+
556,
|
|
31548
|
+
556,
|
|
31549
|
+
222,
|
|
31550
|
+
222,
|
|
31551
|
+
500,
|
|
31552
|
+
222,
|
|
31553
|
+
833,
|
|
31554
|
+
556,
|
|
31555
|
+
556,
|
|
31556
|
+
556,
|
|
31557
|
+
556,
|
|
31558
|
+
333,
|
|
31559
|
+
500,
|
|
31560
|
+
278,
|
|
31561
|
+
556,
|
|
31562
|
+
500,
|
|
31563
|
+
722,
|
|
31564
|
+
500,
|
|
31565
|
+
500,
|
|
31566
|
+
500,
|
|
31567
|
+
334,
|
|
31568
|
+
260,
|
|
31569
|
+
334,
|
|
31570
|
+
584
|
|
31571
|
+
];
|
|
31572
|
+
const OTHER = {
|
|
31573
|
+
"‘": 222,
|
|
31574
|
+
"’": 222,
|
|
31575
|
+
"“": 333,
|
|
31576
|
+
"”": 333,
|
|
31577
|
+
"–": 556,
|
|
31578
|
+
"—": 1e3,
|
|
31579
|
+
"…": 1e3,
|
|
31580
|
+
"•": 350,
|
|
31581
|
+
"·": 278,
|
|
31582
|
+
"©": 737,
|
|
31583
|
+
"®": 737,
|
|
31584
|
+
"™": 1e3,
|
|
31585
|
+
"£": 556,
|
|
31586
|
+
"€": 556,
|
|
31587
|
+
"°": 400,
|
|
31588
|
+
"×": 584
|
|
31589
|
+
};
|
|
31590
|
+
const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
31591
|
+
const AVERAGE = 556;
|
|
31592
|
+
function units(char) {
|
|
31593
|
+
const code = char.codePointAt(0) ?? 0;
|
|
31594
|
+
if (code >= 32 && code <= 126) return ASCII[code - 32] ?? AVERAGE;
|
|
31595
|
+
const known = OTHER[char];
|
|
31596
|
+
if (known !== void 0) return known;
|
|
31597
|
+
if (WIDE.test(char)) return 1e3;
|
|
31598
|
+
const base = char.normalize("NFD").codePointAt(0) ?? 0;
|
|
31599
|
+
if (base >= 32 && base <= 126) return ASCII[base - 32] ?? AVERAGE;
|
|
31600
|
+
return AVERAGE;
|
|
31601
|
+
}
|
|
31602
|
+
function serpTextWidth(value, kind) {
|
|
31603
|
+
let total = 0;
|
|
31604
|
+
for (const char of value) total += units(char);
|
|
31605
|
+
return total * FONT_PX[kind] / 1e3;
|
|
31606
|
+
}
|
|
31076
31607
|
// packages/core/src/lint/rules-seo.ts
|
|
31077
31608
|
const SEO_RULES = {
|
|
31078
31609
|
dash: {
|
|
@@ -31174,7 +31705,8 @@ function lengths(ctx) {
|
|
|
31174
31705
|
const length = block.text.length;
|
|
31175
31706
|
if (length >= low && length <= high) continue;
|
|
31176
31707
|
const what = title ? "title" : "description";
|
|
31177
|
-
|
|
31708
|
+
const pixels = Math.ceil(serpTextWidth(block.text, what));
|
|
31709
|
+
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.");
|
|
31178
31710
|
}
|
|
31179
31711
|
}
|
|
31180
31712
|
const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today)/i;
|
|
@@ -31781,6 +32313,7 @@ const LINT_PRESETS = [
|
|
|
31781
32313
|
"docs",
|
|
31782
32314
|
"meta"
|
|
31783
32315
|
];
|
|
32316
|
+
const LINT_META_KINDS = ["title", "description"];
|
|
31784
32317
|
const LINT_FORMATS = [
|
|
31785
32318
|
"md",
|
|
31786
32319
|
"mdx",
|
|
@@ -31788,6 +32321,11 @@ const LINT_FORMATS = [
|
|
|
31788
32321
|
"astro",
|
|
31789
32322
|
"text"
|
|
31790
32323
|
];
|
|
32324
|
+
const LINT_CATEGORIES = [
|
|
32325
|
+
"ai",
|
|
32326
|
+
"copy",
|
|
32327
|
+
"seo"
|
|
32328
|
+
];
|
|
31791
32329
|
const LINT_MAX_CHARS = 2e5;
|
|
31792
32330
|
// packages/core/src/lint/lint.ts
|
|
31793
32331
|
const LINT_RULES = [
|
|
@@ -31802,6 +32340,15 @@ const LINT_RULES = [
|
|
|
31802
32340
|
...Object.values(SEO_RULES)
|
|
31803
32341
|
];
|
|
31804
32342
|
const AI_RULES = new Set(LINT_RULES.filter((rule) => rule.family === "ai").map((rule) => rule.id));
|
|
32343
|
+
const RULE_CATEGORY = new Map(LINT_RULES.map((rule) => [rule.id, rule.family]));
|
|
32344
|
+
const LENS_CATEGORY = {
|
|
32345
|
+
contrast: "ai",
|
|
32346
|
+
metaphor: "ai",
|
|
32347
|
+
staccato: "ai",
|
|
32348
|
+
anaphora: "ai",
|
|
32349
|
+
"mic-drop": "ai"
|
|
32350
|
+
};
|
|
32351
|
+
const lensCategory = (lens) => LENS_CATEGORY[lens] ?? "copy";
|
|
31805
32352
|
function position(doc, block, index) {
|
|
31806
32353
|
const offset = block.map[Math.min(index, block.map.length - 1)] ?? 0;
|
|
31807
32354
|
const line = lineOf(doc.lineStarts, offset);
|
|
@@ -31828,7 +32375,7 @@ function lintText(text, options = {}) {
|
|
|
31828
32375
|
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")}.`);
|
|
31829
32376
|
const format = options.format ?? "md";
|
|
31830
32377
|
const preset = options.preset ?? defaultPreset(format);
|
|
31831
|
-
const doc = extract(text, format, preset);
|
|
32378
|
+
const doc = extract(text, format, preset, options.metaKind);
|
|
31832
32379
|
const ctx = new Context(doc, preset, LINT_RULES);
|
|
31833
32380
|
runStructureRules(ctx);
|
|
31834
32381
|
runPhraseRules(ctx);
|
|
@@ -31843,7 +32390,10 @@ function lintText(text, options = {}) {
|
|
|
31843
32390
|
const lower = quote.toLowerCase();
|
|
31844
32391
|
return !phrases.some((phrase) => lower.includes(phrase));
|
|
31845
32392
|
};
|
|
31846
|
-
const
|
|
32393
|
+
const only = options.only?.length ? new Set(options.only) : null;
|
|
32394
|
+
const checks = (category) => !only || only.has(category);
|
|
32395
|
+
const ruleCategory = (rule) => RULE_CATEGORY.get(rule) ?? "copy";
|
|
32396
|
+
const hits = dedupe(ctx.hits).filter((hit) => checks(ruleCategory(hit.rule)));
|
|
31847
32397
|
const findings = [];
|
|
31848
32398
|
for (const hit of hits) {
|
|
31849
32399
|
const at = position(doc, hit.block, hit.start);
|
|
@@ -31851,6 +32401,7 @@ function lintText(text, options = {}) {
|
|
|
31851
32401
|
if (!allowed(hit.rule, at.line, quote)) continue;
|
|
31852
32402
|
findings.push({
|
|
31853
32403
|
rule: hit.rule,
|
|
32404
|
+
category: ruleCategory(hit.rule),
|
|
31854
32405
|
level: hit.level,
|
|
31855
32406
|
line: at.line,
|
|
31856
32407
|
column: at.column,
|
|
@@ -31859,12 +32410,13 @@ function lintText(text, options = {}) {
|
|
|
31859
32410
|
fix: hit.fix
|
|
31860
32411
|
});
|
|
31861
32412
|
}
|
|
31862
|
-
for (const found of text.matchAll(RAW_LEAKS)) {
|
|
32413
|
+
for (const found of checks("ai") ? text.matchAll(RAW_LEAKS) : []) {
|
|
31863
32414
|
const offset = found.index ?? 0;
|
|
31864
32415
|
const line = lineOf(doc.lineStarts, offset);
|
|
31865
32416
|
if (!allowed("chatbot-residue", line + 1, found[0])) continue;
|
|
31866
32417
|
findings.push({
|
|
31867
32418
|
rule: "chatbot-residue",
|
|
32419
|
+
category: "ai",
|
|
31868
32420
|
level: "error",
|
|
31869
32421
|
line: line + 1,
|
|
31870
32422
|
column: offset - (doc.lineStarts[line] ?? 0) + 1,
|
|
@@ -31876,6 +32428,7 @@ function lintText(text, options = {}) {
|
|
|
31876
32428
|
const review = [];
|
|
31877
32429
|
const seen = /* @__PURE__ */ new Set();
|
|
31878
32430
|
for (const item of ctx.reviews) {
|
|
32431
|
+
if (!checks(lensCategory(item.lens))) continue;
|
|
31879
32432
|
if (hits.some((hit) => overlaps(item, hit))) continue;
|
|
31880
32433
|
const at = position(doc, item.block, item.start);
|
|
31881
32434
|
const quote = excerpt(item.block, item.start, item.end);
|
|
@@ -31884,6 +32437,7 @@ function lintText(text, options = {}) {
|
|
|
31884
32437
|
seen.add(key);
|
|
31885
32438
|
review.push({
|
|
31886
32439
|
lens: item.lens,
|
|
32440
|
+
category: lensCategory(item.lens),
|
|
31887
32441
|
line: at.line,
|
|
31888
32442
|
column: at.column,
|
|
31889
32443
|
quote,
|
|
@@ -31893,17 +32447,25 @@ function lintText(text, options = {}) {
|
|
|
31893
32447
|
const order = (a, b) => a.line - b.line || a.column - b.column;
|
|
31894
32448
|
findings.sort((a, b) => order(a, b) || a.rule.localeCompare(b.rule) || a.excerpt.localeCompare(b.excerpt));
|
|
31895
32449
|
review.sort((a, b) => order(a, b) || a.lens.localeCompare(b.lens) || a.quote.localeCompare(b.quote));
|
|
32450
|
+
const categories = Object.fromEntries(LINT_CATEGORIES.map((category) => [category, findings.filter((finding) => finding.category === category).length]));
|
|
31896
32451
|
return {
|
|
31897
32452
|
format,
|
|
31898
32453
|
preset,
|
|
31899
32454
|
words: ctx.words,
|
|
31900
32455
|
errors: findings.filter((finding) => finding.level === "error").length,
|
|
31901
32456
|
warnings: findings.filter((finding) => finding.level === "warning").length,
|
|
32457
|
+
categories,
|
|
32458
|
+
...only ? { only: LINT_CATEGORIES.filter((category) => only.has(category)) } : {},
|
|
31902
32459
|
findings,
|
|
31903
32460
|
review
|
|
31904
32461
|
};
|
|
31905
32462
|
}
|
|
31906
32463
|
const plural$3 = (count, word) => `${count.toLocaleString("en-US")} ${word}${count === 1 ? "" : "s"}`;
|
|
32464
|
+
const CATEGORY_NAMES = {
|
|
32465
|
+
ai: "AI patterns",
|
|
32466
|
+
copy: "copy",
|
|
32467
|
+
seo: "SEO"
|
|
32468
|
+
};
|
|
31907
32469
|
function lintReportText(files) {
|
|
31908
32470
|
const lines = [];
|
|
31909
32471
|
let errors = 0;
|
|
@@ -31918,7 +32480,12 @@ function lintReportText(files) {
|
|
|
31918
32480
|
errors += result.errors;
|
|
31919
32481
|
warnings += result.warnings;
|
|
31920
32482
|
reviews += result.review.length;
|
|
31921
|
-
|
|
32483
|
+
const only = result.only ? `, only ${result.only.map((category) => CATEGORY_NAMES[category]).join(" and ")}` : "";
|
|
32484
|
+
const counted = LINT_CATEGORIES.filter((category) => result.categories[category] > 0);
|
|
32485
|
+
const split = counted.length ? ` (${counted.map((category) => `${category} ${result.categories[category].toLocaleString("en-US")}`).join(", ")})` : "";
|
|
32486
|
+
lines.push(`${file.path} (${result.preset}, ${plural$3(result.words, "word")}${only}): ${plural$3(result.errors, "error")}, ${plural$3(result.warnings, "warning")}${split}, ${result.review.length.toLocaleString("en-US")} to review`);
|
|
32487
|
+
if (file.note) lines.push(` ${file.note}`);
|
|
32488
|
+
if (file.partial) lines.push(` Partly checked: ${file.partial}`);
|
|
31922
32489
|
for (const finding of result.findings) lines.push(` ${finding.line}:${finding.column} ${finding.level} ${finding.rule} "${finding.excerpt}"`, ` ${finding.message}`, ` Fix: ${finding.fix}`);
|
|
31923
32490
|
if (result.review.length) {
|
|
31924
32491
|
lines.push(" To review (your call):");
|
|
@@ -31927,7 +32494,14 @@ function lintReportText(files) {
|
|
|
31927
32494
|
lines.push("");
|
|
31928
32495
|
}
|
|
31929
32496
|
const checked = files.filter((file) => "result" in file).length;
|
|
31930
|
-
|
|
32497
|
+
const skipped = files.length - checked;
|
|
32498
|
+
const partial = files.filter((file) => "partial" in file && file.partial).length;
|
|
32499
|
+
if (files.length > 1 || skipped || partial) {
|
|
32500
|
+
const gaps = [skipped ? `${skipped.toLocaleString("en-US")} not checked` : "", partial ? `${partial.toLocaleString("en-US")} partly checked` : ""].filter(Boolean);
|
|
32501
|
+
lines.push(`${plural$3(checked, "file")} checked${gaps.length ? ` (${gaps.join(", ")})` : ""}: ${plural$3(errors, "error")}, ${plural$3(warnings, "warning")}, ${reviews.toLocaleString("en-US")} to review.`);
|
|
32502
|
+
}
|
|
32503
|
+
if (!checked) lines.push("Nothing was checked, so this is not a clean result. The reason is beside each file above.");
|
|
32504
|
+
else if (skipped || partial) lines.push("Not an all-clear: the totals cover only the text that was checked.");
|
|
31931
32505
|
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.");
|
|
31932
32506
|
return lines.join("\n").trimEnd();
|
|
31933
32507
|
}
|
|
@@ -32151,7 +32725,114 @@ async function aiQueriesReport(input, searchAnalytics) {
|
|
|
32151
32725
|
]
|
|
32152
32726
|
};
|
|
32153
32727
|
}
|
|
32728
|
+
// packages/core/src/commands/research-ahrefs.ts
|
|
32729
|
+
async function ahrefs(key, input, missed, totals) {
|
|
32730
|
+
const where = targetParts(input).ahrefs;
|
|
32731
|
+
const get = async (path, params) => {
|
|
32732
|
+
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
32733
|
+
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
32734
|
+
return readJson(await call("ahrefs", new Request(url, { headers: {
|
|
32735
|
+
authorization: `Bearer ${key}`,
|
|
32736
|
+
accept: "application/json"
|
|
32737
|
+
} })), "Ahrefs");
|
|
32738
|
+
};
|
|
32739
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
32740
|
+
if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), missed, async (seed) => {
|
|
32741
|
+
return ((await get("keywords-explorer/matching-terms", {
|
|
32742
|
+
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
32743
|
+
country: input.country,
|
|
32744
|
+
keywords: seed,
|
|
32745
|
+
limit: String(input.limit),
|
|
32746
|
+
order_by: "volume:desc"
|
|
32747
|
+
})).keywords ?? []).map((row) => ({
|
|
32748
|
+
seed,
|
|
32749
|
+
...flattenIntents(row)
|
|
32750
|
+
}));
|
|
32751
|
+
});
|
|
32752
|
+
if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
|
|
32753
|
+
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
32754
|
+
country: input.country,
|
|
32755
|
+
keywords: input.keywords.slice(0, 100).join(",")
|
|
32756
|
+
})).keywords ?? []).map(flattenIntents);
|
|
32757
|
+
if (input.action === "ranked_keywords") {
|
|
32758
|
+
const [data, metrics] = await Promise.all([get("site-explorer/organic-keywords", {
|
|
32759
|
+
select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
|
|
32760
|
+
...where,
|
|
32761
|
+
country: input.country,
|
|
32762
|
+
date: today,
|
|
32763
|
+
limit: String(input.limit),
|
|
32764
|
+
order_by: "sum_traffic:desc"
|
|
32765
|
+
}), get("site-explorer/metrics", {
|
|
32766
|
+
...where,
|
|
32767
|
+
country: input.country,
|
|
32768
|
+
date: today
|
|
32769
|
+
})]);
|
|
32770
|
+
const total = metrics.metrics;
|
|
32771
|
+
if (typeof total?.org_traffic === "number") totals.siteTotal = {
|
|
32772
|
+
metric: "org_traffic",
|
|
32773
|
+
traffic: total.org_traffic,
|
|
32774
|
+
keywords: typeof total.org_keywords === "number" ? total.org_keywords : null
|
|
32775
|
+
};
|
|
32776
|
+
return data.keywords ?? [];
|
|
32777
|
+
}
|
|
32778
|
+
if (input.action === "top_pages") return (await get("site-explorer/top-pages", {
|
|
32779
|
+
select: "url,sum_traffic,keywords,top_keyword,top_keyword_volume,top_keyword_best_position,referring_domains",
|
|
32780
|
+
...where,
|
|
32781
|
+
country: input.country,
|
|
32782
|
+
date: today,
|
|
32783
|
+
limit: String(input.limit),
|
|
32784
|
+
order_by: "sum_traffic:desc"
|
|
32785
|
+
})).pages ?? [];
|
|
32786
|
+
if (input.action === "pages_by_backlinks") return (await get("site-explorer/pages-by-backlinks", {
|
|
32787
|
+
select: "url_to,title_target,http_code_target,refdomains_target,links_to_target,dofollow_to_target,first_seen_link",
|
|
32788
|
+
...where,
|
|
32789
|
+
history: "live",
|
|
32790
|
+
limit: String(input.limit),
|
|
32791
|
+
order_by: "refdomains_target:desc"
|
|
32792
|
+
})).pages ?? [];
|
|
32793
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
|
|
32794
|
+
return ((await get("serp-overview/serp-overview", {
|
|
32795
|
+
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
32796
|
+
keyword,
|
|
32797
|
+
country: input.country,
|
|
32798
|
+
top_positions: String(serpDepth(input))
|
|
32799
|
+
})).positions ?? []).map((row) => ({
|
|
32800
|
+
keyword,
|
|
32801
|
+
...row
|
|
32802
|
+
}));
|
|
32803
|
+
});
|
|
32804
|
+
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
32805
|
+
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
32806
|
+
...where,
|
|
32807
|
+
history: "live",
|
|
32808
|
+
limit: String(input.limit),
|
|
32809
|
+
order_by: "domain_rating:desc"
|
|
32810
|
+
})).refdomains ?? [];
|
|
32811
|
+
return (await get("site-explorer/organic-competitors", {
|
|
32812
|
+
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
32813
|
+
target: input.target,
|
|
32814
|
+
country: input.country,
|
|
32815
|
+
date: today,
|
|
32816
|
+
mode: "subdomains",
|
|
32817
|
+
limit: String(input.limit),
|
|
32818
|
+
order_by: "keywords_common:desc"
|
|
32819
|
+
})).competitors ?? [];
|
|
32820
|
+
}
|
|
32821
|
+
function flattenIntents(row) {
|
|
32822
|
+
const intents = row.intents;
|
|
32823
|
+
if (!intents || typeof intents !== "object") return row;
|
|
32824
|
+
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
32825
|
+
return {
|
|
32826
|
+
...row,
|
|
32827
|
+
intents: names.join(" ") || null
|
|
32828
|
+
};
|
|
32829
|
+
}
|
|
32154
32830
|
// packages/core/src/commands/research-providers.ts
|
|
32831
|
+
const RESEARCH_SCOPES = [
|
|
32832
|
+
"domain",
|
|
32833
|
+
"prefix",
|
|
32834
|
+
"exact"
|
|
32835
|
+
];
|
|
32155
32836
|
function need(headers, provider) {
|
|
32156
32837
|
const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
|
|
32157
32838
|
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\`.`);
|
|
@@ -32187,8 +32868,17 @@ async function call(provider, request) {
|
|
|
32187
32868
|
}
|
|
32188
32869
|
}
|
|
32189
32870
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
32871
|
+
const KEYWORD_ACTIONS = [
|
|
32872
|
+
"keyword_metrics",
|
|
32873
|
+
"keyword_ideas",
|
|
32874
|
+
"serp",
|
|
32875
|
+
"serp_overlap"
|
|
32876
|
+
];
|
|
32877
|
+
const SERP_KEYWORDS = 5;
|
|
32878
|
+
const OVERLAP_KEYWORDS = 10;
|
|
32879
|
+
const OVERLAP_DEPTH = 10;
|
|
32190
32880
|
function requireInput(input) {
|
|
32191
|
-
const needsKeywords =
|
|
32881
|
+
const needsKeywords = KEYWORD_ACTIONS.includes(input.action);
|
|
32192
32882
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
32193
32883
|
if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
|
|
32194
32884
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
@@ -32209,16 +32899,19 @@ async function eachKeyword(keywords, missed, rowsFor) {
|
|
|
32209
32899
|
async function research(provider, headers, input) {
|
|
32210
32900
|
requireInput(input);
|
|
32211
32901
|
if (input.action === "link_gap") return linkGap(provider, headers, input);
|
|
32902
|
+
if (input.action === "serp_overlap") return serpOverlap(provider, headers, input);
|
|
32212
32903
|
const keys = need(headers, provider);
|
|
32213
32904
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
32214
32905
|
const missed = [];
|
|
32215
|
-
const
|
|
32906
|
+
const totals = {};
|
|
32907
|
+
const rows = await ahrefs(keys[0] ?? "", input, missed, totals);
|
|
32216
32908
|
return {
|
|
32217
32909
|
rows,
|
|
32218
32910
|
costUsd: null,
|
|
32219
32911
|
partial: false,
|
|
32220
32912
|
missed,
|
|
32221
|
-
noData: withoutData(input, rows, missed, 100)
|
|
32913
|
+
noData: withoutData(input, rows, missed, 100),
|
|
32914
|
+
...totals
|
|
32222
32915
|
};
|
|
32223
32916
|
}
|
|
32224
32917
|
function countryName(code) {
|
|
@@ -32318,13 +33011,16 @@ async function dataForSeo(keys, input) {
|
|
|
32318
33011
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
32319
33012
|
partial: spent.partial,
|
|
32320
33013
|
missed: spent.missed,
|
|
32321
|
-
noData: withoutData(input, rows, spent.missed, 700)
|
|
33014
|
+
noData: withoutData(input, rows, spent.missed, 700),
|
|
33015
|
+
...spent.available !== void 0 ? { available: spent.available } : {},
|
|
33016
|
+
...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
|
|
33017
|
+
...spent.rankFailed ? { rankFailed: true } : {}
|
|
32322
33018
|
};
|
|
32323
33019
|
}
|
|
32324
33020
|
function withoutData(input, rows, missed, keywordLimit) {
|
|
32325
33021
|
if (input.action === "keyword_metrics" || input.action === "serp") {
|
|
32326
|
-
const max = input.action === "serp" ?
|
|
32327
|
-
const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
33022
|
+
const max = input.action === "serp" ? serpKeywords(input) : keywordLimit;
|
|
33023
|
+
const answered = new Set(rows.filter((row) => input.action === "serp" || (row.search_volume ?? row.volume ?? null) !== null).map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
32328
33024
|
const failed = new Set(missed);
|
|
32329
33025
|
return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
|
|
32330
33026
|
}
|
|
@@ -32332,8 +33028,14 @@ function withoutData(input, rows, missed, keywordLimit) {
|
|
|
32332
33028
|
}
|
|
32333
33029
|
async function dataForSeoRows(keys, input, spent) {
|
|
32334
33030
|
const language = dataForSeoLanguage(input.country);
|
|
32335
|
-
if (!language &&
|
|
33031
|
+
if (!language && ![
|
|
33032
|
+
"serp",
|
|
33033
|
+
"backlinks",
|
|
33034
|
+
"pages_by_backlinks"
|
|
33035
|
+
].includes(input.action)) throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
32336
33036
|
const market = dataForSeoMarket(input.country, language);
|
|
33037
|
+
const where = targetParts(input);
|
|
33038
|
+
let header = null;
|
|
32337
33039
|
const post = async (path, task, attempts = 1) => {
|
|
32338
33040
|
const done = await dataForSeoTask(keys, path, {
|
|
32339
33041
|
...market,
|
|
@@ -32344,6 +33046,9 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
32344
33046
|
});
|
|
32345
33047
|
spent.cost += done.cost;
|
|
32346
33048
|
spent.partial ||= done.partial;
|
|
33049
|
+
header = done.result;
|
|
33050
|
+
const total = done.result?.total_count;
|
|
33051
|
+
if (typeof total === "number") spent.available = total;
|
|
32347
33052
|
if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
|
|
32348
33053
|
return done.result?.items ?? [];
|
|
32349
33054
|
};
|
|
@@ -32357,47 +33062,133 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
32357
33062
|
competition: "keyword_info.competition",
|
|
32358
33063
|
main_intent: "search_intent_info.main_intent"
|
|
32359
33064
|
}));
|
|
32360
|
-
if (input.action === "
|
|
32361
|
-
|
|
33065
|
+
if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (seed) => {
|
|
33066
|
+
return (await post("v3/dataforseo_labs/google/keyword_suggestions/live", {
|
|
33067
|
+
keyword: seed,
|
|
33068
|
+
include_seed_keyword: true,
|
|
33069
|
+
limit: input.limit,
|
|
33070
|
+
order_by: ["keyword_info.search_volume,desc"]
|
|
33071
|
+
})).map((item) => ({
|
|
33072
|
+
seed,
|
|
33073
|
+
...pick(item, {
|
|
33074
|
+
keyword: "keyword",
|
|
33075
|
+
search_volume: "keyword_info.search_volume",
|
|
33076
|
+
keyword_difficulty: "keyword_properties.keyword_difficulty",
|
|
33077
|
+
cpc: "keyword_info.cpc",
|
|
33078
|
+
main_intent: "search_intent_info.main_intent"
|
|
33079
|
+
})
|
|
33080
|
+
}));
|
|
33081
|
+
});
|
|
33082
|
+
if (input.action === "ranked_keywords") {
|
|
33083
|
+
const items = await post("v3/dataforseo_labs/google/ranked_keywords/live", {
|
|
33084
|
+
target: where.scope === "exact" ? where.page : where.host,
|
|
33085
|
+
limit: input.limit,
|
|
33086
|
+
order_by: ["ranked_serp_element.serp_item.etv,desc"],
|
|
33087
|
+
...where.scope === "prefix" ? { filters: [
|
|
33088
|
+
"ranked_serp_element.serp_item.relative_url",
|
|
33089
|
+
"like",
|
|
33090
|
+
`${where.path}%`
|
|
33091
|
+
] } : {}
|
|
33092
|
+
});
|
|
33093
|
+
const organic = get(header, "metrics.organic");
|
|
33094
|
+
const etv = get(organic, "etv");
|
|
33095
|
+
if (typeof etv === "number") {
|
|
33096
|
+
const count = get(organic, "count");
|
|
33097
|
+
spent.siteTotal = {
|
|
33098
|
+
metric: "etv",
|
|
33099
|
+
traffic: Math.round(etv),
|
|
33100
|
+
keywords: typeof count === "number" ? count : null
|
|
33101
|
+
};
|
|
33102
|
+
}
|
|
33103
|
+
return items.map((item) => pick(item, {
|
|
33104
|
+
keyword: "keyword_data.keyword",
|
|
33105
|
+
position: "ranked_serp_element.serp_item.rank_absolute",
|
|
33106
|
+
url: "ranked_serp_element.serp_item.url",
|
|
33107
|
+
search_volume: "keyword_data.keyword_info.search_volume",
|
|
33108
|
+
etv: "ranked_serp_element.serp_item.etv",
|
|
33109
|
+
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
33110
|
+
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
33111
|
+
}));
|
|
33112
|
+
}
|
|
33113
|
+
if (input.action === "serp") {
|
|
33114
|
+
const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
|
|
33115
|
+
return (await post("v3/serp/google/organic/live/advanced", {
|
|
33116
|
+
keyword,
|
|
33117
|
+
depth: serpDepth(input)
|
|
33118
|
+
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
33119
|
+
const row = pick(item, {
|
|
33120
|
+
position: "rank_absolute",
|
|
33121
|
+
url: "url",
|
|
33122
|
+
domain: "domain",
|
|
33123
|
+
title: "title"
|
|
33124
|
+
});
|
|
33125
|
+
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
33126
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
33127
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
33128
|
+
row.url = null;
|
|
33129
|
+
row.domain = site?.[1] ?? null;
|
|
33130
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
33131
|
+
}
|
|
33132
|
+
return {
|
|
33133
|
+
keyword,
|
|
33134
|
+
...row
|
|
33135
|
+
};
|
|
33136
|
+
});
|
|
33137
|
+
});
|
|
33138
|
+
return input.noRank ? rows : withDomainRank(keys, rows, spent);
|
|
33139
|
+
}
|
|
33140
|
+
if (input.action === "top_pages") return (await post("v3/dataforseo_labs/google/relevant_pages/live", {
|
|
33141
|
+
target: where.host,
|
|
32362
33142
|
limit: input.limit,
|
|
32363
|
-
order_by: ["
|
|
33143
|
+
order_by: ["metrics.organic.etv,desc"],
|
|
33144
|
+
...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
|
|
33145
|
+
"page_address",
|
|
33146
|
+
"like",
|
|
33147
|
+
likePrefix(where.host, where.path)
|
|
33148
|
+
] : [
|
|
33149
|
+
"page_address",
|
|
33150
|
+
"=",
|
|
33151
|
+
where.page
|
|
33152
|
+
] }
|
|
32364
33153
|
})).map((item) => pick(item, {
|
|
32365
|
-
|
|
32366
|
-
|
|
32367
|
-
|
|
32368
|
-
|
|
32369
|
-
|
|
32370
|
-
|
|
32371
|
-
|
|
33154
|
+
url: "page_address",
|
|
33155
|
+
etv: "metrics.organic.etv",
|
|
33156
|
+
keywords: "metrics.organic.count",
|
|
33157
|
+
pos_1: "metrics.organic.pos_1",
|
|
33158
|
+
pos_2_3: "metrics.organic.pos_2_3",
|
|
33159
|
+
pos_4_10: "metrics.organic.pos_4_10"
|
|
33160
|
+
}));
|
|
33161
|
+
if (input.action === "pages_by_backlinks") return (await post("v3/backlinks/domain_pages/live", {
|
|
33162
|
+
target: where.host,
|
|
33163
|
+
limit: input.limit,
|
|
33164
|
+
order_by: ["page_summary.referring_domains,desc"],
|
|
33165
|
+
...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
|
|
33166
|
+
"page",
|
|
33167
|
+
"like",
|
|
33168
|
+
likePrefix(where.host, where.path)
|
|
33169
|
+
] : [
|
|
33170
|
+
"page",
|
|
33171
|
+
"=",
|
|
33172
|
+
where.page
|
|
33173
|
+
] }
|
|
33174
|
+
})).map((item) => pick(item, {
|
|
33175
|
+
url: "page",
|
|
33176
|
+
status: "status_code",
|
|
33177
|
+
title: "meta.title",
|
|
33178
|
+
referring_domains: "page_summary.referring_domains",
|
|
33179
|
+
backlinks: "page_summary.backlinks",
|
|
33180
|
+
rank: "page_summary.rank",
|
|
33181
|
+
first_seen: "page_summary.first_seen"
|
|
32372
33182
|
}));
|
|
32373
|
-
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
|
|
32374
|
-
return (await post("v3/serp/google/organic/live/advanced", {
|
|
32375
|
-
keyword,
|
|
32376
|
-
depth: Math.min(input.limit, 100)
|
|
32377
|
-
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
32378
|
-
const row = pick(item, {
|
|
32379
|
-
position: "rank_absolute",
|
|
32380
|
-
url: "url",
|
|
32381
|
-
domain: "domain",
|
|
32382
|
-
title: "title"
|
|
32383
|
-
});
|
|
32384
|
-
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
32385
|
-
const crumb = String(item.breadcrumb ?? "");
|
|
32386
|
-
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
32387
|
-
row.url = null;
|
|
32388
|
-
row.domain = site?.[1] ?? null;
|
|
32389
|
-
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
32390
|
-
}
|
|
32391
|
-
return {
|
|
32392
|
-
keyword,
|
|
32393
|
-
...row
|
|
32394
|
-
};
|
|
32395
|
-
});
|
|
32396
|
-
});
|
|
32397
33183
|
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
32398
|
-
target:
|
|
33184
|
+
target: where.scope === "exact" ? where.page : where.host,
|
|
32399
33185
|
limit: input.limit,
|
|
32400
|
-
order_by: ["rank,desc"]
|
|
33186
|
+
order_by: ["rank,desc"],
|
|
33187
|
+
...where.scope === "prefix" ? { backlinks_filters: [
|
|
33188
|
+
"url_to",
|
|
33189
|
+
"like",
|
|
33190
|
+
likePrefix(where.host, where.path)
|
|
33191
|
+
] } : {}
|
|
32401
33192
|
})).map((item) => pick(item, {
|
|
32402
33193
|
domain: "domain",
|
|
32403
33194
|
rank: "rank",
|
|
@@ -32416,6 +33207,87 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
32416
33207
|
organic_etv: "full_domain_metrics.organic.etv"
|
|
32417
33208
|
}));
|
|
32418
33209
|
}
|
|
33210
|
+
async function withDomainRank(keys, rows, spent) {
|
|
33211
|
+
const domains = [...new Set(rows.map((row) => String(row.domain ?? "")).filter(Boolean))].slice(0, 1e3);
|
|
33212
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
33213
|
+
if (domains.length) try {
|
|
33214
|
+
const done = await dataForSeoTask(keys, "v3/backlinks/bulk_ranks/live", {
|
|
33215
|
+
targets: domains,
|
|
33216
|
+
rank_scale: "one_hundred"
|
|
33217
|
+
});
|
|
33218
|
+
spent.cost += done.cost;
|
|
33219
|
+
const items = done.result?.items ?? [];
|
|
33220
|
+
for (const item of items) if (item.target) ranks.set(item.target, item.rank ?? null);
|
|
33221
|
+
} catch (error) {
|
|
33222
|
+
spent.cost += error.cost ?? 0;
|
|
33223
|
+
spent.rankFailed = true;
|
|
33224
|
+
}
|
|
33225
|
+
return rows.map((row) => {
|
|
33226
|
+
const next = {};
|
|
33227
|
+
for (const [name, value] of Object.entries(row)) {
|
|
33228
|
+
next[name] = value;
|
|
33229
|
+
if (name === "domain") next.domain_rank = ranks.get(String(value ?? "")) ?? null;
|
|
33230
|
+
}
|
|
33231
|
+
return next;
|
|
33232
|
+
});
|
|
33233
|
+
}
|
|
33234
|
+
const siteOf = (url) => {
|
|
33235
|
+
try {
|
|
33236
|
+
return new URL(String(url)).hostname;
|
|
33237
|
+
} catch {
|
|
33238
|
+
return "";
|
|
33239
|
+
}
|
|
33240
|
+
};
|
|
33241
|
+
const serpKeywords = (input) => input.action === "serp_overlap" ? OVERLAP_KEYWORDS : SERP_KEYWORDS;
|
|
33242
|
+
const serpDepth = (input) => Math.max(1, Math.min(input.depth ?? 20, 100));
|
|
33243
|
+
async function serpOverlap(provider, headers, input) {
|
|
33244
|
+
const serps = await research(provider, headers, {
|
|
33245
|
+
...input,
|
|
33246
|
+
action: "serp",
|
|
33247
|
+
depth: OVERLAP_DEPTH,
|
|
33248
|
+
limit: OVERLAP_DEPTH,
|
|
33249
|
+
noRank: true
|
|
33250
|
+
});
|
|
33251
|
+
const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
|
|
33252
|
+
const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
|
|
33253
|
+
const top = new Map(keywords.map((keyword) => {
|
|
33254
|
+
const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
|
|
33255
|
+
const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
|
|
33256
|
+
const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
|
|
33257
|
+
return [keyword, {
|
|
33258
|
+
results: rows.length,
|
|
33259
|
+
pages,
|
|
33260
|
+
sites
|
|
33261
|
+
}];
|
|
33262
|
+
}));
|
|
33263
|
+
const rows = [];
|
|
33264
|
+
for (const [i, a] of keywords.entries()) for (const b of keywords.slice(i + 1)) {
|
|
33265
|
+
const left = top.get(a);
|
|
33266
|
+
const right = top.get(b);
|
|
33267
|
+
if (!left || !right) continue;
|
|
33268
|
+
rows.push({
|
|
33269
|
+
keyword_a: a,
|
|
33270
|
+
keyword_b: b,
|
|
33271
|
+
shared_pages: [...left.pages].filter((url) => right.pages.has(url)).length,
|
|
33272
|
+
shared_sites: [...left.sites].filter((site) => right.sites.has(site)).length,
|
|
33273
|
+
pages_compared_a: left.pages.size,
|
|
33274
|
+
pages_compared_b: right.pages.size,
|
|
33275
|
+
results_a: left.results,
|
|
33276
|
+
results_b: right.results
|
|
33277
|
+
});
|
|
33278
|
+
}
|
|
33279
|
+
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)));
|
|
33280
|
+
const results = [...top.values()];
|
|
33281
|
+
return {
|
|
33282
|
+
...serps,
|
|
33283
|
+
rows,
|
|
33284
|
+
compared: {
|
|
33285
|
+
results: results.reduce((sum, item) => sum + item.results, 0),
|
|
33286
|
+
withPage: results.reduce((sum, item) => sum + item.pages.size, 0),
|
|
33287
|
+
withSite: results.reduce((sum, item) => sum + item.sites.size, 0)
|
|
33288
|
+
}
|
|
33289
|
+
};
|
|
33290
|
+
}
|
|
32419
33291
|
const GAP_DOMAINS = 1e3;
|
|
32420
33292
|
const GAP_COMPETITORS = 5;
|
|
32421
33293
|
const SHORTENERS = [
|
|
@@ -32457,6 +33329,7 @@ async function linkGap(provider, headers, input) {
|
|
|
32457
33329
|
...input,
|
|
32458
33330
|
action: "backlinks",
|
|
32459
33331
|
target: site,
|
|
33332
|
+
scope: "domain",
|
|
32460
33333
|
limit: GAP_DOMAINS
|
|
32461
33334
|
});
|
|
32462
33335
|
cost += got.costUsd ?? 0;
|
|
@@ -32503,77 +33376,49 @@ async function linkGap(provider, headers, input) {
|
|
|
32503
33376
|
...linkLists.length ? { leftOut: linkLists } : {}
|
|
32504
33377
|
};
|
|
32505
33378
|
}
|
|
32506
|
-
function isPage(target) {
|
|
32507
|
-
return /\/./.test(bareTarget(target));
|
|
32508
|
-
}
|
|
32509
33379
|
function bareTarget(target) {
|
|
32510
33380
|
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
32511
33381
|
}
|
|
32512
|
-
|
|
32513
|
-
|
|
32514
|
-
|
|
32515
|
-
|
|
32516
|
-
|
|
32517
|
-
|
|
32518
|
-
|
|
32519
|
-
|
|
32520
|
-
|
|
32521
|
-
const
|
|
32522
|
-
|
|
32523
|
-
|
|
32524
|
-
|
|
32525
|
-
|
|
32526
|
-
|
|
32527
|
-
|
|
32528
|
-
|
|
32529
|
-
|
|
32530
|
-
|
|
32531
|
-
|
|
32532
|
-
|
|
32533
|
-
|
|
32534
|
-
|
|
32535
|
-
|
|
32536
|
-
|
|
32537
|
-
|
|
32538
|
-
|
|
32539
|
-
|
|
32540
|
-
|
|
32541
|
-
top_positions: String(Math.min(input.limit, 100))
|
|
32542
|
-
})).positions ?? []).map((row) => ({
|
|
32543
|
-
keyword,
|
|
32544
|
-
...row
|
|
32545
|
-
}));
|
|
32546
|
-
});
|
|
32547
|
-
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
32548
|
-
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
32549
|
-
target: input.target,
|
|
32550
|
-
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
32551
|
-
history: "live",
|
|
32552
|
-
limit: String(input.limit),
|
|
32553
|
-
order_by: "domain_rating:desc"
|
|
32554
|
-
})).refdomains ?? [];
|
|
32555
|
-
return (await get("site-explorer/organic-competitors", {
|
|
32556
|
-
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
32557
|
-
target: input.target,
|
|
32558
|
-
country: input.country,
|
|
32559
|
-
date: today,
|
|
32560
|
-
mode: "subdomains",
|
|
32561
|
-
limit: String(input.limit),
|
|
32562
|
-
order_by: "keywords_common:desc"
|
|
32563
|
-
})).competitors ?? [];
|
|
32564
|
-
}
|
|
32565
|
-
function flattenIntents(row) {
|
|
32566
|
-
const intents = row.intents;
|
|
32567
|
-
if (!intents || typeof intents !== "object") return row;
|
|
32568
|
-
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
32569
|
-
return {
|
|
32570
|
-
...row,
|
|
32571
|
-
intents: names.join(" ") || null
|
|
33382
|
+
function researchScope(target, scope) {
|
|
33383
|
+
if (scope) return scope;
|
|
33384
|
+
const bare = target.trim().replace(/^https?:\/\//, "");
|
|
33385
|
+
if (!/\/./.test(bare)) return "domain";
|
|
33386
|
+
return bare.endsWith("/") ? "prefix" : "exact";
|
|
33387
|
+
}
|
|
33388
|
+
function targetParts(input) {
|
|
33389
|
+
const scope = researchScope(input.target, input.scope);
|
|
33390
|
+
const bare = input.target.trim().replace(/^https?:\/\//, "");
|
|
33391
|
+
const slash = bare.indexOf("/");
|
|
33392
|
+
const host = (slash === -1 ? bare : bare.slice(0, slash)).toLowerCase();
|
|
33393
|
+
let path = slash === -1 ? "/" : bare.slice(slash);
|
|
33394
|
+
if (scope === "prefix" && !path.endsWith("/")) path += "/";
|
|
33395
|
+
const scheme = /^http:\/\//.test(input.target.trim()) ? "http" : "https";
|
|
33396
|
+
return {
|
|
33397
|
+
scope,
|
|
33398
|
+
host,
|
|
33399
|
+
path,
|
|
33400
|
+
page: `${scheme}://${host}${path}`,
|
|
33401
|
+
ahrefs: scope === "domain" ? {
|
|
33402
|
+
target: host,
|
|
33403
|
+
mode: "subdomains"
|
|
33404
|
+
} : scope === "prefix" ? {
|
|
33405
|
+
target: `${host}${path}`,
|
|
33406
|
+
mode: "prefix"
|
|
33407
|
+
} : {
|
|
33408
|
+
target: `${host}${path}`,
|
|
33409
|
+
mode: "exact"
|
|
33410
|
+
}
|
|
32572
33411
|
};
|
|
32573
33412
|
}
|
|
33413
|
+
const likePrefix = (host, path) => `%${host}${path}%`;
|
|
32574
33414
|
function rowsText(input) {
|
|
32575
33415
|
const total = input.rows.length;
|
|
32576
|
-
if (input.detail === "full")
|
|
33416
|
+
if (input.detail === "full") {
|
|
33417
|
+
const page = input.rows.slice(input.offset, input.offset + input.limit);
|
|
33418
|
+
const end = input.offset + page.length;
|
|
33419
|
+
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}`}.` : "";
|
|
33420
|
+
return `${input.title}\n\n${JSON.stringify(page)}${cut}`;
|
|
33421
|
+
}
|
|
32577
33422
|
const size = input.detail === "summary" ? Math.min(input.limit, 25) : input.limit;
|
|
32578
33423
|
const page = input.rows.slice(input.offset, input.offset + size);
|
|
32579
33424
|
const columns = [...new Set(page.flatMap((row) => Object.keys(row)))];
|
|
@@ -32585,6 +33430,14 @@ function rowsText(input) {
|
|
|
32585
33430
|
end < total ? `More: ${input.more ? input.more(end) : `detail "rows", offset ${end}`}.` : ""
|
|
32586
33431
|
].filter(Boolean).join("\n\n");
|
|
32587
33432
|
}
|
|
33433
|
+
const SPAM_TLDS = /\.(xyz|top|click|buzz|icu|cfd|sbs|monster|rest|lol)$/;
|
|
33434
|
+
function spamHint(query) {
|
|
33435
|
+
const text = query.trim().toLowerCase();
|
|
33436
|
+
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(text) && SPAM_TLDS.test(text)) return "host name on a TLD common in referral spam";
|
|
33437
|
+
if (/^\d+: /.test(text) || /\btest keyword\b/.test(text)) return "rank tracker test query";
|
|
33438
|
+
if ((text.match(/[=,]/g) ?? []).length >= 6 && /"/.test(text)) return "pasted spreadsheet or tool export";
|
|
33439
|
+
return null;
|
|
33440
|
+
}
|
|
32588
33441
|
const DIMENSIONS = [
|
|
32589
33442
|
"query",
|
|
32590
33443
|
"page",
|
|
@@ -32655,6 +33508,14 @@ async function runRows(input, deps) {
|
|
|
32655
33508
|
}
|
|
32656
33509
|
rows.sort((a, b) => dateAt >= 0 ? String(a.date).localeCompare(String(b.date)) : Number(b.clicks ?? 0) - Number(a.clicks ?? 0));
|
|
32657
33510
|
const notes = [];
|
|
33511
|
+
if (dimensions.indexOf("query") >= 0) {
|
|
33512
|
+
const flagged = rows.filter((row) => spamHint(String(row.query ?? "")));
|
|
33513
|
+
if (flagged.length) {
|
|
33514
|
+
for (const row of rows) row.spam_hint = spamHint(String(row.query ?? ""));
|
|
33515
|
+
const impressions = flagged.reduce((sum, row) => sum + Number(row.impressions ?? 0), 0);
|
|
33516
|
+
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.`);
|
|
33517
|
+
}
|
|
33518
|
+
}
|
|
32658
33519
|
if (!dimensions.includes("query") && !dimensions.includes("page")) {
|
|
32659
33520
|
const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
|
32660
33521
|
notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
|
|
@@ -32770,24 +33631,29 @@ const hostOf$2 = (url) => {
|
|
|
32770
33631
|
return null;
|
|
32771
33632
|
}
|
|
32772
33633
|
};
|
|
33634
|
+
const PLACEHOLDERS = [
|
|
33635
|
+
"example.com",
|
|
33636
|
+
"example.org",
|
|
33637
|
+
"example.net"
|
|
33638
|
+
];
|
|
33639
|
+
const PLACEHOLDER = /^your-?(domain|site|url|website|company|blog)(\.[a-z]+)?$/;
|
|
33640
|
+
const isPlaceholder = (host) => PLACEHOLDER.test(host) || PLACEHOLDERS.some((name) => host === name || host.endsWith(`.${name}`));
|
|
32773
33641
|
const NOT_SOURCES = [
|
|
32774
33642
|
"dataforseo.com",
|
|
32775
33643
|
"images.openai.com",
|
|
32776
33644
|
"gstatic.com",
|
|
32777
33645
|
"googleusercontent.com",
|
|
32778
|
-
"ggpht.com"
|
|
32779
|
-
"example.com",
|
|
32780
|
-
"example.org",
|
|
32781
|
-
"example.net"
|
|
33646
|
+
"ggpht.com"
|
|
32782
33647
|
];
|
|
32783
33648
|
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
|
|
32784
33649
|
const isSource = (url) => {
|
|
32785
33650
|
const host = hostOf$2(url);
|
|
32786
|
-
if (!host || IMAGE.test(url)) return false;
|
|
33651
|
+
if (!host || IMAGE.test(url) || isPlaceholder(host)) return false;
|
|
32787
33652
|
return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
|
|
32788
33653
|
};
|
|
32789
33654
|
const empty = {
|
|
32790
33655
|
cited_url: null,
|
|
33656
|
+
mentioned_as: null,
|
|
32791
33657
|
sources: [],
|
|
32792
33658
|
model: null,
|
|
32793
33659
|
answered_at: null
|
|
@@ -32809,12 +33675,14 @@ function judge(engine, task, site) {
|
|
|
32809
33675
|
...empty,
|
|
32810
33676
|
answered_at
|
|
32811
33677
|
};
|
|
32812
|
-
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
32813
|
-
const all = [...shown.sources, ...linked];
|
|
32814
|
-
const
|
|
33678
|
+
const linked = (shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? []).map((url) => url.replace(/[`'"‘’“”()[\]{}<>.,;:!?*]+$/, ""));
|
|
33679
|
+
const all = [...shown.sources, ...linked].filter(isSource);
|
|
33680
|
+
const onTarget = (url) => site.domains.some((domain) => onSite(url, domain));
|
|
33681
|
+
const cited = all.find(onTarget) ?? null;
|
|
32815
33682
|
const base = {
|
|
32816
33683
|
cited_url: cited,
|
|
32817
|
-
|
|
33684
|
+
mentioned_as: null,
|
|
33685
|
+
sources: [...new Set(all.filter((url) => !onTarget(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
|
|
32818
33686
|
model: shown.model,
|
|
32819
33687
|
answered_at
|
|
32820
33688
|
};
|
|
@@ -32823,9 +33691,12 @@ function judge(engine, task, site) {
|
|
|
32823
33691
|
...base
|
|
32824
33692
|
};
|
|
32825
33693
|
const text = prose(shown.markdown);
|
|
33694
|
+
const lower = text.toLowerCase();
|
|
33695
|
+
const mentioned_as = site.names.find((name) => lower.includes(name)) ?? site.terms.find((term) => isBrandQuery(text, [term])) ?? null;
|
|
32826
33696
|
return {
|
|
32827
|
-
result:
|
|
32828
|
-
...base
|
|
33697
|
+
result: mentioned_as ? "mentioned" : "neither",
|
|
33698
|
+
...base,
|
|
33699
|
+
mentioned_as
|
|
32829
33700
|
};
|
|
32830
33701
|
}
|
|
32831
33702
|
async function marketsFor(keys, country) {
|
|
@@ -32853,9 +33724,22 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
32853
33724
|
const keys = dataForSeoKeys(headers);
|
|
32854
33725
|
const domain = registrableDomain(input.site)?.domain;
|
|
32855
33726
|
if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
|
|
33727
|
+
const domains = [domain];
|
|
33728
|
+
for (const value of input.also ?? []) {
|
|
33729
|
+
const other = registrableDomain(value)?.domain;
|
|
33730
|
+
if (!other) throw new CommandError("INVALID_INPUT", `ai_answers takes domains in also, not "${value}".`);
|
|
33731
|
+
if (!domains.includes(other)) domains.push(other);
|
|
33732
|
+
}
|
|
33733
|
+
if (domains.length > 5) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 4 other domains in also.`);
|
|
33734
|
+
const names = domains.filter((name) => !isPlaceholder(name));
|
|
33735
|
+
const brand = [...new Set((input.brand ?? []).map((b) => b.trim()).filter(Boolean))];
|
|
32856
33736
|
const site = {
|
|
32857
|
-
|
|
32858
|
-
|
|
33737
|
+
domains,
|
|
33738
|
+
names,
|
|
33739
|
+
terms: [...new Set(brand.length ? brand.flatMap((name) => deriveBrandTerms({
|
|
33740
|
+
name,
|
|
33741
|
+
siteUrl: ""
|
|
33742
|
+
})) : names.flatMap((name) => deriveBrandTerms({ siteUrl: name })))]
|
|
32859
33743
|
};
|
|
32860
33744
|
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
32861
33745
|
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
@@ -32983,8 +33867,9 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
32983
33867
|
`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}.`,
|
|
32984
33868
|
...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).`] : [],
|
|
32985
33869
|
...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
|
|
33870
|
+
...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.`),
|
|
32986
33871
|
"Each row is one sample of an answer that changes from run to run, not a ranking.",
|
|
32987
|
-
`cited: the answer lists or links a page on ${
|
|
33872
|
+
`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.`,
|
|
32988
33873
|
`Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
|
|
32989
33874
|
] : []];
|
|
32990
33875
|
const shown = rows.map((row) => ({
|
|
@@ -32993,6 +33878,7 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
32993
33878
|
engine: row.engine,
|
|
32994
33879
|
result: row.result,
|
|
32995
33880
|
cited_url: row.cited_url,
|
|
33881
|
+
mentioned_as: row.mentioned_as,
|
|
32996
33882
|
sources: row.sources.join(", "),
|
|
32997
33883
|
model: row.model,
|
|
32998
33884
|
answered_at: row.answered_at,
|
|
@@ -33020,6 +33906,9 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
33020
33906
|
...range
|
|
33021
33907
|
},
|
|
33022
33908
|
questions: [...new Set(rows.map((row) => row.question))],
|
|
33909
|
+
domains,
|
|
33910
|
+
brandTerms: site.terms,
|
|
33911
|
+
brandTermsFrom: brand.length ? "given" : "domains",
|
|
33023
33912
|
costUsd: cost,
|
|
33024
33913
|
pendingTasks: left,
|
|
33025
33914
|
tasks: refs,
|
|
@@ -34475,7 +35364,7 @@ function labelOf(e) {
|
|
|
34475
35364
|
};
|
|
34476
35365
|
if (!last) return {
|
|
34477
35366
|
label: "fix",
|
|
34478
|
-
why: `The redirects loop or pass
|
|
35367
|
+
why: `The redirects loop or pass 10 hops (${hops.map((hop) => hop.status).join(" > ")}).`
|
|
34479
35368
|
};
|
|
34480
35369
|
if (last.error) return {
|
|
34481
35370
|
label: "check",
|
|
@@ -34545,7 +35434,7 @@ function labelOf(e) {
|
|
|
34545
35434
|
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.`
|
|
34546
35435
|
};
|
|
34547
35436
|
const robots = last.robots ? ` with robots "${last.robots}"` : " with no robots meta tag or X-Robots-Tag header";
|
|
34548
|
-
if (/noindex/i.test(coverage) &&
|
|
35437
|
+
if (/noindex/i.test(coverage) && last.google_noindex === false) return {
|
|
34549
35438
|
label: "check",
|
|
34550
35439
|
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." : ""}`
|
|
34551
35440
|
};
|
|
@@ -34571,7 +35460,7 @@ function labelOf(e) {
|
|
|
34571
35460
|
};
|
|
34572
35461
|
return {
|
|
34573
35462
|
label: "check",
|
|
34574
|
-
why:
|
|
35463
|
+
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.`
|
|
34575
35464
|
};
|
|
34576
35465
|
}
|
|
34577
35466
|
function indexingRow(e, sources, inspectedAt) {
|
|
@@ -35381,38 +36270,79 @@ async function markIssues(input, deps) {
|
|
|
35381
36270
|
};
|
|
35382
36271
|
}
|
|
35383
36272
|
// packages/core/src/commands/lint.ts
|
|
35384
|
-
async function
|
|
36273
|
+
async function pageSource(url, readPage) {
|
|
36274
|
+
const page = await readPage(url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
|
|
36275
|
+
if ("error" in page) return {
|
|
36276
|
+
path: url,
|
|
36277
|
+
skipped: `The page could not be read (${page.error}).`
|
|
36278
|
+
};
|
|
36279
|
+
if (!page.markdown) return {
|
|
36280
|
+
path: url,
|
|
36281
|
+
skipped: "The page has no main content to read."
|
|
36282
|
+
};
|
|
36283
|
+
return {
|
|
36284
|
+
path: url,
|
|
36285
|
+
text: page.markdown,
|
|
36286
|
+
note: "Checked the main content extracted from the live page (the Markdown `seo page --detail full` shows), not the whole HTML.",
|
|
36287
|
+
...page.truncated ? { partial: `the main content has ${page.characters.toLocaleString("en-US")} characters; the first ${page.markdown.length.toLocaleString("en-US")} were checked.` } : {}
|
|
36288
|
+
};
|
|
36289
|
+
}
|
|
36290
|
+
async function runLint(input, readFiles, readPage) {
|
|
35385
36291
|
const hasText = typeof input.text === "string" && input.text.length > 0;
|
|
35386
36292
|
const hasPaths = Boolean(input.paths?.length);
|
|
35387
|
-
|
|
36293
|
+
const hasUrl = typeof input.url === "string" && input.url.length > 0;
|
|
36294
|
+
if ([
|
|
36295
|
+
hasText,
|
|
36296
|
+
hasPaths,
|
|
36297
|
+
hasUrl
|
|
36298
|
+
].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.");
|
|
35388
36299
|
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.");
|
|
36300
|
+
if (hasUrl) {
|
|
36301
|
+
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.");
|
|
36302
|
+
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.`);
|
|
36303
|
+
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.");
|
|
36304
|
+
}
|
|
35389
36305
|
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.`);
|
|
36306
|
+
const only = input.only?.length ? LINT_CATEGORIES.filter((category) => input.only?.includes(category)) : void 0;
|
|
35390
36307
|
const files = (hasText ? [{
|
|
35391
36308
|
path: "text",
|
|
35392
36309
|
text: input.text
|
|
35393
|
-
}] : await readFiles(input.paths ?? [])).map((source) => {
|
|
36310
|
+
}] : hasUrl ? [await pageSource(input.url, readPage)] : await readFiles(input.paths ?? [])).map((source) => {
|
|
35394
36311
|
if ("skipped" in source) return source;
|
|
35395
|
-
const format = input.format ?? (hasText ? "
|
|
36312
|
+
const format = input.format ?? (hasPaths ? formatFromPath(source.path) : hasText && input.preset === "meta" ? "text" : "md");
|
|
35396
36313
|
return {
|
|
35397
36314
|
path: source.path,
|
|
36315
|
+
...source.note ? { note: source.note } : {},
|
|
36316
|
+
...source.partial ? { partial: source.partial } : {},
|
|
35398
36317
|
result: lintText(source.text, {
|
|
35399
36318
|
format,
|
|
35400
36319
|
preset: input.preset ?? defaultPreset(format),
|
|
35401
36320
|
ignoreRules: input.ignoreRules,
|
|
35402
|
-
ignorePhrases: input.ignorePhrases
|
|
36321
|
+
ignorePhrases: input.ignorePhrases,
|
|
36322
|
+
only,
|
|
36323
|
+
metaKind: input.metaKind
|
|
35403
36324
|
})
|
|
35404
36325
|
};
|
|
35405
36326
|
});
|
|
36327
|
+
const checked = files.flatMap((file) => "result" in file ? [file] : []);
|
|
36328
|
+
const total = (count) => checked.reduce((sum, file) => sum + count(file), 0);
|
|
35406
36329
|
return {
|
|
35407
36330
|
text: lintReportText(files),
|
|
35408
36331
|
data: {
|
|
35409
36332
|
files: files.map((file) => "skipped" in file ? file : {
|
|
35410
36333
|
path: file.path,
|
|
36334
|
+
...file.note ? { note: file.note } : {},
|
|
36335
|
+
...file.partial ? { partial: file.partial } : {},
|
|
35411
36336
|
...file.result
|
|
35412
36337
|
}),
|
|
35413
|
-
errors:
|
|
35414
|
-
warnings:
|
|
35415
|
-
review:
|
|
36338
|
+
errors: total((file) => file.result.errors),
|
|
36339
|
+
warnings: total((file) => file.result.warnings),
|
|
36340
|
+
review: total((file) => file.result.review.length),
|
|
36341
|
+
checked: checked.length,
|
|
36342
|
+
skipped: files.length - checked.length,
|
|
36343
|
+
partial: checked.filter((file) => file.partial).length,
|
|
36344
|
+
categories: Object.fromEntries(LINT_CATEGORIES.map((category) => [category, total((file) => file.result.categories[category])])),
|
|
36345
|
+
...only ? { only } : {}
|
|
35416
36346
|
}
|
|
35417
36347
|
};
|
|
35418
36348
|
}
|
|
@@ -35420,7 +36350,8 @@ async function runLint(input, readFiles) {
|
|
|
35420
36350
|
const MAX_PAGES = 20;
|
|
35421
36351
|
const TIMEOUT_MS$2 = 6e3;
|
|
35422
36352
|
const MAX_BYTES$3 = 1e6;
|
|
35423
|
-
const clean = (value) => value
|
|
36353
|
+
const clean = (value) => value === null ? null : value.trim().replace(/\s+/g, " ");
|
|
36354
|
+
const BOT_META = /^([a-z][a-z0-9_-]*bot(-[a-z]+)?|slurp|yandex|baiduspider)$/;
|
|
35424
36355
|
async function boundedText(response, maxBytes = MAX_BYTES$3) {
|
|
35425
36356
|
if (!response.body) return "";
|
|
35426
36357
|
const reader = response.body.getReader();
|
|
@@ -35437,8 +36368,8 @@ async function boundedText(response, maxBytes = MAX_BYTES$3) {
|
|
|
35437
36368
|
return text;
|
|
35438
36369
|
}
|
|
35439
36370
|
function headFacts(html, url) {
|
|
35440
|
-
let title =
|
|
35441
|
-
let h1 =
|
|
36371
|
+
let title = null;
|
|
36372
|
+
let h1 = null;
|
|
35442
36373
|
let inTitle = false;
|
|
35443
36374
|
let inH1 = false;
|
|
35444
36375
|
let titleDone = false;
|
|
@@ -35446,14 +36377,25 @@ function headFacts(html, url) {
|
|
|
35446
36377
|
let description = null;
|
|
35447
36378
|
let canonical = null;
|
|
35448
36379
|
const robots = [];
|
|
36380
|
+
const google = [];
|
|
35449
36381
|
const parser = new Parser({
|
|
35450
36382
|
onopentag(name, attributes) {
|
|
35451
|
-
if (name === "title" && !titleDone)
|
|
35452
|
-
|
|
36383
|
+
if (name === "title" && !titleDone) {
|
|
36384
|
+
inTitle = true;
|
|
36385
|
+
title ??= "";
|
|
36386
|
+
}
|
|
36387
|
+
if (name === "h1" && !h1Done) {
|
|
36388
|
+
inH1 = true;
|
|
36389
|
+
h1 ??= "";
|
|
36390
|
+
}
|
|
35453
36391
|
if (name === "meta") {
|
|
35454
|
-
const metaName = attributes.name?.toLowerCase();
|
|
35455
|
-
|
|
35456
|
-
if (metaName === "
|
|
36392
|
+
const metaName = attributes.name?.trim().toLowerCase() ?? "";
|
|
36393
|
+
const content = attributes.content?.trim();
|
|
36394
|
+
if (metaName === "description" && description === null) description = attributes.content ?? "";
|
|
36395
|
+
if (content && (metaName === "robots" || BOT_META.test(metaName))) {
|
|
36396
|
+
robots.push(metaName === "robots" ? content : `${metaName}: ${content}`);
|
|
36397
|
+
if (metaName === "robots" || metaName === "googlebot") google.push(content);
|
|
36398
|
+
}
|
|
35457
36399
|
}
|
|
35458
36400
|
if (name === "link" && canonical === null && attributes.rel?.toLowerCase().split(/\s+/).includes("canonical") && attributes.href) try {
|
|
35459
36401
|
canonical = new URL(attributes.href, url).toString();
|
|
@@ -35462,8 +36404,8 @@ function headFacts(html, url) {
|
|
|
35462
36404
|
}
|
|
35463
36405
|
},
|
|
35464
36406
|
ontext(text) {
|
|
35465
|
-
if (inTitle) title
|
|
35466
|
-
if (inH1) h1
|
|
36407
|
+
if (inTitle) title = (title ?? "") + text;
|
|
36408
|
+
if (inH1) h1 = (h1 ?? "") + text;
|
|
35467
36409
|
},
|
|
35468
36410
|
onclosetag(name) {
|
|
35469
36411
|
if (name === "title" && inTitle) {
|
|
@@ -35486,10 +36428,11 @@ function headFacts(html, url) {
|
|
|
35486
36428
|
description: clean(description),
|
|
35487
36429
|
h1: clean(h1),
|
|
35488
36430
|
canonical,
|
|
35489
|
-
robots: robots.length ? robots.join(", ") : null
|
|
36431
|
+
robots: robots.length ? robots.join(", ") : null,
|
|
36432
|
+
google_noindex: hasMetaRobotsDirective(google, "noindex")
|
|
35490
36433
|
};
|
|
35491
36434
|
}
|
|
35492
|
-
async function facts(url) {
|
|
36435
|
+
async function facts(url, given) {
|
|
35493
36436
|
const out = {
|
|
35494
36437
|
url,
|
|
35495
36438
|
status: null,
|
|
@@ -35498,10 +36441,11 @@ async function facts(url) {
|
|
|
35498
36441
|
description: null,
|
|
35499
36442
|
h1: null,
|
|
35500
36443
|
canonical: null,
|
|
35501
|
-
robots: null
|
|
36444
|
+
robots: null,
|
|
36445
|
+
google_noindex: null
|
|
35502
36446
|
};
|
|
35503
36447
|
try {
|
|
35504
|
-
const response = await fetch(url, {
|
|
36448
|
+
const response = given ?? await fetch(url, {
|
|
35505
36449
|
redirect: "manual",
|
|
35506
36450
|
headers: {
|
|
35507
36451
|
"user-agent": SEO_CRAWLER_USER_AGENT,
|
|
@@ -35513,53 +36457,71 @@ async function facts(url) {
|
|
|
35513
36457
|
const location = response.headers.get("location");
|
|
35514
36458
|
if (location) out.redirects_to = new URL(location, url).toString();
|
|
35515
36459
|
const headerRobots = response.headers.get("x-robots-tag");
|
|
36460
|
+
const headerNoindex = hasXRobotsDirective(headerRobots ?? void 0, "noindex");
|
|
35516
36461
|
const auth = response.headers.get("www-authenticate");
|
|
35517
36462
|
if (auth) out.www_authenticate = auth.slice(0, 200);
|
|
35518
36463
|
if (response.status !== 200 || !response.headers.get("content-type")?.includes("html")) {
|
|
35519
36464
|
out.robots = headerRobots;
|
|
36465
|
+
out.google_noindex = headerNoindex;
|
|
35520
36466
|
await response.body?.cancel();
|
|
35521
36467
|
return out;
|
|
35522
36468
|
}
|
|
35523
36469
|
const head = headFacts(await boundedText(response), url);
|
|
35524
36470
|
Object.assign(out, head);
|
|
35525
36471
|
out.robots = [headerRobots, head.robots].filter(Boolean).join(", ") || null;
|
|
36472
|
+
out.google_noindex = headerNoindex || head.google_noindex;
|
|
35526
36473
|
} catch (error) {
|
|
35527
36474
|
out.error = error instanceof Error ? error.message : "fetch failed";
|
|
35528
36475
|
}
|
|
35529
36476
|
return out;
|
|
35530
36477
|
}
|
|
35531
36478
|
function checkPages(urls) {
|
|
35532
|
-
return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
|
|
36479
|
+
return Promise.all(urls.slice(0, MAX_PAGES).map((url) => facts(url)));
|
|
35533
36480
|
}
|
|
35534
|
-
const MAX_HOPS = 5;
|
|
35535
36481
|
const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
|
|
35536
36482
|
async function liveCheck(url) {
|
|
35537
|
-
const
|
|
35538
|
-
const
|
|
35539
|
-
|
|
35540
|
-
|
|
35541
|
-
|
|
35542
|
-
|
|
35543
|
-
|
|
35544
|
-
|
|
35545
|
-
|
|
35546
|
-
|
|
35547
|
-
|
|
35548
|
-
|
|
36483
|
+
const pages = [];
|
|
36484
|
+
const trace = await traceRedirects(url, {
|
|
36485
|
+
timeoutMs: TIMEOUT_MS$2,
|
|
36486
|
+
onResponse: async (response, at) => {
|
|
36487
|
+
pages.push(await facts(at, response));
|
|
36488
|
+
}
|
|
36489
|
+
});
|
|
36490
|
+
const hops = trace.steps.flatMap((step) => {
|
|
36491
|
+
const kind = redirectKind(step.status);
|
|
36492
|
+
return step.location && kind ? [{
|
|
36493
|
+
from: step.url,
|
|
36494
|
+
status: step.status,
|
|
36495
|
+
to: step.location,
|
|
35549
36496
|
kind
|
|
35550
|
-
}
|
|
35551
|
-
|
|
35552
|
-
|
|
35553
|
-
|
|
35554
|
-
|
|
35555
|
-
|
|
35556
|
-
|
|
35557
|
-
|
|
35558
|
-
|
|
36497
|
+
}] : [];
|
|
36498
|
+
});
|
|
36499
|
+
if (trace.outcome === "loop" || trace.outcome === "too_many") return {
|
|
36500
|
+
first: pages[0],
|
|
36501
|
+
hops,
|
|
36502
|
+
last: null
|
|
36503
|
+
};
|
|
36504
|
+
let last = pages.at(-1);
|
|
36505
|
+
if (trace.error) last = trace.final_status !== null && last ? {
|
|
36506
|
+
...last,
|
|
36507
|
+
error: trace.error
|
|
36508
|
+
} : {
|
|
36509
|
+
url: trace.final_url,
|
|
36510
|
+
status: null,
|
|
36511
|
+
redirects_to: null,
|
|
36512
|
+
title: null,
|
|
36513
|
+
description: null,
|
|
36514
|
+
h1: null,
|
|
36515
|
+
canonical: null,
|
|
36516
|
+
robots: null,
|
|
36517
|
+
google_noindex: null,
|
|
36518
|
+
error: trace.error
|
|
36519
|
+
};
|
|
36520
|
+
const lastPage = last;
|
|
35559
36521
|
return {
|
|
35560
|
-
first,
|
|
36522
|
+
first: pages.length > 1 || !trace.error ? pages[0] : lastPage,
|
|
35561
36523
|
hops,
|
|
35562
|
-
last
|
|
36524
|
+
last: lastPage
|
|
35563
36525
|
};
|
|
35564
36526
|
}
|
|
35565
36527
|
function temporaryRedirectNote(pages) {
|
|
@@ -36145,7 +37107,15 @@ function reportUrls(rows, origin) {
|
|
|
36145
37107
|
return found;
|
|
36146
37108
|
}
|
|
36147
37109
|
function pageFactsTable(checked, origin) {
|
|
36148
|
-
const rows = table$1(checked
|
|
37110
|
+
const rows = table$1(checked.map((page) => {
|
|
37111
|
+
const row = { ...page };
|
|
37112
|
+
for (const key of [
|
|
37113
|
+
"title",
|
|
37114
|
+
"description",
|
|
37115
|
+
"h1"
|
|
37116
|
+
]) if (page[key] === "") row[key] = "(empty)";
|
|
37117
|
+
return row;
|
|
37118
|
+
}), [
|
|
36149
37119
|
"url",
|
|
36150
37120
|
"status",
|
|
36151
37121
|
"redirects_to",
|
|
@@ -36373,6 +37343,38 @@ async function runBingReport(input, deps) {
|
|
|
36373
37343
|
data: report
|
|
36374
37344
|
};
|
|
36375
37345
|
}
|
|
37346
|
+
// packages/core/src/commands/research-cache.ts
|
|
37347
|
+
const RESEARCH_TTL_SECONDS = 86400;
|
|
37348
|
+
function workersResearchStore() {
|
|
37349
|
+
const cache = globalThis.caches?.default;
|
|
37350
|
+
if (!cache) return null;
|
|
37351
|
+
const request = (key) => new Request(`https://research-cache.invalid/${key}`);
|
|
37352
|
+
return {
|
|
37353
|
+
get: async (key) => (await cache.match(request(key)))?.text() ?? null,
|
|
37354
|
+
put: (key, value, ttl) => cache.put(request(key), new Response(value, { headers: { "cache-control": `max-age=${ttl}` } }))
|
|
37355
|
+
};
|
|
37356
|
+
}
|
|
37357
|
+
async function researchKey(parts) {
|
|
37358
|
+
const bytes = new TextEncoder().encode(JSON.stringify(parts));
|
|
37359
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
37360
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
37361
|
+
}
|
|
37362
|
+
async function readResearch(store, key, limit) {
|
|
37363
|
+
if (!store) return null;
|
|
37364
|
+
try {
|
|
37365
|
+
const text = await store.get(key);
|
|
37366
|
+
if (!text) return null;
|
|
37367
|
+
const saved = JSON.parse(text);
|
|
37368
|
+
const complete = saved.result.rows.length < saved.limit;
|
|
37369
|
+
return saved.limit >= limit || complete ? saved : null;
|
|
37370
|
+
} catch {
|
|
37371
|
+
return null;
|
|
37372
|
+
}
|
|
37373
|
+
}
|
|
37374
|
+
async function saveResearch(store, key, saved) {
|
|
37375
|
+
if (!store) return;
|
|
37376
|
+
await store.put(key, JSON.stringify(saved), RESEARCH_TTL_SECONDS).catch(() => void 0);
|
|
37377
|
+
}
|
|
36376
37378
|
// packages/core/src/commands/research.ts
|
|
36377
37379
|
const RESEARCH_ACTIONS = [
|
|
36378
37380
|
"keyword_metrics",
|
|
@@ -36381,10 +37383,14 @@ const RESEARCH_ACTIONS = [
|
|
|
36381
37383
|
"competitors",
|
|
36382
37384
|
"backlinks",
|
|
36383
37385
|
"link_gap",
|
|
36384
|
-
"ai_answers"
|
|
37386
|
+
"ai_answers",
|
|
37387
|
+
"top_pages",
|
|
37388
|
+
"pages_by_backlinks",
|
|
37389
|
+
"keyword_ideas",
|
|
37390
|
+
"serp_overlap"
|
|
36385
37391
|
];
|
|
36386
37392
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
36387
|
-
const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a
|
|
37393
|
+
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.`;
|
|
36388
37394
|
const GENERIC_PLATFORMS = [
|
|
36389
37395
|
"youtube.com",
|
|
36390
37396
|
"facebook.com",
|
|
@@ -36422,47 +37428,108 @@ function realCompetitors(rows, target) {
|
|
|
36422
37428
|
note: `Left out ${gone} ${gone === 1 ? "row" : "rows"}: ${reasons.join(" and ")}. The fixed platform list is ${GENERIC_PLATFORMS.join(", ")}.`
|
|
36423
37429
|
};
|
|
36424
37430
|
}
|
|
37431
|
+
const KEY_HEADER = {
|
|
37432
|
+
dataforseo: "x-dataforseo-login",
|
|
37433
|
+
ahrefs: "x-ahrefs-key"
|
|
37434
|
+
};
|
|
37435
|
+
function chooseProvider(asked, keys) {
|
|
37436
|
+
return asked ?? RESEARCH_PROVIDERS.find((name) => keys.has(KEY_HEADER[name])) ?? "dataforseo";
|
|
37437
|
+
}
|
|
37438
|
+
const VOLUME_ACTIONS = ["keyword_metrics", "keyword_ideas"];
|
|
37439
|
+
const VOLUME_MODEL = {
|
|
37440
|
+
dataforseo: "DataForSEO search_volume is Google Ads' average monthly searches over the last 12 months, rounded into Google's bands.",
|
|
37441
|
+
ahrefs: "Ahrefs volume is Ahrefs' own estimate of average monthly searches, from clickstream data and its keyword database."
|
|
37442
|
+
};
|
|
37443
|
+
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.";
|
|
37444
|
+
const TRAFFIC_MODEL = {
|
|
37445
|
+
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",
|
|
37446
|
+
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"
|
|
37447
|
+
};
|
|
37448
|
+
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.";
|
|
36425
37449
|
async function runResearch(input, keys, options) {
|
|
36426
37450
|
const country = input.country ?? "us";
|
|
36427
37451
|
const target = input.target ?? "";
|
|
36428
37452
|
const offset = input.offset ?? 0;
|
|
36429
|
-
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
36430
37453
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
36431
|
-
const
|
|
37454
|
+
const provider = chooseProvider(input.provider, keys);
|
|
37455
|
+
const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
37456
|
+
const scope = [
|
|
37457
|
+
"ranked_keywords",
|
|
37458
|
+
"top_pages",
|
|
37459
|
+
"backlinks",
|
|
37460
|
+
"pages_by_backlinks"
|
|
37461
|
+
].includes(input.action) && target ? researchScope(target, input.scope) : null;
|
|
37462
|
+
const limit = Math.min(1e3, Math.max(100, offset + 50));
|
|
37463
|
+
const request = {
|
|
36432
37464
|
action: input.action,
|
|
36433
37465
|
keywords: input.keywords ?? [],
|
|
36434
37466
|
target,
|
|
37467
|
+
...scope ? { scope } : {},
|
|
36435
37468
|
competitors: input.competitors ?? [],
|
|
36436
37469
|
country,
|
|
36437
|
-
|
|
37470
|
+
...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
|
|
37471
|
+
};
|
|
37472
|
+
const store = options.store ?? workersResearchStore();
|
|
37473
|
+
const key = await researchKey([
|
|
37474
|
+
provider,
|
|
37475
|
+
provider === "dataforseo" ? keys.get("x-dataforseo-login") : keys.get("x-ahrefs-key"),
|
|
37476
|
+
request
|
|
37477
|
+
]);
|
|
37478
|
+
const saved = await readResearch(store, key, limit);
|
|
37479
|
+
const result = saved?.result ?? await research(provider, keys, {
|
|
37480
|
+
...request,
|
|
37481
|
+
limit
|
|
37482
|
+
});
|
|
37483
|
+
if (!saved) await saveResearch(store, key, {
|
|
37484
|
+
limit,
|
|
37485
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
37486
|
+
result
|
|
36438
37487
|
});
|
|
37488
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
|
|
37489
|
+
const costUsd = saved ? 0 : paid;
|
|
37490
|
+
const order = new Map(request.keywords.map((word, index) => [word, index]));
|
|
37491
|
+
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;
|
|
36439
37492
|
const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
|
|
36440
37493
|
rows: found,
|
|
36441
37494
|
note: null
|
|
36442
37495
|
};
|
|
36443
37496
|
const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
|
|
36444
37497
|
const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
|
|
36445
|
-
const byDr = input.action === "link_gap" &&
|
|
37498
|
+
const byDr = input.action === "link_gap" && provider === "dataforseo" && drKey;
|
|
36446
37499
|
const rated = byDr ? await sortedByDomainRating(rows, drKey) : await withDomainRating(rows, drKey, offset);
|
|
36447
37500
|
const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
|
|
36448
37501
|
const hasTargetDr = targetDr !== void 0 && targetDr !== null;
|
|
37502
|
+
const detail = input.detail ?? "summary";
|
|
37503
|
+
const size = detail === "full" ? Math.max(rated.rows.length, 1) : 50;
|
|
37504
|
+
const shown = rated.rows.slice(offset, offset + size);
|
|
37505
|
+
const next = offset + shown.length < rated.rows.length ? offset + shown.length : null;
|
|
37506
|
+
const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
|
|
36449
37507
|
return {
|
|
36450
37508
|
text: rowsText({
|
|
36451
|
-
title: `${
|
|
37509
|
+
title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
|
|
36452
37510
|
rows: rated.rows,
|
|
36453
37511
|
offset,
|
|
36454
|
-
limit:
|
|
36455
|
-
detail
|
|
37512
|
+
limit: size,
|
|
37513
|
+
detail,
|
|
36456
37514
|
notes: [
|
|
36457
|
-
`${
|
|
36458
|
-
...
|
|
37515
|
+
`${provider} estimates, in the provider’s own units.`,
|
|
37516
|
+
...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." : ""}`] : [],
|
|
37517
|
+
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
|
|
37518
|
+
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
37519
|
+
...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
|
|
37520
|
+
...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.`] : [],
|
|
37521
|
+
...!found.length && scope ? [emptyNote(provider, input.action)] : [],
|
|
37522
|
+
...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."] : [],
|
|
37523
|
+
...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)."] : [],
|
|
37524
|
+
...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."] : [],
|
|
37525
|
+
...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}.`] : [],
|
|
36459
37526
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
36460
|
-
...missed.length ? [`Partial: ${
|
|
36461
|
-
...noData.length ? [noDataNote(input, noData)] : [],
|
|
37527
|
+
...missed.length ? [`Partial: ${provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
37528
|
+
...noData.length ? [noDataNote(provider, input.action, noData)] : [],
|
|
36462
37529
|
...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."] : [],
|
|
36463
37530
|
...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(", ")}.`] : [],
|
|
36464
37531
|
...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.`] : [],
|
|
36465
|
-
...unresolved ? [unresolvedNote(
|
|
37532
|
+
...unresolved ? [unresolvedNote(unresolved, rows.length)] : [],
|
|
36466
37533
|
...note ? [note] : [],
|
|
36467
37534
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
36468
37535
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -36470,15 +37537,22 @@ async function runResearch(input, keys, options) {
|
|
|
36470
37537
|
more: options.call
|
|
36471
37538
|
}),
|
|
36472
37539
|
data: {
|
|
36473
|
-
provider
|
|
37540
|
+
provider,
|
|
36474
37541
|
action: input.action,
|
|
36475
37542
|
target: target || null,
|
|
37543
|
+
scope,
|
|
37544
|
+
siteTotal: siteTotal ? {
|
|
37545
|
+
...siteTotal,
|
|
37546
|
+
model: TRAFFIC_MODEL[provider]
|
|
37547
|
+
} : null,
|
|
37548
|
+
available: available ?? null,
|
|
36476
37549
|
country,
|
|
36477
37550
|
language,
|
|
36478
37551
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
36479
37552
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
36480
37553
|
leftOut: note,
|
|
36481
37554
|
costUsd,
|
|
37555
|
+
savedAt: saved?.savedAt ?? null,
|
|
36482
37556
|
partial,
|
|
36483
37557
|
missedKeywords: missed,
|
|
36484
37558
|
noDataKeywords: noData,
|
|
@@ -36487,19 +37561,40 @@ async function runResearch(input, keys, options) {
|
|
|
36487
37561
|
cutAt: floor ?? null,
|
|
36488
37562
|
leftOut: leftOut ?? []
|
|
36489
37563
|
} : {},
|
|
37564
|
+
...perKeyword ? { resultsPerKeyword: perKeyword } : {},
|
|
37565
|
+
...compared ? { compared } : {},
|
|
36490
37566
|
total: rated.rows.length,
|
|
36491
37567
|
offset,
|
|
36492
|
-
|
|
37568
|
+
next,
|
|
37569
|
+
rowsLeftOut: rated.rows.length - shown.length,
|
|
37570
|
+
rows: shown
|
|
36493
37571
|
}
|
|
36494
37572
|
};
|
|
36495
37573
|
}
|
|
36496
|
-
|
|
37574
|
+
const LINK_ACTIONS = [
|
|
37575
|
+
"backlinks",
|
|
37576
|
+
"pages_by_backlinks",
|
|
37577
|
+
"link_gap"
|
|
37578
|
+
];
|
|
37579
|
+
const SCOPE_TEXT = {
|
|
37580
|
+
domain: "the whole site with its subdomains",
|
|
37581
|
+
prefix: "every URL under this path",
|
|
37582
|
+
exact: "this page only"
|
|
37583
|
+
};
|
|
37584
|
+
function siteTotalNote(provider, total, shown) {
|
|
37585
|
+
const keywords = total.keywords !== null ? ` from ${total.keywords.toLocaleString("en-US")} keywords` : "";
|
|
37586
|
+
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}`;
|
|
37587
|
+
}
|
|
37588
|
+
function emptyNote(provider, action) {
|
|
37589
|
+
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.`;
|
|
37590
|
+
}
|
|
37591
|
+
function noDataNote(provider, action, keywords) {
|
|
36497
37592
|
const list = keywords.join(", ");
|
|
36498
|
-
if (
|
|
36499
|
-
return `No data: ${
|
|
37593
|
+
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" : ""}.`;
|
|
37594
|
+
return `No data: ${provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
|
|
36500
37595
|
}
|
|
36501
|
-
function unresolvedNote(
|
|
36502
|
-
return `${count} of ${total} results have no exact page:
|
|
37596
|
+
function unresolvedNote(count, total) {
|
|
37597
|
+
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.`;
|
|
36503
37598
|
}
|
|
36504
37599
|
const GAP_RATED = 200;
|
|
36505
37600
|
async function sortedByDomainRating(rows, key) {
|
|
@@ -36669,14 +37764,33 @@ async function runPage(input, deps) {
|
|
|
36669
37764
|
}]
|
|
36670
37765
|
});
|
|
36671
37766
|
const full = input.detail === "full";
|
|
36672
|
-
const
|
|
36673
|
-
|
|
36674
|
-
|
|
36675
|
-
|
|
36676
|
-
|
|
36677
|
-
|
|
36678
|
-
|
|
37767
|
+
const pending = /* @__PURE__ */ new Set();
|
|
37768
|
+
const leg = (name, work, done) => {
|
|
37769
|
+
const report = deps.progress;
|
|
37770
|
+
if (!report) return work;
|
|
37771
|
+
pending.add(name);
|
|
37772
|
+
const finish = (line) => {
|
|
37773
|
+
pending.delete(name);
|
|
37774
|
+
report(`${line}.${pending.size ? ` Waiting on ${[...pending].join(", ")}.` : ""}`);
|
|
37775
|
+
};
|
|
37776
|
+
return work.then((value) => {
|
|
37777
|
+
finish(done(value));
|
|
37778
|
+
return value;
|
|
37779
|
+
}, (error) => {
|
|
37780
|
+
finish(`${name} failed`);
|
|
37781
|
+
throw error;
|
|
37782
|
+
});
|
|
37783
|
+
};
|
|
37784
|
+
deps.progress?.(`Checking ${input.url}: the live page, Google index status, sitemaps and Search Console queries.`);
|
|
37785
|
+
const [live, inspection, sitemap, [now, then], markdown] = await Promise.all([
|
|
37786
|
+
leg("live page", deps.liveCheck(input.url), (value) => `Live page: ${value.first.status ?? value.first.error ?? "no answer"}${value.hops.length ? ` and ${value.hops.length} redirects` : ""}`),
|
|
37787
|
+
leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}` : `Google index status: not available (${value.error?.code})`),
|
|
37788
|
+
leg("sitemaps", sitemapUrls(input.site), (value) => `Sitemaps: ${value.urls.length} URLs from ${value.files} ${value.files === 1 ? "file" : "files"}${value.partial ? " (partial)" : ""}`),
|
|
37789
|
+
leg("Search Console queries", Promise.all([query(range), query(before)]), ([current]) => `Search Console: ${current.rows.length} queries`),
|
|
37790
|
+
full && deps.pageMarkdown ? leg("main content", deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })), () => "Main content read") : null
|
|
36679
37791
|
]);
|
|
37792
|
+
const indexed = inspection.index;
|
|
37793
|
+
const indexError = inspection.error;
|
|
36680
37794
|
const facts = live.first;
|
|
36681
37795
|
const reason = indexed ? indexingRow({
|
|
36682
37796
|
url: input.url,
|
|
@@ -36723,8 +37837,9 @@ async function runPage(input, deps) {
|
|
|
36723
37837
|
title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
36724
37838
|
notes: [
|
|
36725
37839
|
`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"}.` : ""}`,
|
|
36726
|
-
indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` :
|
|
37840
|
+
indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
|
|
36727
37841
|
...reason ? [`Google's reason: ${reason.reason}. Label: ${reason.label}. ${reason.why} (Labels are rules over this evidence, not Google's; ${deps.call("index")} explains them.)`] : [],
|
|
37842
|
+
...indexed && /^URL is unknown to Google$/i.test(indexed.coverage ?? "") ? [unknownNext(input, sitemap.urls.includes(input.url), deps)] : [],
|
|
36728
37843
|
`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.`,
|
|
36729
37844
|
deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
|
|
36730
37845
|
url: input.url,
|
|
@@ -36751,6 +37866,7 @@ async function runPage(input, deps) {
|
|
|
36751
37866
|
comparisonRange: before,
|
|
36752
37867
|
page: facts,
|
|
36753
37868
|
index: indexed,
|
|
37869
|
+
indexError,
|
|
36754
37870
|
reason,
|
|
36755
37871
|
totalQueries: rows.length,
|
|
36756
37872
|
queries: rows.slice(offset, offset + 50),
|
|
@@ -36758,6 +37874,56 @@ async function runPage(input, deps) {
|
|
|
36758
37874
|
}
|
|
36759
37875
|
};
|
|
36760
37876
|
}
|
|
37877
|
+
const INSPECT_TIMEOUT_MS = 6e4;
|
|
37878
|
+
async function inspectWithin(deps, input) {
|
|
37879
|
+
let timer;
|
|
37880
|
+
const stop = new AbortController();
|
|
37881
|
+
const timeout = new Promise((resolve) => {
|
|
37882
|
+
timer = setTimeout(() => {
|
|
37883
|
+
resolve({
|
|
37884
|
+
code: "TIMEOUT",
|
|
37885
|
+
message: `URL Inspection did not answer within ${INSPECT_TIMEOUT_MS / 1e3} seconds.`
|
|
37886
|
+
});
|
|
37887
|
+
stop.abort();
|
|
37888
|
+
}, INSPECT_TIMEOUT_MS);
|
|
37889
|
+
});
|
|
37890
|
+
try {
|
|
37891
|
+
return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => ({
|
|
37892
|
+
index,
|
|
37893
|
+
error: null
|
|
37894
|
+
})), timeout.then((error) => ({
|
|
37895
|
+
index: null,
|
|
37896
|
+
error
|
|
37897
|
+
}))]);
|
|
37898
|
+
} catch (error) {
|
|
37899
|
+
const known = commandErrorFrom(error);
|
|
37900
|
+
return {
|
|
37901
|
+
index: null,
|
|
37902
|
+
error: {
|
|
37903
|
+
code: known instanceof CommandError ? known.code : "GOOGLE_ERROR",
|
|
37904
|
+
message: known instanceof Error ? known.message : String(known)
|
|
37905
|
+
}
|
|
37906
|
+
};
|
|
37907
|
+
} finally {
|
|
37908
|
+
clearTimeout(timer);
|
|
37909
|
+
}
|
|
37910
|
+
}
|
|
37911
|
+
function indexErrorText(error, input, deps) {
|
|
37912
|
+
const again = deps.call("page", { url: input.url });
|
|
37913
|
+
const message = error?.message ?? "No reason was returned.";
|
|
37914
|
+
const why = {
|
|
37915
|
+
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.`,
|
|
37916
|
+
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.`,
|
|
37917
|
+
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.`,
|
|
37918
|
+
NOT_CONNECTED: `The Google sign-in did not work. ${message} Next: sign in again, then run ${again}.`,
|
|
37919
|
+
NO_KEY: `Google sign-in is not set up here. ${message}`,
|
|
37920
|
+
INVALID_INPUT: `Google refused the request. ${message} Next: check that the URL belongs to ${input.site}.`
|
|
37921
|
+
};
|
|
37922
|
+
return `Google index status: not available. ${(error && why[error.code]) ?? `URL Inspection failed. ${message} Next: run ${again} again later.`}`;
|
|
37923
|
+
}
|
|
37924
|
+
function unknownNext(input, inSitemap, deps) {
|
|
37925
|
+
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 })}.`;
|
|
37926
|
+
}
|
|
36761
37927
|
function markdownText(markdown, url) {
|
|
36762
37928
|
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\`.`;
|
|
36763
37929
|
if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
|
|
@@ -37199,9 +38365,12 @@ const listSites$1 = async (surface) => runSites({
|
|
|
37199
38365
|
});
|
|
37200
38366
|
const lintInput = {
|
|
37201
38367
|
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.`),
|
|
38368
|
+
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."),
|
|
37202
38369
|
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."),
|
|
37203
|
-
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)."),
|
|
38370
|
+
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)."),
|
|
38371
|
+
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."),
|
|
37204
38372
|
ignore_rules: z.array(z.string().min(1).max(60)).max(50).optional().describe("Optional rule ids or review lenses to skip."),
|
|
38373
|
+
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."),
|
|
37205
38374
|
ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching.")
|
|
37206
38375
|
};
|
|
37207
38376
|
const SHARED_TOOLS = [
|
|
@@ -37360,30 +38529,37 @@ const SHARED_TOOLS = [
|
|
|
37360
38529
|
title: "Keyword, competitor, backlink and AI answer data",
|
|
37361
38530
|
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}`,
|
|
37362
38531
|
input: z.object({
|
|
37363
|
-
provider: z.enum(RESEARCH_PROVIDERS),
|
|
38532
|
+
provider: z.enum(RESEARCH_PROVIDERS).optional().describe("Optional. Without it, the saved key; DataForSEO when both are saved."),
|
|
37364
38533
|
action: z.enum(RESEARCH_ACTIONS),
|
|
37365
38534
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
37366
|
-
target: z.string().default("").describe("Domain
|
|
38535
|
+
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."),
|
|
38536
|
+
scope: z.enum(RESEARCH_SCOPES).optional().describe("Optional: domain, prefix or exact, to read the target another way than its shape says."),
|
|
37367
38537
|
competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
|
|
37368
38538
|
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."),
|
|
38539
|
+
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."),
|
|
38540
|
+
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."),
|
|
37369
38541
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
38542
|
+
depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
|
|
37370
38543
|
...detail
|
|
37371
38544
|
}),
|
|
37372
38545
|
run: async (surface, input) => {
|
|
37373
38546
|
const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
|
|
37374
38547
|
if (input.action === "ai_answers") {
|
|
37375
|
-
if (input.provider
|
|
38548
|
+
if (input.provider === "ahrefs") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
37376
38549
|
return runAiAnswers({
|
|
37377
38550
|
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
37378
38551
|
country: input.country,
|
|
37379
38552
|
detail: input.detail,
|
|
37380
38553
|
offset: input.offset,
|
|
37381
38554
|
questions: input.keywords,
|
|
37382
|
-
tasks: input.tasks
|
|
37383
|
-
|
|
38555
|
+
tasks: input.tasks,
|
|
38556
|
+
also: input.also,
|
|
38557
|
+
brand: input.brand
|
|
38558
|
+
}, 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`);
|
|
37384
38559
|
}
|
|
37385
38560
|
return runResearch(input, await surface.keys(), {
|
|
37386
38561
|
drKey: surface.drKey,
|
|
38562
|
+
store: surface.researchStore,
|
|
37387
38563
|
call: more
|
|
37388
38564
|
});
|
|
37389
38565
|
}
|
|
@@ -37455,20 +38631,26 @@ const SHARED_TOOLS = [
|
|
|
37455
38631
|
name: "seo_lint",
|
|
37456
38632
|
command: "lint",
|
|
37457
38633
|
title: "Check copy for AI writing patterns",
|
|
37458
|
-
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.
|
|
38634
|
+
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.",
|
|
37459
38635
|
input: z.object(lintInput),
|
|
37460
38636
|
localInput: z.object({
|
|
37461
38637
|
...lintInput,
|
|
37462
38638
|
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.")
|
|
37463
38639
|
}),
|
|
37464
|
-
run: async (surface, input) =>
|
|
37465
|
-
|
|
37466
|
-
|
|
37467
|
-
|
|
37468
|
-
|
|
37469
|
-
|
|
37470
|
-
|
|
37471
|
-
|
|
38640
|
+
run: async (surface, input) => {
|
|
38641
|
+
const pageMarkdown = surface.deps.pageMarkdown;
|
|
38642
|
+
return runLint({
|
|
38643
|
+
text: input.text,
|
|
38644
|
+
paths: input.paths,
|
|
38645
|
+
url: input.url,
|
|
38646
|
+
format: input.format,
|
|
38647
|
+
preset: input.preset,
|
|
38648
|
+
ignoreRules: input.ignore_rules,
|
|
38649
|
+
ignorePhrases: input.ignore_phrases,
|
|
38650
|
+
only: input.only,
|
|
38651
|
+
metaKind: input.meta_kind
|
|
38652
|
+
}, surface.readFiles, pageMarkdown && ((url) => pageMarkdown(url, 2e5)));
|
|
38653
|
+
}
|
|
37472
38654
|
})
|
|
37473
38655
|
];
|
|
37474
38656
|
function indexFromStore(shared) {
|
|
@@ -37753,10 +38935,11 @@ function nodeCommandDeps(options) {
|
|
|
37753
38935
|
}),
|
|
37754
38936
|
checkPages,
|
|
37755
38937
|
liveCheck,
|
|
37756
|
-
inspect: async (site, url) => indexStatusFrom(await inspectUrl({
|
|
38938
|
+
inspect: async (site, url, signal) => indexStatusFrom(await inspectUrl({
|
|
37757
38939
|
siteUrl: site,
|
|
37758
38940
|
inspectionUrl: url,
|
|
37759
|
-
accountEmail: options.account
|
|
38941
|
+
accountEmail: options.account,
|
|
38942
|
+
signal
|
|
37760
38943
|
})),
|
|
37761
38944
|
inspectionQuota: (site) => urlInspectionQuota({
|
|
37762
38945
|
siteUrl: site,
|
|
@@ -37766,7 +38949,7 @@ function nodeCommandDeps(options) {
|
|
|
37766
38949
|
read: (site, urls) => options.refresh ? /* @__PURE__ */ new Map() : readSavedInspections(site, urls),
|
|
37767
38950
|
save: saveInspection
|
|
37768
38951
|
},
|
|
37769
|
-
pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
|
|
38952
|
+
pageMarkdown: async (url, limit) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh }), limit),
|
|
37770
38953
|
call: options.call,
|
|
37771
38954
|
bing: localBing,
|
|
37772
38955
|
bingConnect: LOCAL_BING_CONNECT,
|
|
@@ -37778,6 +38961,35 @@ function nodeCommandDeps(options) {
|
|
|
37778
38961
|
...cloudLinked() ? { week: cloudWeek } : {}
|
|
37779
38962
|
};
|
|
37780
38963
|
}
|
|
38964
|
+
function localResearchStore(refresh = false) {
|
|
38965
|
+
const at = (key) => ({
|
|
38966
|
+
provider: "research",
|
|
38967
|
+
credentialScope: "-",
|
|
38968
|
+
operation: "research",
|
|
38969
|
+
request: key
|
|
38970
|
+
});
|
|
38971
|
+
return {
|
|
38972
|
+
get: async (key) => {
|
|
38973
|
+
if (refresh) return null;
|
|
38974
|
+
try {
|
|
38975
|
+
return readProviderCache(at(key), z.string())?.data ?? null;
|
|
38976
|
+
} catch {
|
|
38977
|
+
return null;
|
|
38978
|
+
}
|
|
38979
|
+
},
|
|
38980
|
+
put: async (key, value, ttlSeconds) => {
|
|
38981
|
+
try {
|
|
38982
|
+
writeProviderCache(at(key), {
|
|
38983
|
+
data: value,
|
|
38984
|
+
ttlMs: ttlSeconds * 1e3,
|
|
38985
|
+
rowCount: null,
|
|
38986
|
+
sourceCostMicros: null,
|
|
38987
|
+
taskIds: []
|
|
38988
|
+
});
|
|
38989
|
+
} catch {}
|
|
38990
|
+
}
|
|
38991
|
+
};
|
|
38992
|
+
}
|
|
37781
38993
|
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).";
|
|
37782
38994
|
let bing;
|
|
37783
38995
|
function localBing() {
|
|
@@ -37894,6 +39106,14 @@ async function runDoctor(dependencies = {}) {
|
|
|
37894
39106
|
detail: paths.configDir,
|
|
37895
39107
|
fix: existsSync(paths.configDir) ? void 0 : "Run `seo start` or `seo auth setup-client`."
|
|
37896
39108
|
});
|
|
39109
|
+
if (cloudLinked() && !serviceAccount.configured) {
|
|
39110
|
+
checks.push(await accountCheck());
|
|
39111
|
+
return {
|
|
39112
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
39113
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
39114
|
+
checks
|
|
39115
|
+
};
|
|
39116
|
+
}
|
|
37897
39117
|
checks.push({
|
|
37898
39118
|
id: "oauth-client",
|
|
37899
39119
|
label: "Google credentials",
|
|
@@ -37928,6 +39148,32 @@ async function runDoctor(dependencies = {}) {
|
|
|
37928
39148
|
checks
|
|
37929
39149
|
};
|
|
37930
39150
|
}
|
|
39151
|
+
async function accountCheck() {
|
|
39152
|
+
const via = process.env.SEO_TOKEN?.trim() ? "SEO_TOKEN" : "`seo login`";
|
|
39153
|
+
try {
|
|
39154
|
+
const emails = await cloudGoogleEmails();
|
|
39155
|
+
return emails.length ? {
|
|
39156
|
+
id: "google-login",
|
|
39157
|
+
label: "Google login",
|
|
39158
|
+
status: "pass",
|
|
39159
|
+
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.`
|
|
39160
|
+
} : {
|
|
39161
|
+
id: "google-login",
|
|
39162
|
+
label: "Google login",
|
|
39163
|
+
status: "fail",
|
|
39164
|
+
detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account has no Google sign-in.`,
|
|
39165
|
+
fix: `Connect Google at ${CLOUD_URL}/account, then run \`seo doctor\` again.`
|
|
39166
|
+
};
|
|
39167
|
+
} catch (error) {
|
|
39168
|
+
return {
|
|
39169
|
+
id: "google-login",
|
|
39170
|
+
label: "Google login",
|
|
39171
|
+
status: "fail",
|
|
39172
|
+
detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
39173
|
+
fix: via === "SEO_TOKEN" ? `Make a new token at ${CLOUD_URL}/account and set SEO_TOKEN again.` : "Run `seo login` again."
|
|
39174
|
+
};
|
|
39175
|
+
}
|
|
39176
|
+
}
|
|
37931
39177
|
// packages/core/src/export/update-postmortem.ts
|
|
37932
39178
|
const HEADERS = {
|
|
37933
39179
|
summary: [
|
|
@@ -38604,6 +39850,10 @@ const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
38604
39850
|
".astro",
|
|
38605
39851
|
".next"
|
|
38606
39852
|
]);
|
|
39853
|
+
const CODE_TAGS = /(<(script|style|svg)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi;
|
|
39854
|
+
function withoutCode(html) {
|
|
39855
|
+
return html.replace(CODE_TAGS, (_whole, open, _name, body, close) => `${open}${body.replace(/[^\n]/g, "")}${close}`);
|
|
39856
|
+
}
|
|
38607
39857
|
function tooMany() {
|
|
38608
39858
|
return new SeoError("INVALID_INPUT", `More than 100 files match. Point seo lint at fewer files or a smaller folder.`);
|
|
38609
39859
|
}
|
|
@@ -38656,16 +39906,27 @@ async function readLintFiles(inputs, cwd = process.cwd()) {
|
|
|
38656
39906
|
continue;
|
|
38657
39907
|
}
|
|
38658
39908
|
const text = await readFile(path, "utf8");
|
|
38659
|
-
if (text.length
|
|
39909
|
+
if (text.length <= 2e5) {
|
|
39910
|
+
sources.push({
|
|
39911
|
+
path: label,
|
|
39912
|
+
text
|
|
39913
|
+
});
|
|
39914
|
+
continue;
|
|
39915
|
+
}
|
|
39916
|
+
const size = `It has ${text.length.toLocaleString("en-US")} characters`;
|
|
39917
|
+
const html = [".html", ".htm"].includes(extname(path).toLowerCase());
|
|
39918
|
+
const readable = html ? withoutCode(text) : text;
|
|
39919
|
+
if (readable.length > 2e5) {
|
|
38660
39920
|
sources.push({
|
|
38661
39921
|
path: label,
|
|
38662
|
-
skipped:
|
|
39922
|
+
skipped: `${size}${html ? `, ${readable.length.toLocaleString("en-US")} without scripts, styles and SVG` : ""}; seo lint reads up to ${LINT_MAX_CHARS.toLocaleString("en-US")}.`
|
|
38663
39923
|
});
|
|
38664
39924
|
continue;
|
|
38665
39925
|
}
|
|
38666
39926
|
sources.push({
|
|
38667
39927
|
path: label,
|
|
38668
|
-
text
|
|
39928
|
+
text: readable,
|
|
39929
|
+
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.`
|
|
38669
39930
|
});
|
|
38670
39931
|
}
|
|
38671
39932
|
return sources;
|
|
@@ -40224,6 +41485,6 @@ function isoDate(daysAgo = 0) {
|
|
|
40224
41485
|
date.setUTCDate(date.getUTCDate() - daysAgo);
|
|
40225
41486
|
return date.toISOString().slice(0, 10);
|
|
40226
41487
|
}
|
|
40227
|
-
export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlCoverageCaveat, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
|
|
41488
|
+
export { AHREFS_API_KEY_ENV, AHREFS_API_KEY_SECRET, AI_REFERRAL_SOURCES, AI_REFERRAL_SOURCES_VERSION, ANALYTICS_PROVIDER_IDS, AUDIT_PAGE_RULE_IDS, AhrefsClient, BING_API_KEY_ENV, BING_API_KEY_SECRET, BROWSER_USER_AGENT, CANNIBAL_MAXIMUM_DOMINANT_SHARE, CANNIBAL_MINIMUM_PAGE_IMPRESSIONS, CANNIBAL_MINIMUM_PAGE_SHARE, CLICKY_SITEKEY_ENV, CLICKY_SITE_KEYS_SECRET, CLOUD_SIGN_IN, CLOUD_URL, CRAWL_SKIP_REASONS, CTR_DEFAULT_LIMIT, CTR_MAX_LIMIT, CTR_MAX_MIN_IMPRESSIONS, DATAFORSEO_CREDENTIAL_SECRET, DATAFORSEO_LOGIN_ENV, DATAFORSEO_PASSWORD_ENV, DEFAULT_MAX_PAGES, DEFAULT_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, DEFAULT_UNREACHED_PAGE_LIMIT, DataForSeoClient, DecisionModelError, DecisionRunner, GSC_FINAL_DATA_LAG_DAYS, GSC_TIME_ZONE, GoogleApiError, INCIDENTS_URL, JsonFileStorageAdapter, LINK_ROLES, LINT_MAX_FILES, LOCAL_BING_CONNECT, LOCAL_SIGN_IN, LOG_MAX_AGE_MS, LOG_MAX_FILE_BYTES, LOG_MAX_TOTAL_BYTES, LocalCredentialsProvider, MANAGED_PROVIDER_SECRET_NAMES, MAX_CRAWL_CONCURRENCY, MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES, MAX_CRAWL_TIMEOUT_MS, MAX_MAX_PAGES, MAX_SEARCH_CONSOLE_EXPORT_FILES, MAX_SEARCH_CONSOLE_EXPORT_ROW_LIMIT, PAGE_TYPES, PROVIDER_ERROR_CODES, PROVIDER_SECRET_NAMES, ProviderError, ProviderExtensionRegistry, RESEARCH_IMPORT_COLUMN_KEYS, ResponseSizeLimitError, SEARCH_INTENTS, SEO_CRAWLER_IDENTITY, SEO_CRAWLER_TOKEN, SEO_CRAWLER_USER_AGENT, SEO_PROVIDER_API_VERSION, SEO_VERSION, SERP_TITLE_REFERENCE_WIDTH_PX, SERP_TITLE_WIDTH_PROFILE, SITEMAP_FILE_URL_LIMIT, SITE_CHECK_MAX_ISSUES, SITE_CHECK_MAX_PAGES, SITE_FACTS_DEFAULT_CONCURRENCY, SITE_FACTS_MAX_CONCURRENCY, SITE_FACTS_MAX_SITES, SITE_FACTS_TIMEOUT_MS, STEP_CHANGE_RULES, SeoError, ServiceAccountAccessTokenClient, SessionLedger, TELEMETRY_AGENTS, TELEMETRY_ENDPOINT, TELEMETRY_ERROR_CATEGORIES, TELEMETRY_EVENTS, TELEMETRY_FAILURE_CONTEXTS, TELEMETRY_FAILURE_REASONS, TELEMETRY_NOTICE, TELEMETRY_OPERATIONS, TELEMETRY_REPORTS, TELEMETRY_SCHEMA_VERSION, TELEMETRY_TIMEOUT_MS, TYPESAFE_API_KEY_ENV, agentActionsView, aiReferralSourceForValue, aiReferralsReport, analyticsConnectionLabel, analyticsConnectionProviderId, analyticsConnectionSchema, analyticsProviderDetails, analyticsProviderSchema, analyzeAiReferralRows, analyzeCannibalImportRows, analyzeCannibalRows, analyzeCtrUnderperformersFromRows, analyzeDecay, analyzeIndexCoverageSignals, analyzePageOpportunitiesFromRows, analyzeQueryClustersFromRows, analyzeQuickWinsFromRows, analyzeSecondPageRows, analyzeSegmentRows, analyzeStrikingDistanceRows, analyzeTrafficAnomalyRows, assertCrawlConfigLimits, assertUrlMatchesGscProperty, auditCrawlPages, auditCrawlRequests, auditPage, authStatus, bearerFetch, boundedSitemapInventory, buildAgentFindings, buildQueryClusterReportFromRows, buildTelemetryPayload, builtPagePath, cacheDb, cannibalDataStatus, cannibalImportReport, cannibalReport, cannibalReportWith, changeWindow, checkBuiltSite, checkDatabaseReadiness, checkSites, classifyTelemetryFailure, clearCache, cloudAccountFor, cloudBing, cloudCredentials, cloudGoogleEmails, cloudGoogleToken, cloudKeys, cloudLinked, cloudLogout, cloudWeek, clusterQueryRows, combineRobotsValues, compareSegmentRows, configSchema, contentCoverageRecommendation, contentCoverageType, contentOptimizationFromPageOpportunities, contentOptimizationReport, countLabel, crawlConfigHash, crawlConfigSchema, crawlCoverageCaveat, crawlDefinitionId, crawlFixToQueueItem, crawlImplementationQueueWorkflow, crawlIssueGroupSchema, crawlIssueSchema, crawlOne, crawlPageSnapshotSchema, crawlReportSchema, crawlReportSummarySchema, crawlRequestObservationSchema, crawlRunId, crawlSite, crawlSkipImpact, crawlSkipReasonCountsFromRecord, crawlStatusOnly, crawlTopFixSchema, crawlerJsonSchemas, crawlerRuleCategorySchema, crawlerRuleIdSchema, crawlerRuleInfoSchema, crawlerRuleRecommendationSchema, crawlerRuleSeveritySchema, crawlerSchemas, createAuthorizedClient, createCrawlReport, createCtrBenchmarkContext, createGoogleAccessTokenClient, createLogger, createPageRenderer, ctrUnderperformersReport, ctrUnderperformersReportWith, decayClusterDrafts, decayComparisonRange, decayingReport, decayingReportWith, deleteAhrefsApiKey, deleteBingApiKey, deleteClickySiteKey, deleteDataForSeoCredentials, deleteManagedProviderSecrets, deleteProviderExtensionAccount, deleteProviderExtensionCredentials, deleteProviderSecret, deleteTokens, deriveBrandTerms, describeInstalledProviderExtension, detectAgentName, detectBrandTerms, detectTelemetryAgent, diagnoseCsvFiles, diagnoseProperty, diagnosePropertyWorkflow, effectiveRobotsDirectives, effectiveSnippetControl, ensurePrivateDatabaseFiles, ensureSeoCliDirs, estimateSerpTitleWidth, expectedCtrForPosition, explainRule, fetchCruxFieldData, fetchFailureKind, fetchPage, fetchPageStatus, fetchSearchStatusIncidents, fetchSitemapUrls, fileSize, finalGscDateRange, findOverlappingSearchUpdates, finishCloudLogin, formatRelativeExpiry, ga4MatchReason, ga4PropertyIdFromName, ga4ReportQualityWarnings, ga4RequestCanUseCache, ga4RowsToObjects, getAuthModeStatus, getCacheStats, getClientConfig, getDb, getPrivacySnapshot, getSeoCliPaths, getServiceAccountConfig, getServiceAccountStatus, getTelemetryStatus, getTokenStorageStatus, googleJson, groupCrawlIssues, hasMetaRobotsDirective, hasRule, hasXRobotsDirective, hashKey, indexCoverageSignals, inferTemplateMovement, initializeTelemetry, inspectProviderPackage, inspectSearchConsoleUrl, inspectUrl, installProviderPackage, installedProviderPackageSchema, installedProviderPackagesSchema, internalLinksReport, internalLinksReportWith, interpretUpdateCorrelation, isBrandQuery, isNewerVersion, isSkippableReportError, isTelemetryCi, isTelemetryOperation, isTelemetryReport, isoDate, isoWeek, latestFinalGscDate, linkRecover, linkRecoverRecommendation, lintPaths, listAnalyticsAccounts, listAnalyticsDataStreams, listGa4AccountSummaries, listGa4DataStreams, listGoogleAccounts, listInstalledProviderExtensions, listRules, listSearchConsoleSites, listSearchUpdates, listSites, loadInstalledProviderExtensions, loadProviderExtensions, loadProviderPackage, loadSearchConsoleExport, localAccounts, localBing, localCredentialsProvider, localError, localGoogleFetch, localInternalLinksDependencies, localRefreshClicky, localResearchKeys, localResearchStore, loginWithLoopback, makeRunId, matchGa4WebStreams, measureCoverage, metaRobotsDirectives, migrateLegacyMacConfig, missingOAuthClientMessage, missingSharedClientLoginMessage, nodeCommandDeps, normalizeBrandText, normalizeClickySiteId, normalizeClickySiteKey, normalizeCrawlConfig, normalizeCrawlSkipReasonCounts, normalizeForCoverage, normalizeHttpUrl, normalizeJavaScriptRenderingMode, normalizeLoadedCrawlReport, normalizeProviderExtensionAccount, normalizedResearchColumnName, notSignedIn, noteCacheWrite, openCloudLink, pageEvidenceRecommendation, pageOpportunitiesReport, parseOlderThan, pendingCloudLogin, performanceAudit, performanceReportIsCacheable, plural, providerCapabilitySchema, providerConnectionFieldSchema, providerExactVersionSchema, providerExtensionIdSchema, providerIdSchema, providerIntegritySchema, providerKindSchema, providerPackageManifestSchema, providerPackageNameSchema, providerPreferenceSchema, providerRegistrationSchema, providerRequestJson, providerRequestText, pruneLogs, publicHttpFetch, queryClusterReport, queryClusterTokens, queryContentCoverageFromPage, queryContentFieldsFromPage, queryFieldCoverage, queryOpportunityRecommendation, queryPageMetrics, queryPageTopQuery, queryPagesMetrics, queryPagesMetricsBatch, queryPagesTopQueries, queryPagesTopQueriesBatch, querySearchAnalytics, quickWinsCsvRows, quickWinsReport, quickWinsReportWith, readAhrefsApiKey, readAllTokens, readBingApiKey, readBoundedResponseText, readClickySiteKey, readClickySiteKeys, readConfig, readDataForSeoCredentials, readInstalledProviderPackages, readJson, readJsonOrUndefined, readLintFiles, readOauthClient, readProviderExtensionAccount, readProviderExtensionCredentials, readProviderSecret, readTokens, recommendationForRule, reconcileExportPagesWithCrawl, redirectTrace, refreshAuthToken, refreshPrioritiesCsvFiles, refreshPrioritiesWorkflow, registrableDomain, removeInstalledProviderPackage, renderCrawlCsv, renderCrawlHtml, renderCrawlJunit, renderCrawlMarkdownTickets, renderCrawlPagesCsv, renderCrawlPretty, renderCsv, renderPresentationTablesMarkdown, renderWorkflowMarkdown, reportNarrative, requestHeaders, requirePageVerification, researchImportColumns, resetSeoData, resolveLocalSite, resolveSegmentRanges, resolveSeoCliPaths, reviewObservations, roundedPosition, runAnalyticsProviderLandingPages, runAnalyticsReport, runDoctor, runGa4Report, runProviderExtensionAction, runtimeForProvider, safeProviderAdapterError, saveInstalledProviderPackage, searchAnalyticsRows, searchUpdatesFromIncidents, secondPage, secondPageWith, segmentComparisonRange, segmentImpact, segmentRangeDays, seoErrorEnvelope, sessionSourceInListFilter, setActiveGoogleAccount, setTelemetryEnabled, setTokenStorageMode, shiftIsoDate, shouldExcludeBrandQuery, siteStartUrl, snapshotFetchedPage, startCloudLogin, strikingDistance, strikingDistanceWith, summarizeCrawlReport, pageTechnicalSignals as technicalPageSignals, telemetryErrorCategory, toSeoError, tokenSchema, tokenStoreSchema, topFixes, trackTelemetryCommandFailed, trackTelemetryReportComplete, trackTelemetryReportFailed, trackTelemetryReportStart, trackTelemetrySetupComplete, trafficAnomaly, trafficAnomalyWith, typesafeDecisionModel, unavailableSegmentImpactReport, unicodeTokens, uninstallProviderExtension, updateCorrelation, updateNotice, updatePostmortemCsvFiles, updatePostmortemWorkflow, urlInspectionQuota, validateResearchImportColumnSources, verifyAnalyticsProvider, verifyProviderExtension, verifyQueryContent, verifyQueryContentWith, withAgentReportContract, withRankingHistory, workflowPresentation, writeAhrefsApiKey, writeBingApiKey, writeClickySiteKey, writeConfig, writeDataForSeoCredentials, writeInstalledProviderPackages, writeOauthClient, writeProviderExtensionAccount, writeProviderExtensionCredentials, writeProviderSecret, writeToCache, writeTokens, xRobotsDirectives };
|
|
40228
41489
|
|
|
40229
41490
|
//# sourceMappingURL=index.js.map
|