seo 0.17.1 → 0.17.3
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 +89 -89
- package/dist/cli.js.map +1 -1
- package/dist/{dist-B9mN7y6S.js → dist-BNTdDj1i.js} +1 -1
- package/dist/{dist-BjB7Lfd6.js → dist-CSPI6SOD.js} +106 -105
- package/dist/dist-CSPI6SOD.js.map +1 -0
- package/dist/{esm-G8B5TPzB.js → esm-HKeFfXOD.js} +2 -2
- package/dist/{esm-G8B5TPzB.js.map → esm-HKeFfXOD.js.map} +1 -1
- package/dist/index.d.ts +625 -353
- package/dist/index.js +720 -124
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +720 -123
- 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-HKeFfXOD.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() {
|
|
@@ -47650,6 +48041,46 @@ async function saveResearch(store, key, saved) {
|
|
|
47650
48041
|
if (!store) return;
|
|
47651
48042
|
await store.put(key, JSON.stringify(saved), RESEARCH_TTL_SECONDS).catch(() => void 0);
|
|
47652
48043
|
}
|
|
48044
|
+
// packages/core/dist/commands/research-installed.js
|
|
48045
|
+
async function installedSerpResearch(provider, input, serp) {
|
|
48046
|
+
if (!serp) throw new CommandError("INVALID_INPUT", `${provider} is not a built-in provider. Here the providers are dataforseo and ahrefs; installed provider packages run only in the seo CLI and the local MCP server (seo mcp serve).`);
|
|
48047
|
+
if (input.keywords.length === 0) throw new CommandError("INVALID_INPUT", "serp needs keywords.");
|
|
48048
|
+
const keywords = input.keywords.slice(0, serpKeywords(input));
|
|
48049
|
+
const language = dataForSeoMarket(input.country, null).language_code;
|
|
48050
|
+
const missed = [];
|
|
48051
|
+
const warnings = [];
|
|
48052
|
+
let costMicros = 0;
|
|
48053
|
+
let partial = false;
|
|
48054
|
+
let depth = serpDepth(input);
|
|
48055
|
+
const rows = await eachKeyword(keywords, missed, async (keyword) => {
|
|
48056
|
+
const got = await serp({
|
|
48057
|
+
provider,
|
|
48058
|
+
keyword,
|
|
48059
|
+
country: input.country,
|
|
48060
|
+
language,
|
|
48061
|
+
device: input.device ?? "desktop",
|
|
48062
|
+
depth: serpDepth(input)
|
|
48063
|
+
});
|
|
48064
|
+
depth = Math.min(depth, got.depth);
|
|
48065
|
+
costMicros = costMicros === null || got.costMicros === null ? null : costMicros + got.costMicros;
|
|
48066
|
+
partial ||= got.partial;
|
|
48067
|
+
warnings.push(...got.warnings.map((warning) => `${keyword}: ${warning}`));
|
|
48068
|
+
return got.rows.map((row) => ({
|
|
48069
|
+
keyword,
|
|
48070
|
+
...row
|
|
48071
|
+
}));
|
|
48072
|
+
});
|
|
48073
|
+
const answered = new Set(rows.map((row) => row.keyword));
|
|
48074
|
+
if (depth < serpDepth(input)) warnings.unshift(`The package reads at most ${depth} results a keyword, so this read ${depth} of the ${serpDepth(input)} asked for.`);
|
|
48075
|
+
return {
|
|
48076
|
+
rows,
|
|
48077
|
+
costUsd: costMicros === null ? null : Number((costMicros / 1e6).toFixed(6)),
|
|
48078
|
+
partial,
|
|
48079
|
+
missed,
|
|
48080
|
+
noData: keywords.filter((keyword) => !missed.includes(keyword) && !answered.has(keyword)),
|
|
48081
|
+
warnings
|
|
48082
|
+
};
|
|
48083
|
+
}
|
|
47653
48084
|
// packages/core/dist/commands/research.js
|
|
47654
48085
|
const RESEARCH_ACTIONS = [
|
|
47655
48086
|
"keyword_metrics",
|
|
@@ -47662,10 +48093,11 @@ const RESEARCH_ACTIONS = [
|
|
|
47662
48093
|
"top_pages",
|
|
47663
48094
|
"pages_by_backlinks",
|
|
47664
48095
|
"keyword_ideas",
|
|
47665
|
-
"serp_overlap"
|
|
48096
|
+
"serp_overlap",
|
|
48097
|
+
"index_check"
|
|
47666
48098
|
];
|
|
47667
48099
|
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.`;
|
|
48100
|
+
const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. In the CLI and the local MCP server, serp can also use a provider package installed on this machine that supplies live search results: pass its id as provider. 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
48101
|
const GENERIC_PLATFORMS = [
|
|
47670
48102
|
"youtube.com",
|
|
47671
48103
|
"facebook.com",
|
|
@@ -47727,6 +48159,19 @@ async function runResearch(input, keys, options) {
|
|
|
47727
48159
|
const offset = input.offset ?? 0;
|
|
47728
48160
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
47729
48161
|
const provider = chooseProvider(input.provider, keys);
|
|
48162
|
+
const builtIn = RESEARCH_PROVIDERS.includes(provider) ? provider : null;
|
|
48163
|
+
if (!builtIn && input.action !== "serp") throw new CommandError("INVALID_INPUT", `An installed provider answers serp only; ${input.action} needs a built-in provider.`);
|
|
48164
|
+
if (input.action === "index_check") {
|
|
48165
|
+
if (provider === "ahrefs") throw new CommandError("INVALID_INPUT", "index_check uses DataForSEO: it runs live Google searches, which Ahrefs does not offer.");
|
|
48166
|
+
return runIndexCheck({
|
|
48167
|
+
url: target,
|
|
48168
|
+
phrases: input.keywords,
|
|
48169
|
+
country
|
|
48170
|
+
}, keys);
|
|
48171
|
+
}
|
|
48172
|
+
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.");
|
|
48173
|
+
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.");
|
|
48174
|
+
const device = input.action === "serp" ? input.device ?? "desktop" : null;
|
|
47730
48175
|
const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
47731
48176
|
const scope = [
|
|
47732
48177
|
"ranked_keywords",
|
|
@@ -47742,28 +48187,32 @@ async function runResearch(input, keys, options) {
|
|
|
47742
48187
|
...scope ? { scope } : {},
|
|
47743
48188
|
competitors: input.competitors ?? [],
|
|
47744
48189
|
country,
|
|
47745
|
-
...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
|
|
48190
|
+
...input.action === "serp" ? { depth: input.depth ?? 20 } : {},
|
|
48191
|
+
...device === "mobile" ? { device } : {}
|
|
47746
48192
|
};
|
|
47747
48193
|
const store = options.store ?? workersResearchStore();
|
|
47748
48194
|
const key = await researchKey([
|
|
47749
48195
|
provider,
|
|
47750
|
-
|
|
48196
|
+
builtIn ? keys.get(KEY_HEADER[builtIn]) : null,
|
|
47751
48197
|
request
|
|
47752
48198
|
]);
|
|
47753
48199
|
const saved = await readResearch(store, key, limit);
|
|
47754
|
-
const result = saved?.result ?? await research(
|
|
48200
|
+
const result = saved?.result ?? (builtIn ? await research(builtIn, keys, {
|
|
47755
48201
|
...request,
|
|
47756
48202
|
limit
|
|
47757
|
-
})
|
|
48203
|
+
}) : await installedSerpResearch(provider, {
|
|
48204
|
+
...request,
|
|
48205
|
+
limit
|
|
48206
|
+
}, options.installedSerp));
|
|
47758
48207
|
if (!saved) await saveResearch(store, key, {
|
|
47759
48208
|
limit,
|
|
47760
48209
|
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47761
48210
|
result
|
|
47762
48211
|
});
|
|
47763
|
-
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
|
|
48212
|
+
const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared, aiOverviews, warnings } = result;
|
|
47764
48213
|
const costUsd = saved ? 0 : paid;
|
|
47765
48214
|
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 ??
|
|
48215
|
+
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
48216
|
const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
|
|
47768
48217
|
rows: found,
|
|
47769
48218
|
note: null
|
|
@@ -47781,7 +48230,7 @@ async function runResearch(input, keys, options) {
|
|
|
47781
48230
|
const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
|
|
47782
48231
|
return {
|
|
47783
48232
|
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}` : ""}`}.`,
|
|
48233
|
+
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
48234
|
rows: rated.rows,
|
|
47786
48235
|
offset,
|
|
47787
48236
|
limit: size,
|
|
@@ -47789,16 +48238,19 @@ async function runResearch(input, keys, options) {
|
|
|
47789
48238
|
notes: [
|
|
47790
48239
|
`${provider} estimates, in the provider’s own units.`,
|
|
47791
48240
|
...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
|
-
...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[
|
|
48241
|
+
...VOLUME_ACTIONS.includes(input.action) && builtIn ? [`Volume model: ${VOLUME_MODEL[builtIn]} ${VOLUMES_DIFFER}`] : [],
|
|
48242
|
+
...input.action === "serp" || input.action === "ranked_keywords" ? [positionNote(provider)] : [],
|
|
48243
|
+
...aiOverviews?.length ? [aiOverviewNote(aiOverviews, request.keywords.length)] : input.action === "serp" && provider === "dataforseo" && rows.length ? ["No AI Overview on these results pages."] : [],
|
|
47793
48244
|
...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
|
|
47794
|
-
...siteTotal ? [siteTotalNote(
|
|
48245
|
+
...siteTotal && builtIn ? [siteTotalNote(builtIn, siteTotal, rows.length)] : [],
|
|
47795
48246
|
...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.`] : [],
|
|
47796
48247
|
...!found.length && scope ? [emptyNote(provider, input.action)] : [],
|
|
47797
48248
|
...input.action === "serp" && provider === "dataforseo" ? [rankFailed ? "domain_rank is missing: the DataForSEO rank call failed, so it is unknown, not zero." : "domain_rank is DataForSEO's own authority score for the domain, 0 to 100. It is not Ahrefs' Domain Rating."] : [],
|
|
47798
48249
|
...input.action === "serp" && !drKey && provider === "dataforseo" ? ["No domain_rating column: Domain Rating by Ahrefs needs an Ahrefs key (or the hosted MCP at https://seoskill.dev/mcp, which adds it)."] : [],
|
|
47799
48250
|
...store && rated.rows.length > 50 ? ["The result is kept for a day, so the next pages, and the same rows in full, cost nothing more."] : [],
|
|
47800
|
-
...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
47801
|
-
...partial ? [
|
|
48251
|
+
...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [builtIn ? `DataForSEO cost: $${costUsd}.` : `Cost the ${provider} package reports: $${costUsd}.`] : [],
|
|
48252
|
+
...partial ? [`Partial: ${builtIn ? "DataForSEO" : provider} could not fetch some pages of results, so rows may be missing.`] : [],
|
|
48253
|
+
...warnings?.length ? [`${provider} warnings: ${warnings.join(" ")}`] : [],
|
|
47802
48254
|
...missed.length ? [`Partial: ${provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
47803
48255
|
...noData.length ? [noDataNote(provider, input.action, noData)] : [],
|
|
47804
48256
|
...byDr ? [rows.length > GAP_RATED ? `Sorted by Domain Rating for the top ${GAP_RATED} rows by DataForSEO rank; the ${rows.length - GAP_RATED} rows after them follow by rank, with no Domain Rating.` : "Sorted by Domain Rating, then DataForSEO rank."] : [],
|
|
@@ -47816,13 +48268,15 @@ async function runResearch(input, keys, options) {
|
|
|
47816
48268
|
action: input.action,
|
|
47817
48269
|
target: target || null,
|
|
47818
48270
|
scope,
|
|
47819
|
-
siteTotal: siteTotal ? {
|
|
48271
|
+
siteTotal: siteTotal && builtIn ? {
|
|
47820
48272
|
...siteTotal,
|
|
47821
|
-
model: TRAFFIC_MODEL[
|
|
48273
|
+
model: TRAFFIC_MODEL[builtIn]
|
|
47822
48274
|
} : null,
|
|
47823
48275
|
available: available ?? null,
|
|
47824
48276
|
country,
|
|
47825
48277
|
language,
|
|
48278
|
+
...device ? { device: provider === "dataforseo" ? device : null } : {},
|
|
48279
|
+
...input.action === "serp" && provider === "dataforseo" ? { aiOverviews: aiOverviews ?? [] } : {},
|
|
47826
48280
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
47827
48281
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
47828
48282
|
leftOut: note,
|
|
@@ -47831,6 +48285,7 @@ async function runResearch(input, keys, options) {
|
|
|
47831
48285
|
partial,
|
|
47832
48286
|
missedKeywords: missed,
|
|
47833
48287
|
noDataKeywords: noData,
|
|
48288
|
+
...warnings?.length ? { warnings } : {},
|
|
47834
48289
|
...input.action === "link_gap" ? {
|
|
47835
48290
|
competitors: input.competitors ?? [],
|
|
47836
48291
|
cutAt: floor ?? null,
|
|
@@ -47846,6 +48301,18 @@ async function runResearch(input, keys, options) {
|
|
|
47846
48301
|
}
|
|
47847
48302
|
};
|
|
47848
48303
|
}
|
|
48304
|
+
function positionNote(provider) {
|
|
48305
|
+
if (provider !== "dataforseo" && provider !== "ahrefs") return `position is the organic position the ${provider} package reports (its rankGroup); rank_absolute is its rank among every block on the page, and type is featured_snippet for a featured snippet, which has no organic position.`;
|
|
48306
|
+
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.";
|
|
48307
|
+
}
|
|
48308
|
+
function aiOverviewNote(overviews, keywords) {
|
|
48309
|
+
const each = overviews.map((overview) => {
|
|
48310
|
+
const sites = [...new Set(overview.references.map((ref) => ref.domain).filter((domain) => Boolean(domain)))];
|
|
48311
|
+
const unknown = overview.references.filter((ref) => !ref.domain).length;
|
|
48312
|
+
return `${overview.keyword}: ${overview.references.length} sources${sites.length ? ` (${sites.slice(0, 6).join(", ")}${sites.length > 6 ? ", …" : ""})` : ""}${unknown ? `, ${unknown} behind Google redirect links` : ""}`;
|
|
48313
|
+
});
|
|
48314
|
+
return `AI Overview shown for ${overviews.length} of ${keywords} keyword${keywords === 1 ? "" : "s"}. ${each.join("; ")}. The full list is in the JSON as aiOverviews.`;
|
|
48315
|
+
}
|
|
47849
48316
|
const LINK_ACTIONS = [
|
|
47850
48317
|
"backlinks",
|
|
47851
48318
|
"pages_by_backlinks",
|
|
@@ -48502,7 +48969,7 @@ const percent = (site) => {
|
|
|
48502
48969
|
const clicks = site.clicks;
|
|
48503
48970
|
return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
|
|
48504
48971
|
};
|
|
48505
|
-
function issues(site, startMs, endMs) {
|
|
48972
|
+
function issues(site, startMs, endMs, first = false) {
|
|
48506
48973
|
const found = [];
|
|
48507
48974
|
if (site.index?.dropped) found.push({
|
|
48508
48975
|
rank: 0,
|
|
@@ -48524,11 +48991,11 @@ function issues(site, startMs, endMs) {
|
|
|
48524
48991
|
});
|
|
48525
48992
|
if (crawled && crawl.new_issues > 0) found.push({
|
|
48526
48993
|
rank: 3,
|
|
48527
|
-
heading: plural(crawl.new_issues, "new issue")
|
|
48994
|
+
heading: plural(crawl.new_issues, first ? "open finding" : "new issue")
|
|
48528
48995
|
});
|
|
48529
48996
|
return found;
|
|
48530
48997
|
}
|
|
48531
|
-
function siteLines(site, startMs, endMs) {
|
|
48998
|
+
function siteLines(site, startMs, endMs, first = false) {
|
|
48532
48999
|
const lines = [];
|
|
48533
49000
|
const index = site.index;
|
|
48534
49001
|
if (!index) lines.push("Google index: no checks ran this week yet.");
|
|
@@ -48545,7 +49012,7 @@ function siteLines(site, startMs, endMs) {
|
|
|
48545
49012
|
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
49013
|
else if (crawled) {
|
|
48547
49014
|
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.`);
|
|
49015
|
+
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
49016
|
} 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
49017
|
else lines.push("Weekly crawl: the first crawl is still running.");
|
|
48551
49018
|
const clicks = site.clicks;
|
|
@@ -48613,7 +49080,7 @@ function weekMarkdown(data) {
|
|
|
48613
49080
|
const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
|
|
48614
49081
|
const rows = data.sites.map((site) => ({
|
|
48615
49082
|
site,
|
|
48616
|
-
found: issues(site, startMs, endMs),
|
|
49083
|
+
found: issues(site, startMs, endMs, data.first),
|
|
48617
49084
|
waiting: !site.crawl || !site.index
|
|
48618
49085
|
}));
|
|
48619
49086
|
const attention = rows.filter((row) => row.found.length);
|
|
@@ -48625,7 +49092,7 @@ function weekMarkdown(data) {
|
|
|
48625
49092
|
"",
|
|
48626
49093
|
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
49094
|
];
|
|
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})`] : []);
|
|
49095
|
+
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
49096
|
return out.join("\n");
|
|
48630
49097
|
}
|
|
48631
49098
|
async function runAllSites(deps, sites) {
|
|
@@ -48852,7 +49319,7 @@ const SHARED_TOOLS = [
|
|
|
48852
49319
|
title: "Keyword, competitor, backlink and AI answer data",
|
|
48853
49320
|
description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits. It uses the key the person saved on their seoskill.dev account (or on this machine for the CLI). Actions: ${RESEARCH_HELP}`,
|
|
48854
49321
|
input: z.object({
|
|
48855
|
-
provider: z.
|
|
49322
|
+
provider: z.string().max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional().describe(`${RESEARCH_PROVIDERS.join(" or ")}. Optional: without it, the saved key; DataForSEO when both are saved. For serp in the CLI and the local MCP server it can also be the id of an installed provider package with live search results.`),
|
|
48856
49323
|
action: z.enum(RESEARCH_ACTIONS),
|
|
48857
49324
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
48858
49325
|
target: z.string().default("").describe("Domain (the whole site), a path ending in / for every URL under it, such as example.com/blog/, or one page."),
|
|
@@ -48863,12 +49330,13 @@ const SHARED_TOOLS = [
|
|
|
48863
49330
|
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
49331
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
48865
49332
|
depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
|
|
49333
|
+
device: z.enum(RESEARCH_DEVICES).optional().describe("serp only: desktop (default) or mobile. Google ranks pages differently on each. DataForSEO only."),
|
|
48866
49334
|
...detail
|
|
48867
49335
|
}),
|
|
48868
49336
|
run: async (surface, input) => {
|
|
48869
49337
|
const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
|
|
48870
49338
|
if (input.action === "ai_answers") {
|
|
48871
|
-
if (input.provider
|
|
49339
|
+
if (input.provider && input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
48872
49340
|
return runAiAnswers({
|
|
48873
49341
|
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
48874
49342
|
country: input.country,
|
|
@@ -48883,6 +49351,7 @@ const SHARED_TOOLS = [
|
|
|
48883
49351
|
return runResearch(input, await surface.keys(), {
|
|
48884
49352
|
drKey: surface.drKey,
|
|
48885
49353
|
store: surface.researchStore,
|
|
49354
|
+
installedSerp: surface.installedSerp,
|
|
48886
49355
|
call: more
|
|
48887
49356
|
});
|
|
48888
49357
|
}
|
|
@@ -49845,6 +50314,133 @@ z.object({ packages: z.record(z.string(), z.object({
|
|
|
49845
50314
|
version: providerExactVersionSchema.optional(),
|
|
49846
50315
|
integrity: providerIntegritySchema.optional()
|
|
49847
50316
|
}).passthrough()) }).passthrough();
|
|
50317
|
+
// packages/core/dist/provider-extensions/serp.js
|
|
50318
|
+
const nullableText = z.string().max(5e4).nullable();
|
|
50319
|
+
const serpSnapshotResultSchema = z.object({
|
|
50320
|
+
observedAt: z.string().max(100),
|
|
50321
|
+
effectiveKeyword: z.string().trim().min(1).max(1e3),
|
|
50322
|
+
searchEngineDomain: z.string().max(253).nullable(),
|
|
50323
|
+
checkUrl: z.url().max(8192).nullable(),
|
|
50324
|
+
resultCount: z.number().int().nonnegative().safe().nullable(),
|
|
50325
|
+
pagesCount: z.number().int().nonnegative().max(100).safe().nullable(),
|
|
50326
|
+
features: z.array(z.string().trim().min(1).max(100)).max(100),
|
|
50327
|
+
organicResults: z.array(z.object({
|
|
50328
|
+
rankGroup: z.number().int().positive().safe(),
|
|
50329
|
+
rankAbsolute: z.number().int().positive().safe(),
|
|
50330
|
+
page: z.number().int().positive().max(100).safe(),
|
|
50331
|
+
domain: z.string().trim().min(1).max(253),
|
|
50332
|
+
url: z.url().max(8192),
|
|
50333
|
+
title: z.string().max(1e4).nullable(),
|
|
50334
|
+
description: nullableText,
|
|
50335
|
+
isFeaturedSnippet: z.boolean().nullable()
|
|
50336
|
+
}).strict()).max(100),
|
|
50337
|
+
localPack: z.object({
|
|
50338
|
+
present: z.boolean(),
|
|
50339
|
+
returnedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50340
|
+
retainedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50341
|
+
invalidRows: z.number().int().nonnegative().max(100).safe(),
|
|
50342
|
+
results: z.array(z.unknown()).max(100)
|
|
50343
|
+
}).strict(),
|
|
50344
|
+
coverage: z.object({
|
|
50345
|
+
returnedRows: z.number().int().nonnegative().max(1e4).safe(),
|
|
50346
|
+
retainedRows: z.number().int().nonnegative().max(100).safe(),
|
|
50347
|
+
invalidRows: z.number().int().nonnegative().max(1e4).safe(),
|
|
50348
|
+
providerTotalRows: z.number().int().nonnegative().safe().nullable(),
|
|
50349
|
+
completeness: z.enum([
|
|
50350
|
+
"complete",
|
|
50351
|
+
"partial",
|
|
50352
|
+
"capped"
|
|
50353
|
+
]),
|
|
50354
|
+
nextCursor: z.string().max(500).nullable()
|
|
50355
|
+
}).strict(),
|
|
50356
|
+
request: z.object({
|
|
50357
|
+
endpoint: z.string().trim().min(1).max(500),
|
|
50358
|
+
filters: z.record(z.string().min(1).max(100), z.union([
|
|
50359
|
+
z.string().max(1e3),
|
|
50360
|
+
z.number().finite(),
|
|
50361
|
+
z.boolean()
|
|
50362
|
+
])),
|
|
50363
|
+
sort: z.array(z.string().min(1).max(200)).max(20)
|
|
50364
|
+
}).strict(),
|
|
50365
|
+
cost: z.object({
|
|
50366
|
+
estimatedMicros: z.number().int().nonnegative().safe().nullable(),
|
|
50367
|
+
actualMicros: z.number().int().nonnegative().safe().nullable(),
|
|
50368
|
+
taskIds: z.array(z.string().min(1).max(500)).max(20),
|
|
50369
|
+
native: z.object({
|
|
50370
|
+
unit: z.string().trim().min(1).max(100),
|
|
50371
|
+
estimatedUnits: z.number().finite().nonnegative().nullable(),
|
|
50372
|
+
actualUnits: z.number().finite().nonnegative().nullable(),
|
|
50373
|
+
remainingBefore: z.number().finite().nonnegative().nullable()
|
|
50374
|
+
}).strict().optional()
|
|
50375
|
+
}).strict(),
|
|
50376
|
+
qualityWarnings: z.array(z.object({
|
|
50377
|
+
code: z.string().trim().min(1).max(100),
|
|
50378
|
+
message: z.string().trim().min(1).max(500),
|
|
50379
|
+
field: z.string().max(200).optional(),
|
|
50380
|
+
row: z.number().int().positive().safe().optional()
|
|
50381
|
+
}).strict()).max(50)
|
|
50382
|
+
}).strict();
|
|
50383
|
+
const same = (list, value) => !list || list.some((item) => item.toLowerCase() === value.toLowerCase());
|
|
50384
|
+
function supports(capability, market) {
|
|
50385
|
+
return capability.markets.some((entry) => same(entry.searchEngines, market.searchEngine) && same(entry.countryCodes, market.countryCode) && same(entry.languageCodes, market.languageCode) && same(entry.devices, market.device ?? "desktop"));
|
|
50386
|
+
}
|
|
50387
|
+
const installedProviderSerp = async (input) => {
|
|
50388
|
+
const loaded = await loadProviderExtensions();
|
|
50389
|
+
const provider = loaded.registry.get(input.provider);
|
|
50390
|
+
if (!provider) throw new CommandError("INVALID_INPUT", `No provider ${input.provider} is installed. The built-in providers are dataforseo and ahrefs; \`seo providers list\` shows installed packages.`);
|
|
50391
|
+
const capability = loaded.registry.capability(provider.id, "serp-snapshot");
|
|
50392
|
+
if (!capability) throw new CommandError("INVALID_INPUT", `${provider.displayName} has no live search results (serp-snapshot), so it cannot answer serp. \`seo providers describe ${provider.id}\` lists its own actions.`);
|
|
50393
|
+
const market = {
|
|
50394
|
+
searchEngine: "google",
|
|
50395
|
+
countryCode: input.country.toUpperCase(),
|
|
50396
|
+
languageCode: input.language,
|
|
50397
|
+
device: input.device
|
|
50398
|
+
};
|
|
50399
|
+
if (!supports(capability, market)) throw new CommandError("INVALID_INPUT", `${provider.displayName} has no ${input.device} Google results for country ${input.country} in language ${input.language}.`);
|
|
50400
|
+
let account;
|
|
50401
|
+
let credentials;
|
|
50402
|
+
try {
|
|
50403
|
+
account = normalizeProviderExtensionAccount(provider, readProviderExtensionAccount(provider.id));
|
|
50404
|
+
credentials = await readProviderExtensionCredentials({
|
|
50405
|
+
providerId: provider.id,
|
|
50406
|
+
account,
|
|
50407
|
+
fields: provider.connection.fields
|
|
50408
|
+
});
|
|
50409
|
+
} catch (error) {
|
|
50410
|
+
throw new CommandError("NO_KEY", `${error instanceof Error ? error.message : String(error)} Connect it with \`seo providers connect ${provider.id}\`.`);
|
|
50411
|
+
}
|
|
50412
|
+
const depth = Math.min(input.depth, capability.maxDepth);
|
|
50413
|
+
let raw;
|
|
50414
|
+
try {
|
|
50415
|
+
raw = await capability.run({
|
|
50416
|
+
account,
|
|
50417
|
+
credentials,
|
|
50418
|
+
keyword: input.keyword,
|
|
50419
|
+
market,
|
|
50420
|
+
depth
|
|
50421
|
+
}, runtimeForProvider(provider.id, { maxRequests: capability.maxRequests }));
|
|
50422
|
+
} catch (error) {
|
|
50423
|
+
throw new CommandError("PROVIDER_ERROR", safeProviderAdapterError(provider.id, error, credentials).message);
|
|
50424
|
+
}
|
|
50425
|
+
const parsed = serpSnapshotResultSchema.safeParse(raw);
|
|
50426
|
+
if (!parsed.success) throw new CommandError("PROVIDER_ERROR", `${provider.displayName} returned a results page that does not match the serp-snapshot shape.`);
|
|
50427
|
+
const result = parsed.data;
|
|
50428
|
+
const warnings = result.qualityWarnings.map((warning) => warning.message);
|
|
50429
|
+
return {
|
|
50430
|
+
rows: [...result.organicResults].sort((a, b) => a.rankAbsolute - b.rankAbsolute || (a.url < b.url ? -1 : 1)).map((item) => ({
|
|
50431
|
+
position: item.isFeaturedSnippet ? null : item.rankGroup,
|
|
50432
|
+
rank_absolute: item.rankAbsolute,
|
|
50433
|
+
type: item.isFeaturedSnippet ? "featured_snippet" : "organic",
|
|
50434
|
+
url: item.url,
|
|
50435
|
+
domain: item.domain,
|
|
50436
|
+
title: item.title
|
|
50437
|
+
})),
|
|
50438
|
+
costMicros: result.cost.actualMicros,
|
|
50439
|
+
partial: result.coverage.completeness === "partial",
|
|
50440
|
+
warnings,
|
|
50441
|
+
depth
|
|
50442
|
+
};
|
|
50443
|
+
};
|
|
49848
50444
|
z.object({ limits_and_usage: z.object({
|
|
49849
50445
|
api_key_expiration_date: z.string().trim().min(1).max(100),
|
|
49850
50446
|
subscription: z.string().trim().min(1).max(200),
|
|
@@ -52634,6 +53230,7 @@ function registerSharedTools(server, options = {}) {
|
|
|
52634
53230
|
refreshClicky: localRefreshClicky,
|
|
52635
53231
|
drKey: null,
|
|
52636
53232
|
researchStore: localResearchStore(),
|
|
53233
|
+
installedSerp: installedProviderSerp,
|
|
52637
53234
|
accounts: localAccounts,
|
|
52638
53235
|
signIn: LOCAL_SIGN_IN.signIn,
|
|
52639
53236
|
connect: "run `seo login` to use your seoskill.dev account (Google, Bing and keys in one place), or `seo auth login`, `seo providers bing connect` and `seo providers dataforseo connect` or `seo providers ahrefs connect` on this machine only.",
|