seo 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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-DieyhVZ-.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-Dt4JJZ0P.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
@@ -14823,7 +14823,7 @@ async function readableSites(google, signIn) {
14823
14823
  permissionLevel: entry.permissionLevel ?? ""
14824
14824
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
14825
14825
  }
14826
- const hostOf$2 = (value) => {
14826
+ const hostOf$3 = (value) => {
14827
14827
  try {
14828
14828
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
14829
14829
  } catch {
@@ -14841,14 +14841,14 @@ async function runSites(input) {
14841
14841
  };
14842
14842
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
14843
14843
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
14844
- const bingByHost = new Map(bingSites.map((site) => [hostOf$2(site.url), site]));
14844
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
14845
14845
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
14846
14846
  ...property,
14847
14847
  ...named ? { account: account.email ?? "" } : {},
14848
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$2(property.siteUrl))?.url ?? "" } : {}
14848
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
14849
14849
  })));
14850
- const googleHosts = new Set(properties.map((property) => hostOf$2(property.siteUrl)));
14851
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$2(site.url)));
14850
+ const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
14851
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
14852
14852
  const failed = google.filter((account) => account.properties instanceof Error);
14853
14853
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
14854
14854
  property: property.property.replace("properties/", ""),
@@ -33849,7 +33849,7 @@ function typesafeDecisionModel(input) {
33849
33849
  const MAX_CONCURRENCY = 32;
33850
33850
  const MIN_CONCURRENCY = 2;
33851
33851
  const MAX_ATTEMPTS = 5;
33852
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33852
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33853
33853
  var DecisionRunner = class {
33854
33854
  model;
33855
33855
  limit = MAX_CONCURRENCY;
@@ -33914,7 +33914,7 @@ var DecisionRunner = class {
33914
33914
  } finally {
33915
33915
  this.release();
33916
33916
  }
33917
- await sleep(delay);
33917
+ await sleep$1(delay);
33918
33918
  }
33919
33919
  this.failed += 1;
33920
33920
  return null;
@@ -41955,6 +41955,30 @@ async function dataForSeoTask([login, password], path, task) {
41955
41955
  }
41956
41956
  const NO_RESULTS = 40102;
41957
41957
  const PARTIAL = 40106;
41958
+ const queued = (task) => ({
41959
+ id: String(task.id ?? ""),
41960
+ status_code: Number(task.status_code ?? 0),
41961
+ status_message: String(task.status_message ?? ""),
41962
+ cost: Number(task.cost ?? 0),
41963
+ data: task.data ?? {},
41964
+ result: task.result?.[0] ?? null
41965
+ });
41966
+ async function dataForSeoPost([login, password], path, tasks) {
41967
+ return ((await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
41968
+ method: "POST",
41969
+ headers: {
41970
+ authorization: `Basic ${btoa(`${login}:${password}`)}`,
41971
+ "content-type": "application/json"
41972
+ },
41973
+ body: JSON.stringify(tasks)
41974
+ })), "DataForSEO")).tasks ?? []).map(queued);
41975
+ }
41976
+ const DATAFORSEO_NOT_READY = /* @__PURE__ */ new Set([40601, 40602]);
41977
+ async function dataForSeoGet([login, password], path) {
41978
+ const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
41979
+ if (!task) throw new CommandError("PROVIDER_ERROR", `dataforseo: no task at ${path}`);
41980
+ return queued(task);
41981
+ }
41958
41982
  async function dataForSeo(keys, input) {
41959
41983
  const spent = {
41960
41984
  cost: 0,
@@ -42254,7 +42278,8 @@ const ENGINES = [
42254
42278
  {
42255
42279
  name: "chatgpt",
42256
42280
  means: "a ChatGPT search, read by DataForSEO",
42257
- path: "v3/ai_optimization/chat_gpt/llm_scraper/live/advanced",
42281
+ post: "v3/ai_optimization/chat_gpt/llm_scraper/task_post",
42282
+ get: "v3/ai_optimization/chat_gpt/llm_scraper/task_get/advanced/",
42258
42283
  languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
42259
42284
  task: (question, market) => ({
42260
42285
  keyword: question,
@@ -42274,7 +42299,8 @@ const ENGINES = [
42274
42299
  {
42275
42300
  name: "google_ai_mode",
42276
42301
  means: "Google AI Mode",
42277
- path: "v3/serp/google/ai_mode/live/advanced",
42302
+ post: "v3/serp/google/ai_mode/task_post",
42303
+ get: "v3/serp/google/ai_mode/task_get/advanced/",
42278
42304
  languages: "v3/serp/google/ai_mode/languages",
42279
42305
  task: (question, market) => ({
42280
42306
  keyword: question,
@@ -42285,7 +42311,8 @@ const ENGINES = [
42285
42311
  {
42286
42312
  name: "google_ai_overview",
42287
42313
  means: "the AI Overview on a Google search",
42288
- path: "v3/serp/google/organic/live/advanced",
42314
+ post: "v3/serp/google/organic/task_post",
42315
+ get: "v3/serp/google/organic/task_get/advanced/",
42289
42316
  languages: "v3/serp/google/languages",
42290
42317
  task: (question, market) => ({
42291
42318
  keyword: question,
@@ -42307,56 +42334,55 @@ function onSite(url, domain) {
42307
42334
  return false;
42308
42335
  }
42309
42336
  }
42310
- async function ask(keys, engine, question, market, site) {
42311
- if (typeof market === "string") return {
42312
- result: "unavailable",
42313
- model: null,
42314
- cited_url: null,
42315
- answered_at: null,
42316
- cost_usd: 0,
42317
- error: market
42318
- };
42337
+ const MAX_SOURCES = 8;
42338
+ const hostOf$2 = (url) => {
42319
42339
  try {
42320
- const done = await dataForSeoTask(keys, engine.path, engine.task(question, market));
42321
- const answered = {
42322
- answered_at: String(done.result?.datetime ?? "").slice(0, 10) || null,
42323
- cost_usd: done.cost
42324
- };
42325
- const shown = done.result && engine.shown(done.result);
42326
- if (!shown) return {
42327
- result: "no_answer",
42328
- model: null,
42329
- cited_url: null,
42330
- ...answered
42331
- };
42332
- const base = {
42333
- model: shown.model,
42334
- ...answered
42335
- };
42336
- const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
42337
- const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
42338
- if (cited) return {
42339
- result: "cited",
42340
- cited_url: cited,
42341
- ...base
42342
- };
42343
- const text = prose(shown.markdown);
42344
- return {
42345
- result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
42346
- cited_url: null,
42347
- ...base
42348
- };
42349
- } catch (error) {
42350
- if (error instanceof CommandError && error.code === "NO_KEY") throw error;
42351
- return {
42352
- result: "unavailable",
42353
- model: null,
42354
- cited_url: null,
42355
- answered_at: null,
42356
- cost_usd: error.cost ?? 0,
42357
- error: error instanceof Error ? error.message.slice(0, 200) : "failed"
42358
- };
42340
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
42341
+ } catch {
42342
+ return null;
42359
42343
  }
42344
+ };
42345
+ const empty = {
42346
+ cited_url: null,
42347
+ sources: [],
42348
+ model: null,
42349
+ answered_at: null
42350
+ };
42351
+ function judge(engine, task, site) {
42352
+ if (task.status_code === 40102) return {
42353
+ result: "no_answer",
42354
+ ...empty
42355
+ };
42356
+ if (task.status_code >= 4e4 || !task.result) return {
42357
+ result: "unavailable",
42358
+ ...empty,
42359
+ error: `dataforseo: ${task.status_message}`.slice(0, 200)
42360
+ };
42361
+ const answered_at = String(task.result.datetime ?? "").slice(0, 10) || null;
42362
+ const shown = engine.shown(task.result);
42363
+ if (!shown) return {
42364
+ result: "no_answer",
42365
+ ...empty,
42366
+ answered_at
42367
+ };
42368
+ const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
42369
+ const all = [...shown.sources, ...linked];
42370
+ const cited = all.find((url) => onSite(url, site.domain)) ?? null;
42371
+ const base = {
42372
+ cited_url: cited,
42373
+ sources: [...new Set(all.filter((url) => !onSite(url, site.domain)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
42374
+ model: shown.model,
42375
+ answered_at
42376
+ };
42377
+ if (cited) return {
42378
+ result: "cited",
42379
+ ...base
42380
+ };
42381
+ const text = prose(shown.markdown);
42382
+ return {
42383
+ result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
42384
+ ...base
42385
+ };
42360
42386
  }
42361
42387
  async function marketsFor(keys, country) {
42362
42388
  const code = country.toUpperCase();
@@ -42374,38 +42400,162 @@ async function marketsFor(keys, country) {
42374
42400
  }));
42375
42401
  }
42376
42402
  const usd = (value) => Math.round(value * 1e4) / 1e4;
42403
+ const FIRST_WAIT_MS = 1e4;
42404
+ const ROUND_WAIT_MS = 5e3;
42405
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
42406
+ const TASK_PATTERN = /^([a-z_]+):([0-9a-f-]{20,64})$/;
42407
+ const AI_ANSWER_TASK_PATTERN = TASK_PATTERN;
42377
42408
  async function runAiAnswers(input, headers, deps, more) {
42378
42409
  const keys = dataForSeoKeys(headers);
42379
42410
  const domain = registrableDomain(input.site)?.domain;
42380
42411
  if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
42381
- const range = dateWindow({}, QUESTION_DAYS);
42382
- const questions = ((await aiQueriesReport({
42383
- site: input.site,
42384
- ...range
42385
- }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
42386
42412
  const site = {
42387
42413
  domain,
42388
42414
  terms: deriveBrandTerms({ siteUrl: input.site })
42389
42415
  };
42390
- const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
42391
- const answers = await Promise.all(questions.flatMap((question) => ENGINES.map(async (engine) => ({
42392
- question: question.query,
42393
- impressions: question.impressions,
42394
- engine: engine.name,
42395
- ...await ask(keys, engine, question.query, markets.get(engine.name) ?? "no market", site)
42396
- }))));
42397
- const cost = usd(answers.reduce((sum, row) => sum + row.cost_usd, 0));
42398
- const count = (result) => answers.filter((row) => row.result === result).length;
42399
- 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 ? [
42400
- `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}. DataForSEO cost: $${cost}.`,
42416
+ const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
42417
+ if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
42418
+ const tasks = [...new Set(input.tasks ?? [])];
42419
+ const range = dateWindow({}, QUESTION_DAYS);
42420
+ const rows = [];
42421
+ const waiting = [];
42422
+ const blank = (question, impressions) => ({
42423
+ question,
42424
+ impressions,
42425
+ task: null,
42426
+ cost_usd: 0,
42427
+ ...empty
42428
+ });
42429
+ if (tasks.length) for (const value of tasks) {
42430
+ const match = TASK_PATTERN.exec(value);
42431
+ const engine = ENGINES.find((item) => item.name === match?.[1]);
42432
+ if (!match || !engine) throw new CommandError("INVALID_INPUT", `"${value}" is not an ai_answers task. Tasks look like <engine>:<id>, with the engine one of ${ENGINES.map((item) => item.name).join(", ")}.`);
42433
+ const row = {
42434
+ ...blank("", null),
42435
+ engine: engine.name,
42436
+ result: "pending",
42437
+ task: value
42438
+ };
42439
+ rows.push(row);
42440
+ waiting.push({
42441
+ row,
42442
+ engine,
42443
+ id: match[2]
42444
+ });
42445
+ }
42446
+ else {
42447
+ let questions;
42448
+ if (given.length) questions = given.map((query) => ({
42449
+ query,
42450
+ impressions: null
42451
+ }));
42452
+ else questions = ((await aiQueriesReport({
42453
+ site: input.site,
42454
+ ...range
42455
+ }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
42456
+ const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
42457
+ await Promise.all(ENGINES.map(async (engine) => {
42458
+ const engineRows = questions.map((question) => ({
42459
+ ...blank(question.query, question.impressions),
42460
+ engine: engine.name,
42461
+ result: "unavailable"
42462
+ }));
42463
+ rows.push(...engineRows);
42464
+ if (!questions.length) return;
42465
+ const market = markets.get(engine.name) ?? "no market";
42466
+ if (typeof market === "string") {
42467
+ for (const row of engineRows) row.error = market;
42468
+ return;
42469
+ }
42470
+ let queuedTasks;
42471
+ try {
42472
+ queuedTasks = await dataForSeoPost(keys, engine.post, questions.map((question) => ({
42473
+ ...engine.task(question.query, market),
42474
+ priority: 2
42475
+ })));
42476
+ } catch (error) {
42477
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
42478
+ for (const row of engineRows) row.error = error instanceof Error ? error.message.slice(0, 200) : "failed";
42479
+ return;
42480
+ }
42481
+ engineRows.forEach((row, index) => {
42482
+ const task = queuedTasks[index];
42483
+ row.cost_usd = task?.cost ?? 0;
42484
+ if (!task || task.status_code >= 4e4 || !task.id) {
42485
+ row.error = `dataforseo: ${task?.status_message ?? "no task"}`;
42486
+ return;
42487
+ }
42488
+ row.result = "pending";
42489
+ row.task = `${engine.name}:${task.id}`;
42490
+ waiting.push({
42491
+ row,
42492
+ engine,
42493
+ id: task.id
42494
+ });
42495
+ });
42496
+ }));
42497
+ }
42498
+ const deadline = Date.now() + (input.waitSeconds ?? 40) * 1e3;
42499
+ let pending = waiting;
42500
+ let first = !tasks.length;
42501
+ while (pending.length) {
42502
+ const pause = first ? FIRST_WAIT_MS : ROUND_WAIT_MS;
42503
+ first = false;
42504
+ if (!tasks.length || pending !== waiting) {
42505
+ if (Date.now() + pause > deadline) break;
42506
+ await sleep(pause);
42507
+ }
42508
+ const still = [];
42509
+ await Promise.all(pending.map(async (item) => {
42510
+ try {
42511
+ const task = await dataForSeoGet(keys, `${item.engine.get}${item.id}`);
42512
+ if (!item.row.question) item.row.question = String(task.data.keyword ?? "");
42513
+ if (DATAFORSEO_NOT_READY.has(task.status_code)) {
42514
+ still.push(item);
42515
+ return;
42516
+ }
42517
+ Object.assign(item.row, judge(item.engine, task, site), { task: null });
42518
+ } catch (error) {
42519
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
42520
+ still.push(item);
42521
+ }
42522
+ }));
42523
+ pending = still;
42524
+ if (tasks.length && Date.now() >= deadline) break;
42525
+ }
42526
+ rows.sort((a, b) => a.question.localeCompare(b.question) || ENGINES.findIndex((e) => e.name === a.engine) - ENGINES.findIndex((e) => e.name === b.engine));
42527
+ const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
42528
+ const count = (result) => rows.filter((row) => row.result === result).length;
42529
+ const left = rows.filter((row) => row.task).map((row) => row.task);
42530
+ const cites = /* @__PURE__ */ new Map();
42531
+ for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
42532
+ const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
42533
+ const questionCount = new Set(rows.map((row) => row.question)).size;
42534
+ const notes = [tasks.length ? `${tasks.length === 1 ? "One answer" : `${tasks.length} answers`} queued by an earlier run, read back at no cost.` : given.length ? `The ${given.length} questions you gave, asked as written. Impressions are blank because they don't come from Search Console.` : questionCount ? `The top ${questionCount} 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.`, ...rows.length ? [
42535
+ `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}, not ready ${count("pending")}. DataForSEO cost: $${cost}.`,
42536
+ ...left.length ? [`${left.length === 1 ? "One answer wasn't" : `${left.length} answers weren't`} ready yet. They're paid for: ask ai_answers again with the same target and tasks ${JSON.stringify(left)} in a minute to read them at no cost (DataForSEO keeps them for 30 days).`] : [],
42537
+ ...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
42401
42538
  "Each row is one sample of an answer that changes from run to run, not a ranking.",
42402
- `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.`,
42539
+ `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. sources: the other sites the answer cites, up to ${MAX_SOURCES}. model: the model DataForSEO reports for the answer, when it reports one.`,
42403
42540
  `Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
42404
42541
  ] : []];
42542
+ const shown = rows.map((row) => ({
42543
+ question: row.question,
42544
+ impressions: row.impressions,
42545
+ engine: row.engine,
42546
+ result: row.result,
42547
+ cited_url: row.cited_url,
42548
+ sources: row.sources.join(", "),
42549
+ model: row.model,
42550
+ answered_at: row.answered_at,
42551
+ cost_usd: row.cost_usd,
42552
+ task: row.task,
42553
+ error: row.error ?? null
42554
+ }));
42405
42555
  return {
42406
42556
  text: rowsText({
42407
42557
  title: `AI answers for ${input.site}, ${input.country}.`,
42408
- rows: answers,
42558
+ rows: shown,
42409
42559
  offset: input.offset,
42410
42560
  limit: 50,
42411
42561
  detail: input.detail,
@@ -42417,15 +42567,23 @@ async function runAiAnswers(input, headers, deps, more) {
42417
42567
  action: "ai_answers",
42418
42568
  site: input.site,
42419
42569
  country: input.country,
42420
- questionsFrom: {
42570
+ questionsFrom: tasks.length ? { section: "tasks" } : given.length ? { section: "given" } : {
42421
42571
  section: "ai-queries",
42422
42572
  ...range
42423
42573
  },
42424
- questions: questions.map((question) => question.query),
42574
+ questions: [...new Set(rows.map((row) => row.question))],
42425
42575
  costUsd: cost,
42426
- total: answers.length,
42576
+ pendingTasks: left,
42577
+ citedSites: topCited.map(([host, answers]) => ({
42578
+ host,
42579
+ answers
42580
+ })),
42581
+ total: rows.length,
42427
42582
  offset: input.offset,
42428
- rows: answers.slice(input.offset, input.offset + 50)
42583
+ rows: rows.slice(input.offset, input.offset + 50).map(({ error, ...row }) => error ? {
42584
+ ...row,
42585
+ error
42586
+ } : row)
42429
42587
  }
42430
42588
  };
42431
42589
  }
@@ -45060,7 +45218,7 @@ function nextFor(section, call) {
45060
45218
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
45061
45219
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
45062
45220
  "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.`,
45063
- "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.`,
45221
+ "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.07 of DataForSEO credit), so show the person those questions and ask first.`,
45064
45222
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
45065
45223
  }[section];
45066
45224
  }
@@ -45564,7 +45722,7 @@ const RESEARCH_ACTIONS = [
45564
45722
  "ai_answers"
45565
45723
  ];
45566
45724
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
45567
- 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.`;
45725
+ 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. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
45568
45726
  const GENERIC_PLATFORMS = [
45569
45727
  "youtube.com",
45570
45728
  "facebook.com",
@@ -46512,6 +46670,7 @@ const SHARED_TOOLS = [
46512
46670
  action: z.enum(RESEARCH_ACTIONS),
46513
46671
  keywords: z.array(z.string().min(1)).max(100).default([]),
46514
46672
  target: z.string().default("").describe("Domain or URL."),
46673
+ tasks: z.array(z.string().regex(AI_ANSWER_TASK_PATTERN)).max(30).default([]).describe("ai_answers only: the tasks an earlier run returned as not ready, to read back at no cost."),
46515
46674
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
46516
46675
  ...detail
46517
46676
  }),
@@ -46520,10 +46679,12 @@ const SHARED_TOOLS = [
46520
46679
  if (input.action === "ai_answers") {
46521
46680
  if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
46522
46681
  return runAiAnswers({
46523
- site: await surface.resolveSite(input.target),
46682
+ site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
46524
46683
  country: input.country,
46525
46684
  detail: input.detail,
46526
- offset: input.offset
46685
+ offset: input.offset,
46686
+ questions: input.keywords,
46687
+ tasks: input.tasks
46527
46688
  }, await surface.keys(), surface.deps, more);
46528
46689
  }
46529
46690
  return runResearch(input, await surface.keys(), {