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/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { n as version, t as load } from "./esm-
|
|
2
|
+
import { n as version, t as load } from "./esm-BR8_7rhM.js";
|
|
3
3
|
import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
|
|
4
4
|
import crypto$1, { createHash, randomUUID, sign } from "node:crypto";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -453,13 +453,20 @@ const RULE_DEFINITIONS = [
|
|
|
453
453
|
title: "Large HTML response",
|
|
454
454
|
category: "performance",
|
|
455
455
|
defaultSeverity: "low",
|
|
456
|
-
whyItMatters: "
|
|
457
|
-
howToFix: "
|
|
458
|
-
impactIfIgnored: "
|
|
459
|
-
howToVerify: "Re-run the crawl and confirm sizeBytes is
|
|
456
|
+
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.",
|
|
457
|
+
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.",
|
|
458
|
+
impactIfIgnored: "Content past the 2MB cut is not indexed, and every page of the template carries the same weight on slow connections.",
|
|
459
|
+
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.",
|
|
460
460
|
agentHints: {
|
|
461
|
-
evidenceFields: [
|
|
462
|
-
|
|
461
|
+
evidenceFields: [
|
|
462
|
+
"page.sizeBytes",
|
|
463
|
+
"issue.evidence.thresholdBytes",
|
|
464
|
+
"issue.evidence.mainContentStartBytes",
|
|
465
|
+
"issue.evidence.bytesAfterLimit",
|
|
466
|
+
"issue.evidence.visibleWordsAfterLimit",
|
|
467
|
+
"issue.evidence.indexingRisk"
|
|
468
|
+
],
|
|
469
|
+
suggestedCommands: ["seo crawl <url> --mode page --json"]
|
|
463
470
|
}
|
|
464
471
|
},
|
|
465
472
|
{
|
|
@@ -1097,17 +1104,17 @@ function isAgentFindings(value) {
|
|
|
1097
1104
|
const counts = record(candidate?.counts);
|
|
1098
1105
|
return candidate?.schemaVersion === 1 && candidate.scope === "returned-report" && typeof counts?.total === "number" && typeof counts.returned === "number" && Array.isArray(candidate.items) && candidate.items.every((value) => {
|
|
1099
1106
|
const item = record(value);
|
|
1100
|
-
if (!text(item?.id) || item?.status !== "open" || !text(item.title) || !text(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
|
|
1101
|
-
if (item.type === "fix") return Boolean(text(record(item.fix)?.instruction));
|
|
1107
|
+
if (!text$1(item?.id) || item?.status !== "open" || !text$1(item.title) || !text$1(item.sourcePath) || !Array.isArray(item.allowedOutcomes)) return false;
|
|
1108
|
+
if (item.type === "fix") return Boolean(text$1(record(item.fix)?.instruction));
|
|
1102
1109
|
if (item.type === "review") {
|
|
1103
1110
|
const review = record(item.review);
|
|
1104
1111
|
if (!review) return false;
|
|
1105
|
-
return Boolean(text(review.question) && text(review.changeOnlyIf) && text(review.ifConfirmed) && text(review.ifNotNeeded) && Array.isArray(review.doNot));
|
|
1112
|
+
return Boolean(text$1(review.question) && text$1(review.changeOnlyIf) && text$1(review.ifConfirmed) && text$1(review.ifNotNeeded) && Array.isArray(review.doNot));
|
|
1106
1113
|
}
|
|
1107
1114
|
return false;
|
|
1108
1115
|
});
|
|
1109
1116
|
}
|
|
1110
|
-
function text(value) {
|
|
1117
|
+
function text$1(value) {
|
|
1111
1118
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1112
1119
|
}
|
|
1113
1120
|
function stableId(value) {
|
|
@@ -1130,7 +1137,7 @@ function findingId(item, path, action) {
|
|
|
1130
1137
|
"actionId",
|
|
1131
1138
|
"id"
|
|
1132
1139
|
]) {
|
|
1133
|
-
const id = text(item[key]);
|
|
1140
|
+
const id = text$1(item[key]);
|
|
1134
1141
|
if (id) return id;
|
|
1135
1142
|
}
|
|
1136
1143
|
return `action-${stableId(`${path}|${action}`)}`;
|
|
@@ -1140,9 +1147,9 @@ function findingType(path, item) {
|
|
|
1140
1147
|
}
|
|
1141
1148
|
function findingVerification(item, fallback) {
|
|
1142
1149
|
const supplied = record(item.verification);
|
|
1143
|
-
const expected = text(supplied?.expected ?? item.howToVerify ?? fallback);
|
|
1150
|
+
const expected = text$1(supplied?.expected ?? item.howToVerify ?? fallback);
|
|
1144
1151
|
if (!expected) return void 0;
|
|
1145
|
-
const command = text(supplied?.command);
|
|
1152
|
+
const command = text$1(supplied?.command);
|
|
1146
1153
|
return {
|
|
1147
1154
|
...command ? { command } : {},
|
|
1148
1155
|
expected
|
|
@@ -1150,14 +1157,14 @@ function findingVerification(item, fallback) {
|
|
|
1150
1157
|
}
|
|
1151
1158
|
function stringArray(value) {
|
|
1152
1159
|
if (!Array.isArray(value)) return [];
|
|
1153
|
-
return value.flatMap((item) => text(item) ? [text(item)] : []);
|
|
1160
|
+
return value.flatMap((item) => text$1(item) ? [text$1(item)] : []);
|
|
1154
1161
|
}
|
|
1155
1162
|
function affectedUrls(item, affectedCount) {
|
|
1156
1163
|
const items = stringArray(item.sampleUrls);
|
|
1157
1164
|
const details = record(item.affectedUrlsReport);
|
|
1158
1165
|
const params = record(details?.params);
|
|
1159
|
-
const reportId = details?.id === "affected-urls" ? text(params?.reportId) : void 0;
|
|
1160
|
-
const ruleId = details?.id === "affected-urls" ? text(params?.ruleId) : void 0;
|
|
1166
|
+
const reportId = details?.id === "affected-urls" ? text$1(params?.reportId) : void 0;
|
|
1167
|
+
const ruleId = details?.id === "affected-urls" ? text$1(params?.ruleId) : void 0;
|
|
1161
1168
|
if (!items.length && !reportId) return void 0;
|
|
1162
1169
|
const total = affectedCount ?? items.length;
|
|
1163
1170
|
return {
|
|
@@ -1177,13 +1184,13 @@ function affectedUrls(item, affectedCount) {
|
|
|
1177
1184
|
function reviewGuidance(item, title, action) {
|
|
1178
1185
|
const supplied = record(item.review);
|
|
1179
1186
|
const evidence = record(item.evidence);
|
|
1180
|
-
const ruleId = text(item.ruleId ?? evidence?.ruleId);
|
|
1187
|
+
const ruleId = text$1(item.ruleId ?? evidence?.ruleId);
|
|
1181
1188
|
const defined = (ruleId ? explainRule(ruleId) : void 0)?.review;
|
|
1182
1189
|
return {
|
|
1183
|
-
question: text(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
|
|
1184
|
-
changeOnlyIf: text(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
|
|
1185
|
-
ifConfirmed: text(supplied?.ifConfirmed) ?? action,
|
|
1186
|
-
ifNotNeeded: text(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
|
|
1190
|
+
question: text$1(supplied?.question) ?? defined?.question ?? `Does the retained evidence and confirmed intent show that "${title}" needs a change?`,
|
|
1191
|
+
changeOnlyIf: text$1(supplied?.changeOnlyIf) ?? defined?.changeOnlyIf ?? "Make a change only when the retained evidence and required intent support it.",
|
|
1192
|
+
ifConfirmed: text$1(supplied?.ifConfirmed) ?? action,
|
|
1193
|
+
ifNotNeeded: text$1(supplied?.ifNotNeeded) ?? defined?.ifNotNeeded ?? "Record no change and cite the evidence or confirmed intent that makes a change unnecessary.",
|
|
1187
1194
|
doNot: stringArray(supplied?.doNot).length ? stringArray(supplied?.doNot) : [...defined?.doNot ?? []]
|
|
1188
1195
|
};
|
|
1189
1196
|
}
|
|
@@ -1202,7 +1209,7 @@ function findingEvidence(item) {
|
|
|
1202
1209
|
}
|
|
1203
1210
|
function normalizeFinding(value, path, index, options) {
|
|
1204
1211
|
if (typeof value === "string") {
|
|
1205
|
-
const action = text(value);
|
|
1212
|
+
const action = text$1(value);
|
|
1206
1213
|
if (!action) return void 0;
|
|
1207
1214
|
const common = {
|
|
1208
1215
|
id: `action-${stableId(`${options.reportId ?? ""}|${path}|${action}`)}`,
|
|
@@ -1234,8 +1241,8 @@ function normalizeFinding(value, path, index, options) {
|
|
|
1234
1241
|
}
|
|
1235
1242
|
const item = record(value);
|
|
1236
1243
|
if (!item) return void 0;
|
|
1237
|
-
const action = text(item.action) ?? text(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text(item.recommendation)) ?? text(item.nextStep);
|
|
1238
|
-
const title = text(item.title) ?? text(item.headline) ?? text(item.name) ?? action;
|
|
1244
|
+
const action = text$1(item.action) ?? text$1(item.howToFix) ?? (item.recommendation === "fix" || item.recommendation === "review" ? void 0 : text$1(item.recommendation)) ?? text$1(item.nextStep);
|
|
1245
|
+
const title = text$1(item.title) ?? text$1(item.headline) ?? text$1(item.name) ?? action;
|
|
1239
1246
|
if (!action || !title) return void 0;
|
|
1240
1247
|
const confidence = [
|
|
1241
1248
|
"high",
|
|
@@ -1253,7 +1260,7 @@ function normalizeFinding(value, path, index, options) {
|
|
|
1253
1260
|
status: "open",
|
|
1254
1261
|
title,
|
|
1255
1262
|
...confidence ? { confidence } : {},
|
|
1256
|
-
...text(item.severity) ? { severity: text(item.severity) } : {},
|
|
1263
|
+
...text$1(item.severity) ? { severity: text$1(item.severity) } : {},
|
|
1257
1264
|
...typeof affectedCount === "number" && Number.isFinite(affectedCount) ? { affectedCount } : {},
|
|
1258
1265
|
...urls ? { affectedUrls: urls } : {},
|
|
1259
1266
|
sourcePath: `${path}[${index}]`,
|
|
@@ -1315,7 +1322,7 @@ function inventory(value, path, reportId) {
|
|
|
1315
1322
|
const totalItems = typeof (source.totalPages ?? source.totalItems) === "number" ? Number(source.totalPages ?? source.totalItems) : source.rows.length;
|
|
1316
1323
|
const items = source.rows.map((value, index) => {
|
|
1317
1324
|
const row = record(value) ?? { value };
|
|
1318
|
-
const identity = text(row.id) ?? text(row.url) ?? text(row.path) ?? `${path}[${index}]`;
|
|
1325
|
+
const identity = text$1(row.id) ?? text$1(row.url) ?? text$1(row.path) ?? `${path}[${index}]`;
|
|
1319
1326
|
return {
|
|
1320
1327
|
...row,
|
|
1321
1328
|
id: `inventory-item-${stableId(`${reportId ?? ""}|${path}|${identity}`)}`,
|
|
@@ -1334,7 +1341,7 @@ function inventory(value, path, reportId) {
|
|
|
1334
1341
|
returnedItems: items.length,
|
|
1335
1342
|
complete: source.capped === false && items.length >= totalItems,
|
|
1336
1343
|
...source.criteria !== void 0 ? { criteria: source.criteria } : {},
|
|
1337
|
-
...text(source.note) ? { note: text(source.note) } : {},
|
|
1344
|
+
...text$1(source.note) ? { note: text$1(source.note) } : {},
|
|
1338
1345
|
...pageCount !== void 0 ? { pagination: {
|
|
1339
1346
|
page,
|
|
1340
1347
|
pageCount,
|
|
@@ -2802,7 +2809,7 @@ async function readableSites(google, signIn) {
|
|
|
2802
2809
|
permissionLevel: entry.permissionLevel ?? ""
|
|
2803
2810
|
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
2804
2811
|
}
|
|
2805
|
-
const hostOf$
|
|
2812
|
+
const hostOf$4 = (value) => {
|
|
2806
2813
|
try {
|
|
2807
2814
|
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
2808
2815
|
} catch {
|
|
@@ -2820,14 +2827,14 @@ async function runSites(input) {
|
|
|
2820
2827
|
};
|
|
2821
2828
|
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
2822
2829
|
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
2823
|
-
const bingByHost = new Map(bingSites.map((site) => [hostOf$
|
|
2830
|
+
const bingByHost = new Map(bingSites.map((site) => [hostOf$4(site.url), site]));
|
|
2824
2831
|
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
2825
2832
|
...property,
|
|
2826
2833
|
...named ? { account: account.email ?? "" } : {},
|
|
2827
|
-
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$
|
|
2834
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$4(property.siteUrl))?.url ?? "" } : {}
|
|
2828
2835
|
})));
|
|
2829
|
-
const googleHosts = new Set(properties.map((property) => hostOf$
|
|
2830
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$
|
|
2836
|
+
const googleHosts = new Set(properties.map((property) => hostOf$4(property.siteUrl)));
|
|
2837
|
+
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$4(site.url)));
|
|
2831
2838
|
const failed = google.filter((account) => account.properties instanceof Error);
|
|
2832
2839
|
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
2833
2840
|
property: property.property.replace("properties/", ""),
|
|
@@ -11477,6 +11484,98 @@ function contentOptimizationFromPageOpportunities(sourceReport) {
|
|
|
11477
11484
|
async function contentOptimizationReport(input) {
|
|
11478
11485
|
return contentOptimizationFromPageOpportunities(await pageOpportunitiesReport(input));
|
|
11479
11486
|
}
|
|
11487
|
+
// packages/core/src/analyze/crawler/html-size.ts
|
|
11488
|
+
const GOOGLEBOT_HTML_LIMIT_BYTES = 2097152;
|
|
11489
|
+
const NEAR_LIMIT_SHARE = .9;
|
|
11490
|
+
const HIDDEN_BLOCKS = /<(script|style|svg|noscript|template)\b[\s\S]*?(?:<\/\1\s*>|$)|<!--[\s\S]*?(?:-->|$)/gi;
|
|
11491
|
+
function wordCount(text) {
|
|
11492
|
+
return text.match(/[^\s&;]+/g)?.length ?? 0;
|
|
11493
|
+
}
|
|
11494
|
+
/** Where a page over Googlebot's limit puts its main content. */
|
|
11495
|
+
function googlebotCutEvidence(html) {
|
|
11496
|
+
const bytes = Buffer.from(html, "utf8");
|
|
11497
|
+
if (bytes.byteLength <= 2097152) return void 0;
|
|
11498
|
+
const marker = /<(h1|main)[\s>/]/i.exec(html);
|
|
11499
|
+
const text = html.replace(HIDDEN_BLOCKS, (match) => " ".repeat(match.length)).replace(/<[^>]*>?/g, (match) => " ".repeat(match.length));
|
|
11500
|
+
const limitIndex = bytes.subarray(0, GOOGLEBOT_HTML_LIMIT_BYTES).toString("utf8").length;
|
|
11501
|
+
const startBytes = marker ? Buffer.byteLength(html.slice(0, marker.index)) : void 0;
|
|
11502
|
+
return {
|
|
11503
|
+
limitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11504
|
+
bytesAfterLimit: bytes.byteLength - GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11505
|
+
...marker && startBytes !== void 0 ? {
|
|
11506
|
+
mainContentStartBytes: startBytes,
|
|
11507
|
+
mainContentMarker: marker[1]?.toLowerCase()
|
|
11508
|
+
} : {},
|
|
11509
|
+
mainContentStart: startBytes === void 0 ? "not-found" : startBytes >= 2097152 ? "after-limit" : startBytes >= 2097152 * NEAR_LIMIT_SHARE ? "near-limit" : "before-limit",
|
|
11510
|
+
visibleWordsBeforeLimit: wordCount(text.slice(0, limitIndex)),
|
|
11511
|
+
visibleWordsAfterLimit: wordCount(text.slice(limitIndex))
|
|
11512
|
+
};
|
|
11513
|
+
}
|
|
11514
|
+
const formatBytes = (value) => value.toLocaleString("en-US");
|
|
11515
|
+
function largeHtmlIssue(rule, page) {
|
|
11516
|
+
const sizeBytes = page.sizeBytes ?? 0;
|
|
11517
|
+
const cut = page.googlebotCut;
|
|
11518
|
+
const htmlSource = page.usedJs ? "rendered" : "raw";
|
|
11519
|
+
const base = {
|
|
11520
|
+
...rule,
|
|
11521
|
+
detail: `${Math.round(sizeBytes / 1024)} KB`,
|
|
11522
|
+
evidence: {
|
|
11523
|
+
sizeBytes,
|
|
11524
|
+
thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11525
|
+
htmlSource
|
|
11526
|
+
}
|
|
11527
|
+
};
|
|
11528
|
+
if (!cut) return base;
|
|
11529
|
+
const words = cut.visibleWordsBeforeLimit + cut.visibleWordsAfterLimit;
|
|
11530
|
+
const wordsAfterShare = words ? Math.round(cut.visibleWordsAfterLimit / words * 100) / 100 : void 0;
|
|
11531
|
+
const indexingRisk = htmlSource === "rendered" ? "unknown" : cut.mainContentStart === "after-limit" || cut.mainContentStart === "near-limit" || (wordsAfterShare ?? 0) > .5 ? "main-content-past-limit" : "none-found";
|
|
11532
|
+
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`;
|
|
11533
|
+
const wordsNote = wordsAfterShare === void 0 ? "" : `, with about ${Math.round(wordsAfterShare * 100)}% of the visible words`;
|
|
11534
|
+
return {
|
|
11535
|
+
...base,
|
|
11536
|
+
severity: indexingRisk === "main-content-past-limit" ? "high" : base.severity,
|
|
11537
|
+
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)." : ""}`,
|
|
11538
|
+
evidence: {
|
|
11539
|
+
...base.evidence,
|
|
11540
|
+
googlebotLimitBytes: cut.limitBytes,
|
|
11541
|
+
bytesAfterLimit: cut.bytesAfterLimit,
|
|
11542
|
+
mainContentStartBytes: cut.mainContentStartBytes ?? null,
|
|
11543
|
+
mainContentMarker: cut.mainContentMarker ?? null,
|
|
11544
|
+
mainContentStart: cut.mainContentStart,
|
|
11545
|
+
visibleWordsBeforeLimit: cut.visibleWordsBeforeLimit,
|
|
11546
|
+
visibleWordsAfterLimit: cut.visibleWordsAfterLimit,
|
|
11547
|
+
visibleWordsAfterLimitShare: wordsAfterShare ?? null,
|
|
11548
|
+
indexingRisk
|
|
11549
|
+
}
|
|
11550
|
+
};
|
|
11551
|
+
}
|
|
11552
|
+
const RESPONSE_CAP_ERROR = /^Page response exceeds the (\d+)-byte response limit/;
|
|
11553
|
+
function responseCapIssue(request) {
|
|
11554
|
+
if (request.outcome !== "failure") return void 0;
|
|
11555
|
+
const capBytes = Number(RESPONSE_CAP_ERROR.exec(request.error)?.[1]);
|
|
11556
|
+
if (!capBytes) return void 0;
|
|
11557
|
+
const rule = explainRule("large_html");
|
|
11558
|
+
if (!rule) throw new Error("Missing rule guidance for large_html");
|
|
11559
|
+
return {
|
|
11560
|
+
ruleId: rule.id,
|
|
11561
|
+
title: rule.title,
|
|
11562
|
+
category: rule.category,
|
|
11563
|
+
severity: rule.defaultSeverity,
|
|
11564
|
+
url: request.requestedUrl,
|
|
11565
|
+
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.`,
|
|
11566
|
+
evidence: {
|
|
11567
|
+
dataStatus: "partial",
|
|
11568
|
+
sizeBytes: null,
|
|
11569
|
+
sizeBytesAtLeast: capBytes,
|
|
11570
|
+
thresholdBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11571
|
+
googlebotLimitBytes: GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11572
|
+
bytesAfterLimitAtLeast: capBytes - GOOGLEBOT_HTML_LIMIT_BYTES,
|
|
11573
|
+
mainContentStartBytes: null,
|
|
11574
|
+
mainContentStart: "unknown",
|
|
11575
|
+
indexingRisk: "unknown"
|
|
11576
|
+
}
|
|
11577
|
+
};
|
|
11578
|
+
}
|
|
11480
11579
|
// packages/core/src/analyze/crawler/page-eligibility.ts
|
|
11481
11580
|
function comparableUrl(value) {
|
|
11482
11581
|
if (!value) return void 0;
|
|
@@ -11515,6 +11614,21 @@ function isCdnUtilityUrl(value) {
|
|
|
11515
11614
|
return false;
|
|
11516
11615
|
}
|
|
11517
11616
|
}
|
|
11617
|
+
function globMatch(pattern, value) {
|
|
11618
|
+
if (!pattern.includes("*")) return value.includes(pattern);
|
|
11619
|
+
const parts = pattern.split("*");
|
|
11620
|
+
let position = 0;
|
|
11621
|
+
for (const part of parts) {
|
|
11622
|
+
if (!part) continue;
|
|
11623
|
+
const found = value.slice(position).indexOf(part);
|
|
11624
|
+
if (found === -1) return false;
|
|
11625
|
+
position += found + part.length;
|
|
11626
|
+
}
|
|
11627
|
+
return true;
|
|
11628
|
+
}
|
|
11629
|
+
function passesFilters(url, include, exclude) {
|
|
11630
|
+
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
11631
|
+
}
|
|
11518
11632
|
// packages/core/src/analyze/crawler/audit.ts
|
|
11519
11633
|
function sameUrl(a, b) {
|
|
11520
11634
|
if (!a || !b) return false;
|
|
@@ -11557,7 +11671,7 @@ function urlKey(value) {
|
|
|
11557
11671
|
}
|
|
11558
11672
|
}
|
|
11559
11673
|
const SLOW_RESPONSE_MS = 2e3;
|
|
11560
|
-
const LARGE_HTML_BYTES =
|
|
11674
|
+
const LARGE_HTML_BYTES = GOOGLEBOT_HTML_LIMIT_BYTES;
|
|
11561
11675
|
const COMPRESSION_MIN_BYTES = 4096;
|
|
11562
11676
|
const DEEP_PAGE_DEPTH = 4;
|
|
11563
11677
|
const WEAK_VALUABLE_INLINKS = 1;
|
|
@@ -11729,6 +11843,11 @@ function auditCrawlRequests(requests) {
|
|
|
11729
11843
|
for (const request of requests) {
|
|
11730
11844
|
if (request.outcome === "failure") {
|
|
11731
11845
|
if (request.failureKind === "aborted") continue;
|
|
11846
|
+
const overCap = responseCapIssue(request);
|
|
11847
|
+
if (overCap) {
|
|
11848
|
+
issues.push(overCap);
|
|
11849
|
+
continue;
|
|
11850
|
+
}
|
|
11732
11851
|
issues.push(requestIssue("connection_error", request, request.error, {
|
|
11733
11852
|
outcome: request.outcome,
|
|
11734
11853
|
failureKind: request.failureKind,
|
|
@@ -11841,10 +11960,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
11841
11960
|
outgoingInternalCount: page.outgoingInternalCount,
|
|
11842
11961
|
rendering: page.fetchDiagnostics?.rendering
|
|
11843
11962
|
}));
|
|
11844
|
-
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(issue$1("large_html", page,
|
|
11845
|
-
sizeBytes: page.sizeBytes,
|
|
11846
|
-
thresholdBytes: LARGE_HTML_BYTES
|
|
11847
|
-
}));
|
|
11963
|
+
if (isHtmlPage(page) && (page.sizeBytes ?? 0) > LARGE_HTML_BYTES) issues.push(largeHtmlIssue(issue$1("large_html", page), page));
|
|
11848
11964
|
if (isHtmlPage(page) && !page.compression && (page.sizeBytes ?? 0) > COMPRESSION_MIN_BYTES) issues.push(issue$1("no_compression", page, void 0, {
|
|
11849
11965
|
sizeBytes: page.sizeBytes,
|
|
11850
11966
|
thresholdBytes: COMPRESSION_MIN_BYTES,
|
|
@@ -12661,7 +12777,11 @@ function refreshPrioritiesCsvFiles(report) {
|
|
|
12661
12777
|
}
|
|
12662
12778
|
// packages/core/src/analyze/crawler/crawl-url-queue.ts
|
|
12663
12779
|
function before(left, right) {
|
|
12664
|
-
|
|
12780
|
+
if (left.depth !== right.depth) return left.depth < right.depth;
|
|
12781
|
+
const leftRank = left.rank ?? 0;
|
|
12782
|
+
const rightRank = right.rank ?? 0;
|
|
12783
|
+
if (leftRank !== rightRank) return leftRank < rightRank;
|
|
12784
|
+
return left.url < right.url;
|
|
12665
12785
|
}
|
|
12666
12786
|
var CrawlUrlQueue = class {
|
|
12667
12787
|
#items = [];
|
|
@@ -13191,14 +13311,20 @@ function rateLimitRerunCommand(config, intervalMs) {
|
|
|
13191
13311
|
return `seo crawl ${config.url}${scope}${sitemap} --max-pages ${config.maxPages} --concurrency 1 --fetch-interval-cap 1 --fetch-interval-ms ${slowMs}`;
|
|
13192
13312
|
}
|
|
13193
13313
|
/** Set when the sitemaps list more URLs than the page limit let the crawl read. */
|
|
13194
|
-
function sitemapLimitCaveatInput(config, discovery, defaulted) {
|
|
13195
|
-
if (!discovery
|
|
13314
|
+
function sitemapLimitCaveatInput(config, discovery, defaulted, sample) {
|
|
13315
|
+
if (!discovery) return void 0;
|
|
13316
|
+
const truncated = discovery.roots.some((root) => root.possiblyTruncated);
|
|
13317
|
+
if (!sample && (discovery.urlsReturned < config.maxPages || !truncated)) return;
|
|
13196
13318
|
return {
|
|
13197
13319
|
maxPages: config.maxPages,
|
|
13198
13320
|
defaulted,
|
|
13199
13321
|
ceiling: MAX_CRAWL_PAGES,
|
|
13200
13322
|
health: config.strategy === "health",
|
|
13201
|
-
url: config.url
|
|
13323
|
+
url: config.url,
|
|
13324
|
+
...sample ? { sample: {
|
|
13325
|
+
...sample,
|
|
13326
|
+
readCapped: truncated
|
|
13327
|
+
} } : {}
|
|
13202
13328
|
};
|
|
13203
13329
|
}
|
|
13204
13330
|
function crawlCaveats(input) {
|
|
@@ -13210,8 +13336,9 @@ function crawlCaveats(input) {
|
|
|
13210
13336
|
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}`);
|
|
13211
13337
|
}
|
|
13212
13338
|
if (input.sitemapLimit) {
|
|
13213
|
-
const { maxPages, defaulted, ceiling, health, url } = input.sitemapLimit;
|
|
13214
|
-
|
|
13339
|
+
const { maxPages, defaulted, ceiling, health, url, sample } = input.sitemapLimit;
|
|
13340
|
+
const rerun = `Raise --max-pages, up to ${ceiling.toLocaleString("en-US")}, to check more: seo crawl ${url}${health ? " --health" : ""} --max-pages ${ceiling}`;
|
|
13341
|
+
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}`);
|
|
13215
13342
|
}
|
|
13216
13343
|
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.`);
|
|
13217
13344
|
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
@@ -14057,6 +14184,20 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
14057
14184
|
responseHeaders: z.record(z.string(), z.string()).optional(),
|
|
14058
14185
|
responseTimeMs: z.number().optional(),
|
|
14059
14186
|
sizeBytes: z.number().int().optional(),
|
|
14187
|
+
googlebotCut: z.object({
|
|
14188
|
+
limitBytes: z.number().int().positive(),
|
|
14189
|
+
bytesAfterLimit: z.number().int().nonnegative(),
|
|
14190
|
+
mainContentStartBytes: z.number().int().nonnegative().optional(),
|
|
14191
|
+
mainContentMarker: z.enum(["h1", "main"]).optional(),
|
|
14192
|
+
mainContentStart: z.enum([
|
|
14193
|
+
"before-limit",
|
|
14194
|
+
"near-limit",
|
|
14195
|
+
"after-limit",
|
|
14196
|
+
"not-found"
|
|
14197
|
+
]),
|
|
14198
|
+
visibleWordsBeforeLimit: z.number().int().nonnegative(),
|
|
14199
|
+
visibleWordsAfterLimit: z.number().int().nonnegative()
|
|
14200
|
+
}).optional(),
|
|
14060
14201
|
usedJs: z.boolean().optional(),
|
|
14061
14202
|
fetchSource: z.enum([
|
|
14062
14203
|
"cache",
|
|
@@ -15551,6 +15692,7 @@ async function snapshotFetchedPage(url, fetched) {
|
|
|
15551
15692
|
responseTimeMs: fetched.diagnostics.durationMs,
|
|
15552
15693
|
sizeBytes: Buffer.byteLength(fetched.html),
|
|
15553
15694
|
usedJs: fetched.usedJs,
|
|
15695
|
+
googlebotCut: googlebotCutEvidence(fetched.html),
|
|
15554
15696
|
fetchSource: fetched.diagnostics.source,
|
|
15555
15697
|
cacheState: fetched.diagnostics.cache,
|
|
15556
15698
|
fetchDiagnostics: fetched.diagnostics,
|
|
@@ -17996,6 +18138,74 @@ function sitemapDiscoveryStatus(roots) {
|
|
|
17996
18138
|
if (!roots.some((root) => root.dataStatus !== "unavailable")) return "unavailable";
|
|
17997
18139
|
return roots.every((root) => root.dataStatus === "complete") ? "complete" : "partial";
|
|
17998
18140
|
}
|
|
18141
|
+
/** How many sitemap URLs a crawl reads to pick its sample from. */
|
|
18142
|
+
function sitemapReadLimit(maxPages) {
|
|
18143
|
+
return Math.min(5e4, Math.max(1e4, maxPages * 10));
|
|
18144
|
+
}
|
|
18145
|
+
function sectionOf(url) {
|
|
18146
|
+
try {
|
|
18147
|
+
return new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "";
|
|
18148
|
+
} catch {
|
|
18149
|
+
return "";
|
|
18150
|
+
}
|
|
18151
|
+
}
|
|
18152
|
+
function pathDepth(url) {
|
|
18153
|
+
try {
|
|
18154
|
+
return new URL(url).pathname.split("/").filter(Boolean).length;
|
|
18155
|
+
} catch {
|
|
18156
|
+
return 0;
|
|
18157
|
+
}
|
|
18158
|
+
}
|
|
18159
|
+
/**
|
|
18160
|
+
* Picks up to `limit` URLs round-robin across sections (the first path
|
|
18161
|
+
* segment), so one large section such as a forum cannot fill a capped crawl.
|
|
18162
|
+
* Within a section, shallower paths come first, then URL order.
|
|
18163
|
+
*/
|
|
18164
|
+
function spreadSitemapSample(urls, limit) {
|
|
18165
|
+
const groups = /* @__PURE__ */ new Map();
|
|
18166
|
+
for (const url of urls) {
|
|
18167
|
+
const key = sectionOf(url);
|
|
18168
|
+
const group = groups.get(key);
|
|
18169
|
+
if (group) group.push(url);
|
|
18170
|
+
else groups.set(key, [url]);
|
|
18171
|
+
}
|
|
18172
|
+
const ordered = [...groups.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, group]) => group.map((url) => ({
|
|
18173
|
+
url,
|
|
18174
|
+
depth: pathDepth(url)
|
|
18175
|
+
})).sort((left, right) => left.depth - right.depth || (left.url < right.url ? -1 : left.url > right.url ? 1 : 0)).map((entry) => entry.url));
|
|
18176
|
+
const picked = [];
|
|
18177
|
+
for (let index = 0; picked.length < limit; index += 1) {
|
|
18178
|
+
let added = false;
|
|
18179
|
+
for (const group of ordered) {
|
|
18180
|
+
const url = group[index];
|
|
18181
|
+
if (url === void 0) continue;
|
|
18182
|
+
added = true;
|
|
18183
|
+
picked.push(url);
|
|
18184
|
+
if (picked.length >= limit) break;
|
|
18185
|
+
}
|
|
18186
|
+
if (!added) break;
|
|
18187
|
+
}
|
|
18188
|
+
return {
|
|
18189
|
+
urls: picked,
|
|
18190
|
+
sections: groups.size
|
|
18191
|
+
};
|
|
18192
|
+
}
|
|
18193
|
+
/**
|
|
18194
|
+
* The sitemap URLs a capped crawl checks, leaving room for the seed URLs, and
|
|
18195
|
+
* how they were picked when the sitemaps listed more.
|
|
18196
|
+
*/
|
|
18197
|
+
function sitemapCrawlSample(urls, maxPages, seedUrls, normalize) {
|
|
18198
|
+
const candidates = urls.filter((url) => !seedUrls.has(normalize(url) ?? url));
|
|
18199
|
+
const sample = spreadSitemapSample(candidates, Math.max(0, maxPages - seedUrls.size));
|
|
18200
|
+
return candidates.length > sample.urls.length ? {
|
|
18201
|
+
urls: sample.urls,
|
|
18202
|
+
caveat: {
|
|
18203
|
+
checked: sample.urls.length,
|
|
18204
|
+
read: candidates.length,
|
|
18205
|
+
sections: sample.sections
|
|
18206
|
+
}
|
|
18207
|
+
} : { urls: sample.urls };
|
|
18208
|
+
}
|
|
17999
18209
|
async function sitemapSeeds(input) {
|
|
18000
18210
|
const roots = [];
|
|
18001
18211
|
const urls = /* @__PURE__ */ new Set();
|
|
@@ -18075,21 +18285,6 @@ function isLikelyAsset(url) {
|
|
|
18075
18285
|
const path = new URL(url).pathname.toLowerCase();
|
|
18076
18286
|
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);
|
|
18077
18287
|
}
|
|
18078
|
-
function globMatch(pattern, value) {
|
|
18079
|
-
if (!pattern.includes("*")) return value.includes(pattern);
|
|
18080
|
-
const parts = pattern.split("*");
|
|
18081
|
-
let position = 0;
|
|
18082
|
-
for (const part of parts) {
|
|
18083
|
-
if (!part) continue;
|
|
18084
|
-
const found = value.slice(position).indexOf(part);
|
|
18085
|
-
if (found === -1) return false;
|
|
18086
|
-
position += found + part.length;
|
|
18087
|
-
}
|
|
18088
|
-
return true;
|
|
18089
|
-
}
|
|
18090
|
-
function passesFilters(url, include, exclude) {
|
|
18091
|
-
return (!include.length || include.some((pattern) => globMatch(pattern, url))) && !exclude.some((pattern) => globMatch(pattern, url));
|
|
18092
|
-
}
|
|
18093
18288
|
async function crawlSite(input, dependencies) {
|
|
18094
18289
|
const deps = resolveCrawlSiteDependencies(dependencies);
|
|
18095
18290
|
const config = assertCrawlConfigLimits(normalizeCrawlConfig(input));
|
|
@@ -18144,6 +18339,7 @@ async function crawlSite(input, dependencies) {
|
|
|
18144
18339
|
});
|
|
18145
18340
|
};
|
|
18146
18341
|
let sitemapDiscovery;
|
|
18342
|
+
let sitemapSample;
|
|
18147
18343
|
let externalLinkVerification;
|
|
18148
18344
|
let statusEventChain = Promise.resolve();
|
|
18149
18345
|
const emitStatus = (phase, event = {}) => {
|
|
@@ -18191,7 +18387,7 @@ async function crawlSite(input, dependencies) {
|
|
|
18191
18387
|
const llmsTxt = aiSignals?.signals.llmsTxt;
|
|
18192
18388
|
const healthSitemapUrls = healthStrategy && !config.sitemapUrl && !isCancelled() ? await robotsSession?.sitemapUrls(origin) : void 0;
|
|
18193
18389
|
cancelled = isCancelled();
|
|
18194
|
-
const enqueue = (value, depth) => {
|
|
18390
|
+
const enqueue = (value, depth, rank) => {
|
|
18195
18391
|
const normalized = normalizeUrl$1(value, config.url);
|
|
18196
18392
|
if (!normalized) {
|
|
18197
18393
|
recordSkip("invalid-url");
|
|
@@ -18277,7 +18473,8 @@ async function crawlSite(input, dependencies) {
|
|
|
18277
18473
|
queuedUrls += 1;
|
|
18278
18474
|
queue.push({
|
|
18279
18475
|
url: normalized,
|
|
18280
|
-
depth
|
|
18476
|
+
depth,
|
|
18477
|
+
rank
|
|
18281
18478
|
});
|
|
18282
18479
|
emitStatus("url_queued", {
|
|
18283
18480
|
url: normalized,
|
|
@@ -18297,12 +18494,16 @@ async function crawlSite(input, dependencies) {
|
|
|
18297
18494
|
const sitemap = await sitemapSeeds({
|
|
18298
18495
|
url: config.url,
|
|
18299
18496
|
sitemapUrl: config.sitemapUrl,
|
|
18300
|
-
maxPages: config.maxPages,
|
|
18497
|
+
maxPages: sitemapReadLimit(config.maxPages),
|
|
18301
18498
|
declaredSitemapUrls: aiSignals?.signals.robotsTxt?.sitemapUrls ?? healthSitemapUrls ?? [],
|
|
18302
18499
|
warnings,
|
|
18303
18500
|
fetchSitemapUrls: deps.fetchSitemapUrls
|
|
18304
18501
|
});
|
|
18305
|
-
|
|
18502
|
+
const sample = sitemapCrawlSample(sitemap.urls, config.maxPages, seedUrls, (url) => normalizeUrl$1(url, config.url));
|
|
18503
|
+
sitemapSample = sample.caveat;
|
|
18504
|
+
sample.urls.forEach((url, index) => {
|
|
18505
|
+
enqueue(url, 0, index + 1);
|
|
18506
|
+
});
|
|
18306
18507
|
sitemapDiscovery = sitemap.discovery;
|
|
18307
18508
|
}
|
|
18308
18509
|
checkMemoryPressure();
|
|
@@ -18463,7 +18664,7 @@ async function crawlSite(input, dependencies) {
|
|
|
18463
18664
|
});
|
|
18464
18665
|
continue;
|
|
18465
18666
|
}
|
|
18466
|
-
const page = result.page;
|
|
18667
|
+
const page = structuredClone(result.page);
|
|
18467
18668
|
const rendering = page.fetchDiagnostics?.rendering;
|
|
18468
18669
|
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());
|
|
18469
18670
|
if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
|
|
@@ -18733,7 +18934,7 @@ async function crawlSite(input, dependencies) {
|
|
|
18733
18934
|
queueSafetyInventoryCapped,
|
|
18734
18935
|
originBackpressureSkippedUrls,
|
|
18735
18936
|
rateLimit: rateLimit.caveatInput(config, pages.length),
|
|
18736
|
-
sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0),
|
|
18937
|
+
sitemapLimit: sitemapLimitCaveatInput(config, sitemapDiscovery, input.maxPages === void 0, sitemapSample),
|
|
18737
18938
|
memoryPressureSkippedUrls,
|
|
18738
18939
|
queryVariantSkippedUrls: queryVariantSkipped.size,
|
|
18739
18940
|
renderingFallbackUrls,
|
|
@@ -31198,6 +31399,12 @@ function plainFor(word) {
|
|
|
31198
31399
|
for (const [key, value] of Object.entries(PLAIN)) if (lower.startsWith(key.slice(0, Math.max(4, key.length - 2)))) return value;
|
|
31199
31400
|
return "a plain word";
|
|
31200
31401
|
}
|
|
31402
|
+
function swapFix(word) {
|
|
31403
|
+
const plain = plainFor(word).replace(/\s*\([^)]*\)/g, "");
|
|
31404
|
+
if (plain === "a plain word") return "Use a plain word.";
|
|
31405
|
+
if (/^(?:say|name|give)\b/.test(plain)) return `Cut "${word}", or swap it for a detail the text already gives.`;
|
|
31406
|
+
return `Use ${plain.split(", ").map((swap) => `"${swap}"`).join(" or ")}.`;
|
|
31407
|
+
}
|
|
31201
31408
|
const PHRASE_RULES = [
|
|
31202
31409
|
{
|
|
31203
31410
|
id: "chatbot-residue",
|
|
@@ -31356,7 +31563,7 @@ const PHRASE_RULES = [
|
|
|
31356
31563
|
/\bstunning(?:ly)?\b|\benchanting\b/gi
|
|
31357
31564
|
],
|
|
31358
31565
|
message: (match) => `"${match.text}" is far more common in AI text than in human writing.`,
|
|
31359
|
-
fix: (match) =>
|
|
31566
|
+
fix: (match) => swapFix(match.text.replace(/^(?:rich|a) /i, ""))
|
|
31360
31567
|
},
|
|
31361
31568
|
{
|
|
31362
31569
|
id: "banned-word",
|
|
@@ -31376,7 +31583,7 @@ const PHRASE_RULES = [
|
|
|
31376
31583
|
/\bgets? you to the next fix\b/gi
|
|
31377
31584
|
],
|
|
31378
31585
|
message: (match) => `"${match.text}" is on the house list of words to cut.`,
|
|
31379
|
-
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." :
|
|
31586
|
+
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)
|
|
31380
31587
|
},
|
|
31381
31588
|
{
|
|
31382
31589
|
id: "buzzword",
|
|
@@ -31621,7 +31828,7 @@ const PHRASE_RULES = [
|
|
|
31621
31828
|
/(?:^|[.!?:]\s+)(?:But|And) only (?:part|half|some|partly)\.(?=\s|$)/g
|
|
31622
31829
|
],
|
|
31623
31830
|
message: "A two-word denial used for rhythm. It makes the reader wait.",
|
|
31624
|
-
fix: "Join it to the sentence before,
|
|
31831
|
+
fix: "Join it to the sentence before, so the point is stated once."
|
|
31625
31832
|
},
|
|
31626
31833
|
{
|
|
31627
31834
|
id: "strawman",
|
|
@@ -32419,10 +32626,10 @@ function stockCloser(ctx) {
|
|
|
32419
32626
|
for (const block of ctx.blocks(RHYTHM_KINDS)) for (const sentence of ctx.sentences(block)) {
|
|
32420
32627
|
const text = sentence.text;
|
|
32421
32628
|
if (CLOSERS.some((re) => re.test(text))) {
|
|
32422
|
-
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
|
|
32629
|
+
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.");
|
|
32423
32630
|
continue;
|
|
32424
32631
|
}
|
|
32425
|
-
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.", "
|
|
32632
|
+
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.");
|
|
32426
32633
|
}
|
|
32427
32634
|
}
|
|
32428
32635
|
const FIGURATIVE_NOUNS = "fights?|battles?|hills?|wars?|bets?|gambles?|races?|risks?|horses?|roads?|bridges?|doors?|games?|ships?|cards?";
|
|
@@ -33212,15 +33419,24 @@ async function ahrefs(key, input, missed, totals) {
|
|
|
33212
33419
|
order_by: "refdomains_target:desc"
|
|
33213
33420
|
})).pages ?? [];
|
|
33214
33421
|
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
|
|
33215
|
-
|
|
33422
|
+
const data = await get("serp-overview/serp-overview", {
|
|
33216
33423
|
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
33217
33424
|
keyword,
|
|
33218
33425
|
country: input.country,
|
|
33219
33426
|
top_positions: String(serpDepth(input))
|
|
33220
|
-
})
|
|
33221
|
-
|
|
33222
|
-
|
|
33223
|
-
|
|
33427
|
+
});
|
|
33428
|
+
let organic = 0;
|
|
33429
|
+
return (data.positions ?? []).map((row) => {
|
|
33430
|
+
const { position, type, ...rest } = row;
|
|
33431
|
+
const kinds = Array.isArray(type) ? type.map(String) : [String(type ?? "")];
|
|
33432
|
+
return {
|
|
33433
|
+
keyword,
|
|
33434
|
+
position: kinds.includes("organic") ? ++organic : null,
|
|
33435
|
+
ahrefs_position: position ?? null,
|
|
33436
|
+
type: kinds.join(",") || null,
|
|
33437
|
+
...rest
|
|
33438
|
+
};
|
|
33439
|
+
});
|
|
33224
33440
|
});
|
|
33225
33441
|
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
33226
33442
|
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
@@ -33248,7 +33464,55 @@ function flattenIntents(row) {
|
|
|
33248
33464
|
intents: names.join(" ") || null
|
|
33249
33465
|
};
|
|
33250
33466
|
}
|
|
33467
|
+
// packages/core/src/commands/research-serp.ts
|
|
33468
|
+
const GOTO = /^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/;
|
|
33469
|
+
const hostOf$3 = (url) => {
|
|
33470
|
+
try {
|
|
33471
|
+
return new URL(url).hostname;
|
|
33472
|
+
} catch {
|
|
33473
|
+
return "";
|
|
33474
|
+
}
|
|
33475
|
+
};
|
|
33476
|
+
const text = (value) => typeof value === "string" && value ? value : null;
|
|
33477
|
+
function dataForSeoSerp(keyword, items) {
|
|
33478
|
+
const found = items.find((item) => item.type === "ai_overview");
|
|
33479
|
+
const overview = found ? {
|
|
33480
|
+
keyword,
|
|
33481
|
+
references: (found.references ?? []).slice(0, 20).map((ref) => {
|
|
33482
|
+
const url = String(ref.url ?? "");
|
|
33483
|
+
const goto = GOTO.test(url);
|
|
33484
|
+
return {
|
|
33485
|
+
domain: goto ? null : text(ref.domain) ?? (hostOf$3(url) || null),
|
|
33486
|
+
url: goto || !url ? null : url,
|
|
33487
|
+
title: text(ref.title)
|
|
33488
|
+
};
|
|
33489
|
+
})
|
|
33490
|
+
} : null;
|
|
33491
|
+
return {
|
|
33492
|
+
rows: items.filter((item) => item.type === "organic" || item.type === "featured_snippet").map((item) => {
|
|
33493
|
+
const row = {
|
|
33494
|
+
keyword,
|
|
33495
|
+
position: item.type === "organic" ? Number(item.rank_group) : null,
|
|
33496
|
+
rank_absolute: Number(item.rank_absolute),
|
|
33497
|
+
type: String(item.type),
|
|
33498
|
+
url: text(item.url),
|
|
33499
|
+
domain: text(item.domain),
|
|
33500
|
+
title: text(item.title)
|
|
33501
|
+
};
|
|
33502
|
+
if (GOTO.test(String(row.url))) {
|
|
33503
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
33504
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
33505
|
+
row.url = null;
|
|
33506
|
+
row.domain = site?.[1] ?? null;
|
|
33507
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
33508
|
+
}
|
|
33509
|
+
return row;
|
|
33510
|
+
}),
|
|
33511
|
+
overview
|
|
33512
|
+
};
|
|
33513
|
+
}
|
|
33251
33514
|
// packages/core/src/commands/research-providers.ts
|
|
33515
|
+
const RESEARCH_DEVICES = ["desktop", "mobile"];
|
|
33252
33516
|
const RESEARCH_SCOPES = [
|
|
33253
33517
|
"domain",
|
|
33254
33518
|
"prefix",
|
|
@@ -33372,7 +33636,25 @@ async function dataForSeoList([login, password], path) {
|
|
|
33372
33636
|
if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
|
|
33373
33637
|
return task.result ?? [];
|
|
33374
33638
|
}
|
|
33375
|
-
async function dataForSeoTask(
|
|
33639
|
+
async function dataForSeoTask(keys, path, task) {
|
|
33640
|
+
let spent = 0;
|
|
33641
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
33642
|
+
const done = await dataForSeoTaskOnce(keys, path, task);
|
|
33643
|
+
return {
|
|
33644
|
+
...done,
|
|
33645
|
+
cost: done.cost + spent
|
|
33646
|
+
};
|
|
33647
|
+
} catch (error) {
|
|
33648
|
+
const failed = error;
|
|
33649
|
+
spent += failed.cost ?? 0;
|
|
33650
|
+
if (!SE_ERRORS.has(failed.status ?? 0) || attempt >= SE_TRIES) throw Object.assign(error, {
|
|
33651
|
+
cost: spent,
|
|
33652
|
+
...attempt > 1 ? { tries: attempt } : {}
|
|
33653
|
+
});
|
|
33654
|
+
await wait(SE_RETRY_MS[attempt - 1] ?? 3e3);
|
|
33655
|
+
}
|
|
33656
|
+
}
|
|
33657
|
+
async function dataForSeoTaskOnce([login, password], path, task) {
|
|
33376
33658
|
const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
33377
33659
|
method: "POST",
|
|
33378
33660
|
headers: {
|
|
@@ -33387,7 +33669,10 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
33387
33669
|
partial: false,
|
|
33388
33670
|
noResults: true
|
|
33389
33671
|
};
|
|
33390
|
-
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
|
|
33672
|
+
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), {
|
|
33673
|
+
cost: result?.cost ?? 0,
|
|
33674
|
+
status: result?.status_code ?? 0
|
|
33675
|
+
});
|
|
33391
33676
|
return {
|
|
33392
33677
|
cost: result.cost ?? 0,
|
|
33393
33678
|
result: result.result?.[0] ?? null,
|
|
@@ -33396,6 +33681,9 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
33396
33681
|
}
|
|
33397
33682
|
const NO_RESULTS = 40102;
|
|
33398
33683
|
const PARTIAL = 40106;
|
|
33684
|
+
const SE_ERRORS = /* @__PURE__ */ new Set([40101, 5e4]);
|
|
33685
|
+
const SE_TRIES = 3;
|
|
33686
|
+
const SE_RETRY_MS = [1e3, 3e3];
|
|
33399
33687
|
const queued = (task) => ({
|
|
33400
33688
|
id: String(task.id ?? ""),
|
|
33401
33689
|
status_code: Number(task.status_code ?? 0),
|
|
@@ -33435,6 +33723,7 @@ async function dataForSeo(keys, input) {
|
|
|
33435
33723
|
noData: withoutData(input, rows, spent.missed, 700),
|
|
33436
33724
|
...spent.available !== void 0 ? { available: spent.available } : {},
|
|
33437
33725
|
...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
|
|
33726
|
+
...spent.aiOverviews ? { aiOverviews: spent.aiOverviews } : {},
|
|
33438
33727
|
...spent.rankFailed ? { rankFailed: true } : {}
|
|
33439
33728
|
};
|
|
33440
33729
|
}
|
|
@@ -33523,7 +33812,9 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
33523
33812
|
}
|
|
33524
33813
|
return items.map((item) => pick(item, {
|
|
33525
33814
|
keyword: "keyword_data.keyword",
|
|
33526
|
-
position: "ranked_serp_element.serp_item.
|
|
33815
|
+
position: "ranked_serp_element.serp_item.rank_group",
|
|
33816
|
+
rank_absolute: "ranked_serp_element.serp_item.rank_absolute",
|
|
33817
|
+
type: "ranked_serp_element.serp_item.type",
|
|
33527
33818
|
url: "ranked_serp_element.serp_item.url",
|
|
33528
33819
|
search_volume: "keyword_data.keyword_info.search_volume",
|
|
33529
33820
|
etv: "ranked_serp_element.serp_item.etv",
|
|
@@ -33533,28 +33824,13 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
33533
33824
|
}
|
|
33534
33825
|
if (input.action === "serp") {
|
|
33535
33826
|
const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
|
|
33536
|
-
|
|
33827
|
+
const { rows, overview } = dataForSeoSerp(keyword, await post("v3/serp/google/organic/live/advanced", {
|
|
33537
33828
|
keyword,
|
|
33538
|
-
depth: serpDepth(input)
|
|
33539
|
-
|
|
33540
|
-
|
|
33541
|
-
|
|
33542
|
-
|
|
33543
|
-
domain: "domain",
|
|
33544
|
-
title: "title"
|
|
33545
|
-
});
|
|
33546
|
-
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
33547
|
-
const crumb = String(item.breadcrumb ?? "");
|
|
33548
|
-
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
33549
|
-
row.url = null;
|
|
33550
|
-
row.domain = site?.[1] ?? null;
|
|
33551
|
-
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
33552
|
-
}
|
|
33553
|
-
return {
|
|
33554
|
-
keyword,
|
|
33555
|
-
...row
|
|
33556
|
-
};
|
|
33557
|
-
});
|
|
33829
|
+
depth: serpDepth(input),
|
|
33830
|
+
device: input.device ?? "desktop"
|
|
33831
|
+
}, 2));
|
|
33832
|
+
if (overview) spent.aiOverviews = [...spent.aiOverviews ?? [], overview];
|
|
33833
|
+
return rows;
|
|
33558
33834
|
});
|
|
33559
33835
|
return input.noRank ? rows : withDomainRank(keys, rows, spent);
|
|
33560
33836
|
}
|
|
@@ -33672,7 +33948,7 @@ async function serpOverlap(provider, headers, input) {
|
|
|
33672
33948
|
const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
|
|
33673
33949
|
const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
|
|
33674
33950
|
const top = new Map(keywords.map((keyword) => {
|
|
33675
|
-
const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
|
|
33951
|
+
const rows = serps.rows.filter((row) => row.keyword === keyword && row.type !== "featured_snippet").slice(0, OVERLAP_DEPTH);
|
|
33676
33952
|
const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
|
|
33677
33953
|
const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
|
|
33678
33954
|
return [keyword, {
|
|
@@ -37779,6 +38055,121 @@ async function runBingReport(input, deps) {
|
|
|
37779
38055
|
data: report
|
|
37780
38056
|
};
|
|
37781
38057
|
}
|
|
38058
|
+
const DEVICES = ["desktop", "mobile"];
|
|
38059
|
+
const DEPTH = 10;
|
|
38060
|
+
const bare = (host) => host.toLowerCase().replace(/^www\./, "");
|
|
38061
|
+
function key(url) {
|
|
38062
|
+
try {
|
|
38063
|
+
const parsed = new URL(url);
|
|
38064
|
+
return `${bare(parsed.hostname)}${parsed.pathname.replace(/\/+$/, "")}`.toLowerCase();
|
|
38065
|
+
} catch {
|
|
38066
|
+
return "";
|
|
38067
|
+
}
|
|
38068
|
+
}
|
|
38069
|
+
function hintKey(hint) {
|
|
38070
|
+
const parts = hint.split("›").map((part) => part.trim());
|
|
38071
|
+
if (!parts[0] || parts.some((part) => /^\.\.\.$|…/.test(part))) return null;
|
|
38072
|
+
return `${bare(parts[0])}${parts.length > 1 ? `/${parts.slice(1).join("/")}` : ""}`.toLowerCase();
|
|
38073
|
+
}
|
|
38074
|
+
function phraseFrom(text, words = 2) {
|
|
38075
|
+
const cleaned = (text ?? "").split(/\s+[|\u2013\u2014-]\s+/)[0]?.replace(/["“”]/g, "").trim();
|
|
38076
|
+
if (!cleaned || cleaned.split(/\s+/).length < words) return null;
|
|
38077
|
+
return cleaned.split(/\s+/).slice(0, 12).join(" ");
|
|
38078
|
+
}
|
|
38079
|
+
async function runIndexCheck(input, headers) {
|
|
38080
|
+
let target;
|
|
38081
|
+
try {
|
|
38082
|
+
target = new URL(/^https?:\/\//i.test(input.url) ? input.url : `https://${input.url}`);
|
|
38083
|
+
} catch {
|
|
38084
|
+
throw new CommandError("INVALID_INPUT", "index_check needs a page URL as target.");
|
|
38085
|
+
}
|
|
38086
|
+
const keys = dataForSeoKeys(headers);
|
|
38087
|
+
const country = input.country ?? "us";
|
|
38088
|
+
const market = dataForSeoMarket(country, dataForSeoLanguage(country));
|
|
38089
|
+
const page = key(target.href);
|
|
38090
|
+
const host = bare(target.hostname);
|
|
38091
|
+
let phrases = (input.phrases ?? []).slice(0, 2);
|
|
38092
|
+
let phraseSource = phrases.length ? "given" : null;
|
|
38093
|
+
if (!phrases.length) {
|
|
38094
|
+
const live = await liveCheck(target.href).catch(() => null);
|
|
38095
|
+
const sentence = (live?.last?.description ?? "").split(/(?<=[.!?])\s/)[0] ?? "";
|
|
38096
|
+
const found = [phraseFrom(live?.last?.h1 ?? null) ?? phraseFrom(live?.last?.title ?? null), phraseFrom(sentence.replace(/[.!?]$/, ""), 5)].filter((phrase) => Boolean(phrase));
|
|
38097
|
+
phrases = [...new Set(found)].slice(0, 2);
|
|
38098
|
+
phraseSource = phrases.length ? "the page’s H1 or title and its meta description" : null;
|
|
38099
|
+
}
|
|
38100
|
+
const queries = [`site:${host}${target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "")}`, ...phrases.map((phrase) => `"${phrase.replace(/"/g, "")}"`)];
|
|
38101
|
+
const rows = [];
|
|
38102
|
+
let cost = 0;
|
|
38103
|
+
for (const query of queries) for (const device of DEVICES) {
|
|
38104
|
+
const row = {
|
|
38105
|
+
query,
|
|
38106
|
+
device,
|
|
38107
|
+
result: "empty",
|
|
38108
|
+
position: null,
|
|
38109
|
+
rank_absolute: null,
|
|
38110
|
+
results: 0,
|
|
38111
|
+
top_domains: "",
|
|
38112
|
+
error: null
|
|
38113
|
+
};
|
|
38114
|
+
try {
|
|
38115
|
+
const done = await dataForSeoTask(keys, "v3/serp/google/organic/live/advanced", {
|
|
38116
|
+
...market,
|
|
38117
|
+
keyword: query,
|
|
38118
|
+
depth: DEPTH,
|
|
38119
|
+
device
|
|
38120
|
+
});
|
|
38121
|
+
cost += done.cost;
|
|
38122
|
+
const results = dataForSeoSerp(query, done.result?.items ?? []).rows;
|
|
38123
|
+
row.results = results.length;
|
|
38124
|
+
row.top_domains = [...new Set(results.map((r) => String(r.domain ?? "?")))].slice(0, 3).join(", ");
|
|
38125
|
+
const onSite = results.filter((r) => {
|
|
38126
|
+
const domain = bare(String(r.domain ?? ""));
|
|
38127
|
+
return domain === host || domain.endsWith(`.${host}`);
|
|
38128
|
+
});
|
|
38129
|
+
const match = onSite.find((r) => typeof r.url === "string" && key(r.url) === page || typeof r.url_hint === "string" && hintKey(r.url_hint) === page);
|
|
38130
|
+
if (match) {
|
|
38131
|
+
row.result = "page";
|
|
38132
|
+
row.position = typeof match.position === "number" ? match.position : null;
|
|
38133
|
+
row.rank_absolute = Number(match.rank_absolute) || null;
|
|
38134
|
+
} 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";
|
|
38135
|
+
else if (onSite.length) row.result = "other_pages";
|
|
38136
|
+
else if (results.length) row.result = query.startsWith("site:") ? "fallback" : "other_sites";
|
|
38137
|
+
} catch (error) {
|
|
38138
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
38139
|
+
cost += error.cost ?? 0;
|
|
38140
|
+
row.result = "error";
|
|
38141
|
+
row.error = error.message;
|
|
38142
|
+
}
|
|
38143
|
+
rows.push(row);
|
|
38144
|
+
}
|
|
38145
|
+
const has = (result) => rows.some((row) => row.result === result);
|
|
38146
|
+
const verdict = has("page") ? "indexed" : has("error") || has("unresolved") ? "unknown" : "not_found";
|
|
38147
|
+
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.`;
|
|
38148
|
+
const usd = Math.round(cost * 1e4) / 1e4;
|
|
38149
|
+
return {
|
|
38150
|
+
text: [
|
|
38151
|
+
`Index check for ${target.href} (${country}, desktop and mobile): ${verdict.replace("_", " ")}.`,
|
|
38152
|
+
reason,
|
|
38153
|
+
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.",
|
|
38154
|
+
"",
|
|
38155
|
+
...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}` : ""}`),
|
|
38156
|
+
"",
|
|
38157
|
+
"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.",
|
|
38158
|
+
`DataForSEO cost: $${usd}.`
|
|
38159
|
+
].join("\n"),
|
|
38160
|
+
data: {
|
|
38161
|
+
provider: "dataforseo",
|
|
38162
|
+
action: "index_check",
|
|
38163
|
+
url: target.href,
|
|
38164
|
+
country,
|
|
38165
|
+
verdict,
|
|
38166
|
+
reason,
|
|
38167
|
+
phrases,
|
|
38168
|
+
checks: rows,
|
|
38169
|
+
costUsd: usd
|
|
38170
|
+
}
|
|
38171
|
+
};
|
|
38172
|
+
}
|
|
37782
38173
|
// packages/core/src/commands/research-cache.ts
|
|
37783
38174
|
const RESEARCH_TTL_SECONDS = 86400;
|
|
37784
38175
|
function workersResearchStore() {
|
|
@@ -37823,10 +38214,11 @@ const RESEARCH_ACTIONS = [
|
|
|
37823
38214
|
"top_pages",
|
|
37824
38215
|
"pages_by_backlinks",
|
|
37825
38216
|
"keyword_ideas",
|
|
37826
|
-
"serp_overlap"
|
|
38217
|
+
"serp_overlap",
|
|
38218
|
+
"index_check"
|
|
37827
38219
|
];
|
|
37828
38220
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
37829
|
-
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.`;
|
|
38221
|
+
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.`;
|
|
37830
38222
|
const GENERIC_PLATFORMS = [
|
|
37831
38223
|
"youtube.com",
|
|
37832
38224
|
"facebook.com",
|
|
@@ -37888,6 +38280,17 @@ async function runResearch(input, keys, options) {
|
|
|
37888
38280
|
const offset = input.offset ?? 0;
|
|
37889
38281
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
37890
38282
|
const provider = chooseProvider(input.provider, keys);
|
|
38283
|
+
if (input.action === "index_check") {
|
|
38284
|
+
if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
|
|
38285
|
+
return runIndexCheck({
|
|
38286
|
+
url: target,
|
|
38287
|
+
phrases: input.keywords,
|
|
38288
|
+
country
|
|
38289
|
+
}, keys);
|
|
38290
|
+
}
|
|
38291
|
+
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.");
|
|
38292
|
+
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.");
|
|
38293
|
+
const device = input.action === "serp" ? input.device ?? "desktop" : null;
|
|
37891
38294
|
const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
37892
38295
|
const scope = [
|
|
37893
38296
|
"ranked_keywords",
|
|
@@ -37903,7 +38306,8 @@ async function runResearch(input, keys, options) {
|
|
|
37903
38306
|
...scope ? { scope } : {},
|
|
37904
38307
|
competitors: input.competitors ?? [],
|
|
37905
38308
|
country,
|
|
37906
|
-
...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
|
|
38309
|
+
...input.action === "serp" ? { depth: input.depth ?? 20 } : {},
|
|
38310
|
+
...device === "mobile" ? { device } : {}
|
|
37907
38311
|
};
|
|
37908
38312
|
const store = options.store ?? workersResearchStore();
|
|
37909
38313
|
const key = await researchKey([
|
|
@@ -37921,10 +38325,10 @@ async function runResearch(input, keys, options) {
|
|
|
37921
38325
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
37922
38326
|
result
|
|
37923
38327
|
});
|
|
37924
|
-
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
|
|
38328
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews } = result;
|
|
37925
38329
|
const costUsd = saved ? 0 : paid;
|
|
37926
38330
|
const order = new Map(request.keywords.map((word, index) => [word, index]));
|
|
37927
|
-
const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ??
|
|
38331
|
+
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;
|
|
37928
38332
|
const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
|
|
37929
38333
|
rows: found,
|
|
37930
38334
|
note: null
|
|
@@ -37942,7 +38346,7 @@ async function runResearch(input, keys, options) {
|
|
|
37942
38346
|
const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
|
|
37943
38347
|
return {
|
|
37944
38348
|
text: rowsText({
|
|
37945
|
-
title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
|
|
38349
|
+
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}` : ""}`}.`,
|
|
37946
38350
|
rows: rated.rows,
|
|
37947
38351
|
offset,
|
|
37948
38352
|
limit: size,
|
|
@@ -37951,6 +38355,8 @@ async function runResearch(input, keys, options) {
|
|
|
37951
38355
|
`${provider} estimates, in the provider’s own units.`,
|
|
37952
38356
|
...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." : ""}`] : [],
|
|
37953
38357
|
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
|
|
38358
|
+
...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
|
|
38359
|
+
...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
|
|
37954
38360
|
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
37955
38361
|
...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
|
|
37956
38362
|
...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.`] : [],
|
|
@@ -37984,6 +38390,8 @@ async function runResearch(input, keys, options) {
|
|
|
37984
38390
|
available: available ?? null,
|
|
37985
38391
|
country,
|
|
37986
38392
|
language,
|
|
38393
|
+
...device ? { device: provider === "dataforseo" ? device : null } : {},
|
|
38394
|
+
...input.action === "serp" && provider === "dataforseo" ? { aiOverviews: aiOverviews ?? [] } : {},
|
|
37987
38395
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
37988
38396
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
37989
38397
|
leftOut: note,
|
|
@@ -38007,6 +38415,17 @@ async function runResearch(input, keys, options) {
|
|
|
38007
38415
|
}
|
|
38008
38416
|
};
|
|
38009
38417
|
}
|
|
38418
|
+
function positionNote(provider) {
|
|
38419
|
+
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.";
|
|
38420
|
+
}
|
|
38421
|
+
function aiOverviewNote(overviews, keywords) {
|
|
38422
|
+
const each = overviews.map((overview) => {
|
|
38423
|
+
const sites = [...new Set(overview.references.map((ref) => ref.domain).filter((domain) => Boolean(domain)))];
|
|
38424
|
+
const unknown = overview.references.filter((ref) => !ref.domain).length;
|
|
38425
|
+
return `${overview.keyword}: ${overview.references.length} sources${sites.length ? ` (${sites.slice(0, 6).join(", ")}${sites.length > 6 ? ", …" : ""})` : ""}${unknown ? `, ${unknown} behind Google redirect links` : ""}`;
|
|
38426
|
+
});
|
|
38427
|
+
return `AI Overview shown for ${overviews.length} of ${keywords} keyword${keywords === 1 ? "" : "s"}. ${each.join("; ")}. The full list is in the JSON as aiOverviews.`;
|
|
38428
|
+
}
|
|
38010
38429
|
const LINK_ACTIONS = [
|
|
38011
38430
|
"backlinks",
|
|
38012
38431
|
"pages_by_backlinks",
|
|
@@ -38634,7 +39053,7 @@ const percent = (site) => {
|
|
|
38634
39053
|
const clicks = site.clicks;
|
|
38635
39054
|
return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
|
|
38636
39055
|
};
|
|
38637
|
-
function issues(site, startMs, endMs) {
|
|
39056
|
+
function issues(site, startMs, endMs, first = false) {
|
|
38638
39057
|
const found = [];
|
|
38639
39058
|
if (site.index?.dropped) found.push({
|
|
38640
39059
|
rank: 0,
|
|
@@ -38656,11 +39075,11 @@ function issues(site, startMs, endMs) {
|
|
|
38656
39075
|
});
|
|
38657
39076
|
if (crawled && crawl.new_issues > 0) found.push({
|
|
38658
39077
|
rank: 3,
|
|
38659
|
-
heading: plural$1(crawl.new_issues, "new issue")
|
|
39078
|
+
heading: plural$1(crawl.new_issues, first ? "open finding" : "new issue")
|
|
38660
39079
|
});
|
|
38661
39080
|
return found;
|
|
38662
39081
|
}
|
|
38663
|
-
function siteLines(site, startMs, endMs) {
|
|
39082
|
+
function siteLines(site, startMs, endMs, first = false) {
|
|
38664
39083
|
const lines = [];
|
|
38665
39084
|
const index = site.index;
|
|
38666
39085
|
if (!index) lines.push("Google index: no checks ran this week yet.");
|
|
@@ -38677,7 +39096,7 @@ function siteLines(site, startMs, endMs) {
|
|
|
38677
39096
|
if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural$1(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
|
|
38678
39097
|
else if (crawled) {
|
|
38679
39098
|
const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
|
|
38680
|
-
lines.push(crawl.new_issues ? `Weekly crawl: ${plural$1(crawl.pages, "page")}, ${plural$1(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural$1(crawl.pages, "page")}, no new issues.`);
|
|
39099
|
+
lines.push(crawl.new_issues ? `Weekly crawl: ${plural$1(crawl.pages, "page")}, ${plural$1(crawl.new_issues, first ? "open finding" : "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural$1(crawl.pages, "page")}, no new issues.`);
|
|
38681
39100
|
} 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.`);
|
|
38682
39101
|
else lines.push("Weekly crawl: the first crawl is still running.");
|
|
38683
39102
|
const clicks = site.clicks;
|
|
@@ -38745,7 +39164,7 @@ function weekMarkdown(data) {
|
|
|
38745
39164
|
const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
|
|
38746
39165
|
const rows = data.sites.map((site) => ({
|
|
38747
39166
|
site,
|
|
38748
|
-
found: issues(site, startMs, endMs),
|
|
39167
|
+
found: issues(site, startMs, endMs, data.first),
|
|
38749
39168
|
waiting: !site.crawl || !site.index
|
|
38750
39169
|
}));
|
|
38751
39170
|
const attention = rows.filter((row) => row.found.length);
|
|
@@ -38757,7 +39176,7 @@ function weekMarkdown(data) {
|
|
|
38757
39176
|
"",
|
|
38758
39177
|
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."
|
|
38759
39178
|
];
|
|
38760
|
-
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})`] : []);
|
|
39179
|
+
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})`] : []);
|
|
38761
39180
|
return out.join("\n");
|
|
38762
39181
|
}
|
|
38763
39182
|
async function runAllSites(deps, sites) {
|
|
@@ -38995,6 +39414,7 @@ const SHARED_TOOLS = [
|
|
|
38995
39414
|
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."),
|
|
38996
39415
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
38997
39416
|
depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
|
|
39417
|
+
device: z.enum(RESEARCH_DEVICES).optional().describe("serp only: desktop (default) or mobile. Google ranks pages differently on each. DataForSEO only."),
|
|
38998
39418
|
...detail
|
|
38999
39419
|
}),
|
|
39000
39420
|
run: async (surface, input) => {
|