seo 0.5.0 → 0.6.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-CsRkLZGn.js";
2
+ import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-CwLtPH6B.js";
3
3
  import { createHash, randomUUID, sign } from "node:crypto";
4
4
  import { ZodOptional, z } from "zod";
5
5
  import { arch, homedir, platform, totalmem } from "node:os";
@@ -13774,7 +13774,7 @@ async function runAnalyticsReport(gfetch, propertyId, body, options) {
13774
13774
  //#endregion
13775
13775
  //#region packages/core/dist/bing/api.js
13776
13776
  const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
13777
- const MAX_BYTES$1 = 8e6;
13777
+ const MAX_BYTES$2 = 8e6;
13778
13778
  function bingCall(auth, options) {
13779
13779
  return async (method, params = {}) => {
13780
13780
  const url = new URL(`${BASE}/${method}`);
@@ -13799,7 +13799,7 @@ function bingCall(auth, options) {
13799
13799
  if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
13800
13800
  throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
13801
13801
  }
13802
- return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$1 })).d;
13802
+ return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$2 })).d;
13803
13803
  };
13804
13804
  }
13805
13805
  function day(value) {
@@ -13808,7 +13808,7 @@ function day(value) {
13808
13808
  const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
13809
13809
  return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
13810
13810
  }
13811
- const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13811
+ const count$1 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
13812
13812
  function list(data, label) {
13813
13813
  if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
13814
13814
  return data.filter((item) => Boolean(item) && typeof item === "object");
@@ -13824,8 +13824,8 @@ async function bingTraffic(call, site) {
13824
13824
  let invalidRows = 0;
13825
13825
  for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
13826
13826
  const date = day(row.Date);
13827
- const clicks = count(row.Clicks);
13828
- const impressions = count(row.Impressions);
13827
+ const clicks = count$1(row.Clicks);
13828
+ const impressions = count$1(row.Impressions);
13829
13829
  if (!date || clicks === null || impressions === null) {
13830
13830
  invalidRows += 1;
13831
13831
  continue;
@@ -13848,9 +13848,9 @@ async function keyed(call, method, params) {
13848
13848
  for (const row of list(await call(method, params), "row list")) {
13849
13849
  const date = day(row.Date);
13850
13850
  const key = typeof row.Query === "string" ? row.Query.trim() : "";
13851
- const clicks = count(row.Clicks);
13852
- const impressions = count(row.Impressions);
13853
- const position = count(row.AvgImpressionPosition);
13851
+ const clicks = count$1(row.Clicks);
13852
+ const impressions = count$1(row.Impressions);
13853
+ const position = count$1(row.AvgImpressionPosition);
13854
13854
  if (!date || !key || clicks === null || impressions === null) {
13855
13855
  invalidRows += 1;
13856
13856
  continue;
@@ -14430,7 +14430,7 @@ async function readableSites(google, signIn) {
14430
14430
  permissionLevel: entry.permissionLevel ?? ""
14431
14431
  })).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
14432
14432
  }
14433
- const hostOf = (value) => {
14433
+ const hostOf$1 = (value) => {
14434
14434
  try {
14435
14435
  return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
14436
14436
  } catch {
@@ -14448,14 +14448,14 @@ async function runSites(input) {
14448
14448
  };
14449
14449
  })), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
14450
14450
  const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
14451
- const bingByHost = new Map(bingSites.map((site) => [hostOf(site.url), site]));
14451
+ const bingByHost = new Map(bingSites.map((site) => [hostOf$1(site.url), site]));
14452
14452
  const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
14453
14453
  ...property,
14454
14454
  ...named ? { account: account.email ?? "" } : {},
14455
- ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf(property.siteUrl))?.url ?? "" } : {}
14455
+ ...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$1(property.siteUrl))?.url ?? "" } : {}
14456
14456
  })));
14457
- const googleHosts = new Set(properties.map((property) => hostOf(property.siteUrl)));
14458
- const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf(site.url)));
14457
+ const googleHosts = new Set(properties.map((property) => hostOf$1(property.siteUrl)));
14458
+ const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$1(site.url)));
14459
14459
  const failed = google.filter((account) => account.properties instanceof Error);
