seo 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +4 -1
- package/dist/cli.js +69 -69
- package/dist/cli.js.map +1 -1
- package/dist/{dist-Bv1nbxq_.js → dist-B3L_4U8E.js} +1 -1
- package/dist/{dist-B8MTNp_F.js → dist-BdZyUFiw.js} +96 -95
- package/dist/dist-BdZyUFiw.js.map +1 -0
- package/dist/{esm-BZhSLbcQ.js → esm-gP6ovS6b.js} +2 -2
- package/dist/{esm-BZhSLbcQ.js.map → esm-gP6ovS6b.js.map} +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +29 -27
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +701 -414
- package/dist/mcp.js.map +1 -1
- package/package.json +3 -1
- package/skills/seo/SKILL.md +1 -1
- package/dist/dist-B8MTNp_F.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-gP6ovS6b.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
@@ -13912,12 +13912,12 @@ function day(value) {
|
|
|
13912
13912
|
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
13913
13913
|
}
|
|
13914
13914
|
const count$1 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
13915
|
-
function list(data, label) {
|
|
13915
|
+
function list$1(data, label) {
|
|
13916
13916
|
if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
|
|
13917
13917
|
return data.filter((item) => Boolean(item) && typeof item === "object");
|
|
13918
13918
|
}
|
|
13919
13919
|
async function listBingSites(call) {
|
|
13920
|
-
return list(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
|
|
13920
|
+
return list$1(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
|
|
13921
13921
|
url: row.Url,
|
|
13922
13922
|
verified: row.IsVerified === true
|
|
13923
13923
|
})).sort((a, b) => a.url.localeCompare(b.url, "en"));
|
|
@@ -13925,7 +13925,7 @@ async function listBingSites(call) {
|
|
|
13925
13925
|
async function bingTraffic(call, site) {
|
|
13926
13926
|
const rows = [];
|
|
13927
13927
|
let invalidRows = 0;
|
|
13928
|
-
for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
13928
|
+
for (const row of list$1(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
13929
13929
|
const date = day(row.Date);
|
|
13930
13930
|
const clicks = count$1(row.Clicks);
|
|
13931
13931
|
const impressions = count$1(row.Impressions);
|
|
@@ -13948,7 +13948,7 @@ async function bingTraffic(call, site) {
|
|
|
13948
13948
|
async function keyed(call, method, params) {
|
|
13949
13949
|
const rows = [];
|
|
13950
13950
|
let invalidRows = 0;
|
|
13951
|
-
for (const row of list(await call(method, params), "row list")) {
|
|
13951
|
+
for (const row of list$1(await call(method, params), "row list")) {
|
|
13952
13952
|
const date = day(row.Date);
|
|
13953
13953
|
const key = typeof row.Query === "string" ? row.Query.trim() : "";
|
|
13954
13954
|
const clicks = count$1(row.Clicks);
|
|
@@ -38264,6 +38264,482 @@ async function clickyDomain(site) {
|
|
|
38264
38264
|
return null;
|
|
38265
38265
|
}
|
|
38266
38266
|
}
|
|
38267
|
+
//#endregion
|
|
38268
|
+
//#region packages/core/dist/analyze/ai-queries.js
|
|
38269
|
+
const RULES = [
|
|
38270
|
+
["agent_prompt", "^(search the web for|browse the web|find sources)|do not invent|return the [0-9]+ most relevant|include source urls|context: location|do not include location|question: |reply only with|the model you are using"],
|
|
38271
|
+
["tracker", "\\. ?my location is |\\?\\. ?my( |$)|^evaluate the .+ on "],
|
|
38272
|
+
["reply", "^(yes|yeah|yep|ok|okay|sure|yes please|go on|continue|keep going|tell me more|why not|how so|really|exactly|show me|and then|thanks|thank you|got it|what else|anything else)[?!.,]*$"],
|
|
38273
|
+
["follow_up", "^(what|how) about .{1,40}$|^(and|so|ok so|okay so|but) (what|which|how|why) "],
|
|
38274
|
+
["operator", "(^|\\s)-?(site|before|after|intitle|inurl|intext|filetype):|\"[^\"]*\" or \""],
|
|
38275
|
+
["chat", "\\b(please|can you|could you|give me|show me|tell me|i want|i need|i have|i am|should i|do i need|is it worth|help me|walk me through)\\b|^(what|how|why|is|does|can|will|would) (it|that|this|they)\\b"],
|
|
38276
|
+
["long", "^(\\S+\\s+){9,}\\S+$"]
|
|
38277
|
+
];
|
|
38278
|
+
const AI_QUERY_KINDS = {
|
|
38279
|
+
agent_prompt: "text from an agent prompt, such as \"search the web for\"",
|
|
38280
|
+
tracker: "a visibility-tool probe, such as \"my location is\"",
|
|
38281
|
+
reply: "a bare reply, such as \"yes\" or \"tell me more\"",
|
|
38282
|
+
follow_up: "a follow-up, such as \"what about …\"",
|
|
38283
|
+
operator: "search operators, such as site:, before: or \"a\" or \"b\"",
|
|
38284
|
+
chat: "chat phrasing, such as \"can you\" or \"should i\"",
|
|
38285
|
+
long: "ten or more words with no other sign"
|
|
38286
|
+
};
|
|
38287
|
+
const MATCHERS = RULES.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
|
|
38288
|
+
const AI_QUERY_PATTERN = RULES.map(([, pattern]) => `(?:${pattern})`).join("|");
|
|
38289
|
+
const NOISE = [
|
|
38290
|
+
["spaced_letters", /(^|\s)(\S\s){3,}\S(\s|$)/u],
|
|
38291
|
+
["broken_encoding", /^(яю|ÿþ|þÿ)|&#[0-9]+;?|&[a-z]+;|[âÃÂ][\u0080-\u00bf€™œ“”]|\ufffd/u],
|
|
38292
|
+
["pasted_text", /^(p [a-z]{3,} |here (are|is) (all )?the |here's the |done[,.!]? (you|here) |current state: )/u]
|
|
38293
|
+
];
|
|
38294
|
+
function aiQueryNoise(query) {
|
|
38295
|
+
const text = query.trim().toLowerCase();
|
|
38296
|
+
return NOISE.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
|
|
38297
|
+
}
|
|
38298
|
+
const QUESTION = /^(how|what|what's|whats|which|why|when|where|who|is|are|can|could|does|do|should|would|will|i need|i want|i have|i'm looking|we need|we want)\b|\?$/u;
|
|
38299
|
+
const ASKABLE_KINDS = /* @__PURE__ */ new Set(["chat", "long"]);
|
|
38300
|
+
const PROMPT_CHARACTERS = 500;
|
|
38301
|
+
function askable(query, kind) {
|
|
38302
|
+
const text = query.trim().toLowerCase().replace(/^["'“]+/u, "");
|
|
38303
|
+
return Boolean(kind && ASKABLE_KINDS.has(kind)) && text.length <= PROMPT_CHARACTERS && QUESTION.test(text);
|
|
38304
|
+
}
|
|
38305
|
+
function aiQueryKind(query) {
|
|
38306
|
+
const text = query.trim().toLowerCase();
|
|
38307
|
+
return MATCHERS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
|
|
38308
|
+
}
|
|
38309
|
+
const STOP = new Set("the and for with that this what which who how why when where are is was can does do did you your our from about into than then them they their there have has had will would should could not but its it’s me my i am of to in on at by or an as be if so".split(" "));
|
|
38310
|
+
function words(query) {
|
|
38311
|
+
return new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3 && !STOP.has(word)).map((word) => word.length > 4 && word.endsWith("s") ? word.slice(0, -1) : word));
|
|
38312
|
+
}
|
|
38313
|
+
const SAME_QUESTION = .6;
|
|
38314
|
+
const MAX_ROWS = 25e3;
|
|
38315
|
+
const EXAMPLES = 3;
|
|
38316
|
+
const CANDIDATES = 200;
|
|
38317
|
+
const overlap = (a, b, weight) => {
|
|
38318
|
+
let shared = 0;
|
|
38319
|
+
let union = 0;
|
|
38320
|
+
for (const word of a) {
|
|
38321
|
+
union += weight(word);
|
|
38322
|
+
if (b.has(word)) shared += weight(word);
|
|
38323
|
+
}
|
|
38324
|
+
for (const word of b) if (!a.has(word)) union += weight(word);
|
|
38325
|
+
return union ? shared / union : 0;
|
|
38326
|
+
};
|
|
38327
|
+
const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
|
|
38328
|
+
async function aiQueriesReport(input, searchAnalytics) {
|
|
38329
|
+
const range = {
|
|
38330
|
+
startDate: input.startDate,
|
|
38331
|
+
endDate: input.endDate
|
|
38332
|
+
};
|
|
38333
|
+
const [matched, site] = await Promise.all([searchAnalytics(input.site, {
|
|
38334
|
+
...range,
|
|
38335
|
+
dimensions: ["query", "page"],
|
|
38336
|
+
type: "web",
|
|
38337
|
+
dataState: "final",
|
|
38338
|
+
maxRows: MAX_ROWS,
|
|
38339
|
+
dimensionFilterGroups: [{ filters: [{
|
|
38340
|
+
dimension: "query",
|
|
38341
|
+
operator: "includingRegex",
|
|
38342
|
+
expression: AI_QUERY_PATTERN
|
|
38343
|
+
}] }]
|
|
38344
|
+
}), searchAnalytics(input.site, {
|
|
38345
|
+
...range,
|
|
38346
|
+
dimensions: [],
|
|
38347
|
+
type: "web",
|
|
38348
|
+
dataState: "final"
|
|
38349
|
+
})]);
|
|
38350
|
+
const rows = [...matched.rows].sort((a, b) => b.impressions - a.impressions || String(a.keys[0]).localeCompare(String(b.keys[0])) || String(a.keys[1]).localeCompare(String(b.keys[1])));
|
|
38351
|
+
const usage = /* @__PURE__ */ new Map();
|
|
38352
|
+
const unique = new Set(rows.map((row) => String(row.keys[0] ?? "")));
|
|
38353
|
+
for (const query of unique) for (const word of words(query)) usage.set(word, (usage.get(word) ?? 0) + 1);
|
|
38354
|
+
const weight = (word) => Math.log((unique.size + 1) / (usage.get(word) ?? 1));
|
|
38355
|
+
const groups = [];
|
|
38356
|
+
const byWord = /* @__PURE__ */ new Map();
|
|
38357
|
+
const byText = /* @__PURE__ */ new Map();
|
|
38358
|
+
const kindOf = /* @__PURE__ */ new Map();
|
|
38359
|
+
const noise = /* @__PURE__ */ new Map();
|
|
38360
|
+
for (const row of rows) {
|
|
38361
|
+
const query = String(row.keys[0] ?? "");
|
|
38362
|
+
const page = String(row.keys[1] ?? "");
|
|
38363
|
+
const kind = aiQueryKind(query);
|
|
38364
|
+
if (!kind) continue;
|
|
38365
|
+
const junk = aiQueryNoise(query);
|
|
38366
|
+
if (junk) {
|
|
38367
|
+
const seen = noise.get(junk) ?? /* @__PURE__ */ new Set();
|
|
38368
|
+
seen.add(query.trim().toLowerCase());
|
|
38369
|
+
noise.set(junk, seen);
|
|
38370
|
+
continue;
|
|
38371
|
+
}
|
|
38372
|
+
const text = query.trim().toLowerCase();
|
|
38373
|
+
const own = words(query);
|
|
38374
|
+
let group = byText.get(text);
|
|
38375
|
+
if (!group) {
|
|
38376
|
+
let checked = 0;
|
|
38377
|
+
for (const word of own) {
|
|
38378
|
+
for (const candidate of byWord.get(word) ?? []) {
|
|
38379
|
+
if (++checked > CANDIDATES) break;
|
|
38380
|
+
if (overlap(own, candidate.words, weight) >= SAME_QUESTION) {
|
|
38381
|
+
group = candidate;
|
|
38382
|
+
break;
|
|
38383
|
+
}
|
|
38384
|
+
}
|
|
38385
|
+
if (group || checked > CANDIDATES) break;
|
|
38386
|
+
}
|
|
38387
|
+
}
|
|
38388
|
+
if (!group) {
|
|
38389
|
+
group = {
|
|
38390
|
+
words: own,
|
|
38391
|
+
queries: /* @__PURE__ */ new Map(),
|
|
38392
|
+
pages: /* @__PURE__ */ new Map(),
|
|
38393
|
+
kinds: /* @__PURE__ */ new Map(),
|
|
38394
|
+
clicks: 0,
|
|
38395
|
+
impressions: 0,
|
|
38396
|
+
positionWeight: 0
|
|
38397
|
+
};
|
|
38398
|
+
groups.push(group);
|
|
38399
|
+
for (const word of own) {
|
|
38400
|
+
const list = byWord.get(word) ?? [];
|
|
38401
|
+
list.push(group);
|
|
38402
|
+
byWord.set(word, list);
|
|
38403
|
+
}
|
|
38404
|
+
}
|
|
38405
|
+
byText.set(text, group);
|
|
38406
|
+
if (!kindOf.has(text)) {
|
|
38407
|
+
kindOf.set(text, kind);
|
|
38408
|
+
group.kinds.set(kind, (group.kinds.get(kind) ?? 0) + 1);
|
|
38409
|
+
}
|
|
38410
|
+
group.queries.set(query, (group.queries.get(query) ?? 0) + row.impressions);
|
|
38411
|
+
group.pages.set(page, (group.pages.get(page) ?? 0) + row.impressions);
|
|
38412
|
+
group.clicks += row.clicks;
|
|
38413
|
+
group.impressions += row.impressions;
|
|
38414
|
+
group.positionWeight += row.position * row.impressions;
|
|
38415
|
+
}
|
|
38416
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
38417
|
+
for (const kind of kindOf.values()) {
|
|
38418
|
+
const entry = byKind.get(kind) ?? {
|
|
38419
|
+
queries: 0,
|
|
38420
|
+
impressions: 0
|
|
38421
|
+
};
|
|
38422
|
+
entry.queries += 1;
|
|
38423
|
+
byKind.set(kind, entry);
|
|
38424
|
+
}
|
|
38425
|
+
for (const row of rows) {
|
|
38426
|
+
const kind = kindOf.get(String(row.keys[0] ?? "").trim().toLowerCase());
|
|
38427
|
+
const entry = kind && byKind.get(kind);
|
|
38428
|
+
if (entry) entry.impressions += row.impressions;
|
|
38429
|
+
}
|
|
38430
|
+
const queries = kindOf.size;
|
|
38431
|
+
const impressions = groups.reduce((sum, g) => sum + g.impressions, 0);
|
|
38432
|
+
const clicks = groups.reduce((sum, g) => sum + g.clicks, 0);
|
|
38433
|
+
const total = site.rows[0] ?? {
|
|
38434
|
+
clicks: 0,
|
|
38435
|
+
impressions: 0
|
|
38436
|
+
};
|
|
38437
|
+
const items = groups.map((group) => {
|
|
38438
|
+
const most = (map) => [...map].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
|
|
38439
|
+
const [first, ...rest] = most(group.queries);
|
|
38440
|
+
const kind = [...group.kinds].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
38441
|
+
return {
|
|
38442
|
+
query: first ?? "",
|
|
38443
|
+
page: most(group.pages)[0] ?? "",
|
|
38444
|
+
askable: askable(first ?? "", kind),
|
|
38445
|
+
pages: group.pages.size,
|
|
38446
|
+
kind,
|
|
38447
|
+
wordings: group.queries.size,
|
|
38448
|
+
impressions: group.impressions,
|
|
38449
|
+
clicks: group.clicks,
|
|
38450
|
+
ctr: group.impressions ? round(group.clicks / group.impressions, 4) : 0,
|
|
38451
|
+
position: group.impressions ? round(group.positionWeight / group.impressions, 1) : 0,
|
|
38452
|
+
otherWordings: rest.slice(0, EXAMPLES)
|
|
38453
|
+
};
|
|
38454
|
+
}).sort((a, b) => b.impressions - a.impressions || b.wordings - a.wordings || a.query.localeCompare(b.query) || a.page.localeCompare(b.page));
|
|
38455
|
+
const pct = (part, whole) => whole ? round(part / whole * 100, 1) : null;
|
|
38456
|
+
const kinds = [...byKind].sort((a, b) => b[1].impressions - a[1].impressions || a[0].localeCompare(b[0])).map(([kind, entry]) => `${kind} ${entry.queries}`).join(", ");
|
|
38457
|
+
const leftOut = [...noise].sort((a, b) => a[0].localeCompare(b[0])).map(([reason, seen]) => `${reason.replace("_", " ")} ${seen.size}`).join(", ");
|
|
38458
|
+
const asked = items.filter((item) => item.askable).length;
|
|
38459
|
+
const capped = matched.rowsFetched >= MAX_ROWS;
|
|
38460
|
+
return {
|
|
38461
|
+
range,
|
|
38462
|
+
verdict: queries ? `${queries} queries in ${groups.length} questions have the shape of AI conversations or agent searches.` : "No queries with the shape of AI conversations or agent searches.",
|
|
38463
|
+
summary: {
|
|
38464
|
+
queries,
|
|
38465
|
+
questions: groups.length,
|
|
38466
|
+
askable: asked,
|
|
38467
|
+
leftOut: leftOut || "none",
|
|
38468
|
+
impressions,
|
|
38469
|
+
clicks,
|
|
38470
|
+
shareOfImpressionsPct: pct(impressions, total.impressions),
|
|
38471
|
+
byKind: kinds || "none",
|
|
38472
|
+
ctr: impressions ? round(clicks / impressions, 4) : 0,
|
|
38473
|
+
siteCtr: total.impressions ? round(total.clicks / total.impressions, 4) : 0
|
|
38474
|
+
},
|
|
38475
|
+
kinds: AI_QUERY_KINDS,
|
|
38476
|
+
items,
|
|
38477
|
+
caveats: [
|
|
38478
|
+
"A heuristic: these queries match the patterns of AI conversations and agent searches. Search Console does not mark AI Mode or AI Overviews, and people type some of these too.",
|
|
38479
|
+
"A minimum: Search Console leaves out rare queries, and these are rare by nature.",
|
|
38480
|
+
"Rewordings of one question are grouped by their shared words; page is the page with the most impressions for it.",
|
|
38481
|
+
"Left out and counted, not shown: letters split by spaces, text broken by a wrong encoding, and text pasted from a tool or an agent’s output.",
|
|
38482
|
+
"askable: a chat or long question that opens with a question word or a request such as \"i need\", or ends with a question mark, so an AI engine can be asked it as it is. ai_answers checks the top askable questions only.",
|
|
38483
|
+
"Search Console’s Generative AI report is not in its API, so it is not here.",
|
|
38484
|
+
...capped ? [`Capped at the first ${MAX_ROWS} matching query and page rows.`] : []
|
|
38485
|
+
]
|
|
38486
|
+
};
|
|
38487
|
+
}
|
|
38488
|
+
//#endregion
|
|
38489
|
+
//#region packages/core/dist/commands/research-providers.js
|
|
38490
|
+
function need(headers, provider) {
|
|
38491
|
+
const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
|
|
38492
|
+
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
|
|
38493
|
+
return values;
|
|
38494
|
+
}
|
|
38495
|
+
async function call(provider, request) {
|
|
38496
|
+
const response = await fetch(request);
|
|
38497
|
+
if (response.ok) return response;
|
|
38498
|
+
const text = (await response.text()).slice(0, 300);
|
|
38499
|
+
throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
|
|
38500
|
+
}
|
|
38501
|
+
function requireInput(input) {
|
|
38502
|
+
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
38503
|
+
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
38504
|
+
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
38505
|
+
}
|
|
38506
|
+
async function research(provider, headers, input) {
|
|
38507
|
+
requireInput(input);
|
|
38508
|
+
const keys = need(headers, provider);
|
|
38509
|
+
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
38510
|
+
return {
|
|
38511
|
+
rows: await ahrefs(keys[0] ?? "", input),
|
|
38512
|
+
costUsd: null,
|
|
38513
|
+
partial: false
|
|
38514
|
+
};
|
|
38515
|
+
}
|
|
38516
|
+
function countryName(code) {
|
|
38517
|
+
return new Intl.DisplayNames("en", { type: "region" }).of(code.toUpperCase()) ?? "United States";
|
|
38518
|
+
}
|
|
38519
|
+
function mainLanguage(country) {
|
|
38520
|
+
try {
|
|
38521
|
+
return new Intl.Locale(`und-${country.toUpperCase()}`).maximize().language;
|
|
38522
|
+
} catch {
|
|
38523
|
+
return "en";
|
|
38524
|
+
}
|
|
38525
|
+
}
|
|
38526
|
+
const LABS_LANGUAGES = new Map(`
|
|
38527
|
+
ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
|
|
38528
|
+
bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
|
|
38529
|
+
cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
|
|
38530
|
+
gr:el gt:es hk:en hr:hr hu:hu id:id ie:en il:he in:en it:it jo:ar jp:ja
|
|
38531
|
+
ke:en kh:en kr:ko kz:ru lk:en lt:lt lv:lv ma:ar mc:fr md:ro mk:mk mm:en
|
|
38532
|
+
mt:en mx:es my:en ng:en ni:es nl:nl no:nb nz:en pa:es pe:es ph:en pk:en
|
|
38533
|
+
pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
|
|
38534
|
+
th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
|
|
38535
|
+
`.trim().split(/\s+/).map((pair) => pair.split(":")));
|
|
38536
|
+
function dataForSeoLanguage(country) {
|
|
38537
|
+
return LABS_LANGUAGES.get(country.toLowerCase()) ?? null;
|
|
38538
|
+
}
|
|
38539
|
+
function dataForSeoMarket(country, language) {
|
|
38540
|
+
return {
|
|
38541
|
+
location_name: countryName(country),
|
|
38542
|
+
language_code: language ?? mainLanguage(country)
|
|
38543
|
+
};
|
|
38544
|
+
}
|
|
38545
|
+
function dataForSeoKeys(headers) {
|
|
38546
|
+
return need(headers, "dataforseo");
|
|
38547
|
+
}
|
|
38548
|
+
async function dataForSeoList([login, password], path) {
|
|
38549
|
+
const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
|
|
38550
|
+
if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
|
|
38551
|
+
return task.result ?? [];
|
|
38552
|
+
}
|
|
38553
|
+
async function dataForSeoTask([login, password], path, task) {
|
|
38554
|
+
const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
38555
|
+
method: "POST",
|
|
38556
|
+
headers: {
|
|
38557
|
+
authorization: `Basic ${btoa(`${login}:${password}`)}`,
|
|
38558
|
+
"content-type": "application/json"
|
|
38559
|
+
},
|
|
38560
|
+
body: JSON.stringify([task])
|
|
38561
|
+
})), "DataForSEO")).tasks?.[0];
|
|
38562
|
+
if (result?.status_code === NO_RESULTS) return {
|
|
38563
|
+
cost: result.cost ?? 0,
|
|
38564
|
+
result: null,
|
|
38565
|
+
partial: false
|
|
38566
|
+
};
|
|
38567
|
+
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), { cost: result?.cost ?? 0 });
|
|
38568
|
+
return {
|
|
38569
|
+
cost: result.cost ?? 0,
|
|
38570
|
+
result: result.result?.[0] ?? null,
|
|
38571
|
+
partial: result.status_code === PARTIAL
|
|
38572
|
+
};
|
|
38573
|
+
}
|
|
38574
|
+
const NO_RESULTS = 40102;
|
|
38575
|
+
const PARTIAL = 40106;
|
|
38576
|
+
async function dataForSeo(keys, input) {
|
|
38577
|
+
const spent = {
|
|
38578
|
+
cost: 0,
|
|
38579
|
+
partial: false
|
|
38580
|
+
};
|
|
38581
|
+
return {
|
|
38582
|
+
rows: await dataForSeoRows(keys, input, spent),
|
|
38583
|
+
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
38584
|
+
partial: spent.partial
|
|
38585
|
+
};
|
|
38586
|
+
}
|
|
38587
|
+
async function dataForSeoRows(keys, input, spent) {
|
|
38588
|
+
const language = dataForSeoLanguage(input.country);
|
|
38589
|
+
if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
38590
|
+
const market = dataForSeoMarket(input.country, language);
|
|
38591
|
+
const post = async (path, task) => {
|
|
38592
|
+
const done = await dataForSeoTask(keys, path, {
|
|
38593
|
+
...market,
|
|
38594
|
+
...task
|
|
38595
|
+
}).catch((error) => {
|
|
38596
|
+
spent.cost += error.cost ?? 0;
|
|
38597
|
+
throw error;
|
|
38598
|
+
});
|
|
38599
|
+
spent.cost += done.cost;
|
|
38600
|
+
spent.partial ||= done.partial;
|
|
38601
|
+
return done.result?.items ?? [];
|
|
38602
|
+
};
|
|
38603
|
+
const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
|
|
38604
|
+
const pick = (item, fields) => Object.fromEntries(Object.entries(fields).map(([name, path]) => [name, get(item, path) ?? null]));
|
|
38605
|
+
if (input.action === "keyword_metrics") return (await post("v3/dataforseo_labs/google/keyword_overview/live", { keywords: input.keywords.slice(0, 700) })).map((item) => pick(item, {
|
|
38606
|
+
keyword: "keyword",
|
|
38607
|
+
search_volume: "keyword_info.search_volume",
|
|
38608
|
+
keyword_difficulty: "keyword_properties.keyword_difficulty",
|
|
38609
|
+
cpc: "keyword_info.cpc",
|
|
38610
|
+
competition: "keyword_info.competition",
|
|
38611
|
+
main_intent: "search_intent_info.main_intent"
|
|
38612
|
+
}));
|
|
38613
|
+
if (input.action === "ranked_keywords") return (await post("v3/dataforseo_labs/google/ranked_keywords/live", {
|
|
38614
|
+
target: bareTarget(input.target),
|
|
38615
|
+
limit: input.limit,
|
|
38616
|
+
order_by: ["ranked_serp_element.serp_item.etv,desc"]
|
|
38617
|
+
})).map((item) => pick(item, {
|
|
38618
|
+
keyword: "keyword_data.keyword",
|
|
38619
|
+
position: "ranked_serp_element.serp_item.rank_absolute",
|
|
38620
|
+
url: "ranked_serp_element.serp_item.url",
|
|
38621
|
+
search_volume: "keyword_data.keyword_info.search_volume",
|
|
38622
|
+
etv: "ranked_serp_element.serp_item.etv",
|
|
38623
|
+
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
38624
|
+
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
38625
|
+
}));
|
|
38626
|
+
if (input.action === "serp") {
|
|
38627
|
+
const rows = [];
|
|
38628
|
+
for (const keyword of input.keywords.slice(0, 5)) {
|
|
38629
|
+
const items = await post("v3/serp/google/organic/live/advanced", {
|
|
38630
|
+
keyword,
|
|
38631
|
+
depth: Math.min(input.limit, 100)
|
|
38632
|
+
});
|
|
38633
|
+
for (const item of items) {
|
|
38634
|
+
if (item.type !== "organic") continue;
|
|
38635
|
+
rows.push({
|
|
38636
|
+
keyword,
|
|
38637
|
+
...pick(item, {
|
|
38638
|
+
position: "rank_absolute",
|
|
38639
|
+
url: "url",
|
|
38640
|
+
domain: "domain",
|
|
38641
|
+
title: "title"
|
|
38642
|
+
})
|
|
38643
|
+
});
|
|
38644
|
+
}
|
|
38645
|
+
}
|
|
38646
|
+
return rows;
|
|
38647
|
+
}
|
|
38648
|
+
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
38649
|
+
target: bareTarget(input.target),
|
|
38650
|
+
limit: input.limit,
|
|
38651
|
+
order_by: ["rank,desc"]
|
|
38652
|
+
})).map((item) => pick(item, {
|
|
38653
|
+
domain: "domain",
|
|
38654
|
+
rank: "rank",
|
|
38655
|
+
backlinks: "backlinks",
|
|
38656
|
+
referring_pages: "referring_pages",
|
|
38657
|
+
first_seen: "first_seen"
|
|
38658
|
+
}));
|
|
38659
|
+
return (await post("v3/dataforseo_labs/google/competitors_domain/live", {
|
|
38660
|
+
target: bareTarget(input.target),
|
|
38661
|
+
limit: input.limit
|
|
38662
|
+
})).map((item) => pick(item, {
|
|
38663
|
+
domain: "domain",
|
|
38664
|
+
shared_keywords: "intersections",
|
|
38665
|
+
avg_position: "avg_position",
|
|
38666
|
+
organic_keywords: "full_domain_metrics.organic.count",
|
|
38667
|
+
organic_etv: "full_domain_metrics.organic.etv"
|
|
38668
|
+
}));
|
|
38669
|
+
}
|
|
38670
|
+
function isPage(target) {
|
|
38671
|
+
return /\/./.test(bareTarget(target));
|
|
38672
|
+
}
|
|
38673
|
+
function bareTarget(target) {
|
|
38674
|
+
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
38675
|
+
}
|
|
38676
|
+
async function ahrefs(key, input) {
|
|
38677
|
+
const get = async (path, params) => {
|
|
38678
|
+
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
38679
|
+
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
38680
|
+
return readJson(await call("ahrefs", new Request(url, { headers: {
|
|
38681
|
+
authorization: `Bearer ${key}`,
|
|
38682
|
+
accept: "application/json"
|
|
38683
|
+
} })), "Ahrefs");
|
|
38684
|
+
};
|
|
38685
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
38686
|
+
if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
|
|
38687
|
+
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
38688
|
+
country: input.country,
|
|
38689
|
+
keywords: input.keywords.slice(0, 100).join(",")
|
|
38690
|
+
})).keywords ?? []).map(flattenIntents);
|
|
38691
|
+
if (input.action === "ranked_keywords") return (await get("site-explorer/organic-keywords", {
|
|
38692
|
+
select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
|
|
38693
|
+
target: input.target,
|
|
38694
|
+
country: input.country,
|
|
38695
|
+
date: today,
|
|
38696
|
+
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
38697
|
+
limit: String(input.limit),
|
|
38698
|
+
order_by: "sum_traffic:desc"
|
|
38699
|
+
})).keywords ?? [];
|
|
38700
|
+
if (input.action === "serp") {
|
|
38701
|
+
const rows = [];
|
|
38702
|
+
for (const keyword of input.keywords.slice(0, 5)) {
|
|
38703
|
+
const data = await get("serp-overview/serp-overview", {
|
|
38704
|
+
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
38705
|
+
keyword,
|
|
38706
|
+
country: input.country,
|
|
38707
|
+
top_positions: String(Math.min(input.limit, 100))
|
|
38708
|
+
});
|
|
38709
|
+
for (const row of data.positions ?? []) rows.push({
|
|
38710
|
+
keyword,
|
|
38711
|
+
...row
|
|
38712
|
+
});
|
|
38713
|
+
}
|
|
38714
|
+
return rows;
|
|
38715
|
+
}
|
|
38716
|
+
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
38717
|
+
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
38718
|
+
target: input.target,
|
|
38719
|
+
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
38720
|
+
history: "live",
|
|
38721
|
+
limit: String(input.limit),
|
|
38722
|
+
order_by: "domain_rating:desc"
|
|
38723
|
+
})).refdomains ?? [];
|
|
38724
|
+
return (await get("site-explorer/organic-competitors", {
|
|
38725
|
+
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
38726
|
+
target: input.target,
|
|
38727
|
+
country: input.country,
|
|
38728
|
+
date: today,
|
|
38729
|
+
mode: "subdomains",
|
|
38730
|
+
limit: String(input.limit),
|
|
38731
|
+
order_by: "keywords_common:desc"
|
|
38732
|
+
})).competitors ?? [];
|
|
38733
|
+
}
|
|
38734
|
+
function flattenIntents(row) {
|
|
38735
|
+
const intents = row.intents;
|
|
38736
|
+
if (!intents || typeof intents !== "object") return row;
|
|
38737
|
+
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
38738
|
+
return {
|
|
38739
|
+
...row,
|
|
38740
|
+
intents: names.join(" ") || null
|
|
38741
|
+
};
|
|
38742
|
+
}
|
|
38267
38743
|
function rowsText(input) {
|
|
38268
38744
|
const total = input.rows.length;
|
|
38269
38745
|
if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
|
|
@@ -38382,6 +38858,198 @@ async function runRows(input, deps) {
|
|
|
38382
38858
|
}
|
|
38383
38859
|
};
|
|
38384
38860
|
}
|
|
38861
|
+
const QUESTION_DAYS = 90;
|
|
38862
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
38863
|
+
const urls = (items) => items.map((item) => item.url).filter((url) => typeof url === "string");
|
|
38864
|
+
const overview = (result) => {
|
|
38865
|
+
const block = list(result.items).find((item) => item.type === "ai_overview");
|
|
38866
|
+
if (!block) return null;
|
|
38867
|
+
const parts = list(block.items);
|
|
38868
|
+
return {
|
|
38869
|
+
markdown: typeof block.markdown === "string" ? block.markdown : parts.map((part) => String(part.markdown ?? "")).join("\n"),
|
|
38870
|
+
sources: urls([...list(block.references), ...parts.flatMap((part) => list(part.references))]),
|
|
38871
|
+
model: null
|
|
38872
|
+
};
|
|
38873
|
+
};
|
|
38874
|
+
const ENGINES = [
|
|
38875
|
+
{
|
|
38876
|
+
name: "chatgpt",
|
|
38877
|
+
means: "a ChatGPT search, read by DataForSEO",
|
|
38878
|
+
path: "v3/ai_optimization/chat_gpt/llm_scraper/live/advanced",
|
|
38879
|
+
languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
|
|
38880
|
+
task: (question, market) => ({
|
|
38881
|
+
keyword: question,
|
|
38882
|
+
...market
|
|
38883
|
+
}),
|
|
38884
|
+
shown: (result) => {
|
|
38885
|
+
const markdown = typeof result.markdown === "string" ? result.markdown : "";
|
|
38886
|
+
const sources = urls(list(result.sources));
|
|
38887
|
+
if (!markdown && !sources.length) return null;
|
|
38888
|
+
return {
|
|
38889
|
+
markdown,
|
|
38890
|
+
sources,
|
|
38891
|
+
model: typeof result.model === "string" ? result.model : null
|
|
38892
|
+
};
|
|
38893
|
+
}
|
|
38894
|
+
},
|
|
38895
|
+
{
|
|
38896
|
+
name: "google_ai_mode",
|
|
38897
|
+
means: "Google AI Mode",
|
|
38898
|
+
path: "v3/serp/google/ai_mode/live/advanced",
|
|
38899
|
+
languages: "v3/serp/google/ai_mode/languages",
|
|
38900
|
+
task: (question, market) => ({
|
|
38901
|
+
keyword: question,
|
|
38902
|
+
...market
|
|
38903
|
+
}),
|
|
38904
|
+
shown: overview
|
|
38905
|
+
},
|
|
38906
|
+
{
|
|
38907
|
+
name: "google_ai_overview",
|
|
38908
|
+
means: "the AI Overview on a Google search",
|
|
38909
|
+
path: "v3/serp/google/organic/live/advanced",
|
|
38910
|
+
languages: "v3/serp/google/languages",
|
|
38911
|
+
task: (question, market) => ({
|
|
38912
|
+
keyword: question,
|
|
38913
|
+
depth: 10,
|
|
38914
|
+
load_async_ai_overview: true,
|
|
38915
|
+
...market
|
|
38916
|
+
}),
|
|
38917
|
+
shown: overview
|
|
38918
|
+
}
|
|
38919
|
+
];
|
|
38920
|
+
function prose(markdown) {
|
|
38921
|
+
return markdown.replace(/\[([^\]]*)\]\(https?:\/\/[^)]*\)/g, (_, label) => /^\s*(\d+|[\w-]+(\.[\w-]+)+)\s*$/.test(label) ? " " : label).replace(/https?:\/\/\S+/g, " ");
|
|
38922
|
+
}
|
|
38923
|
+
function onSite(url, domain) {
|
|
38924
|
+
try {
|
|
38925
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
38926
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
38927
|
+
} catch {
|
|
38928
|
+
return false;
|
|
38929
|
+
}
|
|
38930
|
+
}
|
|
38931
|
+
async function ask(keys, engine, question, market, site) {
|
|
38932
|
+
if (typeof market === "string") return {
|
|
38933
|
+
result: "unavailable",
|
|
38934
|
+
model: null,
|
|
38935
|
+
cited_url: null,
|
|
38936
|
+
answered_at: null,
|
|
38937
|
+
cost_usd: 0,
|
|
38938
|
+
error: market
|
|
38939
|
+
};
|
|
38940
|
+
try {
|
|
38941
|
+
const done = await dataForSeoTask(keys, engine.path, engine.task(question, market));
|
|
38942
|
+
const answered = {
|
|
38943
|
+
answered_at: String(done.result?.datetime ?? "").slice(0, 10) || null,
|
|
38944
|
+
cost_usd: done.cost
|
|
38945
|
+
};
|
|
38946
|
+
const shown = done.result && engine.shown(done.result);
|
|
38947
|
+
if (!shown) return {
|
|
38948
|
+
result: "no_answer",
|
|
38949
|
+
model: null,
|
|
38950
|
+
cited_url: null,
|
|
38951
|
+
...answered
|
|
38952
|
+
};
|
|
38953
|
+
const base = {
|
|
38954
|
+
model: shown.model,
|
|
38955
|
+
...answered
|
|
38956
|
+
};
|
|
38957
|
+
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
38958
|
+
const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
|
|
38959
|
+
if (cited) return {
|
|
38960
|
+
result: "cited",
|
|
38961
|
+
cited_url: cited,
|
|
38962
|
+
...base
|
|
38963
|
+
};
|
|
38964
|
+
const text = prose(shown.markdown);
|
|
38965
|
+
return {
|
|
38966
|
+
result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
|
|
38967
|
+
cited_url: null,
|
|
38968
|
+
...base
|
|
38969
|
+
};
|
|
38970
|
+
} catch (error) {
|
|
38971
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
38972
|
+
return {
|
|
38973
|
+
result: "unavailable",
|
|
38974
|
+
model: null,
|
|
38975
|
+
cited_url: null,
|
|
38976
|
+
answered_at: null,
|
|
38977
|
+
cost_usd: error.cost ?? 0,
|
|
38978
|
+
error: error instanceof Error ? error.message.slice(0, 200) : "failed"
|
|
38979
|
+
};
|
|
38980
|
+
}
|
|
38981
|
+
}
|
|
38982
|
+
async function marketsFor(keys, country) {
|
|
38983
|
+
const code = country.toUpperCase();
|
|
38984
|
+
const language = dataForSeoLanguage(country) ?? dataForSeoMarket(country, null).language_code;
|
|
38985
|
+
const [locations, ...languages] = await Promise.all([dataForSeoList(keys, "v3/ai_optimization/chat_gpt/llm_scraper/locations"), ...ENGINES.map((engine) => dataForSeoList(keys, engine.languages))]);
|
|
38986
|
+
const place = locations.find((row) => row.location_type === "Country" && row.country_iso_code === code);
|
|
38987
|
+
const where = place ? { location_code: Number(place.location_code) } : { location_name: dataForSeoMarket(country, null).location_name };
|
|
38988
|
+
return new Map(ENGINES.map((engine, index) => {
|
|
38989
|
+
if (engine.name === "chatgpt" && !place) return [engine.name, `DataForSEO's ChatGPT search has no data for country "${country}".`];
|
|
38990
|
+
const known = (languages[index] ?? []).some((row) => row.language_code === language);
|
|
38991
|
+
return [engine.name, known ? {
|
|
38992
|
+
...where,
|
|
38993
|
+
language_code: language
|
|
38994
|
+
} : `DataForSEO's ${engine.name} has no "${language}" language, the language used for country "${country}".`];
|
|
38995
|
+
}));
|
|
38996
|
+
}
|
|
38997
|
+
const usd = (value) => Math.round(value * 1e4) / 1e4;
|
|
38998
|
+
async function runAiAnswers(input, headers, deps, more) {
|
|
38999
|
+
const keys = dataForSeoKeys(headers);
|
|
39000
|
+
const domain = registrableDomain(input.site)?.domain;
|
|
39001
|
+
if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
|
|
39002
|
+
const range = dateWindow({}, QUESTION_DAYS);
|
|
39003
|
+
const questions = ((await aiQueriesReport({
|
|
39004
|
+
site: input.site,
|
|
39005
|
+
...range
|
|
39006
|
+
}, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
|
|
39007
|
+
const site = {
|
|
39008
|
+
domain,
|
|
39009
|
+
terms: deriveBrandTerms({ siteUrl: input.site })
|
|
39010
|
+
};
|
|
39011
|
+
const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
|
|
39012
|
+
const answers = await Promise.all(questions.flatMap((question) => ENGINES.map(async (engine) => ({
|
|
39013
|
+
question: question.query,
|
|
39014
|
+
impressions: question.impressions,
|
|
39015
|
+
engine: engine.name,
|
|
39016
|
+
...await ask(keys, engine, question.query, markets.get(engine.name) ?? "no market", site)
|
|
39017
|
+
}))));
|
|
39018
|
+
const cost = usd(answers.reduce((sum, row) => sum + row.cost_usd, 0));
|
|
39019
|
+
const count = (result) => answers.filter((row) => row.result === result).length;
|
|
39020
|
+
const notes = [questions.length ? `The top ${questions.length} askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, by impressions. No calls were made for other questions.` : `No askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, so no answers were checked and nothing was spent.`, ...questions.length ? [
|
|
39021
|
+
`Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}. DataForSEO cost: $${cost}.`,
|
|
39022
|
+
"Each row is one sample of an answer that changes from run to run, not a ranking.",
|
|
39023
|
+
`cited: the answer lists or links a page on ${domain} or a subdomain. mentioned: no such source, but the answer text names ${[domain, ...site.terms].join(" or ")}, a text match. no_answer: the engine showed no AI answer for the question. model: the model DataForSEO reports for the answer, when it reports one.`,
|
|
39024
|
+
`Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
|
|
39025
|
+
] : []];
|
|
39026
|
+
return {
|
|
39027
|
+
text: rowsText({
|
|
39028
|
+
title: `AI answers for ${input.site}, ${input.country}.`,
|
|
39029
|
+
rows: answers,
|
|
39030
|
+
offset: input.offset,
|
|
39031
|
+
limit: 50,
|
|
39032
|
+
detail: input.detail,
|
|
39033
|
+
notes,
|
|
39034
|
+
more
|
|
39035
|
+
}),
|
|
39036
|
+
data: {
|
|
39037
|
+
provider: "dataforseo",
|
|
39038
|
+
action: "ai_answers",
|
|
39039
|
+
site: input.site,
|
|
39040
|
+
country: input.country,
|
|
39041
|
+
questionsFrom: {
|
|
39042
|
+
section: "ai-queries",
|
|
39043
|
+
...range
|
|
39044
|
+
},
|
|
39045
|
+
questions: questions.map((question) => question.query),
|
|
39046
|
+
costUsd: cost,
|
|
39047
|
+
total: answers.length,
|
|
39048
|
+
offset: input.offset,
|
|
39049
|
+
rows: answers.slice(input.offset, input.offset + 50)
|
|
39050
|
+
}
|
|
39051
|
+
};
|
|
39052
|
+
}
|
|
38385
39053
|
//#endregion
|
|
38386
39054
|
//#region packages/core/dist/commands/analytics.js
|
|
38387
39055
|
async function analyticsSignIn(accounts, property, signIn) {
|
|
@@ -39446,196 +40114,6 @@ function temporaryRedirectNote(pages) {
|
|
|
39446
40114
|
return `Note: 302 and 307 are temporary redirects; a permanent move usually uses 301 or 308. Temporary redirects here: ${temporary.map((page) => `${page.url} (${page.status} to ${page.redirects_to})`).join("; ")}.`;
|
|
39447
40115
|
}
|
|
39448
40116
|
//#endregion
|
|
39449
|
-
//#region packages/core/dist/analyze/ai-queries.js
|
|
39450
|
-
const RULES = [
|
|
39451
|
-
["agent_prompt", "^(search the web for|browse the web|find sources)|do not invent|return the [0-9]+ most relevant|include source urls|context: location|do not include location|question: "],
|
|
39452
|
-
["tracker", "\\. ?my location is |^evaluate the .+ on "],
|
|
39453
|
-
["reply", "^(yes|yeah|yep|ok|okay|sure|yes please|go on|continue|keep going|tell me more|why not|how so|really|exactly|show me|and then|thanks|thank you|got it|what else|anything else)[?!.,]*$"],
|
|
39454
|
-
["follow_up", "^(what|how) about .{1,40}$|^(and|so|ok so|okay so|but) (what|which|how|why) "],
|
|
39455
|
-
["operator", "(^|\\s)-?(site|before|after|intitle|inurl|intext|filetype):|\"[^\"]*\" or \""],
|
|
39456
|
-
["chat", "\\b(please|can you|could you|give me|show me|tell me|i want|i need|i have|i am|should i|do i need|is it worth|help me|walk me through)\\b|^(what|how|why|is|does|can|will|would) (it|that|this|they)\\b"],
|
|
39457
|
-
["long", "^(\\S+\\s+){9,}\\S+$"]
|
|
39458
|
-
];
|
|
39459
|
-
const AI_QUERY_KINDS = {
|
|
39460
|
-
agent_prompt: "text from an agent prompt, such as \"search the web for\"",
|
|
39461
|
-
tracker: "a visibility-tool probe, such as \"my location is\"",
|
|
39462
|
-
reply: "a bare reply, such as \"yes\" or \"tell me more\"",
|
|
39463
|
-
follow_up: "a follow-up, such as \"what about …\"",
|
|
39464
|
-
operator: "search operators, such as site:, before: or \"a\" or \"b\"",
|
|
39465
|
-
chat: "chat phrasing, such as \"can you\" or \"should i\"",
|
|
39466
|
-
long: "ten or more words with no other sign"
|
|
39467
|
-
};
|
|
39468
|
-
const MATCHERS = RULES.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
|
|
39469
|
-
const AI_QUERY_PATTERN = RULES.map(([, pattern]) => `(?:${pattern})`).join("|");
|
|
39470
|
-
function aiQueryKind(query) {
|
|
39471
|
-
const text = query.trim().toLowerCase();
|
|
39472
|
-
return MATCHERS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
|
|
39473
|
-
}
|
|
39474
|
-
const STOP = new Set("the and for with that this what which who how why when where are is was can does do did you your our from about into than then them they their there have has had will would should could not but its it’s me my i am of to in on at by or an as be if so".split(" "));
|
|
39475
|
-
function words(query) {
|
|
39476
|
-
return new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3 && !STOP.has(word)).map((word) => word.length > 4 && word.endsWith("s") ? word.slice(0, -1) : word));
|
|
39477
|
-
}
|
|
39478
|
-
const SAME_QUESTION = .6;
|
|
39479
|
-
const MAX_ROWS = 25e3;
|
|
39480
|
-
const EXAMPLES = 3;
|
|
39481
|
-
const CANDIDATES = 200;
|
|
39482
|
-
const overlap = (a, b, weight) => {
|
|
39483
|
-
let shared = 0;
|
|
39484
|
-
let union = 0;
|
|
39485
|
-
for (const word of a) {
|
|
39486
|
-
union += weight(word);
|
|
39487
|
-
if (b.has(word)) shared += weight(word);
|
|
39488
|
-
}
|
|
39489
|
-
for (const word of b) if (!a.has(word)) union += weight(word);
|
|
39490
|
-
return union ? shared / union : 0;
|
|
39491
|
-
};
|
|
39492
|
-
const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
|
|
39493
|
-
async function aiQueriesReport(input, searchAnalytics) {
|
|
39494
|
-
const range = {
|
|
39495
|
-
startDate: input.startDate,
|
|
39496
|
-
endDate: input.endDate
|
|
39497
|
-
};
|
|
39498
|
-
const [matched, site] = await Promise.all([searchAnalytics(input.site, {
|
|
39499
|
-
...range,
|
|
39500
|
-
dimensions: ["query", "page"],
|
|
39501
|
-
type: "web",
|
|
39502
|
-
dataState: "final",
|
|
39503
|
-
maxRows: MAX_ROWS,
|
|
39504
|
-
dimensionFilterGroups: [{ filters: [{
|
|
39505
|
-
dimension: "query",
|
|
39506
|
-
operator: "includingRegex",
|
|
39507
|
-
expression: AI_QUERY_PATTERN
|
|
39508
|
-
}] }]
|
|
39509
|
-
}), searchAnalytics(input.site, {
|
|
39510
|
-
...range,
|
|
39511
|
-
dimensions: [],
|
|
39512
|
-
type: "web",
|
|
39513
|
-
dataState: "final"
|
|
39514
|
-
})]);
|
|
39515
|
-
const rows = [...matched.rows].sort((a, b) => b.impressions - a.impressions || String(a.keys[0]).localeCompare(String(b.keys[0])) || String(a.keys[1]).localeCompare(String(b.keys[1])));
|
|
39516
|
-
const usage = /* @__PURE__ */ new Map();
|
|
39517
|
-
const unique = new Set(rows.map((row) => String(row.keys[0] ?? "")));
|
|
39518
|
-
for (const query of unique) for (const word of words(query)) usage.set(word, (usage.get(word) ?? 0) + 1);
|
|
39519
|
-
const weight = (word) => Math.log((unique.size + 1) / (usage.get(word) ?? 1));
|
|
39520
|
-
const groups = [];
|
|
39521
|
-
const byWord = /* @__PURE__ */ new Map();
|
|
39522
|
-
const byText = /* @__PURE__ */ new Map();
|
|
39523
|
-
const kindOf = /* @__PURE__ */ new Map();
|
|
39524
|
-
for (const row of rows) {
|
|
39525
|
-
const query = String(row.keys[0] ?? "");
|
|
39526
|
-
const page = String(row.keys[1] ?? "");
|
|
39527
|
-
const kind = aiQueryKind(query);
|
|
39528
|
-
if (!kind) continue;
|
|
39529
|
-
const text = query.trim().toLowerCase();
|
|
39530
|
-
const own = words(query);
|
|
39531
|
-
let group = byText.get(text);
|
|
39532
|
-
if (!group) {
|
|
39533
|
-
let checked = 0;
|
|
39534
|
-
for (const word of own) {
|
|
39535
|
-
for (const candidate of byWord.get(word) ?? []) {
|
|
39536
|
-
if (++checked > CANDIDATES) break;
|
|
39537
|
-
if (overlap(own, candidate.words, weight) >= SAME_QUESTION) {
|
|
39538
|
-
group = candidate;
|
|
39539
|
-
break;
|
|
39540
|
-
}
|
|
39541
|
-
}
|
|
39542
|
-
if (group || checked > CANDIDATES) break;
|
|
39543
|
-
}
|
|
39544
|
-
}
|
|
39545
|
-
if (!group) {
|
|
39546
|
-
group = {
|
|
39547
|
-
words: own,
|
|
39548
|
-
queries: /* @__PURE__ */ new Map(),
|
|
39549
|
-
pages: /* @__PURE__ */ new Map(),
|
|
39550
|
-
kinds: /* @__PURE__ */ new Map(),
|
|
39551
|
-
clicks: 0,
|
|
39552
|
-
impressions: 0,
|
|
39553
|
-
positionWeight: 0
|
|
39554
|
-
};
|
|
39555
|
-
groups.push(group);
|
|
39556
|
-
for (const word of own) {
|
|
39557
|
-
const list = byWord.get(word) ?? [];
|
|
39558
|
-
list.push(group);
|
|
39559
|
-
byWord.set(word, list);
|
|
39560
|
-
}
|
|
39561
|
-
}
|
|
39562
|
-
byText.set(text, group);
|
|
39563
|
-
if (!kindOf.has(text)) {
|
|
39564
|
-
kindOf.set(text, kind);
|
|
39565
|
-
group.kinds.set(kind, (group.kinds.get(kind) ?? 0) + 1);
|
|
39566
|
-
}
|
|
39567
|
-
group.queries.set(query, (group.queries.get(query) ?? 0) + row.impressions);
|
|
39568
|
-
group.pages.set(page, (group.pages.get(page) ?? 0) + row.impressions);
|
|
39569
|
-
group.clicks += row.clicks;
|
|
39570
|
-
group.impressions += row.impressions;
|
|
39571
|
-
group.positionWeight += row.position * row.impressions;
|
|
39572
|
-
}
|
|
39573
|
-
const byKind = /* @__PURE__ */ new Map();
|
|
39574
|
-
for (const kind of kindOf.values()) {
|
|
39575
|
-
const entry = byKind.get(kind) ?? {
|
|
39576
|
-
queries: 0,
|
|
39577
|
-
impressions: 0
|
|
39578
|
-
};
|
|
39579
|
-
entry.queries += 1;
|
|
39580
|
-
byKind.set(kind, entry);
|
|
39581
|
-
}
|
|
39582
|
-
for (const row of rows) {
|
|
39583
|
-
const kind = kindOf.get(String(row.keys[0] ?? "").trim().toLowerCase());
|
|
39584
|
-
const entry = kind && byKind.get(kind);
|
|
39585
|
-
if (entry) entry.impressions += row.impressions;
|
|
39586
|
-
}
|
|
39587
|
-
const queries = kindOf.size;
|
|
39588
|
-
const impressions = groups.reduce((sum, g) => sum + g.impressions, 0);
|
|
39589
|
-
const clicks = groups.reduce((sum, g) => sum + g.clicks, 0);
|
|
39590
|
-
const total = site.rows[0] ?? {
|
|
39591
|
-
clicks: 0,
|
|
39592
|
-
impressions: 0
|
|
39593
|
-
};
|
|
39594
|
-
const items = groups.map((group) => {
|
|
39595
|
-
const most = (map) => [...map].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
|
|
39596
|
-
const [first, ...rest] = most(group.queries);
|
|
39597
|
-
const kind = [...group.kinds].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
39598
|
-
return {
|
|
39599
|
-
query: first ?? "",
|
|
39600
|
-
page: most(group.pages)[0] ?? "",
|
|
39601
|
-
pages: group.pages.size,
|
|
39602
|
-
kind,
|
|
39603
|
-
wordings: group.queries.size,
|
|
39604
|
-
impressions: group.impressions,
|
|
39605
|
-
clicks: group.clicks,
|
|
39606
|
-
ctr: group.impressions ? round(group.clicks / group.impressions, 4) : 0,
|
|
39607
|
-
position: group.impressions ? round(group.positionWeight / group.impressions, 1) : 0,
|
|
39608
|
-
otherWordings: rest.slice(0, EXAMPLES)
|
|
39609
|
-
};
|
|
39610
|
-
}).sort((a, b) => b.impressions - a.impressions || b.wordings - a.wordings || a.query.localeCompare(b.query) || a.page.localeCompare(b.page));
|
|
39611
|
-
const pct = (part, whole) => whole ? round(part / whole * 100, 1) : null;
|
|
39612
|
-
const kinds = [...byKind].sort((a, b) => b[1].impressions - a[1].impressions || a[0].localeCompare(b[0])).map(([kind, entry]) => `${kind} ${entry.queries}`).join(", ");
|
|
39613
|
-
const capped = matched.rowsFetched >= MAX_ROWS;
|
|
39614
|
-
return {
|
|
39615
|
-
range,
|
|
39616
|
-
verdict: queries ? `${queries} queries in ${groups.length} questions have the shape of AI conversations or agent searches.` : "No queries with the shape of AI conversations or agent searches.",
|
|
39617
|
-
summary: {
|
|
39618
|
-
queries,
|
|
39619
|
-
questions: groups.length,
|
|
39620
|
-
impressions,
|
|
39621
|
-
clicks,
|
|
39622
|
-
shareOfImpressionsPct: pct(impressions, total.impressions),
|
|
39623
|
-
ctr: impressions ? round(clicks / impressions, 4) : 0,
|
|
39624
|
-
siteCtr: total.impressions ? round(total.clicks / total.impressions, 4) : 0,
|
|
39625
|
-
byKind: kinds || "none"
|
|
39626
|
-
},
|
|
39627
|
-
kinds: AI_QUERY_KINDS,
|
|
39628
|
-
items,
|
|
39629
|
-
caveats: [
|
|
39630
|
-
"A heuristic: these queries match the patterns of AI conversations and agent searches. Search Console does not mark AI Mode or AI Overviews, and people type some of these too.",
|
|
39631
|
-
"A minimum: Search Console leaves out rare queries, and these are rare by nature.",
|
|
39632
|
-
"Rewordings of one question are grouped by their shared words; page is the page with the most impressions for it.",
|
|
39633
|
-
"Search Console’s Generative AI report is not in its API, so it is not here.",
|
|
39634
|
-
...capped ? [`Capped at the first ${MAX_ROWS} matching query and page rows.`] : []
|
|
39635
|
-
]
|
|
39636
|
-
};
|
|
39637
|
-
}
|
|
39638
|
-
//#endregion
|
|
39639
40117
|
//#region packages/core/dist/commands/report.js
|
|
39640
40118
|
const SECTIONS = {
|
|
39641
40119
|
overview: "Site totals (clicks, impressions, CTR, position) against the previous period, by device, with the top pages and queries and their change, and Google Analytics sessions for the same dates from the property whose web stream matches the site.",
|
|
@@ -39681,7 +40159,7 @@ function nextFor(section, call) {
|
|
|
39681
40159
|
"content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
|
|
39682
40160
|
cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
|
|
39683
40161
|
"traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
|
|
39684
|
-
"ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them.`,
|
|
40162
|
+
"ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them; ${call("research")} ai_answers with the site as target checks whether AI answers cite it for the top 10 askable questions here (about $0.12 of DataForSEO credit), so show the person those questions and ask first.`,
|
|
39685
40163
|
bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
|
|
39686
40164
|
}[section];
|
|
39687
40165
|
}
|
|
@@ -40120,225 +40598,17 @@ async function runBingReport(input, deps) {
|
|
|
40120
40598
|
};
|
|
40121
40599
|
}
|
|
40122
40600
|
//#endregion
|
|
40123
|
-
//#region packages/core/dist/commands/research-providers.js
|
|
40124
|
-
function need(headers, provider) {
|
|
40125
|
-
const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
|
|
40126
|
-
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
|
|
40127
|
-
return values;
|
|
40128
|
-
}
|
|
40129
|
-
async function call(provider, request) {
|
|
40130
|
-
const response = await fetch(request);
|
|
40131
|
-
if (response.ok) return response;
|
|
40132
|
-
const text = (await response.text()).slice(0, 300);
|
|
40133
|
-
throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
|
|
40134
|
-
}
|
|
40135
|
-
function requireInput(input) {
|
|
40136
|
-
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
40137
|
-
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
40138
|
-
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
40139
|
-
}
|
|
40140
|
-
async function research(provider, headers, input) {
|
|
40141
|
-
requireInput(input);
|
|
40142
|
-
const keys = need(headers, provider);
|
|
40143
|
-
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
40144
|
-
return ahrefs(keys[0] ?? "", input);
|
|
40145
|
-
}
|
|
40146
|
-
function countryName(code) {
|
|
40147
|
-
return new Intl.DisplayNames("en", { type: "region" }).of(code.toUpperCase()) ?? "United States";
|
|
40148
|
-
}
|
|
40149
|
-
function mainLanguage(country) {
|
|
40150
|
-
try {
|
|
40151
|
-
return new Intl.Locale(`und-${country.toUpperCase()}`).maximize().language;
|
|
40152
|
-
} catch {
|
|
40153
|
-
return "en";
|
|
40154
|
-
}
|
|
40155
|
-
}
|
|
40156
|
-
const LABS_LANGUAGES = new Map(`
|
|
40157
|
-
ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
|
|
40158
|
-
bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
|
|
40159
|
-
cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
|
|
40160
|
-
gr:el gt:es hk:en hr:hr hu:hu id:id ie:en il:he in:en it:it jo:ar jp:ja
|
|
40161
|
-
ke:en kh:en kr:ko kz:ru lk:en lt:lt lv:lv ma:ar mc:fr md:ro mk:mk mm:en
|
|
40162
|
-
mt:en mx:es my:en ng:en ni:es nl:nl no:nb nz:en pa:es pe:es ph:en pk:en
|
|
40163
|
-
pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
|
|
40164
|
-
th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
|
|
40165
|
-
`.trim().split(/\s+/).map((pair) => pair.split(":")));
|
|
40166
|
-
function dataForSeoLanguage(country) {
|
|
40167
|
-
return LABS_LANGUAGES.get(country.toLowerCase()) ?? null;
|
|
40168
|
-
}
|
|
40169
|
-
async function dataForSeo([login, password], input) {
|
|
40170
|
-
const language = dataForSeoLanguage(input.country);
|
|
40171
|
-
if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
40172
|
-
const market = {
|
|
40173
|
-
location_name: countryName(input.country),
|
|
40174
|
-
language_code: language ?? mainLanguage(input.country)
|
|
40175
|
-
};
|
|
40176
|
-
const post = async (path, task) => {
|
|
40177
|
-
const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
40178
|
-
method: "POST",
|
|
40179
|
-
headers: {
|
|
40180
|
-
authorization: `Basic ${btoa(`${login}:${password}`)}`,
|
|
40181
|
-
"content-type": "application/json"
|
|
40182
|
-
},
|
|
40183
|
-
body: JSON.stringify([{
|
|
40184
|
-
...market,
|
|
40185
|
-
...task
|
|
40186
|
-
}])
|
|
40187
|
-
})), "DataForSEO")).tasks?.[0];
|
|
40188
|
-
if (!result || result.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`);
|
|
40189
|
-
return result.result?.[0]?.items ?? [];
|
|
40190
|
-
};
|
|
40191
|
-
const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
|
|
40192
|
-
const pick = (item, fields) => Object.fromEntries(Object.entries(fields).map(([name, path]) => [name, get(item, path) ?? null]));
|
|
40193
|
-
if (input.action === "keyword_metrics") return (await post("v3/dataforseo_labs/google/keyword_overview/live", { keywords: input.keywords.slice(0, 700) })).map((item) => pick(item, {
|
|
40194
|
-
keyword: "keyword",
|
|
40195
|
-
search_volume: "keyword_info.search_volume",
|
|
40196
|
-
keyword_difficulty: "keyword_properties.keyword_difficulty",
|
|
40197
|
-
cpc: "keyword_info.cpc",
|
|
40198
|
-
competition: "keyword_info.competition",
|
|
40199
|
-
main_intent: "search_intent_info.main_intent"
|
|
40200
|
-
}));
|
|
40201
|
-
if (input.action === "ranked_keywords") return (await post("v3/dataforseo_labs/google/ranked_keywords/live", {
|
|
40202
|
-
target: bareTarget(input.target),
|
|
40203
|
-
limit: input.limit,
|
|
40204
|
-
order_by: ["ranked_serp_element.serp_item.etv,desc"]
|
|
40205
|
-
})).map((item) => pick(item, {
|
|
40206
|
-
keyword: "keyword_data.keyword",
|
|
40207
|
-
position: "ranked_serp_element.serp_item.rank_absolute",
|
|
40208
|
-
url: "ranked_serp_element.serp_item.url",
|
|
40209
|
-
search_volume: "keyword_data.keyword_info.search_volume",
|
|
40210
|
-
etv: "ranked_serp_element.serp_item.etv",
|
|
40211
|
-
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
40212
|
-
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
40213
|
-
}));
|
|
40214
|
-
if (input.action === "serp") {
|
|
40215
|
-
const rows = [];
|
|
40216
|
-
for (const keyword of input.keywords.slice(0, 5)) {
|
|
40217
|
-
const items = await post("v3/serp/google/organic/live/advanced", {
|
|
40218
|
-
keyword,
|
|
40219
|
-
depth: Math.min(input.limit, 100)
|
|
40220
|
-
});
|
|
40221
|
-
for (const item of items) {
|
|
40222
|
-
if (item.type !== "organic") continue;
|
|
40223
|
-
rows.push({
|
|
40224
|
-
keyword,
|
|
40225
|
-
...pick(item, {
|
|
40226
|
-
position: "rank_absolute",
|
|
40227
|
-
url: "url",
|
|
40228
|
-
domain: "domain",
|
|
40229
|
-
title: "title"
|
|
40230
|
-
})
|
|
40231
|
-
});
|
|
40232
|
-
}
|
|
40233
|
-
}
|
|
40234
|
-
return rows;
|
|
40235
|
-
}
|
|
40236
|
-
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
40237
|
-
target: bareTarget(input.target),
|
|
40238
|
-
limit: input.limit,
|
|
40239
|
-
order_by: ["rank,desc"]
|
|
40240
|
-
})).map((item) => pick(item, {
|
|
40241
|
-
domain: "domain",
|
|
40242
|
-
rank: "rank",
|
|
40243
|
-
backlinks: "backlinks",
|
|
40244
|
-
referring_pages: "referring_pages",
|
|
40245
|
-
first_seen: "first_seen"
|
|
40246
|
-
}));
|
|
40247
|
-
return (await post("v3/dataforseo_labs/google/competitors_domain/live", {
|
|
40248
|
-
target: bareTarget(input.target),
|
|
40249
|
-
limit: input.limit
|
|
40250
|
-
})).map((item) => pick(item, {
|
|
40251
|
-
domain: "domain",
|
|
40252
|
-
shared_keywords: "intersections",
|
|
40253
|
-
avg_position: "avg_position",
|
|
40254
|
-
organic_keywords: "full_domain_metrics.organic.count",
|
|
40255
|
-
organic_etv: "full_domain_metrics.organic.etv"
|
|
40256
|
-
}));
|
|
40257
|
-
}
|
|
40258
|
-
function isPage(target) {
|
|
40259
|
-
return /\/./.test(bareTarget(target));
|
|
40260
|
-
}
|
|
40261
|
-
function bareTarget(target) {
|
|
40262
|
-
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
40263
|
-
}
|
|
40264
|
-
async function ahrefs(key, input) {
|
|
40265
|
-
const get = async (path, params) => {
|
|
40266
|
-
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
40267
|
-
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
40268
|
-
return readJson(await call("ahrefs", new Request(url, { headers: {
|
|
40269
|
-
authorization: `Bearer ${key}`,
|
|
40270
|
-
accept: "application/json"
|
|
40271
|
-
} })), "Ahrefs");
|
|
40272
|
-
};
|
|
40273
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
40274
|
-
if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
|
|
40275
|
-
select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
|
|
40276
|
-
country: input.country,
|
|
40277
|
-
keywords: input.keywords.slice(0, 100).join(",")
|
|
40278
|
-
})).keywords ?? []).map(flattenIntents);
|
|
40279
|
-
if (input.action === "ranked_keywords") return (await get("site-explorer/organic-keywords", {
|
|
40280
|
-
select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
|
|
40281
|
-
target: input.target,
|
|
40282
|
-
country: input.country,
|
|
40283
|
-
date: today,
|
|
40284
|
-
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
40285
|
-
limit: String(input.limit),
|
|
40286
|
-
order_by: "sum_traffic:desc"
|
|
40287
|
-
})).keywords ?? [];
|
|
40288
|
-
if (input.action === "serp") {
|
|
40289
|
-
const rows = [];
|
|
40290
|
-
for (const keyword of input.keywords.slice(0, 5)) {
|
|
40291
|
-
const data = await get("serp-overview/serp-overview", {
|
|
40292
|
-
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
40293
|
-
keyword,
|
|
40294
|
-
country: input.country,
|
|
40295
|
-
top_positions: String(Math.min(input.limit, 100))
|
|
40296
|
-
});
|
|
40297
|
-
for (const row of data.positions ?? []) rows.push({
|
|
40298
|
-
keyword,
|
|
40299
|
-
...row
|
|
40300
|
-
});
|
|
40301
|
-
}
|
|
40302
|
-
return rows;
|
|
40303
|
-
}
|
|
40304
|
-
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
40305
|
-
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
40306
|
-
target: input.target,
|
|
40307
|
-
mode: isPage(input.target) ? "exact" : "subdomains",
|
|
40308
|
-
history: "live",
|
|
40309
|
-
limit: String(input.limit),
|
|
40310
|
-
order_by: "domain_rating:desc"
|
|
40311
|
-
})).refdomains ?? [];
|
|
40312
|
-
return (await get("site-explorer/organic-competitors", {
|
|
40313
|
-
select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
|
|
40314
|
-
target: input.target,
|
|
40315
|
-
country: input.country,
|
|
40316
|
-
date: today,
|
|
40317
|
-
mode: "subdomains",
|
|
40318
|
-
limit: String(input.limit),
|
|
40319
|
-
order_by: "keywords_common:desc"
|
|
40320
|
-
})).competitors ?? [];
|
|
40321
|
-
}
|
|
40322
|
-
function flattenIntents(row) {
|
|
40323
|
-
const intents = row.intents;
|
|
40324
|
-
if (!intents || typeof intents !== "object") return row;
|
|
40325
|
-
const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
|
|
40326
|
-
return {
|
|
40327
|
-
...row,
|
|
40328
|
-
intents: names.join(" ") || null
|
|
40329
|
-
};
|
|
40330
|
-
}
|
|
40331
|
-
//#endregion
|
|
40332
40601
|
//#region packages/core/dist/commands/research.js
|
|
40333
40602
|
const RESEARCH_ACTIONS = [
|
|
40334
40603
|
"keyword_metrics",
|
|
40335
40604
|
"ranked_keywords",
|
|
40336
40605
|
"serp",
|
|
40337
40606
|
"competitors",
|
|
40338
|
-
"backlinks"
|
|
40607
|
+
"backlinks",
|
|
40608
|
+
"ai_answers"
|
|
40339
40609
|
];
|
|
40340
40610
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
40341
|
-
const RESEARCH_HELP =
|
|
40611
|
+
const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: referring domains), ai_answers (DataForSEO only; target: the site. It takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself, then asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s live prices are $0.012 a question, so up to about $0.12 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.`;
|
|
40342
40612
|
const GENERIC_PLATFORMS = [
|
|
40343
40613
|
"youtube.com",
|
|
40344
40614
|
"facebook.com",
|
|
@@ -40381,7 +40651,8 @@ async function runResearch(input, keys, options) {
|
|
|
40381
40651
|
const target = input.target ?? "";
|
|
40382
40652
|
const offset = input.offset ?? 0;
|
|
40383
40653
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
40384
|
-
|
|
40654
|
+
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
40655
|
+
const { rows: found, costUsd, partial } = await research(input.provider, keys, {
|
|
40385
40656
|
action: input.action,
|
|
40386
40657
|
keywords: input.keywords ?? [],
|
|
40387
40658
|
target,
|
|
@@ -40405,6 +40676,8 @@ async function runResearch(input, keys, options) {
|
|
|
40405
40676
|
detail: input.detail ?? "summary",
|
|
40406
40677
|
notes: [
|
|
40407
40678
|
`${input.provider} estimates, in the provider’s own units.`,
|
|
40679
|
+
...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
40680
|
+
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
40408
40681
|
...note ? [note] : [],
|
|
40409
40682
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
40410
40683
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -40420,6 +40693,8 @@ async function runResearch(input, keys, options) {
|
|
|
40420
40693
|
targetDomainRating: hasTargetDr ? targetDr : null,
|
|
40421
40694
|
attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
|
|
40422
40695
|
leftOut: note,
|
|
40696
|
+
costUsd,
|
|
40697
|
+
partial,
|
|
40423
40698
|
total: rated.rows.length,
|
|
40424
40699
|
offset,
|
|
40425
40700
|
rows: rated.rows.slice(offset, offset + 50)
|
|
@@ -40832,7 +41107,7 @@ const SHARED_TOOLS = [
|
|
|
40832
41107
|
tool({
|
|
40833
41108
|
name: "seo_research",
|
|
40834
41109
|
command: "research",
|
|
40835
|
-
title: "Keyword, competitor and
|
|
41110
|
+
title: "Keyword, competitor, backlink and AI answer data",
|
|
40836
41111
|
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}`,
|
|
40837
41112
|
input: z.object({
|
|
40838
41113
|
provider: z.enum(RESEARCH_PROVIDERS),
|
|
@@ -40842,10 +41117,22 @@ const SHARED_TOOLS = [
|
|
|
40842
41117
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
40843
41118
|
...detail
|
|
40844
41119
|
}),
|
|
40845
|
-
run: async (surface, input) =>
|
|
40846
|
-
|
|
40847
|
-
|
|
40848
|
-
|
|
41120
|
+
run: async (surface, input) => {
|
|
41121
|
+
const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
|
|
41122
|
+
if (input.action === "ai_answers") {
|
|
41123
|
+
if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
41124
|
+
return runAiAnswers({
|
|
41125
|
+
site: await surface.resolveSite(input.target),
|
|
41126
|
+
country: input.country,
|
|
41127
|
+
detail: input.detail,
|
|
41128
|
+
offset: input.offset
|
|
41129
|
+
}, await surface.keys(), surface.deps, more);
|
|
41130
|
+
}
|
|
41131
|
+
return runResearch(input, await surface.keys(), {
|
|
41132
|
+
drKey: surface.drKey,
|
|
41133
|
+
call: more
|
|
41134
|
+
});
|
|
41135
|
+
}
|
|
40849
41136
|
}),
|
|
40850
41137
|
tool({
|
|
40851
41138
|
name: "seo_data",
|
|
@@ -40919,7 +41206,7 @@ async function runSharedTool(tool, surface, input) {
|
|
|
40919
41206
|
const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools, Clicky and optional keyword accounts, and returns facts with their dates and limits. It gives no advice.
|
|
40920
41207
|
1. seo_sites: the properties the connected accounts can read.
|
|
40921
41208
|
2. seo_report: the main report. Run it without a section first; it shows the overview and every section's count and top rows. Then ask for the one section you need.
|
|
40922
|
-
3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.`;
|
|
41209
|
+
3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data, and whether AI answers cite the site, from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.`;
|
|
40923
41210
|
const mcpCall = (command, args) => {
|
|
40924
41211
|
const name = `seo_${command.replace("-", "_")}`;
|
|
40925
41212
|
const shown = Object.entries(args ?? {}).filter(([, value]) => value !== void 0);
|