seo 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as version, t as load } from "./esm-CQJgJ8TD.js";
2
+ import { n as version, t as load } from "./esm-Cyusbw2J.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";
@@ -1730,6 +1730,87 @@ function bingCall(auth, options) {
1730
1730
  return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$4 })).d;
1731
1731
  };
1732
1732
  }
1733
+ function day$2(value) {
1734
+ if (typeof value !== "string") return null;
1735
+ const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
1736
+ const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
1737
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
1738
+ }
1739
+ const count$2 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1740
+ function list$1(data, label) {
1741
+ if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
1742
+ return data.filter((item) => Boolean(item) && typeof item === "object");
1743
+ }
1744
+ async function listBingSites(call) {
1745
+ return list$1(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
1746
+ url: row.Url,
1747
+ verified: row.IsVerified === true
1748
+ })).sort((a, b) => a.url.localeCompare(b.url, "en"));
1749
+ }
1750
+ async function bingTraffic(call, site) {
1751
+ const rows = [];
1752
+ let invalidRows = 0;
1753
+ for (const row of list$1(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
1754
+ const date = day$2(row.Date);
1755
+ const clicks = count$2(row.Clicks);
1756
+ const impressions = count$2(row.Impressions);
1757
+ if (!date || clicks === null || impressions === null) {
1758
+ invalidRows += 1;
1759
+ continue;
1760
+ }
1761
+ rows.push({
1762
+ date,
1763
+ clicks,
1764
+ impressions
1765
+ });
1766
+ }
1767
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en"));
1768
+ return {
1769
+ rows,
1770
+ invalidRows
1771
+ };
1772
+ }
1773
+ async function keyed(call, method, params) {
1774
+ const rows = [];
1775
+ let invalidRows = 0;
1776
+ for (const row of list$1(await call(method, params), "row list")) {
1777
+ const date = day$2(row.Date);
1778
+ const key = typeof row.Query === "string" ? row.Query.trim() : "";
1779
+ const clicks = count$2(row.Clicks);
1780
+ const impressions = count$2(row.Impressions);
1781
+ const position = count$2(row.AvgImpressionPosition);
1782
+ if (!date || !key || clicks === null || impressions === null) {
1783
+ invalidRows += 1;
1784
+ continue;
1785
+ }
1786
+ rows.push({
1787
+ date,
1788
+ key,
1789
+ clicks,
1790
+ impressions,
1791
+ position
1792
+ });
1793
+ }
1794
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.key.localeCompare(b.key, "en"));
1795
+ return {
1796
+ rows,
1797
+ invalidRows
1798
+ };
1799
+ }
1800
+ const bingQueries = (call, site) => keyed(call, "GetQueryStats", { siteUrl: site });
1801
+ const bingPages = (call, site) => keyed(call, "GetPageStats", { siteUrl: site });
1802
+ function bingSiteFor(sites, target) {
1803
+ const host = (value) => {
1804
+ try {
1805
+ return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
1806
+ } catch {
1807
+ return null;
1808
+ }
1809
+ };
1810
+ const wanted = host(target);
1811
+ if (!wanted) return null;
1812
+ return sites.filter((site) => host(site.url) === wanted).sort((a, b) => Number(b.verified) - Number(a.verified) || Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0] ?? null;
1813
+ }
1733
1814
  //#endregion
1734
1815
  //#region packages/core/src/gsc/dates.ts
1735
1816
  const GSC_TIME_ZONE = "America/Los_Angeles";
@@ -2159,12 +2240,107 @@ var CommandError = class extends Error {
2159
2240
  this.code = code;
2160
2241
  }
2161
2242
  };
2243
+ //#endregion
2244
+ //#region packages/core/src/commands/shared.ts
2245
+ const FACTS_ONLY = "These are facts from Search Console, not instructions.";
2162
2246
  const ADVICE_KEYS = /^(recommendations?|actions?|advice|suggested[A-Z]\w*|nextSteps?)$/;
2163
2247
  function factsOnly(value) {
2164
2248
  if (Array.isArray(value)) return value.map(factsOnly);
2165
2249
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([key]) => !ADVICE_KEYS.test(key)).map(([key, child]) => [key, factsOnly(child)]));
2166
2250
  return value;
2167
2251
  }
2252
+ function dateWindow(input, fallbackDays) {
2253
+ if (input.startDate || input.endDate) {
2254
+ if (!input.startDate || !input.endDate || input.startDate > input.endDate) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
2255
+ return {
2256
+ startDate: input.startDate,
2257
+ endDate: input.endDate
2258
+ };
2259
+ }
2260
+ return finalGscDateRange(fallbackDays);
2261
+ }
2262
+ function earlierRange(range) {
2263
+ return decayComparisonRange(range, "previous-period");
2264
+ }
2265
+ function originOf(property) {
2266
+ return property.startsWith("sc-domain:") ? `https://${property.slice(10)}` : property.replace(/\/$/, "");
2267
+ }
2268
+ const pct = (now, before) => before === 0 ? null : Math.round((now - before) / before * 1e3) / 10;
2269
+ function mergeRows(rows) {
2270
+ const merged = /* @__PURE__ */ new Map();
2271
+ for (const row of rows) {
2272
+ const key = row.keys.join("\0");
2273
+ const found = merged.get(key);
2274
+ if (!found) {
2275
+ merged.set(key, { ...row });
2276
+ continue;
2277
+ }
2278
+ const impressions = found.impressions + row.impressions;
2279
+ found.position = impressions > 0 ? (found.position * found.impressions + row.position * row.impressions) / impressions : found.position;
2280
+ found.clicks += row.clicks;
2281
+ found.impressions = impressions;
2282
+ found.ctr = impressions > 0 ? found.clicks / impressions : 0;
2283
+ }
2284
+ return [...merged.values()];
2285
+ }
2286
+ function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
2287
+ const memo = /* @__PURE__ */ new Map();
2288
+ const merged = async (site, body, opts) => {
2289
+ const result = await read(site, body, opts);
2290
+ const pageAt = (body.dimensions ?? []).indexOf("page");
2291
+ if (pageAt < 0 || redirects.size === 0) return result;
2292
+ return {
2293
+ ...result,
2294
+ rows: mergeRows(result.rows.map((row) => {
2295
+ const target = redirects.get(row.keys[pageAt] ?? "");
2296
+ if (!target) return row;
2297
+ const keys = [...row.keys];
2298
+ keys[pageAt] = target;
2299
+ return {
2300
+ ...row,
2301
+ keys
2302
+ };
2303
+ }))
2304
+ };
2305
+ };
2306
+ return (site, body, opts) => {
2307
+ const key = JSON.stringify([site, body]);
2308
+ let found = memo.get(key);
2309
+ if (!found) {
2310
+ found = merged(site, body, opts);
2311
+ memo.set(key, found);
2312
+ }
2313
+ return found;
2314
+ };
2315
+ }
2316
+ const MAX_STATUS_BYTES = 10 * 1024 * 1024;
2317
+ async function googleUpdates(window) {
2318
+ if (!window) return [];
2319
+ const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? readJson(response, "Google Search Status", { maxBytes: MAX_STATUS_BYTES }) : null).catch(() => null);
2320
+ if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
2321
+ return findOverlappingSearchUpdates({
2322
+ updates: searchUpdatesFromIncidents(withRankingHistory(incidents), {
2323
+ product: "Ranking",
2324
+ limit: Number.POSITIVE_INFINITY
2325
+ }),
2326
+ startDate: window.start,
2327
+ endDate: window.end,
2328
+ paddingDays: 3
2329
+ });
2330
+ }
2331
+ function checkedDates(checked) {
2332
+ const window = checked;
2333
+ if (!window?.start || !window.end) return "in this window";
2334
+ const dates = window.start === window.end ? window.start : `${window.start} to ${window.end}`;
2335
+ return `within ${window.paddingDays ?? 3} days of ${dates}`;
2336
+ }
2337
+ function listOfUpdates(value, checked) {
2338
+ if (typeof value === "string") return value;
2339
+ if (!Array.isArray(value)) return null;
2340
+ const where = checkedDates(checked);
2341
+ if (value.length === 0) return `Google updates ${where}: none.`;
2342
+ return `Google updates ${where} (timing only, not cause): ${value.map((update) => `${update.name} (${update.start.slice(0, 10)} to ${update.end?.slice(0, 10) ?? "ongoing"})`).join("; ")}.`;
2343
+ }
2168
2344
  function matchProperty(names, site) {
2169
2345
  if (names.includes(site)) return site;
2170
2346
  let host;
@@ -2181,6 +2357,270 @@ function matchProperty(names, site) {
2181
2357
  return names.find((name) => name === `${origin}/` || name === `${origin.replace("://", "://www.")}/` || name === `${origin.replace("://www.", "://")}/`) ?? null;
2182
2358
  }
2183
2359
  //#endregion
2360
+ //#region packages/core/src/commands/present.ts
2361
+ const LIST_KEYS = [
2362
+ "items",
2363
+ "rows",
2364
+ "results",
2365
+ "pages",
2366
+ "urls",
2367
+ "queries",
2368
+ "keywords",
2369
+ "opportunities",
2370
+ "candidates",
2371
+ "clusters",
2372
+ "issues",
2373
+ "domains",
2374
+ "competitors",
2375
+ "links"
2376
+ ];
2377
+ const SKIP_KEYS = /* @__PURE__ */ new Set([
2378
+ "caveats",
2379
+ "warnings",
2380
+ "recommendations",
2381
+ "findings",
2382
+ "sampleUrls",
2383
+ "sampleQueries",
2384
+ "omissions",
2385
+ "dimensions",
2386
+ "brand",
2387
+ "pageChecks"
2388
+ ]);
2389
+ const ID_COLUMNS = [
2390
+ "metric",
2391
+ "direction",
2392
+ "changeDate",
2393
+ "query",
2394
+ "queryFlags",
2395
+ "keyword",
2396
+ "url",
2397
+ "page",
2398
+ "finalUrl",
2399
+ "title",
2400
+ "domain",
2401
+ "target",
2402
+ "check",
2403
+ "rule",
2404
+ "severity",
2405
+ "status",
2406
+ "urls",
2407
+ "topPage"
2408
+ ];
2409
+ const METRIC_COLUMNS = [
2410
+ "percentChange",
2411
+ "baselineMean",
2412
+ "comparisonMean",
2413
+ "clicks",
2414
+ "impressions",
2415
+ "ctr",
2416
+ "position",
2417
+ "previousClicks",
2418
+ "clicksChange",
2419
+ "searchVolume",
2420
+ "volume",
2421
+ "cpc",
2422
+ "difficulty",
2423
+ "rank",
2424
+ "count",
2425
+ "affectedUrls",
2426
+ "score",
2427
+ "priority.score"
2428
+ ];
2429
+ const SUMMARY_ROWS$1 = 10;
2430
+ const CELL_MAX = 80;
2431
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2432
+ const isPrimitive = (value) => value === null || [
2433
+ "string",
2434
+ "number",
2435
+ "boolean"
2436
+ ].includes(typeof value);
2437
+ function mainList(report) {
2438
+ const found = [];
2439
+ const visit = (value, path, depth) => {
2440
+ if (!isObject(value) || depth > 2) return;
2441
+ for (const [key, child] of Object.entries(value)) {
2442
+ if (SKIP_KEYS.has(key)) continue;
2443
+ const childPath = path ? `${path}.${key}` : key;
2444
+ if (Array.isArray(child) && child.length > 0 && child.every(isObject)) found.push({
2445
+ path: childPath,
2446
+ rows: child,
2447
+ known: LIST_KEYS.includes(key)
2448
+ });
2449
+ else if (isObject(child)) visit(child, childPath, depth + 1);
2450
+ }
2451
+ };
2452
+ visit(report, "", 0);
2453
+ found.sort((a, b) => Number(b.known) - Number(a.known) || b.rows.length - a.rows.length || a.path.length - b.path.length);
2454
+ return found[0] ?? null;
2455
+ }
2456
+ function flatten(row) {
2457
+ const flat = {};
2458
+ for (const [key, value] of Object.entries(row)) if (isPrimitive(value)) flat[key] = value;
2459
+ else if (Array.isArray(value) && value.length && value.every(isPrimitive)) flat[key] = value.join(" ");
2460
+ else if (isObject(value)) {
2461
+ for (const [inner, innerValue] of Object.entries(value)) if (isPrimitive(innerValue)) flat[`${key}.${inner}`] = innerValue;
2462
+ }
2463
+ return flat;
2464
+ }
2465
+ function columnsFor(rows, max) {
2466
+ const seen = /* @__PURE__ */ new Map();
2467
+ for (const row of rows.slice(0, 50)) for (const [key, value] of Object.entries(row)) {
2468
+ const entry = seen.get(key) ?? {
2469
+ count: 0,
2470
+ length: 0
2471
+ };
2472
+ entry.count++;
2473
+ entry.length += String(value ?? "").length;
2474
+ seen.set(key, entry);
2475
+ }
2476
+ const usable = [...seen].filter(([key, { count, length }]) => {
2477
+ const average = length / count;
2478
+ return ID_COLUMNS.includes(key) || average <= 60;
2479
+ });
2480
+ const rank = (key) => {
2481
+ const id = ID_COLUMNS.indexOf(key);
2482
+ if (id >= 0) return id;
2483
+ const metric = METRIC_COLUMNS.indexOf(key);
2484
+ if (metric >= 0) return 100 + metric;
2485
+ return 1e3;
2486
+ };
2487
+ return usable.map(([key]) => key).filter((key) => !/(^|\.)(id|method|heuristic|estimatedClickLift|confidence|label)$|^site$/.test(key)).sort((a, b) => rank(a) - rank(b)).slice(0, max);
2488
+ }
2489
+ function cell(value, origin, max = CELL_MAX) {
2490
+ if (value === null || value === void 0) return "";
2491
+ let text = String(value);
2492
+ if (origin && text.startsWith(origin)) text = text.slice(origin.length) || "/";
2493
+ if (typeof value === "number" && !Number.isInteger(value)) {
2494
+ const places = Math.abs(value) < 1 ? 1e3 : 100;
2495
+ text = String(Math.round(value * places) / places);
2496
+ }
2497
+ text = text.replaceAll("|", "\\|").replaceAll("\n", " ");
2498
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
2499
+ }
2500
+ function table$1(rows, columns, origin, max = CELL_MAX) {
2501
+ if (rows.length === 0 || columns.length === 0) return "";
2502
+ return [columns.join(" | "), ...rows.map((row) => columns.map((key) => cell(row[key], origin, max)).join(" | "))].join("\n");
2503
+ }
2504
+ function findString(report, keys) {
2505
+ for (const key of keys) {
2506
+ const value = report[key];
2507
+ if (typeof value === "string") return value;
2508
+ }
2509
+ return null;
2510
+ }
2511
+ function dateRange$1(report) {
2512
+ const range = isObject(report.range) ? report.range : report;
2513
+ const from = findString(range, [
2514
+ "startDate",
2515
+ "from",
2516
+ "start"
2517
+ ]);
2518
+ const to = findString(range, [
2519
+ "endDate",
2520
+ "to",
2521
+ "end"
2522
+ ]);
2523
+ return from && to ? `${from} to ${to}` : null;
2524
+ }
2525
+ function limitsOf(report) {
2526
+ return [report.caveats, isObject(report.retainedEvidence) ? report.retainedEvidence.caveats : null].flatMap((list) => Array.isArray(list) ? list : []).filter((line) => typeof line === "string").filter((line) => !/^(Date window|Selection):/.test(line)).slice(0, 4).map((line) => line.length > 200 ? `${line.slice(0, 199)}…` : line);
2527
+ }
2528
+ function keyFacts(report) {
2529
+ if (!isObject(report.summary)) return null;
2530
+ const facts = Object.entries(report.summary).filter(([key, value]) => key !== "verdict" && isPrimitive(value) && value !== null).slice(0, 8).map(([key, value]) => `${key} ${cell(value, null)}`);
2531
+ return facts.length ? facts.join(", ") : null;
2532
+ }
2533
+ function filteredLine(report) {
2534
+ if (!isObject(report.selection)) return null;
2535
+ const counts = Object.entries(report.selection).filter(([key, value]) => typeof value === "number" && value > 0 && !/(source|aggregated|valid|eligible|returned|total|groups|days)/i.test(key)).map(([key, value]) => `${key} ${value}`);
2536
+ return counts.length ? `Filtered out: ${counts.join(", ")}.` : null;
2537
+ }
2538
+ function findingsFirst(report) {
2539
+ const last = Object.keys(report).filter((key) => /suppress|omitted|excluded/i.test(key));
2540
+ if (!last.length) return report;
2541
+ const out = {};
2542
+ for (const [key, value] of Object.entries(report)) if (!last.includes(key)) out[key] = value;
2543
+ for (const key of last) out[key] = report[key];
2544
+ return out;
2545
+ }
2546
+ function presentReport(input) {
2547
+ const { report, headline, origin } = input;
2548
+ if (!report) return {
2549
+ text: headline,
2550
+ data: null
2551
+ };
2552
+ if (input.detail === "full") {
2553
+ const ordered = findingsFirst(report);
2554
+ return {
2555
+ text: `${headline}\n\n${JSON.stringify(ordered)}`,
2556
+ data: ordered
2557
+ };
2558
+ }
2559
+ const list = mainList(report);
2560
+ const flat = list ? list.rows.map(flatten) : [];
2561
+ const head = [
2562
+ input.label,
2563
+ input.range ?? dateRange$1(report),
2564
+ findString(report, ["dataStatus"])
2565
+ ].filter(Boolean).join(" · ");
2566
+ const facts = keyFacts(report);
2567
+ const limits = limitsOf(report);
2568
+ if (input.detail === "rows") {
2569
+ const page = flat.slice(input.offset, input.offset + input.limit);
2570
+ const columns = columnsFor(flat, 14);
2571
+ const end = input.offset + page.length;
2572
+ return {
2573
+ text: [
2574
+ head,
2575
+ list ? `${list.path}: rows ${input.offset + 1} to ${end} of ${flat.length}.` : "This report has no list of rows. Use detail \"full\".",
2576
+ table$1(page, columns, origin),
2577
+ end < flat.length ? `Next page: ${input.more("rows", end)}` : ""
2578
+ ].filter(Boolean).join("\n\n"),
2579
+ data: {
2580
+ path: list?.path ?? null,
2581
+ total: flat.length,
2582
+ offset: input.offset,
2583
+ columns,
2584
+ rows: page
2585
+ }
2586
+ };
2587
+ }
2588
+ const shown = flat.slice(0, Math.min(input.limit, SUMMARY_ROWS$1));
2589
+ const columns = columnsFor(flat, 8);
2590
+ return {
2591
+ text: [
2592
+ head,
2593
+ headline,
2594
+ facts ? `Facts: ${facts}.` : "",
2595
+ ...input.notes ?? [],
2596
+ flat.length < 5 ? filteredLine(report) : null,
2597
+ list ? `${list.path} (${shown.length} of ${flat.length} shown):\n${table$1(shown, columns, origin)}` : "",
2598
+ limits.length ? `Limits:\n${limits.map((line) => `- ${line}`).join("\n")}` : "",
2599
+ `More: ${shown.length < flat.length ? `${input.more("rows", shown.length)} for more rows, or ` : ""}${input.more("full")} for every field.`
2600
+ ].filter(Boolean).join("\n\n"),
2601
+ data: {
2602
+ summary: isObject(report.summary) ? report.summary : null,
2603
+ path: list?.path ?? null,
2604
+ total: flat.length,
2605
+ columns,
2606
+ rows: shown,
2607
+ limits
2608
+ }
2609
+ };
2610
+ }
2611
+ function preview(report, origin, rows) {
2612
+ const list = mainList(report);
2613
+ if (!list) return {
2614
+ total: 0,
2615
+ text: ""
2616
+ };
2617
+ const flat = list.rows.map(flatten);
2618
+ return {
2619
+ total: flat.length,
2620
+ text: table$1(flat.slice(0, rows), columnsFor(flat, 6), origin)
2621
+ };
2622
+ }
2623
+ //#endregion
2184
2624
  //#region packages/core/src/commands/sites.ts
2185
2625
  async function readableSites(google, signIn) {
2186
2626
  return (await listSearchConsoleSites(google, { signIn })).filter((entry) => entry.permissionLevel !== "siteUnverifiedUser").map((entry) => ({
@@ -2188,6 +2628,73 @@ async function readableSites(google, signIn) {
2188
2628
  permissionLevel: entry.permissionLevel ?? ""
2189
2629
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
2190
2630
  }
2631
+ const hostOf$2 = (value) => {
2632
+ try {
2633
+ return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
2634
+ } catch {
2635
+ return value;
2636
+ }
2637
+ };
2638
+ async function runSites(input) {
2639
+ const named = input.accounts.length > 1;
2640
+ const [google, bing] = await Promise.all([Promise.all(input.accounts.map(async (account) => {
2641
+ const [properties, analytics] = await Promise.all([readableSites(account.google, input.signIn).catch((error) => error instanceof Error ? error : new Error(String(error))), listAnalyticsAccounts(account.google, { signIn: input.signIn }).catch(() => null)]);
2642
+ return {
2643
+ email: account.email,
2644
+ properties,
2645
+ analytics
2646
+ };
2647
+ })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
2648
+ const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
2649
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$2(site.url), site]));
2650
+ const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
2651
+ ...property,
2652
+ ...named ? { account: account.email ?? "" } : {},
2653
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$2(property.siteUrl))?.url ?? "" } : {}
2654
+ })));
2655
+ const googleHosts = new Set(properties.map((property) => hostOf$2(property.siteUrl)));
2656
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$2(site.url)));
2657
+ const failed = google.filter((account) => account.properties instanceof Error);
2658
+ const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
2659
+ property: property.property.replace("properties/", ""),
2660
+ name: property.displayName ?? "",
2661
+ account: summary.displayName ?? "",
2662
+ ...named ? { signIn: account.email ?? "" } : {}
2663
+ }))));
2664
+ const research = [["dataforseo", "x-dataforseo-login"], ["ahrefs", "x-ahrefs-key"]].filter(([, name]) => input.keys.get(name ?? "")).map(([provider]) => provider);
2665
+ const columns = [
2666
+ "siteUrl",
2667
+ "permissionLevel",
2668
+ ...named ? ["account"] : [],
2669
+ ...Array.isArray(bing) ? ["bing"] : []
2670
+ ];
2671
+ return {
2672
+ text: [
2673
+ named ? `Google accounts: ${input.accounts.map((account) => account.email).join(", ")}.` : null,
2674
+ `Search Console properties (${properties.length}):`,
2675
+ properties.length ? table$1(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
2676
+ ...failed.map((account) => `${account.email ?? "Google"}: could not list properties. ${account.properties.message}`),
2677
+ analytics.length ? `Google Analytics properties (${analytics.length}):\n${table$1(analytics, [
2678
+ "property",
2679
+ "name",
2680
+ "account",
2681
+ ...named ? ["signIn"] : []
2682
+ ], null)}` : "Google Analytics: no properties readable with these sign-ins.",
2683
+ input.bing === void 0 ? null : bing === null ? "Bing Webmaster: not connected." : bing instanceof Error ? `Bing Webmaster: could not list sites. ${bing.message}` : bingOnly.length ? `Bing Webmaster sites with no Search Console property here (${bingOnly.length}): ${bingOnly.map((site) => site.url).join(", ")}.` : `Bing Webmaster: ${bingSites.length} verified sites; the bing column shows each one.`,
2684
+ `Research keys: ${research.length ? research.join(", ") : "none"}.`,
2685
+ input.clicky?.length ? `Clicky sites (seo_data with source "clicky"): ${input.clicky.join(", ")}.` : null,
2686
+ `Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
2687
+ "Next: seo_report with a site."
2688
+ ].filter(Boolean).join("\n\n"),
2689
+ data: {
2690
+ searchConsole: properties,
2691
+ googleAnalytics: analytics,
2692
+ bing: Array.isArray(bing) ? bingSites : null,
2693
+ research,
2694
+ clicky: input.clicky ?? []
2695
+ }
2696
+ };
2697
+ }
2191
2698
  //#endregion
2192
2699
  //#region packages/core/src/commands/linked.ts
2193
2700
  const MAX_ROWS$2 = 1e5;
@@ -2264,6 +2771,9 @@ var LinkedAccounts = class {
2264
2771
  })();
2265
2772
  return this.owners;
2266
2773
  }
2774
+ async properties() {
2775
+ return [...(await this.ownerMap()).keys()].sort();
2776
+ }
2267
2777
  async resolve(site) {
2268
2778
  if (this.google.length === 0) throw new CommandError("NOT_CONNECTED", `No Google account is connected. ${this.hint.signIn}`);
2269
2779
  const found = matchProperty([...(await this.ownerMap()).keys()], site);
@@ -5108,11 +5618,11 @@ function analyzeAiReferralRows(input) {
5108
5618
  }
5109
5619
  //#endregion
5110
5620
  //#region packages/core/src/analyze/ai-referrals-query.ts
5111
- const PAGE_SIZE$2 = 25e3;
5621
+ const PAGE_SIZE$3 = 25e3;
5112
5622
  async function fetchAiReferralRows(input) {
5113
- const pageSize = input.pageSize ?? PAGE_SIZE$2;
5623
+ const pageSize = input.pageSize ?? PAGE_SIZE$3;
5114
5624
  if (!Number.isInteger(input.maxRows) || input.maxRows < 1) throw new Error("maxRows must be a positive whole number.");
5115
- if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > PAGE_SIZE$2) throw new Error(`pageSize must be a whole number between 1 and ${PAGE_SIZE$2}.`);
5625
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > PAGE_SIZE$3) throw new Error(`pageSize must be a whole number between 1 and ${PAGE_SIZE$3}.`);
5116
5626
  const rows = [];
5117
5627
  const warnings = /* @__PURE__ */ new Set();
5118
5628
  const retainedKeys = /* @__PURE__ */ new Set();
@@ -5219,7 +5729,7 @@ function resultLimit(input) {
5219
5729
  if (!Number.isInteger(value) || value < 1 || value > MAX_RESULT_LIMIT) throw new SeoError("INVALID_INPUT", `resultLimit must be a whole number between 1 and ${MAX_RESULT_LIMIT}.`);
5220
5730
  return value;
5221
5731
  }
5222
- function evidence(result) {
5732
+ function evidence$1(result) {
5223
5733
  return {
5224
5734
  status: result.truncated || result.warnings.length ? "partial" : "complete",
5225
5735
  calls: result.calls,
@@ -5322,7 +5832,7 @@ async function aiReferralsReport(input, dependencies = {}) {
5322
5832
  label: "AI referral landing-page detail query",
5323
5833
  query
5324
5834
  });
5325
- detailEvidence = evidence(detailResult);
5835
+ detailEvidence = evidence$1(detailResult);
5326
5836
  } catch (error) {
5327
5837
  detailEvidence = unavailableEvidence(`The landing-page detail query failed: ${error instanceof Error ? error.message : String(error)}`);
5328
5838
  }
@@ -5340,7 +5850,7 @@ async function aiReferralsReport(input, dependencies = {}) {
5340
5850
  query
5341
5851
  });
5342
5852
  totalUsers = totalUsersFromRows(usersResult.rows) ?? null;
5343
- totalUsersEvidence = evidence(usersResult);
5853
+ totalUsersEvidence = evidence$1(usersResult);
5344
5854
  if (totalUsers === null) totalUsersEvidence = unavailableEvidence("The de-duplicated total-users query returned no valid value.");
5345
5855
  } catch (error) {
5346
5856
  totalUsersEvidence = unavailableEvidence(`The de-duplicated total-users query failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -5355,7 +5865,7 @@ async function aiReferralsReport(input, dependencies = {}) {
5355
5865
  sourceRows: sourceResult.rows,
5356
5866
  detailRows: detailResult?.rows ?? [],
5357
5867
  totalUsers,
5358
- sourceDiscovery: evidence(sourceResult),
5868
+ sourceDiscovery: evidence$1(sourceResult),
5359
5869
  detail: detailEvidence,
5360
5870
  totalUsersEvidence
5361
5871
  });
@@ -8310,10 +8820,29 @@ async function urlInspectionQuota(input) {
8310
8820
  property: input.siteUrl
8311
8821
  });
8312
8822
  }
