seo 0.3.0-beta.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-1f8VcMU8.js";
2
+ import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-7H_jIpAz.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -23897,12 +23897,12 @@ const KEYRING_SERVICE = "seo";
23897
23897
  const PRIVATE_FILE_MODE = 384;
23898
23898
  const PROVIDER_SECRET_NAMES = {
23899
23899
  ahrefsApiKey: "ahrefs-api-key",
23900
+ bingApiKey: "bing-api-key",
23900
23901
  clickySiteKeys: "clicky-sitekeys",
23901
23902
  dataForSeoCredentials: "dataforseo-credentials",
23902
23903
  providerExtensionCredentials: "provider-extension-credentials"
23903
23904
  };
23904
23905
  const REMOVED_PROVIDER_SECRET_NAMES = [
23905
- "bing-api-key",
23906
23906
  "indexnow-keys",
23907
23907
  "semrush-api-key",
23908
23908
  "serpbase-api-key"
@@ -39117,6 +39117,118 @@ function analyticsConnection(client) {
39117
39117
  };
39118
39118
  }
39119
39119
  //#endregion
39120
+ //#region packages/core/dist/bing/api.js
39121
+ const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
39122
+ const MAX_BYTES$1 = 8e6;
39123
+ function bingCall(auth, options) {
39124
+ return async (method, params = {}) => {
39125
+ const url = new URL(`${BASE}/${method}`);
39126
+ for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
39127
+ const headers = { accept: "application/json" };
39128
+ if ("apiKey" in auth) url.searchParams.set("apikey", auth.apiKey);
39129
+ else headers.authorization = `Bearer ${await auth.accessToken()}`;
39130
+ let response;
39131
+ try {
39132
+ response = await fetch(url, {
39133
+ headers,
39134
+ signal: AbortSignal.timeout(3e4)
39135
+ });
39136
+ } catch (error) {
39137
+ throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster did not respond: ${error instanceof Error ? error.message : String(error)}`);
39138
+ }
39139
+ if (!response.ok) {
39140
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
39141
+ if (response.status === 401 || /invalidapikey/i.test(detail)) throw new SeoError("AUTH_REQUIRED", `Bing Webmaster rejected the sign-in. ${options.signIn}`);
39142
+ if (response.status === 403 || /notauthorized/i.test(detail)) throw new SeoError("ACCESS_DENIED", "Bing Webmaster denied access to this site. It must be verified in the connected Bing account.");
39143
+ if (/ThrottleIP/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is blocking requests from this network (ThrottleIP). Run the same report with the CLI on your own machine, where Bing answers.");
39144
+ if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
39145
+ throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
39146
+ }
39147
+ return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$1 })).d;
39148
+ };
39149
+ }
39150
+ function day(value) {
39151
+ if (typeof value !== "string") return null;
39152
+ const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
39153
+ const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
39154
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
39155
+ }
39156
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
39157
+ function list(data, label) {
39158
+ if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
39159
+ return data.filter((item) => Boolean(item) && typeof item === "object");
39160
+ }
39161
+ async function listBingSites(call) {
39162
+ return list(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
39163
+ url: row.Url,
39164
+ verified: row.IsVerified === true
39165
+ })).sort((a, b) => a.url.localeCompare(b.url, "en"));
39166
+ }
39167
+ async function bingTraffic(call, site) {
39168
+ const rows = [];
39169
+ let invalidRows = 0;
39170
+ for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
39171
+ const date = day(row.Date);
39172
+ const clicks = count(row.Clicks);
39173
+ const impressions = count(row.Impressions);
39174
+ if (!date || clicks === null || impressions === null) {
39175
+ invalidRows += 1;
39176
+ continue;
39177
+ }
39178
+ rows.push({
39179
+ date,
39180
+ clicks,
39181
+ impressions
39182
+ });
39183
+ }
39184
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en"));
39185
+ return {
39186
+ rows,
39187
+ invalidRows
39188
+ };
39189
+ }
39190
+ async function keyed(call, method, params) {
39191
+ const rows = [];
39192
+ let invalidRows = 0;
39193
+ for (const row of list(await call(method, params), "row list")) {
39194
+ const date = day(row.Date);
39195
+ const key = typeof row.Query === "string" ? row.Query.trim() : "";
39196
+ const clicks = count(row.Clicks);
39197
+ const impressions = count(row.Impressions);
39198
+ const position = count(row.AvgImpressionPosition);
39199
+ if (!date || !key || clicks === null || impressions === null) {
39200
+ invalidRows += 1;
39201
+ continue;
39202
+ }
39203
+ rows.push({
39204
+ date,
39205
+ key,
39206
+ clicks,
39207
+ impressions,
39208
+ position
39209
+ });
39210
+ }
39211
+ rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.key.localeCompare(b.key, "en"));
39212
+ return {
39213
+ rows,
39214
+ invalidRows
39215
+ };
39216
+ }
39217
+ const bingQueries = (call, site) => keyed(call, "GetQueryStats", { siteUrl: site });
39218
+ const bingPages = (call, site) => keyed(call, "GetPageStats", { siteUrl: site });
39219
+ function bingSiteFor(sites, target) {
39220
+ const host = (value) => {
39221
+ try {
39222
+ return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
39223
+ } catch {
39224
+ return null;
39225
+ }
39226
+ };
39227
+ const wanted = host(target);
39228
+ if (!wanted) return null;
39229
+ return sites.filter((site) => site.verified && host(site.url) === wanted).sort((a, b) => Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0]?.url ?? null;
39230
+ }
39231
+ //#endregion
39120
39232
  //#region packages/core/dist/commands/present.js
39121
39233
  const LIST_KEYS = [
39122
39234
  "items",
@@ -39616,6 +39728,15 @@ async function runRows(input, deps) {
39616
39728
  }
39617
39729
  //#endregion
39618
39730
  //#region packages/core/dist/commands/analytics.js
39731
+ async function analyticsSignIn(accounts, property, signIn) {
39732
+ const only = accounts.length === 1 ? accounts[0] : void 0;
39733
+ if (only) return only.google;
39734
+ const account = (await Promise.all(accounts.map(async (account) => {
39735
+ return (await listAnalyticsAccounts(account.google, { signIn }).catch(() => [])).some((summary) => summary.propertySummaries.some((item) => item.property === `properties/${property}`)) ? account : null;
39736
+ }))).find(Boolean);
39737
+ if (account) return account.google;
39738
+ throw new CommandError("NO_ACCESS", `No connected Google account can read Google Analytics property ${property}. Call seo_sites to list the properties.`);
39739
+ }
39619
39740
  async function runAnalyticsRows(input, google, options) {
39620
39741
  const range = dateWindow(input, 28);
39621
39742
  const offset = input.offset ?? 0;
@@ -39669,6 +39790,215 @@ async function runAnalyticsRows(input, google, options) {
39669
39790
  };
39670
39791
  }
39671
39792
  //#endregion
39793
+ //#region packages/core/dist/commands/bing.js
39794
+ const BING_LIMITS = ["Bing and Google count clicks, impressions and positions their own way. Their numbers sit side by side here and are never added together.", "Bing lists only its top queries and pages, grouped by week. A query or page missing from Bing’s list is unknown, not zero."];
39795
+ const LOCAL_BING = "Bing data runs on the person’s machine with the CLI: connect a Bing Webmaster API key once with `npx -y seo providers bing connect`, then run `npx -y seo report --site <site> --section bing --json`.";
39796
+ const sitesSeen = /* @__PURE__ */ new WeakMap();
39797
+ async function bingFor(deps, property) {
39798
+ if (!deps.bing) return {
39799
+ call: null,
39800
+ site: null,
39801
+ missing: LOCAL_BING
39802
+ };
39803
+ const call = await deps.bing();
39804
+ if (!call) return {
39805
+ call: null,
39806
+ site: null,
39807
+ missing: `Bing is not connected. ${deps.bingConnect ?? ""}`.trim()
39808
+ };
39809
+ let sites = sitesSeen.get(call);
39810
+ if (!sites) {
39811
+ sites = listBingSites(call);
39812
+ sitesSeen.set(call, sites);
39813
+ }
39814
+ const site = bingSiteFor(await sites, property);
39815
+ return site ? {
39816
+ call,
39817
+ site
39818
+ } : {
39819
+ call: null,
39820
+ site: null,
39821
+ missing: `The connected Bing account has no verified site for ${property.replace(/^sc-domain:/, "")}. Add it in Bing Webmaster Tools; it can import sites from Search Console.`
39822
+ };
39823
+ }
39824
+ function days(range) {
39825
+ return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
39826
+ }
39827
+ async function bingTotals(source, range, before) {
39828
+ if (!source.call) return { missing: source.missing };
39829
+ const traffic = await bingTraffic(source.call, source.site);
39830
+ const sum = (window) => {
39831
+ const rows = traffic.rows.filter((row) => row.date >= window.startDate && row.date <= window.endDate);
39832
+ return {
39833
+ clicks: rows.reduce((total, row) => total + row.clicks, 0),
39834
+ impressions: rows.reduce((total, row) => total + row.impressions, 0),
39835
+ days: new Set(rows.map((row) => row.date)).size,
39836
+ expectedDays: days(window)
39837
+ };
39838
+ };
39839
+ return {
39840
+ site: source.site,
39841
+ current: sum(range),
39842
+ previous: sum(before),
39843
+ invalidRows: traffic.invalidRows
39844
+ };
39845
+ }
39846
+ const change = (now, before) => {
39847
+ const value = pct(now, before);
39848
+ return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
39849
+ };
39850
+ function enginesText(google, bing) {
39851
+ if ("missing" in bing) return `Bing: ${bing.missing}`;
39852
+ const { current: now, previous: then } = bing;
39853
+ const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
39854
+ const both = google.clicks + now.clicks;
39855
+ if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
39856
+ if (now.days < now.expectedDays || then.days < then.expectedDays) lines.push(`Bing has data for ${now.days} of ${now.expectedDays} days (earlier period ${then.days} of ${then.expectedDays}), so its totals are partial.`);
39857
+ return lines.join(" ");
39858
+ }
39859
+ function sumWeeks(rows, window) {
39860
+ const inside = rows.filter((row) => row.date >= window.startDate && row.date <= window.endDate);
39861
+ const byKey = /* @__PURE__ */ new Map();
39862
+ for (const row of inside) {
39863
+ const found = byKey.get(row.key) ?? {
39864
+ key: row.key,
39865
+ clicks: 0,
39866
+ impressions: 0,
39867
+ position: null,
39868
+ weighted: 0,
39869
+ seen: 0
39870
+ };
39871
+ found.clicks += row.clicks;
39872
+ found.impressions += row.impressions;
39873
+ if (row.position !== null && row.impressions > 0) {
39874
+ found.weighted += row.position * row.impressions;
39875
+ found.seen += row.impressions;
39876
+ }
39877
+ byKey.set(row.key, found);
39878
+ }
39879
+ return {
39880
+ rows: [...byKey.values()].map(({ weighted, seen, ...row }) => ({
39881
+ ...row,
39882
+ position: seen > 0 ? Math.round(weighted / seen * 10) / 10 : null
39883
+ })).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
39884
+ weeks: [...new Set(inside.map((row) => row.date))].sort()
39885
+ };
39886
+ }
39887
+ async function bingSection(source, read, property, range) {
39888
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
39889
+ const google = (dimension) => read(property, {
39890
+ ...range,
39891
+ dimensions: [dimension],
39892
+ type: "web",
39893
+ dataState: "final",
39894
+ maxRows: 25e3
39895
+ });
39896
+ const [queries, pages, googleQueries, googlePages] = await Promise.all([
39897
+ bingQueries(source.call, source.site),
39898
+ bingPages(source.call, source.site),
39899
+ google("query"),
39900
+ google("page")
39901
+ ]);
39902
+ const beside = (bing, googleRows, name) => {
39903
+ const norm = (value) => name === "query" ? value.toLowerCase().trim() : value.replace(/#.*$/, "");
39904
+ const byKey = new Map(googleRows.map((row) => [norm(row.keys[0] ?? ""), row]));
39905
+ return bing.map((row) => {
39906
+ const g = byKey.get(norm(row.key));
39907
+ return {
39908
+ [name]: row.key,
39909
+ bingClicks: row.clicks,
39910
+ bingImpressions: row.impressions,
39911
+ bingPosition: row.position,
39912
+ googleClicks: g?.clicks ?? 0,
39913
+ googleImpressions: g?.impressions ?? 0,
39914
+ googlePosition: g ? Math.round(g.position * 10) / 10 : null
39915
+ };
39916
+ });
39917
+ };
39918
+ const q = sumWeeks(queries.rows, range);
39919
+ const p = sumWeeks(pages.rows, range);
39920
+ const invalid = queries.invalidRows + pages.invalidRows;
39921
+ return {
39922
+ range,
39923
+ bingSite: source.site,
39924
+ summary: {
39925
+ bingQueries: q.rows.length,
39926
+ bingPages: p.rows.length,
39927
+ weeks: q.weeks.length
39928
+ },
39929
+ items: beside(q.rows, googleQueries.rows, "query"),
39930
+ topPages: beside(p.rows, googlePages.rows, "page"),
39931
+ caveats: [
39932
+ `Bing weeks counted: ${q.weeks.join(", ") || "none"} (each date names a week). Google numbers cover ${range.startDate} to ${range.endDate} exactly.`,
39933
+ "Google numbers are for the same query text or page URL. 0 means Google showed no row for it in these dates.",
39934
+ ...BING_LIMITS,
39935
+ ...invalid ? [`${invalid} Bing rows were not valid and were left out.`] : []
39936
+ ]
39937
+ };
39938
+ }
39939
+ async function runBingRows(input, deps) {
39940
+ const source = await bingFor(deps, input.site);
39941
+ if (!source.call) throw new CommandError("NOT_CONNECTED", source.missing);
39942
+ const range = dateWindow(input, 28);
39943
+ const previous = earlierRange(range);
39944
+ const all = await (input.dimension === "page" ? bingPages : bingQueries)(source.call, source.site);
39945
+ const now = sumWeeks(all.rows, range);
39946
+ const then = sumWeeks(all.rows, previous);
39947
+ const old = new Map(then.rows.map((row) => [row.key, row]));
39948
+ const seen = new Set(now.rows.map((row) => row.key));
39949
+ const rows = now.rows.map((row) => ({
39950
+ [input.dimension]: row.key,
39951
+ clicks: row.clicks,
39952
+ impressions: row.impressions,
39953
+ position: row.position,
39954
+ previous_clicks: old.get(row.key)?.clicks ?? 0,
39955
+ clicks_change: row.clicks - (old.get(row.key)?.clicks ?? 0)
39956
+ }));
39957
+ for (const row of then.rows) {
39958
+ if (seen.has(row.key)) continue;
39959
+ rows.push({
39960
+ [input.dimension]: row.key,
39961
+ clicks: 0,
39962
+ impressions: 0,
39963
+ position: null,
39964
+ previous_clicks: row.clicks,
39965
+ clicks_change: -row.clicks
39966
+ });
39967
+ }
39968
+ const notes = [
39969
+ `Bing weeks counted: ${now.weeks.join(", ") || "none"}; earlier period ${then.weeks.join(", ") || "none"}. Each date names a week.`,
39970
+ ...BING_LIMITS.slice(1),
39971
+ ...all.invalidRows ? [`${all.invalidRows} Bing rows were not valid and were left out.`] : []
39972
+ ];
39973
+ return {
39974
+ text: `${rowsText({
39975
+ title: `Bing Webmaster ${source.site}, ${range.startDate} to ${range.endDate}, by ${input.dimension}.`,
39976
+ rows,
39977
+ offset: input.offset ?? 0,
39978
+ limit: 50,
39979
+ detail: input.detail ?? "summary",
39980
+ notes,
39981
+ origin: originOf(source.site),
39982
+ more: (offset) => deps.call("data", {
39983
+ site: input.site,
39984
+ source: "bing",
39985
+ dimensions: input.dimension,
39986
+ detail: "rows",
39987
+ offset
39988
+ })
39989
+ })}\n\n${FACTS_ONLY.replace("Search Console", "Bing Webmaster Tools")}`,
39990
+ data: {
39991
+ bingSite: source.site,
39992
+ range,
39993
+ comparisonRange: previous,
39994
+ weeks: now.weeks,
39995
+ total: rows.length,
39996
+ offset: input.offset ?? 0,
39997
+ rows: rows.slice(input.offset ?? 0, (input.offset ?? 0) + 50)
39998
+ }
39999
+ };
40000
+ }
40001
+ //#endregion
39672
40002
  //#region packages/core/dist/commands/domain-rating.js
39673
40003
  const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
39674
40004
  const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
@@ -39856,7 +40186,8 @@ const SECTIONS = {
39856
40186
  "second-page": "Pages with an average position of 11 to 20 and their queries.",
39857
40187
  "content-decay": "Query and page pairs that lost clicks against the previous period.",
39858
40188
  cannibalization: "Queries where more than one page of the site gets impressions, with each page’s share.",
39859
- "traffic-drop": "Daily clicks and impressions checked for sudden changes in the last days, with Google updates whose dates overlap. Overlap is timing only, not cause."
40189
+ "traffic-drop": "Daily clicks and impressions checked for sudden changes in the last days, with Google updates whose dates overlap. Overlap is timing only, not cause.",
40190
+ bing: "Bing’s top queries from Bing Webmaster Tools with Google’s clicks, impressions and position for the same query beside them, and the same for pages."
39860
40191
  };
39861
40192
  const SECTION_IDS = Object.keys(SECTIONS);
39862
40193
  const PAGE_CHECKS = /* @__PURE__ */ new Set([
@@ -39875,7 +40206,8 @@ const DEFAULT_DAYS = {
39875
40206
  "second-page": 28,
39876
40207
  "content-decay": 28,
39877
40208
  cannibalization: 28,
39878
- "traffic-drop": 90
40209
+ "traffic-drop": 90,
40210
+ bing: 28
39879
40211
  };
39880
40212
  function nextFor(section, call) {
39881
40213
  const section_ = (id) => `section "${id}"`;
@@ -39887,7 +40219,8 @@ function nextFor(section, call) {
39887
40219
  "second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
39888
40220
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
39889
40221
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
39890
- "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved.`
40222
+ "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved.`,
40223
+ bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
39891
40224
  }[section];
39892
40225
  }
39893
40226
  async function siteOverview(read, site, range, before, pageContains) {
@@ -40029,7 +40362,22 @@ function overviewText(report, heading, origin, rowsPerList) {
40029
40362
  `Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
40030
40363
  ].join("\n\n");
40031
40364
  }
40032
- async function build(read, site, section, range, now) {
40365
+ function once(make) {
40366
+ let made;
40367
+ return () => {
40368
+ made ??= make();
40369
+ return made;
40370
+ };
40371
+ }
40372
+ async function enginesLine(overview, bing) {
40373
+ const summary = overview.summary;
40374
+ try {
40375
+ return enginesText(summary, await bingTotals(await bing(), overview.range, overview.comparisonRange));
40376
+ } catch (error) {
40377
+ return `Bing: could not be read. ${error instanceof Error ? error.message : String(error)}`;
40378
+ }
40379
+ }
40380
+ async function build(read, site, section, range, now, bing) {
40033
40381
  const dependencies = {
40034
40382
  searchAnalytics: read,
40035
40383
  now
@@ -40088,7 +40436,8 @@ async function build(read, site, section, range, now) {
40088
40436
  end: first.comparisonEnd
40089
40437
  })
40090
40438
  };
40091
- }
40439
+ },
40440
+ bing: async () => bingSection(await bing(), read, site, range)
40092
40441
  }[section];
40093
40442
  return factsOnly(await run());
40094
40443
  }
@@ -40132,13 +40481,16 @@ async function allSections(input, deps) {
40132
40481
  const now = deps.now ?? (() => /* @__PURE__ */ new Date());
40133
40482
  const read = sharedReader(deps.searchAnalytics);
40134
40483
  const origin = originOf(input.site);
40135
- const [overview, ...rest] = await Promise.all(SECTION_IDS.map((id) => build(read, input.site, id, dateWindow(input, DEFAULT_DAYS[id]), now).catch((error) => error instanceof Error ? error : new Error(String(error)))));
40484
+ const bing = once(() => bingFor(deps, input.site));
40485
+ const ids = SECTION_IDS.filter((id) => id !== "bing" || deps.bing);
40486
+ const [overview, ...rest] = await Promise.all(ids.map((id) => build(read, input.site, id, dateWindow(input, DEFAULT_DAYS[id]), now, bing).catch((error) => error instanceof Error ? error : new Error(String(error)))));
40136
40487
  if (!overview || overview instanceof Error) throw overview ?? /* @__PURE__ */ new Error("The overview did not run.");
40137
40488
  const parts = [overviewText(overview, `Report on ${input.site}`, origin, 5)];
40489
+ if (deps.bing) parts.push(await enginesLine(overview, bing));
40138
40490
  const brand = rest.find((run) => !(run instanceof Error) && brandNote(run) !== null);
40139
40491
  if (brand) parts.push(brandNote(brand));
40140
40492
  const sections = {};
40141
- SECTION_IDS.slice(1).forEach((id, index) => {
40493
+ ids.slice(1).forEach((id, index) => {
40142
40494
  const run = rest[index];
40143
40495
  if (!run || run instanceof Error) {
40144
40496
  const message = run?.message ?? "did not run";
@@ -40158,7 +40510,7 @@ async function allSections(input, deps) {
40158
40510
  parts.push(`More: ${deps.call("report", {
40159
40511
  site: input.site,
40160
40512
  section: "<section>"
40161
- })} for its rows (${SECTION_IDS.join(", ")}). Page sections also check their top 20 URLs live and merge same-site redirects.`);
40513
+ })} for its rows (${ids.join(", ")}). Page sections also check their top 20 URLs live and merge same-site redirects.`);
40162
40514
  return {
40163
40515
  text: `${parts.join("\n\n")}\n\n${FACTS_ONLY}`,
40164
40516
  data: {
@@ -40174,10 +40526,11 @@ async function oneSection(input, deps) {
40174
40526
  const detail = input.detail ?? "summary";
40175
40527
  const range = dateWindow(input, DEFAULT_DAYS[section]);
40176
40528
  const origin = originOf(site);
40177
- let report = await build(sharedReader(deps.searchAnalytics), site, section, range, now);
40529
+ const bing = once(() => bingFor(deps, site));
40530
+ let report = await build(sharedReader(deps.searchAnalytics), site, section, range, now, bing);
40178
40531
  const next = nextFor(section, deps.call);
40179
40532
  if (section === "overview" && detail !== "full") return {
40180
- text: `${overviewText(report, `overview of ${site}`, origin, 10)}\n\nMore: ${deps.call("report", {
40533
+ text: `${overviewText(report, `overview of ${site}`, origin, 10)}\n\n${deps.bing ? `${await enginesLine(report, bing)}\n\n` : ""}More: ${deps.call("report", {
40181
40534
  site,
40182
40535
  section,
40183
40536
  detail: "full"
@@ -40196,7 +40549,7 @@ async function oneSection(input, deps) {
40196
40549
  redirects.set(page.url, page.redirects_to.replace(/#.*$/, ""));
40197
40550
  merged.push(`${page.url} → ${page.redirects_to}`);
40198
40551
  }
40199
- if (redirects.size) report = await build(sharedReader(deps.searchAnalytics, redirects), site, section, range, now);
40552
+ if (redirects.size) report = await build(sharedReader(deps.searchAnalytics, redirects), site, section, range, now, bing);
40200
40553
  report.pageChecks = checked;
40201
40554
  }
40202
40555
  const notes = [];
@@ -40224,8 +40577,9 @@ async function oneSection(input, deps) {
40224
40577
  offset
40225
40578
  })
40226
40579
  });
40580
+ const facts = section === "bing" ? FACTS_ONLY.replace("Search Console", "Search Console and Bing") : FACTS_ONLY;
40227
40581
  return {
40228
- text: `${shown.text}\n\n${next}\n\n${FACTS_ONLY}`,
40582
+ text: `${shown.text}\n\n${next}\n\n${facts}`,
40229
40583
  data: detail === "full" ? report : shown.data
40230
40584
  };
40231
40585
  }
@@ -40671,29 +41025,67 @@ async function readableSites(google, signIn) {
40671
41025
  permissionLevel: entry.permissionLevel ?? ""
40672
41026
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
40673
41027
  }
40674
- async function runSites(google, keys, signIn) {
40675
- const [properties, accounts] = await Promise.all([readableSites(google, signIn), listAnalyticsAccounts(google, { signIn }).catch(() => null)]);
40676
- const analytics = accounts?.flatMap((account) => account.propertySummaries.map((property) => ({
41028
+ const hostOf = (value) => {
41029
+ try {
41030
+ return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
41031
+ } catch {
41032
+ return value;
41033
+ }
41034
+ };
41035
+ async function runSites(input) {
41036
+ const named = input.accounts.length > 1;
41037
+ const [google, bing] = await Promise.all([Promise.all(input.accounts.map(async (account) => {
41038
+ const [properties, analytics] = await Promise.all([readableSites(account.google, input.signIn).catch((error) => error instanceof Error ? error : new Error(String(error))), listAnalyticsAccounts(account.google, { signIn: input.signIn }).catch(() => null)]);
41039
+ return {
41040
+ email: account.email,
41041
+ properties,
41042
+ analytics
41043
+ };
41044
+ })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
41045
+ const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
41046
+ const bingByHost = new Map(bingSites.map((site) => [hostOf(site.url), site]));
41047
+ const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
41048
+ ...property,
41049
+ ...named ? { account: account.email ?? "" } : {},
41050
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf(property.siteUrl))?.url ?? "" } : {}
41051
+ })));
41052
+ const googleHosts = new Set(properties.map((property) => hostOf(property.siteUrl)));
41053
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf(site.url)));
41054
+ const failed = google.filter((account) => account.properties instanceof Error);
41055
+ const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
40677
41056
  property: property.property.replace("properties/", ""),
40678
41057
  name: property.displayName ?? "",
40679
- account: account.displayName ?? ""
40680
- }))) ?? null;
40681
- const research = [["dataforseo", "x-dataforseo-login"], ["ahrefs", "x-ahrefs-key"]].filter(([, name]) => keys.get(name ?? "")).map(([provider]) => provider);
41058
+ account: summary.displayName ?? "",
41059
+ ...named ? { signIn: account.email ?? "" } : {}
41060
+ }))));
41061
+ const research = [["dataforseo", "x-dataforseo-login"], ["ahrefs", "x-ahrefs-key"]].filter(([, name]) => input.keys.get(name ?? "")).map(([provider]) => provider);
41062
+ const columns = [
41063
+ "siteUrl",
41064
+ "permissionLevel",
41065
+ ...named ? ["account"] : [],
41066
+ ...Array.isArray(bing) ? ["bing"] : []
41067
+ ];
40682
41068
  return {
40683
41069
  text: [
41070
+ named ? `Google accounts: ${input.accounts.map((account) => account.email).join(", ")}.` : null,
40684
41071
  `Search Console properties (${properties.length}):`,
40685
- properties.length ? table(properties, ["siteUrl", "permissionLevel"], null) : "None. This Google account has no Search Console access, so the reports cannot run.",
40686
- analytics === null ? "Google Analytics: not readable with this sign-in." : `Google Analytics properties (${analytics.length}):\n${table(analytics, [
41072
+ properties.length ? table(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
41073
+ ...failed.map((account) => `${account.email ?? "Google"}: could not list properties. ${account.properties.message}`),
41074
+ analytics.length ? `Google Analytics properties (${analytics.length}):\n${table(analytics, [
40687
41075
  "property",
40688
41076
  "name",
40689
- "account"
40690
- ], null)}`,
41077
+ "account",
41078
+ ...named ? ["signIn"] : []
41079
+ ], null)}` : "Google Analytics: no properties readable with these sign-ins.",
41080
+ input.bing === void 0 ? null : bing === null ? "Bing Webmaster: not connected." : bing instanceof Error ? `Bing Webmaster: could not list sites. ${bing.message}` : bingOnly.length ? `Bing Webmaster sites with no Search Console property here (${bingOnly.length}): ${bingOnly.map((site) => site.url).join(", ")}.` : `Bing Webmaster: ${bingSites.length} verified sites; the bing column shows each one.`,
40691
41081
  `Research keys: ${research.length ? research.join(", ") : "none"}.`,
41082
+ `Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
40692
41083
  "Next: seo_report with a site."
40693
- ].join("\n\n"),
41084
+ ].filter(Boolean).join("\n\n"),
40694
41085
  data: {
40695
41086
  searchConsole: properties,
40696
41087
  googleAnalytics: analytics,
41088
+ bing: Array.isArray(bing) ? bingSites : null,
40697
41089
  research
40698
41090
  }
40699
41091
  };
@@ -40743,7 +41135,11 @@ const detail = {
40743
41135
  ]).default("summary").describe("summary first. rows: the next page of rows. full: every field as JSON."),
40744
41136
  offset: z.number().int().min(0).default(0).describe("rows only: the first row to return.")
40745
41137
  };
40746
- const DATA_SOURCES = ["search_console", "google_analytics"];
41138
+ const DATA_SOURCES = [
41139
+ "search_console",
41140
+ "google_analytics",
41141
+ "bing"
41142
+ ];
40747
41143
  const LOCAL_ANALYTICS = "Clicky and other analytics run on the person’s machine with the CLI and a provider package: `npx -y seo providers install @seoskill/clicky-provider`, connect it once with `seo providers connect clicky`, then `npx -y seo analytics clicky report --json`. `seo providers list` shows installed providers.";
40748
41144
  function tool(spec) {
40749
41145
  return spec;
@@ -40753,9 +41149,15 @@ const SHARED_TOOLS = [
40753
41149
  name: "seo_sites",
40754
41150
  command: "data",
40755
41151
  title: "List sites",
40756
- description: "Lists the Search Console and Google Analytics properties this Google sign-in can read, and which research keys are set. Call it first.",
41152
+ description: "Lists the Search Console and Google Analytics properties the connected Google accounts can read, the Bing Webmaster site for each, and which research keys are set. Call it first.",
40757
41153
  input: z.object({}),
40758
- run: async (surface) => runSites(await surface.google(), await surface.keys(), surface.signIn)
41154
+ run: async (surface) => runSites({
41155
+ accounts: await surface.accounts(),
41156
+ keys: await surface.keys(),
41157
+ signIn: surface.signIn,
41158
+ bing: surface.deps.bing ? await surface.deps.bing() : void 0,
41159
+ connect: surface.connect
41160
+ })
40759
41161
  }),
40760
41162
  tool({
40761
41163
  name: "seo_report",
@@ -40849,24 +41251,32 @@ const SHARED_TOOLS = [
40849
41251
  name: "seo_data",
40850
41252
  command: "data",
40851
41253
  title: "Raw rows",
40852
- description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. ${LOCAL_ANALYTICS}`,
41254
+ description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. ${LOCAL_ANALYTICS}`,
40853
41255
  input: z.object({
40854
41256
  source: z.enum(DATA_SOURCES).default("search_console"),
40855
- site: z.string().min(1).describe("search_console: the property or site. google_analytics: numeric property id from seo_sites."),
40856
- dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console only."),
41257
+ site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites."),
41258
+ dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
40857
41259
  page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
40858
41260
  query: z.string().min(1).optional().describe("search_console only: query text."),
40859
41261
  ...dates,
40860
41262
  ...detail
40861
41263
  }),
40862
41264
  run: async (surface, input) => {
41265
+ if (input.source === "bing") return runBingRows({
41266
+ site: input.site,
41267
+ dimension: input.dimensions[0] === "page" ? "page" : "query",
41268
+ startDate: input.start_date,
41269
+ endDate: input.end_date,
41270
+ detail: input.detail,
41271
+ offset: input.offset
41272
+ }, surface.deps);
40863
41273
  if (input.source === "google_analytics") return runAnalyticsRows({
40864
41274
  property: input.site,
40865
41275
  startDate: input.start_date,
40866
41276
  endDate: input.end_date,
40867
41277
  detail: input.detail,
40868
41278
  offset: input.offset
40869
- }, await surface.google(), {
41279
+ }, await analyticsSignIn(await surface.accounts(), input.site, surface.signIn), {
40870
41280
  signIn: surface.signIn,
40871
41281
  call: (offset) => `seo_data with source "google_analytics", site "${input.site}", detail "rows", offset ${offset}`
40872
41282
  });
@@ -40895,8 +41305,8 @@ async function runSharedTool(tool, surface, input) {
40895
41305
  if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
40896
41306
  return tool.run(surface, parsed.data);
40897
41307
  }
40898
- const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics and optional keyword and analytics accounts, and returns facts with their dates and limits. It gives no advice.
40899
- 1. seo_sites: the properties this sign-in can read.
41308
+ const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools and optional keyword and analytics accounts, and returns facts with their dates and limits. It gives no advice.
41309
+ 1. seo_sites: the properties the connected accounts can read.
40900
41310
  2. seo_report: the main report. Run it without a section first; it shows the overview and every section's count and top rows. Then ask for the one section you need.
40901
41311
  3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the person's own DataForSEO or Ahrefs key, seo_data for raw rows.
40902
41312
  Show the person the facts that matter, for example in a table or a small dashboard, and decide with them what to change. Do not edit pages without their approval.`;
@@ -40942,13 +41352,26 @@ function createApiKeyCredentialStore(input) {
40942
41352
  //#region packages/core/dist/providers/ahrefs/credentials.js
40943
41353
  const AHREFS_API_KEY_ENV = "SEO_AHREFS_API_KEY";
40944
41354
  const AHREFS_API_KEY_SECRET = PROVIDER_SECRET_NAMES.ahrefsApiKey;
40945
- const store = createApiKeyCredentialStore({
41355
+ const store$1 = createApiKeyCredentialStore({
40946
41356
  provider: "ahrefs",
40947
41357
  environmentVariable: AHREFS_API_KEY_ENV,
40948
41358
  secretName: AHREFS_API_KEY_SECRET,
40949
41359
  invalidMessage: "Ahrefs needs a valid API v3 key."
40950
41360
  });
40951
41361
  async function readAhrefsApiKey(input = {}) {
41362
+ return store$1.read(input);
41363
+ }
41364
+ //#endregion
41365
+ //#region packages/core/dist/providers/bing/credentials.js
41366
+ const BING_API_KEY_ENV = "SEO_BING_API_KEY";
41367
+ const BING_API_KEY_SECRET = PROVIDER_SECRET_NAMES.bingApiKey;
41368
+ const store = createApiKeyCredentialStore({
41369
+ provider: "bing",
41370
+ environmentVariable: BING_API_KEY_ENV,
41371
+ secretName: BING_API_KEY_SECRET,
41372
+ invalidMessage: "Bing Webmaster needs a valid API key."
41373
+ });
41374
+ async function readBingApiKey(input = {}) {
40952
41375
  return store.read(input);
40953
41376
  }
40954
41377
  //#endregion
@@ -41083,9 +41506,17 @@ function nodeCommandDeps(options) {
41083
41506
  return null;
41084
41507
  }
41085
41508
  },
41086
- call: options.call
41509
+ call: options.call,
41510
+ bing: localBing,
41511
+ bingConnect: LOCAL_BING_CONNECT
41087
41512
  };
41088
41513
  }
41514
+ const LOCAL_BING_CONNECT = "Run `seo providers bing connect` with an API key from Bing Webmaster Tools (Settings, API access).";
41515
+ let bing;
41516
+ function localBing() {
41517
+ bing ??= readBingApiKey().catch(() => void 0).then((key) => key ? bingCall({ apiKey: key.apiKey }, { signIn: LOCAL_BING_CONNECT }) : null);
41518
+ return bing;
41519
+ }
41089
41520
  async function localResearchKeys() {
41090
41521
  const [dataForSeo, ahrefs] = await Promise.all([readDataForSeoCredentials().catch(() => void 0), readAhrefsApiKey().catch(() => void 0)]);
41091
41522
  const keys = new Headers();
@@ -45513,8 +45944,12 @@ function registerSharedTools(server, options = {}) {
45513
45944
  resolveSite: (site) => resolveLocalSite(site),
45514
45945
  keys: localResearchKeys,
45515
45946
  drKey: null,
45516
- google: () => localGoogleFetch(),
45517
- signIn: LOCAL_SIGN_IN.signIn
45947
+ accounts: async () => [{
45948
+ email: null,
45949
+ google: await localGoogleFetch()
45950
+ }],
45951
+ signIn: LOCAL_SIGN_IN.signIn,
45952
+ connect: "run `seo auth login` for Google, `seo providers bing connect` for Bing, and `seo providers dataforseo connect` or `seo providers ahrefs connect` for keys."
45518
45953
  };
45519
45954
  for (const shared of SHARED_TOOLS) server.registerTool(shared.name, {
45520
45955
  title: shared.title,