seo 0.8.2 → 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-DeY7lZeN.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) {
@@ -38604,7 +39272,7 @@ async function totals$1(found, access, range, before, pages) {
38604
39272
  }, { signIn: access.signIn });
38605
39273
  const onPages = pages ? [filter("landingPage", pages, "CONTAINS")] : [];
38606
39274
  const fromAi = [...onPages, filter("sessionSource", AI_SOURCE_PATTERN, "FULL_REGEXP")];
38607
- const [channels, google, aiSources, aiPages] = await Promise.all([
39275
+ const [channels, google, aiSources, aiPages, engines] = await Promise.all([
38608
39276
  read(["sessionDefaultChannelGroup"], onPages),
38609
39277
  read([], [
38610
39278
  ...onPages,
@@ -38612,7 +39280,8 @@ async function totals$1(found, access, range, before, pages) {
38612
39280
  filter("sessionSource", "google")
38613
39281
  ]),
38614
39282
  read(["sessionSource"], fromAi),
38615
- read(["landingPage"], fromAi)
39283
+ read(["landingPage"], fromAi),
39284
+ read(["sessionSource"], [...onPages, filter("sessionDefaultChannelGroup", "Organic Search")])
38616
39285
  ]);
38617
39286
  const sum = () => ({
38618
39287
  google: {
@@ -38679,6 +39348,7 @@ async function totals$1(found, access, range, before, pages) {
38679
39348
  })).sort((a, b) => b.sessions - a.sessions || b.previousSessions - a.previousSessions || String(a[key]).localeCompare(String(b[key])));
38680
39349
  };
38681
39350
  const bySource = byName(aiSources, "source");
39351
+ const byEngine = byName(engines, "source");
38682
39352
  const aiNow = bySource.reduce((total, row) => total + row.sessions, 0);
38683
39353
  const aiBefore = bySource.reduce((total, row) => total + row.previousSessions, 0);
38684
39354
  const data = { metadata: {
@@ -38687,13 +39357,15 @@ async function totals$1(found, access, range, before, pages) {
38687
39357
  channels,
38688
39358
  google,
38689
39359
  aiSources,
38690
- aiPages
39360
+ aiPages,
39361
+ engines
38691
39362
  ].some((data) => data.metadata?.subjectToThresholding),
38692
39363
  dataLossFromOtherRow: [
38693
39364
  channels,
38694
39365
  google,
38695
39366
  aiSources,
38696
- aiPages
39367
+ aiPages,
39368
+ engines
38697
39369
  ].some((data) => data.metadata?.dataLossFromOtherRow)
38698
39370
  } };
38699
39371
  const notes = [];
@@ -38722,6 +39394,7 @@ async function totals$1(found, access, range, before, pages) {
38722
39394
  previousSessions: periods.before.all.sessions,
38723
39395
  sessionsChangePct: pct(periods.now.all.sessions, periods.before.all.sessions)
38724
39396
  },
39397
+ organicSearchByEngine: byEngine.slice(0, 10),
38725
39398
  aiAssistants: {
38726
39399
  sessions: aiNow,
38727
39400
  previousSessions: aiBefore,
@@ -38734,6 +39407,11 @@ async function totals$1(found, access, range, before, pages) {
38734
39407
  };
38735
39408
  }
38736
39409
  const change$1 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
39410
+ function engineText(rows) {
39411
+ const shown = rows.filter((row) => row.sessions || row.previousSessions);
39412
+ if (shown.length < 2) return "";
39413
+ return `Organic Search sessions by search engine: ${shown.slice(0, AI_ROWS).map((row) => `${row.source} ${row.sessions} (was ${row.previousSessions})`).join(", ")}.`;
39414
+ }
38737
39415
  function aiText(ai) {
38738
39416
  const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
38739
39417
  if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
@@ -38762,10 +39440,11 @@ async function analyticsBeside(access, site, range, before, pages) {
38762
39440
  return {
38763
39441
  text: [
38764
39442
  `Google Analytics, property ${data.propertyName} (${data.property}), matched by its web stream ${data.webStream}${pages ? `, landing pages whose path contains "${pages}"` : ""}: Organic Search sessions from Google ${google.sessions} (was ${google.previousSessions}, ${change$1(google.sessionsChangePct)}), from every search engine ${organic.sessions} (was ${organic.previousSessions}, ${change$1(organic.sessionsChangePct)})${keyEvents}, sessions from all channels ${all.sessions} (was ${all.previousSessions}, ${change$1(all.sessionsChangePct)}).`,
39443
+ engineText(data.organicSearchByEngine),
38765
39444
  "Sessions are Google Analytics’ own count, not Search Console clicks.",
38766
39445
  ...data.notes,
38767
39446
  ...others.length ? [`Other properties with a matching web stream, with fewer sessions: ${others.join(", ")}.`] : []
38768
- ].join(" ").concat("\n\n", aiText(data.aiAssistants)),
39447
+ ].filter(Boolean).join(" ").concat("\n\n", aiText(data.aiAssistants)),
38769
39448
  data: {
38770
39449
  ...data,
38771
39450
  ...others.length ? { otherMatches: others } : {}
@@ -39000,6 +39679,102 @@ async function runBingRows(input, deps) {
39000
39679
  }
39001
39680
  };
39002
39681
  }
39682
+ function moved(rows, range, before, name, limit) {
39683
+ const now = sumWeeks(rows, range);
39684
+ const then = sumWeeks(rows, before);
39685
+ const old = new Map(then.rows.map((row) => [row.key, row]));
39686
+ const seen = new Set(now.rows.map((row) => row.key));
39687
+ const all = now.rows.map((row) => {
39688
+ const earlier = old.get(row.key);
39689
+ return {
39690
+ [name]: row.key,
39691
+ clicks: row.clicks,
39692
+ previousClicks: earlier ? earlier.clicks : null,
39693
+ clicksChange: earlier ? row.clicks - earlier.clicks : null,
39694
+ impressions: row.impressions,
39695
+ position: row.position
39696
+ };
39697
+ });
39698
+ const by = (sign) => all.filter((row) => row.clicksChange !== null && sign * row.clicksChange > 0).sort((a, b) => sign * (b.clicksChange - a.clicksChange) || String(a[name]).localeCompare(String(b[name]))).slice(0, limit);
39699
+ return {
39700
+ top: all.slice(0, limit),
39701
+ biggestLosses: by(-1),
39702
+ biggestGains: by(1),
39703
+ leftTopList: then.rows.filter((row) => !seen.has(row.key)).slice(0, limit).map((row) => ({
39704
+ [name]: row.key,
39705
+ previousClicks: row.clicks
39706
+ })),
39707
+ weeks: now.weeks,
39708
+ previousWeeks: then.weeks
39709
+ };
39710
+ }
39711
+ async function bingOverview(source, range, before) {
39712
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
39713
+ const [totals, queries, pages] = await Promise.all([
39714
+ bingTotals(source, range, before),
39715
+ bingQueries(source.call, source.site),
39716
+ bingPages(source.call, source.site)
39717
+ ]);
39718
+ if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
39719
+ const { current: now, previous: then } = totals;
39720
+ const q = moved(queries.rows, range, before, "query", 15);
39721
+ const p = moved(pages.rows, range, before, "page", 15);
39722
+ return {
39723
+ bingSite: source.site,
39724
+ range,
39725
+ comparisonRange: before,
39726
+ summary: {
39727
+ clicks: now.clicks,
39728
+ previousClicks: then.clicks,
39729
+ clicksChangePct: pct(now.clicks, then.clicks),
39730
+ impressions: now.impressions,
39731
+ previousImpressions: then.impressions,
39732
+ impressionsChangePct: pct(now.impressions, then.impressions),
39733
+ days: now.days,
39734
+ previousDays: then.days
39735
+ },
39736
+ pages: p,
39737
+ queries: q,
39738
+ caveats: [
39739
+ ...now.days < now.expectedDays || then.days < then.expectedDays ? [`Bing has data for ${now.days} of ${now.expectedDays} days (earlier period ${then.days} of ${then.expectedDays}), so its totals are partial.`] : [],
39740
+ `Totals are Bing's daily site totals. Page and query rows are Bing's weekly top rows: weeks ${q.weeks.join(", ") || "none"} against ${q.previousWeeks.join(", ") || "none"}.`,
39741
+ ...BING_LIMITS.slice(1)
39742
+ ]
39743
+ };
39744
+ }
39745
+ function bingOverviewText(report, heading, origin, rowsPerList) {
39746
+ const range = report.range;
39747
+ const before = report.comparisonRange;
39748
+ const t = report.summary;
39749
+ const columns = (name) => [
39750
+ name,
39751
+ "clicks",
39752
+ "previousClicks",
39753
+ "clicksChange",
39754
+ "impressions",
39755
+ "position"
39756
+ ];
39757
+ const part = (title, rows, name) => {
39758
+ const list = rows ?? [];
39759
+ return list.length ? `${title}:\n${table(list.slice(0, rowsPerList), columns(name), origin)}` : "";
39760
+ };
39761
+ const group = (key, name) => {
39762
+ const lists = report[key];
39763
+ return [
39764
+ part(`Biggest Bing ${key === "pages" ? "page" : "query"} click losses`, lists.biggestLosses, name),
39765
+ part(`Biggest Bing ${key === "pages" ? "page" : "query"} click gains`, lists.biggestGains, name),
39766
+ lists.leftTopList?.length ? `Left Bing's top ${key} list (now unknown, not zero): ${lists.leftTopList.slice(0, rowsPerList).map((row) => `${row[name]} (had ${row.previousClicks} clicks)`).join(", ")}.` : "",
39767
+ rowsPerList >= 10 ? part(`Top Bing ${key}`, lists.top, name) : ""
39768
+ ].filter(Boolean).join("\n\n");
39769
+ };
39770
+ return [
39771
+ `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
39772
+ t.days || t.previousDays ? `Bing totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change(t.clicks, t.previousClicks)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change(t.impressions, t.previousImpressions)}).` : "Bing totals: unknown. Bing has no daily data for these dates.",
39773
+ group("pages", "page"),
39774
+ group("queries", "query"),
39775
+ `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
39776
+ ].filter(Boolean).join("\n\n");
39777
+ }
39003
39778
  //#endregion
39004
39779
  //#region packages/core/dist/commands/clicky.js
39005
39780
  const CLICKY_KEY_PREFIX = "x-clicky-";
@@ -39339,196 +40114,6 @@ function temporaryRedirectNote(pages) {
39339
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("; ")}.`;
39340
40115
  }
39341
40116
  //#endregion
39342
- //#region packages/core/dist/analyze/ai-queries.js
39343
- const RULES = [
39344
- ["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: "],
39345
- ["tracker", "\\. ?my location is |^evaluate the .+ on "],
39346
- ["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)[?!.,]*$"],
39347
- ["follow_up", "^(what|how) about .{1,40}$|^(and|so|ok so|okay so|but) (what|which|how|why) "],
39348
- ["operator", "(^|\\s)-?(site|before|after|intitle|inurl|intext|filetype):|\"[^\"]*\" or \""],
39349
- ["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"],
39350
- ["long", "^(\\S+\\s+){9,}\\S+$"]
39351
- ];
39352
- const AI_QUERY_KINDS = {
39353
- agent_prompt: "text from an agent prompt, such as \"search the web for\"",
39354
- tracker: "a visibility-tool probe, such as \"my location is\"",
39355
- reply: "a bare reply, such as \"yes\" or \"tell me more\"",
39356
- follow_up: "a follow-up, such as \"what about …\"",
39357
- operator: "search operators, such as site:, before: or \"a\" or \"b\"",
39358
- chat: "chat phrasing, such as \"can you\" or \"should i\"",
39359
- long: "ten or more words with no other sign"
39360
- };
39361
- const MATCHERS = RULES.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
39362
- const AI_QUERY_PATTERN = RULES.map(([, pattern]) => `(?:${pattern})`).join("|");
39363
- function aiQueryKind(query) {
39364
- const text = query.trim().toLowerCase();
39365
- return MATCHERS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
39366
- }
39367
- 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(" "));
39368
- function words(query) {
39369
- 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));
39370
- }
39371
- const SAME_QUESTION = .6;
39372
- const MAX_ROWS = 25e3;
39373
- const EXAMPLES = 3;
39374
- const CANDIDATES = 200;
39375
- const overlap = (a, b, weight) => {
39376
- let shared = 0;
39377
- let union = 0;
39378
- for (const word of a) {
39379
- union += weight(word);
39380
- if (b.has(word)) shared += weight(word);
39381
- }
39382
- for (const word of b) if (!a.has(word)) union += weight(word);
39383
- return union ? shared / union : 0;
39384
- };
39385
- const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
39386
- async function aiQueriesReport(input, searchAnalytics) {
39387
- const range = {
39388
- startDate: input.startDate,
39389
- endDate: input.endDate
39390
- };
39391
- const [matched, site] = await Promise.all([searchAnalytics(input.site, {
39392
- ...range,
39393
- dimensions: ["query", "page"],
39394
- type: "web",
39395
- dataState: "final",
39396
- maxRows: MAX_ROWS,
39397
- dimensionFilterGroups: [{ filters: [{
39398
- dimension: "query",
39399
- operator: "includingRegex",
39400
- expression: AI_QUERY_PATTERN
39401
- }] }]
39402
- }), searchAnalytics(input.site, {
39403
- ...range,
39404
- dimensions: [],
39405
- type: "web",
39406
- dataState: "final"
39407
- })]);
39408
- 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])));
39409
- const usage = /* @__PURE__ */ new Map();
39410
- const unique = new Set(rows.map((row) => String(row.keys[0] ?? "")));
39411
- for (const query of unique) for (const word of words(query)) usage.set(word, (usage.get(word) ?? 0) + 1);
39412
- const weight = (word) => Math.log((unique.size + 1) / (usage.get(word) ?? 1));
39413
- const groups = [];
39414
- const byWord = /* @__PURE__ */ new Map();
39415
- const byText = /* @__PURE__ */ new Map();
39416
- const kindOf = /* @__PURE__ */ new Map();
39417
- for (const row of rows) {
39418
- const query = String(row.keys[0] ?? "");
39419
- const page = String(row.keys[1] ?? "");
39420
- const kind = aiQueryKind(query);
39421
- if (!kind) continue;
39422
- const text = query.trim().toLowerCase();
39423
- const own = words(query);
39424
- let group = byText.get(text);
39425
- if (!group) {
39426
- let checked = 0;
39427
- for (const word of own) {
39428
- for (const candidate of byWord.get(word) ?? []) {
39429
- if (++checked > CANDIDATES) break;
39430
- if (overlap(own, candidate.words, weight) >= SAME_QUESTION) {
39431
- group = candidate;
39432
- break;
39433
- }
39434
- }
39435
- if (group || checked > CANDIDATES) break;
39436
- }
39437
- }
39438
- if (!group) {
39439
- group = {
39440
- words: own,
39441
- queries: /* @__PURE__ */ new Map(),
39442
- pages: /* @__PURE__ */ new Map(),
39443
- kinds: /* @__PURE__ */ new Map(),
39444
- clicks: 0,
39445
- impressions: 0,
39446
- positionWeight: 0
39447
- };
39448
- groups.push(group);
39449
- for (const word of own) {
39450
- const list = byWord.get(word) ?? [];
39451
- list.push(group);
39452
- byWord.set(word, list);
39453
- }
39454
- }
39455
- byText.set(text, group);
39456
- if (!kindOf.has(text)) {
39457
- kindOf.set(text, kind);
39458
- group.kinds.set(kind, (group.kinds.get(kind) ?? 0) + 1);
39459
- }
39460
- group.queries.set(query, (group.queries.get(query) ?? 0) + row.impressions);
39461
- group.pages.set(page, (group.pages.get(page) ?? 0) + row.impressions);
39462
- group.clicks += row.clicks;
39463
- group.impressions += row.impressions;
39464
- group.positionWeight += row.position * row.impressions;
39465
- }
39466
- const byKind = /* @__PURE__ */ new Map();
39467
- for (const kind of kindOf.values()) {
39468
- const entry = byKind.get(kind) ?? {
39469
- queries: 0,
39470
- impressions: 0
39471
- };
39472
- entry.queries += 1;
39473
- byKind.set(kind, entry);
39474
- }
39475
- for (const row of rows) {
39476
- const kind = kindOf.get(String(row.keys[0] ?? "").trim().toLowerCase());
39477
- const entry = kind && byKind.get(kind);
39478
- if (entry) entry.impressions += row.impressions;
39479
- }
39480
- const queries = kindOf.size;
39481
- const impressions = groups.reduce((sum, g) => sum + g.impressions, 0);
39482
- const clicks = groups.reduce((sum, g) => sum + g.clicks, 0);
39483
- const total = site.rows[0] ?? {
39484
- clicks: 0,
39485
- impressions: 0
39486
- };
39487
- const items = groups.map((group) => {
39488
- const most = (map) => [...map].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
39489
- const [first, ...rest] = most(group.queries);
39490
- const kind = [...group.kinds].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
39491
- return {
39492
- query: first ?? "",
39493
- page: most(group.pages)[0] ?? "",
39494
- pages: group.pages.size,
39495
- kind,
39496
- wordings: group.queries.size,
39497
- impressions: group.impressions,
39498
- clicks: group.clicks,
39499
- ctr: group.impressions ? round(group.clicks / group.impressions, 4) : 0,
39500
- position: group.impressions ? round(group.positionWeight / group.impressions, 1) : 0,
39501
- otherWordings: rest.slice(0, EXAMPLES)
39502
- };
39503
- }).sort((a, b) => b.impressions - a.impressions || b.wordings - a.wordings || a.query.localeCompare(b.query) || a.page.localeCompare(b.page));
39504
- const pct = (part, whole) => whole ? round(part / whole * 100, 1) : null;
39505
- const kinds = [...byKind].sort((a, b) => b[1].impressions - a[1].impressions || a[0].localeCompare(b[0])).map(([kind, entry]) => `${kind} ${entry.queries}`).join(", ");
39506
- const capped = matched.rowsFetched >= MAX_ROWS;
39507
- return {
39508
- range,
39509
- 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.",
39510
- summary: {
39511
- queries,
39512
- questions: groups.length,
39513
- impressions,
39514
- clicks,
39515
- shareOfImpressionsPct: pct(impressions, total.impressions),
39516
- ctr: impressions ? round(clicks / impressions, 4) : 0,
39517
- siteCtr: total.impressions ? round(total.clicks / total.impressions, 4) : 0,
39518
- byKind: kinds || "none"
39519
- },
39520
- kinds: AI_QUERY_KINDS,
39521
- items,
39522
- caveats: [
39523
- "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.",
39524
- "A minimum: Search Console leaves out rare queries, and these are rare by nature.",
39525
- "Rewordings of one question are grouped by their shared words; page is the page with the most impressions for it.",
39526
- "Search Console’s Generative AI report is not in its API, so it is not here.",
39527
- ...capped ? [`Capped at the first ${MAX_ROWS} matching query and page rows.`] : []
39528
- ]
39529
- };
39530
- }
39531
- //#endregion
39532
40117
  //#region packages/core/dist/commands/report.js
39533
40118
  const SECTIONS = {
39534
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.",
@@ -39574,7 +40159,7 @@ function nextFor(section, call) {
39574
40159
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
39575
40160
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
39576
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.`,
39577
- "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.`,
39578
40163
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
39579
40164
  }[section];
39580
40165
  }
