seo 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-92W1Zc-o.js";
2
+ import { n as load, t as version } from "./package-B-M-4PZ_.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
6
- import { dirname, join } from "node:path";
6
+ import { dirname, extname, join, resolve } from "node:path";
7
7
  import { z } from "zod";
8
8
  import lockfile from "proper-lockfile";
9
9
  import { createHash, randomUUID, sign } from "node:crypto";
@@ -21,9 +21,9 @@ import { gunzipSync } from "node:zlib";
21
21
  import { execFile } from "node:child_process";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { promisify } from "node:util";
24
+ import { readFile, stat } from "node:fs/promises";
24
25
  import "pino";
25
26
  import "pino-pretty";
26
- import "node:fs/promises";
27
27
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
29
  import * as z$1 from "zod/v4";
@@ -86,12 +86,12 @@ var SeoError = class extends Error {
86
86
  this.retryable = errorMetadata[code].retryable;
87
87
  }
88
88
  };
89
- function errorMessage$2(error) {
89
+ function errorMessage$3(error) {
90
90
  return error instanceof Error ? error.message : String(error);
91
91
  }
92
92
  function toSeoError(error) {
93
93
  if (error instanceof SeoError) return error;
94
- const message = errorMessage$2(error);
94
+ const message = errorMessage$3(error);
95
95
  if (/not logged in/i.test(message)) return new SeoError("AUTH_REQUIRED", message);
96
96
  if (/oauth client config.*missing|shared seo google app/i.test(message)) return new SeoError("AUTH_CONFIG_REQUIRED", message);
97
97
  if (/invalid_grant|refresh token.*no longer valid/i.test(message)) return new SeoError("AUTH_EXPIRED", message);
@@ -148,6 +148,7 @@ function resolveSeoCliPaths(input = {}) {
148
148
  configFile: join(configDir, "config.json"),
149
149
  tokensFile: join(configDir, "tokens.json"),
150
150
  oauthClientFile: join(configDir, "oauth-client.json"),
151
+ providerSecretsFile: join(configDir, "provider-secrets.json"),
151
152
  telemetryStateFile: join(configDir, "telemetry.json"),
152
153
  cacheDbFile: join(cacheDir, "cache.db")
153
154
  };
@@ -227,6 +228,7 @@ const clientProfileSchema = z.object({
227
228
  watchUrls: z.array(z.string()).default([]),
228
229
  brandTerms: z.array(z.string()).default([]),
229
230
  analytics: analyticsConnectionsSchema,
231
+ searchEngines: z.object({ bing: z.object({ siteUrl: z.string() }).optional() }).optional(),
230
232
  reportDay: z.number().int().min(1).max(31).optional(),
231
233
  technicalWeekday: z.number().int().min(0).max(7).optional(),
232
234
  isDefault: z.boolean().optional(),
@@ -305,7 +307,7 @@ async function deleteKeyringPassword(service, account) {
305
307
  }
306
308
  //#endregion
307
309
  //#region packages/core/dist/storage/config.js
308
- const KEYRING_SERVICE = "seo";
310
+ const KEYRING_SERVICE$1 = "seo";
309
311
  const PRIVATE_FILE_MODE = 384;
310
312
  function tightenConfigPermissions(path) {
311
313
  try {
@@ -336,7 +338,7 @@ function tokenMetadata(tokens) {
336
338
  }
337
339
  async function readKeyringTokens(tokens) {
338
340
  const account = getKeyringAccount(tokens);
339
- const [accessToken, refreshToken] = await Promise.all([getKeyringPassword(KEYRING_SERVICE, `${account}:access`), getKeyringPassword(KEYRING_SERVICE, `${account}:refresh`)]);
341
+ const [accessToken, refreshToken] = await Promise.all([getKeyringPassword(KEYRING_SERVICE$1, `${account}:access`), getKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`)]);
340
342
  return {
341
343
  accessToken: accessToken ?? void 0,
342
344
  refreshToken: refreshToken ?? void 0
@@ -344,14 +346,14 @@ async function readKeyringTokens(tokens) {
344
346
  }
345
347
  async function writeKeyringTokens(tokens) {
346
348
  const account = getKeyringAccount(tokens);
347
- if (tokens.access_token) await setKeyringPassword(KEYRING_SERVICE, `${account}:access`, tokens.access_token);
348
- else await deleteKeyringPassword(KEYRING_SERVICE, `${account}:access`);
349
- if (tokens.refresh_token) await setKeyringPassword(KEYRING_SERVICE, `${account}:refresh`, tokens.refresh_token);
350
- else await deleteKeyringPassword(KEYRING_SERVICE, `${account}:refresh`);
349
+ if (tokens.access_token) await setKeyringPassword(KEYRING_SERVICE$1, `${account}:access`, tokens.access_token);
350
+ else await deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:access`);
351
+ if (tokens.refresh_token) await setKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`, tokens.refresh_token);
352
+ else await deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`);
351
353
  }
352
354
  async function deleteKeyringTokens(tokens) {
353
355
  const account = getKeyringAccount(tokens);
354
- await Promise.all([deleteKeyringPassword(KEYRING_SERVICE, `${account}:access`), deleteKeyringPassword(KEYRING_SERVICE, `${account}:refresh`)]);
356
+ await Promise.all([deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:access`), deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`)]);
355
357
  }
356
358
  async function readTokens() {
357
359
  ensureSeoCliDirs();
@@ -2765,7 +2767,7 @@ function absoluteUrl$2(value, base) {
2765
2767
  return;
2766
2768
  }
2767
2769
  }
2768
- function httpUrl(value, base) {
2770
+ function httpUrl$1(value, base) {
2769
2771
  const resolved = absoluteUrl$2(value, base);
2770
2772
  if (!resolved) return void 0;
2771
2773
  const protocol = new URL(resolved).protocol;
@@ -2917,14 +2919,14 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
2917
2919
  let invalidHreflangCount = 0;
2918
2920
  for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
2919
2921
  const language = $(element).attr("hreflang") ?? "";
2920
- const href = httpUrl($(element).attr("href"), fetchResult.finalUrl);
2922
+ const href = httpUrl$1($(element).attr("href"), fetchResult.finalUrl);
2921
2923
  if (language && href) hreflang.push({
2922
2924
  hreflang: language,
2923
2925
  href
2924
2926
  });
2925
2927
  else invalidHreflangCount += 1;
2926
2928
  }
2927
- const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
2929
+ const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl$1($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
2928
2930
  const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
2929
2931
  const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
2930
2932
  const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
@@ -4341,7 +4343,7 @@ function urlInspectionQuotaDate(now) {
4341
4343
  function urlInspectionQuotaResetAt(now) {
4342
4344
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
4343
4345
  }
4344
- function boundedLimit(value) {
4346
+ function boundedLimit$1(value) {
4345
4347
  const limit = value ?? 2e3;
4346
4348
  if (!Number.isInteger(limit) || limit < 1 || limit > 2e3) throw new SeoError("INVALID_INPUT", "URL Inspection daily limit must be an integer from 1 to 2000.");
4347
4349
  return limit;
@@ -4374,7 +4376,7 @@ function reserveUrlInspectionQuota(input) {
4374
4376
  const database = input.database ?? getDb();
4375
4377
  const now = input.now ?? /* @__PURE__ */ new Date();
4376
4378
  const nowMs = now.getTime();
4377
- const requestedLimit = boundedLimit(input.limit);
4379
+ const requestedLimit = boundedLimit$1(input.limit);
4378
4380
  const quotaDate = urlInspectionQuotaDate(now);
4379
4381
  const resetAt = urlInspectionQuotaResetAt(now);
4380
4382
  const result = database.transaction(() => {
@@ -6157,13 +6159,13 @@ function isLowActionabilityQuery(query) {
6157
6159
  //#endregion
6158
6160
  //#region packages/core/dist/analyze/page-opportunities-selection.js
6159
6161
  const DEFAULT_MIN_IMPRESSIONS$3 = 10;
6160
- const DEFAULT_LIMIT$3 = 25;
6161
- const MAX_LIMIT$3 = 100;
6162
+ const DEFAULT_LIMIT$4 = 25;
6163
+ const MAX_LIMIT$4 = 100;
6162
6164
  function integerInRange$2(value, fallback, minimum, maximum) {
6163
6165
  if (value === void 0 || !Number.isFinite(value)) return fallback;
6164
6166
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
6165
6167
  }
6166
- function isHttpUrl$1(value) {
6168
+ function isHttpUrl$2(value) {
6167
6169
  try {
6168
6170
  const parsed = new URL(value);
6169
6171
  return parsed.protocol === "http:" || parsed.protocol === "https:";
@@ -6185,7 +6187,7 @@ function isBrandRow(input) {
6185
6187
  function normalizePageOpportunityOptions(input) {
6186
6188
  return {
6187
6189
  minImpressions: integerInRange$2(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$3, 1, Number.MAX_SAFE_INTEGER),
6188
- limit: integerInRange$2(input.limit, DEFAULT_LIMIT$3, 1, MAX_LIMIT$3)
6190
+ limit: integerInRange$2(input.limit, DEFAULT_LIMIT$4, 1, MAX_LIMIT$4)
6189
6191
  };
6190
6192
  }
6191
6193
  function selectTargetRows(input) {
@@ -6204,7 +6206,7 @@ function selectTargetRows(input) {
6204
6206
  for (const row of input.rows) {
6205
6207
  const query = row.keys[0]?.trim() ?? "";
6206
6208
  const page = row.keys[1] ?? "";
6207
- if (row.keys.length < 2 || !query || !isHttpUrl$1(page) || !hasValidMetrics(row)) {
6209
+ if (row.keys.length < 2 || !query || !isHttpUrl$2(page) || !hasValidMetrics(row)) {
6208
6210
  selection.invalidRows++;
6209
6211
  continue;
6210
6212
  }
@@ -6240,7 +6242,7 @@ function selectTargetRows(input) {
6240
6242
  function selectBenchmarkRows$1(input) {
6241
6243
  return input.rows.filter((row) => {
6242
6244
  const query = row.keys[0]?.trim() ?? "";
6243
- return row.keys.length >= 2 && isHttpUrl$1(row.keys[1] ?? "") && hasValidMetrics(row) && row.position >= 1 && row.position <= 10 && Boolean(query) && !isLowActionabilityQuery(query) && !isBrandRow({
6245
+ return row.keys.length >= 2 && isHttpUrl$2(row.keys[1] ?? "") && hasValidMetrics(row) && row.position >= 1 && row.position <= 10 && Boolean(query) && !isLowActionabilityQuery(query) && !isBrandRow({
6244
6246
  query,
6245
6247
  site: input.site,
6246
6248
  brandTerms: input.brandTerms,
@@ -6422,7 +6424,7 @@ const defaultDependencies$14 = {
6422
6424
  extract: extractPage,
6423
6425
  now: () => /* @__PURE__ */ new Date()
6424
6426
  };
6425
- function errorMessage$1(error) {
6427
+ function errorMessage$2(error) {
6426
6428
  return error instanceof Error ? error.message : String(error);
6427
6429
  }
6428
6430
  function unique$4(values) {
@@ -6443,7 +6445,7 @@ async function verifyPage(input) {
6443
6445
  });
6444
6446
  warnings.push(...fetched.warnings);
6445
6447
  } catch (error) {
6446
- const reason = `Page fetch failed: ${errorMessage$1(error)}`;
6448
+ const reason = `Page fetch failed: ${errorMessage$2(error)}`;
6447
6449
  return {
6448
6450
  verification: {
6449
6451
  status: "failed",
@@ -6465,7 +6467,7 @@ async function verifyPage(input) {
6465
6467
  warnings: unique$4(warnings)
6466
6468
  };
6467
6469
  } catch (error) {
6468
- const reason = `Page extraction failed: ${errorMessage$1(error)}`;
6470
+ const reason = `Page extraction failed: ${errorMessage$2(error)}`;
6469
6471
  return {
6470
6472
  fetched,
6471
6473
  verification: {
@@ -7657,7 +7659,7 @@ function check$1(section, input) {
7657
7659
  ...input
7658
7660
  };
7659
7661
  }
7660
- function section$1(id, title, checks) {
7662
+ function section$2(id, title, checks) {
7661
7663
  return {
7662
7664
  id,
7663
7665
  title,
@@ -8095,19 +8097,19 @@ function agentReadiness(report) {
8095
8097
  headline: "No agent-discovery evidence is stored in this crawl. Run a fresh focused check.",
8096
8098
  profileApplicability: profiles,
8097
8099
  summary: summariseChecks(checks),
8098
- sections: [section$1("collection", "Evidence collection", [missing]), section$1("profile", "Profile scope", scopeChecks)],
8100
+ sections: [section$2("collection", "Evidence collection", [missing]), section$2("profile", "Profile scope", scopeChecks)],
8099
8101
  checks,
8100
8102
  topActions: [missing],
8101
8103
  caveats: ["Not evaluated does not mean absent or broken.", "This report does not measure rankings, AI mentions, citations, or selection."]
8102
8104
  };
8103
8105
  }
8104
8106
  const sections = [
8105
- section$1("profile", "Profile scope", profileChecks(discovery)),
8106
- section$1("access", "Crawler and protocol access", accessChecks(report, discovery)),
8107
- section$1("representations", "HTML and Markdown representations", markdownChecks(discovery)),
8108
- section$1("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
8109
- section$1("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
8110
- section$1("identity", "Identity evidence", identityChecks(report))
8107
+ section$2("profile", "Profile scope", profileChecks(discovery)),
8108
+ section$2("access", "Crawler and protocol access", accessChecks(report, discovery)),
8109
+ section$2("representations", "HTML and Markdown representations", markdownChecks(discovery)),
8110
+ section$2("discovery", "Agent discovery files", discoveryChecks(discovery, report.ai?.robotsTxt?.contentSignals)),
8111
+ section$2("endpoints", "Agent endpoints and auth discovery", endpointChecks(discovery)),
8112
+ section$2("identity", "Identity evidence", identityChecks(report))
8111
8113
  ];
8112
8114
  const checks = sections.flatMap((item) => item.checks);
8113
8115
  const actionable = checks.filter((item) => [
@@ -8294,7 +8296,7 @@ function status(score, maxScore) {
8294
8296
  if (ratio >= .5) return "warning";
8295
8297
  return "fail";
8296
8298
  }
8297
- function section(id, title, checks) {
8299
+ function section$1(id, title, checks) {
8298
8300
  return {
8299
8301
  id,
8300
8302
  title,
@@ -8352,7 +8354,7 @@ function aiReadiness(report) {
8352
8354
  const blockedSnippets = restrictedSnippets.filter(({ control }) => control.status === "blocked");
8353
8355
  const limitedSnippets = restrictedSnippets.filter(({ control }) => control.status === "limited");
8354
8356
  const sections = [
8355
- section("agent-access", "Agent access", [
8357
+ section$1("agent-access", "Agent access", [
8356
8358
  check({
8357
8359
  id: "robots-ai-bots",
8358
8360
  section: "agent-access",
@@ -8395,7 +8397,7 @@ function aiReadiness(report) {
8395
8397
  evidence: { resources: report.ai?.agentResources ?? [] }
8396
8398
  })
8397
8399
  ]),
8398
- section("machine-readable", "Machine-readable signals", [check({
8400
+ section$1("machine-readable", "Machine-readable signals", [check({
8399
8401
  id: "llms-txt",
8400
8402
  section: "machine-readable",
8401
8403
  maxScore: 0,
@@ -8420,7 +8422,7 @@ function aiReadiness(report) {
8420
8422
  action: invalidJsonLdPages.length ? "Fix invalid JSON-LD before relying on its structured-data claims." : "Use structured data only where it accurately represents the visible page and a supported use case.",
8421
8423
  urls: invalidJsonLdPages.slice(0, 10).map((page) => page.finalUrl)
8422
8424
  })]),
8423
- section("content-clarity", "Content clarity", [
8425
+ section$1("content-clarity", "Content clarity", [
8424
8426
  check({
8425
8427
  id: "answerable-content",
8426
8428
  section: "content-clarity",
@@ -8470,7 +8472,7 @@ function aiReadiness(report) {
8470
8472
  action: "Set the html lang attribute so crawlers and assistive tech understand the page language."
8471
8473
  })
8472
8474
  ]),
8473
- section("entity-signals", "Entity signals", [check({
8475
+ section$1("entity-signals", "Entity signals", [check({
8474
8476
  id: "entity-schema",
8475
8477
  section: "entity-signals",
8476
8478
  maxScore: 0,
@@ -8489,7 +8491,7 @@ function aiReadiness(report) {
8489
8491
  plainEnglish: "Author and date signals help agents judge freshness and accountability.",
8490
8492
  action: "Add visible authors, reviewed dates, and updated dates to content where trust and freshness matter."
8491
8493
  })]),
8492
- section("technical-ux", "Technical UX", [
8494
+ section$1("technical-ux", "Technical UX", [
8493
8495
  check({
8494
8496
  id: "snippet-controls",
8495
8497
  section: "technical-ux",
@@ -8527,7 +8529,7 @@ function aiReadiness(report) {
8527
8529
  action: "Fix 4xx/5xx pages, accidental noindex, and blocked internal URLs before expanding AI discovery work."
8528
8530
  })
8529
8531
  ]),
8530
- section("agent-content", "Agent content observations", [
8532
+ section$1("agent-content", "Agent content observations", [
8531
8533
  check({
8532
8534
  id: "markdown-alternates",
8533
8535
  section: "agent-content",
@@ -8567,7 +8569,7 @@ function aiReadiness(report) {
8567
8569
  evidence: agentDiscovery?.profileApplicability
8568
8570
  })
8569
8571
  ]),
8570
- section("crawl-completeness", "Crawl completeness", [check({
8572
+ section$1("crawl-completeness", "Crawl completeness", [check({
8571
8573
  id: "crawl-depth",
8572
8574
  section: "crawl-completeness",
8573
8575
  maxScore: 8,
@@ -15580,7 +15582,7 @@ function compareText$10(left, right) {
15580
15582
  }
15581
15583
  return leftPoints.length - rightPoints.length;
15582
15584
  }
15583
- function boundedInteger$5(value, fallback, minimum, maximum) {
15585
+ function boundedInteger$6(value, fallback, minimum, maximum) {
15584
15586
  if (value === void 0 || !Number.isFinite(value)) return fallback;
15585
15587
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
15586
15588
  }
@@ -15657,8 +15659,8 @@ function initialSelection(sourceRows) {
15657
15659
  };
15658
15660
  }
15659
15661
  function analyzeCtrUnderperformersFromRows(input) {
15660
- const minImpressions = boundedInteger$5(input.minImpressions, 200, 1, CTR_MAX_MIN_IMPRESSIONS);
15661
- const limit = boundedInteger$5(input.limit, 25, 1, 100);
15662
+ const minImpressions = boundedInteger$6(input.minImpressions, 200, 1, CTR_MAX_MIN_IMPRESSIONS);
15663
+ const limit = boundedInteger$6(input.limit, 25, 1, 100);
15662
15664
  const selection = initialSelection(input.rows.length);
15663
15665
  const benchmarkRows = aggregateCtrRows(input.rows, selection).filter((row) => {
15664
15666
  const query = row.keys[0] ?? "";
@@ -16157,7 +16159,7 @@ function analyzeSegmentRows(input) {
16157
16159
  }
16158
16160
  //#endregion
16159
16161
  //#region packages/core/dist/analyze/segment-impact-input.js
16160
- const DAY_MS$1 = 864e5;
16162
+ const DAY_MS$2 = 864e5;
16161
16163
  const MAX_WINDOW_DAYS = 240;
16162
16164
  function subtractDays$1(value, days) {
16163
16165
  const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -16165,7 +16167,7 @@ function subtractDays$1(value, days) {
16165
16167
  return date.toISOString().slice(0, 10);
16166
16168
  }
16167
16169
  function segmentRangeDays(range) {
16168
- return Math.floor((Date.parse(`${range.endDate}T00:00:00.000Z`) - Date.parse(`${range.startDate}T00:00:00.000Z`)) / DAY_MS$1) + 1;
16170
+ return Math.floor((Date.parse(`${range.endDate}T00:00:00.000Z`) - Date.parse(`${range.startDate}T00:00:00.000Z`)) / DAY_MS$2) + 1;
16169
16171
  }
16170
16172
  function segmentComparisonRange(after, compareDays) {
16171
16173
  const endDate = subtractDays$1(after.startDate, 1);
@@ -16745,13 +16747,13 @@ const MINIMUM_PAGE_SHARE = .1;
16745
16747
  const MINIMUM_PAGE_IMPRESSIONS = 10;
16746
16748
  const MAXIMUM_DOMINANT_SHARE = .8;
16747
16749
  const MAX_SUPPRESSIONS = 100;
16748
- function boundedInteger$4(value, fallback, minimum, maximum) {
16750
+ function boundedInteger$5(value, fallback, minimum, maximum) {
16749
16751
  if (value === void 0 || !Number.isFinite(value)) return fallback;
16750
16752
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
16751
16753
  }
16752
16754
  function analyzeCannibalRows(input) {
16753
- const minImpressions = boundedInteger$4(input.minImpressions, 50, 0, 1e9);
16754
- const limit = boundedInteger$4(input.limit, 25, 1, 100);
16755
+ const minImpressions = boundedInteger$5(input.minImpressions, 50, 0, 1e9);
16756
+ const limit = boundedInteger$5(input.limit, 25, 1, 100);
16755
16757
  const selection = {
16756
16758
  sourceRows: input.rows.length,
16757
16759
  invalidRows: 0,
@@ -17531,7 +17533,7 @@ function groupQuickWins(items) {
17531
17533
  }
17532
17534
  //#endregion
17533
17535
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-analysis-primitives.js
17534
- function boundedInteger$3(value, fallback, minimum, maximum) {
17536
+ function boundedInteger$4(value, fallback, minimum, maximum) {
17535
17537
  if (value === void 0 || !Number.isFinite(value)) return fallback;
17536
17538
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
17537
17539
  }
@@ -17652,12 +17654,12 @@ function roundedSum(values) {
17652
17654
  //#region packages/core/dist/analyze/site-diagnostics/quick-wins-analysis.js
17653
17655
  const DEFAULT_MIN_IMPRESSIONS$2 = 200;
17654
17656
  const MAX_MIN_IMPRESSIONS$2 = 1e9;
17655
- const DEFAULT_LIMIT$2 = 25;
17656
- const MAX_LIMIT$2 = 100;
17657
+ const DEFAULT_LIMIT$3 = 25;
17658
+ const MAX_LIMIT$3 = 100;
17657
17659
  const FALLBACK_SOURCE = "builtin_position_ctr_curve_v1";
17658
17660
  function analyzeQuickWinsFromRows(input) {
17659
- const minImpressions = boundedInteger$3(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$2, 0, MAX_MIN_IMPRESSIONS$2);
17660
- const limit = boundedInteger$3(input.limit, DEFAULT_LIMIT$2, 1, MAX_LIMIT$2);
17661
+ const minImpressions = boundedInteger$4(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$2, 0, MAX_MIN_IMPRESSIONS$2);
17662
+ const limit = boundedInteger$4(input.limit, DEFAULT_LIMIT$3, 1, MAX_LIMIT$3);
17661
17663
  const selected = selectBenchmarkRows(input);
17662
17664
  const benchmarkContext = createCtrBenchmarkContext(selected.rows, {
17663
17665
  samplePopulation: "all_qualified_url_samples",
@@ -17998,7 +18000,7 @@ function compareText$5(left, right) {
17998
18000
  }
17999
18001
  return leftPoints.length - rightPoints.length;
18000
18002
  }
18001
- function isHttpUrl(value) {
18003
+ function isHttpUrl$1(value) {
18002
18004
  try {
18003
18005
  const parsed = new URL(value);
18004
18006
  return parsed.protocol === "http:" || parsed.protocol === "https:";
@@ -18007,7 +18009,7 @@ function isHttpUrl(value) {
18007
18009
  }
18008
18010
  }
18009
18011
  function isValidStrikingDistanceRow(row, query, url) {
18010
- return Boolean(query) && isHttpUrl(url) && Number.isFinite(row.clicks) && Number.isFinite(row.impressions) && Number.isFinite(row.ctr) && Number.isFinite(row.position) && row.clicks >= 0 && row.impressions > 0 && row.clicks <= row.impressions && row.ctr >= 0 && row.ctr <= 1 && row.position > 0;
18012
+ return Boolean(query) && isHttpUrl$1(url) && Number.isFinite(row.clicks) && Number.isFinite(row.impressions) && Number.isFinite(row.ctr) && Number.isFinite(row.position) && row.clicks >= 0 && row.impressions > 0 && row.clicks <= row.impressions && row.ctr >= 0 && row.ctr <= 1 && row.position > 0;
18011
18013
  }
18012
18014
  function isStrikingDistanceBrandRow(input) {
18013
18015
  return shouldExcludeBrandQuery({
@@ -18097,8 +18099,8 @@ function groupStrikingDistanceItems(items) {
18097
18099
  //#region packages/core/dist/analyze/striking-distance-analysis.js
18098
18100
  const DEFAULT_MIN_IMPRESSIONS$1 = 100;
18099
18101
  const MAX_MIN_IMPRESSIONS$1 = 1e9;
18100
- const DEFAULT_LIMIT$1 = 25;
18101
- const MAX_LIMIT$1 = 100;
18102
+ const DEFAULT_LIMIT$2 = 25;
18103
+ const MAX_LIMIT$2 = 100;
18102
18104
  function integerInRange$1(value, fallback, minimum, maximum) {
18103
18105
  if (value === void 0 || !Number.isFinite(value)) return fallback;
18104
18106
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
@@ -18140,7 +18142,7 @@ function selectRows(input) {
18140
18142
  }
18141
18143
  function analyzeStrikingDistanceRows(input) {
18142
18144
  const minImpressions = integerInRange$1(input.minImpressions, DEFAULT_MIN_IMPRESSIONS$1, 0, MAX_MIN_IMPRESSIONS$1);
18143
- const limit = integerInRange$1(input.limit, DEFAULT_LIMIT$1, 1, MAX_LIMIT$1);
18145
+ const limit = integerInRange$1(input.limit, DEFAULT_LIMIT$2, 1, MAX_LIMIT$2);
18144
18146
  const selected = selectRows({
18145
18147
  ...input,
18146
18148
  minImpressions
@@ -18214,7 +18216,7 @@ const defaultDependencies$7 = {
18214
18216
  verifyContent: verifyQueryContent,
18215
18217
  now: () => /* @__PURE__ */ new Date()
18216
18218
  };
18217
- function boundedInteger$2(value, fallback, minimum, maximum) {
18219
+ function boundedInteger$3(value, fallback, minimum, maximum) {
18218
18220
  if (value === void 0 || !Number.isFinite(value)) return fallback;
18219
18221
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
18220
18222
  }
@@ -18278,7 +18280,7 @@ async function strikingDistance(input, dependencies = defaultDependencies$7) {
18278
18280
  brandTerms: input.brandTerms,
18279
18281
  includeBrand: input.includeBrand
18280
18282
  });
18281
- const verifyLimit = boundedInteger$2(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
18283
+ const verifyLimit = boundedInteger$3(input.verifyLimit, DEFAULT_VERIFY_LIMIT, 0, analysis.limit);
18282
18284
  const verificationRequested = input.verifyContent === true || input.verifyContent !== false && input.verifyLimit !== void 0;
18283
18285
  const attempted = verificationRequested ? Math.min(verifyLimit, analysis.items.length) : 0;
18284
18286
  const items = await Promise.all(analysis.items.map(async (item, index) => {
@@ -18467,7 +18469,7 @@ function standardDeviation(values) {
18467
18469
  function toFixedNumber(value, digits = 3) {
18468
18470
  return Number(value.toFixed(digits));
18469
18471
  }
18470
- function percentChange(baseline, comparison) {
18472
+ function percentChange$1(baseline, comparison) {
18471
18473
  if (!baseline) return comparison ? null : 0;
18472
18474
  return (comparison - baseline) / baseline * 100;
18473
18475
  }
@@ -18653,7 +18655,7 @@ function analyzeTrafficAnomalyRows(input) {
18653
18655
  const outsideFlatBaseline = zScore === null && comparisonMean !== baselineMean;
18654
18656
  const significant = outsideFlatBaseline || zScore !== null && Math.abs(zScore) >= 2;
18655
18657
  const direction = significant ? comparisonMean < baselineMean ? "drop" : "spike" : "normal";
18656
- const relativeChange = percentChange(baselineMean, comparisonMean);
18658
+ const relativeChange = percentChange$1(baselineMean, comparisonMean);
18657
18659
  return {
18658
18660
  site: input.site,
18659
18661
  metric,
@@ -19712,7 +19714,7 @@ function dateShift(date, days) {
19712
19714
  }
19713
19715
  //#endregion
19714
19716
  //#region packages/core/dist/analyze/experiments/measurement/window.js
19715
- const DAY_MS = 864e5;
19717
+ const DAY_MS$1 = 864e5;
19716
19718
  function exactDate(value, label) {
19717
19719
  const pattern = /^\d{4}-\d{2}-\d{2}$/;
19718
19720
  const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -19727,7 +19729,7 @@ function boundedDays(value, fallback, label) {
19727
19729
  function calendarDays(startDate, endDate) {
19728
19730
  const start = (/* @__PURE__ */ new Date(`${startDate}T00:00:00.000Z`)).getTime();
19729
19731
  const end = (/* @__PURE__ */ new Date(`${endDate}T00:00:00.000Z`)).getTime();
19730
- return Math.floor((end - start) / DAY_MS) + 1;
19732
+ return Math.floor((end - start) / DAY_MS$1) + 1;
19731
19733
  }
19732
19734
  function subtractCalendarMonths(value, months) {
19733
19735
  const source = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -20273,7 +20275,7 @@ function internalLinksVerdict(input) {
20273
20275
  function uniqueInternalLinksWarnings(warnings) {
20274
20276
  return [...new Map(warnings.map((warning) => [`${warning.stage}\u0000${warning.url}\u0000${warning.code}\u0000${warning.message}`, warning])).values()];
20275
20277
  }
20276
- function errorMessage(error) {
20278
+ function errorMessage$1(error) {
20277
20279
  return error instanceof Error ? error.message : String(error);
20278
20280
  }
20279
20281
  function acceptedAlias(site, value) {
@@ -20323,7 +20325,7 @@ async function verifyInternalLinkTarget(input) {
20323
20325
  stage: "target-fetch",
20324
20326
  url: input.targetUrl,
20325
20327
  code: "fetch-failed",
20326
- message: errorMessage(error)
20328
+ message: errorMessage$1(error)
20327
20329
  }]
20328
20330
  };
20329
20331
  }
@@ -20344,7 +20346,7 @@ async function verifyInternalLinkTarget(input) {
20344
20346
  stage: "target-extract",
20345
20347
  url: input.targetUrl,
20346
20348
  code: "extract-failed",
20347
- message: errorMessage(error)
20349
+ message: errorMessage$1(error)
20348
20350
  }]
20349
20351
  };
20350
20352
  }
@@ -20407,7 +20409,7 @@ async function verifyInternalLinkCandidate(input) {
20407
20409
  stage: "source-fetch",
20408
20410
  url: input.candidate.sourceUrl,
20409
20411
  code: "fetch-failed",
20410
- message: errorMessage(error)
20412
+ message: errorMessage$1(error)
20411
20413
  }]
20412
20414
  };
20413
20415
  }
@@ -20425,7 +20427,7 @@ async function verifyInternalLinkCandidate(input) {
20425
20427
  stage: "source-fetch",
20426
20428
  url: input.candidate.sourceUrl,
20427
20429
  code: "fetch-failed",
20428
- message: errorMessage(error)
20430
+ message: errorMessage$1(error)
20429
20431
  }]
20430
20432
  };
20431
20433
  }
@@ -20439,7 +20441,7 @@ async function verifyInternalLinkCandidate(input) {
20439
20441
  stage: "source-extract",
20440
20442
  url: input.candidate.sourceUrl,
20441
20443
  code: "extract-failed",
20442
- message: errorMessage(error)
20444
+ message: errorMessage$1(error)
20443
20445
  }]
20444
20446
  };
20445
20447
  }
@@ -21247,7 +21249,7 @@ function compareText$3(left, right) {
21247
21249
  }
21248
21250
  return leftPoints.length - rightPoints.length;
21249
21251
  }
21250
- function boundedInteger$1(value, fallback, maximum) {
21252
+ function boundedInteger$2(value, fallback, maximum) {
21251
21253
  if (value === void 0 || !Number.isFinite(value)) return fallback;
21252
21254
  return Math.min(maximum, Math.max(1, Math.trunc(value)));
21253
21255
  }
@@ -21440,10 +21442,10 @@ function caveats(input) {
21440
21442
  return items;
21441
21443
  }
21442
21444
  function analyzeIndexCoverageSignals(input) {
21443
- const itemLimit = boundedInteger$1(input.limits?.itemsPerSection, DEFAULT_ITEMS_PER_SECTION, MAX_ITEMS_PER_SECTION);
21444
- const clusterLimit = boundedInteger$1(input.limits?.templateClusters, DEFAULT_TEMPLATE_LIMIT, MAX_TEMPLATE_LIMIT);
21445
- const clusterSamples = boundedInteger$1(input.limits?.templateSamples, DEFAULT_TEMPLATE_SAMPLES, MAX_TEMPLATE_SAMPLES);
21446
- const minUrls = boundedInteger$1(input.templateThresholds?.minUrls, DEFAULT_TEMPLATE_MIN_URLS, Number.MAX_SAFE_INTEGER);
21445
+ const itemLimit = boundedInteger$2(input.limits?.itemsPerSection, DEFAULT_ITEMS_PER_SECTION, MAX_ITEMS_PER_SECTION);
21446
+ const clusterLimit = boundedInteger$2(input.limits?.templateClusters, DEFAULT_TEMPLATE_LIMIT, MAX_TEMPLATE_LIMIT);
21447
+ const clusterSamples = boundedInteger$2(input.limits?.templateSamples, DEFAULT_TEMPLATE_SAMPLES, MAX_TEMPLATE_SAMPLES);
21448
+ const minUrls = boundedInteger$2(input.templateThresholds?.minUrls, DEFAULT_TEMPLATE_MIN_URLS, Number.MAX_SAFE_INTEGER);
21447
21449
  const requestedMinShare = input.templateThresholds?.minShare;
21448
21450
  const minShare = Number.isFinite(requestedMinShare) ? Math.min(1, Math.max(0, requestedMinShare ?? DEFAULT_TEMPLATE_MIN_SHARE)) : DEFAULT_TEMPLATE_MIN_SHARE;
21449
21451
  const crawl = crawlInput(input.crawl);
@@ -25130,16 +25132,16 @@ function benchmarkDetails(benchmark) {
25130
25132
  positiveUrlSamples: benchmark.positiveUrlSamples
25131
25133
  };
25132
25134
  }
25133
- function boundedInteger(input) {
25135
+ function boundedInteger$1(input) {
25134
25136
  const value = Number.isFinite(input.value) ? input.value ?? 0 : input.fallback;
25135
25137
  return Math.min(input.maximum ?? Number.POSITIVE_INFINITY, Math.max(1, Math.floor(value)));
25136
25138
  }
25137
25139
  function analyzeQueryClustersFromRows(input) {
25138
- const minImpressions = boundedInteger({
25140
+ const minImpressions = boundedInteger$1({
25139
25141
  value: input.minImpressions,
25140
25142
  fallback: 25
25141
25143
  });
25142
- const limit = boundedInteger({
25144
+ const limit = boundedInteger$1({
25143
25145
  value: input.limit,
25144
25146
  fallback: 25,
25145
25147
  maximum: 100
@@ -25781,8 +25783,8 @@ function reportPresentation(report) {
25781
25783
  //#region packages/core/dist/analyze/second-page-analysis.js
25782
25784
  const DEFAULT_MIN_IMPRESSIONS = 50;
25783
25785
  const MAX_MIN_IMPRESSIONS = 1e9;
25784
- const DEFAULT_LIMIT = 10;
25785
- const MAX_LIMIT = 100;
25786
+ const DEFAULT_LIMIT$1 = 10;
25787
+ const MAX_LIMIT$1 = 100;
25786
25788
  function integerInRange(value, fallback, minimum, maximum) {
25787
25789
  if (value === void 0 || !Number.isFinite(value)) return fallback;
25788
25790
  return Math.min(maximum, Math.max(minimum, Math.floor(value)));
@@ -25869,7 +25871,7 @@ function compareItems(left, right) {
25869
25871
  }
25870
25872
  function analyzeSecondPageRows(input) {
25871
25873
  const minImpressions = integerInRange(input.minImpressions, DEFAULT_MIN_IMPRESSIONS, 0, MAX_MIN_IMPRESSIONS);
25872
- const limit = integerInRange(input.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
25874
+ const limit = integerInRange(input.limit, DEFAULT_LIMIT$1, 1, MAX_LIMIT$1);
25873
25875
  const selection = {
25874
25876
  sourceRows: input.rows.length,
25875
25877
  invalidRows: 0,
@@ -27267,207 +27269,1558 @@ async function updatePostmortemWorkflow(input, dependencies = {}) {
27267
27269
  });
27268
27270
  }
27269
27271
  //#endregion
27270
- //#region packages/core/dist/doctor.js
27271
- const REQUIRED_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly", "https://www.googleapis.com/auth/analytics.readonly"];
27272
- async function runDoctor(dependencies = {}) {
27273
- const paths = getSeoCliPaths();
27274
- const config = readConfig();
27275
- const authMode = getAuthModeStatus();
27276
- const serviceAccount = getServiceAccountStatus();
27277
- const tokens = await readTokens();
27278
- const oauthClient = readOauthClient();
27279
- const tokenClientConfigured = tokens ? Boolean(getClientConfig(tokens.client_source)) : void 0;
27280
- const checks = [];
27281
- try {
27282
- const database = (dependencies.checkDatabase ?? checkDatabaseReadiness)();
27283
- checks.push({
27284
- id: "local-database",
27285
- label: "Local database",
27286
- status: "pass",
27287
- detail: database.dbPath
27288
- });
27289
- } catch (error) {
27290
- checks.push({
27291
- id: "local-database",
27292
- label: "Local database",
27293
- status: "fail",
27294
- detail: error instanceof Error ? error.message : "The local database could not be opened.",
27295
- fix: "Upgrade or reinstall `seo`. If this still fails, report it at https://github.com/iannuttall/seo/issues."
27296
- });
27272
+ //#region packages/core/dist/bing/analysis.js
27273
+ const DAY_MS = 864e5;
27274
+ const DEFAULT_COMPARISON_DAYS = 28;
27275
+ const WEEKLY_PERIODS = 4;
27276
+ const OPPORTUNITY_MIN_IMPRESSIONS = 20;
27277
+ const OPPORTUNITY_MIN_POSITION = 4;
27278
+ const OPPORTUNITY_MAX_POSITION = 20;
27279
+ const RESULT_LIMIT = 10;
27280
+ function dateOffset(date, days) {
27281
+ return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10);
27282
+ }
27283
+ function percentChange(previous, current) {
27284
+ if (previous === 0) return null;
27285
+ return round((current - previous) / previous * 100);
27286
+ }
27287
+ function round(value, digits = 3) {
27288
+ const factor = 10 ** digits;
27289
+ return Math.round(value * factor) / factor;
27290
+ }
27291
+ function trafficPeriod(byDate, startDate, endDate, expectedDays) {
27292
+ let clicks = 0;
27293
+ let impressions = 0;
27294
+ let observedDays = 0;
27295
+ for (const [date, row] of byDate) {
27296
+ if (date < startDate || date > endDate) continue;
27297
+ observedDays += 1;
27298
+ clicks += row.clicks;
27299
+ impressions += row.impressions;
27297
27300
  }
27298
- checks.push({
27299
- id: "config-dir",
27300
- label: "Config directory",
27301
- status: existsSync(paths.configDir) ? "pass" : "warn",
27302
- detail: paths.configDir,
27303
- fix: existsSync(paths.configDir) ? void 0 : "Run `seo start` or `seo auth setup-client`."
27304
- });
27305
- checks.push({
27306
- id: "oauth-client",
27307
- label: "Google credentials",
27308
- status: serviceAccount.configured ? "pass" : tokenClientConfigured === false ? "fail" : serviceAccount.error ? "fail" : authMode.sharedConfigured || authMode.byoConfigured ? "pass" : "fail",
27309
- detail: serviceAccount.configured ? `Service account configured for ${serviceAccount.identity}.` : tokenClientConfigured === false ? `Stored Google login uses the ${tokens?.client_source === "shared" ? "shared seo app" : "BYO client"}, but that client is not configured.` : serviceAccount.error ? serviceAccount.error : authMode.sharedConfigured ? "Shared client configured." : authMode.byoConfigured ? `BYO client configured at ${paths.oauthClientFile}.` : "No shared or BYO Google OAuth client configured.",
27310
- fix: serviceAccount.configured ? void 0 : tokenClientConfigured === false ? tokens?.client_source === "shared" ? "Reinstall `seo`. If the shared client is still missing, report it at https://github.com/iannuttall/seo/issues." : "Restore the same client with `seo auth setup-client`, or run `seo auth logout` before signing in with a different client." : serviceAccount.error ? "Set one valid service account credential source, then run `seo doctor` again." : authMode.sharedConfigured || authMode.byoConfigured ? void 0 : "Run `seo auth setup-client` or set SEO_GOOGLE_CLIENT_ID and SEO_GOOGLE_CLIENT_SECRET."
27311
- });
27312
- checks.push({
27313
- id: "oauth-client-file",
27314
- label: "BYO OAuth file",
27315
- status: oauthClient ? "pass" : "warn",
27316
- detail: oauthClient ? paths.oauthClientFile : "No BYO OAuth client file. This is fine if using a shared client."
27317
- });
27318
- checks.push({
27319
- id: "google-login",
27320
- label: "Google login",
27321
- status: serviceAccount.configured || tokens ? "pass" : "fail",
27322
- detail: serviceAccount.configured ? `Using service account ${serviceAccount.identity}.` : tokens ? `Signed in as ${tokens.account_email}.` : "No Google token found.",
27323
- fix: serviceAccount.configured || tokens ? void 0 : "Run `seo auth login`, or configure a service account for CI."
27324
- });
27325
- const grantedScopes = new Set(tokens?.scope.split(/\s+/).filter(Boolean) ?? []);
27326
- for (const scope of REQUIRED_SCOPES) checks.push({
27327
- id: `scope:${scope}`,
27328
- label: scope.endsWith("webmasters.readonly") ? "Search Console scope" : "Google Analytics scope",
27329
- status: serviceAccount.configured || tokens && grantedScopes.has(scope) ? "pass" : tokens ? "fail" : "warn",
27330
- detail: serviceAccount.configured ? `${scope} requested by the service account.` : tokens ? grantedScopes.has(scope) ? scope : `Missing ${scope}.` : "Cannot inspect scopes until login is complete.",
27331
- fix: !serviceAccount.configured && tokens && !grantedScopes.has(scope) ? "Run `seo auth logout` then `seo auth login`." : void 0
27332
- });
27333
- checks.push({
27334
- id: "default-site",
27335
- label: "Saved GSC property",
27336
- status: "pass",
27337
- detail: config.defaultSite ?? "No saved default. Human CLI commands will prompt; agents can pass --site."
27338
- });
27339
- checks.push({
27340
- id: "default-google-analytics",
27341
- label: "Saved Google Analytics property",
27342
- status: "pass",
27343
- detail: config.analytics.google.defaultPropertyId ?? "No saved default. Human CLI commands will prompt; agents can pass --property."
27344
- });
27345
27301
  return {
27346
- ok: checks.every((check) => check.status !== "fail"),
27347
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
27348
- checks
27302
+ startDate,
27303
+ endDate,
27304
+ expectedDays,
27305
+ observedDays,
27306
+ missingDays: Math.max(0, expectedDays - observedDays),
27307
+ clicks,
27308
+ impressions,
27309
+ ctr: impressions ? round(clicks / impressions * 100) : null
27349
27310
  };
27350
27311
  }
27351
- //#endregion
27352
- //#region packages/core/dist/providers/dataforseo.js
27353
- var DataForSeoProvider = class {
27354
- name = "dataforseo";
27355
- capabilities = { overview: true };
27356
- getAuthHeader() {
27357
- const config = readConfig();
27358
- const login = config.providers.dataForSeoLogin;
27359
- const password = config.providers.dataForSeoPassword;
27360
- if (!login || !password) throw new Error("DataForSEO credentials missing. Add login and password to config.json.");
27361
- return `Basic ${Buffer.from(`${login}:${password}`).toString("base64")}`;
27362
- }
27363
- async keywordOverview(phrase, opts = {}) {
27364
- const response = await fetch$1("https://api.dataforseo.com/v3/dataforseo_labs/google/keyword_overview/live", {
27365
- method: "POST",
27366
- headers: {
27367
- authorization: this.getAuthHeader(),
27368
- "content-type": "application/json"
27369
- },
27370
- body: JSON.stringify([{
27371
- keyword: phrase,
27372
- language_code: "en",
27373
- location_code: 2840
27374
- }])
27375
- });
27376
- if (!response.ok) throw new Error(`DataForSEO request failed with ${response.status}.`);
27377
- const json = await response.json();
27378
- const row = json.tasks?.[0]?.result?.[0];
27379
- return {
27380
- data: {
27381
- phrase: row?.keyword ?? phrase,
27382
- volume: row?.search_volume,
27383
- competition: row?.competition,
27384
- cpc: row?.cpc,
27385
- difficulty: row?.keyword_difficulty
27386
- },
27387
- usage: {
27388
- provider: "DataForSEO",
27389
- units: 1,
27390
- unitLabel: "tasks",
27391
- estimatedUsd: json.cost,
27392
- calls: 1
27393
- },
27394
- warnings: opts.database ? ["DataForSEO overview currently ignores the database option."] : void 0
27312
+ function analyzeBingTraffic(rows, comparisonDays = DEFAULT_COMPARISON_DAYS) {
27313
+ if (!rows.length) return void 0;
27314
+ const byDate = /* @__PURE__ */ new Map();
27315
+ for (const row of rows) {
27316
+ const existing = byDate.get(row.date) ?? {
27317
+ clicks: 0,
27318
+ impressions: 0
27319
+ };
27320
+ existing.clicks += row.clicks;
27321
+ existing.impressions += row.impressions;
27322
+ byDate.set(row.date, existing);
27323
+ }
27324
+ const latestDate = [...byDate.keys()].sort().at(-1);
27325
+ if (!latestDate) return void 0;
27326
+ const currentStart = dateOffset(latestDate, -(comparisonDays - 1));
27327
+ const previousEnd = dateOffset(currentStart, -1);
27328
+ const previousStart = dateOffset(previousEnd, -(comparisonDays - 1));
27329
+ const current = trafficPeriod(byDate, currentStart, latestDate, comparisonDays);
27330
+ const previous = trafficPeriod(byDate, previousStart, previousEnd, comparisonDays);
27331
+ return {
27332
+ comparisonDays,
27333
+ current,
27334
+ previous,
27335
+ changes: {
27336
+ clicks: current.clicks - previous.clicks,
27337
+ clicksPercent: percentChange(previous.clicks, current.clicks),
27338
+ impressions: current.impressions - previous.impressions,
27339
+ impressionsPercent: percentChange(previous.impressions, current.impressions),
27340
+ ctrPercentagePoints: current.ctr === null || previous.ctr === null ? null : round(current.ctr - previous.ctr)
27341
+ }
27342
+ };
27343
+ }
27344
+ const CRAWL_FIELDS = [
27345
+ "crawledPages",
27346
+ "inIndex",
27347
+ "inLinks",
27348
+ "crawlErrors",
27349
+ "code4xx",
27350
+ "code5xx",
27351
+ "blockedByRobotsTxt",
27352
+ "connectionTimeout",
27353
+ "dnsFailures",
27354
+ "containsMalware"
27355
+ ];
27356
+ function analyzeBingCrawl(rows, comparisonDays = DEFAULT_COMPARISON_DAYS) {
27357
+ const ordered = [...rows].sort((a, b) => a.date.localeCompare(b.date, "en"));
27358
+ const current = ordered.at(-1);
27359
+ if (!current) return void 0;
27360
+ const targetDate = dateOffset(current.date, -comparisonDays);
27361
+ const previous = ordered.filter((row) => row.date <= targetDate).at(-1);
27362
+ const changes = {};
27363
+ if (!previous) return {
27364
+ comparisonDays,
27365
+ targetDate,
27366
+ current,
27367
+ previous: void 0,
27368
+ changes
27369
+ };
27370
+ for (const field of CRAWL_FIELDS) {
27371
+ const currentValue = current[field];
27372
+ const previousValue = previous[field];
27373
+ if (typeof currentValue !== "number" || typeof previousValue !== "number") continue;
27374
+ changes[field] = {
27375
+ previous: previousValue,
27376
+ current: currentValue,
27377
+ absolute: currentValue - previousValue,
27378
+ percent: percentChange(previousValue, currentValue)
27395
27379
  };
27396
27380
  }
27397
- };
27398
- //#endregion
27399
- //#region packages/core/dist/providers/semrush/csv.js
27400
- function parseSemicolonCsv(text) {
27401
- return text.trim().split("\n").map((line) => line.split(";").map((cell) => cell.replace(/^"|"$/g, "").trim()));
27402
- }
27403
- //#endregion
27404
- //#region packages/core/dist/providers/semrush/cache.js
27405
- const BASE_URL = "https://api.semrush.com/";
27406
- const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
27407
- function estimateUsd(units) {
27408
- return units / 1e3 * .05;
27409
- }
27410
- async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, refresh = false) {
27411
- const apiKey = readConfig().providers.semrushApiKey;
27412
- if (!apiKey) throw new Error("Semrush API key missing. Add it to config.json first.");
27413
- const requestParams = Object.fromEntries(Object.entries({
27414
- ...params,
27415
- key: apiKey,
27416
- type: endpoint
27417
- }).filter(([, value]) => value !== void 0));
27418
- const db = getDb();
27419
- const queryHash = hashKey([endpoint, requestParams]);
27420
- const cached = db.prepare("SELECT response_json, credits_used FROM semrush_cache WHERE endpoint = ? AND query_hash = ? AND expires_at > ?").get(endpoint, queryHash, Date.now());
27421
- if (!refresh && cached?.response_json) return {
27422
- data: JSON.parse(cached.response_json),
27423
- usage: {
27424
- provider: "Semrush",
27425
- units: cached.credits_used ?? 0,
27426
- unitLabel: "units",
27427
- estimatedUsd: estimateUsd(cached.credits_used ?? 0),
27428
- calls: 1,
27429
- cacheHits: 1
27430
- },
27431
- cached: true
27381
+ return {
27382
+ comparisonDays,
27383
+ targetDate,
27384
+ current,
27385
+ previous,
27386
+ changes
27432
27387
  };
27433
- const url = new URL(BASE_URL);
27434
- url.search = new URLSearchParams(requestParams).toString();
27435
- const response = await fetch$1(url);
27436
- const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
27437
- if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
27438
- const rows = parseSemicolonCsv(text);
27439
- const data = map(rows);
27440
- const credits = Math.max(0, rows.length - 1) * creditsPerLine;
27441
- const requestJson = JSON.stringify(requestParams);
27442
- const responseJson = JSON.stringify(data);
27443
- db.prepare(`INSERT OR REPLACE INTO semrush_cache
27444
- (endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
27445
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
27446
- noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
27388
+ }
27389
+ function aggregateDimensions(rows, dates) {
27390
+ const values = /* @__PURE__ */ new Map();
27391
+ for (const row of rows) {
27392
+ if (!dates.has(row.date)) continue;
27393
+ const item = values.get(row.value) ?? {
27394
+ clicks: 0,
27395
+ impressions: 0,
27396
+ clickPositionTotal: 0,
27397
+ clickPositionWeight: 0,
27398
+ impressionPositionTotal: 0,
27399
+ impressionPositionWeight: 0,
27400
+ observedDates: /* @__PURE__ */ new Set()
27401
+ };
27402
+ item.clicks += row.clicks;
27403
+ item.impressions += row.impressions;
27404
+ item.observedDates.add(row.date);
27405
+ if (row.avgClickPosition !== void 0 && row.clicks > 0) {
27406
+ const weight = row.clicks;
27407
+ item.clickPositionTotal += row.avgClickPosition * weight;
27408
+ item.clickPositionWeight += weight;
27409
+ }
27410
+ if (row.avgImpressionPosition !== void 0 && row.impressions > 0) {
27411
+ const weight = row.impressions;
27412
+ item.impressionPositionTotal += row.avgImpressionPosition * weight;
27413
+ item.impressionPositionWeight += weight;
27414
+ }
27415
+ values.set(row.value, item);
27416
+ }
27417
+ return new Map([...values].map(([value, item]) => [value, {
27418
+ value,
27419
+ observedWeeks: item.observedDates.size,
27420
+ clicks: item.clicks,
27421
+ impressions: item.impressions,
27422
+ avgClickPosition: item.clickPositionWeight ? round(item.clickPositionTotal / item.clickPositionWeight) : null,
27423
+ avgImpressionPosition: item.impressionPositionWeight ? round(item.impressionPositionTotal / item.impressionPositionWeight) : null
27424
+ }]));
27425
+ }
27426
+ function compareValue(a, b) {
27427
+ return a.value.localeCompare(b.value, "en");
27428
+ }
27429
+ function analyzeBingDimensions(rows, kind) {
27430
+ const dates = [...new Set(rows.map((row) => row.date))].sort().reverse();
27431
+ const currentDates = dates.slice(0, WEEKLY_PERIODS);
27432
+ const previousDates = dates.slice(WEEKLY_PERIODS, WEEKLY_PERIODS * 2);
27433
+ const current = aggregateDimensions(rows, new Set(currentDates));
27434
+ const previous = aggregateDimensions(rows, new Set(previousDates));
27435
+ const matchedValues = [...current.keys()].filter((value) => previous.has(value));
27436
+ const comparableValues = currentDates.length === WEEKLY_PERIODS && previousDates.length === WEEKLY_PERIODS ? matchedValues.filter((value) => {
27437
+ const currentValue = current.get(value);
27438
+ const previousValue = previous.get(value);
27439
+ return currentValue?.observedWeeks === WEEKLY_PERIODS && previousValue?.observedWeeks === WEEKLY_PERIODS;
27440
+ }) : [];
27441
+ const movements = comparableValues.map((value) => {
27442
+ const currentValue = current.get(value);
27443
+ const previousValue = previous.get(value);
27444
+ return {
27445
+ value,
27446
+ current: currentValue,
27447
+ previous: previousValue,
27448
+ changes: {
27449
+ clicks: currentValue.clicks - previousValue.clicks,
27450
+ clicksPercent: percentChange(previousValue.clicks, currentValue.clicks),
27451
+ impressions: currentValue.impressions - previousValue.impressions,
27452
+ impressionsPercent: percentChange(previousValue.impressions, currentValue.impressions)
27453
+ }
27454
+ };
27455
+ }).sort((a, b) => Math.abs(b.changes.clicks) - Math.abs(a.changes.clicks) || Math.abs(b.changes.impressions) - Math.abs(a.changes.impressions) || compareValue(a, b)).slice(0, RESULT_LIMIT);
27456
+ const opportunities = [...current.values()].filter((row) => row.impressions >= OPPORTUNITY_MIN_IMPRESSIONS && row.avgImpressionPosition !== null && row.avgImpressionPosition >= OPPORTUNITY_MIN_POSITION && row.avgImpressionPosition <= OPPORTUNITY_MAX_POSITION).sort((a, b) => b.impressions - a.impressions || b.clicks - a.clicks || compareValue(a, b)).slice(0, RESULT_LIMIT);
27447
27457
  return {
27448
- data,
27449
- usage: {
27450
- provider: "Semrush",
27451
- units: credits,
27452
- unitLabel: "units",
27453
- estimatedUsd: estimateUsd(credits),
27454
- calls: 1
27455
- }
27458
+ kind,
27459
+ sourceSemantics: "Bing weekly top-list evidence. Missing dimensions are unknown, not zero. Movements require observation in every week of both periods.",
27460
+ periods: {
27461
+ weeksPerPeriod: WEEKLY_PERIODS,
27462
+ currentDates,
27463
+ previousDates
27464
+ },
27465
+ coverage: {
27466
+ sourceRows: rows.length,
27467
+ currentDimensions: current.size,
27468
+ previousDimensions: previous.size,
27469
+ matchedDimensions: matchedValues.length,
27470
+ comparableDimensions: comparableValues.length,
27471
+ incompleteMatchedDimensions: matchedValues.length - comparableValues.length,
27472
+ currentOnlyDimensions: [...current.keys()].filter((value) => !previous.has(value)).length,
27473
+ previousOnlyDimensions: [...previous.keys()].filter((value) => !current.has(value)).length
27474
+ },
27475
+ thresholds: {
27476
+ heuristic: true,
27477
+ opportunityMinImpressions: OPPORTUNITY_MIN_IMPRESSIONS,
27478
+ opportunityPositionRange: [OPPORTUNITY_MIN_POSITION, OPPORTUNITY_MAX_POSITION],
27479
+ resultLimit: RESULT_LIMIT
27480
+ },
27481
+ movements,
27482
+ opportunities
27456
27483
  };
27457
27484
  }
27458
27485
  //#endregion
27459
- //#region packages/core/dist/providers/semrush/mappers.js
27460
- function mapOverview(rows) {
27461
- const [header, first] = rows;
27462
- if (!header || !first) return { phrase: "" };
27463
- const record = Object.fromEntries(header.map((key, index) => [key, first[index]]));
27464
- return {
27465
- phrase: record.Ph ?? "",
27466
- volume: Number(record.Nq ?? 0) || void 0,
27467
- cpc: Number(record.Cp ?? 0) || void 0,
27468
- competition: Number(record.Co ?? 0) || void 0,
27469
- difficulty: Number(record.Kd ?? 0) || void 0,
27470
- results: Number(record.Nr ?? 0) || void 0
27486
+ //#region packages/core/dist/bing/client.js
27487
+ const DEFAULT_BASE_URL = "https://ssl.bing.com/webmaster/api.svc/json";
27488
+ const MAX_RESPONSE_BYTES$1 = 2e6;
27489
+ const MAX_STAT_ROWS = 400;
27490
+ const MAX_DIMENSION_ROWS = 8e3;
27491
+ const MAX_LINK_ROWS = 200;
27492
+ function bingDate(value) {
27493
+ if (typeof value !== "string") return void 0;
27494
+ const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
27495
+ const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
27496
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString().slice(0, 10);
27497
+ }
27498
+ function nonnegativeNumber(value) {
27499
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
27500
+ }
27501
+ function positionNumber(value) {
27502
+ if (value === void 0 || value === null || value === -1) return { valid: true };
27503
+ const number = nonnegativeNumber(value);
27504
+ return number === void 0 ? { valid: false } : {
27505
+ valid: true,
27506
+ value: number
27507
+ };
27508
+ }
27509
+ function isHttpUrl(value) {
27510
+ if (typeof value !== "string" || value.length > 2e3) return false;
27511
+ try {
27512
+ const url = new URL(value);
27513
+ return url.protocol === "http:" || url.protocol === "https:";
27514
+ } catch {
27515
+ return false;
27516
+ }
27517
+ }
27518
+ function boundedRows(rows, invalidRows, limit = MAX_STAT_ROWS) {
27519
+ const capped = rows.length > limit;
27520
+ return {
27521
+ rows: capped ? rows.slice(-limit) : rows,
27522
+ invalidRows,
27523
+ capped,
27524
+ returnedRows: rows.length
27525
+ };
27526
+ }
27527
+ function dimensionValue(value, kind) {
27528
+ if (typeof value !== "string") return void 0;
27529
+ const normalized = value.trim();
27530
+ if (!normalized || normalized.length > 2e3) return void 0;
27531
+ if (kind === "page" && !isHttpUrl(normalized)) return void 0;
27532
+ return normalized;
27533
+ }
27534
+ function errorMessage(body) {
27535
+ try {
27536
+ const parsed = JSON.parse(body);
27537
+ return typeof parsed.Message === "string" ? parsed.Message : void 0;
27538
+ } catch {
27539
+ return;
27540
+ }
27541
+ }
27542
+ var BingWebmasterClient = class {
27543
+ credentials;
27544
+ authentication;
27545
+ baseUrl;
27546
+ fetchImpl;
27547
+ timeoutMs;
27548
+ constructor(credentials, options = {}) {
27549
+ this.credentials = credentials;
27550
+ if (!(credentials.apiKey?.trim() || credentials.accessToken?.trim())) throw new SeoError("AUTH_REQUIRED", "Bing Webmaster credentials are empty.");
27551
+ this.authentication = credentials.apiKey ? "api-key" : "oauth";
27552
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
27553
+ this.fetchImpl = options.fetchImpl ?? publicHttpFetch;
27554
+ this.timeoutMs = options.timeoutMs ?? 3e4;
27555
+ }
27556
+ async request(method, params = {}) {
27557
+ const url = new URL(`${this.baseUrl}/${method}`);
27558
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
27559
+ if (this.credentials.apiKey) url.searchParams.set("apikey", this.credentials.apiKey);
27560
+ let response;
27561
+ try {
27562
+ response = await this.fetchImpl(url.toString(), {
27563
+ headers: {
27564
+ accept: "application/json",
27565
+ ...this.credentials.accessToken ? { authorization: `Bearer ${this.credentials.accessToken}` } : {}
27566
+ },
27567
+ signal: AbortSignal.timeout(this.timeoutMs)
27568
+ });
27569
+ } catch (error) {
27570
+ if (error instanceof SeoError) throw error;
27571
+ throw new SeoError("PROVIDER_UNAVAILABLE", `Bing Webmaster did not respond: ${error instanceof Error ? error.message : String(error)}`);
27572
+ }
27573
+ const body = await readBoundedResponseText(response, MAX_RESPONSE_BYTES$1, "Bing Webmaster response");
27574
+ if (!response.ok) {
27575
+ const message = errorMessage(body);
27576
+ if (response.status === 401 || /invalidapikey|unauthorized/i.test(message ?? "")) throw new SeoError("AUTH_REQUIRED", "Bing Webmaster rejected the saved credential. Run `seo providers bing connect` again.");
27577
+ if (response.status === 403) throw new SeoError("ACCESS_DENIED", "Bing Webmaster denied access to this site. Check that it is verified for the connected account.");
27578
+ if (response.status === 429) throw new SeoError("RATE_LIMITED", "Bing Webmaster rate limited this request. Try again later.");
27579
+ throw new SeoError("PROVIDER_UNAVAILABLE", `Bing Webmaster request failed with HTTP ${response.status}${message ? ` (${message})` : ""}.`);
27580
+ }
27581
+ try {
27582
+ return JSON.parse(body).d;
27583
+ } catch {
27584
+ throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid JSON.");
27585
+ }
27586
+ }
27587
+ async listSites() {
27588
+ const data = await this.request("GetUserSites");
27589
+ if (!Array.isArray(data)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned an invalid site list.");
27590
+ const sites = [];
27591
+ let invalidRows = 0;
27592
+ for (const item of data) {
27593
+ if (!item || typeof item !== "object") {
27594
+ invalidRows += 1;
27595
+ continue;
27596
+ }
27597
+ const row = item;
27598
+ if (typeof row.Url !== "string" || typeof row.IsVerified !== "boolean") {
27599
+ invalidRows += 1;
27600
+ continue;
27601
+ }
27602
+ sites.push({
27603
+ url: row.Url,
27604
+ isVerified: row.IsVerified
27605
+ });
27606
+ }
27607
+ sites.sort((a, b) => a.url.localeCompare(b.url, "en"));
27608
+ return {
27609
+ sites,
27610
+ invalidRows
27611
+ };
27612
+ }
27613
+ async getTraffic(siteUrl) {
27614
+ const data = await this.request("GetRankAndTrafficStats", { siteUrl });
27615
+ if (!Array.isArray(data)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid traffic statistics.");
27616
+ const rows = [];
27617
+ let invalidRows = 0;
27618
+ for (const item of data) {
27619
+ if (!item || typeof item !== "object") {
27620
+ invalidRows += 1;
27621
+ continue;
27622
+ }
27623
+ const row = item;
27624
+ const date = bingDate(row.Date);
27625
+ const clicks = nonnegativeNumber(row.Clicks);
27626
+ const impressions = nonnegativeNumber(row.Impressions);
27627
+ if (!date || clicks === void 0 || impressions === void 0) {
27628
+ invalidRows += 1;
27629
+ continue;
27630
+ }
27631
+ rows.push({
27632
+ date,
27633
+ clicks,
27634
+ impressions
27635
+ });
27636
+ }
27637
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.clicks - b.clicks || a.impressions - b.impressions);
27638
+ return boundedRows(rows, invalidRows);
27639
+ }
27640
+ async getCrawlStats(siteUrl) {
27641
+ const data = await this.request("GetCrawlStats", { siteUrl });
27642
+ if (!Array.isArray(data)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid crawl statistics.");
27643
+ const fieldMap = {
27644
+ AllOtherCodes: "allOtherCodes",
27645
+ BlockedByRobotsTxt: "blockedByRobotsTxt",
27646
+ Code2xx: "code2xx",
27647
+ Code301: "code301",
27648
+ Code302: "code302",
27649
+ Code4xx: "code4xx",
27650
+ Code5xx: "code5xx",
27651
+ ConnectionTimeout: "connectionTimeout",
27652
+ ContainsMalware: "containsMalware",
27653
+ CrawledPages: "crawledPages",
27654
+ CrawlErrors: "crawlErrors",
27655
+ DnsFailures: "dnsFailures",
27656
+ InIndex: "inIndex",
27657
+ InLinks: "inLinks"
27658
+ };
27659
+ const rows = [];
27660
+ let invalidRows = 0;
27661
+ for (const item of data) {
27662
+ if (!item || typeof item !== "object") {
27663
+ invalidRows += 1;
27664
+ continue;
27665
+ }
27666
+ const source = item;
27667
+ const date = bingDate(source.Date);
27668
+ if (!date) {
27669
+ invalidRows += 1;
27670
+ continue;
27671
+ }
27672
+ const row = { date };
27673
+ let invalid = false;
27674
+ for (const [providerField, field] of Object.entries(fieldMap)) {
27675
+ const value = source[providerField];
27676
+ if (value === void 0 || value === null) continue;
27677
+ const number = nonnegativeNumber(value);
27678
+ if (number === void 0) {
27679
+ invalid = true;
27680
+ break;
27681
+ }
27682
+ row[field] = number;
27683
+ }
27684
+ if (invalid) {
27685
+ invalidRows += 1;
27686
+ continue;
27687
+ }
27688
+ rows.push(row);
27689
+ }
27690
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en"));
27691
+ return boundedRows(rows, invalidRows);
27692
+ }
27693
+ async getQueryStats(siteUrl) {
27694
+ return this.getDimensionStats("GetQueryStats", siteUrl, "query");
27695
+ }
27696
+ async getPageStats(siteUrl) {
27697
+ return this.getDimensionStats("GetPageStats", siteUrl, "page");
27698
+ }
27699
+ async getDimensionStats(method, siteUrl, kind) {
27700
+ const data = await this.request(method, { siteUrl });
27701
+ if (!Array.isArray(data)) throw new SeoError("PROVIDER_UNAVAILABLE", `Bing Webmaster returned invalid ${kind} statistics.`);
27702
+ const rows = [];
27703
+ let invalidRows = 0;
27704
+ for (const item of data) {
27705
+ if (!item || typeof item !== "object") {
27706
+ invalidRows += 1;
27707
+ continue;
27708
+ }
27709
+ const source = item;
27710
+ const date = bingDate(source.Date);
27711
+ const value = dimensionValue(source.Query, kind);
27712
+ const clicks = nonnegativeNumber(source.Clicks);
27713
+ const impressions = nonnegativeNumber(source.Impressions);
27714
+ const clickPosition = positionNumber(source.AvgClickPosition);
27715
+ const impressionPosition = positionNumber(source.AvgImpressionPosition);
27716
+ if (!date || !value || clicks === void 0 || impressions === void 0 || !clickPosition.valid || !impressionPosition.valid) {
27717
+ invalidRows += 1;
27718
+ continue;
27719
+ }
27720
+ rows.push({
27721
+ date,
27722
+ value,
27723
+ clicks,
27724
+ impressions,
27725
+ avgClickPosition: clickPosition.value,
27726
+ avgImpressionPosition: impressionPosition.value
27727
+ });
27728
+ }
27729
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.value.localeCompare(b.value, "en"));
27730
+ return boundedRows(rows, invalidRows, MAX_DIMENSION_ROWS);
27731
+ }
27732
+ async getLinkCounts(siteUrl, page) {
27733
+ const data = await this.request("GetLinkCounts", {
27734
+ siteUrl,
27735
+ page: String(page)
27736
+ });
27737
+ if (!data || typeof data !== "object") throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid link counts.");
27738
+ const response = data;
27739
+ if (!Array.isArray(response.Links)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid link counts.");
27740
+ const rows = [];
27741
+ let invalidRows = 0;
27742
+ for (const item of response.Links) {
27743
+ if (!item || typeof item !== "object") {
27744
+ invalidRows += 1;
27745
+ continue;
27746
+ }
27747
+ const row = item;
27748
+ const count = nonnegativeNumber(row.Count);
27749
+ if (!isHttpUrl(row.Url) || count === void 0) {
27750
+ invalidRows += 1;
27751
+ continue;
27752
+ }
27753
+ rows.push({
27754
+ url: row.Url,
27755
+ count
27756
+ });
27757
+ }
27758
+ rows.sort((a, b) => b.count - a.count || a.url.localeCompare(b.url, "en"));
27759
+ return linkPage(rows, response.TotalPages, invalidRows);
27760
+ }
27761
+ async getUrlLinks(siteUrl, link, page) {
27762
+ const data = await this.request("GetUrlLinks", {
27763
+ siteUrl,
27764
+ link,
27765
+ page: String(page)
27766
+ });
27767
+ if (!data || typeof data !== "object") throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid referring links.");
27768
+ const response = data;
27769
+ if (!Array.isArray(response.Details)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid referring links.");
27770
+ const rows = [];
27771
+ let invalidRows = 0;
27772
+ for (const item of response.Details) {
27773
+ if (!item || typeof item !== "object") {
27774
+ invalidRows += 1;
27775
+ continue;
27776
+ }
27777
+ const row = item;
27778
+ if (!isHttpUrl(row.Url)) {
27779
+ invalidRows += 1;
27780
+ continue;
27781
+ }
27782
+ rows.push({
27783
+ url: row.Url,
27784
+ anchorText: typeof row.AnchorText === "string" && row.AnchorText.trim() ? row.AnchorText.trim() : void 0
27785
+ });
27786
+ }
27787
+ rows.sort((a, b) => a.url.localeCompare(b.url, "en") || (a.anchorText ?? "").localeCompare(b.anchorText ?? "", "en"));
27788
+ return linkPage(rows, response.TotalPages, invalidRows);
27789
+ }
27790
+ };
27791
+ function linkPage(rows, totalPagesValue, invalidRows) {
27792
+ const totalPages = nonnegativeNumber(totalPagesValue);
27793
+ if (totalPages === void 0 || !Number.isInteger(totalPages)) throw new SeoError("PROVIDER_UNAVAILABLE", "Bing Webmaster returned invalid link pagination.");
27794
+ const capped = rows.length > MAX_LINK_ROWS;
27795
+ return {
27796
+ rows: capped ? rows.slice(0, MAX_LINK_ROWS) : rows,
27797
+ totalPages,
27798
+ invalidRows,
27799
+ capped,
27800
+ returnedRows: rows.length
27801
+ };
27802
+ }
27803
+ //#endregion
27804
+ //#region packages/core/dist/storage/provider-secrets.js
27805
+ const KEYRING_SERVICE = "seo";
27806
+ function account(name) {
27807
+ return `provider:${name}`;
27808
+ }
27809
+ function readFile$1() {
27810
+ return readJsonFile(getSeoCliPaths().providerSecretsFile) ?? {
27811
+ version: 1,
27812
+ secrets: {}
27813
+ };
27814
+ }
27815
+ async function readProviderSecret(input) {
27816
+ const environment = input.env ?? process.env;
27817
+ const environmentValue = input.envVar ? environment[input.envVar]?.trim() : void 0;
27818
+ if (environmentValue) return {
27819
+ value: environmentValue,
27820
+ source: "environment"
27821
+ };
27822
+ if (readConfig().security.useKeychain) try {
27823
+ const value = await getKeyringPassword(KEYRING_SERVICE, account(input.name));
27824
+ if (value) return {
27825
+ value,
27826
+ source: "keychain"
27827
+ };
27828
+ } catch {}
27829
+ const value = readFile$1().secrets[input.name];
27830
+ return value ? {
27831
+ value,
27832
+ source: "file"
27833
+ } : void 0;
27834
+ }
27835
+ //#endregion
27836
+ //#region packages/core/dist/bing/credentials.js
27837
+ const BING_API_KEY_SECRET = "bing-api-key";
27838
+ const BING_API_KEY_ENV = "SEO_BING_API_KEY";
27839
+ async function createBingWebmasterClient(input = {}) {
27840
+ const secret = await readProviderSecret({
27841
+ name: BING_API_KEY_SECRET,
27842
+ envVar: BING_API_KEY_ENV,
27843
+ env: input.env
27844
+ });
27845
+ if (!secret) throw new SeoError("AUTH_REQUIRED", "Bing Webmaster is not connected. Run `seo providers bing connect`, or set SEO_BING_API_KEY for this process.");
27846
+ return {
27847
+ client: new BingWebmasterClient({ apiKey: secret.value }),
27848
+ credentialSource: secret.source
27849
+ };
27850
+ }
27851
+ //#endregion
27852
+ //#region packages/core/dist/bing/overview.js
27853
+ const DAILY_OUTPUT_ROWS = 14;
27854
+ function message(error) {
27855
+ return error instanceof Error ? error.message : String(error);
27856
+ }
27857
+ function range(rows) {
27858
+ return rows.length ? {
27859
+ startDate: rows[0]?.date ?? "",
27860
+ endDate: rows.at(-1)?.date ?? ""
27861
+ } : void 0;
27862
+ }
27863
+ function dailyOutput(data) {
27864
+ const rows = data.rows.slice(-14);
27865
+ return {
27866
+ ...data,
27867
+ rows,
27868
+ retainedRows: data.rows.length,
27869
+ outputSelection: {
27870
+ strategy: "most-recent",
27871
+ availableRows: data.rows.length,
27872
+ returnedRows: rows.length,
27873
+ omittedRows: Math.max(0, data.rows.length - rows.length)
27874
+ }
27875
+ };
27876
+ }
27877
+ async function section(work, options) {
27878
+ try {
27879
+ const data = await work();
27880
+ const reasons = [
27881
+ ...data.invalidRows ? [`${data.invalidRows} invalid provider rows were excluded.`] : [],
27882
+ ...data.capped ? [`The provider response exceeded the ${options.rowLimit}-row limit.`] : [],
27883
+ ...options.sourceWarning ? [options.sourceWarning] : []
27884
+ ];
27885
+ return reasons.length ? {
27886
+ status: "partial",
27887
+ data,
27888
+ warning: reasons.join(" ")
27889
+ } : {
27890
+ status: "complete",
27891
+ data
27892
+ };
27893
+ } catch (error) {
27894
+ if (error instanceof SeoError && (error.code === "AUTH_REQUIRED" || error.code === "ACCESS_DENIED")) throw error;
27895
+ return {
27896
+ status: "unavailable",
27897
+ warning: message(error)
27898
+ };
27899
+ }
27900
+ }
27901
+ function changeFinding(input) {
27902
+ return {
27903
+ code: input.code,
27904
+ severity: "warning",
27905
+ title: input.title,
27906
+ evidence: {
27907
+ current: input.current,
27908
+ previous: input.previous,
27909
+ absoluteChange: input.absolute,
27910
+ percentChange: input.percent,
27911
+ currentDate: input.dates.current,
27912
+ previousDate: input.dates.previous
27913
+ },
27914
+ interpretation: input.interpretation,
27915
+ verification: input.verification
27916
+ };
27917
+ }
27918
+ function buildFindings(input) {
27919
+ const findings = [];
27920
+ const traffic = input.traffic;
27921
+ if (traffic && traffic.current.observedDays === traffic.current.expectedDays && traffic.previous.observedDays === traffic.previous.expectedDays && (traffic.changes.clicksPercent !== null && Math.abs(traffic.changes.clicksPercent) >= 10 || traffic.changes.impressionsPercent !== null && Math.abs(traffic.changes.impressionsPercent) >= 10)) {
27922
+ const metric = traffic.changes.clicksPercent !== null && Math.abs(traffic.changes.clicksPercent) >= 10 ? "clicks" : "impressions";
27923
+ const declined = traffic.changes[metric] < 0;
27924
+ findings.push({
27925
+ code: `bing_${metric}_${declined ? "declined" : "increased"}`,
27926
+ severity: declined ? "warning" : "info",
27927
+ title: `Bing ${metric} ${declined ? "declined" : "increased"} in the latest complete period`,
27928
+ evidence: {
27929
+ currentClicks: traffic.current.clicks,
27930
+ previousClicks: traffic.previous.clicks,
27931
+ clicksChange: traffic.changes.clicks,
27932
+ clicksPercentChange: traffic.changes.clicksPercent,
27933
+ currentImpressions: traffic.current.impressions,
27934
+ previousImpressions: traffic.previous.impressions,
27935
+ impressionsChange: traffic.changes.impressions,
27936
+ impressionsPercentChange: traffic.changes.impressionsPercent,
27937
+ ctrPercentagePointChange: traffic.changes.ctrPercentagePoints,
27938
+ currentRange: {
27939
+ startDate: traffic.current.startDate,
27940
+ endDate: traffic.current.endDate
27941
+ },
27942
+ previousRange: {
27943
+ startDate: traffic.previous.startDate,
27944
+ endDate: traffic.previous.endDate
27945
+ }
27946
+ },
27947
+ interpretation: "This is a Bing-observed traffic change. It does not establish the cause or describe Google traffic.",
27948
+ verification: "Review the query and page evidence for the same dates, then compare current pages and crawl health before changing content."
27949
+ });
27950
+ }
27951
+ const crawl = input.crawl;
27952
+ if (crawl?.previous) {
27953
+ const dates = {
27954
+ current: crawl.current.date,
27955
+ previous: crawl.previous.date
27956
+ };
27957
+ const errors = crawl.changes.crawlErrors;
27958
+ if (errors && errors.absolute >= Math.max(10, Math.ceil(errors.previous * .5))) findings.push(changeFinding({
27959
+ code: "bing_crawl_errors_increased",
27960
+ title: "Bing crawl errors increased",
27961
+ ...errors,
27962
+ dates,
27963
+ interpretation: "Bing reported more crawl errors in its latest daily snapshot. The count may include URLs outside the current sitemap.",
27964
+ verification: "Run the fast sitemap health check, inspect Bing crawl issue samples, then audit any affected URLs directly."
27965
+ }));
27966
+ const code4xx = crawl.changes.code4xx;
27967
+ if (code4xx && code4xx.absolute >= Math.max(10, Math.ceil(code4xx.previous * .5))) findings.push(changeFinding({
27968
+ code: "bing_4xx_increased",
27969
+ title: "Bing-observed 4xx responses increased",
27970
+ ...code4xx,
27971
+ dates,
27972
+ interpretation: "Bing encountered more 4xx responses. Intentional removals and obsolete discovered URLs can contribute to this count.",
27973
+ verification: "Inspect Bing crawl issue URLs and recover or redirect only URLs that should still exist or retain search value."
27974
+ }));
27975
+ const code5xx = crawl.changes.code5xx;
27976
+ if (code5xx && code5xx.current > 0 && code5xx.absolute > 0) findings.push(changeFinding({
27977
+ code: "bing_5xx_increased",
27978
+ title: "Bing-observed server errors increased",
27979
+ ...code5xx,
27980
+ dates,
27981
+ interpretation: "Bing encountered more server errors in the latest snapshot. Even a small increase deserves a live check.",
27982
+ verification: "Check current server logs and run targeted URL audits before treating the provider count as an active outage."
27983
+ }));
27984
+ }
27985
+ for (const [kind, analysis] of [["query", input.queries], ["page", input.pages]]) {
27986
+ if (!analysis?.opportunities.length) continue;
27987
+ findings.push({
27988
+ code: `bing_${kind}_opportunities`,
27989
+ severity: "review",
27990
+ title: `${analysis.opportunities.length} Bing ${kind} opportunities need review`,
27991
+ evidence: {
27992
+ retainedOpportunities: analysis.opportunities.length,
27993
+ currentDimensions: analysis.coverage.currentDimensions,
27994
+ matchedDimensions: analysis.coverage.matchedDimensions,
27995
+ comparableDimensions: analysis.coverage.comparableDimensions,
27996
+ thresholds: analysis.thresholds
27997
+ },
27998
+ interpretation: "These are high-impression top-list entries currently observed around positions 4 to 20. The threshold is a prioritization heuristic, not a search-engine rule.",
27999
+ verification: kind === "query" ? "Check intent and the ranking page before changing copy or creating content." : "Audit the selected page and its matching queries before changing internal links, titles, or content."
28000
+ });
28001
+ }
28002
+ return findings;
28003
+ }
28004
+ async function bingWebmasterOverview(input) {
28005
+ if (!input.site.trim()) throw new SeoError("INVALID_INPUT", "Pass a Bing Webmaster site URL.");
28006
+ const resolved = input.client ? {
28007
+ client: input.client,
28008
+ credentialSource: input.credentialSource ?? "environment"
28009
+ } : await createBingWebmasterClient();
28010
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
28011
+ const topListWarning = "Bing returns weekly top-list rows, so dimensions missing from a period remain unknown.";
28012
+ const [traffic, crawl, queries, pages] = await Promise.all([
28013
+ section(() => resolved.client.getTraffic(input.site), { rowLimit: 400 }),
28014
+ section(() => resolved.client.getCrawlStats(input.site), { rowLimit: 400 }),
28015
+ section(() => resolved.client.getQueryStats(input.site), {
28016
+ rowLimit: 8e3,
28017
+ sourceWarning: topListWarning
28018
+ }),
28019
+ section(() => resolved.client.getPageStats(input.site), {
28020
+ rowLimit: 8e3,
28021
+ sourceWarning: topListWarning
28022
+ })
28023
+ ]);
28024
+ const statuses = [
28025
+ traffic.status,
28026
+ crawl.status,
28027
+ queries.status,
28028
+ pages.status
28029
+ ];
28030
+ const dataStatus = statuses.every((status) => status === "complete") ? "complete" : statuses.every((status) => status === "unavailable") ? "unavailable" : "partial";
28031
+ const trafficAnalysis = traffic.status === "unavailable" ? void 0 : analyzeBingTraffic(traffic.data.rows);
28032
+ const crawlAnalysis = crawl.status === "unavailable" ? void 0 : analyzeBingCrawl(crawl.data.rows);
28033
+ const queryAnalysis = queries.status === "unavailable" ? void 0 : analyzeBingDimensions(queries.data.rows, "query");
28034
+ const pageAnalysis = pages.status === "unavailable" ? void 0 : analyzeBingDimensions(pages.data.rows, "page");
28035
+ const findings = buildFindings({
28036
+ traffic: trafficAnalysis,
28037
+ crawl: crawlAnalysis,
28038
+ queries: queryAnalysis,
28039
+ pages: pageAnalysis
28040
+ });
28041
+ return {
28042
+ schemaVersion: 2,
28043
+ site: input.site,
28044
+ generatedAt: observedAt,
28045
+ dataStatus,
28046
+ summary: {
28047
+ findings: findings.length,
28048
+ warnings: findings.filter((finding) => finding.severity === "warning").length,
28049
+ reviewItems: findings.filter((finding) => finding.severity === "review").length
28050
+ },
28051
+ findings,
28052
+ outputBudget: {
28053
+ maxFindings: 6,
28054
+ maxDailyRowsPerSection: DAILY_OUTPUT_ROWS,
28055
+ maxDailyRowsTotal: DAILY_OUTPUT_ROWS * 2,
28056
+ maxDimensionItemsPerList: 10,
28057
+ maxDimensionItemsTotal: 40
28058
+ },
28059
+ provenance: {
28060
+ provider: "bing-webmaster",
28061
+ authentication: resolved.client.authentication,
28062
+ credentialSource: resolved.credentialSource,
28063
+ observedAt,
28064
+ cached: false,
28065
+ rowLimits: {
28066
+ traffic: 400,
28067
+ crawl: 400,
28068
+ queries: 8e3,
28069
+ pages: 8e3
28070
+ },
28071
+ methods: [
28072
+ "GetRankAndTrafficStats",
28073
+ "GetCrawlStats",
28074
+ "GetQueryStats",
28075
+ "GetPageStats"
28076
+ ]
28077
+ },
28078
+ traffic: traffic.status === "unavailable" ? traffic : {
28079
+ ...traffic,
28080
+ data: {
28081
+ ...dailyOutput(traffic.data),
28082
+ range: range(traffic.data.rows),
28083
+ clicks: traffic.data.rows.reduce((total, row) => total + row.clicks, 0),
28084
+ impressions: traffic.data.rows.reduce((total, row) => total + row.impressions, 0),
28085
+ analysis: trafficAnalysis
28086
+ }
28087
+ },
28088
+ crawl: crawl.status === "unavailable" ? crawl : {
28089
+ ...crawl,
28090
+ data: {
28091
+ ...dailyOutput(crawl.data),
28092
+ range: range(crawl.data.rows),
28093
+ latest: crawl.data.rows.at(-1),
28094
+ analysis: crawlAnalysis
28095
+ }
28096
+ },
28097
+ queries: queries.status === "unavailable" ? queries : {
28098
+ ...queries,
28099
+ data: {
28100
+ returnedRows: queries.data.returnedRows,
28101
+ retainedRows: queries.data.rows.length,
28102
+ invalidRows: queries.data.invalidRows,
28103
+ capped: queries.data.capped,
28104
+ range: range(queries.data.rows),
28105
+ analysis: queryAnalysis
28106
+ }
28107
+ },
28108
+ pages: pages.status === "unavailable" ? pages : {
28109
+ ...pages,
28110
+ data: {
28111
+ returnedRows: pages.data.returnedRows,
28112
+ retainedRows: pages.data.rows.length,
28113
+ invalidRows: pages.data.invalidRows,
28114
+ capped: pages.data.capped,
28115
+ range: range(pages.data.rows),
28116
+ analysis: pageAnalysis
28117
+ }
28118
+ },
28119
+ caveats: [
28120
+ "Bing reports its own observed search and crawl evidence. It is not a complete view of every search engine.",
28121
+ "The inIndex field is provider-reported crawl statistics, not independent proof that a URL is indexed.",
28122
+ "Query and page movements include only dimensions observed in every weekly top list in both periods. Missing entries are not treated as zero.",
28123
+ "Opportunity thresholds prioritize review. They are heuristics, not search-engine requirements."
28124
+ ]
28125
+ };
28126
+ }
28127
+ //#endregion
28128
+ //#region packages/core/dist/doctor.js
28129
+ const REQUIRED_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly", "https://www.googleapis.com/auth/analytics.readonly"];
28130
+ async function runDoctor(dependencies = {}) {
28131
+ const paths = getSeoCliPaths();
28132
+ const config = readConfig();
28133
+ const authMode = getAuthModeStatus();
28134
+ const serviceAccount = getServiceAccountStatus();
28135
+ const tokens = await readTokens();
28136
+ const oauthClient = readOauthClient();
28137
+ const tokenClientConfigured = tokens ? Boolean(getClientConfig(tokens.client_source)) : void 0;
28138
+ const checks = [];
28139
+ try {
28140
+ const database = (dependencies.checkDatabase ?? checkDatabaseReadiness)();
28141
+ checks.push({
28142
+ id: "local-database",
28143
+ label: "Local database",
28144
+ status: "pass",
28145
+ detail: database.dbPath
28146
+ });
28147
+ } catch (error) {
28148
+ checks.push({
28149
+ id: "local-database",
28150
+ label: "Local database",
28151
+ status: "fail",
28152
+ detail: error instanceof Error ? error.message : "The local database could not be opened.",
28153
+ fix: "Upgrade or reinstall `seo`. If this still fails, report it at https://github.com/iannuttall/seo/issues."
28154
+ });
28155
+ }
28156
+ checks.push({
28157
+ id: "config-dir",
28158
+ label: "Config directory",
28159
+ status: existsSync(paths.configDir) ? "pass" : "warn",
28160
+ detail: paths.configDir,
28161
+ fix: existsSync(paths.configDir) ? void 0 : "Run `seo start` or `seo auth setup-client`."
28162
+ });
28163
+ checks.push({
28164
+ id: "oauth-client",
28165
+ label: "Google credentials",
28166
+ status: serviceAccount.configured ? "pass" : tokenClientConfigured === false ? "fail" : serviceAccount.error ? "fail" : authMode.sharedConfigured || authMode.byoConfigured ? "pass" : "fail",
28167
+ detail: serviceAccount.configured ? `Service account configured for ${serviceAccount.identity}.` : tokenClientConfigured === false ? `Stored Google login uses the ${tokens?.client_source === "shared" ? "shared seo app" : "BYO client"}, but that client is not configured.` : serviceAccount.error ? serviceAccount.error : authMode.sharedConfigured ? "Shared client configured." : authMode.byoConfigured ? `BYO client configured at ${paths.oauthClientFile}.` : "No shared or BYO Google OAuth client configured.",
28168
+ fix: serviceAccount.configured ? void 0 : tokenClientConfigured === false ? tokens?.client_source === "shared" ? "Reinstall `seo`. If the shared client is still missing, report it at https://github.com/iannuttall/seo/issues." : "Restore the same client with `seo auth setup-client`, or run `seo auth logout` before signing in with a different client." : serviceAccount.error ? "Set one valid service account credential source, then run `seo doctor` again." : authMode.sharedConfigured || authMode.byoConfigured ? void 0 : "Run `seo auth setup-client` or set SEO_GOOGLE_CLIENT_ID and SEO_GOOGLE_CLIENT_SECRET."
28169
+ });
28170
+ checks.push({
28171
+ id: "oauth-client-file",
28172
+ label: "BYO OAuth file",
28173
+ status: oauthClient ? "pass" : "warn",
28174
+ detail: oauthClient ? paths.oauthClientFile : "No BYO OAuth client file. This is fine if using a shared client."
28175
+ });
28176
+ checks.push({
28177
+ id: "google-login",
28178
+ label: "Google login",
28179
+ status: serviceAccount.configured || tokens ? "pass" : "fail",
28180
+ detail: serviceAccount.configured ? `Using service account ${serviceAccount.identity}.` : tokens ? `Signed in as ${tokens.account_email}.` : "No Google token found.",
28181
+ fix: serviceAccount.configured || tokens ? void 0 : "Run `seo auth login`, or configure a service account for CI."
28182
+ });
28183
+ const grantedScopes = new Set(tokens?.scope.split(/\s+/).filter(Boolean) ?? []);
28184
+ for (const scope of REQUIRED_SCOPES) checks.push({
28185
+ id: `scope:${scope}`,
28186
+ label: scope.endsWith("webmasters.readonly") ? "Search Console scope" : "Google Analytics scope",
28187
+ status: serviceAccount.configured || tokens && grantedScopes.has(scope) ? "pass" : tokens ? "fail" : "warn",
28188
+ detail: serviceAccount.configured ? `${scope} requested by the service account.` : tokens ? grantedScopes.has(scope) ? scope : `Missing ${scope}.` : "Cannot inspect scopes until login is complete.",
28189
+ fix: !serviceAccount.configured && tokens && !grantedScopes.has(scope) ? "Run `seo auth logout` then `seo auth login`." : void 0
28190
+ });
28191
+ checks.push({
28192
+ id: "default-site",
28193
+ label: "Saved GSC property",
28194
+ status: "pass",
28195
+ detail: config.defaultSite ?? "No saved default. Human CLI commands will prompt; agents can pass --site."
28196
+ });
28197
+ checks.push({
28198
+ id: "default-google-analytics",
28199
+ label: "Saved Google Analytics property",
28200
+ status: "pass",
28201
+ detail: config.analytics.google.defaultPropertyId ?? "No saved default. Human CLI commands will prompt; agents can pass --property."
28202
+ });
28203
+ return {
28204
+ ok: checks.every((check) => check.status !== "fail"),
28205
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28206
+ checks
28207
+ };
28208
+ }
28209
+ //#endregion
28210
+ //#region packages/core/dist/links/normalize.js
28211
+ const FIELD_ALIASES = {
28212
+ sourceUrl: [
28213
+ "sourceurl",
28214
+ "source",
28215
+ "referringurl",
28216
+ "referringpage",
28217
+ "fromurl"
28218
+ ],
28219
+ targetUrl: [
28220
+ "targeturl",
28221
+ "target",
28222
+ "destinationurl",
28223
+ "destination",
28224
+ "tourl"
28225
+ ],
28226
+ anchorText: ["anchortext", "anchor"],
28227
+ firstSeenAt: ["firstseenat", "firstseen"],
28228
+ lastSeenAt: ["lastseenat", "lastseen"],
28229
+ nofollow: ["nofollow", "isnofollow"]
28230
+ };
28231
+ function key(value) {
28232
+ return value.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
28233
+ }
28234
+ function valueFor(row, field) {
28235
+ const aliases = new Set(FIELD_ALIASES[field]);
28236
+ for (const [name, value] of Object.entries(row)) if (aliases.has(key(name))) return value;
28237
+ }
28238
+ function httpUrl(value) {
28239
+ if (typeof value !== "string" || !value.trim() || value.trim().length > 2e3) return;
28240
+ try {
28241
+ const url = new URL(value.trim());
28242
+ return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
28243
+ } catch {
28244
+ return;
28245
+ }
28246
+ }
28247
+ function optionalString(value) {
28248
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, 1e3) : void 0;
28249
+ }
28250
+ function optionalBoolean(value) {
28251
+ if (typeof value === "boolean") return value;
28252
+ if (typeof value !== "string") return void 0;
28253
+ if (/^(true|yes|1)$/i.test(value.trim())) return true;
28254
+ if (/^(false|no|0)$/i.test(value.trim())) return false;
28255
+ }
28256
+ function normalizeLinkEvidenceRow(raw) {
28257
+ const source = httpUrl(valueFor(raw, "sourceUrl"));
28258
+ const target = httpUrl(valueFor(raw, "targetUrl"));
28259
+ if (!source || !target) return void 0;
28260
+ return {
28261
+ sourceUrl: source.toString(),
28262
+ sourceDomain: source.hostname.toLowerCase(),
28263
+ targetUrl: target.toString(),
28264
+ anchorText: optionalString(valueFor(raw, "anchorText")),
28265
+ firstSeenAt: optionalString(valueFor(raw, "firstSeenAt")),
28266
+ lastSeenAt: optionalString(valueFor(raw, "lastSeenAt")),
28267
+ nofollow: optionalBoolean(valueFor(raw, "nofollow"))
28268
+ };
28269
+ }
28270
+ function linkEvidenceKey(row) {
28271
+ return `${row.sourceUrl}\u0000${row.targetUrl}\u0000${row.anchorText ?? ""}`;
28272
+ }
28273
+ function compareLinkEvidence(a, b) {
28274
+ return a.targetUrl.localeCompare(b.targetUrl, "en") || a.sourceUrl.localeCompare(b.sourceUrl, "en") || (a.anchorText ?? "").localeCompare(b.anchorText ?? "", "en");
28275
+ }
28276
+ //#endregion
28277
+ //#region packages/core/dist/links/bing.js
28278
+ const MAX_COUNT_PAGES = 5;
28279
+ const DEFAULT_TARGET_LIMIT = 20;
28280
+ const MAX_TARGET_LIMIT = 50;
28281
+ const DEFAULT_DETAIL_PAGES = 1;
28282
+ const MAX_DETAIL_PAGES = 3;
28283
+ const DEFAULT_ROW_LIMIT$2 = 500;
28284
+ const MAX_ROW_LIMIT$2 = 1e3;
28285
+ const MAX_CONCURRENCY = 3;
28286
+ function integer(value, fallback, max, name) {
28287
+ const resolved = value ?? fallback;
28288
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > max) throw new SeoError("INVALID_INPUT", `${name} must be between 1 and ${max}.`);
28289
+ return resolved;
28290
+ }
28291
+ async function mapConcurrent(values, concurrency, work) {
28292
+ const output = new Array(values.length);
28293
+ let next = 0;
28294
+ async function worker() {
28295
+ while (next < values.length) {
28296
+ const index = next;
28297
+ next += 1;
28298
+ const value = values[index];
28299
+ if (value !== void 0) output[index] = await work(value);
28300
+ }
28301
+ }
28302
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
28303
+ return output;
28304
+ }
28305
+ async function targetDetails(input) {
28306
+ const rows = [];
28307
+ let requestedPages = 0;
28308
+ let invalidRows = 0;
28309
+ let returnedRows = 0;
28310
+ let partial = false;
28311
+ let totalPages = 0;
28312
+ for (let page = 0; page < input.pages; page += 1) {
28313
+ requestedPages += 1;
28314
+ let response;
28315
+ try {
28316
+ response = await input.client.getUrlLinks(input.site, input.target.url, page);
28317
+ } catch (error) {
28318
+ if (error instanceof SeoError && (error.code === "AUTH_REQUIRED" || error.code === "ACCESS_DENIED")) throw error;
28319
+ return {
28320
+ rows,
28321
+ requestedPages,
28322
+ invalidRows,
28323
+ returnedRows,
28324
+ partial: true,
28325
+ failed: true
28326
+ };
28327
+ }
28328
+ invalidRows += response.invalidRows;
28329
+ returnedRows += response.returnedRows;
28330
+ rows.push(...response.rows);
28331
+ totalPages = response.totalPages;
28332
+ partial ||= response.capped;
28333
+ if (page + 1 >= response.totalPages || response.rows.length === 0) break;
28334
+ }
28335
+ partial ||= requestedPages < totalPages;
28336
+ return {
28337
+ rows,
28338
+ requestedPages,
28339
+ invalidRows,
28340
+ returnedRows,
28341
+ partial,
28342
+ failed: false
28343
+ };
28344
+ }
28345
+ async function collectBingLinkEvidence(input) {
28346
+ if (!input.site.trim()) throw new SeoError("INVALID_INPUT", "Pass a Bing Webmaster site URL.");
28347
+ const targetLimit = integer(input.targetLimit, DEFAULT_TARGET_LIMIT, MAX_TARGET_LIMIT, "Bing link target limit");
28348
+ const detailPages = integer(input.detailPagesPerTarget, DEFAULT_DETAIL_PAGES, MAX_DETAIL_PAGES, "Bing detail page limit");
28349
+ const rowLimit = integer(input.rowLimit, DEFAULT_ROW_LIMIT$2, MAX_ROW_LIMIT$2, "Bing link row limit");
28350
+ const client = input.client ?? (await createBingWebmasterClient()).client;
28351
+ const countRows = [];
28352
+ let countPagesRequested = 0;
28353
+ let invalidRows = 0;
28354
+ let countInvalidRows = 0;
28355
+ let countRowsReturned = 0;
28356
+ let partial = false;
28357
+ let totalCountPages = 0;
28358
+ for (let page = 0; page < MAX_COUNT_PAGES; page += 1) {
28359
+ const response = await client.getLinkCounts(input.site, page);
28360
+ countPagesRequested += 1;
28361
+ invalidRows += response.invalidRows;
28362
+ countInvalidRows += response.invalidRows;
28363
+ countRowsReturned += response.returnedRows;
28364
+ countRows.push(...response.rows);
28365
+ totalCountPages = response.totalPages;
28366
+ partial ||= response.capped;
28367
+ if (countRows.length >= targetLimit || page + 1 >= response.totalPages || response.rows.length === 0) break;
28368
+ }
28369
+ partial ||= countPagesRequested < totalCountPages;
28370
+ const countsByUrl = /* @__PURE__ */ new Map();
28371
+ for (const row of countRows) countsByUrl.set(row.url, Math.max(row.count, countsByUrl.get(row.url) ?? 0));
28372
+ const counts = [...countsByUrl].map(([url, count]) => ({
28373
+ url,
28374
+ count
28375
+ }));
28376
+ counts.sort((a, b) => b.count - a.count || a.url.localeCompare(b.url, "en"));
28377
+ const selectedTargets = counts.slice(0, targetLimit);
28378
+ partial ||= counts.length > targetLimit;
28379
+ const details = await mapConcurrent(selectedTargets, MAX_CONCURRENCY, async (target) => targetDetails({
28380
+ client,
28381
+ site: input.site,
28382
+ target,
28383
+ pages: detailPages
28384
+ }));
28385
+ const rows = [];
28386
+ const keys = /* @__PURE__ */ new Set();
28387
+ let duplicateRows = 0;
28388
+ let detailPagesRequested = 0;
28389
+ let detailInvalidRows = 0;
28390
+ let detailRowsReturned = 0;
28391
+ let failedTargets = 0;
28392
+ for (const [index, detail] of details.entries()) {
28393
+ const target = selectedTargets[index];
28394
+ if (!target) continue;
28395
+ detailPagesRequested += detail.requestedPages;
28396
+ if (detail.failed) failedTargets += 1;
28397
+ invalidRows += detail.invalidRows;
28398
+ detailInvalidRows += detail.invalidRows;
28399
+ detailRowsReturned += detail.returnedRows;
28400
+ partial ||= detail.partial;
28401
+ for (const source of detail.rows) {
28402
+ if (rows.length >= rowLimit) {
28403
+ partial = true;
28404
+ break;
28405
+ }
28406
+ const row = normalizeLinkEvidenceRow({
28407
+ sourceUrl: source.url,
28408
+ targetUrl: target.url,
28409
+ anchorText: source.anchorText
28410
+ });
28411
+ if (!row) {
28412
+ invalidRows += 1;
28413
+ continue;
28414
+ }
28415
+ const key = linkEvidenceKey(row);
28416
+ if (keys.has(key)) {
28417
+ duplicateRows += 1;
28418
+ continue;
28419
+ }
28420
+ keys.add(key);
28421
+ rows.push(row);
28422
+ }
28423
+ }
28424
+ rows.sort(compareLinkEvidence);
28425
+ return {
28426
+ rows,
28427
+ targetCounts: selectedTargets.map((target) => ({
28428
+ targetUrl: target.url,
28429
+ providerReportedLinks: target.count,
28430
+ observedLinks: rows.filter((row) => row.targetUrl === target.url).length
28431
+ })),
28432
+ provenance: {
28433
+ provider: "bing-webmaster",
28434
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
28435
+ cached: false,
28436
+ suppliedRows: detailRowsReturned + detailInvalidRows,
28437
+ validRows: rows.length,
28438
+ invalidRows,
28439
+ duplicateRows,
28440
+ capped: partial,
28441
+ rowLimit,
28442
+ completeness: partial ? "partial" : "unknown",
28443
+ providerRequests: {
28444
+ methods: ["GetLinkCounts", "GetUrlLinks"],
28445
+ targetPagesRequested: countPagesRequested,
28446
+ detailPagesRequested,
28447
+ maxConcurrentRequests: MAX_CONCURRENCY
28448
+ },
28449
+ providerCoverage: {
28450
+ targetCountRows: {
28451
+ returnedRows: countRowsReturned,
28452
+ retainedRows: counts.length,
28453
+ invalidRows: countInvalidRows
28454
+ },
28455
+ detailRows: {
28456
+ returnedRows: detailRowsReturned,
28457
+ retainedRows: rows.length,
28458
+ invalidRows: detailInvalidRows
28459
+ }
28460
+ }
28461
+ },
28462
+ warnings: [
28463
+ ...countRowsReturned === 0 ? ["Bing returned no target-link rows. This does not prove that the site has no links."] : [],
28464
+ ...partial ? ["Bing link evidence was bounded by provider pagination, failed requests, or report limits."] : [],
28465
+ ...failedTargets ? [`${failedTargets} target detail request${failedTargets === 1 ? "" : "s"} failed while other evidence was retained.`] : []
28466
+ ]
28467
+ };
28468
+ }
28469
+ //#endregion
28470
+ //#region packages/core/dist/links/import.js
28471
+ const DEFAULT_ROW_LIMIT$1 = 1e4;
28472
+ const MAX_ROW_LIMIT$1 = 1e5;
28473
+ const MAX_STREAM_BYTES = 5e7;
28474
+ const MAX_JSON_BYTES = 1e7;
28475
+ const MAX_CELL_BYTES = 1e6;
28476
+ function formatFor$1(path, explicit) {
28477
+ if (explicit) return explicit;
28478
+ const extension = extname(path).toLowerCase();
28479
+ if (extension === ".csv") return "csv";
28480
+ if (extension === ".jsonl" || extension === ".ndjson") return "jsonl";
28481
+ if (extension === ".json") return "json";
28482
+ throw new SeoError("INVALID_INPUT", "Link imports must be CSV, JSON, JSONL, or NDJSON.");
28483
+ }
28484
+ function boundedRowLimit(value) {
28485
+ const limit = value ?? DEFAULT_ROW_LIMIT$1;
28486
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ROW_LIMIT$1) throw new SeoError("INVALID_INPUT", `Link import row limit must be between 1 and ${MAX_ROW_LIMIT$1}.`);
28487
+ return limit;
28488
+ }
28489
+ async function* csvRecords(path, byteLimit, progress) {
28490
+ const stream = createReadStream(path, { encoding: "utf8" });
28491
+ let record = [];
28492
+ let field = "";
28493
+ let quoted = false;
28494
+ let pendingQuote = false;
28495
+ for await (const chunk of stream) {
28496
+ progress.bytesRead += Buffer.byteLength(chunk);
28497
+ if (progress.bytesRead > byteLimit) {
28498
+ stream.destroy();
28499
+ throw new SeoError("INVALID_INPUT", `Link import exceeds the ${byteLimit}-byte streaming limit.`);
28500
+ }
28501
+ for (const character of chunk) {
28502
+ if (pendingQuote) {
28503
+ if (character === "\"") {
28504
+ field += "\"";
28505
+ pendingQuote = false;
28506
+ continue;
28507
+ }
28508
+ quoted = false;
28509
+ pendingQuote = false;
28510
+ }
28511
+ if (quoted) if (character === "\"") pendingQuote = true;
28512
+ else field += character;
28513
+ else if (character === "\"" && field.length === 0) quoted = true;
28514
+ else if (character === ",") {
28515
+ record.push(field);
28516
+ field = "";
28517
+ } else if (character === "\n") {
28518
+ record.push(field.replace(/\r$/, ""));
28519
+ field = "";
28520
+ if (record.some((value) => value.length > 0)) yield record;
28521
+ record = [];
28522
+ } else field += character;
28523
+ if (Buffer.byteLength(field) > MAX_CELL_BYTES) {
28524
+ stream.destroy();
28525
+ throw new SeoError("INVALID_INPUT", `Link import contains a cell larger than ${MAX_CELL_BYTES} bytes.`);
28526
+ }
28527
+ }
28528
+ }
28529
+ if (quoted && !pendingQuote) throw new SeoError("INVALID_INPUT", "Link CSV contains an unclosed quote.");
28530
+ if (field || record.length) {
28531
+ record.push(field.replace(/\r$/, ""));
28532
+ if (record.some((value) => value.length > 0)) yield record;
28533
+ }
28534
+ }
28535
+ async function* jsonlRecords(path, byteLimit, progress) {
28536
+ const stream = createReadStream(path, { encoding: "utf8" });
28537
+ let remainder = "";
28538
+ for await (const chunk of stream) {
28539
+ progress.bytesRead += Buffer.byteLength(chunk);
28540
+ if (progress.bytesRead > byteLimit) {
28541
+ stream.destroy();
28542
+ throw new SeoError("INVALID_INPUT", `Link import exceeds the ${byteLimit}-byte streaming limit.`);
28543
+ }
28544
+ remainder += chunk;
28545
+ let newline = remainder.indexOf("\n");
28546
+ while (newline >= 0) {
28547
+ const line = remainder.slice(0, newline).trim();
28548
+ remainder = remainder.slice(newline + 1);
28549
+ if (line) yield parseObject(line);
28550
+ newline = remainder.indexOf("\n");
28551
+ }
28552
+ if (Buffer.byteLength(remainder) > MAX_CELL_BYTES) {
28553
+ stream.destroy();
28554
+ throw new SeoError("INVALID_INPUT", `Link import contains a line larger than ${MAX_CELL_BYTES} bytes.`);
28555
+ }
28556
+ }
28557
+ if (remainder.trim()) yield parseObject(remainder);
28558
+ }
28559
+ function parseObject(value) {
28560
+ try {
28561
+ const parsed = JSON.parse(value);
28562
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
28563
+ return parsed;
28564
+ } catch {
28565
+ throw new SeoError("INVALID_INPUT", "Link JSONL must contain one JSON object per line.");
28566
+ }
28567
+ }
28568
+ function rowFromCsv(headers, values) {
28569
+ return Object.fromEntries(headers.map((header, index) => [header, values[index]]));
28570
+ }
28571
+ async function importLinkEvidence(input) {
28572
+ const path = resolve(input.file);
28573
+ const file = await stat(path).catch(() => void 0);
28574
+ if (!file?.isFile()) throw new SeoError("INVALID_INPUT", `Link import file was not found: ${path}`);
28575
+ const format = formatFor$1(path, input.format);
28576
+ const rowLimit = boundedRowLimit(input.rowLimit);
28577
+ if (format === "json" && file.size > MAX_JSON_BYTES) throw new SeoError("INVALID_INPUT", `JSON link imports are limited to ${MAX_JSON_BYTES} bytes. Use CSV or JSONL for larger files.`);
28578
+ const progress = { bytesRead: 0 };
28579
+ let records;
28580
+ if (format === "csv") {
28581
+ const csv = csvRecords(path, MAX_STREAM_BYTES, progress);
28582
+ const first = await csv.next();
28583
+ const headers = (Array.isArray(first.value) ? first.value : []).map((value, index) => index === 0 ? value.replace(/^\uFEFF/, "") : value);
28584
+ if (!headers.length) throw new SeoError("INVALID_INPUT", "Link CSV has no header row.");
28585
+ records = (async function* () {
28586
+ for await (const values of csv) yield rowFromCsv(headers, values);
28587
+ })();
28588
+ } else if (format === "jsonl") records = jsonlRecords(path, MAX_STREAM_BYTES, progress);
28589
+ else {
28590
+ const body = await readFile(path, "utf8");
28591
+ progress.bytesRead = Buffer.byteLength(body);
28592
+ let parsed;
28593
+ try {
28594
+ parsed = JSON.parse(body);
28595
+ } catch {
28596
+ throw new SeoError("INVALID_INPUT", "Link JSON is not valid JSON.");
28597
+ }
28598
+ if (!Array.isArray(parsed)) throw new SeoError("INVALID_INPUT", "Link JSON must be an array of objects.");
28599
+ records = (async function* () {
28600
+ for (const row of parsed) yield row && typeof row === "object" && !Array.isArray(row) ? row : {};
28601
+ })();
28602
+ }
28603
+ const rows = [];
28604
+ const keys = /* @__PURE__ */ new Set();
28605
+ let suppliedRows = 0;
28606
+ let invalidRows = 0;
28607
+ let duplicateRows = 0;
28608
+ let capped = false;
28609
+ for await (const raw of records) {
28610
+ if (suppliedRows >= rowLimit) {
28611
+ capped = true;
28612
+ break;
28613
+ }
28614
+ suppliedRows += 1;
28615
+ const row = normalizeLinkEvidenceRow(raw);
28616
+ if (!row) {
28617
+ invalidRows += 1;
28618
+ continue;
28619
+ }
28620
+ const key = linkEvidenceKey(row);
28621
+ if (keys.has(key)) {
28622
+ duplicateRows += 1;
28623
+ continue;
28624
+ }
28625
+ keys.add(key);
28626
+ rows.push(row);
28627
+ }
28628
+ rows.sort(compareLinkEvidence);
28629
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
28630
+ return {
28631
+ rows,
28632
+ targetCounts: [],
28633
+ provenance: {
28634
+ provider: `${format}-import`,
28635
+ observedAt,
28636
+ cached: false,
28637
+ suppliedRows,
28638
+ validRows: rows.length,
28639
+ invalidRows,
28640
+ duplicateRows,
28641
+ capped,
28642
+ rowLimit,
28643
+ completeness: capped ? "partial" : "unknown",
28644
+ file: {
28645
+ path,
28646
+ format,
28647
+ bytesRead: progress.bytesRead,
28648
+ fileBytes: file.size
28649
+ }
28650
+ },
28651
+ warnings: capped ? [`Import stopped after ${rowLimit} rows. The file contains more data.`] : []
28652
+ };
28653
+ }
28654
+ //#endregion
28655
+ //#region packages/core/dist/links/report.js
28656
+ const DEFAULT_OUTPUT_LIMIT = 100;
28657
+ const MAX_OUTPUT_LIMIT = 500;
28658
+ function boundedLimit(value) {
28659
+ const limit = value ?? DEFAULT_OUTPUT_LIMIT;
28660
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_OUTPUT_LIMIT) throw new SeoError("INVALID_INPUT", `Link report limit must be between 1 and ${MAX_OUTPUT_LIMIT}.`);
28661
+ return limit;
28662
+ }
28663
+ function targetCounts(rows) {
28664
+ const counts = /* @__PURE__ */ new Map();
28665
+ for (const row of rows) counts.set(row.targetUrl, (counts.get(row.targetUrl) ?? 0) + 1);
28666
+ return [...counts].map(([targetUrl, observedLinks]) => ({
28667
+ targetUrl,
28668
+ observedLinks
28669
+ })).sort((a, b) => b.observedLinks - a.observedLinks || a.targetUrl.localeCompare(b.targetUrl, "en"));
28670
+ }
28671
+ function linkEvidenceReport(input) {
28672
+ const limit = boundedLimit(input.limit);
28673
+ const rows = input.evidence.rows.slice(0, limit);
28674
+ const domains = new Set(input.evidence.rows.map((row) => row.sourceDomain));
28675
+ const observedTargets = targetCounts(input.evidence.rows);
28676
+ const counts = input.evidence.targetCounts.length ? input.evidence.targetCounts : observedTargets;
28677
+ return {
28678
+ schemaVersion: 1,
28679
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28680
+ dataStatus: input.evidence.provenance.completeness === "complete" ? "complete" : "partial",
28681
+ summary: {
28682
+ observedLinks: input.evidence.rows.length,
28683
+ referringDomains: domains.size,
28684
+ targetPages: new Set(input.evidence.rows.map((row) => row.targetUrl)).size,
28685
+ providerTargetPages: counts.length
28686
+ },
28687
+ provenance: input.evidence.provenance,
28688
+ selection: {
28689
+ availableRows: input.evidence.rows.length,
28690
+ returnedRows: rows.length,
28691
+ omittedRows: Math.max(0, input.evidence.rows.length - rows.length),
28692
+ limit
28693
+ },
28694
+ targetCounts: counts.slice(0, 100),
28695
+ links: rows,
28696
+ warnings: input.evidence.warnings,
28697
+ caveats: [
28698
+ "This is bounded provider or imported evidence, not a complete backlink index.",
28699
+ "A missing link is not proof that no link exists, and an observed link is not a quality or ranking verdict.",
28700
+ "Referring-domain counts describe the retained rows only."
28701
+ ]
28702
+ };
28703
+ }
28704
+ //#endregion
28705
+ //#region packages/core/dist/providers/dataforseo.js
28706
+ var DataForSeoProvider = class {
28707
+ name = "dataforseo";
28708
+ capabilities = { overview: true };
28709
+ getAuthHeader() {
28710
+ const config = readConfig();
28711
+ const login = config.providers.dataForSeoLogin;
28712
+ const password = config.providers.dataForSeoPassword;
28713
+ if (!login || !password) throw new Error("DataForSEO credentials missing. Add login and password to config.json.");
28714
+ return `Basic ${Buffer.from(`${login}:${password}`).toString("base64")}`;
28715
+ }
28716
+ async keywordOverview(phrase, opts = {}) {
28717
+ const response = await fetch$1("https://api.dataforseo.com/v3/dataforseo_labs/google/keyword_overview/live", {
28718
+ method: "POST",
28719
+ headers: {
28720
+ authorization: this.getAuthHeader(),
28721
+ "content-type": "application/json"
28722
+ },
28723
+ body: JSON.stringify([{
28724
+ keyword: phrase,
28725
+ language_code: "en",
28726
+ location_code: 2840
28727
+ }])
28728
+ });
28729
+ if (!response.ok) throw new Error(`DataForSEO request failed with ${response.status}.`);
28730
+ const json = await response.json();
28731
+ const row = json.tasks?.[0]?.result?.[0];
28732
+ return {
28733
+ data: {
28734
+ phrase: row?.keyword ?? phrase,
28735
+ volume: row?.search_volume,
28736
+ competition: row?.competition,
28737
+ cpc: row?.cpc,
28738
+ difficulty: row?.keyword_difficulty
28739
+ },
28740
+ usage: {
28741
+ provider: "DataForSEO",
28742
+ units: 1,
28743
+ unitLabel: "tasks",
28744
+ estimatedUsd: json.cost,
28745
+ calls: 1
28746
+ },
28747
+ warnings: opts.database ? ["DataForSEO overview currently ignores the database option."] : void 0
28748
+ };
28749
+ }
28750
+ };
28751
+ //#endregion
28752
+ //#region packages/core/dist/providers/semrush/csv.js
28753
+ function parseSemicolonCsv(text) {
28754
+ return text.trim().split("\n").map((line) => line.split(";").map((cell) => cell.replace(/^"|"$/g, "").trim()));
28755
+ }
28756
+ //#endregion
28757
+ //#region packages/core/dist/providers/semrush/cache.js
28758
+ const BASE_URL = "https://api.semrush.com/";
28759
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
28760
+ function estimateUsd(units) {
28761
+ return units / 1e3 * .05;
28762
+ }
28763
+ async function cachedSemrushCall(endpoint, params, map, ttlMs, creditsPerLine, refresh = false) {
28764
+ const apiKey = readConfig().providers.semrushApiKey;
28765
+ if (!apiKey) throw new Error("Semrush API key missing. Add it to config.json first.");
28766
+ const requestParams = Object.fromEntries(Object.entries({
28767
+ ...params,
28768
+ key: apiKey,
28769
+ type: endpoint
28770
+ }).filter(([, value]) => value !== void 0));
28771
+ const db = getDb();
28772
+ const queryHash = hashKey([endpoint, requestParams]);
28773
+ const cached = db.prepare("SELECT response_json, credits_used FROM semrush_cache WHERE endpoint = ? AND query_hash = ? AND expires_at > ?").get(endpoint, queryHash, Date.now());
28774
+ if (!refresh && cached?.response_json) return {
28775
+ data: JSON.parse(cached.response_json),
28776
+ usage: {
28777
+ provider: "Semrush",
28778
+ units: cached.credits_used ?? 0,
28779
+ unitLabel: "units",
28780
+ estimatedUsd: estimateUsd(cached.credits_used ?? 0),
28781
+ calls: 1,
28782
+ cacheHits: 1
28783
+ },
28784
+ cached: true
28785
+ };
28786
+ const url = new URL(BASE_URL);
28787
+ url.search = new URLSearchParams(requestParams).toString();
28788
+ const response = await fetch$1(url);
28789
+ const text = await readBoundedResponseText(response, MAX_RESPONSE_BYTES, "Semrush response");
28790
+ if (!response.ok || text.startsWith("ERROR ::")) throw new Error(text || `Semrush request failed with ${response.status}.`);
28791
+ const rows = parseSemicolonCsv(text);
28792
+ const data = map(rows);
28793
+ const credits = Math.max(0, rows.length - 1) * creditsPerLine;
28794
+ const requestJson = JSON.stringify(requestParams);
28795
+ const responseJson = JSON.stringify(data);
28796
+ db.prepare(`INSERT OR REPLACE INTO semrush_cache
28797
+ (endpoint, query_hash, request_json, response_json, credits_used, fetched_at, expires_at)
28798
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(endpoint, queryHash, requestJson, responseJson, credits, Date.now(), Date.now() + ttlMs);
28799
+ noteCacheWrite(Buffer.byteLength(requestJson) + Buffer.byteLength(responseJson));
28800
+ return {
28801
+ data,
28802
+ usage: {
28803
+ provider: "Semrush",
28804
+ units: credits,
28805
+ unitLabel: "units",
28806
+ estimatedUsd: estimateUsd(credits),
28807
+ calls: 1
28808
+ }
28809
+ };
28810
+ }
28811
+ //#endregion
28812
+ //#region packages/core/dist/providers/semrush/mappers.js
28813
+ function mapOverview(rows) {
28814
+ const [header, first] = rows;
28815
+ if (!header || !first) return { phrase: "" };
28816
+ const record = Object.fromEntries(header.map((key, index) => [key, first[index]]));
28817
+ return {
28818
+ phrase: record.Ph ?? "",
28819
+ volume: Number(record.Nq ?? 0) || void 0,
28820
+ cpc: Number(record.Cp ?? 0) || void 0,
28821
+ competition: Number(record.Co ?? 0) || void 0,
28822
+ difficulty: Number(record.Kd ?? 0) || void 0,
28823
+ results: Number(record.Nr ?? 0) || void 0
27471
28824
  };
27472
28825
  }
27473
28826
  function mapKeywordRows(rows) {
@@ -27603,6 +28956,472 @@ var LocalCredentialsProvider = class {
27603
28956
  }
27604
28957
  };
27605
28958
  new LocalCredentialsProvider();
28959
+ //#endregion
28960
+ //#region packages/core/dist/server-logs/parse.js
28961
+ const MONTHS = {
28962
+ Jan: 0,
28963
+ Feb: 1,
28964
+ Mar: 2,
28965
+ Apr: 3,
28966
+ May: 4,
28967
+ Jun: 5,
28968
+ Jul: 6,
28969
+ Aug: 7,
28970
+ Sep: 8,
28971
+ Oct: 9,
28972
+ Nov: 10,
28973
+ Dec: 11
28974
+ };
28975
+ const CRAWLERS = [
28976
+ {
28977
+ family: "Googlebot",
28978
+ category: "search",
28979
+ patterns: ["googlebot", "google-inspectiontool"]
28980
+ },
28981
+ {
28982
+ family: "Bingbot",
28983
+ category: "search",
28984
+ patterns: ["bingbot", "bingpreview"]
28985
+ },
28986
+ {
28987
+ family: "Applebot",
28988
+ category: "search",
28989
+ patterns: ["applebot"]
28990
+ },
28991
+ {
28992
+ family: "DuckDuckBot",
28993
+ category: "search",
28994
+ patterns: ["duckduckbot"]
28995
+ },
28996
+ {
28997
+ family: "YandexBot",
28998
+ category: "search",
28999
+ patterns: ["yandexbot"]
29000
+ },
29001
+ {
29002
+ family: "Baiduspider",
29003
+ category: "search",
29004
+ patterns: ["baiduspider"]
29005
+ },
29006
+ {
29007
+ family: "PetalBot",
29008
+ category: "search",
29009
+ patterns: ["petalbot"]
29010
+ },
29011
+ {
29012
+ family: "OpenAI",
29013
+ category: "ai",
29014
+ patterns: [
29015
+ "gptbot",
29016
+ "chatgpt-user",
29017
+ "oai-searchbot"
29018
+ ]
29019
+ },
29020
+ {
29021
+ family: "Anthropic",
29022
+ category: "ai",
29023
+ patterns: [
29024
+ "claudebot",
29025
+ "claude-searchbot",
29026
+ "claude-user"
29027
+ ]
29028
+ },
29029
+ {
29030
+ family: "Perplexity",
29031
+ category: "ai",
29032
+ patterns: ["perplexitybot", "perplexity-user"]
29033
+ },
29034
+ {
29035
+ family: "Amazonbot",
29036
+ category: "ai",
29037
+ patterns: ["amazonbot"]
29038
+ },
29039
+ {
29040
+ family: "Bytespider",
29041
+ category: "ai",
29042
+ patterns: ["bytespider"]
29043
+ },
29044
+ {
29045
+ family: "CCBot",
29046
+ category: "ai",
29047
+ patterns: ["ccbot"]
29048
+ },
29049
+ {
29050
+ family: "Cohere",
29051
+ category: "ai",
29052
+ patterns: ["cohere-ai"]
29053
+ }
29054
+ ];
29055
+ const COMBINED_LOG = /^\S+\s+\S+\s+\S+\s+\[([^\]]+)]\s+"([^"\s]+)\s+([^"\s]+)(?:\s+[^"\s]+)?"\s+(\d{3})\s+(\d+|-)\s*(?:"[^"]*"\s+"([^"]*)")?\s*$/;
29056
+ function classifyCrawler(userAgent) {
29057
+ if (!userAgent) return void 0;
29058
+ const value = userAgent.toLowerCase();
29059
+ const match = CRAWLERS.find((crawler) => crawler.patterns.some((pattern) => value.includes(pattern)));
29060
+ return match ? {
29061
+ family: match.family,
29062
+ category: match.category
29063
+ } : void 0;
29064
+ }
29065
+ function logTimestamp(value) {
29066
+ const match = /^(\d{2})\/([A-Z][a-z]{2})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(value);
29067
+ if (!match) return void 0;
29068
+ const month = MONTHS[match[2] ?? ""];
29069
+ if (month === void 0) return void 0;
29070
+ const utc = Date.UTC(Number(match[3]), month, Number(match[1]), Number(match[4]), Number(match[5]), Number(match[6]));
29071
+ const offsetMinutes = Number(match[8]) * 60 + Number(match[9]);
29072
+ const adjusted = utc + (match[7] === "+" ? -1 : 1) * offsetMinutes * 6e4;
29073
+ const date = new Date(adjusted);
29074
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
29075
+ }
29076
+ function normalizedPath(value) {
29077
+ if (typeof value !== "string" || value.length < 1 || value.length > 2e3) return;
29078
+ try {
29079
+ if (value.startsWith("/")) return new URL(value, "https://server-log.invalid").pathname;
29080
+ const url = new URL(value);
29081
+ return ["http:", "https:"].includes(url.protocol) ? url.pathname : void 0;
29082
+ } catch {
29083
+ return;
29084
+ }
29085
+ }
29086
+ function normalizedMethod(value) {
29087
+ if (typeof value !== "string" || !/^[A-Za-z]{1,16}$/.test(value)) return;
29088
+ return value.toUpperCase();
29089
+ }
29090
+ function normalizedStatus(value) {
29091
+ const status = typeof value === "number" ? value : Number(value);
29092
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? status : void 0;
29093
+ }
29094
+ function normalizedBytes(value) {
29095
+ if (value === void 0 || value === null || value === "" || value === "-") return;
29096
+ const bytes = typeof value === "number" ? value : Number(value);
29097
+ return Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : void 0;
29098
+ }
29099
+ function record(input) {
29100
+ let timestamp;
29101
+ if (typeof input.timestamp === "string") {
29102
+ const date = new Date(input.timestamp);
29103
+ if (!Number.isNaN(date.getTime())) timestamp = date.toISOString();
29104
+ }
29105
+ const method = normalizedMethod(input.method);
29106
+ const path = normalizedPath(input.path);
29107
+ const status = normalizedStatus(input.status);
29108
+ if (!timestamp || !method || !path || status === void 0) return void 0;
29109
+ const userAgent = typeof input.userAgent === "string" && input.userAgent.length <= 2e3 ? input.userAgent : void 0;
29110
+ return {
29111
+ timestamp,
29112
+ method,
29113
+ path,
29114
+ status,
29115
+ bytes: normalizedBytes(input.bytes),
29116
+ userAgent,
29117
+ crawler: classifyCrawler(userAgent)
29118
+ };
29119
+ }
29120
+ function parseCombinedLogLine(line) {
29121
+ const match = COMBINED_LOG.exec(line);
29122
+ if (!match) return void 0;
29123
+ const timestamp = logTimestamp(match[1] ?? "");
29124
+ if (!timestamp) return void 0;
29125
+ return record({
29126
+ timestamp,
29127
+ method: match[2],
29128
+ path: match[3],
29129
+ status: match[4],
29130
+ bytes: match[5],
29131
+ userAgent: match[6]
29132
+ });
29133
+ }
29134
+ function firstValue(value, names) {
29135
+ for (const name of names) if (value[name] !== void 0) return value[name];
29136
+ }
29137
+ function parseJsonLogLine(line) {
29138
+ let value;
29139
+ try {
29140
+ value = JSON.parse(line);
29141
+ } catch {
29142
+ return;
29143
+ }
29144
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
29145
+ const row = value;
29146
+ return record({
29147
+ timestamp: firstValue(row, [
29148
+ "timestamp",
29149
+ "time",
29150
+ "datetime",
29151
+ "@timestamp"
29152
+ ]),
29153
+ method: firstValue(row, [
29154
+ "method",
29155
+ "requestMethod",
29156
+ "http_method"
29157
+ ]),
29158
+ path: firstValue(row, [
29159
+ "path",
29160
+ "url",
29161
+ "requestUri",
29162
+ "request_uri"
29163
+ ]),
29164
+ status: firstValue(row, [
29165
+ "status",
29166
+ "statusCode",
29167
+ "status_code"
29168
+ ]),
29169
+ bytes: firstValue(row, [
29170
+ "bytes",
29171
+ "bodyBytesSent",
29172
+ "body_bytes_sent"
29173
+ ]),
29174
+ userAgent: firstValue(row, [
29175
+ "userAgent",
29176
+ "user_agent",
29177
+ "httpUserAgent",
29178
+ "http_user_agent"
29179
+ ])
29180
+ });
29181
+ }
29182
+ function emptyStatusBreakdown() {
29183
+ return {
29184
+ success: 0,
29185
+ redirect: 0,
29186
+ clientError: 0,
29187
+ serverError: 0,
29188
+ other: 0
29189
+ };
29190
+ }
29191
+ function addStatus(breakdown, status) {
29192
+ if (status >= 200 && status < 300) breakdown.success += 1;
29193
+ else if (status >= 300 && status < 400) breakdown.redirect += 1;
29194
+ else if (status >= 400 && status < 500) breakdown.clientError += 1;
29195
+ else if (status >= 500) breakdown.serverError += 1;
29196
+ else breakdown.other += 1;
29197
+ }
29198
+ //#endregion
29199
+ //#region packages/core/dist/server-logs/import.js
29200
+ const DEFAULT_ROW_LIMIT = 1e6;
29201
+ const MAX_ROW_LIMIT = 1e7;
29202
+ const DEFAULT_PATH_LIMIT = 25e3;
29203
+ const MAX_PATH_LIMIT = 1e5;
29204
+ const BYTE_LIMIT = 1e9;
29205
+ const MAX_LINE_BYTES = 65536;
29206
+ function boundedInteger(value, fallback, maximum, label) {
29207
+ const result = value ?? fallback;
29208
+ if (!Number.isInteger(result) || result < 1 || result > maximum) throw new SeoError("INVALID_INPUT", `${label} must be between 1 and ${maximum}.`);
29209
+ return result;
29210
+ }
29211
+ function formatFor(path, explicit) {
29212
+ if (explicit) return explicit;
29213
+ const extension = extname(path).toLowerCase();
29214
+ return [".jsonl", ".ndjson"].includes(extension) ? "jsonl" : "combined";
29215
+ }
29216
+ async function* lines(path, progress) {
29217
+ const stream = createReadStream(path, {
29218
+ encoding: "utf8",
29219
+ highWaterMark: 64 * 1024
29220
+ });
29221
+ let remainder = "";
29222
+ let discarding = false;
29223
+ for await (const chunk of stream) {
29224
+ const remainingBytes = BYTE_LIMIT - progress.bytesRead;
29225
+ if (remainingBytes <= 0) {
29226
+ progress.bytesCapped = true;
29227
+ break;
29228
+ }
29229
+ const chunkBytes = Buffer.byteLength(chunk);
29230
+ const accepted = chunkBytes > remainingBytes ? Buffer.from(chunk).subarray(0, remainingBytes).toString("utf8") : chunk;
29231
+ progress.bytesRead += Buffer.byteLength(accepted);
29232
+ if (chunkBytes > remainingBytes) progress.bytesCapped = true;
29233
+ remainder += accepted;
29234
+ let newline = remainder.indexOf("\n");
29235
+ while (newline >= 0) {
29236
+ const line = remainder.slice(0, newline).replace(/\r$/, "");
29237
+ remainder = remainder.slice(newline + 1);
29238
+ if (discarding || Buffer.byteLength(line) > MAX_LINE_BYTES) {
29239
+ yield { tooLong: true };
29240
+ discarding = false;
29241
+ } else if (line.trim()) yield { line };
29242
+ newline = remainder.indexOf("\n");
29243
+ }
29244
+ if (Buffer.byteLength(remainder) > MAX_LINE_BYTES) {
29245
+ remainder = "";
29246
+ discarding = true;
29247
+ }
29248
+ if (progress.bytesCapped) break;
29249
+ }
29250
+ if (discarding || Buffer.byteLength(remainder) > MAX_LINE_BYTES) yield { tooLong: true };
29251
+ else if (remainder.trim()) yield { line: remainder.replace(/\r$/, "") };
29252
+ }
29253
+ function compareCrawler(a, b) {
29254
+ return b.requests - a.requests || a.family.localeCompare(b.family, "en");
29255
+ }
29256
+ function compareCrawlerPath(a, b) {
29257
+ return b.requests - a.requests || a.family.localeCompare(b.family, "en") || a.path.localeCompare(b.path, "en");
29258
+ }
29259
+ function updatePeriod(timestamp, period) {
29260
+ if (!period.firstSeenAt || timestamp < period.firstSeenAt) period.firstSeenAt = timestamp;
29261
+ if (!period.lastSeenAt || timestamp > period.lastSeenAt) period.lastSeenAt = timestamp;
29262
+ }
29263
+ function newCrawler(record) {
29264
+ const crawler = record.crawler;
29265
+ if (!crawler) throw new Error("Crawler evidence is required.");
29266
+ return {
29267
+ family: crawler.family,
29268
+ category: crawler.category,
29269
+ requests: 0,
29270
+ lastSeenAt: record.timestamp,
29271
+ ...emptyStatusBreakdown()
29272
+ };
29273
+ }
29274
+ function newCrawlerPath(record) {
29275
+ return {
29276
+ ...newCrawler(record),
29277
+ path: record.path
29278
+ };
29279
+ }
29280
+ async function importServerLog(input) {
29281
+ const path = resolve(input.file);
29282
+ const file = await stat(path).catch(() => void 0);
29283
+ if (!file?.isFile()) throw new SeoError("INVALID_INPUT", `Server log was not found: ${path}`);
29284
+ const format = formatFor(path, input.format);
29285
+ const rowLimit = boundedInteger(input.rowLimit, DEFAULT_ROW_LIMIT, MAX_ROW_LIMIT, "Server log row limit");
29286
+ const pathLimit = boundedInteger(input.pathLimit, DEFAULT_PATH_LIMIT, MAX_PATH_LIMIT, "Server log path limit");
29287
+ const parse = format === "jsonl" ? parseJsonLogLine : parseCombinedLogLine;
29288
+ const progress = {
29289
+ bytesRead: 0,
29290
+ bytesCapped: false
29291
+ };
29292
+ const period = {};
29293
+ const statusCodes = /* @__PURE__ */ new Map();
29294
+ const crawlers = /* @__PURE__ */ new Map();
29295
+ const crawlerPaths = /* @__PURE__ */ new Map();
29296
+ let suppliedRows = 0;
29297
+ let parsedRows = 0;
29298
+ let invalidRows = 0;
29299
+ let crawlerRows = 0;
29300
+ let responseBytes = 0;
29301
+ let rowsCapped = false;
29302
+ let pathsCapped = false;
29303
+ let untrackedCrawlerPathRows = 0;
29304
+ for await (const item of lines(path, progress)) {
29305
+ if (suppliedRows >= rowLimit) {
29306
+ rowsCapped = true;
29307
+ break;
29308
+ }
29309
+ suppliedRows += 1;
29310
+ const parsed = item.line ? parse(item.line) : void 0;
29311
+ if (!parsed) {
29312
+ invalidRows += 1;
29313
+ continue;
29314
+ }
29315
+ parsedRows += 1;
29316
+ responseBytes += parsed.bytes ?? 0;
29317
+ updatePeriod(parsed.timestamp, period);
29318
+ statusCodes.set(parsed.status, (statusCodes.get(parsed.status) ?? 0) + 1);
29319
+ if (!parsed.crawler) continue;
29320
+ crawlerRows += 1;
29321
+ const crawler = crawlers.get(parsed.crawler.family) ?? newCrawler(parsed);
29322
+ crawler.requests += 1;
29323
+ if (parsed.timestamp > crawler.lastSeenAt) crawler.lastSeenAt = parsed.timestamp;
29324
+ addStatus(crawler, parsed.status);
29325
+ crawlers.set(parsed.crawler.family, crawler);
29326
+ const key = `${parsed.crawler.family}\u0000${parsed.path}`;
29327
+ let crawlerPath = crawlerPaths.get(key);
29328
+ if (!crawlerPath && crawlerPaths.size >= pathLimit) {
29329
+ pathsCapped = true;
29330
+ untrackedCrawlerPathRows += 1;
29331
+ continue;
29332
+ }
29333
+ crawlerPath ??= newCrawlerPath(parsed);
29334
+ crawlerPath.requests += 1;
29335
+ if (parsed.timestamp > crawlerPath.lastSeenAt) crawlerPath.lastSeenAt = parsed.timestamp;
29336
+ addStatus(crawlerPath, parsed.status);
29337
+ crawlerPaths.set(key, crawlerPath);
29338
+ }
29339
+ const fileReadCompletely = !rowsCapped && !progress.bytesCapped && progress.bytesRead >= file.size;
29340
+ const completeness = fileReadCompletely && invalidRows === 0 && !pathsCapped ? "complete" : "partial";
29341
+ const warnings = [];
29342
+ if (rowsCapped) warnings.push(`Analysis stopped after ${rowLimit} rows.`);
29343
+ if (progress.bytesCapped) warnings.push(`Analysis stopped after ${BYTE_LIMIT} input bytes.`);
29344
+ if (invalidRows) warnings.push(`${invalidRows} malformed or unsupported rows were skipped.`);
29345
+ if (pathsCapped) warnings.push(`Crawler path tracking stopped at ${pathLimit} unique crawler and path pairs. Overall crawler and status totals still include later valid rows.`);
29346
+ return {
29347
+ summary: {
29348
+ suppliedRows,
29349
+ parsedRows,
29350
+ invalidRows,
29351
+ crawlerRows,
29352
+ nonCrawlerRows: parsedRows - crawlerRows,
29353
+ responseBytes,
29354
+ ...period
29355
+ },
29356
+ statusCodes: [...statusCodes].map(([status, requests]) => ({
29357
+ status,
29358
+ requests
29359
+ })).sort((a, b) => a.status - b.status),
29360
+ crawlers: [...crawlers.values()].sort(compareCrawler),
29361
+ crawlerPaths: [...crawlerPaths.values()].sort(compareCrawlerPath),
29362
+ provenance: {
29363
+ source: "local-server-log",
29364
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
29365
+ cached: false,
29366
+ file: {
29367
+ path,
29368
+ format,
29369
+ bytesRead: progress.bytesRead,
29370
+ fileBytes: file.size
29371
+ },
29372
+ limits: {
29373
+ rowLimit,
29374
+ pathLimit,
29375
+ byteLimit: BYTE_LIMIT,
29376
+ maxLineBytes: MAX_LINE_BYTES
29377
+ },
29378
+ coverage: {
29379
+ fileReadCompletely,
29380
+ rowsCapped,
29381
+ bytesCapped: progress.bytesCapped,
29382
+ pathsCapped,
29383
+ untrackedCrawlerPathRows
29384
+ },
29385
+ completeness
29386
+ },
29387
+ warnings
29388
+ };
29389
+ }
29390
+ //#endregion
29391
+ //#region packages/core/dist/server-logs/report.js
29392
+ const DEFAULT_LIMIT = 100;
29393
+ const MAX_LIMIT = 500;
29394
+ function outputLimit(value) {
29395
+ const limit = value ?? DEFAULT_LIMIT;
29396
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) throw new SeoError("INVALID_INPUT", `Server log output limit must be between 1 and ${MAX_LIMIT}.`);
29397
+ return limit;
29398
+ }
29399
+ function serverLogReport(input) {
29400
+ const limit = outputLimit(input.limit);
29401
+ const crawlerPaths = input.evidence.crawlerPaths.slice(0, limit);
29402
+ return {
29403
+ schemaVersion: 1,
29404
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
29405
+ dataStatus: input.evidence.provenance.completeness,
29406
+ summary: input.evidence.summary,
29407
+ statusCodes: input.evidence.statusCodes,
29408
+ crawlers: input.evidence.crawlers,
29409
+ crawlerPaths,
29410
+ selection: {
29411
+ availableCrawlerPaths: input.evidence.crawlerPaths.length,
29412
+ returnedCrawlerPaths: crawlerPaths.length,
29413
+ omittedCrawlerPaths: Math.max(0, input.evidence.crawlerPaths.length - crawlerPaths.length),
29414
+ limit
29415
+ },
29416
+ provenance: input.evidence.provenance,
29417
+ warnings: input.evidence.warnings,
29418
+ caveats: [
29419
+ "Crawler families are identified from user-agent strings, which can be missing or spoofed.",
29420
+ "This report describes only the supplied log file and cannot prove that every server or time period is represented.",
29421
+ "Crawler path rankings describe retained path aggregates when the path limit is reached."
29422
+ ]
29423
+ };
29424
+ }
27606
29425
  const TELEMETRY_TIMEOUT_MS = 2e3;
27607
29426
  const TELEMETRY_REPORTS = [
27608
29427
  "affected-urls",
@@ -27612,6 +29431,7 @@ const TELEMETRY_REPORTS = [
27612
29431
  "ai-search-scorecard",
27613
29432
  "audit-page",
27614
29433
  "audit-urls",
29434
+ "bing-webmaster-overview",
27615
29435
  "cannibalisation",
27616
29436
  "community-intent",
27617
29437
  "compare-crawls",
@@ -27630,6 +29450,8 @@ const TELEMETRY_REPORTS = [
27630
29450
  "index-monitor",
27631
29451
  "index-watch",
27632
29452
  "internal-links",
29453
+ "link-evidence",
29454
+ "server-log-analysis",
27633
29455
  "link-recovery",
27634
29456
  "crawl-history",
27635
29457
  "crawler-rules",
@@ -29602,6 +31424,87 @@ function registerOpportunityTools(server, dependencies = {}) {
29602
31424
  });
29603
31425
  }
29604
31426
  //#endregion
31427
+ //#region packages/mcp/src/provider-tools.ts
31428
+ const MAX_AGENT_ROWS_PER_SECTION = 14;
31429
+ function compactRows(data) {
31430
+ if (data.rows.length <= MAX_AGENT_ROWS_PER_SECTION && data.outputSelection) return data;
31431
+ const rows = data.rows.slice(-14);
31432
+ return {
31433
+ ...data,
31434
+ rows,
31435
+ outputSelection: {
31436
+ strategy: "most-recent",
31437
+ availableRows: data.rows.length,
31438
+ returnedRows: rows.length,
31439
+ omittedRows: Math.max(0, data.rows.length - rows.length)
31440
+ }
31441
+ };
31442
+ }
31443
+ function compactBingWebmasterOverview(result) {
31444
+ return {
31445
+ ...result,
31446
+ traffic: result.traffic.status === "unavailable" ? result.traffic : {
31447
+ ...result.traffic,
31448
+ data: compactRows(result.traffic.data)
31449
+ },
31450
+ crawl: result.crawl.status === "unavailable" ? result.crawl : {
31451
+ ...result.crawl,
31452
+ data: compactRows(result.crawl.data)
31453
+ },
31454
+ outputBudget: result.outputBudget
31455
+ };
31456
+ }
31457
+ function registerProviderTools(server) {
31458
+ server.registerTool("seo_bing_webmaster_overview", {
31459
+ description: "Find bounded Bing traffic, crawl, query, and page insights for one verified site",
31460
+ inputSchema: { site: z$1.string().url().max(2e3) }
31461
+ }, async ({ site }) => {
31462
+ try {
31463
+ const result = await bingWebmasterOverview({ site });
31464
+ const firstFinding = result.findings[0]?.title;
31465
+ return toolSuccess(`Bing evidence is ${result.dataStatus} with ${result.summary.findings} findings.${firstFinding ? ` Review first: ${firstFinding}.` : ""}`, compactBingWebmasterOverview(result));
31466
+ } catch (error) {
31467
+ return toolError(error);
31468
+ }
31469
+ });
31470
+ server.registerTool("seo_link_evidence", {
31471
+ description: "Review bounded referring-link evidence from Bing Webmaster or a local export",
31472
+ inputSchema: {
31473
+ site: z$1.string().url().max(2e3).optional(),
31474
+ file: z$1.string().min(1).max(4096).optional(),
31475
+ format: z$1.enum([
31476
+ "csv",
31477
+ "json",
31478
+ "jsonl"
31479
+ ]).optional(),
31480
+ rowLimit: z$1.number().int().min(1).max(1e5).optional(),
31481
+ targetLimit: z$1.number().int().min(1).max(50).optional(),
31482
+ detailPagesPerTarget: z$1.number().int().min(1).max(3).optional(),
31483
+ limit: z$1.number().int().min(1).max(500).optional()
31484
+ }
31485
+ }, async ({ site, file, format, rowLimit, targetLimit, detailPagesPerTarget, limit }) => {
31486
+ try {
31487
+ if (Boolean(site) === Boolean(file)) throw new SeoError("INVALID_INPUT", "Pass exactly one of site for Bing or file for a local import.");
31488
+ const report = linkEvidenceReport({
31489
+ evidence: file ? await importLinkEvidence({
31490
+ file,
31491
+ format,
31492
+ rowLimit
31493
+ }) : await collectBingLinkEvidence({
31494
+ site: site ?? "",
31495
+ rowLimit,
31496
+ targetLimit,
31497
+ detailPagesPerTarget
31498
+ }),
31499
+ limit
31500
+ });
31501
+ return toolSuccess(`${report.summary.observedLinks} referring links were retained from ${report.provenance.provider}.`, report);
31502
+ } catch (error) {
31503
+ return toolError(error);
31504
+ }
31505
+ });
31506
+ }
31507
+ //#endregion
29605
31508
  //#region packages/mcp/src/pseo-tools.ts
29606
31509
  function compactPseoReport(result) {
29607
31510
  return {
@@ -29894,6 +31797,91 @@ const REPORT_DEPTH = {
29894
31797
  }
29895
31798
  ]
29896
31799
  },
31800
+ "bing-webmaster-overview": {
31801
+ readOrder: [
31802
+ "dataStatus and provenance",
31803
+ "summary and findings, including verification steps",
31804
+ "traffic analysis current and previous periods, then changes",
31805
+ "crawl analysis current and previous snapshots, then changes",
31806
+ "query and page coverage before movements or opportunities",
31807
+ "outputBudget, per-section outputSelection, and caveats"
31808
+ ],
31809
+ doNotClaim: [
31810
+ "Bing evidence does not describe Google or the whole search market, and inIndex is not URL-level proof of indexing.",
31811
+ "A missing weekly top-list entry is not zero traffic, and movements require observation in every week of both periods.",
31812
+ "Opportunity thresholds prioritize review and are not Bing requirements.",
31813
+ "Partial, capped, invalid, or unavailable rows cannot support an all-clear."
31814
+ ],
31815
+ verify: "Check the same verified site in Bing Webmaster Tools and investigate the exact dates behind any crawl or traffic change.",
31816
+ related: [
31817
+ {
31818
+ id: "search-performance-overview",
31819
+ reason: "Adds Search Console evidence."
31820
+ },
31821
+ {
31822
+ id: "site-crawl",
31823
+ reason: "Checks the current site directly."
31824
+ },
31825
+ {
31826
+ id: "link-recovery",
31827
+ reason: "Finds broken search-value URLs."
31828
+ }
31829
+ ]
31830
+ },
31831
+ "link-evidence": {
31832
+ readOrder: [
31833
+ "dataStatus, provenance, warnings, and caveats",
31834
+ "selection and summary coverage counts",
31835
+ "targetCounts, then the bounded links list"
31836
+ ],
31837
+ doNotClaim: [
31838
+ "The retained rows are not a complete backlink index.",
31839
+ "Missing rows do not prove a link does not exist.",
31840
+ "Observed links do not establish quality, authority, value, or ranking impact."
31841
+ ],
31842
+ verify: "Open a selected referring URL and confirm the link is still present before acting on it.",
31843
+ related: [
31844
+ {
31845
+ id: "link-recovery",
31846
+ reason: "Finds broken search-value URLs."
31847
+ },
31848
+ {
31849
+ id: "redirect-trace",
31850
+ reason: "Checks a target redirect chain."
31851
+ },
31852
+ {
31853
+ id: "site-crawl",
31854
+ reason: "Checks internal links and live pages."
31855
+ }
31856
+ ]
31857
+ },
31858
+ "server-log-analysis": {
31859
+ readOrder: [
31860
+ "dataStatus, provenance coverage, warnings, and caveats",
31861
+ "summary and statusCodes",
31862
+ "crawlers, then the bounded crawlerPaths list and selection"
31863
+ ],
31864
+ doNotClaim: [
31865
+ "A user-agent match does not verify the crawler identity or source IP.",
31866
+ "The supplied file does not prove that every server or time period is represented.",
31867
+ "Path rankings are partial when path tracking reached its limit."
31868
+ ],
31869
+ verify: "Check important crawler errors against the original log line and deployment or server configuration before changing the site.",
31870
+ related: [
31871
+ {
31872
+ id: "site-crawl",
31873
+ reason: "Checks the current site directly."
31874
+ },
31875
+ {
31876
+ id: "crawler-rules",
31877
+ reason: "Explains current crawl checks."
31878
+ },
31879
+ {
31880
+ id: "redirect-trace",
31881
+ reason: "Traces one failing or redirecting path."
31882
+ }
31883
+ ]
31884
+ },
29897
31885
  cannibalisation: {
29898
31886
  readOrder: [
29899
31887
  "dataStatus and both source.pageExposure and source.propertyDemand with validation counts and completeness",
@@ -31177,6 +33165,27 @@ const REPORT_GUIDANCE = {
31177
33165
  avoidWhen: ["You need the crawler to discover pages for you.", "You only need a deep review of one page."],
31178
33166
  outcome: "Consistent technical checks for each supplied URL."
31179
33167
  },
33168
+ "bing-webmaster-overview": {
33169
+ name: "Bing Webmaster overview",
33170
+ description: "Review bounded Bing traffic trends, crawl changes, and query and page opportunities for one verified site.",
33171
+ useWhen: ["You want processed search and crawl insights observed by Bing.", "You need a second search-engine view beside Search Console."],
33172
+ avoidWhen: ["Bing Webmaster is not connected for this installation.", "You need URL-level backlink rows or proof that a page is indexed."],
33173
+ outcome: "Prioritized Bing findings with traffic comparisons, crawl changes, query and page opportunities, provider coverage, and caveats."
33174
+ },
33175
+ "link-evidence": {
33176
+ name: "Referring link evidence",
33177
+ description: "Review a bounded set of pages that link to the site from Bing Webmaster or a local export.",
33178
+ useWhen: ["You want concrete referring URLs and anchor text where available.", "You need one normalized view of Bing or an exported CSV, JSON, or JSONL file."],
33179
+ avoidWhen: ["You need a complete backlink index or a domain authority score.", "You want to find broken internal links during a crawl."],
33180
+ outcome: "A bounded, deduplicated link list with target-page counts and explicit source coverage."
33181
+ },
33182
+ "server-log-analysis": {
33183
+ name: "Server log crawler analysis",
33184
+ description: "Stream a local access log to see observed search and AI crawler requests, statuses, and paths.",
33185
+ useWhen: ["You need evidence of crawler requests that reached the web server.", "You want to find crawler-facing 4xx or 5xx responses in a local access log."],
33186
+ avoidWhen: ["You do not have a combined or JSONL access log.", "You need to prove crawler identity from a user-agent string alone."],
33187
+ outcome: "Bounded crawler, status, and path aggregates with file coverage and parser limits kept visible."
33188
+ },
31180
33189
  cannibalisation: {
31181
33190
  name: "Query and URL overlap",
31182
33191
  description: "Find search queries that appear across several URLs and decide whether the overlap needs attention.",
@@ -31683,6 +33692,35 @@ function registerReportTools(server) {
31683
33692
  registerSecondPageTool(server);
31684
33693
  }
31685
33694
  //#endregion
33695
+ //#region packages/mcp/src/server-log-tools.ts
33696
+ function registerServerLogTools(server) {
33697
+ server.registerTool("seo_server_log_analysis", {
33698
+ description: "Stream a local access log into bounded search and AI crawler evidence",
33699
+ inputSchema: {
33700
+ file: z$1.string().min(1).max(4096),
33701
+ format: z$1.enum(["combined", "jsonl"]).optional(),
33702
+ rowLimit: z$1.number().int().min(1).max(1e7).optional(),
33703
+ pathLimit: z$1.number().int().min(1).max(1e5).optional(),
33704
+ limit: z$1.number().int().min(1).max(500).optional()
33705
+ }
33706
+ }, async ({ file, format, rowLimit, pathLimit, limit }) => {
33707
+ try {
33708
+ const report = serverLogReport({
33709
+ evidence: await importServerLog({
33710
+ file,
33711
+ format,
33712
+ rowLimit,
33713
+ pathLimit
33714
+ }),
33715
+ limit
33716
+ });
33717
+ return toolSuccess(`${report.summary.crawlerRows} crawler requests were observed in ${report.summary.parsedRows} parsed log rows.`, report);
33718
+ } catch (error) {
33719
+ return toolError(error);
33720
+ }
33721
+ });
33722
+ }
33723
+ //#endregion
31686
33724
  //#region packages/mcp/src/agent-output-budget.ts
31687
33725
  const AGENT_STRUCTURED_OUTPUT_MAX_BYTES = 96 * 1024;
31688
33726
  function jsonBytes(value) {
@@ -32079,6 +34117,16 @@ const reportGroups = [
32079
34117
  "seo_redirect_trace"
32080
34118
  ]
32081
34119
  },
34120
+ {
34121
+ category: "monitoring",
34122
+ register: registerProviderTools,
34123
+ names: ["seo_bing_webmaster_overview", "seo_link_evidence"]
34124
+ },
34125
+ {
34126
+ category: "monitoring",
34127
+ register: registerServerLogTools,
34128
+ names: ["seo_server_log_analysis"]
34129
+ },
32082
34130
  {
32083
34131
  category: "reporting",
32084
34132
  register: registerReportTools,