14460
14460
  const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
14461
14461
  property: property.property.replace("properties/", ""),
@@ -14484,6 +14484,7 @@ async function runSites(input) {
14484
14484
  ], null)}` : "Google Analytics: no properties readable with these sign-ins.",
14485
14485
  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.`,
14486
14486
  `Research keys: ${research.length ? research.join(", ") : "none"}.`,
14487
+ input.clicky?.length ? `Clicky sites (seo_data with source "clicky"): ${input.clicky.join(", ")}.` : null,
14487
14488
  `Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
14488
14489
  "Next: seo_report with a site."
14489
14490
  ].filter(Boolean).join("\n\n"),
@@ -14491,7 +14492,8 @@ async function runSites(input) {
14491
14492
  searchConsole: properties,
14492
14493
  googleAnalytics: analytics,
14493
14494
  bing: Array.isArray(bing) ? bingSites : null,
14494
- research
14495
+ research,
14496
+ clicky: input.clicky ?? []
14495
14497
  }
14496
14498
  };
14497
14499
  }
@@ -14751,13 +14753,13 @@ let credentials;
14751
14753
  function expiresSoon(value) {
14752
14754
  return [...value.google, ...value.bing].some((item) => "expiresAt" in item && item.expiresAt - EARLY < Date.now());
14753
14755
  }
14754
- async function cloudCredentials() {
14756
+ async function cloudCredentials(options = {}) {
14755
14757
  const current = credentials ? await credentials.catch(() => null) : null;
14756
- if (current && !expiresSoon(current)) return current;
14758
+ if (current && !expiresSoon(current) && !options.refreshClicky) return current;
14757
14759
  credentials = (async () => {
14758
14760
  const token = cloudToken();
14759
14761
  if (!token) throw new SeoError("AUTH_REQUIRED", notSignedIn());
14760
- const result = await call$1("/account/cli/credentials", { token });
14762
+ const result = await call$1(`/account/cli/credentials${options.refreshClicky ? "?clicky=refresh" : ""}`, { token });
14761
14763
  if (result.status === 401) throw new SeoError("AUTH_EXPIRED", `This machine is not linked to a seoskill.dev account any more. ${CLOUD_SIGN_IN}`);
14762
14764
  if (!result.ok) throw new SeoError("PROVIDER_UNAVAILABLE", `seoskill.dev returned ${result.status}. Try again in a minute.`);
14763
14765
  return result.body;
@@ -25631,6 +25633,9 @@ async function storedSiteKeys() {
25631
25633
  source: credential.source
25632
25634
  };
25633
25635
  }
25636
+ async function readClickySiteKeys() {
25637
+ return (await storedSiteKeys()).siteKeys;
25638
+ }
25634
25639
  async function readClickySiteKey(siteIdValue, input = {}) {
25635
25640
  const siteId = normalizeClickySiteId(siteIdValue);
25636
25641
  const environmentKey = (input.env ?? process.env)[CLICKY_SITEKEY_ENV]?.trim();
@@ -36920,6 +36925,61 @@ async function updatePostmortemWorkflow(input, dependencies = {}) {
36920
36925
  }
36921
36926
  });
36922
36927
  }
36928
+ //#endregion
36929
+ //#region packages/core/dist/clicky/api.js
36930
+ const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
36931
+ const MAX_BYTES$1 = 5e6;
36932
+ var ClickyError = class extends Error {
36933
+ code;
36934
+ constructor(code, message) {
36935
+ super(message);
36936
+ this.code = code;
36937
+ }
36938
+ };
36939
+ function isClickySiteId(value) {
36940
+ return /^\d{1,30}$/u.test(value.trim());
36941
+ }
36942
+ function isClickySitekey(value) {
36943
+ return /^[A-Za-z0-9]{12,64}$/u.test(value.trim());
36944
+ }
36945
+ async function clickyStats(site, types, options = {}) {
36946
+ const url = new URL(ENDPOINT$1);
36947
+ url.searchParams.set("site_id", site.siteId);
36948
+ url.searchParams.set("sitekey", site.sitekey);
36949
+ url.searchParams.set("type", types.join(","));
36950
+ url.searchParams.set("output", "json");
36951
+ url.searchParams.set("limit", String(options.limit ?? 1e3));
36952
+ url.searchParams.set("app", "seo");
36953
+ if (options.date) url.searchParams.set("date", options.date);
36954
+ const response = await fetch(url).catch(() => {
36955
+ throw new ClickyError("remote", "Could not reach Clicky. Try again.");
36956
+ });
36957
+ if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
36958
+ const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$1 });
36959
+ if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
36960
+ const failed = groups.find((group) => typeof group?.error === "string");
36961
+ if (failed?.error) throw /sitekey|site id|authenticat|permission/iu.test(failed.error) ? new ClickyError("auth", `Clicky rejected site ID ${site.siteId} or its sitekey.`) : new ClickyError("remote", `Clicky: ${failed.error.slice(0, 200)}`);
36962
+ const out = {};
36963
+ for (const type of types) out[type] = [];
36964
+ for (const group of groups) {
36965
+ if (!group?.type || !(group.type in out)) continue;
36966
+ for (const date of group.dates ?? []) for (const item of date.items ?? []) if (item && typeof item === "object") out[group.type]?.push(item);
36967
+ }
36968
+ return out;
36969
+ }
36970
+ async function clickyDomain(site) {
36971
+ const { pages } = await clickyStats(site, ["pages"], {
36972
+ date: "last-30-days",
36973
+ limit: 1
36974
+ });
36975
+ const url = pages?.find((item) => item.url)?.url;
36976
+ if (!url) return null;
36977
+ try {
36978
+ return new URL(url).hostname.replace(/^www\./u, "");
36979
+ } catch {
36980
+ return null;
36981
+ }
36982
+ }
36923
36983
  function rowsText(input) {
36924
36984
  const total = input.rows.length;
36925
36985
  if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
@@ -37322,6 +37382,161 @@ async function runBingRows(input, deps) {
37322
37382
  };
37323
37383
  }
37324
37384
  //#endregion
37385
+ //#region packages/core/dist/commands/clicky.js
37386
+ const CLICKY_KEY_PREFIX = "x-clicky-";
37387
+ function clickySites(keys) {
37388
+ const sites = [];
37389
+ for (const [name, value] of keys) {
37390
+ if (!name.startsWith("x-clicky-")) continue;
37391
+ const siteId = name.slice(9);
37392
+ if (!isClickySiteId(siteId)) continue;
37393
+ let sitekey = value.trim();
37394
+ let domain = null;
37395
+ let account = false;
37396
+ if (sitekey.startsWith("{")) try {
37397
+ const parsed = JSON.parse(sitekey);
37398
+ sitekey = typeof parsed.sitekey === "string" ? parsed.sitekey : "";
37399
+ domain = typeof parsed.domain === "string" ? parsed.domain : null;
37400
+ account = parsed.account === true;
37401
+ } catch {
37402
+ continue;
37403
+ }
37404
+ if (isClickySitekey(sitekey)) sites.push({
37405
+ siteId,
37406
+ sitekey,
37407
+ domain,
37408
+ account
37409
+ });
37410
+ }
37411
+ return sites.sort((left, right) => left.siteId < right.siteId ? -1 : left.siteId > right.siteId ? 1 : 0);
37412
+ }
37413
+ const clickyLabel = (site) => site.domain ? `${site.siteId} (${site.domain})` : site.siteId;
37414
+ function hostOf(value) {
37415
+ const text = value.trim().toLowerCase().replace(/^sc-domain:/u, "");
37416
+ try {
37417
+ return new URL(text.includes("://") ? text : `https://${text}`).hostname.replace(/^www\./u, "");
37418
+ } catch {
37419
+ return text;
37420
+ }
37421
+ }
37422
+ async function pickClickySite(saved, target, connect, refresh) {
37423
+ const host = hostOf(target);
37424
+ const match = (site) => site.siteId === target.trim() || site.domain === host;
37425
+ let sites = saved;
37426
+ if (!sites.some(match) && refresh) sites = await refresh() ?? sites;
37427
+ if (!sites.length) throw new CommandError("NO_KEY", `No Clicky site is saved. Add your Clicky account, or one site's ID and sitekey (Clicky, Preferences, Info): ${connect}`);
37428
+ const found = sites.find(match);
37429
+ if (found) return found;
37430
+ if (!isClickySiteId(target)) {
37431
+ for (const site of sites.filter((item) => !item.domain).slice(0, 10)) site.domain = await clickyDomain(site).catch(() => null);
37432
+ const named = sites.find(match);
37433
+ if (named) return named;
37434
+ }
37435
+ throw new CommandError("NO_ACCESS", `No saved Clicky site matches ${target}. Saved Clicky sites: ${sites.map(clickyLabel).join(", ")}. Pass one of these site IDs, or add the site: ${connect}`);
37436
+ }
37437
+ const DAY = 864e5;
37438
+ const isoDay = (time) => new Date(time).toISOString().slice(0, 10);
37439
+ function clickyRange(input) {
37440
+ if (!input.startDate && !input.endDate) {
37441
+ const end = Date.now() - DAY;
37442
+ return {
37443
+ startDate: isoDay(end - 27 * DAY),
37444
+ endDate: isoDay(end)
37445
+ };
37446
+ }
37447
+ const start = Date.parse(`${input.startDate}T00:00:00Z`);
37448
+ const end = Date.parse(`${input.endDate}T00:00:00Z`);
37449
+ if (!input.startDate || !input.endDate || !(start <= end)) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
37450
+ if ((end - start) / DAY + 1 > 31) throw new CommandError("INVALID_INPUT", "Clicky reads at most 31 days at a time. Send a shorter range.");
37451
+ return {
37452
+ startDate: input.startDate,
37453
+ endDate: input.endDate
37454
+ };
37455
+ }
37456
+ const count = (item) => {
37457
+ const value = Number(item.value);
37458
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
37459
+ };
37460
+ const totals = (items) => items.map((item) => ({
37461
+ name: item.title ?? "",
37462
+ visits: count(item)
37463
+ })).filter((item) => Boolean(item.name) && item.visits !== null);
37464
+ const line = (items) => {
37465
+ const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
37466
+ const rest = items.slice(10);
37467
+ if (rest.length) top.push(`${rest.length} others ${rest.reduce((sum, item) => sum + item.visits, 0)}`);
37468
+ return top.join("; ");
37469
+ };
37470
+ const LIMIT = 1e3;
37471
+ async function runClickyRows(input, keys, options) {
37472
+ const refresh = options.refresh;
37473
+ const site = await pickClickySite(clickySites(keys), input.site, options.connect, refresh && (async () => {
37474
+ const fresh = await refresh().catch(() => null);
37475
+ return fresh && clickySites(fresh);
37476
+ }));
37477
+ const range = clickyRange(input);
37478
+ const offset = input.offset ?? 0;
37479
+ const stats = await clickyStats(site, [
37480
+ "pages-entrance",
37481
+ "traffic-sources",
37482
+ "searches-engines"
37483
+ ], {
37484
+ date: `${range.startDate},${range.endDate}`,
37485
+ limit: LIMIT
37486
+ }).catch((error) => {
37487
+ if (error instanceof ClickyError) throw new CommandError(error.code === "auth" ? "NO_ACCESS" : "PROVIDER_ERROR", error.code === "auth" ? `${error.message} Save it again: ${options.connect}` : error.message);
37488
+ throw error;
37489
+ });
37490
+ const entrances = stats["pages-entrance"] ?? [];
37491
+ const visitsByPage = /* @__PURE__ */ new Map();
37492
+ for (const item of entrances) {
37493
+ const visits = count(item);
37494
+ if (visits === null || !item.url) continue;
37495
+ let page;
37496
+ try {
37497
+ page = new URL(item.url).pathname;
37498
+ } catch {
37499
+ continue;
37500
+ }
37501
+ visitsByPage.set(page, (visitsByPage.get(page) ?? 0) + visits);
37502
+ }
37503
+ const rows = [...visitsByPage].map(([page, visits]) => ({
37504
+ page,
37505
+ visits
37506
+ })).sort((left, right) => right.visits - left.visits || (left.page < right.page ? -1 : left.page > right.page ? 1 : 0));
37507
+ const sources = totals(stats["traffic-sources"] ?? []);
37508
+ const engines = totals(stats["searches-engines"] ?? []);
37509
+ const notes = [
37510
+ "Visits by landing page from every source, as Clicky counts them, in the Clicky site’s time zone. Clicky does not split landing pages by source.",
37511
+ sources.length ? `Traffic sources (visits): ${line(sources)}.` : "Traffic sources: none in this range.",
37512
+ engines.length ? `Search engines (visits): ${line(engines)}.` : "Search engines: none in this range."
37513
+ ];
37514
+ if (entrances.length >= LIMIT) notes.push(`Clicky returned its ${LIMIT}-row limit; pages past it are missing, not zero.`);
37515
+ return {
37516
+ text: rowsText({
37517
+ title: `Clicky site ${clickyLabel(site)}: landing pages, ${range.startDate} to ${range.endDate}.`,
37518
+ rows,
37519
+ offset,
37520
+ limit: 50,
37521
+ detail: input.detail ?? "summary",
37522
+ notes,
37523
+ more: options.call
37524
+ }),
37525
+ data: {
37526
+ siteId: site.siteId,
37527
+ domain: site.domain,
37528
+ range,
37529
+ metric: "visits",
37530
+ sources,
37531
+ searchEngines: engines,
37532
+ capped: entrances.length >= LIMIT,
37533
+ total: rows.length,
37534
+ offset,
37535
+ rows: rows.slice(offset, offset + 50)
37536
+ }
37537
+ };
37538
+ }
37539
+ //#endregion
37325
37540
  //#region packages/core/dist/commands/domain-rating.js