8823
+ function reserveOrSkip(input) {
8824
+ try {
8825
+ return reserveUrlInspectionQuota(input);
8826
+ } catch (error) {
8827
+ if (error instanceof SeoError) throw error;
8828
+ return;
8829
+ }
8830
+ }
8831
+ function finalize(reservation, outcome, blockedUntil, now) {
8832
+ if (!reservation) return;
8833
+ try {
8834
+ finalizeUrlInspectionQuota({
8835
+ reservation,
8836
+ outcome,
8837
+ blockedUntil,
8838
+ now
8839
+ });
8840
+ } catch {}
8841
+ }
8313
8842
  async function inspectUrl(input) {
8314
8843
  const inspectionUrl = assertUrlMatchesGscProperty(input.siteUrl, input.inspectionUrl);
8315
8844
  const { client, quotaIdentity } = await getAuthorized(input.accountEmail ?? await cloudAccountFor(input.siteUrl));
8316
- const reservation = reserveUrlInspectionQuota({
8845
+ const reservation = reserveOrSkip({
8317
8846
  credentialKey: credentialKey(quotaIdentity),
8318
8847
  property: input.siteUrl,
8319
8848
  limit: input.quotaLimit
@@ -8329,35 +8858,24 @@ async function inspectUrl(input) {
8329
8858
  })
8330
8859
  });
8331
8860
  } catch (error) {
8332
- finalizeUrlInspectionQuota({
8333
- reservation,
8334
- outcome: "consumed"
8335
- });
8861
+ finalize(reservation, "consumed");
8336
8862
  if (error instanceof SeoError) throw error;
8337
8863
  throw new SeoError("PROVIDER_UNAVAILABLE", `URL Inspection request failed: ${error instanceof Error ? error.message : String(error)}`);
8338
8864
  }
8339
8865
  if (response.status === 429) {
8340
8866
  const now = /* @__PURE__ */ new Date();
8341
8867
  const blockedUntil = retryAfter$1(response, now);
8342
- finalizeUrlInspectionQuota({
8343
- reservation,
8344
- outcome: "exhausted",
8345
- blockedUntil,
8346
- now
8347
- });
8868
+ finalize(reservation, "exhausted", blockedUntil, now);
8348
8869
  throw new UrlInspectionQuotaError({
8349
8870
  property: input.siteUrl,
8350
- resetAt: (blockedUntil ?? new Date(reservation.resetAt)).toISOString(),
8351
- used: reservation.used + reservation.count,
8352
- limit: reservation.limit,
8871
+ resetAt: (blockedUntil ?? (reservation ? new Date(reservation.resetAt) : urlInspectionQuotaResetAt(now))).toISOString(),
8872
+ used: reservation ? reservation.used + reservation.count : 0,
8873
+ limit: reservation?.limit ?? 2e3,
8353
8874
  reason: "provider",
8354
8875
  requestSent: true
8355
8876
  });
8356
8877
  }
8357
- finalizeUrlInspectionQuota({
8358
- reservation,
8359
- outcome: "consumed"
8360
- });
8878
+ finalize(reservation, "consumed");
8361
8879
  if (!response.ok) throw inspectionResponseError(response, input);
8362
8880
  return await readJson(response, "URL Inspection");
8363
8881
  }
@@ -28488,8 +29006,63 @@ async function detectBrandTerms(input) {
28488
29006
  };
28489
29007
  }
28490
29008
  //#endregion
29009
+ //#region packages/core/src/clicky/api.ts
29010
+ const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
29011
+ const MAX_BYTES$3 = 5e6;
29012
+ var ClickyError = class extends Error {
29013
+ code;
29014
+ constructor(code, message) {
29015
+ super(message);
29016
+ this.code = code;
29017
+ }
29018
+ };
29019
+ function isClickySiteId(value) {
29020
+ return /^\d{1,30}$/u.test(value.trim());
29021
+ }
29022
+ function isClickySitekey(value) {
29023
+ return /^[A-Za-z0-9]{12,64}$/u.test(value.trim());
29024
+ }
29025
+ async function clickyStats(site, types, options = {}) {
29026
+ const url = new URL(ENDPOINT$1);
29027
+ url.searchParams.set("site_id", site.siteId);
29028
+ url.searchParams.set("sitekey", site.sitekey);
29029
+ url.searchParams.set("type", types.join(","));
29030
+ url.searchParams.set("output", "json");
29031
+ url.searchParams.set("limit", String(options.limit ?? 1e3));
29032
+ url.searchParams.set("app", "seo");
29033
+ if (options.date) url.searchParams.set("date", options.date);
29034
+ const response = await fetch(url).catch(() => {
29035
+ throw new ClickyError("remote", "Could not reach Clicky. Try again.");
29036
+ });
29037
+ if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
29038
+ const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$3 });
29039
+ if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
29040
+ const failed = groups.find((group) => typeof group?.error === "string");
29041
+ if (failed?.error) throw /sitekey|site id|authenticat|permission/iu.test(failed.error) ? new ClickyError("auth", `Clicky rejected site ID ${site.siteId} or its sitekey.`) : new ClickyError("remote", `Clicky: ${failed.error.slice(0, 200)}`);
29042
+ const out = {};
29043
+ for (const type of types) out[type] = [];
29044
+ for (const group of groups) {
29045
+ if (!group?.type || !(group.type in out)) continue;
29046
+ for (const date of group.dates ?? []) for (const item of date.items ?? []) if (item && typeof item === "object") out[group.type]?.push(item);
29047
+ }
29048
+ return out;
29049
+ }
29050
+ async function clickyDomain(site) {
29051
+ const { pages } = await clickyStats(site, ["pages"], {
29052
+ date: "last-30-days",
29053
+ limit: 1
29054
+ });
29055
+ const url = pages?.find((item) => item.url)?.url;
29056
+ if (!url) return null;
29057
+ try {
29058
+ return new URL(url).hostname.replace(/^www\./u, "");
29059
+ } catch {
29060
+ return null;
29061
+ }
29062
+ }
29063
+ //#endregion
28491
29064
  //#region packages/core/src/analyze/ai-queries.ts
