seo 0.15.1 → 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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as version, t as load } from "./esm-D12ZCG_1.js";
2
+ import { n as version, t as load } from "./esm-Dt4JJZ0P.js";
3
3
  import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
4
4
  import crypto, { createHash, randomUUID, sign } from "node:crypto";
5
5
  import { z } from "zod";
@@ -2709,7 +2709,7 @@ async function readableSites(google, signIn) {
2709
2709
  permissionLevel: entry.permissionLevel ?? ""
2710
2710
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
2711
2711
  }
2712
- const hostOf$2 = (value) => {
2712
+ const hostOf$3 = (value) => {
2713
2713
  try {
2714
2714
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
2715
2715
  } catch {
@@ -2727,14 +2727,14 @@ async function runSites(input) {
2727
2727
  };
2728
2728
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
2729
2729
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
2730
- const bingByHost = new Map(bingSites.map((site) => [hostOf$2(site.url), site]));
2730
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
2731
2731
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
2732
2732
  ...property,
2733
2733
  ...named ? { account: account.email ?? "" } : {},
2734
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$2(property.siteUrl))?.url ?? "" } : {}
2734
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
2735
2735
  })));
2736
- const googleHosts = new Set(properties.map((property) => hostOf$2(property.siteUrl)));
2737
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$2(site.url)));
2736
+ const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
2737
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
2738
2738
  const failed = google.filter((account) => account.properties instanceof Error);
2739
2739
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
2740
2740
  property: property.property.replace("properties/", ""),
