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/PRIVACY.md +4 -1
- package/dist/cli.js +84 -84
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BtwI_F7s.js → dist-B3L_4U8E.js} +1 -1
- package/dist/{dist-DkegfPAq.js → dist-BdZyUFiw.js} +100 -92
- package/dist/dist-BdZyUFiw.js.map +1 -0
- package/dist/{esm-DeY7lZeN.js → esm-gP6ovS6b.js} +2 -2
- package/dist/{esm-DeY7lZeN.js.map → esm-gP6ovS6b.js.map} +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +29 -27
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +874 -424
- package/dist/mcp.js.map +1 -1
- package/package.json +3 -1
- package/skills/seo/SKILL.md +1 -1
- package/dist/dist-DkegfPAq.js.map +0 -1
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-
|
|
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
|
-
|
|
39971
|
-
|
|
39972
|
-
|
|
39973
|
-
const
|
|
39974
|
-
if (
|
|
39975
|
-
|
|
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
|
-
|
|
40176
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
40611
|
-
|
|
40612
|
-
|
|
40613
|
-
|
|
40614
|
-
|
|
40615
|
-
|
|
40616
|
-
|
|
40617
|
-
|
|
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
|
|
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) =>
|
|
40683
|
-
|
|
40684
|
-
|
|
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);
|