28492
- const RULES = [
29065
+ const RULES$1 = [
28493
29066
  ["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"],
28494
29067
  ["tracker", "\\. ?my location is |\\?\\. ?my( |$)|^evaluate the .+ on "],
28495
29068
  ["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)[?!.,]*$"],
@@ -28498,10 +29071,255 @@ const RULES = [
28498
29071
  ["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"],
28499
29072
  ["long", "^(\\S+\\s+){9,}\\S+$"]
28500
29073
  ];
28501
- RULES.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
28502
- RULES.map(([, pattern]) => `(?:${pattern})`).join("|");
28503
- 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(" "));
28504
- new Map(`
29074
+ const AI_QUERY_KINDS = {
29075
+ agent_prompt: "text from an agent prompt, such as \"search the web for\"",
29076
+ tracker: "a visibility-tool probe, such as \"my location is\"",
29077
+ reply: "a bare reply, such as \"yes\" or \"tell me more\"",
29078
+ follow_up: "a follow-up, such as \"what about …\"",
29079
+ operator: "search operators, such as site:, before: or \"a\" or \"b\"",
29080
+ chat: "chat phrasing, such as \"can you\" or \"should i\"",
29081
+ long: "ten or more words with no other sign"
29082
+ };
29083
+ const MATCHERS = RULES$1.map(([kind, pattern]) => [kind, new RegExp(pattern)]);
29084
+ const AI_QUERY_PATTERN = RULES$1.map(([, pattern]) => `(?:${pattern})`).join("|");
29085
+ const NOISE = [
29086
+ ["spaced_letters", /(^|\s)(\S\s){3,}\S(\s|$)/u],
29087
+ ["broken_encoding", /^(яю|ÿþ|þÿ)|&#[0-9]+;?|&[a-z]+;|[âÃÂ][\u0080-\u00bf€™œ“”]|\ufffd/u],
29088
+ ["pasted_text", /^(p [a-z]{3,} |here (are|is) (all )?the |here's the |done[,.!]? (you|here) |current state: )/u]
29089
+ ];
29090
+ function aiQueryNoise(query) {
29091
+ const text = query.trim().toLowerCase();
29092
+ return NOISE.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
29093
+ }
29094
+ 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;
29095
+ const ASKABLE_KINDS = /* @__PURE__ */ new Set(["chat", "long"]);
29096
+ const PROMPT_CHARACTERS = 500;
29097
+ function askable(query, kind) {
29098
+ const text = query.trim().toLowerCase().replace(/^["'“]+/u, "");
29099
+ return Boolean(kind && ASKABLE_KINDS.has(kind)) && text.length <= PROMPT_CHARACTERS && QUESTION.test(text);
29100
+ }
29101
+ function aiQueryKind(query) {
29102
+ const text = query.trim().toLowerCase();
29103
+ return MATCHERS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
29104
+ }
29105
+ 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(" "));
29106
+ function words(query) {
29107
+ 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));
29108
+ }
29109
+ const SAME_QUESTION = .6;
29110
+ const MAX_ROWS = 25e3;
29111
+ const EXAMPLES = 3;
29112
+ const CANDIDATES = 200;
29113
+ const overlap = (a, b, weight) => {
29114
+ let shared = 0;
29115
+ let union = 0;
29116
+ for (const word of a) {
29117
+ union += weight(word);
29118
+ if (b.has(word)) shared += weight(word);
29119
+ }
29120
+ for (const word of b) if (!a.has(word)) union += weight(word);
29121
+ return union ? shared / union : 0;
29122
+ };
29123
+ const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
29124
+ async function aiQueriesReport(input, searchAnalytics) {
29125
+ const range = {
29126
+ startDate: input.startDate,
29127
+ endDate: input.endDate
29128
+ };
29129
+ const [matched, site] = await Promise.all([searchAnalytics(input.site, {
29130
+ ...range,
29131
+ dimensions: ["query", "page"],
29132
+ type: "web",
29133
+ dataState: "final",
29134
+ maxRows: MAX_ROWS,
29135
+ dimensionFilterGroups: [{ filters: [{
29136
+ dimension: "query",
29137
+ operator: "includingRegex",
29138
+ expression: AI_QUERY_PATTERN
29139
+ }] }]
29140
+ }), searchAnalytics(input.site, {
29141
+ ...range,
29142
+ dimensions: [],
29143
+ type: "web",
29144
+ dataState: "final"
29145
+ })]);
29146
+ 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])));
29147
+ const usage = /* @__PURE__ */ new Map();
29148
+ const unique = new Set(rows.map((row) => String(row.keys[0] ?? "")));
29149
+ for (const query of unique) for (const word of words(query)) usage.set(word, (usage.get(word) ?? 0) + 1);
29150
+ const weight = (word) => Math.log((unique.size + 1) / (usage.get(word) ?? 1));
29151
+ const groups = [];
29152
+ const byWord = /* @__PURE__ */ new Map();
29153
+ const byText = /* @__PURE__ */ new Map();
29154
+ const kindOf = /* @__PURE__ */ new Map();
29155
+ const noise = /* @__PURE__ */ new Map();
29156
+ for (const row of rows) {
29157
+ const query = String(row.keys[0] ?? "");
29158
+ const page = String(row.keys[1] ?? "");
29159
+ const kind = aiQueryKind(query);
29160
+ if (!kind) continue;
29161
+ const junk = aiQueryNoise(query);
29162
+ if (junk) {
29163
+ const seen = noise.get(junk) ?? /* @__PURE__ */ new Set();
29164
+ seen.add(query.trim().toLowerCase());
29165
+ noise.set(junk, seen);
29166
+ continue;
29167
+ }
29168
+ const text = query.trim().toLowerCase();
29169
+ const own = words(query);
29170
+ let group = byText.get(text);
29171
+ if (!group) {
29172
+ let checked = 0;
29173
+ for (const word of own) {
29174
+ for (const candidate of byWord.get(word) ?? []) {
29175
+ if (++checked > CANDIDATES) break;
29176
+ if (overlap(own, candidate.words, weight) >= SAME_QUESTION) {
29177
+ group = candidate;
29178
+ break;
29179
+ }
29180
+ }
29181
+ if (group || checked > CANDIDATES) break;
29182
+ }
29183
+ }
29184
+ if (!group) {
29185
+ group = {
29186
+ words: own,
29187
+ queries: /* @__PURE__ */ new Map(),
29188
+ pages: /* @__PURE__ */ new Map(),
29189
+ kinds: /* @__PURE__ */ new Map(),
29190
+ clicks: 0,
29191
+ impressions: 0,
29192
+ positionWeight: 0
29193
+ };
29194
+ groups.push(group);
29195
+ for (const word of own) {
29196
+ const list = byWord.get(word) ?? [];
29197
+ list.push(group);
29198
+ byWord.set(word, list);
29199
+ }
29200
+ }
29201
+ byText.set(text, group);
29202
+ if (!kindOf.has(text)) {
29203
+ kindOf.set(text, kind);
29204
+ group.kinds.set(kind, (group.kinds.get(kind) ?? 0) + 1);
29205
+ }
29206
+ group.queries.set(query, (group.queries.get(query) ?? 0) + row.impressions);
29207
+ group.pages.set(page, (group.pages.get(page) ?? 0) + row.impressions);
29208
+ group.clicks += row.clicks;
29209
+ group.impressions += row.impressions;
29210
+ group.positionWeight += row.position * row.impressions;
29211
+ }
29212
+ const byKind = /* @__PURE__ */ new Map();
29213
+ for (const kind of kindOf.values()) {
29214
+ const entry = byKind.get(kind) ?? {
29215
+ queries: 0,
29216
+ impressions: 0
29217
+ };
29218
+ entry.queries += 1;
29219
+ byKind.set(kind, entry);
29220
+ }
29221
+ for (const row of rows) {
29222
+ const kind = kindOf.get(String(row.keys[0] ?? "").trim().toLowerCase());
29223
+ const entry = kind && byKind.get(kind);
29224
+ if (entry) entry.impressions += row.impressions;
29225
+ }
29226
+ const queries = kindOf.size;
29227
+ const impressions = groups.reduce((sum, g) => sum + g.impressions, 0);
29228
+ const clicks = groups.reduce((sum, g) => sum + g.clicks, 0);
29229
+ const total = site.rows[0] ?? {
29230
+ clicks: 0,
29231
+ impressions: 0
29232
+ };
29233
+ const items = groups.map((group) => {
29234
+ const most = (map) => [...map].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
29235
+ const [first, ...rest] = most(group.queries);
29236
+ const kind = [...group.kinds].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
29237
+ return {
29238
+ query: first ?? "",
29239
+ page: most(group.pages)[0] ?? "",
29240
+ askable: askable(first ?? "", kind),
29241
+ pages: group.pages.size,
29242
+ kind,
29243
+ wordings: group.queries.size,
29244
+ impressions: group.impressions,
29245
+ clicks: group.clicks,
29246
+ ctr: group.impressions ? round(group.clicks / group.impressions, 4) : 0,
29247
+ position: group.impressions ? round(group.positionWeight / group.impressions, 1) : 0,
29248
+ otherWordings: rest.slice(0, EXAMPLES)
29249
+ };
29250
+ }).sort((a, b) => b.impressions - a.impressions || b.wordings - a.wordings || a.query.localeCompare(b.query) || a.page.localeCompare(b.page));
29251
+ const pct = (part, whole) => whole ? round(part / whole * 100, 1) : null;
29252
+ const kinds = [...byKind].sort((a, b) => b[1].impressions - a[1].impressions || a[0].localeCompare(b[0])).map(([kind, entry]) => `${kind} ${entry.queries}`).join(", ");
29253
+ const leftOut = [...noise].sort((a, b) => a[0].localeCompare(b[0])).map(([reason, seen]) => `${reason.replace("_", " ")} ${seen.size}`).join(", ");
29254
+ const asked = items.filter((item) => item.askable).length;
29255
+ const capped = matched.rowsFetched >= MAX_ROWS;
29256
+ return {
29257
+ range,
29258
+ 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.",
29259
+ summary: {
29260
+ queries,
29261
+ questions: groups.length,
29262
+ askable: asked,
29263
+ leftOut: leftOut || "none",
29264
+ impressions,
29265
+ clicks,
29266
+ shareOfImpressionsPct: pct(impressions, total.impressions),
29267
+ byKind: kinds || "none",
29268
+ ctr: impressions ? round(clicks / impressions, 4) : 0,
29269
+ siteCtr: total.impressions ? round(total.clicks / total.impressions, 4) : 0
29270
+ },
29271
+ kinds: AI_QUERY_KINDS,
29272
+ items,
29273
+ caveats: [
29274
+ "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.",
29275
+ "A minimum: Search Console leaves out rare queries, and these are rare by nature.",
29276
+ "Rewordings of one question are grouped by their shared words; page is the page with the most impressions for it.",
29277
+ "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.",
29278
+ "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.",
29279
+ "Search Console’s Generative AI report is not in its API, so it is not here.",
29280
+ ...capped ? [`Capped at the first ${MAX_ROWS} matching query and page rows.`] : []
29281
+ ]
29282
+ };
29283
+ }
29284
+ //#endregion
29285
+ //#region packages/core/src/commands/research-providers.ts
29286
+ function need(headers, provider) {
29287
+ const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
29288
+ 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\`.`);
29289
+ return values;
29290
+ }
29291
+ async function call(provider, request) {
29292
+ const response = await fetch(request);
29293
+ if (response.ok) return response;
29294
+ const text = (await response.text()).slice(0, 300);
29295
+ throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
29296
+ }
29297
+ function requireInput(input) {
29298
+ const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
29299
+ if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
29300
+ if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
29301
+ }
29302
+ async function research(provider, headers, input) {
29303
+ requireInput(input);
29304
+ const keys = need(headers, provider);
29305
+ if (provider === "dataforseo") return dataForSeo(keys, input);
29306
+ return {
29307
+ rows: await ahrefs(keys[0] ?? "", input),
29308
+ costUsd: null,
29309
+ partial: false
29310
+ };
29311
+ }
29312
+ function countryName(code) {
29313
+ return new Intl.DisplayNames("en", { type: "region" }).of(code.toUpperCase()) ?? "United States";
29314
+ }
29315
+ function mainLanguage(country) {
29316
+ try {
29317
+ return new Intl.Locale(`und-${country.toUpperCase()}`).maximize().language;
29318
+ } catch {
29319
+ return "en";
29320
+ }
29321
+ }
29322
+ const LABS_LANGUAGES = new Map(`
28505
29323
  ae:ar al:sq am:hy ao:pt ar:es at:de au:en az:az ba:bs bd:bn be:nl bf:fr
28506
29324
  bg:bg bh:ar bo:es br:pt ca:en ch:de ci:fr cl:es cm:fr co:es cr:es cy:el
28507
29325
  cz:cs de:de dk:da dz:ar ec:es ee:et eg:ar es:es fi:fi fr:fr gb:en gh:en
@@ -28511,16 +29329,588 @@ new Map(`
28511
29329
  pl:pl pt:pt py:es ro:ro rs:sr sa:ar se:sv sg:en si:sl sk:sk sn:fr sv:es
28512
29330
  th:th tn:ar tr:tr tw:zh-TW ua:uk us:en uy:es ve:es vn:vi za:en
28513
29331
  `.trim().split(/\s+/).map((pair) => pair.split(":")));
28514
- const DIMENSIONS = [
28515
- "query",
28516
- "page",
28517
- "date",
28518
- "country",
28519
- "device",
28520
- "searchAppearance"
28521
- ];
28522
- //#endregion
28523
- //#region packages/core/src/ga4/matching.ts
29332
+ function dataForSeoLanguage(country) {
29333
+ return LABS_LANGUAGES.get(country.toLowerCase()) ?? null;
29334
+ }
29335
+ function dataForSeoMarket(country, language) {
29336
+ return {
29337
+ location_name: countryName(country),
29338
+ language_code: language ?? mainLanguage(country)
29339
+ };
29340
+ }
29341
+ function dataForSeoKeys(headers) {
29342
+ return need(headers, "dataforseo");
29343
+ }
29344
+ async function dataForSeoList([login, password], path) {
29345
+ const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
29346
+ if (!task || task.status_code >= 4e4) throw new CommandError("PROVIDER_ERROR", `dataforseo: no list at ${path}`);
29347
+ return task.result ?? [];
29348
+ }
29349
+ async function dataForSeoTask([login, password], path, task) {
29350
+ const result = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
29351
+ method: "POST",
29352
+ headers: {
29353
+ authorization: `Basic ${btoa(`${login}:${password}`)}`,
29354
+ "content-type": "application/json"
29355
+ },
29356
+ body: JSON.stringify([task])
29357
+ })), "DataForSEO")).tasks?.[0];
29358
+ if (result?.status_code === NO_RESULTS) return {
29359
+ cost: result.cost ?? 0,
29360
+ result: null,
29361
+ partial: false
29362
+ };
29363
+ 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 });
29364
+ return {
29365
+ cost: result.cost ?? 0,
29366
+ result: result.result?.[0] ?? null,
29367
+ partial: result.status_code === PARTIAL
29368
+ };
29369
+ }
29370
+ const NO_RESULTS = 40102;
29371
+ const PARTIAL = 40106;
29372
+ async function dataForSeo(keys, input) {
29373
+ const spent = {
29374
+ cost: 0,
29375
+ partial: false
29376
+ };
29377
+ return {
29378
+ rows: await dataForSeoRows(keys, input, spent),
29379
+ costUsd: Math.round(spent.cost * 1e4) / 1e4,
29380
+ partial: spent.partial
29381
+ };
29382
+ }
29383
+ async function dataForSeoRows(keys, input, spent) {
29384
+ const language = dataForSeoLanguage(input.country);
29385
+ if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
29386
+ const market = dataForSeoMarket(input.country, language);
29387
+ const post = async (path, task) => {
29388
+ const done = await dataForSeoTask(keys, path, {
29389
+ ...market,
29390
+ ...task
29391
+ }).catch((error) => {
29392
+ spent.cost += error.cost ?? 0;
29393
+ throw error;
29394
+ });
29395
+ spent.cost += done.cost;
29396
+ spent.partial ||= done.partial;
29397
+ return done.result?.items ?? [];
29398
+ };
29399
+ const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
29400
+ const pick = (item, fields) => Object.fromEntries(Object.entries(fields).map(([name, path]) => [name, get(item, path) ?? null]));
29401
+ 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, {
29402
+ keyword: "keyword",
29403
+ search_volume: "keyword_info.search_volume",
29404
+ keyword_difficulty: "keyword_properties.keyword_difficulty",
29405
+ cpc: "keyword_info.cpc",
29406
+ competition: "keyword_info.competition",
29407
+ main_intent: "search_intent_info.main_intent"
29408
+ }));
29409
+ if (input.action === "ranked_keywords") return (await post("v3/dataforseo_labs/google/ranked_keywords/live", {
29410
+ target: bareTarget(input.target),
29411
+ limit: input.limit,
29412
+ order_by: ["ranked_serp_element.serp_item.etv,desc"]
29413
+ })).map((item) => pick(item, {
29414
+ keyword: "keyword_data.keyword",
29415
+ position: "ranked_serp_element.serp_item.rank_absolute",
29416
+ url: "ranked_serp_element.serp_item.url",
29417
+ search_volume: "keyword_data.keyword_info.search_volume",
29418
+ etv: "ranked_serp_element.serp_item.etv",
29419
+ keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
29420
+ main_intent: "keyword_data.search_intent_info.main_intent"
29421
+ }));
29422
+ if (input.action === "serp") {
29423
+ const rows = [];
29424
+ for (const keyword of input.keywords.slice(0, 5)) {
29425
+ const items = await post("v3/serp/google/organic/live/advanced", {
29426
+ keyword,
29427
+ depth: Math.min(input.limit, 100)
29428
+ });
29429
+ for (const item of items) {
29430
+ if (item.type !== "organic") continue;
29431
+ rows.push({
29432
+ keyword,
29433
+ ...pick(item, {
29434
+ position: "rank_absolute",
29435
+ url: "url",
29436
+ domain: "domain",
29437
+ title: "title"
29438
+ })
29439
+ });
29440
+ }
29441
+ }
29442
+ return rows;
29443
+ }
29444
+ if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
29445
+ target: bareTarget(input.target),
29446
+ limit: input.limit,
29447
+ order_by: ["rank,desc"]
29448
+ })).map((item) => pick(item, {
29449
+ domain: "domain",
29450
+ rank: "rank",
29451
+ backlinks: "backlinks",
29452
+ referring_pages: "referring_pages",
29453
+ first_seen: "first_seen"
29454
+ }));
29455
+ return (await post("v3/dataforseo_labs/google/competitors_domain/live", {
29456
+ target: bareTarget(input.target),
29457
+ limit: input.limit
29458
+ })).map((item) => pick(item, {
29459
+ domain: "domain",
29460
+ shared_keywords: "intersections",
29461
+ avg_position: "avg_position",
29462
+ organic_keywords: "full_domain_metrics.organic.count",
29463
+ organic_etv: "full_domain_metrics.organic.etv"
29464
+ }));
29465
+ }
29466
+ function isPage(target) {
29467
+ return /\/./.test(bareTarget(target));
29468
+ }
29469
+ function bareTarget(target) {
29470
+ return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
29471
+ }
29472
+ async function ahrefs(key, input) {
29473
+ const get = async (path, params) => {
29474
+ const url = new URL(`https://api.ahrefs.com/v3/${path}`);
29475
+ for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
29476
+ return readJson(await call("ahrefs", new Request(url, { headers: {
29477
+ authorization: `Bearer ${key}`,
29478
+ accept: "application/json"
29479
+ } })), "Ahrefs");
29480
+ };
29481
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
29482
+ if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
29483
+ select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
29484
+ country: input.country,
29485
+ keywords: input.keywords.slice(0, 100).join(",")
29486
+ })).keywords ?? []).map(flattenIntents);
29487
+ if (input.action === "ranked_keywords") return (await get("site-explorer/organic-keywords", {
29488
+ select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
29489
+ target: input.target,
29490
+ country: input.country,
29491
+ date: today,
29492
+ mode: isPage(input.target) ? "exact" : "subdomains",
29493
+ limit: String(input.limit),
29494
+ order_by: "sum_traffic:desc"
29495
+ })).keywords ?? [];
29496
+ if (input.action === "serp") {
29497
+ const rows = [];
29498
+ for (const keyword of input.keywords.slice(0, 5)) {
29499
+ const data = await get("serp-overview/serp-overview", {
29500
+ select: "position,type,url,title,domain_rating,traffic,keywords",
29501
+ keyword,
29502
+ country: input.country,
29503
+ top_positions: String(Math.min(input.limit, 100))
29504
+ });
29505
+ for (const row of data.positions ?? []) rows.push({
29506
+ keyword,
29507
+ ...row
29508
+ });
29509
+ }
29510
+ return rows;
29511
+ }
29512
+ if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
29513
+ select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
29514
+ target: input.target,
29515
+ mode: isPage(input.target) ? "exact" : "subdomains",
29516
+ history: "live",
29517
+ limit: String(input.limit),
29518
+ order_by: "domain_rating:desc"
29519
+ })).refdomains ?? [];
29520
+ return (await get("site-explorer/organic-competitors", {
29521
+ select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
29522
+ target: input.target,
29523
+ country: input.country,
29524
+ date: today,
29525
+ mode: "subdomains",
29526
+ limit: String(input.limit),
29527
+ order_by: "keywords_common:desc"
29528
+ })).competitors ?? [];
29529
+ }
29530
+ function flattenIntents(row) {
29531
+ const intents = row.intents;
29532
+ if (!intents || typeof intents !== "object") return row;
29533
+ const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
29534
+ return {
29535
+ ...row,
29536
+ intents: names.join(" ") || null
29537
+ };
29538
+ }
29539
+ function rowsText(input) {
29540
+ const total = input.rows.length;
29541
+ if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
29542
+ const size = input.detail === "summary" ? Math.min(input.limit, 25) : input.limit;
29543
+ const page = input.rows.slice(input.offset, input.offset + size);
29544
+ const columns = [...new Set(page.flatMap((row) => Object.keys(row)))];
29545
+ const end = input.offset + page.length;
29546
+ return [
29547
+ input.title,
29548
+ ...input.notes ?? [],
29549
+ total === 0 ? "No rows." : `Rows ${input.offset + 1} to ${end} of ${total}:\n${table$1(page, columns, input.origin ?? null)}`,
29550
+ end < total ? `More: ${input.more ? input.more(end) : `detail "rows", offset ${end}`}.` : ""
29551
+ ].filter(Boolean).join("\n\n");
29552
+ }
29553
+ const DIMENSIONS = [
29554
+ "query",
29555
+ "page",
29556
+ "date",
29557
+ "country",
29558
+ "device",
29559
+ "searchAppearance"
29560
+ ];
29561
+ const ROWS = 25e3;
29562
+ async function runRows(input, deps) {
29563
+ const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
29564
+ const filters = input.filters ?? [];
29565
+ const range = dateWindow(input, 28);
29566
+ const previous = earlierRange(range);
29567
+ const query = {
29568
+ dimensions,
29569
+ type: "web",
29570
+ maxRows: ROWS,
29571
+ ...filters.length ? { dimensionFilterGroups: [{
29572
+ groupType: "and",
29573
+ filters
29574
+ }] } : {}
29575
+ };
29576
+ const [current, before] = await Promise.all([deps.searchAnalytics(input.site, {
29577
+ ...query,
29578
+ ...range
29579
+ }), deps.searchAnalytics(input.site, {
29580
+ ...query,
29581
+ ...previous
29582
+ })]);
29583
+ const pageAt = dimensions.indexOf("page");
29584
+ const merge = (rows) => pageAt < 0 ? rows : collapseFragmentPageRows(rows, { pageKeyIndex: pageAt }).rows;
29585
+ const now = merge(current.rows);
29586
+ const then = merge(before.rows);
29587
+ const name = (keys) => keys.join("\0");
29588
+ const dateAt = dimensions.indexOf("date");
29589
+ const keyOf = (keys, start) => name(keys.map((key, index) => index === dateAt ? String(Math.round((Date.parse(key) - Date.parse(start)) / 864e5)) : key));
29590
+ const earlier = new Map(then.map((row) => [keyOf(row.keys, previous.startDate), row]));
29591
+ const seen = /* @__PURE__ */ new Set();
29592
+ const rows = now.map((row) => {
29593
+ const key = keyOf(row.keys, range.startDate);
29594
+ seen.add(key);
29595
+ const old = earlier.get(key);
29596
+ return {
29597
+ ...Object.fromEntries(dimensions.map((d, i) => [d, row.keys[i]])),
29598
+ clicks: row.clicks,
29599
+ impressions: row.impressions,
29600
+ ctr: row.ctr,
29601
+ position: row.position,
29602
+ ...dateAt >= 0 ? { previous_date: old?.keys[dateAt] ?? null } : {},
29603
+ previous_clicks: old?.clicks ?? 0,
29604
+ clicks_change: row.clicks - (old?.clicks ?? 0),
29605
+ previous_position: old?.position ?? null
29606
+ };
29607
+ });
29608
+ if (dateAt < 0) for (const old of then) {
29609
+ if (seen.has(keyOf(old.keys, previous.startDate))) continue;
29610
+ rows.push({
29611
+ ...Object.fromEntries(dimensions.map((d, i) => [d, old.keys[i]])),
29612
+ clicks: 0,
29613
+ impressions: 0,
29614
+ ctr: 0,
29615
+ position: null,
29616
+ previous_clicks: old.clicks,
29617
+ clicks_change: -old.clicks,
29618
+ previous_position: old.position
29619
+ });
29620
+ }
29621
+ rows.sort((a, b) => dateAt >= 0 ? String(a.date).localeCompare(String(b.date)) : Number(b.clicks ?? 0) - Number(a.clicks ?? 0));
29622
+ const notes = [];
29623
+ if (!dimensions.includes("query") && !dimensions.includes("page")) {
29624
+ const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
29625
+ notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
29626
+ }
29627
+ notes.push(`Compared with ${previous.startDate} to ${previous.endDate}${before.rowsFetched >= ROWS ? " (earlier period hit the row limit)" : ""}. 0 clicks with no position means the row had no impressions in the current period.`);
29628
+ if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
29629
+ if (current.rowsFetched >= ROWS) notes.push(`Search Console has more than ${ROWS} rows; add a filter to narrow it.`);
29630
+ notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
29631
+ const detail = input.detail ?? "summary";
29632
+ return {
29633
+ text: `${rowsText({
29634
+ title: `Search Console ${input.site}, ${range.startDate} to ${range.endDate}, web, by ${dimensions.join(" and ")}.`,
29635
+ rows,
29636
+ offset: input.offset ?? 0,
29637
+ limit: 50,
29638
+ detail,
29639
+ notes,
29640
+ origin: originOf(input.site),
29641
+ more: (offset) => deps.call("data", {
29642
+ site: input.site,
29643
+ dimensions: dimensions.join(","),
29644
+ detail: "rows",
29645
+ offset
29646
+ })
29647
+ })}\n\n${FACTS_ONLY}`,
29648
+ data: {
29649
+ range,
29650
+ comparisonRange: previous,
29651
+ total: rows.length,
29652
+ offset: input.offset ?? 0,
29653
+ rows: rows.slice(input.offset ?? 0, (input.offset ?? 0) + 50)
29654
+ }
29655
+ };
29656
+ }
29657
+ const QUESTION_DAYS = 90;
29658
+ const list = (value) => Array.isArray(value) ? value : [];
29659
+ const urls = (items) => items.map((item) => item.url).filter((url) => typeof url === "string");
29660
+ const overview = (result) => {
29661
+ const block = list(result.items).find((item) => item.type === "ai_overview");
29662
+ if (!block) return null;
29663
+ const parts = list(block.items);
29664
+ return {
29665
+ markdown: typeof block.markdown === "string" ? block.markdown : parts.map((part) => String(part.markdown ?? "")).join("\n"),
29666
+ sources: urls([...list(block.references), ...parts.flatMap((part) => list(part.references))]),
29667
+ model: null
29668
+ };
29669
+ };
29670
+ const ENGINES = [
29671
+ {
29672
+ name: "chatgpt",
29673
+ means: "a ChatGPT search, read by DataForSEO",
29674
+ path: "v3/ai_optimization/chat_gpt/llm_scraper/live/advanced",
29675
+ languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
29676
+ task: (question, market) => ({
29677
+ keyword: question,
29678
+ ...market
29679
+ }),
29680
+ shown: (result) => {
29681
+ const markdown = typeof result.markdown === "string" ? result.markdown : "";
29682
+ const sources = urls(list(result.sources));
29683
+ if (!markdown && !sources.length) return null;
29684
+ return {
29685
+ markdown,
29686
+ sources,
29687
+ model: typeof result.model === "string" ? result.model : null
29688
+ };
29689
+ }
29690
+ },
29691
+ {
29692
+ name: "google_ai_mode",
29693
+ means: "Google AI Mode",
29694
+ path: "v3/serp/google/ai_mode/live/advanced",
29695
+ languages: "v3/serp/google/ai_mode/languages",
29696
+ task: (question, market) => ({
29697
+ keyword: question,
29698
+ ...market
29699
+ }),
29700
+ shown: overview
29701
+ },
29702
+ {
29703
+ name: "google_ai_overview",
29704
+ means: "the AI Overview on a Google search",
29705
+ path: "v3/serp/google/organic/live/advanced",
29706
+ languages: "v3/serp/google/languages",
29707
+ task: (question, market) => ({
29708
+ keyword: question,
29709
+ depth: 10,
29710
+ load_async_ai_overview: true,
29711
+ ...market
29712
+ }),
29713
+ shown: overview
29714
+ }
29715
+ ];
29716
+ function prose(markdown) {
29717
+ return markdown.replace(/\[([^\]]*)\]\(https?:\/\/[^)]*\)/g, (_, label) => /^\s*(\d+|[\w-]+(\.[\w-]+)+)\s*$/.test(label) ? " " : label).replace(/https?:\/\/\S+/g, " ");
29718
+ }
29719
+ function onSite(url, domain) {
29720
+ try {
29721
+ const host = new URL(url).hostname.toLowerCase();
29722
+ return host === domain || host.endsWith(`.${domain}`);
29723
+ } catch {
29724
+ return false;
29725
+ }
29726
+ }
29727
+ async function ask(keys, engine, question, market, site) {
29728
+ if (typeof market === "string") return {
29729
+ result: "unavailable",
29730
+ model: null,
29731
+ cited_url: null,
29732
+ answered_at: null,
29733
+ cost_usd: 0,
29734
+ error: market
29735
+ };
29736
+ try {
29737
+ const done = await dataForSeoTask(keys, engine.path, engine.task(question, market));
29738
+ const answered = {
29739
+ answered_at: String(done.result?.datetime ?? "").slice(0, 10) || null,
29740
+ cost_usd: done.cost
29741
+ };
29742
+ const shown = done.result && engine.shown(done.result);
29743
+ if (!shown) return {
29744
+ result: "no_answer",
29745
+ model: null,
29746
+ cited_url: null,
29747
+ ...answered
29748
+ };
29749
+ const base = {
29750
+ model: shown.model,
29751
+ ...answered
29752
+ };
29753
+ const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
29754
+ const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
29755
+ if (cited) return {
29756
+ result: "cited",
29757
+ cited_url: cited,
29758
+ ...base
29759
+ };
29760
+ const text = prose(shown.markdown);
29761
+ return {
29762
+ result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
29763
+ cited_url: null,
29764
+ ...base
29765
+ };
29766
+ } catch (error) {
29767
+ if (error instanceof CommandError && error.code === "NO_KEY") throw error;
29768
+ return {
29769
+ result: "unavailable",
29770
+ model: null,
29771
+ cited_url: null,
29772
+ answered_at: null,
29773
+ cost_usd: error.cost ?? 0,
29774
+ error: error instanceof Error ? error.message.slice(0, 200) : "failed"
29775
+ };
29776
+ }
29777
+ }
29778
+ async function marketsFor(keys, country) {
29779
+ const code = country.toUpperCase();
29780
+ const language = dataForSeoLanguage(country) ?? dataForSeoMarket(country, null).language_code;
29781
+ const [locations, ...languages] = await Promise.all([dataForSeoList(keys, "v3/ai_optimization/chat_gpt/llm_scraper/locations"), ...ENGINES.map((engine) => dataForSeoList(keys, engine.languages))]);
29782
+ const place = locations.find((row) => row.location_type === "Country" && row.country_iso_code === code);
29783
+ const where = place ? { location_code: Number(place.location_code) } : { location_name: dataForSeoMarket(country, null).location_name };
29784
+ return new Map(ENGINES.map((engine, index) => {
29785
+ if (engine.name === "chatgpt" && !place) return [engine.name, `DataForSEO's ChatGPT search has no data for country "${country}".`];
29786
+ const known = (languages[index] ?? []).some((row) => row.language_code === language);
29787
+ return [engine.name, known ? {
29788
+ ...where,
29789
+ language_code: language
29790
+ } : `DataForSEO's ${engine.name} has no "${language}" language, the language used for country "${country}".`];
29791
+ }));
29792
+ }
29793
+ const usd = (value) => Math.round(value * 1e4) / 1e4;
29794
+ async function runAiAnswers(input, headers, deps, more) {
29795
+ const keys = dataForSeoKeys(headers);
29796
+ const domain = registrableDomain(input.site)?.domain;
29797
+ if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
29798
+ const range = dateWindow({}, QUESTION_DAYS);
29799
+ const questions = ((await aiQueriesReport({
29800
+ site: input.site,
29801
+ ...range
29802
+ }, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
29803
+ const site = {
29804
+ domain,
29805
+ terms: deriveBrandTerms({ siteUrl: input.site })
29806
+ };
29807
+ const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
29808
+ const answers = await Promise.all(questions.flatMap((question) => ENGINES.map(async (engine) => ({
29809
+ question: question.query,
29810
+ impressions: question.impressions,
29811
+ engine: engine.name,
29812
+ ...await ask(keys, engine, question.query, markets.get(engine.name) ?? "no market", site)
29813
+ }))));
29814
+ const cost = usd(answers.reduce((sum, row) => sum + row.cost_usd, 0));
29815
+ const count = (result) => answers.filter((row) => row.result === result).length;
29816
+ 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 ? [
29817
+ `Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}. DataForSEO cost: $${cost}.`,
29818
+ "Each row is one sample of an answer that changes from run to run, not a ranking.",
29819
+ `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.`,
29820
+ `Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
29821
+ ] : []];
29822
+ return {
29823
+ text: rowsText({
29824
+ title: `AI answers for ${input.site}, ${input.country}.`,
29825
+ rows: answers,
29826
+ offset: input.offset,
29827
+ limit: 50,
29828
+ detail: input.detail,
29829
+ notes,
29830
+ more
29831
+ }),
29832
+ data: {
29833
+ provider: "dataforseo",
29834
+ action: "ai_answers",
29835
+ site: input.site,
29836
+ country: input.country,
29837
+ questionsFrom: {
29838
+ section: "ai-queries",
29839
+ ...range
29840
+ },
29841
+ questions: questions.map((question) => question.query),
29842
+ costUsd: cost,
29843
+ total: answers.length,
29844
+ offset: input.offset,
29845
+ rows: answers.slice(input.offset, input.offset + 50)
29846
+ }
29847
+ };
29848
+ }
29849
+ //#endregion
29850
+ //#region packages/core/src/commands/analytics.ts
29851
+ async function analyticsSignIn(accounts, property, signIn) {
29852
+ const only = accounts.length === 1 ? accounts[0] : void 0;
29853
+ if (only) return only.google;
29854
+ const account = (await Promise.all(accounts.map(async (account) => {
29855
+ return (await listAnalyticsAccounts(account.google, { signIn }).catch(() => [])).some((summary) => summary.propertySummaries.some((item) => item.property === `properties/${property}`)) ? account : null;
29856
+ }))).find(Boolean);
29857
+ if (account) return account.google;
29858
+ throw new CommandError("NO_ACCESS", `No connected Google account can read Google Analytics property ${property}. Call seo_sites to list the properties.`);
29859
+ }
29860
+ async function runAnalyticsRows(input, google, options) {
29861
+ const range = dateWindow(input, 28);
29862
+ const offset = input.offset ?? 0;
29863
+ const limit = Math.min(1e3, offset + 50);
29864
+ const data = await runAnalyticsReport(google, input.property, {
29865
+ dateRanges: [range],
29866
+ dimensions: [{ name: "landingPage" }],
29867
+ metrics: [
29868
+ { name: "sessions" },
29869
+ { name: "totalUsers" },
29870
+ { name: "engagedSessions" },
29871
+ { name: "keyEvents" }
29872
+ ],
29873
+ dimensionFilter: { filter: {
29874
+ fieldName: "sessionDefaultChannelGroup",
29875
+ stringFilter: { value: "Organic Search" }
29876
+ } },
29877
+ orderBys: [{
29878
+ metric: { metricName: "sessions" },
29879
+ desc: true
29880
+ }],
29881
+ limit
29882
+ }, options);
29883
+ const notes = ["Sessions in the Organic Search default channel group."];
29884
+ if (data.metadata?.subjectToThresholding) notes.push("Google Analytics applied thresholding; small rows can be hidden.");
29885
+ if (data.metadata?.dataLossFromOtherRow) notes.push("Some rows were grouped into (other) by Google Analytics.");
29886
+ if ((data.rowCount ?? 0) > limit) notes.push(`${data.rowCount} landing pages in total; ${limit} read.`);
29887
+ const rows = (data.rows ?? []).map((row) => ({
29888
+ page: row.dimensionValues[0]?.value ?? "",
29889
+ sessions: Number(row.metricValues[0]?.value ?? 0),
29890
+ users: Number(row.metricValues[1]?.value ?? 0),
29891
+ engaged_sessions: Number(row.metricValues[2]?.value ?? 0),
29892
+ key_events: Number(row.metricValues[3]?.value ?? 0)
29893
+ }));
29894
+ return {
29895
+ text: rowsText({
29896
+ title: `Google Analytics property ${input.property}: search landing pages, ${range.startDate} to ${range.endDate}.`,
29897
+ rows,
29898
+ offset,
29899
+ limit: 50,
29900
+ detail: input.detail ?? "summary",
29901
+ notes,
29902
+ more: options.call
29903
+ }),
29904
+ data: {
29905
+ range,
29906
+ total: rows.length,
29907
+ offset,
29908
+ rows: rows.slice(offset, offset + 50)
29909
+ }
29910
+ };
29911
+ }
29912
+ //#endregion
29913
+ //#region packages/core/src/ga4/matching.ts
28524
29914
  function hostnameFromUrl(value) {
28525
29915
  try {
28526
29916
  const url = new URL(value);
@@ -28562,7 +29952,85 @@ function ga4MatchReason(match, site) {
28562
29952
  const location = match.match === "domain" ? "is inside" : "matches";
28563
29953
  return `Matched Google Analytics web stream ${stream} (${match.defaultUri}): its hostname ${location} ${site}.`;
28564
29954
  }
28565
- `${[
29955
+ //#endregion
29956
+ //#region packages/core/src/commands/analytics-site.ts
29957
+ const STREAM_CHECKS = 30;
29958
+ const AT_ONCE = 5;
29959
+ const TIED = 3;
29960
+ const hostOf$1 = (site) => {
29961
+ const value = site.replace(/^sc-domain:/, "");
29962
+ try {
29963
+ return new URL(value.includes("://") ? value : `https://${value}`).hostname;
29964
+ } catch {
29965
+ return value;
29966
+ }
29967
+ };
29968
+ const plain = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
29969
+ function ranked(site, candidates) {
29970
+ const host = hostOf$1(site).replace(/^www\./, "");
29971
+ const words = [plain(host), plain(host.split(".")[0] ?? "")].filter((word) => word.length >= 3);
29972
+ const named = (candidate) => words.some((word) => plain(candidate.propertyName).includes(word)) ? 0 : 1;
29973
+ return [...candidates].sort((a, b) => named(a) - named(b) || a.property.localeCompare(b.property, "en", { numeric: true }));
29974
+ }
29975
+ function best(site, matches) {
29976
+ const host = hostOf$1(site).replace(/^www\./, "");
29977
+ const score = (match) => match.hostname === host || match.hostname === `www.${host}` ? 0 : 1;
29978
+ return [...matches].sort((a, b) => score(a) - score(b) || a.hostname.length - b.hostname.length || a.property.localeCompare(b.property, "en", { numeric: true }));
29979
+ }
29980
+ async function findProperty(access, site) {
29981
+ const accounts = await access.accounts();
29982
+ const listed = await Promise.all(accounts.map((account) => listAnalyticsAccounts(account.google, { signIn: access.signIn }).then((summaries) => ({
29983
+ account,
29984
+ summaries
29985
+ }), (error) => error instanceof Error ? error : new Error(String(error)))));
29986
+ const seen = /* @__PURE__ */ new Set();
29987
+ const candidates = [];
29988
+ for (const entry of listed) {
29989
+ if (entry instanceof Error) continue;
29990
+ for (const summary of entry.summaries) for (const item of summary.propertySummaries) {
29991
+ const property = item.property.replace(/^properties\//, "");
29992
+ if (seen.has(property) || !/^\d+$/.test(property)) continue;
29993
+ seen.add(property);
29994
+ candidates.push({
29995
+ account: summary.account,
29996
+ property,
29997
+ propertyName: item.displayName ?? property,
29998
+ google: entry.account.google
29999
+ });
30000
+ }
30001
+ }
30002
+ if (!candidates.length) {
30003
+ const failed = listed.find((entry) => entry instanceof Error);
30004
+ return failed ? `could not be read. ${failed.message}` : "the connected Google accounts cannot read any property.";
30005
+ }
30006
+ const order = ranked(site, candidates).slice(0, STREAM_CHECKS);
30007
+ let failures = 0;
30008
+ for (let start = 0; start < order.length; start += AT_ONCE) {
30009
+ const batch = order.slice(start, start + AT_ONCE);
30010
+ const streams = await Promise.all(batch.map((candidate) => listAnalyticsDataStreams(candidate.google, candidate.property, { signIn: access.signIn }).catch(() => {
30011
+ failures += 1;
30012
+ return [];
30013
+ })));
30014
+ const matches = best(site, batch.flatMap((candidate, index) => matchGa4WebStreams(site, (streams[index] ?? []).map((stream) => ({
30015
+ ...candidate,
30016
+ stream
30017
+ })))));
30018
+ const found = /* @__PURE__ */ new Map();
30019
+ for (const match of matches) {
30020
+ const candidate = batch.find((item) => item.property === match.property);
30021
+ if (!candidate || found.has(match.property)) continue;
30022
+ found.set(match.property, {
30023
+ property: match.property,
30024
+ name: match.propertyName,
30025
+ stream: match.defaultUri,
30026
+ google: candidate.google
30027
+ });
30028
+ }
30029
+ if (found.size) return [...found.values()].slice(0, TIED);
30030
+ }
30031
+ return `no web stream matches ${site} in ${order.length < candidates.length ? `the first ${order.length} of ${candidates.length} properties, named like the site first` : `${candidates.length} ${candidates.length === 1 ? "property" : "properties"}`}${failures ? ` (${failures} could not be read)` : ""}.`;
30032
+ }
30033
+ const AI_SOURCES = [
28566
30034
  "chatgpt.com",
28567
30035
  "chatgpt",
28568
30036
  "chat.openai.com",
@@ -28579,83 +30047,1505 @@ function ga4MatchReason(match, site) {
28579
30047
  "you.com",
28580
30048
  "phind.com",
28581
30049
  "poe.com"
28582
- ].map((source) => source.replaceAll(".", "\\.")).join("|")}`;
28583
- //#endregion
28584
- //#region packages/core/src/commands/clicky.ts
28585
- const CLICKY_KEY_PREFIX = "x-clicky-";
28586
- const MAX_INSPECT_LIMIT = 2e3;
28587
- //#endregion
28588
- //#region packages/core/src/commands/page-facts.ts
28589
- const MAX_PAGES = 20;
28590
- const TIMEOUT_MS$2 = 6e3;
28591
- const MAX_BYTES$2 = 1e6;
28592
- const clean = (value) => value?.trim().replace(/\s+/g, " ") || null;
28593
- async function boundedText(response) {
28594
- if (!response.body) return "";
28595
- const reader = response.body.getReader();
28596
- const decoder = new TextDecoder();
28597
- let text = "";
28598
- let total = 0;
28599
- while (total < MAX_BYTES$2) {
28600
- const { done, value } = await reader.read();
28601
- if (done) return text + decoder.decode();
28602
- total += value.byteLength;
28603
- text += decoder.decode(value, { stream: true });
28604
- }
28605
- await reader.cancel();
28606
- return text;
28607
- }
28608
- function headFacts(html, url) {
28609
- let title = "";
28610
- let h1 = "";
28611
- let inTitle = false;
28612
- let inH1 = false;
28613
- let titleDone = false;
28614
- let h1Done = false;
28615
- let description = null;
28616
- let canonical = null;
28617
- const robots = [];
28618
- const parser = new Parser({
28619
- onopentag(name, attributes) {
28620
- if (name === "title" && !titleDone) inTitle = true;
28621
- if (name === "h1" && !h1Done) inH1 = true;
28622
- if (name === "meta") {
28623
- const metaName = attributes.name?.toLowerCase();
28624
- if (metaName === "description" && description === null) description = attributes.content ?? null;
28625
- if (metaName === "robots" && attributes.content) robots.push(attributes.content);
28626
- }
28627
- if (name === "link" && canonical === null && attributes.rel?.toLowerCase().split(/\s+/).includes("canonical") && attributes.href) try {
28628
- canonical = new URL(attributes.href, url).toString();
28629
- } catch {
28630
- canonical = attributes.href;
28631
- }
30050
+ ];
30051
+ const AI_SOURCE_PATTERN = `(www\\.)?(${AI_SOURCES.map((source) => source.replaceAll(".", "\\.")).join("|")})`;
30052
+ const AI_ROWS = 5;
30053
+ const filter = (fieldName, value, matchType = "EXACT") => ({ filter: {
30054
+ fieldName,
30055
+ stringFilter: {
30056
+ matchType,
30057
+ value
30058
+ }
30059
+ } });
30060
+ async function totals$1(found, access, range, before, pages) {
30061
+ const read = (dimensions, filters) => runAnalyticsReport(found.google, found.property, {
30062
+ dateRanges: [{
30063
+ ...range,
30064
+ name: "now"
30065
+ }, {
30066
+ ...before,
30067
+ name: "before"
30068
+ }],
30069
+ dimensions: dimensions.map((name) => ({ name })),
30070
+ metrics: [{ name: "sessions" }, { name: "keyEvents" }],
30071
+ ...filters.length ? { dimensionFilter: { andGroup: { expressions: filters } } } : {},
30072
+ limit: 100
30073
+ }, { signIn: access.signIn });
30074
+ const onPages = pages ? [filter("landingPage", pages, "CONTAINS")] : [];
30075
+ const fromAi = [...onPages, filter("sessionSource", AI_SOURCE_PATTERN, "FULL_REGEXP")];
30076
+ const [channels, google, aiSources, aiPages, engines] = await Promise.all([
30077
+ read(["sessionDefaultChannelGroup"], onPages),
30078
+ read([], [
30079
+ ...onPages,
30080
+ filter("sessionDefaultChannelGroup", "Organic Search"),
30081
+ filter("sessionSource", "google")
30082
+ ]),
30083
+ read(["sessionSource"], fromAi),
30084
+ read(["landingPage"], fromAi),
30085
+ read(["sessionSource"], [...onPages, filter("sessionDefaultChannelGroup", "Organic Search")])
30086
+ ]);
30087
+ const sum = () => ({
30088
+ google: {
30089
+ sessions: 0,
30090
+ keyEvents: 0
28632
30091
  },
28633
- ontext(text) {
28634
- if (inTitle) title += text;
28635
- if (inH1) h1 += text;
30092
+ organic: {
30093
+ sessions: 0,
30094
+ keyEvents: 0
28636
30095
  },
28637
- onclosetag(name) {
28638
- if (name === "title" && inTitle) {
28639
- inTitle = false;
28640
- titleDone = true;
28641
- }
28642
- if (name === "h1" && inH1) {
28643
- inH1 = false;
28644
- h1Done = true;
28645
- }
30096
+ all: {
30097
+ sessions: 0,
30098
+ keyEvents: 0
28646
30099
  }
28647
- }, {
28648
- decodeEntities: true,
28649
- lowerCaseTags: true
28650
30100
  });
28651
- parser.write(html);
28652
- parser.end();
28653
- return {
28654
- title: clean(title),
28655
- description: clean(description),
28656
- h1: clean(h1),
28657
- canonical,
28658
- robots: robots.length ? robots.join(", ") : null
30101
+ const periods = {
30102
+ now: sum(),
30103
+ before: sum()
30104
+ };
30105
+ const each = (data, add) => {
30106
+ const periodAt = (data.dimensionHeaders?.map((header) => header.name) ?? []).indexOf("dateRange");
30107
+ for (const row of data.rows ?? []) {
30108
+ const values = row.dimensionValues ?? [];
30109
+ add((periodAt >= 0 ? values[periodAt] : values.at(-1))?.value === "before" ? periods.before : periods.now, values[0]?.value ?? "", {
30110
+ sessions: Number(row.metricValues?.[0]?.value ?? 0),
30111
+ keyEvents: Number(row.metricValues?.[1]?.value ?? 0)
30112
+ });
30113
+ }
30114
+ };
30115
+ const plus = (into, row) => {
30116
+ into.sessions += row.sessions;
30117
+ into.keyEvents += row.keyEvents;
30118
+ };
30119
+ each(channels, (period, channel, row) => {
30120
+ plus(period.all, row);
30121
+ if (channel === "Organic Search") plus(period.organic, row);
30122
+ });
30123
+ each(google, (period, _, row) => plus(period.google, row));
30124
+ const byName = (data, key) => {
30125
+ const rows = /* @__PURE__ */ new Map();
30126
+ const periodAt = (data.dimensionHeaders?.map((header) => header.name) ?? []).indexOf("dateRange");
30127
+ for (const row of data.rows ?? []) {
30128
+ const values = row.dimensionValues ?? [];
30129
+ const name = values[0]?.value ?? "";
30130
+ const before = (periodAt >= 0 ? values[periodAt] : values.at(-1))?.value === "before";
30131
+ const entry = rows.get(name) ?? {
30132
+ now: 0,
30133
+ before: 0,
30134
+ keyEvents: 0
30135
+ };
30136
+ const sessions = Number(row.metricValues?.[0]?.value ?? 0);
30137
+ if (before) entry.before += sessions;
30138
+ else {
30139
+ entry.now += sessions;
30140
+ entry.keyEvents += Number(row.metricValues?.[1]?.value ?? 0);
30141
+ }
30142
+ rows.set(name, entry);
30143
+ }
30144
+ return [...rows].map(([name, entry]) => ({
30145
+ [key]: name,
30146
+ sessions: entry.now,
30147
+ previousSessions: entry.before,
30148
+ keyEvents: entry.keyEvents
30149
+ })).sort((a, b) => b.sessions - a.sessions || b.previousSessions - a.previousSessions || String(a[key]).localeCompare(String(b[key])));
30150
+ };
30151
+ const bySource = byName(aiSources, "source");
30152
+ const byEngine = byName(engines, "source");
30153
+ const aiNow = bySource.reduce((total, row) => total + row.sessions, 0);
30154
+ const aiBefore = bySource.reduce((total, row) => total + row.previousSessions, 0);
30155
+ const data = { metadata: {
30156
+ timeZone: channels.metadata?.timeZone,
30157
+ subjectToThresholding: [
30158
+ channels,
30159
+ google,
30160
+ aiSources,
30161
+ aiPages,
30162
+ engines
30163
+ ].some((data) => data.metadata?.subjectToThresholding),
30164
+ dataLossFromOtherRow: [
30165
+ channels,
30166
+ google,
30167
+ aiSources,
30168
+ aiPages,
30169
+ engines
30170
+ ].some((data) => data.metadata?.dataLossFromOtherRow)
30171
+ } };
30172
+ const notes = [];
30173
+ if (data.metadata?.timeZone && data.metadata.timeZone !== "America/Los_Angeles") notes.push(`Google Analytics days use the property's time zone, ${data.metadata.timeZone}; Search Console days use Pacific time.`);
30174
+ if (data.metadata?.subjectToThresholding) notes.push("Google Analytics applied thresholding; small rows can be hidden.");
30175
+ if (data.metadata?.dataLossFromOtherRow) notes.push("Google Analytics grouped some rows into (other).");
30176
+ return {
30177
+ property: found.property,
30178
+ propertyName: found.name,
30179
+ webStream: found.stream,
30180
+ ...pages ? { landingPageContains: pages } : {},
30181
+ organicSearchFromGoogle: {
30182
+ sessions: periods.now.google.sessions,
30183
+ previousSessions: periods.before.google.sessions,
30184
+ sessionsChangePct: pct(periods.now.google.sessions, periods.before.google.sessions)
30185
+ },
30186
+ organicSearch: {
30187
+ sessions: periods.now.organic.sessions,
30188
+ previousSessions: periods.before.organic.sessions,
30189
+ sessionsChangePct: pct(periods.now.organic.sessions, periods.before.organic.sessions),
30190
+ keyEvents: periods.now.organic.keyEvents,
30191
+ previousKeyEvents: periods.before.organic.keyEvents
30192
+ },
30193
+ allChannels: {
30194
+ sessions: periods.now.all.sessions,
30195
+ previousSessions: periods.before.all.sessions,
30196
+ sessionsChangePct: pct(periods.now.all.sessions, periods.before.all.sessions)
30197
+ },
30198
+ organicSearchByEngine: byEngine.slice(0, 10),
30199
+ aiAssistants: {
30200
+ sessions: aiNow,
30201
+ previousSessions: aiBefore,
30202
+ sessionsChangePct: pct(aiNow, aiBefore),
30203
+ bySource,
30204
+ topLandingPages: byName(aiPages, "page").slice(0, 20),
30205
+ sources: AI_SOURCES
30206
+ },
30207
+ notes
30208
+ };
30209
+ }
30210
+ const change$1 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
30211
+ function engineText(rows) {
30212
+ const shown = rows.filter((row) => row.sessions || row.previousSessions);
30213
+ if (shown.length < 2) return "";
30214
+ return `Organic Search sessions by search engine: ${shown.slice(0, AI_ROWS).map((row) => `${row.source} ${row.sessions} (was ${row.previousSessions})`).join(", ")}.`;
30215
+ }
30216
+ function aiText(ai) {
30217
+ const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
30218
+ if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
30219
+ return [
30220
+ `Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$1(ai.sessionsChangePct)}). By source: ${list(ai.bySource, "source")}.`,
30221
+ ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
30222
+ "Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
30223
+ ].filter(Boolean).join(" ");
30224
+ }
30225
+ async function analyticsBeside(access, site, range, before, pages) {
30226
+ if (!access) return null;
30227
+ try {
30228
+ const found = await findProperty(access, site);
30229
+ if (typeof found === "string") return {
30230
+ text: `Google Analytics: ${found}`,
30231
+ data: { error: found }
30232
+ };
30233
+ const read = await Promise.all(found.map((item) => totals$1(item, access, range, before, pages).catch((error) => error instanceof Error ? error : new Error(String(error)))));
30234
+ const data = [...read.filter((item) => !(item instanceof Error))].sort((a, b) => b.allChannels.sessions - a.allChannels.sessions)[0];
30235
+ if (!data) throw read[0];
30236
+ const others = found.filter((item) => item.property !== data.property).map((item) => `${item.name} (${item.property})`);
30237
+ const google = data.organicSearchFromGoogle;
30238
+ const organic = data.organicSearch;
30239
+ const all = data.allChannels;
30240
+ const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
30241
+ return {
30242
+ text: [
30243
+ `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)}).`,
30244
+ engineText(data.organicSearchByEngine),
30245
+ "Sessions are Google Analytics’ own count, not Search Console clicks.",
30246
+ ...data.notes,
30247
+ ...others.length ? [`Other properties with a matching web stream, with fewer sessions: ${others.join(", ")}.`] : []
30248
+ ].filter(Boolean).join(" ").concat("\n\n", aiText(data.aiAssistants)),
30249
+ data: {
30250
+ ...data,
30251
+ ...others.length ? { otherMatches: others } : {}
30252
+ }
30253
+ };
30254
+ } catch (error) {
30255
+ const message = error instanceof Error ? error.message : String(error);
30256
+ return {
30257
+ text: `Google Analytics: could not be read. ${message}`,
30258
+ data: { error: message }
30259
+ };
30260
+ }
30261
+ }
30262
+ //#endregion
30263
+ //#region packages/core/src/commands/audit.ts
30264
+ const AUDIT_SECTION = "weekly-audit";
30265
+ const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing or duplicate titles, descriptions and H1s, noindex, canonicals to another page, sitemap URLs that do not return 200, thin pages), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
30266
+ const RULES = {
30267
+ link_to_error: "Internal links to a page that returns an error",
30268
+ link_to_redirect: "Internal links to a redirect",
30269
+ link_failed: "Internal links to a page that could not be fetched",
30270
+ title_missing: "Pages with no title",
30271
+ title_duplicate: "Titles used by more than one page",
30272
+ description_missing: "Pages with no meta description",
30273
+ h1_missing: "Pages with no H1",
30274
+ noindex: "Pages that ask not to be indexed",
30275
+ canonical_elsewhere: "Pages whose canonical is another URL",
30276
+ sitemap_url_not_ok: "Sitemap URLs that do not return 200",
30277
+ sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
30278
+ thin_page: "Pages under 100 words (heuristic)"
30279
+ };
30280
+ const PAGE_SIZE$1 = 50;
30281
+ const SUMMARY_ROWS = 20;
30282
+ const day$1 = (ms) => new Date(ms).toISOString().slice(0, 10);
30283
+ function relative$2(value, origin) {
30284
+ return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
30285
+ }
30286
+ function evidence(finding, origin) {
30287
+ const details = JSON.parse(finding.details);
30288
+ const parts = [];
30289
+ if (typeof details.status === "number") parts.push(`${details.status}`);
30290
+ if (typeof details.location === "string") parts.push(`to ${relative$2(details.location, origin)}`);
30291
+ if (typeof details.canonical === "string") parts.push(`canonical ${relative$2(details.canonical, origin)}`);
30292
+ if (typeof details.words === "number") parts.push(`${details.words} words`);
30293
+ const linking = typeof details.linking_pages === "number";
30294
+ const count = linking ? details.linking_pages : details.pages;
30295
+ if (typeof count === "number") {
30296
+ const shown = (Array.isArray(details.examples) ? details.examples.map((url) => relative$2(url, origin)) : []).slice(0, 3);
30297
+ const noun = `${linking ? "linking page" : "page"}${count === 1 ? "" : "s"}`;
30298
+ parts.push(`${count} ${noun}${shown.length ? ` (${shown.join(", ")}${count > shown.length ? ", and more" : ""})` : ""}`);
30299
+ }
30300
+ return parts.join(", ");
30301
+ }
30302
+ function state(finding, since) {
30303
+ if (finding.state === "resolved") return "resolved";
30304
+ if (finding.reopened_at && finding.reopened_at >= since) return "back";
30305
+ if (finding.first_seen_at >= since) return "new";
30306
+ return `open since ${day$1(finding.first_seen_at)}`;
30307
+ }
30308
+ function table(rows, origin, since) {
30309
+ return ["state | finding | subject | evidence", ...rows.map((finding) => [
30310
+ state(finding, since),
30311
+ finding.rule,
30312
+ relative$2(finding.subject, origin),
30313
+ evidence(finding, origin)
30314
+ ].join(" | "))].join("\n");
30315
+ }
30316
+ function ordered(findings, since) {
30317
+ const rank = (finding) => {
30318
+ const label = state(finding, since);
30319
+ return label === "new" || label === "back" ? 0 : label === "resolved" ? 1 : 2;
30320
+ };
30321
+ return [...findings].sort((a, b) => rank(a) - rank(b) || a.rule.localeCompare(b.rule) || (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
30322
+ }
30323
+ function runAuditReport(input, snapshot, call) {
30324
+ const site = input.site;
30325
+ const detail = input.detail ?? "summary";
30326
+ if (!snapshot) return {
30327
+ text: `${site} is not in the weekly site audit. Add it under Beta on the account page; the first crawl starts at once.`,
30328
+ data: {
30329
+ site,
30330
+ audited: false
30331
+ }
30332
+ };
30333
+ const { summary, origin } = snapshot;
30334
+ const running = snapshot.run?.status === "running" ? `A crawl is running now: ${snapshot.run.fetched} pages so far.` : null;
30335
+ if (!summary || snapshot.finished_at === null) return {
30336
+ text: [
30337
+ `The first weekly crawl of ${site} has not finished.`,
30338
+ running,
30339
+ snapshot.run?.error ? `Last error: ${snapshot.run.error}` : null,
30340
+ `Run ${call("report", {
30341
+ site,
30342
+ section: AUDIT_SECTION
30343
+ })} again later.`
30344
+ ].filter(Boolean).join(" "),
30345
+ data: {
30346
+ site,
30347
+ audited: true,
30348
+ finished: false
30349
+ }
30350
+ };
30351
+ const since = snapshot.history[0]?.started_at ?? snapshot.finished_at;
30352
+ const previous = snapshot.history[1];
30353
+ const findings = ordered(snapshot.findings, since);
30354
+ if (detail === "full") return {
30355
+ text: JSON.stringify({
30356
+ site,
30357
+ ...snapshot,
30358
+ findings
30359
+ }),
30360
+ data: {
30361
+ site,
30362
+ ...snapshot,
30363
+ findings
30364
+ }
30365
+ };
30366
+ const pages = summary.pages;
30367
+ const coverage = summary.complete ? `${pages.fetched} pages, the whole site the crawl could reach` : summary.status === "failed" ? `${pages.fetched} pages before the crawl stopped on errors, so it is partial` : `${pages.fetched} pages; it stopped at the 5,000-page limit${pages.links_past_cap ? ` with ${pages.links_past_cap} more links past it` : ""}, so it is partial`;
30368
+ const head = [
30369
+ `Weekly site audit of ${site}, crawled ${day$1(snapshot.finished_at)}: ${coverage}.${pages.blocked ? ` ${pages.blocked === 1 ? "1 URL" : `${pages.blocked} URLs`} blocked by robots.txt ${pages.blocked === 1 ? "was" : "were"} not fetched.` : ""}`,
30370
+ previous ? `Since the crawl of ${day$1(previous.finished_at)}: ${summary.findings.new} new, ${summary.findings.reopened} back, ${summary.findings.resolved} resolved. ${summary.findings.open} open now.` : `This is the first crawl, so every finding is new: ${summary.findings.open} open.`,
30371
+ summary.findings.resolution === "crawled-pages-only" ? "Because the crawl is partial, a finding counts as resolved only when this crawl fetched its page again; a finding not seen here is not an all-clear." : null,
30372
+ summary.findings.capped_rules.length ? `Capped at 1,000 findings each, so these counts are lower bounds: ${summary.findings.capped_rules.join(", ")}.` : null,
30373
+ running
30374
+ ].filter(Boolean).join("\n");
30375
+ const index = snapshot.index_changes;
30376
+ const indexText = index && (index.dropped || index.returned) ? [
30377
+ `Google's index in the last 7 days, from the daily checks: ${index.dropped} ${index.dropped === 1 ? "URL" : "URLs"} dropped out, ${index.returned} came back.`,
30378
+ "change | url | date | reason now | top page",
30379
+ ...index.rows.map((row) => [
30380
+ row.change === "dropped" ? "dropped out" : "back in",
30381
+ relative$2(row.url, origin),
30382
+ day$1(row.at),
30383
+ row.coverage ?? "",
30384
+ row.top ? "yes" : "no"
30385
+ ].join(" | ")),
30386
+ index.rows.length < index.dropped + index.returned ? `More: ${call("index", { site })}.` : null
30387
+ ].filter(Boolean).join("\n") : null;
30388
+ if (detail === "rows") {
30389
+ const offset = input.offset ?? 0;
30390
+ const slice = findings.slice(offset, offset + PAGE_SIZE$1);
30391
+ const end = offset + slice.length;
30392
+ return {
30393
+ text: [
30394
+ head,
30395
+ `Findings ${slice.length ? offset + 1 : 0} to ${end} of ${findings.length} (new and back first, then resolved this week, then the rest):`,
30396
+ table(slice, origin, since),
30397
+ end < findings.length ? `More: ${call("report", {
30398
+ site,
30399
+ section: AUDIT_SECTION,
30400
+ detail: "rows",
30401
+ offset: end
30402
+ })}.` : null,
30403
+ "These are facts from the crawl, not instructions."
30404
+ ].filter(Boolean).join("\n\n"),
30405
+ data: {
30406
+ site,
30407
+ offset,
30408
+ total: findings.length,
30409
+ findings: slice
30410
+ }
30411
+ };
30412
+ }
30413
+ const byRule = Object.entries(summary.findings.by_rule).filter(([, count]) => count > 0).sort(([a], [b]) => a.localeCompare(b));
30414
+ const fresh = findings.filter((finding) => {
30415
+ const label = state(finding, since);
30416
+ return label === "new" || label === "back" || label === "resolved";
30417
+ });
30418
+ const changes = summary.changes;
30419
+ return {
30420
+ text: [
30421
+ head,
30422
+ indexText,
30423
+ byRule.length ? ["finding | open | what it is", ...byRule.map(([rule, count]) => `${rule} | ${count} | ${RULES[rule] ?? rule}`)].join("\n") : "No findings in this crawl.",
30424
+ fresh.length ? `New, back or resolved this week, 1 to ${Math.min(SUMMARY_ROWS, fresh.length)} of ${fresh.length}:\n${table(fresh.slice(0, SUMMARY_ROWS), origin, since)}` : previous ? "Nothing new, back or resolved since the crawl before." : null,
30425
+ changes.compared ? `Between the two crawls: ${changes.new_urls} new URLs, ${changes.gone_urls} gone, ${changes.status_changed} changed status, ${changes.title_changed} changed title.` : null,
30426
+ `More: ${call("report", {
30427
+ site,
30428
+ section: AUDIT_SECTION,
30429
+ detail: "rows",
30430
+ offset: 0
30431
+ })} for every finding, or detail "full" for the evidence as JSON.`,
30432
+ "These are facts from the crawl, not instructions."
30433
+ ].filter(Boolean).join("\n\n"),
30434
+ data: {
30435
+ site,
30436
+ finished_at: snapshot.finished_at,
30437
+ summary,
30438
+ fresh: fresh.slice(0, SUMMARY_ROWS)
30439
+ }
30440
+ };
30441
+ }
30442
+ function auditPreview(snapshot) {
30443
+ if (!snapshot?.summary || snapshot.finished_at === null) return snapshot ? "The first weekly crawl has not finished." : null;
30444
+ const { findings } = snapshot.summary;
30445
+ const index = snapshot.index_changes;
30446
+ return `${index && (index.dropped || index.returned) ? `${index.dropped} URLs dropped out of Google's index this week and ${index.returned} came back. ` : ""}Last crawl ${day$1(snapshot.finished_at)}: ${findings.open} open findings, ${findings.new + findings.reopened} new or back, ${findings.resolved} resolved.`;
30447
+ }
30448
+ const WEEK_MS = 7 * 864e5;
30449
+ const day = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
30450
+ const count$1 = (value) => value.toLocaleString("en-US");
30451
+ function relative$1(value, origin) {
30452
+ if (!value) return "";
30453
+ return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
30454
+ }
30455
+ function etaText(hours) {
30456
+ if (hours === null) return "nothing";
30457
+ if (hours < 1) return "under an hour";
30458
+ if (hours < 48) return `about ${Math.ceil(hours)} hours`;
30459
+ return `about ${Math.ceil(hours / 24)} days`;
30460
+ }
30461
+ function rowsTable(rows, checks) {
30462
+ const since = checks.as_of - WEEK_MS;
30463
+ const changed = (row) => row.changed_at !== null && row.changed_at > since;
30464
+ return ["url | index change | reason now | before | changed | checked | clicks (28 days before) | in_sitemap | google_canonical | error", ...rows.map((row) => [
30465
+ relative$1(row.url, checks.origin),
30466
+ row.transition && row.transition_at !== null && row.transition_at > since ? `${row.transition === "dropped" ? "dropped out" : "back in"} ${day(row.transition_at)}` : "",
30467
+ row.coverage ?? (row.checked_at === null ? "not checked yet" : ""),
30468
+ changed(row) ? row.prev_coverage ?? "" : "",
30469
+ changed(row) ? day(row.changed_at) : "",
30470
+ day(row.checked_at),
30471
+ `${count$1(row.clicks)} (${count$1(row.previous_clicks)})${row.lost ? " lost" : ""}`,
30472
+ row.in_sitemap ? "yes" : "no",
30473
+ row.google_canonical && row.google_canonical !== row.url ? relative$1(row.google_canonical, checks.origin) : "",
30474
+ row.error ?? ""
30475
+ ].join(" | "))].join("\n");
30476
+ }
30477
+ function head(site, checks) {
30478
+ const { queued } = checks;
30479
+ const quota = checks.properties.map((item) => `${item.property} (${count$1(item.urls)} URLs: ${count$1(item.top)} top pages checked daily, the rest every ${item.cycle_days === 1 ? "day" : `${item.cycle_days} days`}; ${count$1(item.used_today)} of ${count$1(checks.per_day)} checks used today)`).join(", ");
30480
+ return [
30481
+ `Google indexing of ${site}, from the URL Inspection results this server keeps for it (as of ${new Date(checks.as_of).toISOString().slice(0, 16).replace("T", " ")} UTC).`,
30482
+ checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$1(checks.dropped_this_week)} ${checks.dropped_this_week === 1 ? "URL" : "URLs"} dropped out of Google's index and ${count$1(checks.returned_this_week)} came back; they are listed first. A top page's drop counts only after a second check an hour later.` : null,
30483
+ `Covered: ${count$1(checks.covered)} of ${count$1(checks.known)} known URLs have a current result. Known URLs are the sitemap's, the crawl's and the Search Console pages with clicks or impressions. Each property spends its full ${count$1(checks.per_day)} checks a day: its top pages (lost clicks, then clicks, then impressions, up to ${count$1(checks.top_per_day)}) daily, the rest as often as the quota allows, and a page the weekly crawl saw change at once.`,
30484
+ queued.total ? `Queued: ${count$1(queued.total)} (${count$1(queued.lost)} pages that lost their clicks, ${count$1(queued.search)} with clicks or impressions, ${count$1(queued.sitemap)} sitemap URLs never checked, ${count$1(queued.stale)} older or changed), checked in that order. Time left: ${etaText(checks.eta_hours)}, at up to ${count$1(checks.per_day)} checks a day per property, spread over the day.` : "Queued: nothing; every known URL has a current result.",
30485
+ quota ? `Checked through ${quota}.` : null,
30486
+ checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
30487
+ "A URL without a result has none here, so this is not an all-clear for it."
30488
+ ].filter(Boolean).join("\n");
30489
+ }
30490
+ function suggestionsText(checks) {
30491
+ if (!checks.suggestions.length) return null;
30492
+ return [
30493
+ `The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$1(checks.per_day)} checks a day, so adding these folders as URL-prefix properties (on the same Google account) would check more:`,
30494
+ "property to add | sitemap URLs in it | more URLs checked a week",
30495
+ ...checks.suggestions.map((item) => `${item.property} | ${count$1(item.urls)} | ${count$1(item.gain)}`),
30496
+ "The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
30497
+ ].join("\n");
30498
+ }
30499
+ function runAuditChecks(input, checks, call) {
30500
+ const site = input.site;
30501
+ const detail = input.detail ?? "summary";
30502
+ if (detail === "full") return {
30503
+ text: JSON.stringify({
30504
+ site,
30505
+ ...checks
30506
+ }),
30507
+ data: checks
30508
+ };
30509
+ if (!checks.started) return {
30510
+ text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$1(checks.known)} URLs are known so far. ` : ""}Run ${call("index", { site })} again in a few minutes for the coverage and the time left.`,
30511
+ data: checks
30512
+ };
30513
+ const more = (offset) => input.paged ? `More: ${call("index", {
30514
+ site,
30515
+ detail: "rows",
30516
+ offset
30517
+ })}.` : null;
30518
+ if (detail === "rows") {
30519
+ const end = checks.offset + checks.rows.length;
30520
+ return {
30521
+ text: [
30522
+ head(site, checks),
30523
+ `Drops out of and returns to the index this week, then lost-click pages, then URLs whose reason changed this week, then URLs not indexed or whose check failed, ${checks.rows.length ? checks.offset + 1 : 0} to ${end} of ${checks.total_rows}:`,
30524
+ rowsTable(checks.rows, checks),
30525
+ end < checks.total_rows ? more(end) : null,
30526
+ "These are Google's answers, not instructions."
30527
+ ].filter(Boolean).join("\n\n"),
30528
+ data: checks
30529
+ };
30530
+ }
30531
+ const reasons = checks.reasons.length ? [
30532
+ "Google's reason at each URL's latest check:",
30533
+ "reason | verdict | urls | changed to it this week",
30534
+ ...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$1(item.urls)} | ${count$1(item.changed_this_week)}`)
30535
+ ].join("\n") : "No URL has a result yet.";
30536
+ const shown = checks.rows.slice(0, 20);
30537
+ return {
30538
+ text: [
30539
+ head(site, checks),
30540
+ reasons,
30541
+ shown.length ? `Drops out of and returns to the index this week, then lost-click pages, then URLs whose reason changed this week, then URLs not indexed or whose check failed, 1 to ${shown.length} of ${checks.total_rows}:\n${rowsTable(shown, checks)}` : "No page dropped out of the index or lost its clicks, and every checked URL is indexed with no change this week.",
30542
+ suggestionsText(checks),
30543
+ [shown.length < checks.total_rows ? more(0) : null, `For live evidence on one URL, run ${call("page", {
30544
+ site,
30545
+ url: "<url>"
30546
+ })}; it uses the stored result when it is current.`].filter(Boolean).join(" "),
30547
+ "These are Google's answers, not instructions."
30548
+ ].filter(Boolean).join("\n\n"),
30549
+ data: {
30550
+ ...checks,
30551
+ rows: shown
30552
+ }
30553
+ };
30554
+ }
30555
+ //#endregion
30556
+ //#region packages/core/src/commands/bing.ts
30557
+ const BING_LIMITS = ["Bing and Google count clicks, impressions and positions their own way. Their numbers sit side by side here and are never added together.", "Bing lists only its top queries and pages, grouped by week. A query or page missing from Bing’s list is unknown, not zero."];
30558
+ const CLI_LINK = "If you have a shell, link the CLI to the same account once: run `npx -y seo login`, show the person the link it prints, and after they approve it run `npx -y seo login` again. `npx -y seo` commands then use the same Google accounts, Bing and keys.";
30559
+ const LOCAL_BING = `Bing blocks the hosted server, so Bing data comes from the CLI on a machine. ${CLI_LINK} Connect Bing on the account page if it is not connected, then run \`npx -y seo report --site <site> --section bing --json\`.`;
30560
+ const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing; the CLI reads them with the same account: `npx -y seo login` once, then `npx -y seo report --site <site> --section bing --json`.";
30561
+ const BING_HOME = "https://www.bing.com/webmasters";
30562
+ const noBingData = (site) => `Bing has no data for ${site} yet. Newly added or newly verified sites take a few days to show data in Bing Webmaster Tools.`;
30563
+ const sitesSeen = /* @__PURE__ */ new WeakMap();
30564
+ async function bingFor(deps, property) {
30565
+ if (!deps.bing) return {
30566
+ call: null,
30567
+ site: null,
30568
+ missing: LOCAL_BING
30569
+ };
30570
+ const call = await deps.bing();
30571
+ if (!call) return {
30572
+ call: null,
30573
+ site: null,
30574
+ missing: `Bing is not connected. ${deps.bingConnect ?? ""}`.trim()
30575
+ };
30576
+ let sites = sitesSeen.get(call);
30577
+ if (!sites) {
30578
+ sites = listBingSites(call);
30579
+ sitesSeen.set(call, sites);
30580
+ }
30581
+ const site = bingSiteFor(await sites, property);
30582
+ if (site?.verified) return {
30583
+ call,
30584
+ site: site.url
30585
+ };
30586
+ return {
30587
+ call: null,
30588
+ site: null,
30589
+ missing: site ? `${site.url} is in the connected Bing account but is not verified, so Bing gives no data for it. Verify it at ${BING_HOME}.` : `The connected Bing account does not have ${property.replace(/^sc-domain:/, "")}. Add and verify it at ${BING_HOME} (it can import sites from Search Console). New sites take a few days to show data.`
30590
+ };
30591
+ }
30592
+ function days(range) {
30593
+ return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
30594
+ }
30595
+ async function bingTotals(source, range, before) {
30596
+ if (!source.call) return { missing: source.missing };
30597
+ const traffic = await bingTraffic(source.call, source.site);
30598
+ if (!traffic.rows.length && !traffic.invalidRows) return { missing: noBingData(source.site) };
30599
+ const sum = (window) => {
30600
+ const rows = traffic.rows.filter((row) => row.date >= window.startDate && row.date <= window.endDate);
30601
+ return {
30602
+ clicks: rows.reduce((total, row) => total + row.clicks, 0),
30603
+ impressions: rows.reduce((total, row) => total + row.impressions, 0),
30604
+ days: new Set(rows.map((row) => row.date)).size,
30605
+ expectedDays: days(window)
30606
+ };
30607
+ };
30608
+ return {
30609
+ site: source.site,
30610
+ current: sum(range),
30611
+ previous: sum(before),
30612
+ invalidRows: traffic.invalidRows
30613
+ };
30614
+ }
30615
+ const change = (now, before) => {
30616
+ const value = pct(now, before);
30617
+ return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
30618
+ };
30619
+ function enginesText(google, bing) {
30620
+ if ("missing" in bing) return `Bing: ${bing.missing}`;
30621
+ const { current: now, previous: then } = bing;
30622
+ const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
30623
+ const both = google.clicks + now.clicks;
30624
+ if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
30625
+ if (now.days < now.expectedDays || then.days < then.expectedDays) lines.push(`Bing has data for ${now.days} of ${now.expectedDays} days (earlier period ${then.days} of ${then.expectedDays}), so its totals are partial.`);
30626
+ return lines.join(" ");
30627
+ }
30628
+ function sumWeeks(rows, window) {
30629
+ const inside = rows.filter((row) => row.date >= window.startDate && row.date <= window.endDate);
30630
+ const byKey = /* @__PURE__ */ new Map();
30631
+ for (const row of inside) {
30632
+ const found = byKey.get(row.key) ?? {
30633
+ key: row.key,
30634
+ clicks: 0,
30635
+ impressions: 0,
30636
+ position: null,
30637
+ weighted: 0,
30638
+ seen: 0
30639
+ };
30640
+ found.clicks += row.clicks;
30641
+ found.impressions += row.impressions;
30642
+ if (row.position !== null && row.impressions > 0) {
30643
+ found.weighted += row.position * row.impressions;
30644
+ found.seen += row.impressions;
30645
+ }
30646
+ byKey.set(row.key, found);
30647
+ }
30648
+ return {
30649
+ rows: [...byKey.values()].map(({ weighted, seen, ...row }) => ({
30650
+ ...row,
30651
+ position: seen > 0 ? Math.round(weighted / seen * 10) / 10 : null
30652
+ })).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
30653
+ weeks: [...new Set(inside.map((row) => row.date))].sort()
30654
+ };
30655
+ }
30656
+ async function bingSection(source, read, property, range) {
30657
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
30658
+ const google = (dimension) => read(property, {
30659
+ ...range,
30660
+ dimensions: [dimension],
30661
+ type: "web",
30662
+ dataState: "final",
30663
+ maxRows: 25e3
30664
+ });
30665
+ const [queries, pages, googleRead] = await Promise.all([
30666
+ bingQueries(source.call, source.site),
30667
+ bingPages(source.call, source.site),
30668
+ Promise.all([google("query"), google("page")]).catch((error) => error instanceof Error ? error : new Error(String(error)))
30669
+ ]);
30670
+ const googleError = googleRead instanceof Error ? googleRead : null;
30671
+ const [googleQueries, googlePages] = googleRead instanceof Error ? [] : googleRead;
30672
+ const beside = (bing, googleRows, name) => {
30673
+ const norm = (value) => name === "query" ? value.toLowerCase().trim() : value.replace(/#.*$/, "");
30674
+ const byKey = new Map(googleRows?.map((row) => [norm(row.keys[0] ?? ""), row]));
30675
+ return bing.map((row) => {
30676
+ const g = byKey.get(norm(row.key));
30677
+ const known = googleRows ? 0 : null;
30678
+ return {
30679
+ [name]: row.key,
30680
+ bingClicks: row.clicks,
30681
+ bingImpressions: row.impressions,
30682
+ bingPosition: row.position,
30683
+ googleClicks: g?.clicks ?? known,
30684
+ googleImpressions: g?.impressions ?? known,
30685
+ googlePosition: g ? Math.round(g.position * 10) / 10 : null
30686
+ };
30687
+ });
30688
+ };
30689
+ const q = sumWeeks(queries.rows, range);
30690
+ const p = sumWeeks(pages.rows, range);
30691
+ const invalid = queries.invalidRows + pages.invalidRows;
30692
+ const empty = !queries.rows.length && !pages.rows.length && !invalid;
30693
+ return {
30694
+ range,
30695
+ bingSite: source.site,
30696
+ summary: {
30697
+ bingQueries: q.rows.length,
30698
+ bingPages: p.rows.length,
30699
+ weeks: q.weeks.length
30700
+ },
30701
+ items: beside(q.rows, googleQueries?.rows, "query"),
30702
+ topPages: beside(p.rows, googlePages?.rows, "page"),
30703
+ caveats: [
30704
+ ...empty ? [noBingData(source.site)] : [],
30705
+ ...googleError ? [`Search Console was not available, so the Google columns are empty (unknown, not zero): ${googleError.message}`] : [],
30706
+ `Bing weeks counted: ${q.weeks.join(", ") || "none"} (each date names a week). Google numbers cover ${range.startDate} to ${range.endDate} exactly.`,
30707
+ "Google numbers are for the same query text or page URL. 0 means Google showed no row for it in these dates.",
30708
+ ...BING_LIMITS,
30709
+ ...invalid ? [`${invalid} Bing rows were not valid and were left out.`] : []
30710
+ ]
30711
+ };
30712
+ }
30713
+ async function runBingRows(input, deps) {
30714
+ const source = await bingFor(deps, input.site);
30715
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
30716
+ const range = dateWindow(input, 28);
30717
+ const previous = earlierRange(range);
30718
+ const all = await (input.dimension === "page" ? bingPages : bingQueries)(source.call, source.site);
30719
+ const now = sumWeeks(all.rows, range);
30720
+ const then = sumWeeks(all.rows, previous);
30721
+ const old = new Map(then.rows.map((row) => [row.key, row]));
30722
+ const seen = new Set(now.rows.map((row) => row.key));
30723
+ const rows = now.rows.map((row) => ({
30724
+ [input.dimension]: row.key,
30725
+ clicks: row.clicks,
30726
+ impressions: row.impressions,
30727
+ position: row.position,
30728
+ previous_clicks: old.get(row.key)?.clicks ?? 0,
30729
+ clicks_change: row.clicks - (old.get(row.key)?.clicks ?? 0)
30730
+ }));
30731
+ for (const row of then.rows) {
30732
+ if (seen.has(row.key)) continue;
30733
+ rows.push({
30734
+ [input.dimension]: row.key,
30735
+ clicks: 0,
30736
+ impressions: 0,
30737
+ position: null,
30738
+ previous_clicks: row.clicks,
30739
+ clicks_change: -row.clicks
30740
+ });
30741
+ }
30742
+ const notes = [
30743
+ ...all.rows.length || all.invalidRows ? [] : [noBingData(source.site)],
30744
+ `Bing weeks counted: ${now.weeks.join(", ") || "none"}; earlier period ${then.weeks.join(", ") || "none"}. Each date names a week.`,
30745
+ ...BING_LIMITS.slice(1),
30746
+ ...all.invalidRows ? [`${all.invalidRows} Bing rows were not valid and were left out.`] : []
30747
+ ];
30748
+ return {
30749
+ text: `${rowsText({
30750
+ title: `Bing Webmaster ${source.site}, ${range.startDate} to ${range.endDate}, by ${input.dimension}.`,
30751
+ rows,
30752
+ offset: input.offset ?? 0,
30753
+ limit: 50,
30754
+ detail: input.detail ?? "summary",
30755
+ notes,
30756
+ origin: originOf(source.site),
30757
+ more: (offset) => deps.call("data", {
30758
+ site: input.site,
30759
+ source: "bing",
30760
+ dimensions: input.dimension,
30761
+ detail: "rows",
30762
+ offset
30763
+ })
30764
+ })}\n\n${FACTS_ONLY.replace("Search Console", "Bing Webmaster Tools")}`,
30765
+ data: {
30766
+ bingSite: source.site,
30767
+ range,
30768
+ comparisonRange: previous,
30769
+ weeks: now.weeks,
30770
+ total: rows.length,
30771
+ offset: input.offset ?? 0,
30772
+ rows: rows.slice(input.offset ?? 0, (input.offset ?? 0) + 50)
30773
+ }
30774
+ };
30775
+ }
30776
+ function moved(rows, range, before, name, limit) {
30777
+ const now = sumWeeks(rows, range);
30778
+ const then = sumWeeks(rows, before);
30779
+ const old = new Map(then.rows.map((row) => [row.key, row]));
30780
+ const seen = new Set(now.rows.map((row) => row.key));
30781
+ const all = now.rows.map((row) => {
30782
+ const earlier = old.get(row.key);
30783
+ return {
30784
+ [name]: row.key,
30785
+ clicks: row.clicks,
30786
+ previousClicks: earlier ? earlier.clicks : null,
30787
+ clicksChange: earlier ? row.clicks - earlier.clicks : null,
30788
+ impressions: row.impressions,
30789
+ position: row.position
30790
+ };
30791
+ });
30792
+ 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);
30793
+ return {
30794
+ top: all.slice(0, limit),
30795
+ biggestLosses: by(-1),
30796
+ biggestGains: by(1),
30797
+ leftTopList: then.rows.filter((row) => !seen.has(row.key)).slice(0, limit).map((row) => ({
30798
+ [name]: row.key,
30799
+ previousClicks: row.clicks
30800
+ })),
30801
+ weeks: now.weeks,
30802
+ previousWeeks: then.weeks
30803
+ };
30804
+ }
30805
+ async function bingOverview(source, range, before) {
30806
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
30807
+ const [totals, queries, pages] = await Promise.all([
30808
+ bingTotals(source, range, before),
30809
+ bingQueries(source.call, source.site),
30810
+ bingPages(source.call, source.site)
30811
+ ]);
30812
+ if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
30813
+ const { current: now, previous: then } = totals;
30814
+ const q = moved(queries.rows, range, before, "query", 15);
30815
+ const p = moved(pages.rows, range, before, "page", 15);
30816
+ return {
30817
+ bingSite: source.site,
30818
+ range,
30819
+ comparisonRange: before,
30820
+ summary: {
30821
+ clicks: now.clicks,
30822
+ previousClicks: then.clicks,
30823
+ clicksChangePct: pct(now.clicks, then.clicks),
30824
+ impressions: now.impressions,
30825
+ previousImpressions: then.impressions,
30826
+ impressionsChangePct: pct(now.impressions, then.impressions),
30827
+ days: now.days,
30828
+ previousDays: then.days
30829
+ },
30830
+ pages: p,
30831
+ queries: q,
30832
+ caveats: [
30833
+ ...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.`] : [],
30834
+ `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"}.`,
30835
+ ...BING_LIMITS.slice(1)
30836
+ ]
30837
+ };
30838
+ }
30839
+ function bingOverviewText(report, heading, origin, rowsPerList) {
30840
+ const range = report.range;
30841
+ const before = report.comparisonRange;
30842
+ const t = report.summary;
30843
+ const columns = (name) => [
30844
+ name,
30845
+ "clicks",
30846
+ "previousClicks",
30847
+ "clicksChange",
30848
+ "impressions",
30849
+ "position"
30850
+ ];
30851
+ const part = (title, rows, name) => {
30852
+ const list = rows ?? [];
30853
+ return list.length ? `${title}:\n${table$1(list.slice(0, rowsPerList), columns(name), origin)}` : "";
30854
+ };
30855
+ const group = (key, name) => {
30856
+ const lists = report[key];
30857
+ return [
30858
+ part(`Biggest Bing ${key === "pages" ? "page" : "query"} click losses`, lists.biggestLosses, name),
30859
+ part(`Biggest Bing ${key === "pages" ? "page" : "query"} click gains`, lists.biggestGains, name),
30860
+ 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(", ")}.` : "",
30861
+ rowsPerList >= 10 ? part(`Top Bing ${key}`, lists.top, name) : ""
30862
+ ].filter(Boolean).join("\n\n");
30863
+ };
30864
+ return [
30865
+ `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
30866
+ 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.",
30867
+ group("pages", "page"),
30868
+ group("queries", "query"),
30869
+ `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
30870
+ ].filter(Boolean).join("\n\n");
30871
+ }
30872
+ //#endregion
30873
+ //#region packages/core/src/commands/clicky.ts
30874
+ const CLICKY_KEY_PREFIX = "x-clicky-";
30875
+ function clickySites(keys) {
30876
+ const sites = [];
30877
+ for (const [name, value] of keys) {
30878
+ if (!name.startsWith("x-clicky-")) continue;
30879
+ const siteId = name.slice(9);
30880
+ if (!isClickySiteId(siteId)) continue;
30881
+ let sitekey = value.trim();
30882
+ let domain = null;
30883
+ let account = false;
30884
+ if (sitekey.startsWith("{")) try {
30885
+ const parsed = JSON.parse(sitekey);
30886
+ sitekey = typeof parsed.sitekey === "string" ? parsed.sitekey : "";
30887
+ domain = typeof parsed.domain === "string" ? parsed.domain : null;
30888
+ account = parsed.account === true;
30889
+ } catch {
30890
+ continue;
30891
+ }
30892
+ if (isClickySitekey(sitekey)) sites.push({
30893
+ siteId,
30894
+ sitekey,
30895
+ domain,
30896
+ account
30897
+ });
30898
+ }
30899
+ return sites.sort((left, right) => left.siteId < right.siteId ? -1 : left.siteId > right.siteId ? 1 : 0);
30900
+ }
30901
+ const clickyLabel = (site) => site.domain ? `${site.siteId} (${site.domain})` : site.siteId;
30902
+ function hostOf(value) {
30903
+ const text = value.trim().toLowerCase().replace(/^sc-domain:/u, "");
30904
+ try {
30905
+ return new URL(text.includes("://") ? text : `https://${text}`).hostname.replace(/^www\./u, "");
30906
+ } catch {
30907
+ return text;
30908
+ }
30909
+ }
30910
+ async function pickClickySite(saved, target, connect, refresh) {
30911
+ const host = hostOf(target);
30912
+ const match = (site) => site.siteId === target.trim() || site.domain === host;
30913
+ let sites = saved;
30914
+ if (!sites.some(match) && refresh) sites = await refresh() ?? sites;
30915
+ if (!sites.length) throw new CommandError("NO_KEY", `No Clicky site is saved. Add your Clicky account, or one site's ID and sitekey (Clicky, Preferences, Info): ${connect}`);
30916
+ const found = sites.find(match);
30917
+ if (found) return found;
30918
+ if (!isClickySiteId(target)) {
30919
+ for (const site of sites.filter((item) => !item.domain).slice(0, 10)) site.domain = await clickyDomain(site).catch(() => null);
30920
+ const named = sites.find(match);
30921
+ if (named) return named;
30922
+ }
30923
+ throw new CommandError("NO_ACCESS", `No saved Clicky site matches ${target}. Saved Clicky sites: ${sites.map(clickyLabel).join(", ")}. Pass one of these site IDs, or add the site: ${connect}`);
30924
+ }
30925
+ const DAY = 864e5;
30926
+ const isoDay = (time) => new Date(time).toISOString().slice(0, 10);
30927
+ function clickyRange(input) {
30928
+ if (!input.startDate && !input.endDate) {
30929
+ const end = Date.now() - DAY;
30930
+ return {
30931
+ startDate: isoDay(end - 27 * DAY),
30932
+ endDate: isoDay(end)
30933
+ };
30934
+ }
30935
+ const start = Date.parse(`${input.startDate}T00:00:00Z`);
30936
+ const end = Date.parse(`${input.endDate}T00:00:00Z`);
30937
+ if (!input.startDate || !input.endDate || !(start <= end)) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
30938
+ if ((end - start) / DAY + 1 > 31) throw new CommandError("INVALID_INPUT", "Clicky reads at most 31 days at a time. Send a shorter range.");
30939
+ return {
30940
+ startDate: input.startDate,
30941
+ endDate: input.endDate
30942
+ };
30943
+ }
30944
+ const count = (item) => {
30945
+ const value = Number(item.value);
30946
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
30947
+ };
30948
+ const totals = (items) => items.map((item) => ({
30949
+ name: item.title ?? "",
30950
+ visits: count(item)
30951
+ })).filter((item) => Boolean(item.name) && item.visits !== null);
30952
+ const line = (items) => {
30953
+ const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
30954
+ const rest = items.slice(10);
30955
+ if (rest.length) top.push(`${rest.length} others ${rest.reduce((sum, item) => sum + item.visits, 0)}`);
30956
+ return top.join("; ");
30957
+ };
30958
+ const LIMIT = 1e3;
30959
+ async function runClickyRows(input, keys, options) {
30960
+ const refresh = options.refresh;
30961
+ const site = await pickClickySite(clickySites(keys), input.site, options.connect, refresh && (async () => {
30962
+ const fresh = await refresh().catch(() => null);
30963
+ return fresh && clickySites(fresh);
30964
+ }));
30965
+ const range = clickyRange(input);
30966
+ const offset = input.offset ?? 0;
30967
+ const stats = await clickyStats(site, [
30968
+ "pages-entrance",
30969
+ "traffic-sources",
30970
+ "searches-engines"
30971
+ ], {
30972
+ date: `${range.startDate},${range.endDate}`,
30973
+ limit: LIMIT
30974
+ }).catch((error) => {
30975
+ if (error instanceof ClickyError) throw new CommandError(error.code === "auth" ? "NO_ACCESS" : "PROVIDER_ERROR", error.code === "auth" ? `${error.message} Save it again: ${options.connect}` : error.message);
30976
+ throw error;
30977
+ });
30978
+ const entrances = stats["pages-entrance"] ?? [];
30979
+ const visitsByPage = /* @__PURE__ */ new Map();
30980
+ for (const item of entrances) {
30981
+ const visits = count(item);
30982
+ if (visits === null || !item.url) continue;
30983
+ let page;
30984
+ try {
30985
+ page = new URL(item.url).pathname;
30986
+ } catch {
30987
+ continue;
30988
+ }
30989
+ visitsByPage.set(page, (visitsByPage.get(page) ?? 0) + visits);
30990
+ }
30991
+ const rows = [...visitsByPage].map(([page, visits]) => ({
30992
+ page,
30993
+ visits
30994
+ })).sort((left, right) => right.visits - left.visits || (left.page < right.page ? -1 : left.page > right.page ? 1 : 0));
30995
+ const sources = totals(stats["traffic-sources"] ?? []);
30996
+ const engines = totals(stats["searches-engines"] ?? []);
30997
+ const notes = [
30998
+ "Visits by landing page from every source, as Clicky counts them, in the Clicky site’s time zone. Clicky does not split landing pages by source.",
30999
+ sources.length ? `Traffic sources (visits): ${line(sources)}.` : "Traffic sources: none in this range.",
31000
+ engines.length ? `Search engines (visits): ${line(engines)}.` : "Search engines: none in this range."
31001
+ ];
31002
+ if (entrances.length >= LIMIT) notes.push(`Clicky returned its ${LIMIT}-row limit; pages past it are missing, not zero.`);
31003
+ return {
31004
+ text: rowsText({
31005
+ title: `Clicky site ${clickyLabel(site)}: landing pages, ${range.startDate} to ${range.endDate}.`,
31006
+ rows,
31007
+ offset,
31008
+ limit: 50,
31009
+ detail: input.detail ?? "summary",
31010
+ notes,
31011
+ more: options.call
31012
+ }),
31013
+ data: {
31014
+ siteId: site.siteId,
31015
+ domain: site.domain,
31016
+ range,
31017
+ metric: "visits",
31018
+ sources,
31019
+ searchEngines: engines,
31020
+ capped: entrances.length >= LIMIT,
31021
+ total: rows.length,
31022
+ offset,
31023
+ rows: rows.slice(offset, offset + 50)
31024
+ }
31025
+ };
31026
+ }
31027
+ //#endregion
31028
+ //#region packages/core/src/commands/domain-rating.ts
31029
+ const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
31030
+ const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
31031
+ const MAX_DOMAINS = 25;
31032
+ const TTL_SECONDS = 1440 * 60;
31033
+ const DOMAIN_FIELDS = [
31034
+ "domain",
31035
+ "competitor_domain",
31036
+ "Dn"
31037
+ ];
31038
+ function bareDomain(value) {
31039
+ return value.replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./, "").toLowerCase();
31040
+ }
31041
+ async function lookup(domain, key) {
31042
+ const url = `${ENDPOINT}?target=${encodeURIComponent(domain)}`;
31043
+ const cache = globalThis.caches?.default;
31044
+ const cacheKey = new Request(url);
31045
+ const cached = await cache?.match(cacheKey);
31046
+ const response = cached ?? await fetch(url, {
31047
+ headers: {
31048
+ authorization: `Bearer ${key}`,
31049
+ accept: "application/json"
31050
+ },
31051
+ signal: AbortSignal.timeout(5e3)
31052
+ });
31053
+ if (!response.ok) return null;
31054
+ const body = await readJson(response.clone(), "Ahrefs Domain Rating");
31055
+ if (cache && !cached) {
31056
+ const copy = new Response(response.body, response);
31057
+ copy.headers.set("cache-control", `max-age=${TTL_SECONDS}`);
31058
+ await cache.put(cacheKey, copy);
31059
+ }
31060
+ return body.domain_rating?.domain_rating ?? null;
31061
+ }
31062
+ async function domainRatings(domains, key) {
31063
+ const found = /* @__PURE__ */ new Map();
31064
+ if (!key) return found;
31065
+ const unique = [...new Set(domains.map(bareDomain).filter(Boolean))].slice(0, MAX_DOMAINS);
31066
+ const values = await Promise.all(unique.map((domain) => lookup(domain, key).catch(() => null)));
31067
+ unique.forEach((domain, index) => {
31068
+ found.set(domain, values[index] ?? null);
31069
+ });
31070
+ return found;
31071
+ }
31072
+ async function withDomainRating(rows, key) {
31073
+ const field = DOMAIN_FIELDS.find((name) => rows.some((row) => typeof row[name] === "string"));
31074
+ if (!key || !field || rows.some((row) => "domain_rating" in row)) return {
31075
+ rows,
31076
+ added: false
31077
+ };
31078
+ const ratings = await domainRatings(rows.map((row) => String(row[field] ?? "")), key);
31079
+ return {
31080
+ rows: rows.map((row) => {
31081
+ const next = {};
31082
+ for (const [name, value] of Object.entries(row)) {
31083
+ next[name] = value;
31084
+ if (name === field) next.domain_rating = ratings.get(bareDomain(String(value ?? ""))) ?? null;
31085
+ }
31086
+ return next;
31087
+ }),
31088
+ added: true
31089
+ };
31090
+ }
31091
+ const MAX_INSPECT_LIMIT = 2e3;
31092
+ const CONCURRENCY = 20;
31093
+ const REFERRING_KEPT = 10;
31094
+ const LABELS = {
31095
+ fix: "the evidence shows a fault on the site",
31096
+ check: "a person should look; the evidence alone does not settle it",
31097
+ expected: "the evidence shows the site works as built, nothing to fix",
31098
+ indexed: "Google indexes it and the live check shows no fault"
31099
+ };
31100
+ const AUTH_SEGMENTS = /* @__PURE__ */ new Set([
31101
+ "account",
31102
+ "admin",
31103
+ "api",
31104
+ "auth",
31105
+ "cart",
31106
+ "checkout",
31107
+ "dashboard",
31108
+ "login",
31109
+ "logout",
31110
+ "my-account",
31111
+ "oauth",
31112
+ "sign-in",
31113
+ "signin",
31114
+ "wp-admin",
31115
+ "wp-login.php"
31116
+ ]);
31117
+ const bareHost = (url) => new URL(url).hostname.replace(/^www\./, "");
31118
+ const sameSite = (a, b) => {
31119
+ try {
31120
+ return bareHost(a) === bareHost(b);
31121
+ } catch {
31122
+ return false;
31123
+ }
31124
+ };
31125
+ function variantOf(url, target) {
31126
+ const from = new URL(url);
31127
+ const to = new URL(target);
31128
+ if (from.pathname !== to.pathname || from.search !== to.search) return null;
31129
+ if (!sameSite(url, target)) return null;
31130
+ if (from.protocol !== to.protocol) return "http:// variant";
31131
+ if (from.host !== to.host) return `host variant (${from.host})`;
31132
+ return null;
31133
+ }
31134
+ const samePath = (a, b) => {
31135
+ const [from, to] = [new URL(a), new URL(b)];
31136
+ return from.pathname === to.pathname && from.search === to.search;
31137
+ };
31138
+ const authPath = (url) => AUTH_SEGMENTS.has(new URL(url).pathname.split("/")[1]?.toLowerCase() ?? "");
31139
+ const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
31140
+ function labelOf(e) {
31141
+ const { url, status, live } = e;
31142
+ const { first, hops, last } = live;
31143
+ const indexed = status.verdict === "PASS";
31144
+ const coverage = status.coverage ?? "";
31145
+ const referring = status.referring_urls.filter((item) => item !== url && sameSite(item, url) && !samePath(item, url));
31146
+ const pointers = [e.inSitemap ? "the sitemap lists it" : null, referring.length ? `Google lists ${plural$1(referring.length, "referring page")} on this site as of its last crawl (${referring[0]}${referring.length > 1 ? ", and more" : ""})` : null].filter(Boolean);
31147
+ if (first.error) return indexed ? {
31148
+ label: "indexed",
31149
+ why: `Google indexes it. The live check failed (${first.error}).`
31150
+ } : {
31151
+ label: "check",
31152
+ why: `The live check failed (${first.error}), so only Google's reason is shown.`
31153
+ };
31154
+ const temporary = hops.find((hop) => hop.kind === "temporary" && new URL(hop.from).host === new URL(hop.to).host);
31155
+ if (temporary) return {
31156
+ label: "fix",
31157
+ why: `${temporary.from} sends a ${temporary.status} (temporary) redirect to ${temporary.to} on the same host; a permanent move uses 301 or 308.`
31158
+ };
31159
+ if (!last) return {
31160
+ label: "fix",
31161
+ why: `The redirects loop or pass 5 hops (${hops.map((hop) => hop.status).join(" > ")}).`
31162
+ };
31163
+ if (last.error) return {
31164
+ label: "check",
31165
+ why: `It redirects to ${last.url}, which could not be fetched (${last.error}).`
31166
+ };
31167
+ const end = last.status ?? 0;
31168
+ const now = hops.length ? `redirects to ${last.url}, which returns ${end}` : `returns ${end} now`;
31169
+ const via = `It ${now}`;
31170
+ if (end === 404 || end === 410) {
31171
+ if (pointers.length) return {
31172
+ label: "fix",
31173
+ why: `${via}, and ${pointers.join(" and ")}.`
31174
+ };
31175
+ if (hops.length) return {
31176
+ label: "fix",
31177
+ why: `${via}: a redirect to a missing page.`
31178
+ };
31179
+ return {
31180
+ label: "expected",
31181
+ why: `${via}, and neither the sitemap nor any referring page Google lists on this site points to it.`
31182
+ };
31183
+ }
31184
+ if ((end === 401 || end === 403) && !hops.length) {
31185
+ if (e.inSitemap) return {
31186
+ label: "fix",
31187
+ why: `${via}, but the sitemap lists it.`
31188
+ };
31189
+ if (end === 401 && first.www_authenticate) return {
31190
+ label: "expected",
31191
+ why: `${via} with a WWW-Authenticate header (${first.www_authenticate.split(/\s/)[0]}), so the server asks for sign-in here.`
31192
+ };
31193
+ if (authPath(url)) return {
31194
+ label: "expected",
31195
+ why: `${via} on a path that looks like a sign-in area (heuristic: from the path only).`
31196
+ };
31197
+ return {
31198
+ label: "check",
31199
+ why: `${via} with no WWW-Authenticate header and no sign-in path, so the evidence does not show whether it should be public.`
31200
+ };
31201
+ }
31202
+ if (hops.length) {
31203
+ const codes = hops.map((hop) => hop.status).join(" > ");
31204
+ if (end !== 200) return {
31205
+ label: "check",
31206
+ why: `${via} (${codes}).`
31207
+ };
31208
+ if (e.inSitemap) return {
31209
+ label: "fix",
31210
+ why: `The sitemap lists it, but it redirects (${codes}) to ${last.url}.`
31211
+ };
31212
+ const declared = last.canonical === last.url ? "declares itself canonical" : e.sitemap.has(last.url) ? "is in the sitemap" : null;
31213
+ const variant = variantOf(url, last.url);
31214
+ const moved = `${variant ? `An ${variant}` : "A permanent redirect"}: ${codes} to ${last.url}`;
31215
+ const stale = indexed ? " Google still indexes this URL as of its last crawl." : "";
31216
+ const links = referring.length ? ` ${pointers.at(-1)}.` : "";
31217
+ if (declared) return {
31218
+ label: "expected",
31219
+ why: `${moved}, which returns 200 and ${declared}.${stale}${links}`
31220
+ };
31221
+ return {
31222
+ label: "check",
31223
+ why: `${moved}, which declares ${last.canonical ? `${last.canonical} as canonical` : "no canonical"}.${stale}${links}`
31224
+ };
31225
+ }
31226
+ if (/^(Crawled|Discovered) - currently not indexed$|^URL is unknown to Google$/i.test(coverage)) return {
31227
+ label: "check",
31228
+ why: `${coverage.startsWith("Crawled") ? "Google crawled it but has not indexed it, and gives no further reason" : coverage.startsWith("Discovered") ? "Google knows the URL but has not crawled it yet" : "Google has no record of this URL"}.${e.inSitemap ? " The sitemap lists it." : ""} It returns ${end} now.`
31229
+ };
31230
+ const robots = last.robots ? ` with robots "${last.robots}"` : " with no robots meta tag or X-Robots-Tag header";
31231
+ if (/noindex/i.test(coverage) && !/noindex/i.test(last.robots ?? "")) return {
31232
+ label: "check",
31233
+ why: `Google saw noindex when it last crawled${status.last_crawl ? ` (${status.last_crawl.slice(0, 10)})` : ""}, but it ${now}${robots}, so Google's reason may be out of date.${e.inSitemap ? " The sitemap lists it." : ""}`
31234
+ };
31235
+ const declaredCanonical = status.user_canonical ?? last.canonical;
31236
+ if (status.google_canonical && declaredCanonical && status.google_canonical !== declaredCanonical) return {
31237
+ label: "check",
31238
+ why: `Google chose ${status.google_canonical} as canonical; the page declares ${declaredCanonical}.`
31239
+ };
31240
+ if (indexed) return end === 200 ? {
31241
+ label: "indexed",
31242
+ why: "Google indexes it and it returns 200 now."
31243
+ } : {
31244
+ label: "check",
31245
+ why: `Google indexes it, but it ${now}.`
31246
+ };
31247
+ if (e.inSitemap) return {
31248
+ label: "fix",
31249
+ why: `The sitemap lists it, but Google does not index it. It ${now}${robots}.`
31250
+ };
31251
+ if (status.google_canonical && status.google_canonical === status.user_canonical && status.google_canonical !== url) return {
31252
+ label: "expected",
31253
+ why: `The page declares ${status.user_canonical} as canonical and Google uses it.`
31254
+ };
31255
+ return {
31256
+ label: "check",
31257
+ why: /noindex/i.test(last.robots ?? "") ? `${via}${robots} and the sitemap does not list it. Whether it should be indexed is the site's call.` : `${via}${robots}. No rule here settles this reason.`
31258
+ };
31259
+ }
31260
+ function indexingRow(e, sources, inspectedAt) {
31261
+ const { status, live } = e;
31262
+ const { label, why } = labelOf(e);
31263
+ return {
31264
+ reason: status.coverage ?? status.verdict ?? "No reason returned",
31265
+ url: e.url,
31266
+ label,
31267
+ why,
31268
+ verdict: status.verdict,
31269
+ indexing: status.indexing,
31270
+ status: live.first.status,
31271
+ redirects: live.hops.length ? live.hops.map((hop) => `${hop.status} ${hop.kind}`).join(" > ") : null,
31272
+ redirects_to: live.first.redirects_to,
31273
+ final_url: live.last?.url ?? null,
31274
+ final_status: live.last?.status ?? null,
31275
+ page_canonical: live.last?.canonical ?? null,
31276
+ user_canonical: status.user_canonical,
31277
+ google_canonical: status.google_canonical,
31278
+ in_sitemap: e.inSitemap,
31279
+ referring_urls: status.referring_urls.slice(0, REFERRING_KEPT),
31280
+ last_crawl: status.last_crawl,
31281
+ inspected_at: inspectedAt,
31282
+ page_fetch: status.page_fetch,
31283
+ robots_txt: status.robots_txt,
31284
+ crawled_as: status.crawled_as,
31285
+ sources,
31286
+ ...live.first.error ? { live_error: live.first.error } : {}
31287
+ };
31288
+ }
31289
+ const byText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
31290
+ function sortRows(rows) {
31291
+ return [...rows].sort((a, b) => byText(a.reason, b.reason) || byText(a.url, b.url));
31292
+ }
31293
+ function byReason(rows) {
31294
+ const groups = /* @__PURE__ */ new Map();
31295
+ for (const row of rows) {
31296
+ const counts = groups.get(row.reason) ?? {
31297
+ fix: 0,
31298
+ check: 0,
31299
+ expected: 0,
31300
+ indexed: 0
31301
+ };
31302
+ counts[row.label] += 1;
31303
+ groups.set(row.reason, counts);
31304
+ }
31305
+ return [...groups].sort((a, b) => byText(a[0], b[0])).map(([reason, counts]) => ({
31306
+ reason,
31307
+ urls: counts.fix + counts.check + counts.expected + counts.indexed,
31308
+ ...counts
31309
+ }));
31310
+ }
31311
+ const isCode = (error, codes) => codes.includes(String(error?.code));
31312
+ function knownUrls(input) {
31313
+ const found = /* @__PURE__ */ new Map();
31314
+ const add = (url, source) => {
31315
+ const sources = found.get(url) ?? [];
31316
+ if (!sources.includes(source)) sources.push(source);
31317
+ found.set(url, sources);
31318
+ };
31319
+ const pages = input.pages.filter((page) => page.impressions > 0 && !page.url.includes("#")).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions || byText(a.url, b.url));
31320
+ for (const url of input.lost) add(url, "lost_clicks");
31321
+ for (const page of pages) add(page.url, "search_console");
31322
+ for (const url of input.sitemap.urls) add(url, "sitemap");
31323
+ if (input.home) add(input.home, "home");
31324
+ return {
31325
+ urls: [...found].map(([url, sources]) => ({
31326
+ url,
31327
+ sources
31328
+ })),
31329
+ sitemap: input.sitemap,
31330
+ searchConsole: pages.length,
31331
+ searchConsoleCapped: input.pagesCapped,
31332
+ lostClicks: input.lost.length
31333
+ };
31334
+ }
31335
+ async function inspectKnown(site, known, deps, asked = 100) {
31336
+ const cap = Math.min(Math.max(1, Math.floor(asked)), MAX_INSPECT_LIMIT);
31337
+ const wanted = known.urls.slice(0, cap);
31338
+ let saved = /* @__PURE__ */ new Map();
31339
+ try {
31340
+ saved = deps.savedInspections?.read(site, wanted.map((item) => item.url)) ?? saved;
31341
+ } catch {}
31342
+ const missing = wanted.filter((item) => !saved.has(item.url));
31343
+ const quota = deps.inspectionQuota ? await deps.inspectionQuota(site).catch(() => null) : null;
31344
+ const left = quota ? Math.max(0, quota.limit - quota.used) : missing.length;
31345
+ const asking = new Set(missing.slice(0, left).map((item) => item.url));
31346
+ const queue = wanted.filter((item) => saved.has(item.url) || asking.has(item.url));
31347
+ const quotaShort = left < missing.length;
31348
+ const statuses = /* @__PURE__ */ new Map();
31349
+ const rows = [];
31350
+ const failed = [];
31351
+ const sitemap = new Set(known.sitemap.urls);
31352
+ const now = () => (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
31353
+ let quotaStop = false;
31354
+ let next = 0;
31355
+ let fatal = null;
31356
+ const worker = async () => {
31357
+ while (fatal === null && next < queue.length) {
31358
+ const item = queue[next++];
31359
+ const stored = saved.get(item.url);
31360
+ let status;
31361
+ let inspectedAt;
31362
+ if (stored) {
31363
+ status = stored.status;
31364
+ inspectedAt = stored.inspectedAt;
31365
+ } else {
31366
+ if (quotaStop) continue;
31367
+ try {
31368
+ status = await deps.inspect(site, item.url);
31369
+ inspectedAt = now();
31370
+ } catch (error) {
31371
+ if (isCode(error, ["RATE_LIMITED"])) quotaStop = true;
31372
+ else if (isCode(error, [
31373
+ "AUTH_EXPIRED",
31374
+ "AUTH_REQUIRED",
31375
+ "NOT_CONNECTED"
31376
+ ])) fatal = error;
31377
+ else failed.push({
31378
+ url: item.url,
31379
+ error: error instanceof Error ? error.message : String(error)
31380
+ });
31381
+ continue;
31382
+ }
31383
+ try {
31384
+ deps.savedInspections?.save(site, item.url, status);
31385
+ } catch {}
31386
+ }
31387
+ statuses.set(item.url, status);
31388
+ const live = await deps.liveCheck(item.url);
31389
+ rows.push(indexingRow({
31390
+ url: item.url,
31391
+ status,
31392
+ live,
31393
+ inSitemap: item.sources.includes("sitemap"),
31394
+ sitemap
31395
+ }, item.sources, inspectedAt));
31396
+ }
31397
+ };
31398
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
31399
+ if (fatal !== null) throw fatal;
31400
+ const reused = rows.filter((row) => saved.has(row.url));
31401
+ const oldest = reused.reduce((first, row) => first === null || row.inspected_at < first ? row.inspected_at : first, null);
31402
+ return {
31403
+ rows: sortRows(rows),
31404
+ statuses,
31405
+ known,
31406
+ limit: cap,
31407
+ inspected: rows.length,
31408
+ fromSaved: reused.length,
31409
+ oldestSaved: oldest,
31410
+ failed: failed.sort((a, b) => byText(a.url, b.url)),
31411
+ stoppedBy: quotaStop || quotaShort ? "quota" : known.urls.length > wanted.length ? "limit" : null,
31412
+ quota
31413
+ };
31414
+ }
31415
+ const n = (value) => value.toLocaleString("en-US");
31416
+ function liveCell(row) {
31417
+ if (row.live_error) return `error: ${row.live_error}`;
31418
+ if (!row.redirects) return String(row.status ?? "");
31419
+ return `${row.redirects.replace(/ (permanent|temporary)/g, "")} > ${row.final_status ?? "loop"}`;
31420
+ }
31421
+ function indexingText(site, run, page, origin, askMore, now) {
31422
+ const { known } = run;
31423
+ const total = known.urls.length;
31424
+ const notInspected = total - run.inspected - run.failed.length;
31425
+ const sources = [
31426
+ `lost-click pages ${n(known.lostClicks)}`,
31427
+ `Search Console pages with impressions in the last 28 days ${n(known.searchConsole)}${known.searchConsoleCapped ? ", capped" : ""}`,
31428
+ `sitemap ${n(known.sitemap.urls.length)} from ${plural$1(known.sitemap.files, "file")}${known.sitemap.partial ? ", partial" : ""}`
31429
+ ];
31430
+ const lowerBound = known.sitemap.partial || known.searchConsoleCapped ? " at least" : "";
31431
+ const asked = run.inspected - run.fromSaved;
31432
+ const most = Math.min(total, MAX_INSPECT_LIMIT, run.quota ? run.inspected + run.quota.limit - run.quota.used - asked : MAX_INSPECT_LIMIT);
31433
+ const days = run.oldestSaved ? Math.floor((now.getTime() - Date.parse(run.oldestSaved)) / 864e5) : 0;
31434
+ const savedNote = run.fromSaved ? ` (${n(asked)} asked of Google now, ${n(run.fromSaved)} from saved results; the oldest was inspected ${run.oldestSaved?.slice(0, 16).replace("T", " ")} UTC, ${days === 0 ? "under a day" : plural$1(days, "day")} ago. Saved results are reused for 7 days)` : "";
31435
+ const stop = run.stoppedBy === "quota" ? "The URL Inspection quota stopped it." : run.stoppedBy === "limit" ? `It stopped at ${n(run.limit)}, the number of inspections for this run${askMore && most > run.limit ? `; to inspect more, up to the quota left today: ${askMore(most)}` : ""}.` : "Every known URL has a result.";
31436
+ const lines = [
31437
+ `Google's indexing reasons for ${site}, from URL Inspection. Search Console's Page indexing report has no API, so known URLs are inspected one at a time, in a fixed order (lost-click pages, Search Console pages by clicks then impressions, sitemap URLs, then the rest), and checked live from here.`,
31438
+ `Known URLs:${lowerBound} ${n(total)} (${sources.join("; ")}). Inspected ${n(run.inspected)} of ${n(total)}${savedNote}. ${stop}${notInspected > 0 ? ` The ${n(notInspected)} URLs not inspected have no result here, so this is not an all-clear for them.` : ""}${known.sitemap.notes.length ? ` Sitemap: ${known.sitemap.notes.join("; ")}.` : ""}`,
31439
+ run.quota ? `URL Inspection quota: this machine had used ${n(run.quota.used)} of ${n(run.quota.limit)} today for this property before the run. Google also counts inspections from other tools.` : "URL Inspection quota: not counted on this server; Google stops inspections at 2,000 a day per property."
31440
+ ];
31441
+ if (run.failed.length) lines.push(`URL Inspection failed for ${plural$1(run.failed.length, "URL")}: ${run.failed.slice(0, 5).map((item) => `${item.url} (${item.error})`).join("; ")}${run.failed.length > 5 ? "; and more" : ""}.`);
31442
+ const groups = byReason(run.rows);
31443
+ if (groups.length) lines.push(`By Google's reason, with labels from the rules below (not Google's):\n${table$1(groups, [
31444
+ "reason",
31445
+ "urls",
31446
+ "fix",
31447
+ "check",
31448
+ "expected",
31449
+ "indexed"
31450
+ ], null)}`);
31451
+ const listed = run.rows.filter((row) => row.label !== "indexed");
31452
+ const shown = page.summary ? listed.filter((row) => row.label === "fix" || row.label === "check") : listed;
31453
+ const indexedCount = run.rows.length - listed.length;
31454
+ const expectedCount = listed.length - shown.length;
31455
+ const pageRows = shown.slice(page.offset, page.offset + page.size).map((row) => ({
31456
+ reason: row.reason,
31457
+ label: row.label,
31458
+ url: row.url,
31459
+ live: liveCell(row),
31460
+ final_url: row.final_url === row.url ? "" : row.final_url,
31461
+ in_sitemap: row.in_sitemap ? "yes" : "no",
31462
+ last_crawl: row.last_crawl?.slice(0, 10) ?? "",
31463
+ why: row.why
31464
+ }));
31465
+ const end = page.offset + pageRows.length;
31466
+ const more = shown.length - end;
31467
+ const others = page.summary ? [
31468
+ more ? `${more} more labelled fix or check` : null,
31469
+ expectedCount ? `${expectedCount} expected` : null,
31470
+ indexedCount ? `${indexedCount} indexed with no fault found (in the full result)` : null
31471
+ ].filter(Boolean) : [];
31472
+ if (page.summary && (more || expectedCount)) others.push(`every row but the indexed ones: ${page.more(0)}`);
31473
+ lines.push(`${shown.length === 0 ? `No inspected URL is labelled ${page.summary ? "fix or check" : "fix, check or expected"}.` : `${page.summary ? "URLs labelled fix or check" : "URLs that are not indexed or show a fault"}, ${page.offset + 1} to ${end} of ${shown.length}:\n${table$1(pageRows, Object.keys(pageRows[0] ?? {}), origin, 400)}`}${others.length ? `\nNot listed here: ${others.join("; ")}.` : ""}`, `Labels: ${Object.entries(LABELS).map(([label, text]) => `${label}, ${text}`).join("; ")}. Rules: a same-host 302 or 307 is fix; a 404 or 410 that the sitemap or a referring page on this site points to is fix; a sitemap URL that redirects or that Google does not index is fix; a URL outside the sitemap (such as an http:// or host variant) that permanently redirects to a page declaring itself canonical is expected; a 401 with a WWW-Authenticate header is expected, and a 401 or 403 on a sign-in path is expected by heuristic; "Crawled - currently not indexed", "Discovered - currently not indexed" and a Google canonical that differs from the declared one are check. Referring pages are as of Google's last crawl. Live checks are server responses, so a browser-only HSTS "307 Internal Redirect" never shows here.`);
31474
+ if (!page.summary && more) lines.push(`More rows: ${page.more(end)}.`);
31475
+ return lines;
31476
+ }
31477
+ //#endregion
31478
+ //#region packages/core/src/commands/page-facts.ts
31479
+ const MAX_PAGES = 20;
31480
+ const TIMEOUT_MS$2 = 6e3;
31481
+ const MAX_BYTES$2 = 1e6;
31482
+ const clean = (value) => value?.trim().replace(/\s+/g, " ") || null;
31483
+ async function boundedText(response) {
31484
+ if (!response.body) return "";
31485
+ const reader = response.body.getReader();
31486
+ const decoder = new TextDecoder();
31487
+ let text = "";
31488
+ let total = 0;
31489
+ while (total < MAX_BYTES$2) {
31490
+ const { done, value } = await reader.read();
31491
+ if (done) return text + decoder.decode();
31492
+ total += value.byteLength;
31493
+ text += decoder.decode(value, { stream: true });
31494
+ }
31495
+ await reader.cancel();
31496
+ return text;
31497
+ }
31498
+ function headFacts(html, url) {
31499
+ let title = "";
31500
+ let h1 = "";
31501
+ let inTitle = false;
31502
+ let inH1 = false;
31503
+ let titleDone = false;
31504
+ let h1Done = false;
31505
+ let description = null;
31506
+ let canonical = null;
31507
+ const robots = [];
31508
+ const parser = new Parser({
31509
+ onopentag(name, attributes) {
31510
+ if (name === "title" && !titleDone) inTitle = true;
31511
+ if (name === "h1" && !h1Done) inH1 = true;
31512
+ if (name === "meta") {
31513
+ const metaName = attributes.name?.toLowerCase();
31514
+ if (metaName === "description" && description === null) description = attributes.content ?? null;
31515
+ if (metaName === "robots" && attributes.content) robots.push(attributes.content);
31516
+ }
31517
+ if (name === "link" && canonical === null && attributes.rel?.toLowerCase().split(/\s+/).includes("canonical") && attributes.href) try {
31518
+ canonical = new URL(attributes.href, url).toString();
31519
+ } catch {
31520
+ canonical = attributes.href;
31521
+ }
31522
+ },
31523
+ ontext(text) {
31524
+ if (inTitle) title += text;
31525
+ if (inH1) h1 += text;
31526
+ },
31527
+ onclosetag(name) {
31528
+ if (name === "title" && inTitle) {
31529
+ inTitle = false;
31530
+ titleDone = true;
31531
+ }
31532
+ if (name === "h1" && inH1) {
31533
+ inH1 = false;
31534
+ h1Done = true;
31535
+ }
31536
+ }
31537
+ }, {
31538
+ decodeEntities: true,
31539
+ lowerCaseTags: true
31540
+ });
31541
+ parser.write(html);
31542
+ parser.end();
31543
+ return {
31544
+ title: clean(title),
31545
+ description: clean(description),
31546
+ h1: clean(h1),
31547
+ canonical,
31548
+ robots: robots.length ? robots.join(", ") : null
28659
31549
  };
28660
31550
  }
28661
31551
  async function facts(url) {
@@ -28689,63 +31579,548 @@ async function facts(url) {
28689
31579
  await response.body?.cancel();
28690
31580
  return out;
28691
31581
  }
28692
- const head = headFacts(await boundedText(response), url);
28693
- Object.assign(out, head);
28694
- out.robots = [headerRobots, head.robots].filter(Boolean).join(", ") || null;
28695
- } catch (error) {
28696
- out.error = error instanceof Error ? error.message : "fetch failed";
28697
- }
28698
- return out;
28699
- }
28700
- function checkPages(urls) {
28701
- return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
31582
+ const head = headFacts(await boundedText(response), url);
31583
+ Object.assign(out, head);
31584
+ out.robots = [headerRobots, head.robots].filter(Boolean).join(", ") || null;
31585
+ } catch (error) {
31586
+ out.error = error instanceof Error ? error.message : "fetch failed";
31587
+ }
31588
+ return out;
31589
+ }
31590
+ function checkPages(urls) {
31591
+ return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
31592
+ }
31593
+ const MAX_HOPS = 5;
31594
+ const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
31595
+ async function liveCheck(url) {
31596
+ const first = await facts(url);
31597
+ const hops = [];
31598
+ const seen = /* @__PURE__ */ new Set([url]);
31599
+ let last = first;
31600
+ while (last.redirects_to && last.status !== null) {
31601
+ const kind = redirectKind(last.status);
31602
+ if (!kind) break;
31603
+ const to = last.redirects_to;
31604
+ hops.push({
31605
+ from: last.url,
31606
+ status: last.status,
31607
+ to,
31608
+ kind
31609
+ });
31610
+ if (seen.has(to) || hops.length > MAX_HOPS) return {
31611
+ first,
31612
+ hops,
31613
+ last: null
31614
+ };
31615
+ seen.add(to);
31616
+ last = await facts(to);
31617
+ }
31618
+ return {
31619
+ first,
31620
+ hops,
31621
+ last
31622
+ };
31623
+ }
31624
+ function temporaryRedirectNote(pages) {
31625
+ const temporary = pages.filter((page) => (page.status === 302 || page.status === 307) && page.redirects_to !== null && new URL(page.redirects_to).host === new URL(page.url).host);
31626
+ if (!temporary.length) return null;
31627
+ 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("; ")}.`;
31628
+ }
31629
+ //#endregion
31630
+ //#region packages/core/src/commands/report.ts
31631
+ const SECTIONS = {
31632
+ 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.",
31633
+ "quick-wins": "Query and page pairs at average positions 4 to 10 with a click-through rate below the site’s own curve for that position.",
31634
+ "low-ctr": "Query and page pairs at average positions 1 to 4 with a click-through rate below the site’s own curve: titles and snippets to review with the person.",
31635
+ "striking-distance": "Queries just outside the top positions, with impressions, for pages that already rank.",
31636
+ "second-page": "Pages with an average position of 11 to 20 and their queries.",
31637
+ "content-decay": "Query and page pairs that lost clicks against the previous period.",
31638
+ cannibalization: "Queries where more than one page of the site gets impressions, with each page’s share.",
31639
+ "traffic-drop": "Daily clicks and impressions checked for a sudden change that lasted, with the date it started (else the last 7 days against the days before), and Google updates within 3 days of that date. Overlap is timing only, not cause.",
31640
+ "ai-queries": "Queries with the shape of AI conversations and agent searches (replies such as \"yes go on\", follow-ups, agent prompts, search operators, chat phrasing, ten or more words), with rewordings grouped into one question and its top page, impressions, clicks and position. A heuristic: Search Console does not mark AI Mode.",
31641
+ bing: "Bing’s top queries from Bing Webmaster Tools with Google’s clicks, impressions and position for the same query beside them, and the same for pages."
31642
+ };
31643
+ const SECTION_IDS = Object.keys(SECTIONS);
31644
+ const PAGE_CHECKS = /* @__PURE__ */ new Set([
31645
+ "quick-wins",
31646
+ "low-ctr",
31647
+ "striking-distance",
31648
+ "second-page",
31649
+ "content-decay",
31650
+ "cannibalization"
31651
+ ]);
31652
+ const DEFAULT_DAYS = {
31653
+ overview: 28,
31654
+ "quick-wins": 28,
31655
+ "low-ctr": 28,
31656
+ "striking-distance": 28,
31657
+ "second-page": 28,
31658
+ "content-decay": 28,
31659
+ cannibalization: 28,
31660
+ "traffic-drop": 90,
31661
+ "ai-queries": 90,
31662
+ bing: 28
31663
+ };
31664
+ function nextFor(section, call) {
31665
+ const section_ = (id) => `section "${id}"`;
31666
+ return {
31667
+ overview: `Next: ${call("compare")} with a change date, or ${call("page")} for one page that moved.`,
31668
+ "quick-wins": `Next: ${call("page")} for one page’s queries and live facts; ${section_("low-ctr")} for the top positions.`,
31669
+ "low-ctr": `Next: ${call("page")} for one page; ${call("research")} serp to see who else ranks.`,
31670
+ "striking-distance": `Next: ${section_("second-page")} to see the same pages by page; ${call("research")} keyword_metrics for search volume.`,
31671
+ "second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
31672
+ "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
31673
+ cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
31674
+ "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.`,
31675
+ "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.`,
31676
+ bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
31677
+ }[section];
31678
+ }
31679
+ async function siteOverview(read, site, range, before, pageContains) {
31680
+ const get = (dimensions, window, maxRows = 1e3) => read(site, {
31681
+ ...window,
31682
+ dimensions,
31683
+ type: "web",
31684
+ dataState: "final",
31685
+ maxRows,
31686
+ ...pageContains ? { dimensionFilterGroups: [{
31687
+ groupType: "and",
31688
+ filters: [{
31689
+ dimension: "page",
31690
+ operator: "contains",
31691
+ expression: pageContains
31692
+ }]
31693
+ }] } : {}
31694
+ });
31695
+ const [total, totalBefore, device, deviceBefore, pages, pagesBefore, queries, queriesBefore] = await Promise.all([
31696
+ get([], range),
31697
+ get([], before),
31698
+ get(["device"], range),
31699
+ get(["device"], before),
31700
+ get(["page"], range, 5e3),
31701
+ get(["page"], before, 5e3),
31702
+ get(["query"], range, 5e3),
31703
+ get(["query"], before, 5e3)
31704
+ ]);
31705
+ const row = (r) => r ?? {
31706
+ clicks: 0,
31707
+ impressions: 0,
31708
+ ctr: 0,
31709
+ position: 0
31710
+ };
31711
+ const now = row(total.rows[0]);
31712
+ const then = row(totalBefore.rows[0]);
31713
+ const compare = (current, earlier, name, limit) => {
31714
+ const old = new Map(earlier.map((r) => [r.keys[0], r]));
31715
+ const seen = new Set(current.map((r) => r.keys[0]));
31716
+ const rows = current.map((r) => {
31717
+ const earlierRow = old.get(r.keys[0]);
31718
+ return {
31719
+ [name]: r.keys[0],
31720
+ clicks: r.clicks,
31721
+ previousClicks: earlierRow?.clicks ?? 0,
31722
+ clicksChange: r.clicks - (earlierRow?.clicks ?? 0),
31723
+ impressions: r.impressions,
31724
+ previousImpressions: earlierRow?.impressions ?? 0,
31725
+ impressionsChange: r.impressions - (earlierRow?.impressions ?? 0),
31726
+ position: r.position
31727
+ };
31728
+ });
31729
+ for (const r of earlier) {
31730
+ if (seen.has(r.keys[0])) continue;
31731
+ rows.push({
31732
+ [name]: r.keys[0],
31733
+ clicks: 0,
31734
+ previousClicks: r.clicks,
31735
+ clicksChange: -r.clicks,
31736
+ impressions: 0,
31737
+ previousImpressions: r.impressions,
31738
+ impressionsChange: -r.impressions,
31739
+ position: 0
31740
+ });
31741
+ }
31742
+ const by = (key, sign) => [...rows].sort((a, b) => sign * (Number(b[key]) - Number(a[key]))).filter((r) => sign * Number(r[key]) > 0).slice(0, limit);
31743
+ return {
31744
+ top: rows.slice(0, limit),
31745
+ biggestLosses: by("clicksChange", -1),
31746
+ biggestGains: by("clicksChange", 1),
31747
+ biggestImpressionLosses: by("impressionsChange", -1)
31748
+ };
31749
+ };
31750
+ const devices = new Map(deviceBefore.rows.map((r) => [r.keys[0], r]));
31751
+ return {
31752
+ range,
31753
+ comparisonRange: before,
31754
+ ...pageContains ? { pageContains } : {},
31755
+ summary: {
31756
+ clicks: now.clicks,
31757
+ previousClicks: then.clicks,
31758
+ clicksChangePct: pct(now.clicks, then.clicks),
31759
+ impressions: now.impressions,
31760
+ previousImpressions: then.impressions,
31761
+ impressionsChangePct: pct(now.impressions, then.impressions),
31762
+ ctr: Math.round(now.ctr * 1e4) / 1e4,
31763
+ previousCtr: Math.round(then.ctr * 1e4) / 1e4,
31764
+ position: Math.round(now.position * 10) / 10,
31765
+ previousPosition: Math.round(then.position * 10) / 10
31766
+ },
31767
+ devices: device.rows.map((r) => ({
31768
+ device: r.keys[0],
31769
+ clicks: r.clicks,
31770
+ previousClicks: devices.get(r.keys[0])?.clicks ?? 0,
31771
+ impressions: r.impressions,
31772
+ position: r.position
31773
+ })),
31774
+ pages: compare(pages.rows, pagesBefore.rows, "page", 15),
31775
+ queries: compare(queries.rows, queriesBefore.rows, "query", 15),
31776
+ caveats: ["Totals come from Search Console with no dimension, so they include queries that Search Console hides for privacy.", "Page and query lists cover the top 5,000 rows in each period."]
31777
+ };
31778
+ }
31779
+ function overviewText(report, heading, origin, rowsPerList) {
31780
+ const range = report.range;
31781
+ const before = report.comparisonRange;
31782
+ const t = report.summary;
31783
+ const change = (value) => value === void 0 || value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
31784
+ const section = (name, key) => {
31785
+ const group = report[key === "page" ? "pages" : "queries"] ?? {};
31786
+ const columns = [
31787
+ key,
31788
+ "clicks",
31789
+ "previousClicks",
31790
+ "clicksChange",
31791
+ "impressions",
31792
+ "impressionsChange",
31793
+ "position"
31794
+ ];
31795
+ const part = (title, rows) => rows.length ? `${title}:\n${table$1(rows.slice(0, rowsPerList), columns, origin)}` : "";
31796
+ return [
31797
+ rowsPerList >= 10 ? part(`Top ${name} by clicks`, group.top ?? []) : "",
31798
+ part(`Biggest ${name} click losses`, group.biggestLosses ?? []) || `No ${name} lost clicks.`,
31799
+ part(`Biggest ${name} impression losses`, group.biggestImpressionLosses ?? []),
31800
+ part(`Biggest ${name} click gains`, group.biggestGains ?? [])
31801
+ ].filter(Boolean).join("\n\n");
31802
+ };
31803
+ return [
31804
+ `${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}${report.pageContains ? `, pages whose URL contains "${report.pageContains}"` : ""}.`,
31805
+ `Totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change(t.clicksChangePct)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change(t.impressionsChangePct)}), CTR ${t.ctr} (was ${t.previousCtr}), average position ${t.position} (was ${t.previousPosition}).`,
31806
+ `Devices:\n${table$1(report.devices, [
31807
+ "device",
31808
+ "clicks",
31809
+ "previousClicks",
31810
+ "impressions",
31811
+ "position"
31812
+ ], null)}`,
31813
+ section("pages", "page"),
31814
+ section("queries", "query"),
31815
+ `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
31816
+ ].join("\n\n");
31817
+ }
31818
+ function once(make) {
31819
+ let made;
31820
+ return () => {
31821
+ made ??= make();
31822
+ return made;
31823
+ };
31824
+ }
31825
+ async function enginesLine(overview, bing) {
31826
+ const summary = overview.summary;
31827
+ try {
31828
+ return enginesText(summary, await bingTotals(await bing(), overview.range, overview.comparisonRange));
31829
+ } catch (error) {
31830
+ return `Bing: could not be read. ${error instanceof Error ? error.message : String(error)}`;
31831
+ }
31832
+ }
31833
+ async function bingLead(overview, bing, origin, rowsPerList) {
31834
+ const range = overview.range;
31835
+ const before = overview.comparisonRange;
31836
+ const google = overview.summary.clicks;
31837
+ try {
31838
+ const source = await bing();
31839
+ if (!source.call) return null;
31840
+ const totals = await bingTotals(source, range, before);
31841
+ if ("missing" in totals || totals.current.clicks <= google) return null;
31842
+ const report = await bingOverview(source, range, before);
31843
+ return {
31844
+ text: `## Bing\n${bingOverviewText(report, `Bing sends more clicks than Google here, so here is Bing's overview of ${totals.site}`, origin, rowsPerList)}`,
31845
+ data: report
31846
+ };
31847
+ } catch {
31848
+ return null;
31849
+ }
31850
+ }
31851
+ async function build(read, site, section, range, now, bing) {
31852
+ const dependencies = {
31853
+ searchAnalytics: read,
31854
+ now
31855
+ };
31856
+ const common = {
31857
+ site,
31858
+ limit: 100,
31859
+ includeBrand: false,
31860
+ ...range
31861
+ };
31862
+ const run = {
31863
+ overview: () => siteOverview(read, site, range, earlierRange(range)),
31864
+ "quick-wins": () => quickWinsReportWith({
31865
+ ...common,
31866
+ verifyContent: false
31867
+ }, dependencies),
31868
+ "low-ctr": () => ctrUnderperformersReportWith(common, dependencies),
31869
+ "striking-distance": () => strikingDistanceWith({
31870
+ ...common,
31871
+ verifyContent: false
31872
+ }, dependencies),
31873
+ "second-page": () => secondPageWith({
31874
+ ...common,
31875
+ verifyContent: false
31876
+ }, dependencies),
31877
+ "content-decay": () => decayingReportWith({
31878
+ ...common,
31879
+ comparison: "previous-period"
31880
+ }, dependencies),
31881
+ cannibalization: async () => {
31882
+ const result = await cannibalReportWith(common, dependencies);
31883
+ const items = result.items ?? [];
31884
+ return {
31885
+ ...result,
31886
+ items: items.map((item) => {
31887
+ const pages = item.pages ?? [];
31888
+ return {
31889
+ ...item,
31890
+ urls: pages.map((page) => `${page.url} (${Math.round(page.impressionShare * 100)}%)`).join(" ; "),
31891
+ topPage: item.suggestedOwnerUrl
31892
+ };
31893
+ })
31894
+ };
31895
+ },
31896
+ "traffic-drop": async () => {
31897
+ const result = await trafficAnomalyWith({
31898
+ site,
31899
+ ...range,
31900
+ recentDays: 7
31901
+ }, dependencies);
31902
+ const window = changeWindow(result.anomalies);
31903
+ return {
31904
+ ...result,
31905
+ updateWindow: window && {
31906
+ ...window,
31907
+ paddingDays: 3
31908
+ },
31909
+ googleUpdates: await googleUpdates(window)
31910
+ };
31911
+ },
31912
+ "ai-queries": () => aiQueriesReport({
31913
+ site,
31914
+ ...range
31915
+ }, read),
31916
+ bing: async () => bingSection(await bing(), read, site, range)
31917
+ }[section];
31918
+ return factsOnly(await run());
31919
+ }
31920
+ function brandNote(report) {
31921
+ const brand = report.brand;
31922
+ if (!brand?.terms) return null;
31923
+ return `Brand terms left out: ${brand.terms.join(", ") || "none"}${brand.excluded?.length ? `. Not treated as brand: ${brand.excluded.map((item) => item.term).join(", ")}` : ""}.`;
31924
+ }
31925
+ function reportUrls(rows, origin) {
31926
+ const found = [];
31927
+ const visit = (value, key = "") => {
31928
+ if (found.length >= 20) return;
31929
+ if (typeof value === "string") {
31930
+ if (/^(url|page|finalUrl|targetUrl)$/i.test(key)) {
31931
+ const url = value.startsWith("/") ? `${origin}${value}` : value;
31932
+ if (/^https?:\/\//.test(url) && !found.includes(url)) found.push(url);
31933
+ }
31934
+ return;
31935
+ }
31936
+ if (Array.isArray(value)) for (const item of value) visit(item, key);
31937
+ else if (value && typeof value === "object") for (const [child, item] of Object.entries(value)) visit(item, child);
31938
+ };
31939
+ for (const row of rows) visit(row);
31940
+ return found;
28702
31941
  }
28703
- const MAX_HOPS = 5;
28704
- const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
28705
- async function liveCheck(url) {
28706
- const first = await facts(url);
28707
- const hops = [];
28708
- const seen = /* @__PURE__ */ new Set([url]);
28709
- let last = first;
28710
- while (last.redirects_to && last.status !== null) {
28711
- const kind = redirectKind(last.status);
28712
- if (!kind) break;
28713
- const to = last.redirects_to;
28714
- hops.push({
28715
- from: last.url,
28716
- status: last.status,
28717
- to,
28718
- kind
28719
- });
28720
- if (seen.has(to) || hops.length > MAX_HOPS) return {
28721
- first,
28722
- hops,
28723
- last: null
31942
+ function pageFactsTable(checked, origin) {
31943
+ const rows = table$1(checked, [
31944
+ "url",
31945
+ "status",
31946
+ "redirects_to",
31947
+ "title",
31948
+ "description",
31949
+ "h1",
31950
+ "canonical",
31951
+ "robots",
31952
+ ...checked.some((page) => page.error) ? ["error"] : []
31953
+ ], origin, 320);
31954
+ const note = temporaryRedirectNote(checked);
31955
+ return note ? `${rows}\n${note}` : rows;
31956
+ }
31957
+ const PAGE_SIZE = 50;
31958
+ async function allSections(input, deps) {
31959
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
31960
+ const read = sharedReader(deps.searchAnalytics);
31961
+ const origin = originOf(input.site);
31962
+ const bing = once(() => bingFor(deps, input.site));
31963
+ const ids = SECTION_IDS.filter((id) => id !== "bing" || deps.bing);
31964
+ const range = dateWindow(input, DEFAULT_DAYS.overview);
31965
+ const analytics = analyticsBeside(deps.analytics, input.site, range, earlierRange(range));
31966
+ const [overview, ...rest] = await Promise.all(ids.map((id) => build(read, input.site, id, dateWindow(input, DEFAULT_DAYS[id]), now, bing).catch((error) => error instanceof Error ? error : new Error(String(error)))));
31967
+ if (!overview || overview instanceof Error) throw overview ?? /* @__PURE__ */ new Error("The overview did not run.");
31968
+ const parts = [overviewText(overview, `Report on ${input.site}`, origin, 5)];
31969
+ parts.push(deps.bing ? await enginesLine(overview, bing) : NO_BING_HERE);
31970
+ const bingFirst = deps.bing ? await bingLead(overview, bing, origin, 5) : null;
31971
+ if (bingFirst) parts.push(bingFirst.text);
31972
+ const ga = await analytics;
31973
+ if (ga) parts.push(ga.text);
31974
+ const audit = deps.audit ? auditPreview(await deps.audit.snapshot(input.site)) : null;
31975
+ const brand = rest.find((run) => !(run instanceof Error) && brandNote(run) !== null);
31976
+ if (brand) parts.push(brandNote(brand));
31977
+ const sections = {};
31978
+ ids.slice(1).forEach((id, index) => {
31979
+ const run = rest[index];
31980
+ if (!run || run instanceof Error) {
31981
+ const message = run?.message ?? "did not run";
31982
+ parts.push(`## ${id}\nNot available: ${message}`);
31983
+ sections[id] = { error: message };
31984
+ return;
31985
+ }
31986
+ const shown = preview(run, origin, 3);
31987
+ sections[id] = { total: shown.total };
31988
+ parts.push([
31989
+ `## ${id}: ${shown.total} ${shown.total === 1 ? "row" : "rows"}`,
31990
+ SECTIONS[id],
31991
+ shown.text,
31992
+ id === "traffic-drop" ? listOfUpdates(run.googleUpdates, run.updateWindow) : null
31993
+ ].filter(Boolean).join("\n"));
31994
+ });
31995
+ if (deps.audit) {
31996
+ parts.push(`## ${AUDIT_SECTION}\n${audit ?? "This site is not in the weekly site audit."}`);
31997
+ sections[AUDIT_SECTION] = { line: audit };
31998
+ }
31999
+ const names = deps.audit ? [...ids, AUDIT_SECTION] : ids;
32000
+ parts.push(`More: ${deps.call("report", {
32001
+ site: input.site,
32002
+ section: "<section>"
32003
+ })} for its rows (${names.join(", ")}). Page sections also check their top 20 URLs live and merge same-site redirects.`);
32004
+ return {
32005
+ text: `${parts.join("\n\n")}\n\n${FACTS_ONLY}`,
32006
+ data: {
32007
+ site: input.site,
32008
+ overview,
32009
+ ...ga ? { analytics: ga.data } : {},
32010
+ ...bingFirst ? { bingOverview: bingFirst.data } : {},
32011
+ sections
32012
+ }
32013
+ };
32014
+ }
32015
+ async function oneSection(input, deps) {
32016
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
32017
+ const { site, section } = input;
32018
+ const detail = input.detail ?? "summary";
32019
+ const range = dateWindow(input, DEFAULT_DAYS[section]);
32020
+ const origin = originOf(site);
32021
+ const bing = once(() => bingFor(deps, site));
32022
+ let report = await build(sharedReader(deps.searchAnalytics), site, section, range, now, bing);
32023
+ const next = nextFor(section, deps.call);
32024
+ if (section === "overview") {
32025
+ const ga = await analyticsBeside(deps.analytics, site, range, earlierRange(range));
32026
+ if (ga) report.analytics = ga.data;
32027
+ const bingFirst = deps.bing ? await bingLead(report, bing, origin, 10) : null;
32028
+ if (bingFirst) report.bingOverview = bingFirst.data;
32029
+ if (detail !== "full") return {
32030
+ text: [
32031
+ overviewText(report, `overview of ${site}`, origin, 10),
32032
+ deps.bing ? await enginesLine(report, bing) : NO_BING_HERE,
32033
+ bingFirst?.text,
32034
+ ga?.text,
32035
+ `More: ${deps.call("report", {
32036
+ site,
32037
+ section,
32038
+ detail: "full"
32039
+ })} for every row.`,
32040
+ next,
32041
+ FACTS_ONLY
32042
+ ].filter(Boolean).join("\n\n"),
32043
+ data: report
28724
32044
  };
28725
- seen.add(to);
28726
- last = await facts(to);
28727
32045
  }
32046
+ let checked = [];
32047
+ const merged = [];
32048
+ if (PAGE_CHECKS.has(section)) {
32049
+ const list = Object.values(report).find((value) => Array.isArray(value) && value.length > 0);
32050
+ checked = await deps.checkPages(reportUrls(report.items ?? list ?? [], origin));
32051
+ const redirects = /* @__PURE__ */ new Map();
32052
+ for (const page of checked) {
32053
+ if (!page.redirects_to) continue;
32054
+ if (new URL(page.redirects_to).host !== new URL(page.url).host) continue;
32055
+ redirects.set(page.url, page.redirects_to.replace(/#.*$/, ""));
32056
+ merged.push(`${page.url} → ${page.redirects_to}`);
32057
+ }
32058
+ if (redirects.size) report = await build(sharedReader(deps.searchAnalytics, redirects), site, section, range, now, bing);
32059
+ report.pageChecks = checked;
32060
+ }
32061
+ const notes = [];
32062
+ const brand = brandNote(report);
32063
+ if (brand) notes.push(brand);
32064
+ const updates = listOfUpdates(report.googleUpdates, report.updateWindow);
32065
+ if (updates) notes.push(updates);
32066
+ if (checked.length) notes.push(`Live page checks (${checked.length} URLs, fetched now, no redirects followed):\n${pageFactsTable(checked, origin)}`);
32067
+ if (merged.length) notes.push(`Redirect sources merged into their targets, so each pair counts as one page here: ${merged.join("; ")}.`);
32068
+ const before = earlierRange(range);
32069
+ const shown = presentReport({
32070
+ report,
32071
+ headline: [`${section} on ${site}.`, typeof report.verdict === "string" ? report.verdict : ""].filter(Boolean).join(" "),
32072
+ label: section,
32073
+ range: section === "content-decay" ? `${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}` : `${range.startDate} to ${range.endDate}`,
32074
+ notes,
32075
+ origin,
32076
+ detail,
32077
+ offset: input.offset ?? 0,
32078
+ limit: PAGE_SIZE,
32079
+ more: (level, offset) => deps.call("report", {
32080
+ site,
32081
+ section,
32082
+ detail: level,
32083
+ offset
32084
+ })
32085
+ });
32086
+ const facts = section === "bing" ? FACTS_ONLY.replace("Search Console", "Search Console and Bing") : FACTS_ONLY;
28728
32087
  return {
28729
- first,
28730
- hops,
28731
- last
32088
+ text: `${shown.text}\n\n${next}\n\n${facts}`,
32089
+ data: detail === "full" ? report : shown.data
32090
+ };
32091
+ }
32092
+ function runReport(input, deps) {
32093
+ if (input.section === "weekly-audit") {
32094
+ const audit = deps.audit;
32095
+ if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
32096
+ return audit.snapshot(input.site).then((snapshot) => runAuditReport(input, snapshot, deps.call));
32097
+ }
32098
+ return input.section ? oneSection({
32099
+ ...input,
32100
+ section: input.section
32101
+ }, deps) : allSections(input, deps);
32102
+ }
32103
+ async function runBingReport(input, deps) {
32104
+ const range = dateWindow(input, DEFAULT_DAYS.overview);
32105
+ const before = earlierRange(range);
32106
+ const source = await bingFor(deps, input.site);
32107
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
32108
+ const [report, ga] = await Promise.all([bingOverview(source, range, before), analyticsBeside(deps.analytics, source.site, range, before)]);
32109
+ if (ga) report.analytics = ga.data;
32110
+ return {
32111
+ text: [
32112
+ bingOverviewText(report, `Bing report on ${source.site}. No Search Console property covers this site, so this report uses Bing`, originOf(source.site), 10),
32113
+ ga?.text,
32114
+ `More: ${deps.call("data", {
32115
+ source: "bing",
32116
+ site: input.site,
32117
+ dimensions: "page"
32118
+ })} for every Bing page, or the same with query.`,
32119
+ FACTS_ONLY.replace("Search Console", "Bing Webmaster Tools")
32120
+ ].filter(Boolean).join("\n\n"),
32121
+ data: report
28732
32122
  };
28733
32123
  }
28734
- //#endregion
28735
- //#region packages/core/src/commands/report.ts
28736
- const SECTIONS = {
28737
- 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.",
28738
- "quick-wins": "Query and page pairs at average positions 4 to 10 with a click-through rate below the site’s own curve for that position.",
28739
- "low-ctr": "Query and page pairs at average positions 1 to 4 with a click-through rate below the site’s own curve: titles and snippets to review with the person.",
28740
- "striking-distance": "Queries just outside the top positions, with impressions, for pages that already rank.",
28741
- "second-page": "Pages with an average position of 11 to 20 and their queries.",
28742
- "content-decay": "Query and page pairs that lost clicks against the previous period.",
28743
- cannibalization: "Queries where more than one page of the site gets impressions, with each page’s share.",
28744
- "traffic-drop": "Daily clicks and impressions checked for a sudden change that lasted, with the date it started (else the last 7 days against the days before), and Google updates within 3 days of that date. Overlap is timing only, not cause.",
28745
- "ai-queries": "Queries with the shape of AI conversations and agent searches (replies such as \"yes go on\", follow-ups, agent prompts, search operators, chat phrasing, ten or more words), with rewordings grouped into one question and its top page, impressions, clicks and position. A heuristic: Search Console does not mark AI Mode.",
28746
- bing: "Bing’s top queries from Bing Webmaster Tools with Google’s clicks, impressions and position for the same query beside them, and the same for pages."
28747
- };
28748
- const SECTION_IDS = Object.keys(SECTIONS);
28749
32124
  //#endregion
28750
32125
  //#region packages/core/src/commands/research.ts
28751
32126
  const RESEARCH_ACTIONS = [
@@ -28758,6 +32133,212 @@ const RESEARCH_ACTIONS = [
28758
32133
  ];
28759
32134
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
28760
32135
  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.`;
32136
+ const GENERIC_PLATFORMS = [
32137
+ "youtube.com",
32138
+ "facebook.com",
32139
+ "wikipedia.org",
32140
+ "reddit.com",
32141
+ "quora.com",
32142
+ "instagram.com",
32143
+ "linkedin.com",
32144
+ "x.com",
32145
+ "twitter.com",
32146
+ "pinterest.com",
32147
+ "tiktok.com",
32148
+ "amazon.com"
32149
+ ];
32150
+ const within = (domain, parent) => domain === parent || domain.endsWith(`.${parent}`);
32151
+ function realCompetitors(rows, target) {
32152
+ const self = bareDomain(target);
32153
+ const domainOf = (row) => bareDomain(String(row.domain ?? row.competitor_domain ?? ""));
32154
+ const isSelf = (domain) => self !== "" && within(domain, self);
32155
+ const isPlatform = (domain) => GENERIC_PLATFORMS.some((name) => within(domain, name));
32156
+ const domains = rows.map(domainOf);
32157
+ const own = domains.filter(isSelf);
32158
+ const platforms = domains.filter((d) => !isSelf(d) && isPlatform(d));
32159
+ const gone = own.length + platforms.length;
32160
+ if (!gone) return {
32161
+ rows,
32162
+ note: null
32163
+ };
32164
+ const reasons = [own.length ? `the target itself (${own.join(", ")})` : "", platforms.length ? `generic platforms that share keywords with most sites (${platforms.join(", ")})` : ""].filter(Boolean);
32165
+ return {
32166
+ rows: rows.filter((row) => {
32167
+ const domain = domainOf(row);
32168
+ return !isSelf(domain) && !isPlatform(domain);
32169
+ }),
32170
+ note: `Left out ${gone} ${gone === 1 ? "row" : "rows"}: ${reasons.join(" and ")}. The fixed platform list is ${GENERIC_PLATFORMS.join(", ")}.`
32171
+ };
32172
+ }
32173
+ async function runResearch(input, keys, options) {
32174
+ const country = input.country ?? "us";
32175
+ const target = input.target ?? "";
32176
+ const offset = input.offset ?? 0;
32177
+ const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
32178
+ if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
32179
+ const { rows: found, costUsd, partial } = await research(input.provider, keys, {
32180
+ action: input.action,
32181
+ keywords: input.keywords ?? [],
32182
+ target,
32183
+ country,
32184
+ limit: Math.min(1e3, Math.max(100, offset + 50))
32185
+ });
32186
+ const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
32187
+ rows: found,
32188
+ note: null
32189
+ };
32190
+ const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
32191
+ const rated = await withDomainRating(rows, drKey);
32192
+ const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
32193
+ const hasTargetDr = targetDr !== void 0 && targetDr !== null;
32194
+ return {
32195
+ text: rowsText({
32196
+ title: `${input.provider} ${input.action}${target ? ` for ${target}` : ""}, ${country}${language ? `, language ${language}` : ""}.`,
32197
+ rows: rated.rows,
32198
+ offset,
32199
+ limit: 50,
32200
+ detail: input.detail ?? "summary",
32201
+ notes: [
32202
+ `${input.provider} estimates, in the provider’s own units.`,
32203
+ ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
32204
+ ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
32205
+ ...note ? [note] : [],
32206
+ ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
32207
+ ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
32208
+ ],
32209
+ more: options.call
32210
+ }),
32211
+ data: {
32212
+ provider: input.provider,
32213
+ action: input.action,
32214
+ target: target || null,
32215
+ country,
32216
+ language,
32217
+ targetDomainRating: hasTargetDr ? targetDr : null,
32218
+ attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
32219
+ leftOut: note,
32220
+ costUsd,
32221
+ partial,
32222
+ total: rated.rows.length,
32223
+ offset,
32224
+ rows: rated.rows.slice(offset, offset + 50)
32225
+ }
32226
+ };
32227
+ }
32228
+ //#endregion
32229
+ //#region packages/core/src/commands/sitemap-urls.ts
32230
+ const MAX_FILES = 20;
32231
+ const MAX_URLS = 5e4;
32232
+ const MAX_BYTES$1 = 2e7;
32233
+ const TIMEOUT_MS$1 = 1e4;
32234
+ async function boundedBytes(body) {
32235
+ if (!body) return /* @__PURE__ */ new Uint8Array();
32236
+ const reader = body.getReader();
32237
+ const chunks = [];
32238
+ let total = 0;
32239
+ while (true) {
32240
+ const { done, value } = await reader.read();
32241
+ if (done) break;
32242
+ total += value.byteLength;
32243
+ if (total > MAX_BYTES$1) {
32244
+ await reader.cancel();
32245
+ return null;
32246
+ }
32247
+ chunks.push(value);
32248
+ }
32249
+ const bytes = new Uint8Array(total);
32250
+ let offset = 0;
32251
+ for (const chunk of chunks) {
32252
+ bytes.set(chunk, offset);
32253
+ offset += chunk.byteLength;
32254
+ }
32255
+ return bytes;
32256
+ }
32257
+ async function get(url) {
32258
+ const response = await fetch(url, {
32259
+ headers: { "user-agent": SEO_CRAWLER_USER_AGENT },
32260
+ signal: AbortSignal.timeout(TIMEOUT_MS$1)
32261
+ });
32262
+ if (!response.ok) {
32263
+ await response.body?.cancel();
32264
+ throw new Error(`answered ${response.status}`);
32265
+ }
32266
+ let bytes = await boundedBytes(response.body);
32267
+ if (bytes && bytes[0] === 31 && bytes[1] === 139) bytes = await boundedBytes(new Response(bytes).body?.pipeThrough(new DecompressionStream("gzip")) ?? null);
32268
+ if (!bytes) throw new Error(`is larger than ${MAX_BYTES$1} bytes`);
32269
+ return new TextDecoder().decode(bytes);
32270
+ }
32271
+ const ENTITIES = {
32272
+ amp: "&",
32273
+ lt: "<",
32274
+ gt: ">",
32275
+ quot: "\"",
32276
+ apos: "'"
32277
+ };
32278
+ function locs(xml) {
32279
+ return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?\s*<\/loc>/gi)].map((match) => (match[1] ?? "").trim().replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => ENTITIES[name] ?? ""));
32280
+ }
32281
+ const inside = (site, url) => {
32282
+ try {
32283
+ return assertUrlMatchesGscProperty(site, url);
32284
+ } catch {
32285
+ return null;
32286
+ }
32287
+ };
32288
+ async function sitemapUrls(site) {
32289
+ const origin = new URL(originOf(site)).origin;
32290
+ const notes = [];
32291
+ const declared = [...(await get(`${origin}/robots.txt`).catch(() => "")).matchAll(/^\s*sitemap:\s*(\S+)/gim)].map((match) => match[1] ?? "").filter(Boolean);
32292
+ const queue = declared.length ? declared : [`${origin}/sitemap.xml`];
32293
+ const seenFiles = /* @__PURE__ */ new Set();
32294
+ const urls = /* @__PURE__ */ new Set();
32295
+ let files = 0;
32296
+ let partial = false;
32297
+ while (queue.length) {
32298
+ const file = queue.shift();
32299
+ if (seenFiles.has(file)) continue;
32300
+ seenFiles.add(file);
32301
+ if (files >= MAX_FILES) {
32302
+ partial = true;
32303
+ notes.push(`stopped at ${MAX_FILES} sitemap files`);
32304
+ break;
32305
+ }
32306
+ files += 1;
32307
+ let xml;
32308
+ try {
32309
+ xml = await get(file);
32310
+ } catch (error) {
32311
+ if (!declared.length && files === 1) {
32312
+ notes.push(`robots.txt names no sitemap and ${file} ${error instanceof Error ? error.message : "could not be read"}`);
32313
+ continue;
32314
+ }
32315
+ partial = true;
32316
+ notes.push(`${file} ${error instanceof Error ? error.message : "could not be read"}`);
32317
+ continue;
32318
+ }
32319
+ if (/<sitemapindex[\s>]/i.test(xml)) {
32320
+ queue.push(...locs(xml));
32321
+ continue;
32322
+ }
32323
+ for (const loc of locs(xml)) {
32324
+ const url = inside(site, loc);
32325
+ if (!url) continue;
32326
+ if (urls.size >= MAX_URLS) {
32327
+ partial = true;
32328
+ notes.push(`stopped at ${MAX_URLS.toLocaleString("en-US")} URLs`);
32329
+ queue.length = 0;
32330
+ break;
32331
+ }
32332
+ urls.add(url);
32333
+ }
32334
+ }
32335
+ return {
32336
+ urls: [...urls],
32337
+ files,
32338
+ partial,
32339
+ notes: [...new Set(notes)]
32340
+ };
32341
+ }
28761
32342
  //#endregion
28762
32343
  //#region packages/core/src/commands/site.ts
28763
32344
  function indexStatusFrom(result) {
@@ -28778,8 +32359,320 @@ function indexStatusFrom(result) {
28778
32359
  sitemaps: list(status.sitemap)
28779
32360
  };
28780
32361
  }
28781
- const LOST_PAGES_RULE = `Pages that had at least 3 clicks in the previous 28 days and kept ${.2 * 100}% or less of them in the last 28 days, with a live check of the top 20 and Google’s index status for the top 10 and the home page.`;
32362
+ async function runPage(input, deps) {
32363
+ const range = dateWindow(input, 28);
32364
+ const before = earlierRange(range);
32365
+ const query = (window) => deps.searchAnalytics(input.site, {
32366
+ ...window,
32367
+ dimensions: ["query"],
32368
+ type: "web",
32369
+ maxRows: 5e3,
32370
+ dimensionFilterGroups: [{
32371
+ groupType: "and",
32372
+ filters: [{
32373
+ dimension: "page",
32374
+ operator: "equals",
32375
+ expression: input.url
32376
+ }]
32377
+ }]
32378
+ });
32379
+ const full = input.detail === "full";
32380
+ const [live, indexed, sitemap, now, then, markdown] = await Promise.all([
32381
+ deps.liveCheck(input.url),
32382
+ deps.inspect(input.site, input.url).catch(() => null),
32383
+ sitemapUrls(input.site),
32384
+ query(range),
32385
+ query(before),
32386
+ full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
32387
+ ]);
32388
+ const facts = live.first;
32389
+ const reason = indexed ? indexingRow({
32390
+ url: input.url,
32391
+ status: indexed,
32392
+ live,
32393
+ inSitemap: sitemap.urls.includes(input.url),
32394
+ sitemap: new Set(sitemap.urls)
32395
+ }, [], (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString()) : null;
32396
+ const old = new Map(then.rows.map((row) => [row.keys[0], row]));
32397
+ const seen = /* @__PURE__ */ new Set();
32398
+ const rows = now.rows.map((row) => {
32399
+ seen.add(row.keys[0] ?? "");
32400
+ const earlier = old.get(row.keys[0]);
32401
+ return {
32402
+ query: row.keys[0],
32403
+ clicks: row.clicks,
32404
+ previous_clicks: earlier?.clicks ?? 0,
32405
+ clicks_change: row.clicks - (earlier?.clicks ?? 0),
32406
+ impressions: row.impressions,
32407
+ ctr: row.ctr,
32408
+ position: row.position,
32409
+ previous_position: earlier?.position ?? null
32410
+ };
32411
+ });
32412
+ for (const row of then.rows) {
32413
+ if (seen.has(row.keys[0] ?? "")) continue;
32414
+ rows.push({
32415
+ query: row.keys[0],
32416
+ clicks: 0,
32417
+ previous_clicks: row.clicks,
32418
+ clicks_change: -row.clicks,
32419
+ impressions: 0,
32420
+ ctr: 0,
32421
+ position: null,
32422
+ previous_position: row.position
32423
+ });
32424
+ }
32425
+ rows.sort((a, b) => Number(b.clicks) - Number(a.clicks) || Number(b.impressions) - Number(a.impressions));
32426
+ const sum = (list, key) => list.reduce((total, row) => total + row[key], 0);
32427
+ const offset = input.offset ?? 0;
32428
+ return {
32429
+ text: [
32430
+ rowsText({
32431
+ title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
32432
+ notes: [
32433
+ `Live page (fetched now):\n${pageFactsTable([facts], null)}${live.hops.length ? `\nRedirects: ${live.hops.map((hop) => `${hop.status} to ${hop.to}`).join(", ")}${live.last ? `, which returns ${live.last.status ?? live.last.error}` : ", then loop or pass 5 hops"}.` : ""}`,
32434
+ indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
32435
+ ...reason ? [`Google's reason: ${reason.reason}. Label: ${reason.label}. ${reason.why} (Labels are rules over this evidence, not Google's; ${deps.call("index")} explains them.)`] : [],
32436
+ `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
32437
+ deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
32438
+ url: input.url,
32439
+ detail: "full"
32440
+ })}.` : `Main content as Markdown: only in the CLI and the local MCP server, with \`npx -y seo page --url ${input.url} --detail full\`.`
32441
+ ],
32442
+ rows,
32443
+ offset,
32444
+ limit: 50,
32445
+ detail: input.detail ?? "summary",
32446
+ more: (next) => deps.call("page", {
32447
+ url: input.url,
32448
+ detail: "rows",
32449
+ offset: next
32450
+ })
32451
+ }),
32452
+ ...full ? [markdownText(markdown, input.url)] : [],
32453
+ `Next: ${deps.call("research")} serp with the top query to see who else ranks.`,
32454
+ FACTS_ONLY
32455
+ ].join("\n\n"),
32456
+ data: {
32457
+ url: input.url,
32458
+ range,
32459
+ comparisonRange: before,
32460
+ page: facts,
32461
+ index: indexed,
32462
+ reason,
32463
+ totalQueries: rows.length,
32464
+ queries: rows.slice(offset, offset + 50),
32465
+ ...full ? { markdown } : {}
32466
+ }
32467
+ };
32468
+ }
32469
+ function markdownText(markdown, url) {
32470
+ if (!markdown) return `Main content as Markdown: not on this server; it runs in the CLI and the local MCP server. ${CLI_LINK} Then run \`npx -y seo page --url ${url} --detail full\`.`;
32471
+ if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
32472
+ if (!markdown.markdown) return "Main content as Markdown: the page has no main content to read.";
32473
+ return `Main content as Markdown (live page${markdown.truncated ? `, cut to ${markdown.markdown.length.toLocaleString("en-US")} of ${markdown.characters.toLocaleString("en-US")} characters` : ""}):\n\n${markdown.markdown}`;
32474
+ }
32475
+ async function runCompare(input, deps) {
32476
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) throw new CommandError("INVALID_INPUT", "The date must be YYYY-MM-DD.");
32477
+ const last = latestFinalGscDate(deps.now?.() ?? /* @__PURE__ */ new Date());
32478
+ const daysAfter = Math.round((Date.parse(last) - Date.parse(input.date)) / 864e5) + 1;
32479
+ if (daysAfter < 3) throw new CommandError("INVALID_INPUT", `Search Console has final data to ${last}. Use a date at least 3 days before that.`);
32480
+ const days = Math.min(28, daysAfter);
32481
+ const after = {
32482
+ startDate: input.date,
32483
+ endDate: shiftIsoDate(input.date, days - 1)
32484
+ };
32485
+ const before = {
32486
+ startDate: shiftIsoDate(input.date, -days),
32487
+ endDate: shiftIsoDate(input.date, -1)
32488
+ };
32489
+ const [report, updates, analytics] = await Promise.all([
32490
+ siteOverview(sharedReader(deps.searchAnalytics), input.site, after, before, input.pages),
32491
+ googleUpdates({
32492
+ start: before.startDate,
32493
+ end: after.endDate
32494
+ }),
32495
+ analyticsBeside(deps.analytics, input.site, after, before, input.pages)
32496
+ ]);
32497
+ return {
32498
+ text: [
32499
+ overviewText(report, `Compare on ${input.site}: ${days} days after ${input.date}`, originOf(input.site), 10),
32500
+ analytics?.text,
32501
+ listOfUpdates(updates),
32502
+ `Next: ${deps.call("page")} for a page that moved.`,
32503
+ FACTS_ONLY
32504
+ ].filter(Boolean).join("\n\n"),
32505
+ data: {
32506
+ ...report,
32507
+ ...analytics ? { analytics: analytics.data } : {},
32508
+ googleUpdates: updates
32509
+ }
32510
+ };
32511
+ }
32512
+ const LOST_SHARE = .2;
32513
+ const MIN_LOST = 3;
32514
+ const CHECKED = 20;
32515
+ const INSPECTED = 10;
32516
+ const SITE_WIDE_MIN = 5;
32517
+ const PAGE_ROWS = 25e3;
32518
+ function indexSummary(inspected, home) {
32519
+ const known = inspected.filter((item) => item?.verdict != null);
32520
+ const indexed = known.filter((item) => item.verdict === "PASS").length;
32521
+ const coverage = /* @__PURE__ */ new Map();
32522
+ for (const item of known) {
32523
+ if (item.verdict === "PASS") continue;
32524
+ const state = item.coverage ?? item.verdict ?? "unknown";
32525
+ coverage.set(state, (coverage.get(state) ?? 0) + 1);
32526
+ }
32527
+ const notIndexed = known.length - indexed;
32528
+ const homeIndexed = home?.status?.verdict ? home.status.verdict === "PASS" : null;
32529
+ const siteWide = notIndexed >= SITE_WIDE_MIN && notIndexed * 2 > known.length && homeIndexed;
32530
+ const byCoverage = [...coverage].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([state, count]) => ({
32531
+ state,
32532
+ count
32533
+ }));
32534
+ return {
32535
+ text: (known.length ? [`Index status of the ${known.length} inspected pages: ${indexed} indexed, ${notIndexed} not indexed${byCoverage.length ? ` (${byCoverage.map((item) => `${item.count} ${item.state}`).join("; ")})` : ""}.${home && homeIndexed !== null ? ` Home page ${home.url}: ${homeIndexed ? "indexed" : `not indexed (${home.status?.coverage ?? home.status?.verdict})`}.` : ""}`, siteWide ? `Site-wide pattern: ${notIndexed} of ${known.length} inspected pages are not in Google's index, but the home page is. This points to the whole site, not single pages. It does not show the cause.` : null] : []).filter(Boolean).join("\n"),
32536
+ data: {
32537
+ inspected: known.length,
32538
+ indexed,
32539
+ notIndexed,
32540
+ byCoverage,
32541
+ homePage: home && homeIndexed !== null ? {
32542
+ url: home.url,
32543
+ indexed: homeIndexed
32544
+ } : null,
32545
+ siteWide: Boolean(siteWide)
32546
+ }
32547
+ };
32548
+ }
32549
+ const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED} and the home page.`;
28782
32550
  const INDEXING_RULE = `Google’s indexing reasons for 100 known URLs a run in a fixed order (lost-click pages, Search Console pages by clicks then impressions, sitemap URLs, then the rest), each checked live and labelled fix, check, expected or indexed from the evidence, sorted by reason then URL.`;
32551
+ async function runLostPages(input, deps) {
32552
+ const range = dateWindow(input, 28);
32553
+ const before = earlierRange(range);
32554
+ const read = (window) => deps.searchAnalytics(input.site, {
32555
+ ...window,
32556
+ dimensions: ["page"],
32557
+ type: "web",
32558
+ maxRows: PAGE_ROWS
32559
+ });
32560
+ const [now, then, sitemap] = await Promise.all([
32561
+ read(range),
32562
+ read(before),
32563
+ sitemapUrls(input.site)
32564
+ ]);
32565
+ const current = new Map(now.rows.map((row) => [row.keys[0], row.clicks]));
32566
+ const lost = then.rows.filter((row) => row.clicks >= MIN_LOST && !row.keys[0]?.includes("#")).map((row) => ({
32567
+ url: row.keys[0] ?? "",
32568
+ previous_clicks: row.clicks,
32569
+ clicks: current.get(row.keys[0]) ?? 0
32570
+ })).filter((row) => row.clicks <= row.previous_clicks * LOST_SHARE).sort((a, b) => b.previous_clicks - b.clicks - (a.previous_clicks - a.clicks) || a.url.localeCompare(b.url));
32571
+ const top = lost.slice(0, CHECKED);
32572
+ const first = top[0]?.url ?? sitemap.urls[0] ?? now.rows[0]?.keys[0];
32573
+ const homeUrl = first ? `${new URL(first).origin}/` : null;
32574
+ const known = knownUrls({
32575
+ lost: lost.map((row) => row.url),
32576
+ home: homeUrl,
32577
+ sitemap,
32578
+ pages: now.rows.map((row) => ({
32579
+ url: row.keys[0] ?? "",
32580
+ clicks: row.clicks,
32581
+ impressions: row.impressions
32582
+ })),
32583
+ pagesCapped: now.rows.length >= PAGE_ROWS
32584
+ });
32585
+ const [facts, run] = await Promise.all([deps.checkPages(top.map((row) => row.url)), inspectKnown(input.site, known, deps, input.maxUrls)]);
32586
+ const status = (url) => url ? run.statuses.get(url) ?? null : null;
32587
+ const summary = indexSummary(top.slice(0, INSPECTED).filter((row) => row.url !== homeUrl).map((row) => status(row.url)), top.length && homeUrl ? {
32588
+ url: homeUrl,
32589
+ status: status(homeUrl)
32590
+ } : null);
32591
+ const rows = top.map((row, index) => {
32592
+ const indexed = index < INSPECTED ? status(row.url) : null;
32593
+ return {
32594
+ ...row,
32595
+ status: facts[index]?.status ?? null,
32596
+ redirects_to: facts[index]?.redirects_to ?? null,
32597
+ robots: facts[index]?.robots ?? null,
32598
+ canonical: facts[index]?.canonical ?? null,
32599
+ verdict: indexed?.verdict ?? null,
32600
+ coverage: indexed?.coverage ?? null,
32601
+ google_canonical: indexed?.google_canonical ?? null
32602
+ };
32603
+ });
32604
+ const detail = input.detail ?? "summary";
32605
+ const offset = input.offset ?? 0;
32606
+ const origin = originOf(input.site);
32607
+ const firstFix = run.rows.find((row) => row.label === "fix");
32608
+ const reasons = indexingText(input.site, run, {
32609
+ summary: detail === "summary",
32610
+ offset,
32611
+ size: detail === "summary" ? 25 : 50,
32612
+ more: (next) => deps.call("index", {
32613
+ site: input.site,
32614
+ max_urls: input.maxUrls,
32615
+ detail: "rows",
32616
+ offset: next
32617
+ })
32618
+ }, origin, deps.inspectionQuota ? (count) => deps.call("index", {
32619
+ site: input.site,
32620
+ max_urls: count
32621
+ }) : null, deps.now?.() ?? /* @__PURE__ */ new Date());
32622
+ const lostText = [
32623
+ `Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
32624
+ `${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
32625
+ rows.length ? table$1(rows, Object.keys(rows[0] ?? {}), origin) : "No pages lost their clicks.",
32626
+ summary.text,
32627
+ "Live checks are fetched now with no redirects followed. Index status is from URL Inspection.",
32628
+ temporaryRedirectNote(rows)
32629
+ ];
32630
+ const indexing = {
32631
+ rule: INDEXING_RULE,
32632
+ known: {
32633
+ total: known.urls.length,
32634
+ sitemap: sitemap.urls.length,
32635
+ sitemapFiles: sitemap.files,
32636
+ sitemapPartial: sitemap.partial,
32637
+ searchConsole: known.searchConsole,
32638
+ searchConsoleCapped: known.searchConsoleCapped,
32639
+ lostClicks: known.lostClicks
32640
+ },
32641
+ limit: run.limit,
32642
+ inspected: run.inspected,
32643
+ fromSaved: run.fromSaved,
32644
+ oldestSaved: run.oldestSaved,
32645
+ failed: run.failed,
32646
+ stoppedBy: run.stoppedBy,
32647
+ quota: run.quota,
32648
+ byReason: byReason(run.rows),
32649
+ rows: run.rows
32650
+ };
32651
+ return {
32652
+ text: [...detail === "full" ? [`Google's indexing reasons for ${input.site}, every inspected URL as JSON, sorted by reason then URL:`, JSON.stringify(indexing)] : [
32653
+ ...detail === "summary" ? lostText : [],
32654
+ ...reasons,
32655
+ `To see what changed, keep this run's full result (${deps.call("index", {
32656
+ site: input.site,
32657
+ max_urls: input.maxUrls,
32658
+ detail: "full"
32659
+ })}) and compare it with the next run by url and reason. Inspect the same number each week: the order is fixed, so the same first URLs come back. Where results are saved (the CLI and the local MCP), a URL inspected in the last 7 days is reused; any other URL is inspected again and uses URL Inspection quota.`,
32660
+ `Next: ${firstFix ? deps.call("page", { url: firstFix.url }) : deps.call("page")} for one URL, including a URL from a Search Console email that is not in this set; ${deps.call("report", {
32661
+ site: input.site,
32662
+ section: "content-decay"
32663
+ })} for pages that lost part of their clicks.`
32664
+ ], FACTS_ONLY].filter(Boolean).join("\n\n"),
32665
+ data: {
32666
+ range,
32667
+ comparisonRange: before,
32668
+ rule: LOST_PAGES_RULE,
32669
+ lostPages: lost.length,
32670
+ index: summary.data,
32671
+ pages: rows,
32672
+ indexing
32673
+ }
32674
+ };
32675
+ }
28783
32676
  //#endregion