37326
37541
  const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
37327
37542
  const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
@@ -37547,7 +37762,7 @@ function nextFor(section, call) {
37547
37762
  "second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
37548
37763
  "content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
37549
37764
  cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
37550
- "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved.`,
37765
+ "traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
37551
37766
  bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
37552
37767
  }[section];
37553
37768
  }
@@ -38337,7 +38552,39 @@ const LOST_SHARE = .2;
38337
38552
  const MIN_LOST = 3;
38338
38553
  const CHECKED = 20;
38339
38554
  const INSPECTED = 10;
38340
- const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED}.`;
38555
+ const SITE_WIDE_MIN = 5;
38556
+ function indexSummary(inspected, home) {
38557
+ const known = inspected.filter((item) => item?.verdict != null);
38558
+ const indexed = known.filter((item) => item.verdict === "PASS").length;
38559
+ const coverage = /* @__PURE__ */ new Map();
38560
+ for (const item of known) {
38561
+ if (item.verdict === "PASS") continue;
38562
+ const state = item.coverage ?? item.verdict ?? "unknown";
38563
+ coverage.set(state, (coverage.get(state) ?? 0) + 1);
38564
+ }
38565
+ const notIndexed = known.length - indexed;
38566
+ const homeIndexed = home?.status?.verdict ? home.status.verdict === "PASS" : null;
38567
+ const siteWide = notIndexed >= SITE_WIDE_MIN && notIndexed * 2 > known.length && homeIndexed;
38568
+ const byCoverage = [...coverage].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([state, count]) => ({
38569
+ state,
38570
+ count
38571
+ }));
38572
+ return {
38573
+ text: (known.length ? [`Index status of the ${known.length} inspected pages: ${indexed} indexed, ${notIndexed} not indexed${byCoverage.length ? ` (${byCoverage.map((item) => `${item.count} ${item.state}`).join("; ")})` : ""}.${home && homeIndexed !== null ? ` Home page ${home.url}: ${homeIndexed ? "indexed" : `not indexed (${home.status?.coverage ?? home.status?.verdict})`}.` : ""}`, siteWide ? `Site-wide pattern: ${notIndexed} of ${known.length} inspected pages are not in Google's index, but the home page is. This points to the whole site, not single pages. It does not show the cause.` : null] : []).filter(Boolean).join("\n"),
38574
+ data: {
38575
+ inspected: known.length,
38576
+ indexed,
38577
+ notIndexed,
38578
+ byCoverage,
38579
+ homePage: home && homeIndexed !== null ? {
38580
+ url: home.url,
38581
+ indexed: homeIndexed
38582
+ } : null,
38583
+ siteWide: Boolean(siteWide)
38584
+ }
38585
+ };
38586
+ }
38587
+ const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED} and the home page.`;
38341
38588
  async function runLostPages(input, deps) {
38342
38589
  const range = dateWindow(input, 28);
38343
38590
  const before = earlierRange(range);
@@ -38355,7 +38602,17 @@ async function runLostPages(input, deps) {
38355
38602
  clicks: current.get(row.keys[0]) ?? 0
38356
38603
  })).filter((row) => row.clicks <= row.previous_clicks * LOST_SHARE).sort((a, b) => b.previous_clicks - b.clicks - (a.previous_clicks - a.clicks) || a.url.localeCompare(b.url));
38357
38604
  const top = lost.slice(0, CHECKED);
38358
- const [facts, indexed] = await Promise.all([deps.checkPages(top.map((row) => row.url)), Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url)))]);
38605
+ const homeUrl = top[0] ? `${new URL(top[0].url).origin}/` : null;
38606
+ const [facts, indexed, homeStatus] = await Promise.all([
38607
+ deps.checkPages(top.map((row) => row.url)),
38608
+ Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url))),
38609
+ homeUrl && !top.slice(0, INSPECTED).some((row) => row.url === homeUrl) ? deps.inspect(input.site, homeUrl).catch(() => null) : null
38610
+ ]);
38611
+ const homeIndex = top.slice(0, INSPECTED).findIndex((row) => row.url === homeUrl);
38612
+ const summary = indexSummary(homeIndex >= 0 ? indexed.filter((_, index) => index !== homeIndex) : indexed, homeUrl ? {
38613
+ url: homeUrl,
38614
+ status: homeIndex >= 0 ? indexed[homeIndex] ?? null : homeStatus
38615
+ } : null);
38359
38616
  const rows = top.map((row, index) => ({
38360
38617
  ...row,
38361
38618
  status: facts[index]?.status ?? null,
@@ -38371,6 +38628,7 @@ async function runLostPages(input, deps) {
38371
38628
  `Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