@@ -39732,6 +40317,24 @@ async function enginesLine(overview, bing) {
39732
40317
  return `Bing: could not be read. ${error instanceof Error ? error.message : String(error)}`;
39733
40318
  }
39734
40319
  }
40320
+ async function bingLead(overview, bing, origin, rowsPerList) {
40321
+ const range = overview.range;
40322
+ const before = overview.comparisonRange;
40323
+ const google = overview.summary.clicks;
40324
+ try {
40325
+ const source = await bing();
40326
+ if (!source.call) return null;
40327
+ const totals = await bingTotals(source, range, before);
40328
+ if ("missing" in totals || totals.current.clicks <= google) return null;
40329
+ const report = await bingOverview(source, range, before);
40330
+ return {
40331
+ text: `## Bing\n${bingOverviewText(report, `Bing sends more clicks than Google here, so here is Bing's overview of ${totals.site}`, origin, rowsPerList)}`,
40332
+ data: report
40333
+ };
40334
+ } catch {
40335
+ return null;
40336
+ }
40337
+ }
39735
40338
  async function build(read, site, section, range, now, bing) {
39736
40339
  const dependencies = {
39737
40340
  searchAnalytics: read,
@@ -39851,6 +40454,8 @@ async function allSections(input, deps) {
39851
40454
  if (!overview || overview instanceof Error) throw overview ?? /* @__PURE__ */ new Error("The overview did not run.");
39852
40455
  const parts = [overviewText(overview, `Report on ${input.site}`, origin, 5)];
39853
40456
  parts.push(deps.bing ? await enginesLine(overview, bing) : NO_BING_HERE);
40457
+ const bingFirst = deps.bing ? await bingLead(overview, bing, origin, 5) : null;
40458
+ if (bingFirst) parts.push(bingFirst.text);
39854
40459
  const ga = await analytics;
39855
40460
  if (ga) parts.push(ga.text);
39856
40461
  const brand = rest.find((run) => !(run instanceof Error) && brandNote(run) !== null);
@@ -39883,6 +40488,7 @@ async function allSections(input, deps) {
39883
40488
  site: input.site,
39884
40489
  overview,
39885
40490
  ...ga ? { analytics: ga.data } : {},
40491
+ ...bingFirst ? { bingOverview: bingFirst.data } : {},
39886
40492
  sections
39887
40493
  }
39888
40494
  };
@@ -39899,10 +40505,13 @@ async function oneSection(input, deps) {
39899
40505
  if (section === "overview") {
39900
40506
  const ga = await analyticsBeside(deps.analytics, site, range, earlierRange(range));
39901
40507
  if (ga) report.analytics = ga.data;
40508
+ const bingFirst = deps.bing ? await bingLead(report, bing, origin, 10) : null;
40509
+ if (bingFirst) report.bingOverview = bingFirst.data;
39902
40510
  if (detail !== "full") return {
39903
40511
  text: [
39904
40512
  overviewText(report, `overview of ${site}`, origin, 10),
39905
40513
  deps.bing ? await enginesLine(report, bing) : NO_BING_HERE,
40514
+ bingFirst?.text,
39906
40515
  ga?.text,
39907
40516
  `More: ${deps.call("report", {
39908
40517
  site,
@@ -39967,213 +40576,25 @@ function runReport$1(input, deps) {
39967
40576
  section: input.section
39968
40577
  }, deps) : allSections(input, deps);
39969
40578
  }
39970
- //#endregion
39971
- //#region packages/core/dist/commands/research-providers.js
39972
- function need(headers, provider) {
39973
- const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
39974
- 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\`.`);
39975
- return values;
39976
- }
39977
- async function call(provider, request) {
39978
- const response = await fetch(request);
39979
- if (response.ok) return response;
39980
- const text = (await response.text()).slice(0, 300);
39981
- throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
39982
- }
39983
- function requireInput(input) {
39984
- const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
39985
- if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
39986
- if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
39987
- }
39988
- async function research(provider, headers, input) {
39989
- requireInput(input);
39990
- const keys = need(headers, provider);
39991
- if (provider === "dataforseo") return dataForSeo(keys, input);
39992
- return ahrefs(keys[0] ?? "", input);
39993
- }
39994
- function countryName(code) {
39995
- return new Intl.DisplayNames("en", { type: "region" }).of(code.toUpperCase()) ?? "United States";
39996
- }
39997
- function mainLanguage(country) {
39998
- try {
39999
- return new Intl.Locale(`und-${country.toUpperCase()}`).maximize().language;
40000
- } catch {
40001
- return "en";
40002
- }
40003
- }
40004
- const LABS_LANGUAGES = new Map(`
40005
- ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
40006
- bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
40007
- cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
40008
- gr:el gt:es hk:en hr:hr hu:hu id:id ie:en il:he in:en it:it jo:ar jp:ja
40009
- ke:en kh:en kr:ko kz:ru lk:en lt:lt lv:lv ma:ar mc:fr md:ro mk:mk mm:en
40010
- mt:en mx:es my:en ng:en ni:es nl:nl no:nb nz:en pa:es pe:es ph:en pk:en
40011
- pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
40012
- th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
40013
- `.trim().split(/\s+/).map((pair) => pair.split(":")));
40014
- function dataForSeoLanguage(country) {
40015
- return LABS_LANGUAGES.get(country.toLowerCase()) ?? null;
40016
- }
40017
- async function dataForSeo([login, password], input) {
40018
- const language = dataForSeoLanguage(input.country);
40019
- if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
40020
- const market = {
40021
- location_name: countryName(input.country),
40022
- language_code: language ?? mainLanguage(input.country)
40023
- };
40024
- const post = async (path, task) => {
40025
- const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
40026
- method: "POST",
40027
- headers: {
40028
- authorization: `Basic ${btoa(`${login}:${password}`)}`,
40029
- "content-type": "application/json"
40030
- },
40031
- body: JSON.stringify([{
40032
- ...market,
40033
- ...task
40034
- }])
40035
- })), "DataForSEO")).tasks?.[0];
40036
- if (!result || result.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`);
40037
- return result.result?.[0]?.items ?? [];
40038
- };
40039
- const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
40040
- const pick = (item, fields) => Object.fromEntries(Object.entries(fields).map(([name, path]) => [name, get(item, path) ?? null]));
40041
- 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, {
40042
- keyword: "keyword",
40043
- search_volume: "keyword_info.search_volume",
40044
- keyword_difficulty: "keyword_properties.keyword_difficulty",
40045
- cpc: "keyword_info.cpc",
40046
- competition: "keyword_info.competition",
40047
- main_intent: "search_intent_info.main_intent"
40048
- }));
40049
- if (input.action === "ranked_keywords") return (await post("v3/dataforseo_labs/google/ranked_keywords/live", {
40050
- target: bareTarget(input.target),
40051
- limit: input.limit,
40052
- order_by: ["ranked_serp_element.serp_item.etv,desc"]
40053
- })).map((item) => pick(item, {
40054
- keyword: "keyword_data.keyword",
40055
- position: "ranked_serp_element.serp_item.rank_absolute",
40056
- url: "ranked_serp_element.serp_item.url",
40057
- search_volume: "keyword_data.keyword_info.search_volume",
40058
- etv: "ranked_serp_element.serp_item.etv",
40059
- keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
40060
- main_intent: "keyword_data.search_intent_info.main_intent"
40061
- }));
40062
- if (input.action === "serp") {
40063
- const rows = [];
40064
- for (const keyword of input.keywords.slice(0, 5)) {
40065
- const items = await post("v3/serp/google/organic/live/advanced", {
40066
- keyword,
40067
- depth: Math.min(input.limit, 100)
40068
- });
40069
- for (const item of items) {
40070
- if (item.type !== "organic") continue;
40071
- rows.push({
40072
- keyword,
40073
- ...pick(item, {
40074
- position: "rank_absolute",
40075
- url: "url",
40076
- domain: "domain",
40077
- title: "title"
40078
- })
40079
- });
40080
- }
40081
- }
40082
- return rows;
40083
- }
40084
- if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
40085
- target: bareTarget(input.target),
40086
- limit: input.limit,
40087
- order_by: ["rank,desc"]
40088
- })).map((item) => pick(item, {
40089
- domain: "domain",
40090
- rank: "rank",
40091
- backlinks: "backlinks",
40092
- referring_pages: "referring_pages",
40093
- first_seen: "first_seen"
40094
- }));
40095
- return (await post("v3/dataforseo_labs/google/competitors_domain/live", {
40096
- target: bareTarget(input.target),
40097
- limit: input.limit
40098
- })).map((item) => pick(item, {
40099
- domain: "domain",
40100
- shared_keywords: "intersections",
40101
- avg_position: "avg_position",
40102
- organic_keywords: "full_domain_metrics.organic.count",
40103
- organic_etv: "full_domain_metrics.organic.etv"
40104
- }));
40105
- }
40106
- function isPage(target) {
40107
- return /\/./.test(bareTarget(target));
40108
- }
40109
- function bareTarget(target) {
40110
- return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
40111
- }
40112
- async function ahrefs(key, input) {
40113
- const get = async (path, params) => {
40114
- const url = new URL(`https://api.ahrefs.com/v3/${path}`);
40115
- for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
40116
- return readJson(await call("ahrefs", new Request(url, { headers: {
40117
- authorization: `Bearer ${key}`,
40118
- accept: "application/json"
40119
- } })), "Ahrefs");
40120
- };
40121
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
40122
- if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
40123
- select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
40124
- country: input.country,
40125
- keywords: input.keywords.slice(0, 100).join(",")
40126
- })).keywords ?? []).map(flattenIntents);
40127
- if (input.action === "ranked_keywords") return (await get("site-explorer/organic-keywords", {
40128
- select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
40129
- target: input.target,
40130
- country: input.country,
40131
- date: today,
40132
- mode: isPage(input.target) ? "exact" : "subdomains",
40133
- limit: String(input.limit),
40134
- order_by: "sum_traffic:desc"
40135
- })).keywords ?? [];
40136
- if (input.action === "serp") {
40137
- const rows = [];
40138
- for (const keyword of input.keywords.slice(0, 5)) {
40139
- const data = await get("serp-overview/serp-overview", {
40140
- select: "position,type,url,title,domain_rating,traffic,keywords",
40141
- keyword,
40142
- country: input.country,
40143
- top_positions: String(Math.min(input.limit, 100))
40144
- });
40145
- for (const row of data.positions ?? []) rows.push({
40146
- keyword,
40147
- ...row
40148
- });
40149
- }
40150
- return rows;
40151
- }
40152
- if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
40153
- select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
40154
- target: input.target,
40155
- mode: isPage(input.target) ? "exact" : "subdomains",
40156
- history: "live",
40157
- limit: String(input.limit),
40158
- order_by: "domain_rating:desc"
40159
- })).refdomains ?? [];
40160
- return (await get("site-explorer/organic-competitors", {
40161
- select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
40162
- target: input.target,
40163
- country: input.country,
40164
- date: today,
40165
- mode: "subdomains",
40166
- limit: String(input.limit),
40167
- order_by: "keywords_common:desc"
40168
- })).competitors ?? [];
40169
- }
40170
- function flattenIntents(row) {
40171
- const intents = row.intents;
40172
- if (!intents || typeof intents !== "object") return row;
40173
- const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
40579
+ async function runBingReport(input, deps) {
40580
+ const range = dateWindow(input, DEFAULT_DAYS.overview);
40581
+ const before = earlierRange(range);
40582
+ const source = await bingFor(deps, input.site);
40583
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
40584
+ const [report, ga] = await Promise.all([bingOverview(source, range, before), analyticsBeside(deps.analytics, source.site, range, before)]);
40585
+ if (ga) report.analytics = ga.data;
40174
40586
  return {
40175
- ...row,
40176
- intents: names.join(" ") || null
40587
+ text: [
40588
+ bingOverviewText(report, `Bing report on ${source.site}. No Search Console property covers this site, so this report uses Bing`, originOf(source.site), 10),
40589
+ ga?.text,
40590
+ `More: ${deps.call("data", {
40591
+ source: "bing",
40592
+ site: input.site,
40593
+ dimensions: "page"
40594
+ })} for every Bing page, or the same with query.`,
40595
+ FACTS_ONLY.replace("Search Console", "Bing Webmaster Tools")
40596
+ ].filter(Boolean).join("\n\n"),
40597
+ data: report
40177
40598
  };
40178
40599
  }
40179
40600
  //#endregion
@@ -40183,10 +40604,11 @@ const RESEARCH_ACTIONS = [
40183
40604
  "ranked_keywords",
40184
40605
  "serp",
40185
40606
  "competitors",
40186
- "backlinks"
40607
+ "backlinks",
40608
+ "ai_answers"
40187
40609
  ];
40188
40610
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
40189
- 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.`;
40190
40612
  const GENERIC_PLATFORMS = [
40191
40613
  "youtube.com",
40192
40614
  "facebook.com",
@@ -40229,7 +40651,8 @@ async function runResearch(input, keys, options) {
40229
40651
  const target = input.target ?? "";
40230
40652
  const offset = input.offset ?? 0;
40231
40653
  const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
40232
- 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, {
40233
40656
  action: input.action,
40234
40657
  keywords: input.keywords ?? [],
40235
40658
  target,
@@ -40253,6 +40676,8 @@ async function runResearch(input, keys, options) {
40253
40676
  detail: input.detail ?? "summary",
40254
40677
  notes: [
40255
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."] : [],
40256
40681
  ...note ? [note] : [],
40257
40682
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
40258
40683
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -40268,6 +40693,8 @@ async function runResearch(input, keys, options) {
40268
40693
  targetDomainRating: hasTargetDr ? targetDr : null,
40269
40694
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
40270
40695
  leftOut: note,
40696
+ costUsd,
40697
+ partial,
40271
40698
  total: rated.rows.length,
40272
40699
  offset,
40273
40700
  rows: rated.rows.slice(offset, offset + 50)
@@ -40607,14 +41034,25 @@ const SHARED_TOOLS = [
40607
41034
  ...dates,
40608
41035
  ...detail
40609
41036
  }),
40610
- run: async (surface, input) => runReport$1({
40611
- site: input.section === "bing" ? await surface.resolveSite(input.site).catch(() => input.site) : await surface.resolveSite(input.site),
40612
- section: input.section,
40613
- startDate: input.start_date,
40614
- endDate: input.end_date,
40615
- detail: input.detail,
40616
- offset: input.offset
40617
- }, surface.deps)
41037
+ run: async (surface, input) => {
41038
+ const resolved = await surface.resolveSite(input.site).catch((error) => error instanceof Error ? error : new Error(String(error)));
41039
+ if (resolved instanceof Error && !input.section && surface.deps.bing) return runBingReport({
41040
+ site: input.site,
41041
+ startDate: input.start_date,
41042
+ endDate: input.end_date
41043
+ }, surface.deps).catch(() => {
41044
+ throw resolved;
41045
+ });
41046
+ if (resolved instanceof Error && input.section !== "bing") throw resolved;
41047
+ return runReport$1({
41048
+ site: resolved instanceof Error ? input.site : resolved,
41049
+ section: input.section,
41050
+ startDate: input.start_date,
41051
+ endDate: input.end_date,
41052
+ detail: input.detail,
41053
+ offset: input.offset
41054
+ }, surface.deps);
41055
+ }
40618
41056
  }),
40619
41057
  tool({
40620
41058
  name: "seo_page",
@@ -40669,7 +41107,7 @@ const SHARED_TOOLS = [
40669
41107
  tool({
40670
41108
  name: "seo_research",
40671
41109
  command: "research",
40672
- title: "Keyword, competitor and backlink data",
41110
+ title: "Keyword, competitor, backlink and AI answer data",
40673
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}`,
40674
41112
  input: z.object({
40675
41113
  provider: z.enum(RESEARCH_PROVIDERS),
@@ -40679,10 +41117,22 @@ const SHARED_TOOLS = [
40679
41117
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
40680
41118
  ...detail
40681
41119
  }),
40682
- run: async (surface, input) => runResearch(input, await surface.keys(), {
40683
- drKey: surface.drKey,
40684
- call: (offset) => `seo_research with the same input, detail "rows", offset ${offset}`
40685
- })
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
+ }
40686
41136
  }),
40687
41137
  tool({
40688
41138
  name: "seo_data",
@@ -40756,7 +41206,7 @@ async function runSharedTool(tool, surface, input) {
40756
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.
40757
41207
  1. seo_sites: the properties the connected accounts can read.
40758
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.
40759
- 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.`;
40760
41210
  const mcpCall = (command, args) => {
40761
41211
  const name = `seo_${command.replace("-", "_")}`;
40762
41212
  const shown = Object.entries(args ?? {}).filter(([, value]) => value !== void 0);