28784
32677
  //#region packages/core/src/commands/tools.ts
28785
32678
  const site = z.string().min(1).describe("Search Console property or site, such as sc-domain:example.com, https://example.com/ or example.com.");
@@ -28801,44 +32694,249 @@ const DATA_SOURCES = [
28801
32694
  "bing",
28802
32695
  "clicky"
28803
32696
  ];
28804
- z.object({}), `${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}`, z.object({
28805
- site,
28806
- section: z.enum(SECTION_IDS).optional().describe("Optional. Leave it out first; then ask for one section."),
28807
- ...dates,
28808
- ...detail
28809
- }), z.object({
28810
- url: z.url(),
28811
- ...dates,
28812
- ...detail
28813
- }), z.object({
28814
- site,
28815
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD: the first day after the change."),
28816
- pages: z.string().min(1).optional().describe("Optional URL text, such as /blog/.")
28817
- }), `${INDEXING_RULE}${LOST_PAGES_RULE}`, z.object({
28818
- site,
28819
- ...dates,
28820
- ...detail
28821
- }), z.object({
28822
- site,
28823
- ...dates,
28824
- max_urls: z.number().int().min(1).max(MAX_INSPECT_LIMIT).optional().describe(`URLs to inspect with Google, 100 by default, up to the URL Inspection quota left today.`),
28825
- ...detail
28826
- }), `${RESEARCH_HELP}`, z.object({
28827
- provider: z.enum(RESEARCH_PROVIDERS),
28828
- action: z.enum(RESEARCH_ACTIONS),
28829
- keywords: z.array(z.string().min(1)).max(100).default([]),
28830
- target: z.string().default("").describe("Domain or URL."),
28831
- country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
28832
- ...detail
28833
- }), z.object({
28834
- source: z.enum(DATA_SOURCES).default("search_console"),
28835
- site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites. clicky: the site or its Clicky site ID."),
28836
- dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
28837
- page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
28838
- query: z.string().min(1).optional().describe("search_console only: query text."),
28839
- ...dates,
28840
- ...detail
28841
- });
32697
+ function tool(spec) {
32698
+ return spec;
32699
+ }
32700
+ const SHARED_TOOLS = [
32701
+ tool({
32702
+ name: "seo_sites",
32703
+ command: "data",
32704
+ title: "List sites",
32705
+ description: "Lists the Search Console and Google Analytics properties the connected Google accounts can read, the Bing Webmaster site for each, and which research keys are set. Call it first.",
32706
+ input: z.object({}),
32707
+ run: async (surface) => runSites({
32708
+ accounts: await surface.accounts(),
32709
+ keys: await surface.keys(),
32710
+ clicky: clickySites(await surface.keys()).map(clickyLabel),
32711
+ signIn: surface.signIn,
32712
+ bing: surface.deps.bing ? await surface.deps.bing() : void 0,
32713
+ connect: surface.connect
32714
+ })
32715
+ }),
32716
+ tool({
32717
+ name: "seo_report",
32718
+ command: "report",
32719
+ title: "Search Console report",
32720
+ description: `The main report for a Search Console property. Without a section it returns the overview plus each section's row count and top rows. With a section it returns that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}`,
32721
+ input: z.object({
32722
+ site,
32723
+ section: z.enum(SECTION_IDS).optional().describe("Optional. Leave it out first; then ask for one section."),
32724
+ ...dates,
32725
+ ...detail
32726
+ }),
32727
+ run: async (surface, input) => {
32728
+ const resolved = await surface.resolveSite(input.site).catch((error) => error instanceof Error ? error : new Error(String(error)));
32729
+ if (resolved instanceof Error && !input.section && surface.deps.bing) return runBingReport({
32730
+ site: input.site,
32731
+ startDate: input.start_date,
32732
+ endDate: input.end_date
32733
+ }, surface.deps).catch(() => {
32734
+ throw resolved;
32735
+ });
32736
+ if (resolved instanceof Error && input.section !== "bing") throw resolved;
32737
+ return runReport({
32738
+ site: resolved instanceof Error ? input.site : resolved,
32739
+ section: input.section,
32740
+ startDate: input.start_date,
32741
+ endDate: input.end_date,
32742
+ detail: input.detail,
32743
+ offset: input.offset
32744
+ }, surface.deps);
32745
+ }
32746
+ }),
32747
+ tool({
32748
+ name: "seo_page",
32749
+ command: "page",
32750
+ title: "One page",
32751
+ description: "Everything for one URL: live page facts (status, redirect, title, description, H1, canonical, robots), Google’s index status from URL Inspection, and the page’s Search Console queries against the previous period.",
32752
+ input: z.object({
32753
+ url: z.url(),
32754
+ ...dates,
32755
+ ...detail
32756
+ }),
32757
+ run: async (surface, input) => runPage({
32758
+ site: await surface.resolveSite(input.url),
32759
+ url: input.url,
32760
+ startDate: input.start_date,
32761
+ endDate: input.end_date,
32762
+ detail: input.detail,
32763
+ offset: input.offset
32764
+ }, surface.deps)
32765
+ }),
32766
+ tool({
32767
+ name: "seo_compare",
32768
+ command: "compare",
32769
+ title: "Before and after a date",
32770
+ description: "Compares equal periods before and after a date, such as a site change or a Google update: totals, devices, and the pages and queries that moved, Google Analytics sessions from the property whose web stream matches the site, and Google updates in the window. Optional pages narrows it to URLs that contain that text, such as /blog/.",
32771
+ input: z.object({
32772
+ site,
32773
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD: the first day after the change."),
32774
+ pages: z.string().min(1).optional().describe("Optional URL text, such as /blog/.")
32775
+ }),
32776
+ run: async (surface, input) => runCompare({
32777
+ site: await surface.resolveSite(input.site),
32778
+ date: input.date,
32779
+ pages: input.pages
32780
+ }, surface.deps)
32781
+ }),
32782
+ tool({
32783
+ name: "seo_index",
32784
+ command: "index",
32785
+ title: "Google indexing reasons and pages that lost their clicks",
32786
+ description: `${INDEXING_RULE} Use it for a first baseline and then weekly: keep the full result and compare by URL and reason to find new reasons. Also: ${LOST_PAGES_RULE}`,
32787
+ input: z.object({
32788
+ site,
32789
+ ...dates,
32790
+ ...detail
32791
+ }),
32792
+ localInput: z.object({
32793
+ site,
32794
+ ...dates,
32795
+ max_urls: z.number().int().min(1).max(MAX_INSPECT_LIMIT).optional().describe(`URLs to inspect with Google, 100 by default, up to the URL Inspection quota left today.`),
32796
+ ...detail
32797
+ }),
32798
+ run: async (surface, input) => runLostPages({
32799
+ site: await surface.resolveSite(input.site),
32800
+ startDate: input.start_date,
32801
+ endDate: input.end_date,
32802
+ detail: input.detail,
32803
+ offset: input.offset,
32804
+ maxUrls: input.max_urls
32805
+ }, surface.deps)
32806
+ }),
32807
+ tool({
32808
+ name: "seo_research",
32809
+ command: "research",
32810
+ title: "Keyword, competitor, backlink and AI answer data",
32811
+ 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}`,
32812
+ input: z.object({
32813
+ provider: z.enum(RESEARCH_PROVIDERS),
32814
+ action: z.enum(RESEARCH_ACTIONS),
32815
+ keywords: z.array(z.string().min(1)).max(100).default([]),
32816
+ target: z.string().default("").describe("Domain or URL."),
32817
+ country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
32818
+ ...detail
32819
+ }),
32820
+ run: async (surface, input) => {
32821
+ const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
32822
+ if (input.action === "ai_answers") {
32823
+ if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
32824
+ return runAiAnswers({
32825
+ site: await surface.resolveSite(input.target),
32826
+ country: input.country,
32827
+ detail: input.detail,
32828
+ offset: input.offset
32829
+ }, await surface.keys(), surface.deps, more);
32830
+ }
32831
+ return runResearch(input, await surface.keys(), {
32832
+ drKey: surface.drKey,
32833
+ call: more
32834
+ });
32835
+ }
32836
+ }),
32837
+ tool({
32838
+ name: "seo_data",
32839
+ command: "data",
32840
+ title: "Raw rows",
32841
+ description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. clicky: landing-page visits, traffic sources and search engines from a Clicky site the person saved (site is its domain or Clicky site ID; at most 31 days).`,
32842
+ input: z.object({
32843
+ source: z.enum(DATA_SOURCES).default("search_console"),
32844
+ site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites. clicky: the site or its Clicky site ID."),
32845
+ dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
32846
+ page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
32847
+ query: z.string().min(1).optional().describe("search_console only: query text."),
32848
+ ...dates,
32849
+ ...detail
32850
+ }),
32851
+ run: async (surface, input) => {
32852
+ if (input.source === "clicky") return runClickyRows({
32853
+ site: input.site,
32854
+ startDate: input.start_date,
32855
+ endDate: input.end_date,
32856
+ detail: input.detail,
32857
+ offset: input.offset
32858
+ }, await surface.keys(), {
32859
+ connect: surface.connect,
32860
+ call: (offset) => `seo_data with source "clicky", site "${input.site}", detail "rows", offset ${offset}`,
32861
+ refresh: surface.refreshClicky
32862
+ });
32863
+ if (input.source === "bing") return runBingRows({
32864
+ site: input.site,
32865
+ dimension: input.dimensions[0] === "page" ? "page" : "query",
32866
+ startDate: input.start_date,
32867
+ endDate: input.end_date,
32868
+ detail: input.detail,
32869
+ offset: input.offset
32870
+ }, surface.deps);
32871
+ if (input.source === "google_analytics") return runAnalyticsRows({
32872
+ property: input.site,
32873
+ startDate: input.start_date,
32874
+ endDate: input.end_date,
32875
+ detail: input.detail,
32876
+ offset: input.offset
32877
+ }, await analyticsSignIn(await surface.accounts(), input.site, surface.signIn), {
32878
+ signIn: surface.signIn,
32879
+ call: (offset) => `seo_data with source "google_analytics", site "${input.site}", detail "rows", offset ${offset}`
32880
+ });
32881
+ return runRows({
32882
+ site: await surface.resolveSite(input.site),
32883
+ dimensions: input.dimensions,
32884
+ filters: [...input.page ? [{
32885
+ dimension: "page",
32886
+ operator: "contains",
32887
+ expression: input.page
32888
+ }] : [], ...input.query ? [{
32889
+ dimension: "query",
32890
+ operator: "contains",
32891
+ expression: input.query
32892
+ }] : []],
32893
+ startDate: input.start_date,
32894
+ endDate: input.end_date,
32895
+ detail: input.detail,
32896
+ offset: input.offset
32897
+ }, surface.deps);
32898
+ }
32899
+ })
32900
+ ];
32901
+ function indexFromStore(shared) {
32902
+ const paged = "detail" in shared.input.shape;
32903
+ return {
32904
+ ...shared,
32905
+ description: `${shared.description} For a site in the weekly site audit beta it answers at once from the URL Inspection results this server keeps for the site, checked in the background each day, and says how many URLs are covered, how many are queued and the time left.`,
32906
+ run: async (surface, parsed) => {
32907
+ const input = parsed;
32908
+ const audit = surface.deps.audit;
32909
+ if (!audit) return shared.run(surface, parsed);
32910
+ const site = await surface.resolveSite(input.site);
32911
+ const detail = input.detail ?? "summary";
32912
+ const offset = detail === "rows" ? input.offset ?? 0 : 0;
32913
+ const limit = detail === "rows" ? 50 : 20;
32914
+ let checks = await audit.checks(site, offset, limit);
32915
+ if (!checks) {
32916
+ if (await audit.add(site) === "full") {
32917
+ const result = await shared.run(surface, parsed);
32918
+ return {
32919
+ text: `${result.text}\n\n${site} is not in the weekly site audit, and this account already audits the most sites the beta allows, so this ran the usual way. Remove a site under Beta on the account page to keep its checks here.`,
32920
+ data: result.data
32921
+ };
32922
+ }
32923
+ checks = await audit.checks(site, offset, limit);
32924
+ if (!checks) throw new Error(`The audit of ${site} did not start.`);
32925
+ }
32926
+ return runAuditChecks({
32927
+ site,
32928
+ detail,
32929
+ offset,
32930
+ paged
32931
+ }, checks, surface.deps.call);
32932
+ }
32933
+ };
32934
+ }
32935
+ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared) : shared.name === "seo_report" ? {
32936
+ ...shared,
32937
+ description: `${shared.description}\n- ${AUDIT_SECTION}: ${AUDIT_SECTION_TEXT}`,
32938
+ input: shared.input.extend({ section: z.enum([...SECTION_IDS, AUDIT_SECTION]).optional().describe("Optional. Leave it out first; then ask for one section.") })
32939
+ } : shared);
28842
32940
  //#endregion
28843
32941
  //#region packages/core/src/gsc/client/inspection-cache.ts
28844
32942
  const INSPECTION_FRESH_MS = 7 * 864e5;