@@ -23563,7 +23563,7 @@ function typesafeDecisionModel(input) {
23563
23563
  const MAX_CONCURRENCY = 32;
23564
23564
  const MIN_CONCURRENCY = 2;
23565
23565
  const MAX_ATTEMPTS = 5;
23566
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23566
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23567
23567
  var DecisionRunner = class {
23568
23568
  model;
23569
23569
  limit = MAX_CONCURRENCY;
@@ -23628,7 +23628,7 @@ var DecisionRunner = class {
23628
23628
  } finally {
23629
23629
  this.release();
23630
23630
  }
23631
- await sleep(delay);
23631
+ await sleep$1(delay);
23632
23632
  }
23633
23633
  this.failed += 1;
23634
23634
  return null;
@@ -31876,6 +31876,30 @@ async function dataForSeoTask([login, password], path, task) {
31876
31876
  }
31877
31877
  const NO_RESULTS = 40102;
31878
31878
  const PARTIAL = 40106;
31879
+ const queued = (task) => ({
31880
+ id: String(task.id ?? ""),
31881
+ status_code: Number(task.status_code ?? 0),
31882
+ status_message: String(task.status_message ?? ""),
31883
+ cost: Number(task.cost ?? 0),
31884
+ data: task.data ?? {},
31885
+ result: task.result?.[0] ?? null
31886
+ });
31887
+ async function dataForSeoPost([login, password], path, tasks) {
31888
+ return ((await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
31889
+ method: "POST",
31890
+ headers: {
31891
+ authorization: `Basic ${btoa(`${login}:${password}`)}`,
31892
+ "content-type": "application/json"
31893
+ },
31894
+ body: JSON.stringify(tasks)
31895
+ })), "DataForSEO")).tasks ?? []).map(queued);
31896
+ }
31897
+ const DATAFORSEO_NOT_READY = /* @__PURE__ */ new Set([40601, 40602]);
31898
+ async function dataForSeoGet([login, password], path) {
31899
+ const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
31900
+ if (!task) throw new CommandError("PROVIDER_ERROR", `dataforseo: no task at ${path}`);
31901
+ return queued(task);
31902
+ }
31879
31903
  async function dataForSeo(keys, input) {
31880
31904
  const spent = {
31881
31905
  cost: 0,
@@ -32175,7 +32199,8 @@ const ENGINES = [
32175
32199
  {
32176
32200
  name: "chatgpt",
32177
32201
  means: "a ChatGPT search, read by DataForSEO",
32178
- path: "v3/ai_optimization/chat_gpt/llm_scraper/live/advanced",
32202
+ post: "v3/ai_optimization/chat_gpt/llm_scraper/task_post",
32203
+ get: "v3/ai_optimization/chat_gpt/llm_scraper/task_get/advanced/",
32179
32204
  languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
32180
32205
  task: (question, market) => ({
32181
32206
  keyword: question,
@@ -32195,7 +32220,8 @@ const ENGINES = [
32195
32220
  {
32196
32221
  name: "google_ai_mode",
32197
32222
  means: "Google AI Mode",
32198
- path: "v3/serp/google/ai_mode/live/advanced",
32223
+ post: "v3/serp/google/ai_mode/task_post",
32224
+ get: "v3/serp/google/ai_mode/task_get/advanced/",
32199
32225
  languages: "v3/serp/google/ai_mode/languages",
32200
32226
  task: (question, market) => ({
32201
32227
  keyword: question,
@@ -32206,7 +32232,8 @@ const ENGINES = [
32206
32232
  {
32207
32233
  name: "google_ai_overview",
32208
32234
  means: "the AI Overview on a Google search",
32209
- path: "v3/serp/google/organic/live/advanced",
32235
+ post: "v3/serp/google/organic/task_post",
32236
+ get: "v3/serp/google/organic/task_get/advanced/",
32210
32237
  languages: "v3/serp/google/languages",
32211
32238
  task: (question, market) => ({
32212
32239
  keyword: question,
@@ -32228,56 +32255,55 @@ function onSite(url, domain) {
32228
32255
  return false;
32229
32256
  }
32230
32257
  }
32231
- async function ask(keys, engine, question, market, site) {
32232
- if (typeof market === "string") return {
32233
- result: "unavailable",
32234
- model: null,
32235
- cited_url: null,
32236
- answered_at: null,
32237
- cost_usd: 0,
32238
- error: market
32239
- };
32258
+ const MAX_SOURCES = 8;
32259
+ const hostOf$2 = (url) => {
32240
32260
  try {
32241
- const done = await dataForSeoTask(keys, engine.path, engine.task(question, market));
32242
- const answered = {
32243
- answered_at: String(done.result?.datetime ?? "").slice(0, 10) || null,
32244
- cost_usd: done.cost
32245
- };
32246
- const shown = done.result && engine.shown(done.result);
32247
- if (!shown) return {
32248
- result: "no_answer",
32249
- model: null,
32250
- cited_url: null,
32251
- ...answered
32252
- };
32253
- const base = {
32254
- model: shown.model,
32255
- ...answered
32256
- };
32257
- const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
32258
- const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
32259
- if (cited) return {
32260
- result: "cited",
32261
- cited_url: cited,
32262
- ...base
32263
- };
32264
- const text = prose(shown.markdown);
32265
- return {
32266
- result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
32267
- cited_url: null,
32268
- ...base
32269
- };
32270
- } catch (error) {
32271
- if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32272
- return {
32273
- result: "unavailable",
32274
- model: null,
32275
- cited_url: null,
32276
- answered_at: null,
32277
- cost_usd: error.cost ?? 0,
32278
- error: error instanceof Error ? error.message.slice(0, 200) : "failed"
32279
- };
32261
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
32262
+ } catch {
32263
+ return null;
32280
32264
  }
32265
+ };
32266
+ const empty = {
32267
+ cited_url: null,
32268
+ sources: [],
32269
+ model: null,
32270
+ answered_at: null
32271
+ };
32272
+ function judge(engine, task, site) {
32273
+ if (task.status_code === 40102) return {
32274
+ result: "no_answer",
32275
+ ...empty
32276
+ };
32277
+ if (task.status_code >= 4e4 || !task.result) return {
32278
+ result: "unavailable",
32279
+ ...empty,
32280
+ error: `dataforseo: ${task.status_message}`.slice(0, 200)
32281
+ };
32282
+ const answered_at = String(task.result.datetime ?? "").slice(0, 10) || null;
32283
+ const shown = engine.shown(task.result);
32284
+ if (!shown) return {
32285
+ result: "no_answer",
32286
+ ...empty,
32287
+ answered_at
32288
+ };
32289
+ const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
32290
+ const all = [...shown.sources, ...linked];
32291
+ const cited = all.find((url) => onSite(url, site.domain)) ?? null;
32292
+ const base = {
32293
+ cited_url: cited,
32294
+ sources: [...new Set(all.filter((url) => !onSite(url, site.domain)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
32295
+ model: shown.model,
32296
+ answered_at
32297
+ };
32298
+ if (cited) return {
32299
+ result: "cited",
32300
+ ...base
32301
+ };
32302
+ const text = prose(shown.markdown);
32303
+ return {
32304
+ result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
32305
+ ...base
32306
+ };
32281
32307
  }
32282
32308
  async function marketsFor(keys, country) {
32283
32309
  const code = country.toUpperCase();
@@ -32295,45 +32321,162 @@ async function marketsFor(keys, country) {
32295
32321
  }));
32296
32322
  }
32297
32323
  const usd = (value) => Math.round(value * 1e4) / 1e4;
32324
+ const FIRST_WAIT_MS = 1e4;
32325
+ const ROUND_WAIT_MS = 5e3;
32326
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
32327
+ const TASK_PATTERN = /^([a-z_]+):([0-9a-f-]{20,64})$/;
32328
+ const AI_ANSWER_TASK_PATTERN = TASK_PATTERN;
32298
32329
  async function runAiAnswers(input, headers, deps, more) {
32299
32330
  const keys = dataForSeoKeys(headers);
32300
32331
  const domain = registrableDomain(input.site)?.domain;
32301
32332
  if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
32302
- const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
32303
- if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
32304
- const range = dateWindow({}, QUESTION_DAYS);
32305
- let questions;
32306
- if (given.length) questions = given.map((query) => ({
32307
- query,
32308
- impressions: null
32309
- }));
32310
- else questions = ((await aiQueriesReport({
32311
- site: input.site,
32312
- ...range
32313
- }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
32314
32333
  const site = {
32315
32334
  domain,
32316
32335
  terms: deriveBrandTerms({ siteUrl: input.site })
32317
32336
  };
32318
- const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
32319
- const answers = await Promise.all(questions.flatMap((question) => ENGINES.map(async (engine) => ({
32320
- question: question.query,
32321
- impressions: question.impressions,
32322
- engine: engine.name,
32323
- ...await ask(keys, engine, question.query, markets.get(engine.name) ?? "no market", site)
32324
- }))));
32325
- const cost = usd(answers.reduce((sum, row) => sum + row.cost_usd, 0));
32326
- const count = (result) => answers.filter((row) => row.result === result).length;
32327
- const notes = [given.length ? `The ${given.length} questions you gave, asked as written. Impressions are blank because they don't come from Search Console.` : 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 ? [
32328
- `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}. DataForSEO cost: $${cost}.`,
32337
+ const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
32338
+ if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
32339
+ const tasks = [...new Set(input.tasks ?? [])];
32340
+ const range = dateWindow({}, QUESTION_DAYS);
32341
+ const rows = [];
32342
+ const waiting = [];
32343
+ const blank = (question, impressions) => ({
32344
+ question,
32345
+ impressions,
32346
+ task: null,
32347
+ cost_usd: 0,
32348
+ ...empty
32349
+ });
32350
+ if (tasks.length) for (const value of tasks) {
32351
+ const match = TASK_PATTERN.exec(value);
32352
+ const engine = ENGINES.find((item) => item.name === match?.[1]);
32353
+ 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(", ")}.`);
32354
+ const row = {
32355
+ ...blank("", null),
32356
+ engine: engine.name,
32357
+ result: "pending",
32358
+ task: value
32359
+ };
32360
+ rows.push(row);
32361
+ waiting.push({
32362
+ row,
32363
+ engine,
32364
+ id: match[2]
32365
+ });
32366
+ }
32367
+ else {
32368
+ let questions;
32369
+ if (given.length) questions = given.map((query) => ({
32370
+ query,
32371
+ impressions: null
32372
+ }));
32373
+ else questions = ((await aiQueriesReport({
32374
+ site: input.site,
32375
+ ...range
32376
+ }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
32377
+ const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
32378
+ await Promise.all(ENGINES.map(async (engine) => {
32379
+ const engineRows = questions.map((question) => ({
32380
+ ...blank(question.query, question.impressions),
32381
+ engine: engine.name,
32382
+ result: "unavailable"
32383
+ }));
32384
+ rows.push(...engineRows);
32385
+ if (!questions.length) return;
32386
+ const market = markets.get(engine.name) ?? "no market";
32387
+ if (typeof market === "string") {
32388
+ for (const row of engineRows) row.error = market;
32389
+ return;
32390
+ }
32391
+ let queuedTasks;
32392
+ try {
32393
+ queuedTasks = await dataForSeoPost(keys, engine.post, questions.map((question) => ({
32394
+ ...engine.task(question.query, market),
32395
+ priority: 2
32396
+ })));
32397
+ } catch (error) {
32398
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32399
+ for (const row of engineRows) row.error = error instanceof Error ? error.message.slice(0, 200) : "failed";
32400
+ return;
32401
+ }
32402
+ engineRows.forEach((row, index) => {
32403
+ const task = queuedTasks[index];
32404
+ row.cost_usd = task?.cost ?? 0;
32405
+ if (!task || task.status_code >= 4e4 || !task.id) {
32406
+ row.error = `dataforseo: ${task?.status_message ?? "no task"}`;
32407
+ return;
32408
+ }
32409
+ row.result = "pending";
32410
+ row.task = `${engine.name}:${task.id}`;
32411
+ waiting.push({
32412
+ row,
32413
+ engine,
32414
+ id: task.id
32415
+ });
32416
+ });
32417
+ }));
32418
+ }
32419
+ const deadline = Date.now() + (input.waitSeconds ?? 40) * 1e3;
32420
+ let pending = waiting;
32421
+ let first = !tasks.length;
32422
+ while (pending.length) {
32423
+ const pause = first ? FIRST_WAIT_MS : ROUND_WAIT_MS;
32424
+ first = false;
32425
+ if (!tasks.length || pending !== waiting) {
32426
+ if (Date.now() + pause > deadline) break;
32427
+ await sleep(pause);
32428
+ }
32429
+ const still = [];
32430
+ await Promise.all(pending.map(async (item) => {
32431
+ try {
32432
+ const task = await dataForSeoGet(keys, `${item.engine.get}${item.id}`);
32433
+ if (!item.row.question) item.row.question = String(task.data.keyword ?? "");
32434
+ if (DATAFORSEO_NOT_READY.has(task.status_code)) {
32435
+ still.push(item);
32436
+ return;
32437
+ }
32438
+ Object.assign(item.row, judge(item.engine, task, site), { task: null });
32439
+ } catch (error) {
32440
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
32441
+ still.push(item);
32442
+ }
32443
+ }));
32444
+ pending = still;
32445
+ if (tasks.length && Date.now() >= deadline) break;
32446
+ }
32447
+ rows.sort((a, b) => a.question.localeCompare(b.question) || ENGINES.findIndex((e) => e.name === a.engine) - ENGINES.findIndex((e) => e.name === b.engine));
32448
+ const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
32449
+ const count = (result) => rows.filter((row) => row.result === result).length;
32450
+ const left = rows.filter((row) => row.task).map((row) => row.task);
32451
+ const cites = /* @__PURE__ */ new Map();
32452
+ for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
32453
+ const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
32454
+ const questionCount = new Set(rows.map((row) => row.question)).size;
32455
+ 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 ? [
32456
+ `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}.`,
32457
+ ...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).`] : [],
32458
+ ...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
32329
32459
  "Each row is one sample of an answer that changes from run to run, not a ranking.",
32330
- `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.`,
32460
+ `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.`,
32331
32461
  `Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
32332
32462
  ] : []];
32463
+ const shown = rows.map((row) => ({
32464
+ question: row.question,
32465
+ impressions: row.impressions,
32466
+ engine: row.engine,
32467
+ result: row.result,
32468
+ cited_url: row.cited_url,
32469
+ sources: row.sources.join(", "),
32470
+ model: row.model,
32471
+ answered_at: row.answered_at,
32472
+ cost_usd: row.cost_usd,
32473
+ task: row.task,
32474
+ error: row.error ?? null
32475
+ }));
32333
32476
  return {
32334
32477
  text: rowsText({
32335
32478
  title: `AI answers for ${input.site}, ${input.country}.`,
32336
- rows: answers,
32479
+ rows: shown,
32337
32480
  offset: input.offset,
32338
32481
  limit: 50,
32339
32482
  detail: input.detail,
@@ -32345,15 +32488,23 @@ async function runAiAnswers(input, headers, deps, more) {
32345
32488
  action: "ai_answers",
32346
32489
  site: input.site,
32347
32490
  country: input.country,
32348
- questionsFrom: given.length ? { section: "given" } : {
32491
+ questionsFrom: tasks.length ? { section: "tasks" } : given.length ? { section: "given" } : {
32349
32492
  section: "ai-queries",
32350
32493
  ...range
32351
32494
  },
32352
- questions: questions.map((question) => question.query),
32495
+ questions: [...new Set(rows.map((row) => row.question))],
32353
32496
  costUsd: cost,
32354
- total: answers.length,
32497
+ pendingTasks: left,
32498
+ citedSites: topCited.map(([host, answers]) => ({
32499
+ host,
32500
+ answers
32501
+ })),
32502
+ total: rows.length,
32355
32503
  offset: input.offset,
32356
- rows: answers.slice(input.offset, input.offset + 50)
32504
+ rows: rows.slice(input.offset, input.offset + 50).map(({ error, ...row }) => error ? {
32505
+ ...row,
32506
+ error
32507
+ } : row)
32357
32508
  }
32358
32509
  };
32359
32510
  }
@@ -34935,7 +35086,7 @@ function nextFor(section, call) {
34935
35086
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
34936
35087
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
34937
35088
  "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.`,
34938
- "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.`,
35089
+ "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.`,
34939
35090
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
34940
35091
  }[section];
34941
35092
  }
@@ -35439,7 +35590,7 @@ const RESEARCH_ACTIONS = [
35439
35590
  "ai_answers"
35440
35591
  ];
35441
35592
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
35442
- 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. 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 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.`;
35593
+ 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.`;
35443
35594
  const GENERIC_PLATFORMS = [
35444
35595
  "youtube.com",
35445
35596
  "facebook.com",
@@ -36358,6 +36509,7 @@ const SHARED_TOOLS = [
36358
36509
  action: z.enum(RESEARCH_ACTIONS),
36359
36510
  keywords: z.array(z.string().min(1)).max(100).default([]),
36360
36511
  target: z.string().default("").describe("Domain or URL."),
36512
+ 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."),
36361
36513
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
36362
36514
  ...detail
36363
36515
  }),
@@ -36366,11 +36518,12 @@ const SHARED_TOOLS = [
36366
36518
  if (input.action === "ai_answers") {
36367
36519
  if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
36368
36520
  return runAiAnswers({
36369
- site: input.keywords.length ? input.target : await surface.resolveSite(input.target),
36521
+ site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
36370
36522
  country: input.country,
36371
36523
  detail: input.detail,
36372
36524
  offset: input.offset,
36373
- questions: input.keywords
36525
+ questions: input.keywords,
36526
+ tasks: input.tasks
36374
36527
  }, await surface.keys(), surface.deps, more);
36375
36528
  }
36376
36529
  return runResearch(input, await surface.keys(), {