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/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-BZhSLbcQ.js";
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 = "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). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.";
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
- const found = await research(input.provider, keys, {
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 backlink data",
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) => runResearch(input, await surface.keys(), {
40846
- drKey: surface.drKey,
40847
- call: (offset) => `seo_research with the same input, detail "rows", offset ${offset}`
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);