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/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BnpsvtXl.js → dist-DOJoZZ9W.js} +1 -1
- package/dist/{dist-DoPZlCJq.js → dist-Dpl5hSMM.js} +71 -71
- package/dist/{dist-DoPZlCJq.js.map → dist-Dpl5hSMM.js.map} +1 -1
- package/dist/{esm-D12ZCG_1.js → esm-Dt4JJZ0P.js} +2 -2
- package/dist/{esm-D12ZCG_1.js.map → esm-Dt4JJZ0P.js.map} +1 -1
- package/dist/index.js +245 -92
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +245 -92
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
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-
|
|
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$
|
|
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$
|
|
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$
|
|
2734
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
|
|
2735
2735
|
})));
|
|
2736
|
-
const googleHosts = new Set(properties.map((property) => hostOf$
|
|
2737
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32232
|
-
|
|
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
|
-
|
|
32242
|
-
|
|
32243
|
-
|
|
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
|
|
32319
|
-
|
|
32320
|
-
|
|
32321
|
-
|
|
32322
|
-
|
|
32323
|
-
|
|
32324
|
-
|
|
32325
|
-
|
|
32326
|
-
|
|
32327
|
-
|
|
32328
|
-
|
|
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:
|
|
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:
|
|
32495
|
+
questions: [...new Set(rows.map((row) => row.question))],
|
|
32353
32496
|
costUsd: cost,
|
|
32354
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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(), {
|