seo 0.17.1 → 0.17.2
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 +87 -87
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BjB7Lfd6.js → dist-CW8OlPkK.js} +107 -106
- package/dist/dist-CW8OlPkK.js.map +1 -0
- package/dist/{dist-B9mN7y6S.js → dist-Ck71nZiP.js} +1 -1
- package/dist/{esm-G8B5TPzB.js → esm-BR8_7rhM.js} +2 -2
- package/dist/{esm-G8B5TPzB.js.map → esm-BR8_7rhM.js.map} +1 -1
- package/dist/index.d.ts +451 -233
- package/dist/index.js +532 -112
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +532 -112
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/skills/seo/SKILL.md +1 -1
- package/dist/dist-BjB7Lfd6.js.map +0 -1
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-BR8_7rhM.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
@@ -12571,13 +12571,20 @@ const RULE_DEFINITIONS = [
|
|
|
12571
12571
|
title: "Large HTML response",
|
|
12572
12572
|
category: "performance",
|
|
12573
12573
|
defaultSeverity: "low",
|
|
12574
|
-
whyItMatters: "
|
|
12575
|
-
howToFix: "
|
|
12576
|
-
impactIfIgnored: "
|
|
12577
|
-
howToVerify: "Re-run the crawl and confirm sizeBytes is
|
|
12574
|
+
whyItMatters: "Googlebot reads only the first 2MB of uncompressed HTML for Google Search and indexes the page as if the file ended there. When the h1 or main element, or most of the visible text, sits past that cut, Google may index the page without its main content. Large HTML is also slow to download and parse.",
|
|
12575
|
+
howToFix: "Move the main content earlier and trim what comes before it: large inline CSS, inline SVG, JSON data and duplicated markup. Load non-critical styles and scripts as separate files.",
|
|
12576
|
+
impactIfIgnored: "Content past the 2MB cut is not indexed, and every page of the template carries the same weight on slow connections.",
|
|
12577
|
+
howToVerify: "Re-run the crawl and confirm sizeBytes is under 2,097,152, or that mainContentStartBytes is well before it and few visible words sit after it. The word split is a heuristic from the raw HTML.",
|
|
12578
12578
|
agentHints: {
|
|
12579
|
-
evidenceFields: [
|
|
12580
|
-
|
|
12579
|
+
evidenceFields: [
|
|
12580
|
+
"page.sizeBytes",
|
|
12581
|
+
"issue.evidence.thresholdBytes",
|
|
12582
|
+
"issue.evidence.mainContentStartBytes",
|
|
12583
|
+
"issue.evidence.bytesAfterLimit",
|
|
12584
|
+
"issue.evidence.visibleWordsAfterLimit",
|
|
12585
|
+
"issue.evidence.indexingRisk"
|
|
12586
|
+
],
|
|
12587
|
+
suggestedCommands: ["seo crawl <url> --mode page --json"]
|
|
12581
12588
|
}
|
|
12582
12589
|
},
|
|
12583
12590
|
{
|
|
@@ -13212,17 +13219,17 @@ function isAgentFindings(value) {
|
|
|
13212
13219
|
const counts = record(candidate?.counts);
|
|
13213
13220
|
return candidate?.schemaVersion === 1 && candidate.scope === "returned-report" && typeof counts?.total === "number" && typeof counts.returned === "number" && Array.isArray(candidate.items) && candidate.items.every((value) => {
|
|
13214
13221
|
const item = record(value);
|
|
13215
|
-
if (!text$
|
|
13216
|
-
if (item.type === "fix") return Boolean(text$
|
|
13222
|
+
if (!text$2(item?.id) || item?.status !== "open" || !text$2(item.title) || !text$2(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
|
|
13223
|
+
if (item.type === "fix") return Boolean(text$2(record(item.fix)?.instruction));
|
|
13217
13224
|
if (item.type === "review") {
|
|
13218
13225
|
const review = record(item.review);
|
|
13219
13226
|
if (!review) return false;
|
|
13220
|
-
return Boolean(text$
|
|
13227
|
+
return Boolean(text$2(review.question) && text$2(review.changeOnlyIf) && text$2(review.ifConfirmed) && text$2(review.ifNotNeeded) && Array.isArray(review.doNot));
|
|
13221
13228
|
}
|
|
13222
13229
|
return false;
|
|
13223
13230
|
});
|
|
13224
13231
|
}
|
|
13225
|
-
function text$
|
|
13232
|
+
function text$2(value) {
|
|
13226
13233
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
13227
13234
|
}
|
|
13228
13235
|
function stableId(value) {
|
|
@@ -13245,7 +13252,7 @@ function findingId(item, path, action) {
|
|
|
13245
13252
|
"actionId",
|
|
13246
13253
|
"id"
|
|
13247
13254
|
]) {
|
|
13248
|
-
const id = text$
|
|
13255
|
+
const id = text$2(item[key]);
|
|
13249
13256
|
if (id) return id;
|
|
13250
13257
|
}
|
|
13251
13258
|
return `action-${stableId(`${path}|${action}`)}`;
|
|
@@ -13255,9 +13262,9 @@ function findingType(path, item) {
|
|
|
13255
13262
|
}
|
|
13256
13263
|
function findingVerification(item, fallback) {
|
|
13257
13264
|
const supplied = record(item.verification);
|
|
13258
|
-
const expected = text$
|
|
13265
|
+
const expected = text$2(supplied?.expected ?? item.howToVerify ?? fallback);
|
|
13259
13266
|
if (!expected) return void 0;
|
|
13260
|
-
const command = text$
|
|
13267
|
+
const command = text$2(supplied?.command);
|
|
13261
13268
|
return {
|
|
13262
13269
|
...command ? { command } : {},
|
|
13263
13270
|
expected
|
|
@@ -13265,14 +13272,14 @@ function findingVerification(item, fallback) {
|
|
|
13265
13272
|
}
|
|
13266
13273
|
function stringArray(value) {
|
|
13267
13274
|
if (!Array.isArray(value)) return [];
|
|
13268
|
-
return value.flatMap((item) => text$
|
|
13275
|
+
return value.flatMap((item) => text$2(item) ? [text$2(item)] : []);
|
|
13269
13276
|
}
|
|
13270
13277
|
function affectedUrls(item, affectedCount) {
|
|
13271
13278
|
const items = stringArray(item.sampleUrls);
|
|
13272
13279
|
const details = record(item.affectedUrlsReport);
|
|
13273
13280
|
const params = record(details?.params);
|
|
13274
|
-
const reportId = details?.id === "affected-urls" ? text$
|
|
13275
|
-
const ruleId = details?.id === "affected-urls" ? text$
|
|
13281
|
+
const reportId = details?.id === "affected-urls" ? text$2(params?.reportId) : void 0;
|
|
13282
|
+
const ruleId = details?.id === "affected-urls" ? text$2(params?.ruleId) : void 0;
|
|
13276
13283
|
if (!items.length && !reportId) return void 0;
|
|
13277
13284
|
const total = affectedCount ?? items.length;
|
|
13278
13285
|
return {
|
|
@@ -13292,13 +13299,13 @@ function affectedUrls(item, affectedCount) {
|
|
|
13292
13299
|
function reviewGuidance(item, title, action) {
|
|
13293
13300
|
const supplied = record(item.review);
|
|
13294
13301
|
const evidence = record(item.evidence);
|
|
13295
|
-
const ruleId = text$
|
|
13302
|
+
const ruleId = text$2(item.ruleId ?? evidence?.ruleId);
|
|
13296
13303
|
const defined = (ruleId ? explainRule(ruleId) : void 0)?.review;
|
|
13297
13304
|
return {
|
|
13298
|
-
question: text$
|
|
13299
|
-
changeOnlyIf: text$
|
|
13300
|
-
ifConfirmed: text$
|
|
13301
|
-
ifNotNeeded: text$
|
|
13305
|
+
question: text$2(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
|
|
13306
|
+
changeOnlyIf: text$2(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
|
|
13307
|
+
ifConfirmed: text$2(supplied?.ifConfirmed) ?? action,
|
|
13308
|
+
ifNotNeeded: text$2(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
|
|
13302
13309
|
doNot: stringArray(supplied?.doNot).length ? stringArray(supplied?.doNot) : [...defined?.doNot ?? []]
|
|
13303
13310
|
};
|
|
13304
13311
|
}
|
|
@@ -13317,7 +13324,7 @@ function findingEvidence(item) {
|
|
|
13317
13324
|
}
|
|
13318
13325
|
function normalizeFinding(value, path, index, options) {
|
|
13319
13326
|
if (typeof value === "string") {
|
|
13320
|
-
const action = text$
|
|
13327
|
+
const action = text$2(value);
|
|
13321
13328
|
if (!action) return void 0;
|
|
13322
13329
|
const common = {
|
|
13323
13330
|
id: `action-${stableId(`${options.reportId ?? ""}|${path}|${action}`)}`,
|
|
@@ -13349,8 +13356,8 @@ function normalizeFinding(value, path, index, options) {
|
|
|
13349
13356
|
}
|
|
13350
13357
|
const item = record(value);
|
|
13351
13358
|
if (!item) return void 0;
|
|
13352
|
-
const action = text$
|
|
13353
|
-
const title = text$
|
|
13359
|
+
const action = text$2(item.action) ?? text$2(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text$2(item.recommendation)) ?? text$2(item.nextStep);
|
|
13360
|
+
const title = text$2(item.title) ?? text$2(item.headline) ?? text$2(item.name) ?? action;
|
|
13354
13361
|
if (!action || !title) return void 0;
|
|
13355
13362
|
const confidence = [
|
|
13356
13363
|
"high",
|
|
@@ -13368,7 +13375,7 @@ function normalizeFinding(value, path, index, options) {
|
|
|
13368
13375
|
status: "open",
|
|
13369
13376
|
title,
|
|
13370
13377
|
...confidence ? { confidence } : {},
|
|
13371
|
-
...text$
|
|
13378
|
+
...text$2(item.severity) ? { severity: text$2(item.severity) } : {},
|
|
13372
13379
|
...typeof affectedCount === "number" && Number.isFinite(affectedCount) ? { affectedCount } : {},
|
|
13373
13380
|
...urls ? { affectedUrls: urls } : {},
|
|
13374
13381
|
sourcePath: `${path}[${index}]`,
|
|
@@ -13430,7 +13437,7 @@ function inventory(value, path, reportId) {
|
|
|
13430
13437
|
const totalItems = typeof (source.totalPages ?? source.totalItems) === "number" ? Number(source.totalPages ?? source.totalItems) : source.rows.length;
|
|
13431
13438
|
const items = source.rows.map((value, index) => {
|
|
13432
13439
|
const row = record(value) ?? { value };
|
|
13433
|
-
const identity = text$
|
|
13440
|
+
const identity = text$2(row.id) ?? text$2(row.url) ?? text$2(row.path) ?? `${path}[${index}]`;
|
|
13434
13441
|
return {
|
|
13435
13442
|
...row,
|
|
13436
13443
|
id: `inventory-item-${stableId(`${reportId ?? ""}|${path}|${identity}`)}`,
|
|
@@ -13449,7 +13456,7 @@ function inventory(value, path, reportId) {
|
|
|
13449
13456
|
returnedItems: items.length,
|
|
13450
13457
|
complete: source.capped === false && items.length >= totalItems,
|
|
13451
13458
|
...source.criteria !== void 0 ? { criteria: source.criteria } : {},
|
|
13452
|
-
...text$
|
|
13459
|
+
...text$2(source.note) ? { note: text$2(source.note) } : {},
|
|
13453
13460
|
...pageCount !== void 0 ? { pagination: {
|
|
13454
13461
|
page,
|
|
13455
13462
|
pageCount,
|
|
@@ -14915,7 +14922,7 @@ async function readableSites(google, signIn) {
|
|
|
14915
14922
|
permissionLevel: entry.permissionLevel ?? ""
|
|
14916
14923
|
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
14917
14924
|
}
|
|
14918
|
-
const hostOf$
|
|
14925
|
+
const hostOf$4 = (value) => {
|
|
14919
14926
|
try {
|
|
14920
14927
|
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
14921
14928
|
} catch {
|
|
@@ -14933,14 +14940,14 @@ async function runSites(input) {
|
|
|
14933
14940
|
};
|
|
14934
14941
|
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
14935
14942
|
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
14936
|
-
const bingByHost = new Map(bingSites.map((site) => [hostOf$
|
|
14943
|
+
const bingByHost = new Map(bingSites.map((site) => [hostOf$4(site.url), site]));
|
|
14937
14944
|
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
14938
14945
|
...property,
|
|
14939
14946
|
...named ? { account: account.email ?? "" } : {},
|
|
14940
|
-
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$
|
|
14947
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$4(property.siteUrl))?.url ?? "" } : {}
|
|
14941
14948
|
})));
|
|
14942
|
-
const googleHosts = new Set(properties.map((property) => hostOf$
|
|
14943
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$
|
|
14949
|
+
const googleHosts = new Set(properties.map((property) => hostOf$4(property.siteUrl)));
|
|
14950
|
+
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$4(site.url)));
|
|
14944
14951
|
const failed = google.filter((account) => account.properties instanceof Error);
|
|
14945
14952
|
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
14946
14953
|
property: property.property.replace("properties/", ""),
|
|
@@ -22849,6 +22856,98 @@ function contentOptimizationFromPageOpportunities(sourceReport) {
|
|
|
22849
22856
|
async function contentOptimizationReport(input) {
|
|
22850
22857
|
return contentOptimizationFromPageOpportunities(await pageOpportunitiesReport(input));
|
|
22851
22858
|
}
|
|
22859
|
+
// packages/core/dist/analyze/crawler/html-size.js
|
|
22860
|
+
const GOOGLEBOT_HTML_LIMIT_BYTES = 2097152;
|
|
22861
|
+
const NEAR_LIMIT_SHARE = .9;
|
|
22862
|
+
const HIDDEN_BLOCKS = /<(script|style|svg|noscript|template)\b[\s\S]*?(?:<\/\1\s*>|$)|<!--[\s\S]*?(?:-->|$)/gi;
|
|
22863
|
+
function wordCount(text) {
|
|
22864
|
+
return text.match(/[^\s&;]+/g)?.length ?? 0;
|
|
22865
|
+
}
|
|
22866
|
+
/** Where a page over Googlebot's limit puts its main content. */
|
|
22867
|
+
function googlebotCutEvidence(html) {
|
|
22868
|
+
const bytes = Buffer.from(html, "utf8");
|
|
22869
|
+
if (bytes.byteLength <= 2097152) return void 0;
|
|
22870
|
+
const marker = /<(h1|main)[\s>/]/i.exec(html);
|
|
22871
|
+
const text = html.replace(HIDDEN_BLOCKS, (match) => " ".repeat(match.length)).replace(/<[^>]*>?/g, (match) => " ".repeat(match.length));
|
|
22872
|
+
const limitIndex = bytes.subarray(0, GOOGLEBOT_HTML_LIMIT_BYTES).toString("utf8").length;
|
|
22873
|
+
const startBytes = marker ? Buffer.byteLength(html.slice(0, marker.index)) : void 0;
|
|
22874
|
+
return {
|
|
22875
|
+
limitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22876
|
+
bytesAfterLimit: bytes.byteLength - GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22877
|
+
...marker && startBytes !== void 0 ? {
|
|
22878
|
+
mainContentStartBytes: startBytes,
|
|
22879
|
+
mainContentMarker: marker[1]?.toLowerCase()
|
|
22880
|
+
} : {},
|
|
22881
|
+
mainContentStart: startBytes === void 0 ? "not-found" : startBytes >= 2097152 ? "after-limit" : startBytes >= 2097152 * NEAR_LIMIT_SHARE ? "near-limit" : "before-limit",
|
|
22882
|
+
visibleWordsBeforeLimit: wordCount(text.slice(0, limitIndex)),
|
|
22883
|
+
visibleWordsAfterLimit: wordCount(text.slice(limitIndex))
|
|
22884
|
+
};
|
|
22885
|
+
}
|
|
22886
|
+
const formatBytes = (value) => value.toLocaleString("en-US");
|
|
22887
|
+
function largeHtmlIssue(rule, page) {
|
|
22888
|
+
const sizeBytes = page.sizeBytes ?? 0;
|
|
22889
|
+
const cut = page.googlebotCut;
|
|
22890
|
+
const htmlSource = page.usedJs ? "rendered" : "raw";
|
|
22891
|
+
const base = {
|
|
22892
|
+
...rule,
|
|
22893
|
+
detail: `${Math.round(sizeBytes / 1024)} KB`,
|
|
22894
|
+
evidence: {
|
|
22895
|
+
sizeBytes,
|
|
22896
|
+
thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22897
|
+
htmlSource
|
|
22898
|
+
}
|
|
22899
|
+
};
|
|
22900
|
+
if (!cut) return base;
|
|
22901
|
+
const words = cut.visibleWordsBeforeLimit + cut.visibleWordsAfterLimit;
|
|
22902
|
+
const wordsAfterShare = words ? Math.round(cut.visibleWordsAfterLimit / words * 100) / 100 : void 0;
|
|
22903
|
+
const indexingRisk = htmlSource === "rendered" ? "unknown" : cut.mainContentStart === "after-limit" || cut.mainContentStart === "near-limit" || (wordsAfterShare ?? 0) > .5 ? "main-content-past-limit" : "none-found";
|
|
22904
|
+
const start = cut.mainContentStartBytes === void 0 ? "no <h1> or <main> found" : `main content (<${cut.mainContentMarker}>) starts at byte ${formatBytes(cut.mainContentStartBytes)}, ${cut.mainContentStart === "after-limit" ? "after" : cut.mainContentStart === "near-limit" ? "just before" : "before"} the cut`;
|
|
22905
|
+
const wordsNote = wordsAfterShare === void 0 ? "" : `, with about ${Math.round(wordsAfterShare * 100)}% of the visible words`;
|
|
22906
|
+
return {
|
|
22907
|
+
...base,
|
|
22908
|
+
severity: indexingRisk === "main-content-past-limit" ? "high" : base.severity,
|
|
22909
|
+
detail: `${Math.round(sizeBytes / 1024)} KB; ${start}; ${formatBytes(cut.bytesAfterLimit)} bytes sit past Googlebot's ${formatBytes(cut.limitBytes)}-byte cut${wordsNote}.${indexingRisk === "main-content-past-limit" ? " Googlebot stops reading at the cut and indexes only what came before it (the word split is a heuristic)." : ""}`,
|
|
22910
|
+
evidence: {
|
|
22911
|
+
...base.evidence,
|
|
22912
|
+
googlebotLimitBytes: cut.limitBytes,
|
|
22913
|
+
bytesAfterLimit: cut.bytesAfterLimit,
|
|
22914
|
+
mainContentStartBytes: cut.mainContentStartBytes ?? null,
|
|
22915
|
+
mainContentMarker: cut.mainContentMarker ?? null,
|
|
22916
|
+
mainContentStart: cut.mainContentStart,
|
|
22917
|
+
visibleWordsBeforeLimit: cut.visibleWordsBeforeLimit,
|
|
22918
|
+
visibleWordsAfterLimit: cut.visibleWordsAfterLimit,
|
|
22919
|
+
visibleWordsAfterLimitShare: wordsAfterShare ?? null,
|
|
22920
|
+
indexingRisk
|
|
22921
|
+
}
|
|
22922
|
+
};
|
|
22923
|
+
}
|
|
22924
|
+
const RESPONSE_CAP_ERROR = /^Page response exceeds the (\d+)-byte response limit/;
|
|
22925
|
+
function responseCapIssue(request) {
|
|
22926
|
+
if (request.outcome !== "failure") return void 0;
|
|
22927
|
+
const capBytes = Number(RESPONSE_CAP_ERROR.exec(request.error)?.[1]);
|
|
22928
|
+
if (!capBytes) return void 0;
|
|
22929
|
+
const rule = explainRule("large_html");
|
|
22930
|
+
if (!rule) throw new Error("Missing rule guidance for large_html");
|
|
22931
|
+
return {
|
|
22932
|
+
ruleId: rule.id,
|
|
22933
|
+
title: rule.title,
|
|
22934
|
+
category: rule.category,
|
|
22935
|
+
severity: rule.defaultSeverity,
|
|
22936
|
+
url: request.requestedUrl,
|
|
22937
|
+
detail: `Over ${Math.round(capBytes / 1024)} KB; the crawler stopped reading at its ${formatBytes(capBytes)}-byte cap, so where the main content starts is unknown.`,
|
|
22938
|
+
evidence: {
|
|
22939
|
+
dataStatus: "partial",
|
|
22940
|
+
sizeBytes: null,
|
|
22941
|
+
sizeBytesAtLeast: capBytes,
|
|
22942
|
+
thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22943
|
+
googlebotLimitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22944
|
+
bytesAfterLimitAtLeast: capBytes - GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
22945
|
+
mainContentStartBytes: null,
|
|
22946
|
+
mainContentStart: "unknown",
|
|
22947
|
+
indexingRisk: "unknown"
|
|
22948
|
+
}
|
|
22949
|
+
};
|
|
22950
|
+
}
|
|
22852
22951
|
// packages/core/dist/analyze/crawler/page-eligibility.js
|
|
22853
22952
|
function comparableUrl(value) {
|
|
22854
22953
|
if (!value) return void 0;
|
|
@@ -22887,6 +22986,21 @@ function isCdnUtilityUrl(value) {
|
|
|
22887
22986
|
return false;
|
|
22888
22987
|
}
|
|
22889
22988
|
}
|
|
22989
|
+
function globMatch(pattern, value) {
|
|
22990
|
+
if (!pattern.includes("*")) return value.includes(pattern);
|
|
22991
|
+
const parts = pattern.split("*");
|
|
22992
|
+
let position = 0;
|
|
22993
|
+
for (const part of parts) {
|
|
22994
|
+
if (!part) continue;
|
|
22995
|
+
const found = value.slice(position).indexOf(part);
|
|
22996
|
+
if (found === -1) return false;
|
|
22997
|
+
position += found + part.length;
|
|
22998
|
+
}
|
|
22999
|
+
return true;
|
|
23000
|
+
}
|
|
23001
|
+
function passesFilters(url, include, exclude) {
|
|
23002
|
+
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
23003
|
+
}
|
|
22890
23004
|
// packages/core/dist/analyze/crawler/audit.js
|
|
22891
23005
|
function sameUrl(a, b) {
|
|
22892
23006
|
if (!a || !b) return false;
|
|
@@ -22929,7 +23043,7 @@ function urlKey(value) {
|
|
|
22929
23043
|
}
|
|
22930
23044
|
}
|
|
22931
23045
|
const SLOW_RESPONSE_MS = 2e3;
|
|
22932
|
-
const LARGE_HTML_BYTES =
|
|
23046
|
+
const LARGE_HTML_BYTES = GOOGLEBOT_HTML_LIMIT_BYTES;
|
|
22933
23047
|
const COMPRESSION_MIN_BYTES = 4096;
|
|
22934
23048
|
const DEEP_PAGE_DEPTH = 4;
|
|
22935
23049
|
const WEAK_VALUABLE_INLINKS = 1;
|
|
@@ -23101,6 +23215,11 @@ function auditCrawlRequests(requests) {
|
|
|
23101
23215
|
for (const request of requests) {
|
|
23102
23216
|
if (request.outcome === "failure") {
|
|
23103
23217
|
if (request.failureKind === "aborted") continue;
|
|
23218
|
+
const overCap = responseCapIssue(request);
|
|
23219
|
+
if (overCap) {
|
|
23220
|
+
issues.push(overCap);
|
|
23221
|
+
continue;
|
|
23222
|
+
}
|
|
23104
23223
|
issues.push(requestIssue("connection_error", request, request.error, {
|
|
23105
23224
|
outcome: request.outcome,
|
|
23106
23225
|
failureKind: request.failureKind,
|
|
@@ -23213,10 +23332,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
23213
23332
|
outgoingInternalCount: page.outgoingInternalCount,
|
|
23214
23333
|
rendering: page.fetchDiagnostics?.rendering
|
|
23215
23334
|
}));
|
|
23216
|
-
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue$1("large_html", page,
|
|
23217
|
-
sizeBytes: page.sizeBytes,
|
|
23218
|
-
thresholdBytes: LARGE_HTML_BYTES
|
|
23219
|
-
}));
|
|
23335
|
+
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(largeHtmlIssue(issue$1("large_html", page), page));
|
|
23220
23336
|
if (isHtmlPage(page) && !page.compression && (page.sizeBytes ?? 0) > COMPRESSION_MIN_BYTES) issues.push(issue$1("no_compression", page, void 0, {
|
|
23221
23337
|
sizeBytes: page.sizeBytes,
|
|
23222
23338
|
thresholdBytes: COMPRESSION_MIN_BYTES,
|
|
@@ -23467,7 +23583,11 @@ function crawlSkipReasonCountsFromRecord(input = {}) {
|
|
|
23467
23583
|
}
|
|
23468
23584
|
// packages/core/dist/analyze/crawler/crawl-url-queue.js
|
|
23469
23585
|
function before(left, right) {
|
|
23470
|
-
|
|
23586
|
+
if (left.depth !== right.depth) return left.depth < right.depth;
|
|
23587
|
+
const leftRank = left.rank ?? 0;
|
|
23588
|
+
const rightRank = right.rank ?? 0;
|
|
23589
|
+
if (leftRank !== rightRank) return leftRank < rightRank;
|
|
23590
|
+
return left.url < right.url;
|
|
23471
23591
|
}
|
|
23472
23592
|
var CrawlUrlQueue = class {
|
|
23473
23593
|
#items = [];
|
|
@@ -23964,14 +24084,20 @@ function rateLimitRerunCommand(config, intervalMs) {
|
|
|
23964
24084
|
return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
|
|
23965
24085
|
}
|
|
23966
24086
|
/** Set when the sitemaps list more URLs than the page limit let the crawl read. */
|
|
23967
|
-
function sitemapLimitCaveatInput(config, discovery, defaulted) {
|
|
23968
|
-
if (!discovery
|
|
24087
|
+
function sitemapLimitCaveatInput(config, discovery, defaulted, sample) {
|
|
24088
|
+
if (!discovery) return void 0;
|
|
24089
|
+
const truncated = discovery.roots.some((root) => root.possiblyTruncated);
|
|
24090
|
+
if (!sample && (discovery.urlsReturned < config.maxPages || !truncated)) return;
|
|
23969
24091
|
return {
|
|
23970
24092
|
maxPages: config.maxPages,
|
|
23971
24093
|
defaulted,
|
|
23972
24094
|
ceiling: MAX_CRAWL_PAGES,
|
|
23973
24095
|
health: config.strategy === "health",
|
|
23974
|
-
url: config.url
|
|
24096
|
+
url: config.url,
|
|
24097
|
+
...sample ? { sample: {
|
|
24098
|
+
...sample,
|
|
24099
|
+
readCapped: truncated
|
|
24100
|
+
} } : {}
|
|
23975
24101
|
};
|
|
23976
24102
|
}
|
|
23977
24103
|
function crawlCaveats(input) {
|
|
@@ -23983,8 +24109,9 @@ function crawlCaveats(input) {
|
|
|
23983
24109
|
caveats.push(`${CRAWL_COVERAGE_CAVEAT_PREFIX} read ${readPages} of ${readPages + skippedUrls} pages because the origin kept rate limiting the crawl (HTTP 429) after it backed off. Rerun slower: ${rerunCommand}`);
|
|
23984
24110
|
}
|
|
23985
24111
|
if (input.sitemapLimit) {
|
|
23986
|
-
const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
|
|
23987
|
-
|
|
24112
|
+
const { maxPages, defaulted, ceiling, health, url, sample } = input.sitemapLimit;
|
|
24113
|
+
const rerun = `Raise --max-pages, up to ${ceiling.toLocaleString("en-US")}, to check more: seo crawl ${url}${health ? " --health" : ""} --max-pages ${ceiling}`;
|
|
24114
|
+
caveats.push(sample ? `Checked a sample of ${sample.checked.toLocaleString("en-US")} of the ${sample.read.toLocaleString("en-US")}${sample.readCapped ? "+" : ""} sitemap URLs read because the page limit is ${maxPages}${defaulted ? " by default" : ""}. The sample takes URLs in turn from each of ${sample.sections} sections (the first path segment), shortest paths first, so no one section fills the crawl.${sample.readCapped ? " The sitemaps list more URLs than the crawl read." : ""} ${rerun}` : `Checked only the first ${maxPages} sitemap URLs because the page limit is ${maxPages}${defaulted ? " by default" : ""}, and the sitemaps list more. ${rerun}`);
|
|
23988
24115
|
}
|
|
23989
24116
|
if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
|
|
23990
24117
|
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
@@ -24512,6 +24639,20 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
24512
24639
|
responseHeaders: z.record(z.string(), z.string()).optional(),
|
|
24513
24640
|
responseTimeMs: z.number().optional(),
|
|
24514
24641
|
sizeBytes: z.number().int().optional(),
|
|
24642
|
+
googlebotCut: z.object({
|
|
24643
|
+
limitBytes: z.number().int().positive(),
|
|
24644
|
+
bytesAfterLimit: z.number().int().nonnegative(),
|
|
24645
|
+
mainContentStartBytes: z.number().int().nonnegative().optional(),
|
|
24646
|
+
mainContentMarker: z.enum(["h1", "main"]).optional(),
|
|
24647
|
+
mainContentStart: z.enum([
|
|
24648
|
+
"before-limit",
|
|
24649
|
+
"near-limit",
|
|
24650
|
+
"after-limit",
|
|
24651
|
+
"not-found"
|
|
24652
|
+
]),
|
|
24653
|
+
visibleWordsBeforeLimit: z.number().int().nonnegative(),
|
|
24654
|
+
visibleWordsAfterLimit: z.number().int().nonnegative()
|
|
24655
|
+
}).optional(),
|
|
24515
24656
|
usedJs: z.boolean().optional(),
|
|
24516
24657
|
fetchSource: z.enum([
|
|
24517
24658
|
"cache",
|
|
@@ -25991,6 +26132,7 @@ async function snapshotFetchedPage(url, fetched) {
|
|
|
25991
26132
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
25992
26133
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
25993
26134
|
usedJs: fetched.usedJs,
|
|
26135
|
+
googlebotCut: googlebotCutEvidence(fetched.html),
|
|
25994
26136
|
fetchSource: fetched.diagnostics.source,
|
|
25995
26137
|
cacheState: fetched.diagnostics.cache,
|
|
25996
26138
|
fetchDiagnostics: fetched.diagnostics,
|
|
@@ -28328,6 +28470,74 @@ function sitemapDiscoveryStatus(roots) {
|
|
|
28328
28470
|
if (!roots.some((root) => root.dataStatus !== "unavailable")) return "unavailable";
|
|
28329
28471
|
return roots.every((root) => root.dataStatus === "complete") ? "complete" : "partial";
|
|
28330
28472
|
}
|
|
28473
|
+
/** How many sitemap URLs a crawl reads to pick its sample from. */
|
|
28474
|
+
function sitemapReadLimit(maxPages) {
|
|
28475
|
+
return Math.min(5e4, Math.max(1e4, maxPages * 10));
|
|
28476
|
+
}
|
|
28477
|
+
function sectionOf(url) {
|
|
28478
|
+
try {
|
|
28479
|
+
return new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "";
|
|
28480
|
+
} catch {
|
|
28481
|
+
return "";
|
|
28482
|
+
}
|
|
28483
|
+
}
|
|
28484
|
+
function pathDepth(url) {
|
|
28485
|
+
try {
|
|
28486
|
+
return new URL(url).pathname.split("/").filter(Boolean).length;
|
|
28487
|
+
} catch {
|
|
28488
|
+
return 0;
|
|
28489
|
+
}
|
|
28490
|
+
}
|
|
28491
|
+
/**
|
|
28492
|
+
* Picks up to `limit` URLs round-robin across sections (the first path
|
|
28493
|
+
* segment), so one large section such as a forum cannot fill a capped crawl.
|
|
28494
|
+
* Within a section, shallower paths come first, then URL order.
|
|
28495
|
+
*/
|
|
28496
|
+
function spreadSitemapSample(urls, limit) {
|
|
28497
|
+
const groups = /* @__PURE__ */ new Map();
|
|
28498
|
+
for (const url of urls) {
|
|
28499
|
+
const key = sectionOf(url);
|
|
28500
|
+
const group = groups.get(key);
|
|
28501
|
+
if (group) group.push(url);
|
|
28502
|
+
else groups.set(key, [url]);
|
|
28503
|
+
}
|
|
28504
|
+
const ordered = [...groups.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, group]) => group.map((url) => ({
|
|
28505
|
+
url,
|
|
28506
|
+
depth: pathDepth(url)
|
|
28507
|
+
})).sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).map((entry) => entry.url));
|
|
28508
|
+
const picked = [];
|
|
28509
|
+
for (let index = 0; picked.length < limit; index += 1) {
|
|
28510
|
+
let added = false;
|
|
28511
|
+
for (const group of ordered) {
|
|
28512
|
+
const url = group[index];
|
|
28513
|
+
if (url === void 0) continue;
|
|
28514
|
+
added = true;
|
|
28515
|
+
picked.push(url);
|
|
28516
|
+
if (picked.length >= limit) break;
|
|
28517
|
+
}
|
|
28518
|
+
if (!added) break;
|
|
28519
|
+
}
|
|
28520
|
+
return {
|
|
28521
|
+
urls: picked,
|
|
28522
|
+
sections: groups.size
|
|
28523
|
+
};
|
|
28524
|
+
}
|
|
28525
|
+
/**
|
|
28526
|
+
* The sitemap URLs a capped crawl checks, leaving room for the seed URLs, and
|
|
28527
|
+
* how they were picked when the sitemaps listed more.
|
|
28528
|
+
*/
|
|
28529
|
+
function sitemapCrawlSample(urls, maxPages, seedUrls, normalize) {
|
|
28530
|
+
const candidates = urls.filter((url) => !seedUrls.has(normalize(url) ?? url));
|
|
28531
|
+
const sample = spreadSitemapSample(candidates, Math.max(0, maxPages - seedUrls.size));
|
|
28532
|
+
return candidates.length > sample.urls.length ? {
|
|
28533
|
+
urls: sample.urls,
|
|
28534
|
+
caveat: {
|
|
28535
|
+
checked: sample.urls.length,
|
|
28536
|
+
read: candidates.length,
|
|
28537
|
+
sections: sample.sections
|
|
28538
|
+
}
|
|
28539
|
+
} : { urls: sample.urls };
|
|
28540
|
+
}
|
|
28331
28541
|
async function sitemapSeeds(input) {
|
|
28332
28542
|
const roots = [];
|
|
28333
28543
|
const urls = /* @__PURE__ */ new Set();
|
|
@@ -28407,21 +28617,6 @@ function isLikelyAsset(url) {
|
|
|
28407
28617
|
const path = new URL(url).pathname.toLowerCase();
|
|
28408
28618
|
return /\.(avif|bmp|css|csv|dmg|eot|gif|gz|ico|jpeg|jpg|js|json|map|mov|mp3|mp4|ogg|otf|pdf|png|svg|tar|ttf|wav|webm|webp|woff|woff2|zip)$/.test(path);
|
|
28409
28619
|
}
|
|
28410
|
-
function globMatch(pattern, value) {
|
|
28411
|
-
if (!pattern.includes("*")) return value.includes(pattern);
|
|
28412
|
-
const parts = pattern.split("*");
|
|
28413
|
-
let position = 0;
|
|
28414
|
-
for (const part of parts) {
|
|
28415
|
-
if (!part) continue;
|
|
28416
|
-
const found = value.slice(position).indexOf(part);
|
|
28417
|
-
if (found === -1) return false;
|
|
28418
|
-
position += found + part.length;
|
|
28419
|
-
}
|
|
28420
|
-
return true;
|
|
28421
|
-
}
|
|
28422
|
-
function passesFilters(url, include, exclude) {
|
|
28423
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
28424
|
-
}
|
|
28425
28620
|
async function crawlSite(input, dependencies) {
|
|
28426
28621
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
28427
28622
|
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
@@ -28476,6 +28671,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28476
28671
|
});
|
|
28477
28672
|
};
|
|
28478
28673
|
let sitemapDiscovery;
|
|
28674
|
+
let sitemapSample;
|
|
28479
28675
|
let externalLinkVerification;
|
|
28480
28676
|
let statusEventChain = Promise.resolve();
|
|
28481
28677
|
const emitStatus = (phase, event = {}) => {
|
|
@@ -28523,7 +28719,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28523
28719
|
const llmsTxt = aiSignals?.signals.llmsTxt;
|
|
28524
28720
|
const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
|
|
28525
28721
|
cancelled = isCancelled();
|
|
28526
|
-
const enqueue = (value, depth) => {
|
|
28722
|
+
const enqueue = (value, depth, rank) => {
|
|
28527
28723
|
const normalized = normalizeUrl$1(value, config.url);
|
|
28528
28724
|
if (!normalized) {
|
|
28529
28725
|
recordSkip("invalid-url");
|
|
@@ -28609,7 +28805,8 @@ async function crawlSite(input, dependencies) {
|
|
|
28609
28805
|
queuedUrls += 1;
|
|
28610
28806
|
queue.push({
|
|
28611
28807
|
url: normalized,
|
|
28612
|
-
depth
|
|
28808
|
+
depth,
|
|
28809
|
+
rank
|
|
28613
28810
|
});
|
|
28614
28811
|
emitStatus("url_queued", {
|
|
28615
28812
|
url: normalized,
|
|
@@ -28629,12 +28826,16 @@ async function crawlSite(input, dependencies) {
|
|
|
28629
28826
|
const sitemap = await sitemapSeeds({
|
|
28630
28827
|
url: config.url,
|
|
28631
28828
|
sitemapUrl: config.sitemapUrl,
|
|
28632
|
-
maxPages: config.maxPages,
|
|
28829
|
+
maxPages: sitemapReadLimit(config.maxPages),
|
|
28633
28830
|
declaredSitemapUrls: aiSignals?.signals.robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
28634
28831
|
warnings,
|
|
28635
28832
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
28636
28833
|
});
|
|
28637
|
-
|
|
28834
|
+
const sample = sitemapCrawlSample(sitemap.urls, config.maxPages, seedUrls, (url) => normalizeUrl$1(url, config.url));
|
|
28835
|
+
sitemapSample = sample.caveat;
|
|
28836
|
+
sample.urls.forEach((url, index) => {
|
|
28837
|
+
enqueue(url, 0, index + 1);
|
|
28838
|
+
});
|
|
28638
28839
|
sitemapDiscovery = sitemap.discovery;
|
|
28639
28840
|
}
|
|
28640
28841
|
checkMemoryPressure();
|
|
@@ -28795,7 +28996,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28795
28996
|
});
|
|
28796
28997
|
continue;
|
|
28797
28998
|
}
|
|
28798
|
-
const page = result.page;
|
|
28999
|
+
const page = structuredClone(result.page);
|
|
28799
29000
|
const rendering = page.fetchDiagnostics?.rendering;
|
|
28800
29001
|
if (rendering?.status === "unavailable" && config.js === "on") throw new SeoError("INVALID_INPUT", `--rendering on needs a local Chrome or Chromium browser, and none could start. ${rendering.error ?? ""} Install Chromium, or set SEO_BROWSER_EXECUTABLE_PATH to a Chromium executable, then rerun. Use --rendering auto to accept raw HTML.`.replace(/\s+/g, " ").trim());
|
|
28801
29002
|
if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
|
|
@@ -29065,7 +29266,7 @@ async function crawlSite(input, dependencies) {
|
|
|
29065
29266
|
queueSafetyInventoryCapped,
|
|
29066
29267
|
originBackpressureSkippedUrls,
|
|
29067
29268
|
rateLimit: rateLimit.caveatInput(config, pages.length),
|
|
29068
|
-
sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
|
|
29269
|
+
sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0, sitemapSample),
|
|
29069
29270
|
memoryPressureSkippedUrls,
|
|
29070
29271
|
queryVariantSkippedUrls: queryVariantSkipped.size,
|
|
29071
29272
|
renderingFallbackUrls,
|
|
@@ -40984,6 +41185,12 @@ function plainFor(word) {
|
|
|
40984
41185
|
for (const [key, value] of Object.entries(PLAIN)) if (lower.startsWith(key.slice(0, Math.max(4, key.length - 2)))) return value;
|
|
40985
41186
|
return "a plain word";
|
|
40986
41187
|
}
|
|
41188
|
+
function swapFix(word) {
|
|
41189
|
+
const plain = plainFor(word).replace(/\s*\([^)]*\)/g, "");
|
|
41190
|
+
if (plain === "a plain word") return "Use a plain word.";
|
|
41191
|
+
if (/^(?:say|name|give)\b/.test(plain)) return `Cut "${word}", or swap it for a detail the text already gives.`;
|
|
41192
|
+
return `Use ${plain.split(", ").map((swap) => `"${swap}"`).join(" or ")}.`;
|
|
41193
|
+
}
|
|
40987
41194
|
const PHRASE_RULES = [
|
|
40988
41195
|
{
|
|
40989
41196
|
id: "chatbot-residue",
|
|
@@ -41142,7 +41349,7 @@ const PHRASE_RULES = [
|
|
|
41142
41349
|
/\bstunning(?:ly)?\b|\benchanting\b/gi
|
|
41143
41350
|
],
|
|
41144
41351
|
message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
|
|
41145
|
-
fix: (match) =>
|
|
41352
|
+
fix: (match) => swapFix(match.text.replace(/^(?:rich|a) /i, ""))
|
|
41146
41353
|
},
|
|
41147
41354
|
{
|
|
41148
41355
|
id: "banned-word",
|
|
@@ -41162,7 +41369,7 @@ const PHRASE_RULES = [
|
|
|
41162
41369
|
/\bgets? you to the next fix\b/gi
|
|
41163
41370
|
],
|
|
41164
41371
|
message: (match) => `"${match.text}" is on the house list of words to cut.`,
|
|
41165
|
-
fix: (match) => /source of truth/i.test(match.text) ? "Name the actual file, field or result." : /hype|everything you need|next fix/i.test(match.text) ? "Say what the page or tool actually gives the reader." :
|
|
41372
|
+
fix: (match) => /source of truth/i.test(match.text) ? "Name the actual file, field or result." : /hype|everything you need|next fix/i.test(match.text) ? "Say what the page or tool actually gives the reader." : swapFix(match.text)
|
|
41166
41373
|
},
|
|
41167
41374
|
{
|
|
41168
41375
|
id: "buzzword",
|
|
@@ -41407,7 +41614,7 @@ const PHRASE_RULES = [
|
|
|
41407
41614
|
/(?:^|[.!?:]\s+)(?:But|And) only (?:part|half|some|partly)\.(?=\s|$)/g
|
|
41408
41615
|
],
|
|
41409
41616
|
message: "A two-word denial used for rhythm. It makes the reader wait.",
|
|
41410
|
-
fix: "Join it to the sentence before,
|
|
41617
|
+
fix: "Join it to the sentence before, so the point is stated once."
|
|
41411
41618
|
},
|
|
41412
41619
|
{
|
|
41413
41620
|
id: "strawman",
|
|
@@ -42205,10 +42412,10 @@ function stockCloser(ctx) {
|
|
|
42205
42412
|
for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
|
|
42206
42413
|
const text = sentence.text;
|
|
42207
42414
|
if (CLOSERS.some((re) => re.test(text))) {
|
|
42208
|
-
ctx.hit(rule, block, sentence.start, sentence.end, "A stock kicker that tells the reader how to feel about the last point.", "Cut it. If the
|
|
42415
|
+
ctx.hit(rule, block, sentence.start, sentence.end, "A stock kicker that tells the reader how to feel about the last point.", "Cut it. If the text already has the number or fact that makes the point, end on that.");
|
|
42209
42416
|
continue;
|
|
42210
42417
|
}
|
|
42211
|
-
if (/^(?:but |and )?(?:that|this)(?:'s| is) not (?:how|what|why|where|a|an|the)\b/i.test(text) && words$2(text).length <= 10) ctx.hit(reframe, block, sentence.start, sentence.end, "A short \"That's not ...\" line dismisses a view instead of stating one.", "
|
|
42418
|
+
if (/^(?:but |and )?(?:that|this)(?:'s| is) not (?:how|what|why|where|a|an|the)\b/i.test(text) && words$2(text).length <= 10) ctx.hit(reframe, block, sentence.start, sentence.end, "A short \"That's not ...\" line dismisses a view instead of stating one.", "Cut it, or say what is true with a fact the text already gives.");
|
|
42212
42419
|
}
|
|
42213
42420
|
}
|
|
42214
42421
|
const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|risks?|horses?|roads?|bridges?|doors?|games?|ships?|cards?";
|
|
@@ -42998,15 +43205,24 @@ async function ahrefs(key, input, missed, totals) {
|
|
|
42998
43205
|
order_by: "refdomains_target:desc"
|
|
42999
43206
|
})).pages ?? [];
|
|
43000
43207
|
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
|
|
43001
|
-
|
|
43208
|
+
const data = await get("serp-overview/serp-overview", {
|
|
43002
43209
|
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
43003
43210
|
keyword,
|
|
43004
43211
|
country: input.country,
|
|
43005
43212
|
top_positions: String(serpDepth(input))
|
|
43006
|
-
})
|
|
43007
|
-
|
|
43008
|
-
|
|
43009
|
-
|
|
43213
|
+
});
|
|
43214
|
+
let organic = 0;
|
|
43215
|
+
return (data.positions ?? []).map((row) => {
|
|
43216
|
+
const { position, type, ...rest } = row;
|
|
43217
|
+
const kinds = Array.isArray(type) ? type.map(String) : [String(type ?? "")];
|
|
43218
|
+
return {
|
|
43219
|
+
keyword,
|
|
43220
|
+
position: kinds.includes("organic") ? ++organic : null,
|
|
43221
|
+
ahrefs_position: position ?? null,
|
|
43222
|
+
type: kinds.join(",") || null,
|
|
43223
|
+
...rest
|
|
43224
|
+
};
|
|
43225
|
+
});
|
|
43010
43226
|
});
|
|
43011
43227
|
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
43012
43228
|
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
@@ -43034,7 +43250,55 @@ function flattenIntents(row) {
|
|
|
43034
43250
|
intents: names.join(" ") || null
|
|
43035
43251
|
};
|
|
43036
43252
|
}
|
|
43253
|
+
// packages/core/dist/commands/research-serp.js
|
|
43254
|
+
const GOTO = /^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/;
|
|
43255
|
+
const hostOf$3 = (url) => {
|
|
43256
|
+
try {
|
|
43257
|
+
return new URL(url).hostname;
|
|
43258
|
+
} catch {
|
|
43259
|
+
return "";
|
|
43260
|
+
}
|
|
43261
|
+
};
|
|
43262
|
+
const text$1 = (value) => typeof value === "string" && value ? value : null;
|
|
43263
|
+
function dataForSeoSerp(keyword, items) {
|
|
43264
|
+
const found = items.find((item) => item.type === "ai_overview");
|
|
43265
|
+
const overview = found ? {
|
|
43266
|
+
keyword,
|
|
43267
|
+
references: (found.references ?? []).slice(0, 20).map((ref) => {
|
|
43268
|
+
const url = String(ref.url ?? "");
|
|
43269
|
+
const goto = GOTO.test(url);
|
|
43270
|
+
return {
|
|
43271
|
+
domain: goto ? null : text$1(ref.domain) ?? (hostOf$3(url) || null),
|
|
43272
|
+
url: goto || !url ? null : url,
|
|
43273
|
+
title: text$1(ref.title)
|
|
43274
|
+
};
|
|
43275
|
+
})
|
|
43276
|
+
} : null;
|
|
43277
|
+
return {
|
|
43278
|
+
rows: items.filter((item) => item.type === "organic" || item.type === "featured_snippet").map((item) => {
|
|
43279
|
+
const row = {
|
|
43280
|
+
keyword,
|
|
43281
|
+
position: item.type === "organic" ? Number(item.rank_group) : null,
|
|
43282
|
+
rank_absolute: Number(item.rank_absolute),
|
|
43283
|
+
type: String(item.type),
|
|
43284
|
+
url: text$1(item.url),
|
|
43285
|
+
domain: text$1(item.domain),
|
|
43286
|
+
title: text$1(item.title)
|
|
43287
|
+
};
|
|
43288
|
+
if (GOTO.test(String(row.url))) {
|
|
43289
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
43290
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
43291
|
+
row.url = null;
|
|
43292
|
+
row.domain = site?.[1] ?? null;
|
|
43293
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
43294
|
+
}
|
|
43295
|
+
return row;
|
|
43296
|
+
}),
|
|
43297
|
+
overview
|
|
43298
|
+
};
|
|
43299
|
+
}
|
|
43037
43300
|
// packages/core/dist/commands/research-providers.js
|
|
43301
|
+
const RESEARCH_DEVICES = ["desktop", "mobile"];
|
|
43038
43302
|
const RESEARCH_SCOPES = [
|
|
43039
43303
|
"domain",
|
|
43040
43304
|
"prefix",
|
|
@@ -43158,7 +43422,25 @@ async function dataForSeoList([login, password], path) {
|
|
|
43158
43422
|
if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
|
|
43159
43423
|
return task.result ?? [];
|
|
43160
43424
|
}
|
|
43161
|
-
async function dataForSeoTask(
|
|
43425
|
+
async function dataForSeoTask(keys, path, task) {
|
|
43426
|
+
let spent = 0;
|
|
43427
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
43428
|
+
const done = await dataForSeoTaskOnce(keys, path, task);
|
|
43429
|
+
return {
|
|
43430
|
+
...done,
|
|
43431
|
+
cost: done.cost + spent
|
|
43432
|
+
};
|
|
43433
|
+
} catch (error) {
|
|
43434
|
+
const failed = error;
|
|
43435
|
+
spent += failed.cost ?? 0;
|
|
43436
|
+
if (!SE_ERRORS.has(failed.status ?? 0) || attempt >= SE_TRIES) throw Object.assign(error, {
|
|
43437
|
+
cost: spent,
|
|
43438
|
+
...attempt > 1 ? { tries: attempt } : {}
|
|
43439
|
+
});
|
|
43440
|
+
await wait(SE_RETRY_MS[attempt - 1] ?? 3e3);
|
|
43441
|
+
}
|
|
43442
|
+
}
|
|
43443
|
+
async function dataForSeoTaskOnce([login, password], path, task) {
|
|
43162
43444
|
const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
43163
43445
|
method: "POST",
|
|
43164
43446
|
headers: {
|
|
@@ -43173,7 +43455,10 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
43173
43455
|
partial: false,
|
|
43174
43456
|
noResults: true
|
|
43175
43457
|
};
|
|
43176
|
-
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
|
|
43458
|
+
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
|
|
43459
|
+
cost: result?.cost ?? 0,
|
|
43460
|
+
status: result?.status_code ?? 0
|
|
43461
|
+
});
|
|
43177
43462
|
return {
|
|
43178
43463
|
cost: result.cost ?? 0,
|
|
43179
43464
|
result: result.result?.[0] ?? null,
|
|
@@ -43182,6 +43467,9 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
43182
43467
|
}
|
|
43183
43468
|
const NO_RESULTS = 40102;
|
|
43184
43469
|
const PARTIAL = 40106;
|
|
43470
|
+
const SE_ERRORS = /* @__PURE__ */ new Set([40101, 5e4]);
|
|
43471
|
+
const SE_TRIES = 3;
|
|
43472
|
+
const SE_RETRY_MS = [1e3, 3e3];
|
|
43185
43473
|
const queued = (task) => ({
|
|
43186
43474
|
id: String(task.id ?? ""),
|
|
43187
43475
|
status_code: Number(task.status_code ?? 0),
|
|
@@ -43221,6 +43509,7 @@ async function dataForSeo(keys, input) {
|
|
|
43221
43509
|
noData: withoutData(input, rows, spent.missed, 700),
|
|
43222
43510
|
...spent.available !== void 0 ? { available: spent.available } : {},
|
|
43223
43511
|
...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
|
|
43512
|
+
...spent.aiOverviews ? { aiOverviews: spent.aiOverviews } : {},
|
|
43224
43513
|
...spent.rankFailed ? { rankFailed: true } : {}
|
|
43225
43514
|
};
|
|
43226
43515
|
}
|
|
@@ -43309,7 +43598,9 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
43309
43598
|
}
|
|
43310
43599
|
return items.map((item) => pick(item, {
|
|
43311
43600
|
keyword: "keyword_data.keyword",
|
|
43312
|
-
position: "ranked_serp_element.serp_item.
|
|
43601
|
+
position: "ranked_serp_element.serp_item.rank_group",
|
|
43602
|
+
rank_absolute: "ranked_serp_element.serp_item.rank_absolute",
|
|
43603
|
+
type: "ranked_serp_element.serp_item.type",
|
|
43313
43604
|
url: "ranked_serp_element.serp_item.url",
|
|
43314
43605
|
search_volume: "keyword_data.keyword_info.search_volume",
|
|
43315
43606
|
etv: "ranked_serp_element.serp_item.etv",
|
|
@@ -43319,28 +43610,13 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
43319
43610
|
}
|
|
43320
43611
|
if (input.action === "serp") {
|
|
43321
43612
|
const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
|
|
43322
|
-
|
|
43613
|
+
const { rows, overview } = dataForSeoSerp(keyword, await post("v3/serp/google/organic/live/advanced", {
|
|
43323
43614
|
keyword,
|
|
43324
|
-
depth: serpDepth(input)
|
|
43325
|
-
|
|
43326
|
-
|
|
43327
|
-
|
|
43328
|
-
|
|
43329
|
-
domain: "domain",
|
|
43330
|
-
title: "title"
|
|
43331
|
-
});
|
|
43332
|
-
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
43333
|
-
const crumb = String(item.breadcrumb ?? "");
|
|
43334
|
-
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
43335
|
-
row.url = null;
|
|
43336
|
-
row.domain = site?.[1] ?? null;
|
|
43337
|
-
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
43338
|
-
}
|
|
43339
|
-
return {
|
|
43340
|
-
keyword,
|
|
43341
|
-
...row
|
|
43342
|
-
};
|
|
43343
|
-
});
|
|
43615
|
+
depth: serpDepth(input),
|
|
43616
|
+
device: input.device ?? "desktop"
|
|
43617
|
+
}, 2));
|
|
43618
|
+
if (overview) spent.aiOverviews = [...spent.aiOverviews ?? [], overview];
|
|
43619
|
+
return rows;
|
|
43344
43620
|
});
|
|
43345
43621
|
return input.noRank ? rows : withDomainRank(keys, rows, spent);
|
|
43346
43622
|
}
|
|
@@ -43458,7 +43734,7 @@ async function serpOverlap(provider, headers, input) {
|
|
|
43458
43734
|
const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
|
|
43459
43735
|
const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
|
|
43460
43736
|
const top = new Map(keywords.map((keyword) => {
|
|
43461
|
-
const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
|
|
43737
|
+
const rows = serps.rows.filter((row) => row.keyword === keyword && row.type !== "featured_snippet").slice(0, OVERLAP_DEPTH);
|
|
43462
43738
|
const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
|
|
43463
43739
|
const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
|
|
43464
43740
|
return [keyword, {
|
|
@@ -47618,6 +47894,121 @@ async function runBingReport(input, deps) {
|
|
|
47618
47894
|
data: report
|
|
47619
47895
|
};
|
|
47620
47896
|
}
|
|
47897
|
+
const DEVICES = ["desktop", "mobile"];
|
|
47898
|
+
const DEPTH = 10;
|
|
47899
|
+
const bare = (host) => host.toLowerCase().replace(/^www\./, "");
|
|
47900
|
+
function key(url) {
|
|
47901
|
+
try {
|
|
47902
|
+
const parsed = new URL(url);
|
|
47903
|
+
return `${bare(parsed.hostname)}${parsed.pathname.replace(/\/+$/, "")}`.toLowerCase();
|
|
47904
|
+
} catch {
|
|
47905
|
+
return "";
|
|
47906
|
+
}
|
|
47907
|
+
}
|
|
47908
|
+
function hintKey(hint) {
|
|
47909
|
+
const parts = hint.split("›").map((part) => part.trim());
|
|
47910
|
+
if (!parts[0] || parts.some((part) => /^\.\.\.$|…/.test(part))) return null;
|
|
47911
|
+
return `${bare(parts[0])}${parts.length > 1 ? `/${parts.slice(1).join("/")}` : ""}`.toLowerCase();
|
|
47912
|
+
}
|
|
47913
|
+
function phraseFrom(text, words = 2) {
|
|
47914
|
+
const cleaned = (text ?? "").split(/\s+[|\u2013\u2014-]\s+/)[0]?.replace(/["“”]/g, "").trim();
|
|
47915
|
+
if (!cleaned || cleaned.split(/\s+/).length < words) return null;
|
|
47916
|
+
return cleaned.split(/\s+/).slice(0, 12).join(" ");
|
|
47917
|
+
}
|
|
47918
|
+
async function runIndexCheck(input, headers) {
|
|
47919
|
+
let target;
|
|
47920
|
+
try {
|
|
47921
|
+
target = new URL(/^https?:\/\//i.test(input.url) ? input.url : `https://${input.url}`);
|
|
47922
|
+
} catch {
|
|
47923
|
+
throw new CommandError("INVALID_INPUT", "index_check needs a page URL as target.");
|
|
47924
|
+
}
|
|
47925
|
+
const keys = dataForSeoKeys(headers);
|
|
47926
|
+
const country = input.country ?? "us";
|
|
47927
|
+
const market = dataForSeoMarket(country, dataForSeoLanguage(country));
|
|
47928
|
+
const page = key(target.href);
|
|
47929
|
+
const host = bare(target.hostname);
|
|
47930
|
+
let phrases = (input.phrases ?? []).slice(0, 2);
|
|
47931
|
+
let phraseSource = phrases.length ? "given" : null;
|
|
47932
|
+
if (!phrases.length) {
|
|
47933
|
+
const live = await liveCheck(target.href).catch(() => null);
|
|
47934
|
+
const sentence = (live?.last?.description ?? "").split(/(?<=[.!?])\s/)[0] ?? "";
|
|
47935
|
+
const found = [phraseFrom(live?.last?.h1 ?? null) ?? phraseFrom(live?.last?.title ?? null), phraseFrom(sentence.replace(/[.!?]$/, ""), 5)].filter((phrase) => Boolean(phrase));
|
|
47936
|
+
phrases = [...new Set(found)].slice(0, 2);
|
|
47937
|
+
phraseSource = phrases.length ? "the page’s H1 or title and its meta description" : null;
|
|
47938
|
+
}
|
|
47939
|
+
const queries = [`site:${host}${target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "")}`, ...phrases.map((phrase) => `"${phrase.replace(/"/g, "")}"`)];
|
|
47940
|
+
const rows = [];
|
|
47941
|
+
let cost = 0;
|
|
47942
|
+
for (const query of queries) for (const device of DEVICES) {
|
|
47943
|
+
const row = {
|
|
47944
|
+
query,
|
|
47945
|
+
device,
|
|
47946
|
+
result: "empty",
|
|
47947
|
+
position: null,
|
|
47948
|
+
rank_absolute: null,
|
|
47949
|
+
results: 0,
|
|
47950
|
+
top_domains: "",
|
|
47951
|
+
error: null
|
|
47952
|
+
};
|
|
47953
|
+
try {
|
|
47954
|
+
const done = await dataForSeoTask(keys, "v3/serp/google/organic/live/advanced", {
|
|
47955
|
+
...market,
|
|
47956
|
+
keyword: query,
|
|
47957
|
+
depth: DEPTH,
|
|
47958
|
+
device
|
|
47959
|
+
});
|
|
47960
|
+
cost += done.cost;
|
|
47961
|
+
const results = dataForSeoSerp(query, done.result?.items ?? []).rows;
|
|
47962
|
+
row.results = results.length;
|
|
47963
|
+
row.top_domains = [...new Set(results.map((r) => String(r.domain ?? "?")))].slice(0, 3).join(", ");
|
|
47964
|
+
const onSite = results.filter((r) => {
|
|
47965
|
+
const domain = bare(String(r.domain ?? ""));
|
|
47966
|
+
return domain === host || domain.endsWith(`.${host}`);
|
|
47967
|
+
});
|
|
47968
|
+
const match = onSite.find((r) => typeof r.url === "string" && key(r.url) === page || typeof r.url_hint === "string" && hintKey(r.url_hint) === page);
|
|
47969
|
+
if (match) {
|
|
47970
|
+
row.result = "page";
|
|
47971
|
+
row.position = typeof match.position === "number" ? match.position : null;
|
|
47972
|
+
row.rank_absolute = Number(match.rank_absolute) || null;
|
|
47973
|
+
} else if (onSite.some((r) => r.url === null && (typeof r.url_hint !== "string" || hintKey(r.url_hint) === null || hintKey(r.url_hint) === host))) row.result = "unresolved";
|
|
47974
|
+
else if (onSite.length) row.result = "other_pages";
|
|
47975
|
+
else if (results.length) row.result = query.startsWith("site:") ? "fallback" : "other_sites";
|
|
47976
|
+
} catch (error) {
|
|
47977
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
47978
|
+
cost += error.cost ?? 0;
|
|
47979
|
+
row.result = "error";
|
|
47980
|
+
row.error = error.message;
|
|
47981
|
+
}
|
|
47982
|
+
rows.push(row);
|
|
47983
|
+
}
|
|
47984
|
+
const has = (result) => rows.some((row) => row.result === result);
|
|
47985
|
+
const verdict = has("page") ? "indexed" : has("error") || has("unresolved") ? "unknown" : "not_found";
|
|
47986
|
+
const reason = verdict === "indexed" ? `Google returned the page itself for ${rows.filter((row) => row.result === "page").length} of ${rows.length} searches.` : verdict === "unknown" ? has("error") ? `${rows.filter((row) => row.result === "error").length} of ${rows.length} searches failed after retries, and none of the others returned the page. Ask again later.` : "Google returned results from the site, but DataForSEO gave Google redirect links whose page it could not resolve, so they may or may not be this page." : `None of ${rows.length} searches returned the page.${has("fallback") ? " Where the site: search showed other sites, Google had dropped the filter because nothing matched; those are not rankings." : ""} It may be missing from the index, or indexed without this text. Check it with URL Inspection (seo page or seo index) if the site is yours.`;
|
|
47987
|
+
const usd = Math.round(cost * 1e4) / 1e4;
|
|
47988
|
+
return {
|
|
47989
|
+
text: [
|
|
47990
|
+
`Index check for ${target.href} (${country}, desktop and mobile): ${verdict.replace("_", " ")}.`,
|
|
47991
|
+
reason,
|
|
47992
|
+
phraseSource ? `Exact phrase from ${phraseSource}.` : "No exact phrase: none was given and the page’s H1 and title could not be read, so only site: was asked.",
|
|
47993
|
+
"",
|
|
47994
|
+
...rows.map((row) => `${row.device.padEnd(7)} ${row.result}${row.position !== null ? ` (organic ${row.position}, absolute ${row.rank_absolute})` : ""} ${row.query}${row.results ? ` [${row.results} results: ${row.top_domains}]` : ""}${row.error ? ` ${row.error}` : ""}`),
|
|
47995
|
+
"",
|
|
47996
|
+
"This asks Google search, not Search Console. Google can index a page and still not show it for a site: or phrase search, so not found is evidence, not proof.",
|
|
47997
|
+
`DataForSEO cost: $${usd}.`
|
|
47998
|
+
].join("\n"),
|
|
47999
|
+
data: {
|
|
48000
|
+
provider: "dataforseo",
|
|
48001
|
+
action: "index_check",
|
|
48002
|
+
url: target.href,
|
|
48003
|
+
country,
|
|
48004
|
+
verdict,
|
|
48005
|
+
reason,
|
|
48006
|
+
phrases,
|
|
48007
|
+
checks: rows,
|
|
48008
|
+
costUsd: usd
|
|
48009
|
+
}
|
|
48010
|
+
};
|
|
48011
|
+
}
|
|
47621
48012
|
// packages/core/dist/commands/research-cache.js
|
|
47622
48013
|
const RESEARCH_TTL_SECONDS = 86400;
|
|
47623
48014
|
function workersResearchStore() {
|
|
@@ -47662,10 +48053,11 @@ const RESEARCH_ACTIONS = [
|
|
|
47662
48053
|
"top_pages",
|
|
47663
48054
|
"pages_by_backlinks",
|
|
47664
48055
|
"keyword_ideas",
|
|
47665
|
-
"serp_overlap"
|
|
48056
|
+
"serp_overlap",
|
|
48057
|
+
"index_check"
|
|
47666
48058
|
];
|
|
47667
48059
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
47668
|
-
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.`;
|
|
48060
|
+
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 with their organic position beside DataForSEO's rank_absolute, which also counts AI Overviews and other blocks, plus the AI Overview's sources; device desktop or mobile, DataForSEO only; 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), index_check (DataForSEO only; target: one page URL; keywords: up to 2 exact phrases from the page, default its H1 or title. Searches Google for site:<url> and each "<phrase>" on desktop and mobile, retries failed searches, treats Google's fallback to unrelated sites as no match, and says indexed, not found or unknown with each search's evidence; about $0.01 a page), 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.`;
|
|
47669
48061
|
const GENERIC_PLATFORMS = [
|
|
47670
48062
|
"youtube.com",
|
|
47671
48063
|
"facebook.com",
|
|
@@ -47727,6 +48119,17 @@ async function runResearch(input, keys, options) {
|
|
|
47727
48119
|
const offset = input.offset ?? 0;
|
|
47728
48120
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
47729
48121
|
const provider = chooseProvider(input.provider, keys);
|
|
48122
|
+
if (input.action === "index_check") {
|
|
48123
|
+
if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
|
|
48124
|
+
return runIndexCheck({
|
|
48125
|
+
url: target,
|
|
48126
|
+
phrases: input.keywords,
|
|
48127
|
+
country
|
|
48128
|
+
}, keys);
|
|
48129
|
+
}
|
|
48130
|
+
if (input.device && input.action !== "serp") throw new CommandError("INVALID_INPUT", "device works with serp only. ranked_keywords and the other database actions have one ranking per keyword, from desktop results.");
|
|
48131
|
+
if (input.device === "mobile" && provider === "ahrefs") throw new CommandError("INVALID_INPUT", "Ahrefs' SERP overview has no device choice, so it cannot show mobile results. Use provider dataforseo for device mobile.");
|
|
48132
|
+
const device = input.action === "serp" ? input.device ?? "desktop" : null;
|
|
47730
48133
|
const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
47731
48134
|
const scope = [
|
|
47732
48135
|
"ranked_keywords",
|
|
@@ -47742,7 +48145,8 @@ async function runResearch(input, keys, options) {
|
|
|
47742
48145
|
...scope ? { scope } : {},
|
|
47743
48146
|
competitors: input.competitors ?? [],
|
|
47744
48147
|
country,
|
|
47745
|
-
...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
|
|
48148
|
+
...input.action === "serp" ? { depth: input.depth ?? 20 } : {},
|
|
48149
|
+
...device === "mobile" ? { device } : {}
|
|
47746
48150
|
};
|
|
47747
48151
|
const store = options.store ?? workersResearchStore();
|
|
47748
48152
|
const key = await researchKey([
|
|
@@ -47760,10 +48164,10 @@ async function runResearch(input, keys, options) {
|
|
|
47760
48164
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47761
48165
|
result
|
|
47762
48166
|
});
|
|
47763
|
-
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
|
|
48167
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews } = result;
|
|
47764
48168
|
const costUsd = saved ? 0 : paid;
|
|
47765
48169
|
const order = new Map(request.keywords.map((word, index) => [word, index]));
|
|
47766
|
-
const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ??
|
|
48170
|
+
const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 0) - Number(b.position ?? 0) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
|
|
47767
48171
|
const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
|
|
47768
48172
|
rows: found,
|
|
47769
48173
|
note: null
|
|
@@ -47781,7 +48185,7 @@ async function runResearch(input, keys, options) {
|
|
|
47781
48185
|
const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
|
|
47782
48186
|
return {
|
|
47783
48187
|
text: rowsText({
|
|
47784
|
-
title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
|
|
48188
|
+
title: `${provider} ${input.action}${device && provider === "dataforseo" ? ` (${device})` : ""}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
|
|
47785
48189
|
rows: rated.rows,
|
|
47786
48190
|
offset,
|
|
47787
48191
|
limit: size,
|
|
@@ -47790,6 +48194,8 @@ async function runResearch(input, keys, options) {
|
|
|
47790
48194
|
`${provider} estimates, in the provider’s own units.`,
|
|
47791
48195
|
...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." : ""}`] : [],
|
|
47792
48196
|
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
|
|
48197
|
+
...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
|
|
48198
|
+
...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
|
|
47793
48199
|
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
47794
48200
|
...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
|
|
47795
48201
|
...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.`] : [],
|
|
@@ -47823,6 +48229,8 @@ async function runResearch(input, keys, options) {
|
|
|
47823
48229
|
available: available ?? null,
|
|
47824
48230
|
country,
|
|
47825
48231
|
language,
|
|
48232
|
+
...device ? { device: provider === "dataforseo" ? device : null } : {},
|
|
48233
|
+
...input.action === "serp" && provider === "dataforseo" ? { aiOverviews: aiOverviews ?? [] } : {},
|
|
47826
48234
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
47827
48235
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
47828
48236
|
leftOut: note,
|
|
@@ -47846,6 +48254,17 @@ async function runResearch(input, keys, options) {
|
|
|
47846
48254
|
}
|
|
47847
48255
|
};
|
|
47848
48256
|
}
|
|
48257
|
+
function positionNote(provider) {
|
|
48258
|
+
return provider === "dataforseo" ? "position is the organic position: the nth organic link (DataForSEO rank_group). rank_absolute counts every block on the page from the top, AI Overviews, People Also Ask, videos and images included, so it is higher. A featured snippet has type featured_snippet and no organic position." : "position is the organic position: the nth row Ahrefs marks organic. ahrefs_position is Ahrefs' own position field, and type lists what each row is.";
|
|
48259
|
+
}
|
|
48260
|
+
function aiOverviewNote(overviews, keywords) {
|
|
48261
|
+
const each = overviews.map((overview) => {
|
|
48262
|
+
const sites = [...new Set(overview.references.map((ref) => ref.domain).filter((domain) => Boolean(domain)))];
|
|
48263
|
+
const unknown = overview.references.filter((ref) => !ref.domain).length;
|
|
48264
|
+
return `${overview.keyword}: ${overview.references.length} sources${sites.length ? ` (${sites.slice(0, 6).join(", ")}${sites.length > 6 ? ", …" : ""})` : ""}${unknown ? `, ${unknown} behind Google redirect links` : ""}`;
|
|
48265
|
+
});
|
|
48266
|
+
return `AI Overview shown for ${overviews.length} of ${keywords} keyword${keywords === 1 ? "" : "s"}. ${each.join("; ")}. The full list is in the JSON as aiOverviews.`;
|
|
48267
|
+
}
|
|
47849
48268
|
const LINK_ACTIONS = [
|
|
47850
48269
|
"backlinks",
|
|
47851
48270
|
"pages_by_backlinks",
|
|
@@ -48502,7 +48921,7 @@ const percent = (site) => {
|
|
|
48502
48921
|
const clicks = site.clicks;
|
|
48503
48922
|
return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
|
|
48504
48923
|
};
|
|
48505
|
-
function issues(site, startMs, endMs) {
|
|
48924
|
+
function issues(site, startMs, endMs, first = false) {
|
|
48506
48925
|
const found = [];
|
|
48507
48926
|
if (site.index?.dropped) found.push({
|
|
48508
48927
|
rank: 0,
|
|
@@ -48524,11 +48943,11 @@ function issues(site, startMs, endMs) {
|
|
|
48524
48943
|
});
|
|
48525
48944
|
if (crawled && crawl.new_issues > 0) found.push({
|
|
48526
48945
|
rank: 3,
|
|
48527
|
-
heading: plural(crawl.new_issues, "new issue")
|
|
48946
|
+
heading: plural(crawl.new_issues, first ? "open finding" : "new issue")
|
|
48528
48947
|
});
|
|
48529
48948
|
return found;
|
|
48530
48949
|
}
|
|
48531
|
-
function siteLines(site, startMs, endMs) {
|
|
48950
|
+
function siteLines(site, startMs, endMs, first = false) {
|
|
48532
48951
|
const lines = [];
|
|
48533
48952
|
const index = site.index;
|
|
48534
48953
|
if (!index) lines.push("Google index: no checks ran this week yet.");
|
|
@@ -48545,7 +48964,7 @@ function siteLines(site, startMs, endMs) {
|
|
|
48545
48964
|
if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
|
|
48546
48965
|
else if (crawled) {
|
|
48547
48966
|
const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
|
|
48548
|
-
lines.push(crawl.new_issues ? `Weekly crawl: ${plural(crawl.pages, "page")}, ${plural(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural(crawl.pages, "page")}, no new issues.`);
|
|
48967
|
+
lines.push(crawl.new_issues ? `Weekly crawl: ${plural(crawl.pages, "page")}, ${plural(crawl.new_issues, first ? "open finding" : "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural(crawl.pages, "page")}, no new issues.`);
|
|
48549
48968
|
} else if (site.added_at < startMs) lines.push(`Weekly crawl: none finished this week${site.start_error ? `: ${sentence(site.start_error)}` : "."} It tries again each hour.`);
|
|
48550
48969
|
else lines.push("Weekly crawl: the first crawl is still running.");
|
|
48551
48970
|
const clicks = site.clicks;
|
|
@@ -48613,7 +49032,7 @@ function weekMarkdown(data) {
|
|
|
48613
49032
|
const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
|
|
48614
49033
|
const rows = data.sites.map((site) => ({
|
|
48615
49034
|
site,
|
|
48616
|
-
found: issues(site, startMs, endMs),
|
|
49035
|
+
found: issues(site, startMs, endMs, data.first),
|
|
48617
49036
|
waiting: !site.crawl || !site.index
|
|
48618
49037
|
}));
|
|
48619
49038
|
const attention = rows.filter((row) => row.found.length);
|
|
@@ -48625,7 +49044,7 @@ function weekMarkdown(data) {
|
|
|
48625
49044
|
"",
|
|
48626
49045
|
attention.length ? total === 1 ? "Your site had changes worth a look this week." : `${attention.length} of your ${total} sites had changes worth a look this week.` : quiet.length ? total === 1 ? "Your site had a quiet week." : quiet.length === total ? `All ${total} of your sites had a quiet week.` : `${quiet.length} of your ${total} sites had a quiet week.` : "Your sites are still on their first checks."
|
|
48627
49046
|
];
|
|
48628
|
-
for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...updateLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
|
|
49047
|
+
for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs, data.first).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...updateLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
|
|
48629
49048
|
return out.join("\n");
|
|
48630
49049
|
}
|
|
48631
49050
|
async function runAllSites(deps, sites) {
|
|
@@ -48863,6 +49282,7 @@ const SHARED_TOOLS = [
|
|
|
48863
49282
|
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."),
|
|
48864
49283
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
48865
49284
|
depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
|
|
49285
|
+
device: z.enum(RESEARCH_DEVICES).optional().describe("serp only: desktop (default) or mobile. Google ranks pages differently on each. DataForSEO only."),
|
|
48866
49286
|
...detail
|
|
48867
49287
|
}),
|
|
48868
49288
|
run: async (surface, input) => {
|