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/PRIVACY.md +21 -6
- package/README.md +8 -8
- package/dist/cli.js +85 -85
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BIlBtrjd.js → dist-C_Ezkcm5.js} +1 -1
- package/dist/{dist-CN4VawW-.js → dist-nmppE37y.js} +31 -30
- package/dist/dist-nmppE37y.js.map +1 -0
- package/dist/index.d.ts +48 -44
- package/dist/index.js +28 -11
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +310 -28
- package/dist/mcp.js.map +1 -1
- package/dist/{package-CsRkLZGn.js → package-CwLtPH6B.js} +2 -2
- package/dist/{package-CsRkLZGn.js.map → package-CwLtPH6B.js.map} +1 -1
- package/package.json +2 -2
- package/skills/seo/SKILL.md +2 -2
- package/dist/dist-CN4VawW-.js.map +0 -1
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-
|
|
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$
|
|
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$
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|