38372
38629
  `${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
38373
38630
  rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
38631
+ summary.text,
38374
38632
  "Live checks are fetched now with no redirects followed. Index status is from URL Inspection; Google allows about 2,000 inspections a day per property.",
38375
38633
  temporaryRedirectNote(rows),
38376
38634
  `Next: ${deps.call("page")} for one URL; ${deps.call("report", {
@@ -38384,6 +38642,7 @@ async function runLostPages(input, deps) {
38384
38642
  comparisonRange: before,
38385
38643
  rule: LOST_PAGES_RULE,
38386
38644
  lostPages: lost.length,
38645
+ index: summary.data,
38387
38646
  pages: rows
38388
38647
  }
38389
38648
  };
@@ -38436,9 +38695,9 @@ const detail = {
38436
38695
  const DATA_SOURCES = [
38437
38696
  "search_console",
38438
38697
  "google_analytics",
38439
- "bing"
38698
+ "bing",
38699
+ "clicky"
38440
38700
  ];
38441
- 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.";
38442
38701
  function tool(spec) {
38443
38702
  return spec;
38444
38703
  }
@@ -38452,6 +38711,7 @@ const SHARED_TOOLS = [
38452
38711
  run: async (surface) => runSites({
38453
38712
  accounts: await surface.accounts(),
38454
38713
  keys: await surface.keys(),
38714
+ clicky: clickySites(await surface.keys()).map(clickyLabel),
38455
38715
  signIn: surface.signIn,
38456
38716
  bing: surface.deps.bing ? await surface.deps.bing() : void 0,
38457
38717
  connect: surface.connect
@@ -38549,10 +38809,10 @@ const SHARED_TOOLS = [
38549
38809
  name: "seo_data",
38550
38810
  command: "data",
38551
38811
  title: "Raw rows",
38552
- 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}`,
38812
+ description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. clicky: landing-page visits, traffic sources and search engines from a Clicky site the person saved (site is its domain or Clicky site ID; at most 31 days).`,
38553
38813
  input: z.object({
38554
38814
  source: z.enum(DATA_SOURCES).default("search_console"),
38555
- site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites."),
38815
+ site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites. clicky: the site or its Clicky site ID."),
38556
38816
  dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
38557
38817
  page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
38558
38818
  query: z.string().min(1).optional().describe("search_console only: query text."),
@@ -38560,6 +38820,17 @@ const SHARED_TOOLS = [
38560
38820
  ...detail
38561
38821
  }),
38562
38822
  run: async (surface, input) => {
38823
+ if (input.source === "clicky") return runClickyRows({
38824
+ site: input.site,
38825
+ startDate: input.start_date,
38826
+ endDate: input.end_date,
38827
+ detail: input.detail,
38828
+ offset: input.offset
38829
+ }, await surface.keys(), {
38830
+ connect: surface.connect,
38831
+ call: (offset) => `seo_data with source "clicky", site "${input.site}", detail "rows", offset ${offset}`,
38832
+ refresh: surface.refreshClicky
38833
+ });
38563
38834
  if (input.source === "bing") return runBingRows({
38564
38835
  site: input.site,
38565
38836
  dimension: input.dimensions[0] === "page" ? "page" : "query",
@@ -38603,10 +38874,10 @@ async function runSharedTool(tool, surface, input) {
38603
38874
  if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
38604
38875
  return tool.run(surface, parsed.data);
38605
38876
  }
38606
- 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.
38877
+ const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools, Clicky and optional keyword accounts, and returns facts with their dates and limits. It gives no advice.
38607
38878
  1. seo_sites: the properties the connected accounts can read.
38608
38879
  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.
38609
- 3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows.
38880
+ 3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.
38610
38881
  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.`;
38611
38882
  const mcpCall = (command, args) => {
38612
38883
  const name = `seo_${command.replace("-", "_")}`;
@@ -38833,12 +39104,17 @@ async function localAccounts() {
38833
39104
  }));
38834
39105
  }
38835
39106
  async function localResearchKeys() {
38836
- const [dataForSeo, ahrefs, saved] = await Promise.all([
39107
+ const [dataForSeo, ahrefs, clicky, saved] = await Promise.all([
38837
39108
  readDataForSeoCredentials().catch(() => void 0),
38838
39109
  readAhrefsApiKey().catch(() => void 0),
39110
+ readClickySiteKeys().catch(() => ({})),
38839
39111
  cloudLinked() ? cloudKeys().catch(() => ({})) : {}
38840
39112
  ]);
38841
39113
  const keys = new Headers(saved);
39114
+ for (const [siteId, sitekey] of Object.entries(clicky)) {
39115
+ const name = `${CLICKY_KEY_PREFIX}${siteId}`;
39116
+ if (!keys.has(name)) keys.set(name, sitekey);
39117
+ }
38842
39118
  if (dataForSeo) {
38843
39119
  keys.set("x-dataforseo-login", dataForSeo.login);
38844
39120
  keys.set("x-dataforseo-password", dataForSeo.password);
@@ -38846,6 +39122,11 @@ async function localResearchKeys() {
38846
39122
  if (ahrefs?.apiKey) keys.set("x-ahrefs-key", ahrefs.apiKey);
38847
39123
  return keys;
38848
39124
  }
39125
+ async function localRefreshClicky() {
39126
+ if (!cloudLinked()) return null;
39127
+ await cloudCredentials({ refreshClicky: true });
39128
+ return localResearchKeys();
39129
+ }
38849
39130
  async function resolveLocalSite(site, options = {}) {
38850
39131
  const found = matchProperty((await listSites(options.refresh, options.account)).map((entry) => entry.siteUrl), site);
38851
39132
  if (!found) throw new CommandError("NO_ACCESS", `This Google login has no Search Console property for ${site}. Run \`seo sites\` to list the properties it can read.`);
@@ -42362,6 +42643,7 @@ function registerSharedTools(server, options = {}) {
42362
42643
  deps: nodeCommandDeps({ call: mcpCall }),
42363
42644
  resolveSite: (site) => resolveLocalSite(site),
42364
42645
  keys: localResearchKeys,
42646
+ refreshClicky: localRefreshClicky,
42365
42647
  drKey: null,
42366
42648
  accounts: localAccounts,
42367
42649
  signIn: LOCAL_SIGN_IN.signIn,