seo 0.5.0 → 0.7.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 +13 -14
- package/dist/cli.js +84 -85
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BIlBtrjd.js → dist-C21OaeU2.js} +1 -1
- package/dist/{dist-CN4VawW-.js → dist-DVFMlaqF.js} +84 -80
- package/dist/dist-DVFMlaqF.js.map +1 -0
- package/dist/index.d.ts +30 -11
- package/dist/index.js +539 -98
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +882 -177
- package/dist/mcp.js.map +1 -1
- package/dist/{package-CsRkLZGn.js → package-CmplBMg7.js} +2 -2
- package/dist/{package-CsRkLZGn.js.map → package-CmplBMg7.js.map} +1 -1
- package/package.json +3 -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-CmplBMg7.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;
|
|
@@ -16800,7 +16802,7 @@ function extractCanonicalEvidence($, headers, baseUrl) {
|
|
|
16800
16802
|
//#region packages/core/dist/extract/content-sketch.js
|
|
16801
16803
|
const SHINGLE_SIZE = 5;
|
|
16802
16804
|
const MAX_HASHES = 32;
|
|
16803
|
-
function words(value) {
|
|
16805
|
+
function words$1(value) {
|
|
16804
16806
|
return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
16805
16807
|
}
|
|
16806
16808
|
function sampleIndexes(total, limit) {
|
|
@@ -16811,7 +16813,7 @@ function digest(value) {
|
|
|
16811
16813
|
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
16812
16814
|
}
|
|
16813
16815
|
function contentSketch(value) {
|
|
16814
|
-
const tokens = words(value);
|
|
16816
|
+
const tokens = words$1(value);
|
|
16815
16817
|
const hashes = sampleIndexes(Math.max(0, tokens.length - SHINGLE_SIZE + 1), MAX_HASHES).map((index) => digest(tokens.slice(index, index + SHINGLE_SIZE).join(" ")));
|
|
16816
16818
|
return {
|
|
16817
16819
|
version: 1,
|
|
@@ -16823,6 +16825,11 @@ function contentSketch(value) {
|
|
|
16823
16825
|
}
|
|
16824
16826
|
//#endregion
|
|
16825
16827
|
//#region packages/core/dist/extract/main-content.js
|
|
16828
|
+
function parseDocument$1(html) {
|
|
16829
|
+
const { document } = parseHTML(html);
|
|
16830
|
+
if (document.documentElement?.localName === "html") return document;
|
|
16831
|
+
return parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`).document;
|
|
16832
|
+
}
|
|
16826
16833
|
function readableError(error) {
|
|
16827
16834
|
return error instanceof Error ? error.message : String(error);
|
|
16828
16835
|
}
|
|
@@ -16852,10 +16859,6 @@ function resolveDocumentUrls(document, baseUrl) {
|
|
|
16852
16859
|
}
|
|
16853
16860
|
}
|
|
16854
16861
|
function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetail) {
|
|
16855
|
-
const { document } = parseHTML(fetchResult.html);
|
|
16856
|
-
const bodyText = document.body.textContent ?? "";
|
|
16857
|
-
const article = new Readability(document).parse();
|
|
16858
|
-
const text = article?.textContent ?? bodyText;
|
|
16859
16862
|
const fallback = requested !== "readability";
|
|
16860
16863
|
const diagnostics = {
|
|
16861
16864
|
requested,
|
|
@@ -16868,13 +16871,27 @@ function readabilityResult(fetchResult, requested, fallbackReason, fallbackDetai
|
|
|
16868
16871
|
fallbackDetail
|
|
16869
16872
|
} : {}
|
|
16870
16873
|
};
|
|
16871
|
-
|
|
16872
|
-
|
|
16873
|
-
|
|
16874
|
-
|
|
16875
|
-
|
|
16876
|
-
|
|
16877
|
-
|
|
16874
|
+
const warnings = fallback && fallbackDetail ? [`Defuddle extraction fell back to Readability: ${fallbackDetail}`] : [];
|
|
16875
|
+
try {
|
|
16876
|
+
const document = parseDocument$1(fetchResult.html);
|
|
16877
|
+
const bodyText = document.body?.textContent ?? "";
|
|
16878
|
+
const article = new Readability(document).parse();
|
|
16879
|
+
const text = article?.textContent ?? bodyText;
|
|
16880
|
+
return {
|
|
16881
|
+
text,
|
|
16882
|
+
excerpt: normalizedText(article?.excerpt),
|
|
16883
|
+
wordCount: countCjkAwareWords(text),
|
|
16884
|
+
diagnostics,
|
|
16885
|
+
warnings
|
|
16886
|
+
};
|
|
16887
|
+
} catch (error) {
|
|
16888
|
+
return {
|
|
16889
|
+
text: "",
|
|
16890
|
+
wordCount: 0,
|
|
16891
|
+
diagnostics,
|
|
16892
|
+
warnings: [...warnings, `The page's main content could not be read: ${readableError(error)}`]
|
|
16893
|
+
};
|
|
16894
|
+
}
|
|
16878
16895
|
}
|
|
16879
16896
|
function defaultParseDefuddle(document, options) {
|
|
16880
16897
|
return new Defuddle(document, options).parse();
|
|
@@ -16882,13 +16899,13 @@ function defaultParseDefuddle(document, options) {
|
|
|
16882
16899
|
function extractMainContent(fetchResult, extractor = "defuddle", dependencies = {}) {
|
|
16883
16900
|
if (extractor === "readability") return readabilityResult(fetchResult, extractor);
|
|
16884
16901
|
try {
|
|
16885
|
-
const
|
|
16902
|
+
const document = parseDocument$1(fetchResult.html);
|
|
16886
16903
|
resolveDocumentUrls(document, fetchResult.finalUrl);
|
|
16887
16904
|
const article = (dependencies.parseDefuddle ?? defaultParseDefuddle)(document, {
|
|
16888
16905
|
url: fetchResult.finalUrl,
|
|
16889
16906
|
useAsync: false
|
|
16890
16907
|
});
|
|
16891
|
-
const text = typeof article.content === "string" ? load(article.content).text() : document.body
|
|
16908
|
+
const text = typeof article.content === "string" ? load(article.content).text() : document.body?.textContent ?? "";
|
|
16892
16909
|
if (!text.trim()) return readabilityResult(fetchResult, extractor, "defuddle_empty", "Defuddle returned no main content");
|
|
16893
16910
|
const reportedWordCount = article.wordCount;
|
|
16894
16911
|
const hasValidWordCount = typeof reportedWordCount === "number" && Number.isFinite(reportedWordCount) && reportedWordCount >= 0;
|
|
@@ -16911,6 +16928,28 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
|
|
|
16911
16928
|
return readabilityResult(fetchResult, extractor, "defuddle_error", readableError(error));
|
|
16912
16929
|
}
|
|
16913
16930
|
}
|
|
16931
|
+
const MARKDOWN_LIMIT = 2e4;
|
|
16932
|
+
async function extractMainMarkdown(fetchResult) {
|
|
16933
|
+
const document = parseDocument$1(fetchResult.html);
|
|
16934
|
+
resolveDocumentUrls(document, fetchResult.finalUrl);
|
|
16935
|
+
const { Defuddle: toMarkdown } = await import("defuddle/node");
|
|
16936
|
+
const markdown = ((await toMarkdown(document, fetchResult.finalUrl, {
|
|
16937
|
+
markdown: true,
|
|
16938
|
+
useAsync: false
|
|
16939
|
+
})).content ?? "").trim();
|
|
16940
|
+
if (markdown.length <= 2e4) return {
|
|
16941
|
+
markdown,
|
|
16942
|
+
characters: markdown.length,
|
|
16943
|
+
truncated: false
|
|
16944
|
+
};
|
|
16945
|
+
const cut = markdown.slice(0, MARKDOWN_LIMIT);
|
|
16946
|
+
const end = cut.lastIndexOf("\n");
|
|
16947
|
+
return {
|
|
16948
|
+
markdown: (end > 2e4 / 2 ? cut.slice(0, end) : cut).trimEnd(),
|
|
16949
|
+
characters: markdown.length,
|
|
16950
|
+
truncated: true
|
|
16951
|
+
};
|
|
16952
|
+
}
|
|
16914
16953
|
//#endregion
|
|
16915
16954
|
//#region packages/core/dist/extract/google-rich-results.js
|
|
16916
16955
|
const SUPPORTED_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -20686,7 +20725,7 @@ const QUERY_FLAG_METHOD = {
|
|
|
20686
20725
|
"navigational-other": "Names a domain that is not the property's host, ends with a spoken domain suffix or a login/site/app word, starts with a large platform name and adds no topic word, or is one short ASCII word that is not a brand term. Such queries usually look for another site."
|
|
20687
20726
|
}
|
|
20688
20727
|
};
|
|
20689
|
-
const FUNCTION_WORDS = /* @__PURE__ */ new Set([
|
|
20728
|
+
const FUNCTION_WORDS$1 = /* @__PURE__ */ new Set([
|
|
20690
20729
|
"a",
|
|
20691
20730
|
"about",
|
|
20692
20731
|
"an",
|
|
@@ -20751,7 +20790,7 @@ function isLikelyAutomatedQuery(query) {
|
|
|
20751
20790
|
if (query.trim().split(/\s+/).filter(Boolean).some((token) => IDENTIFIER_TOKEN.test(token))) return true;
|
|
20752
20791
|
const tokens = tokenize(query);
|
|
20753
20792
|
if (tokens.length >= 20) return true;
|
|
20754
|
-
if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS.has(token))) return true;
|
|
20793
|
+
if (tokens.length >= 8 && !/[^\p{ASCII}]/u.test(query) && !tokens.some((token) => FUNCTION_WORDS$1.has(token))) return true;
|
|
20755
20794
|
return false;
|
|
20756
20795
|
}
|
|
20757
20796
|
const FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
@@ -25631,6 +25670,9 @@ async function storedSiteKeys() {
|
|
|
25631
25670
|
source: credential.source
|
|
25632
25671
|
};
|
|
25633
25672
|
}
|
|
25673
|
+
async function readClickySiteKeys() {
|
|
25674
|
+
return (await storedSiteKeys()).siteKeys;
|
|
25675
|
+
}
|
|
25634
25676
|
async function readClickySiteKey(siteIdValue, input = {}) {
|
|
25635
25677
|
const siteId = normalizeClickySiteId(siteIdValue);
|
|
25636
25678
|
const environmentKey = (input.env ?? process.env)[CLICKY_SITEKEY_ENV]?.trim();
|
|
@@ -27687,7 +27729,7 @@ function analyzeCtrUnderperformersFromRows(input) {
|
|
|
27687
27729
|
//#endregion
|
|
27688
27730
|
//#region packages/core/dist/analyze/ctr-underperformers-report.js
|
|
27689
27731
|
const MAX_GSC_ROWS$3 = 1e5;
|
|
27690
|
-
const DEFAULT_DAYS$
|
|
27732
|
+
const DEFAULT_DAYS$4 = 28;
|
|
27691
27733
|
const MAX_DAYS$2 = 548;
|
|
27692
27734
|
function plural$4(count, singular, pluralLabel = `${singular}s`) {
|
|
27693
27735
|
return count === 1 ? singular : pluralLabel;
|
|
@@ -27710,7 +27752,7 @@ async function ctrUnderperformersReportWith(input, dependencies) {
|
|
|
27710
27752
|
const explicitRange = explicitDateRange(input, MAX_DAYS$2);
|
|
27711
27753
|
const days = explicitRange?.days ?? integerOption$1({
|
|
27712
27754
|
value: input.days,
|
|
27713
|
-
fallback: DEFAULT_DAYS$
|
|
27755
|
+
fallback: DEFAULT_DAYS$4,
|
|
27714
27756
|
minimum: 1,
|
|
27715
27757
|
maximum: MAX_DAYS$2,
|
|
27716
27758
|
label: "Days"
|
|
@@ -30915,7 +30957,7 @@ function quickWinTemplateSummaries(items) {
|
|
|
30915
30957
|
}
|
|
30916
30958
|
//#endregion
|
|
30917
30959
|
//#region packages/core/dist/analyze/site-diagnostics/quick-wins-report.js
|
|
30918
|
-
const DEFAULT_DAYS$
|
|
30960
|
+
const DEFAULT_DAYS$3 = 28;
|
|
30919
30961
|
const MAX_DAYS$1 = 548;
|
|
30920
30962
|
const DEFAULT_VERIFY_LIMIT$1 = 5;
|
|
30921
30963
|
const MAX_VERIFY_LIMIT = 100;
|
|
@@ -30937,7 +30979,7 @@ async function quickWinsReportWith(input, dependencies) {
|
|
|
30937
30979
|
const explicitRange = explicitDateRange(input, MAX_DAYS$1);
|
|
30938
30980
|
const days = explicitRange?.days ?? integerOption$1({
|
|
30939
30981
|
value: input.days,
|
|
30940
|
-
fallback: DEFAULT_DAYS$
|
|
30982
|
+
fallback: DEFAULT_DAYS$3,
|
|
30941
30983
|
minimum: 1,
|
|
30942
30984
|
maximum: MAX_DAYS$1,
|
|
30943
30985
|
label: "Days"
|
|
@@ -31361,7 +31403,7 @@ function analyzeStrikingDistanceRows(input) {
|
|
|
31361
31403
|
}
|
|
31362
31404
|
//#endregion
|
|
31363
31405
|
//#region packages/core/dist/analyze/striking-distance-report.js
|
|
31364
|
-
const DEFAULT_DAYS$
|
|
31406
|
+
const DEFAULT_DAYS$2 = 28;
|
|
31365
31407
|
const MAX_DAYS = 548;
|
|
31366
31408
|
const DEFAULT_VERIFY_LIMIT = 5;
|
|
31367
31409
|
const MAX_SOURCE_ROWS$1 = 1e5;
|
|
@@ -31370,7 +31412,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
|
|
|
31370
31412
|
return Math.min(maximum, Math.max(minimum, Math.floor(value)));
|
|
31371
31413
|
}
|
|
31372
31414
|
function reportDays(value) {
|
|
31373
|
-
if (value === void 0) return DEFAULT_DAYS$
|
|
31415
|
+
if (value === void 0) return DEFAULT_DAYS$2;
|
|
31374
31416
|
if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS}.`);
|
|
31375
31417
|
return value;
|
|
31376
31418
|
}
|
|
@@ -32810,20 +32852,394 @@ function analyzeInternalLinksFromRows(input) {
|
|
|
32810
32852
|
}
|
|
32811
32853
|
};
|
|
32812
32854
|
}
|
|
32813
|
-
|
|
32814
|
-
|
|
32815
|
-
|
|
32816
|
-
|
|
32817
|
-
|
|
32818
|
-
|
|
32855
|
+
const MAX_TERMS = 100;
|
|
32856
|
+
const PLACEMENT_SENTENCES = 2;
|
|
32857
|
+
const ANCHORS_PER_SENTENCE = 3;
|
|
32858
|
+
const MIN_SENTENCE_WORDS = 6;
|
|
32859
|
+
const NAME_WEIGHT = 2;
|
|
32860
|
+
const GENERIC_ANCHORS = /^(click here|here|read more|learn more|more|this|this post|this article|this page|link|website|check it out|see more|view|continue reading)$/i;
|
|
32861
|
+
function normalise(value) {
|
|
32862
|
+
return value.normalize("NFKD").replace(/\p{Diacritic}/gu, "").normalize("NFC").replace(/([\p{L}\p{N}])[’'`]([\p{L}\p{N}])/gu, "$1$2").toLowerCase().replace(/&/g, " and ").replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
32863
|
+
}
|
|
32864
|
+
const words = (value) => normalise(value).split(" ").filter(Boolean);
|
|
32865
|
+
const FUNCTION_WORDS = new Set("a an and are as at be but by for from has have how if in into is it its of on or that the their then there these they this to was we were what when where which who why will with you your our can do does not just more most also than too very about after before over under again all any each other some such no nor only own same so up out off one two both every few several many much should would could because while since although though until unless whether however".split(" "));
|
|
32866
|
+
const NOT_PLURAL = /* @__PURE__ */ new Set([
|
|
32867
|
+
"always",
|
|
32868
|
+
"atlas",
|
|
32869
|
+
"canvas",
|
|
32870
|
+
"gas",
|
|
32871
|
+
"lens",
|
|
32872
|
+
"means",
|
|
32873
|
+
"news",
|
|
32874
|
+
"perhaps",
|
|
32875
|
+
"series",
|
|
32876
|
+
"species",
|
|
32877
|
+
"sometimes",
|
|
32878
|
+
"whereas",
|
|
32879
|
+
"yes",
|
|
32880
|
+
"this",
|
|
32881
|
+
"has",
|
|
32882
|
+
"was",
|
|
32883
|
+
"does",
|
|
32884
|
+
"its"
|
|
32885
|
+
]);
|
|
32886
|
+
const CHE = /* @__PURE__ */ new Set([
|
|
32887
|
+
"caches",
|
|
32888
|
+
"niches",
|
|
32889
|
+
"headaches",
|
|
32890
|
+
"cliches",
|
|
32891
|
+
"avalanches",
|
|
32892
|
+
"moustaches"
|
|
32893
|
+
]);
|
|
32894
|
+
const NOT_AGENT = /* @__PURE__ */ new Set([
|
|
32895
|
+
"customer",
|
|
32896
|
+
"together",
|
|
32897
|
+
"however",
|
|
32898
|
+
"whatever",
|
|
32899
|
+
"whenever",
|
|
32900
|
+
"wherever",
|
|
32901
|
+
"remember",
|
|
32902
|
+
"consider",
|
|
32903
|
+
"character",
|
|
32904
|
+
"register",
|
|
32905
|
+
"november",
|
|
32906
|
+
"december",
|
|
32907
|
+
"september",
|
|
32908
|
+
"october"
|
|
32909
|
+
]);
|
|
32910
|
+
function singular(word) {
|
|
32911
|
+
if (word.length <= 3 || !/^[a-z]+$/.test(word) || NOT_PLURAL.has(word) || /(ss|us|is)$/.test(word)) return word;
|
|
32912
|
+
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
|
32913
|
+
if (/(xes|ches|shes|sses)$/.test(word) && !CHE.has(word)) return word.slice(0, -2);
|
|
32914
|
+
return word.endsWith("s") ? word.slice(0, -1) : word;
|
|
32915
|
+
}
|
|
32916
|
+
function stem(word) {
|
|
32917
|
+
const one = singular(word);
|
|
32918
|
+
if (!/^[a-z]+$/.test(one)) return one;
|
|
32919
|
+
if (one.length >= 8 && one.endsWith("er") && !one.endsWith("eer") && one.at(-3) !== one.at(-4) && !NOT_AGENT.has(one)) return one.slice(0, -2);
|
|
32920
|
+
if (one.length > 5 && one.endsWith("ly")) return one.slice(0, -2);
|
|
32921
|
+
if (one.length > 5 && one.endsWith("ed") && !one.endsWith("eed")) return one.slice(0, -2);
|
|
32922
|
+
if (one.length > 6 && one.endsWith("ing")) return one.slice(0, -3);
|
|
32923
|
+
return one;
|
|
32924
|
+
}
|
|
32925
|
+
const terms = (text) => words(text).filter((word) => word.length > 1 && !FUNCTION_WORDS.has(word) && !/^\d+$/.test(word)).map(stem);
|
|
32926
|
+
const slugWords = (url) => {
|
|
32927
|
+
try {
|
|
32928
|
+
return new URL(url).pathname.split(/[/._-]+/).filter(Boolean).join(" ");
|
|
32929
|
+
} catch {
|
|
32930
|
+
return "";
|
|
32931
|
+
}
|
|
32932
|
+
};
|
|
32933
|
+
function keyTerms(page) {
|
|
32934
|
+
const key = /* @__PURE__ */ new Map();
|
|
32935
|
+
const title = page.title.replace(/\s+[|\u2013\u2014-]\s+[^|\u2013\u2014]*$/u, "");
|
|
32936
|
+
for (const term of terms(page.description)) key.set(term, 1);
|
|
32937
|
+
for (const term of terms(`${title} ${page.h1} ${lastSegmentWords(page.url)}`)) key.set(term, NAME_WEIGHT);
|
|
32938
|
+
for (const term of terms(siteName(page.url))) key.delete(term);
|
|
32939
|
+
return key;
|
|
32940
|
+
}
|
|
32941
|
+
function lastSegmentWords(url) {
|
|
32942
|
+
try {
|
|
32943
|
+
return (new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "").split(/[._-]+/).join(" ");
|
|
32944
|
+
} catch {
|
|
32945
|
+
return "";
|
|
32946
|
+
}
|
|
32819
32947
|
}
|
|
32820
|
-
function
|
|
32821
|
-
|
|
32822
|
-
|
|
32823
|
-
|
|
32948
|
+
function siteName(url) {
|
|
32949
|
+
try {
|
|
32950
|
+
return new URL(url).hostname.replace(/^www\./u, "").split(".")[0] ?? "";
|
|
32951
|
+
} catch {
|
|
32952
|
+
return "";
|
|
32953
|
+
}
|
|
32824
32954
|
}
|
|
32825
|
-
function
|
|
32826
|
-
|
|
32955
|
+
function termCounts(page) {
|
|
32956
|
+
const counts = {};
|
|
32957
|
+
const add = (text, weight) => {
|
|
32958
|
+
for (const term of terms(text)) counts[term] = (counts[term] ?? 0) + weight;
|
|
32959
|
+
};
|
|
32960
|
+
add(page.title, 3);
|
|
32961
|
+
add(page.h1, 3);
|
|
32962
|
+
add(slugWords(page.url), 3);
|
|
32963
|
+
add(page.description, 2);
|
|
32964
|
+
for (const heading of page.headings) add(heading, 2);
|
|
32965
|
+
for (const sentence of page.sentences) add(sentence.text, 1);
|
|
32966
|
+
return counts;
|
|
32967
|
+
}
|
|
32968
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
32969
|
+
"script",
|
|
32970
|
+
"style",
|
|
32971
|
+
"noscript",
|
|
32972
|
+
"svg",
|
|
32973
|
+
"template",
|
|
32974
|
+
"iframe",
|
|
32975
|
+
"select"
|
|
32976
|
+
]);
|
|
32977
|
+
const CHROME = /* @__PURE__ */ new Set([
|
|
32978
|
+
"nav",
|
|
32979
|
+
"header",
|
|
32980
|
+
"footer",
|
|
32981
|
+
"aside"
|
|
32982
|
+
]);
|
|
32983
|
+
const MAIN = /* @__PURE__ */ new Set(["main", "article"]);
|
|
32984
|
+
const BLOCK = new Set("p div li h1 h2 h3 h4 h5 h6 blockquote td th dd dt pre section article main aside header footer nav figcaption summary details tr ul ol table body form".split(" "));
|
|
32985
|
+
const VOID = /* @__PURE__ */ new Set([
|
|
32986
|
+
"br",
|
|
32987
|
+
"hr",
|
|
32988
|
+
"img",
|
|
32989
|
+
"input",
|
|
32990
|
+
"meta",
|
|
32991
|
+
"link",
|
|
32992
|
+
"wbr",
|
|
32993
|
+
"source",
|
|
32994
|
+
"col",
|
|
32995
|
+
"embed"
|
|
32996
|
+
]);
|
|
32997
|
+
const ENTITIES = {
|
|
32998
|
+
amp: "&",
|
|
32999
|
+
lt: "<",
|
|
33000
|
+
gt: ">",
|
|
33001
|
+
quot: "\"",
|
|
33002
|
+
apos: "'",
|
|
33003
|
+
nbsp: " ",
|
|
33004
|
+
hellip: "…",
|
|
33005
|
+
mdash: "—",
|
|
33006
|
+
ndash: "–",
|
|
33007
|
+
rsquo: "’",
|
|
33008
|
+
lsquo: "‘",
|
|
33009
|
+
rdquo: "”",
|
|
33010
|
+
ldquo: "“"
|
|
33011
|
+
};
|
|
33012
|
+
const TAG_ATTRS = String.raw`(?:[^>"'=]|=\s*"[^"]*"|=\s*'[^']*'|=(?!\s*["'])|["'])*`;
|
|
33013
|
+
const A_OPEN = "";
|
|
33014
|
+
const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
|
33015
|
+
function decode(text) {
|
|
33016
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => {
|
|
33017
|
+
if (code[0] === "#") {
|
|
33018
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
33019
|
+
const point = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
33020
|
+
return point > 0 && point <= 1114111 ? String.fromCodePoint(point) : "";
|
|
33021
|
+
}
|
|
33022
|
+
return ENTITIES[code.toLowerCase()] ?? match;
|
|
33023
|
+
});
|
|
33024
|
+
}
|
|
33025
|
+
const clean$1 = (value) => value.split(A_OPEN).join("").replace(/\s+/g, " ").trim();
|
|
33026
|
+
function splitSentences(block) {
|
|
33027
|
+
return block.replace(/\s+/g, " ").split(/(?<=[.!?…]["”’)]?)\s+(?=["“‘(]?[A-Z0-9])|(?<=[。!?])/).map((sentence) => sentence.trim()).filter((sentence) => sentence.length <= 500);
|
|
33028
|
+
}
|
|
33029
|
+
function pageSentences(html) {
|
|
33030
|
+
const sentences = [];
|
|
33031
|
+
const hasMain = /<(main|article)[\s>]|role=["']main["']/i.test(html);
|
|
33032
|
+
const stack = [];
|
|
33033
|
+
let skip = 0;
|
|
33034
|
+
let chrome = 0;
|
|
33035
|
+
let mainDepth = 0;
|
|
33036
|
+
let main = hasMain ? 0 : 1;
|
|
33037
|
+
let section = "";
|
|
33038
|
+
let buffer = "";
|
|
33039
|
+
let inAnchor = false;
|
|
33040
|
+
const flushRaw = (tag) => {
|
|
33041
|
+
const raw = buffer;
|
|
33042
|
+
const text = clean$1(raw);
|
|
33043
|
+
buffer = "";
|
|
33044
|
+
if (!text || sentences.length >= 250) return;
|
|
33045
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
33046
|
+
if (tag !== "h1") section = text;
|
|
33047
|
+
return;
|
|
33048
|
+
}
|
|
33049
|
+
for (const part of tag === "li" ? [raw] : splitSentences(raw)) {
|
|
33050
|
+
const sentence = clean$1(part);
|
|
33051
|
+
if (sentence.length >= 25 && sentence.split(" ").length >= 4 || UNSPACED.test(sentence) && sentence.length >= 10) {
|
|
33052
|
+
if (sentences.length >= 250) return;
|
|
33053
|
+
sentences.push({
|
|
33054
|
+
text: sentence,
|
|
33055
|
+
section,
|
|
33056
|
+
hasLink: part.includes(A_OPEN)
|
|
33057
|
+
});
|
|
33058
|
+
}
|
|
33059
|
+
}
|
|
33060
|
+
};
|
|
33061
|
+
const closeTo = (tag) => {
|
|
33062
|
+
if (!stack.some((entry) => entry.tag === tag)) return;
|
|
33063
|
+
while (stack.length) {
|
|
33064
|
+
const entry = stack.pop();
|
|
33065
|
+
if (!entry) break;
|
|
33066
|
+
flushRaw(entry.tag);
|
|
33067
|
+
if (entry.main) {
|
|
33068
|
+
main -= 1;
|
|
33069
|
+
mainDepth -= 1;
|
|
33070
|
+
}
|
|
33071
|
+
if (entry.chrome) chrome -= 1;
|
|
33072
|
+
if (entry.tag === tag) break;
|
|
33073
|
+
}
|
|
33074
|
+
};
|
|
33075
|
+
const tokens = html.matchAll(new RegExp(String.raw`<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([a-zA-Z][\w:-]*)(${TAG_ATTRS})>|([^<]+)`, "g"));
|
|
33076
|
+
for (const match of tokens) {
|
|
33077
|
+
const [whole, close, rawTag, rawAttrs = "", text] = match;
|
|
33078
|
+
if (text !== void 0) {
|
|
33079
|
+
if (!skip && main > 0 && chrome === 0) buffer += decode(text);
|
|
33080
|
+
continue;
|
|
33081
|
+
}
|
|
33082
|
+
if (!rawTag) continue;
|
|
33083
|
+
const tag = rawTag.toLowerCase();
|
|
33084
|
+
if (skip) {
|
|
33085
|
+
if (close && SKIP.has(tag)) skip -= 1;
|
|
33086
|
+
continue;
|
|
33087
|
+
}
|
|
33088
|
+
if (close) {
|
|
33089
|
+
if (tag === "a") inAnchor = false;
|
|
33090
|
+
else if (BLOCK.has(tag)) closeTo(tag);
|
|
33091
|
+
continue;
|
|
33092
|
+
}
|
|
33093
|
+
if (SKIP.has(tag)) {
|
|
33094
|
+
if (!whole.endsWith("/>")) skip += 1;
|
|
33095
|
+
continue;
|
|
33096
|
+
}
|
|
33097
|
+
if (tag === "br") buffer += " ";
|
|
33098
|
+
if (tag === "a" && !inAnchor && /\bhref\s*=/i.test(rawAttrs)) {
|
|
33099
|
+
inAnchor = true;
|
|
33100
|
+
if (main > 0 && chrome === 0) buffer += A_OPEN;
|
|
33101
|
+
}
|
|
33102
|
+
if (VOID.has(tag) || !BLOCK.has(tag)) continue;
|
|
33103
|
+
const parent = stack[stack.length - 1];
|
|
33104
|
+
if (parent) flushRaw(parent.tag);
|
|
33105
|
+
const role = /\brole\s*=\s*["']?main/i.test(rawAttrs);
|
|
33106
|
+
const isMain = MAIN.has(tag) || role;
|
|
33107
|
+
const isChrome = tag === "nav" || tag === "aside" || CHROME.has(tag) && mainDepth === 0;
|
|
33108
|
+
if (isMain) {
|
|
33109
|
+
main += 1;
|
|
33110
|
+
mainDepth += 1;
|
|
33111
|
+
}
|
|
33112
|
+
if (isChrome) chrome += 1;
|
|
33113
|
+
stack.push({
|
|
33114
|
+
tag,
|
|
33115
|
+
main: isMain,
|
|
33116
|
+
chrome: isChrome
|
|
33117
|
+
});
|
|
33118
|
+
}
|
|
33119
|
+
while (stack.length) {
|
|
33120
|
+
const last = stack[stack.length - 1];
|
|
33121
|
+
if (!last) break;
|
|
33122
|
+
closeTo(last.tag);
|
|
33123
|
+
}
|
|
33124
|
+
flushRaw("");
|
|
33125
|
+
return sentences;
|
|
33126
|
+
}
|
|
33127
|
+
var LinkCorpus = class {
|
|
33128
|
+
df = /* @__PURE__ */ new Map();
|
|
33129
|
+
size;
|
|
33130
|
+
constructor(pages) {
|
|
33131
|
+
this.size = pages.length;
|
|
33132
|
+
for (const counts of pages) for (const term in counts) this.df.set(term, (this.df.get(term) ?? 0) + 1);
|
|
33133
|
+
}
|
|
33134
|
+
idf(term) {
|
|
33135
|
+
return Math.log((this.size + 1) / ((this.df.get(term) ?? 1) + 1)) + 1;
|
|
33136
|
+
}
|
|
33137
|
+
vector(counts) {
|
|
33138
|
+
let norm = 0;
|
|
33139
|
+
const shared = [];
|
|
33140
|
+
for (const term in counts) {
|
|
33141
|
+
const weight = (1 + Math.log(counts[term] ?? 1)) * this.idf(term);
|
|
33142
|
+
norm += weight * weight;
|
|
33143
|
+
if ((this.df.get(term) ?? 0) >= 2) shared.push([term, weight]);
|
|
33144
|
+
}
|
|
33145
|
+
norm = Math.sqrt(norm) || 1;
|
|
33146
|
+
shared.sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
|
|
33147
|
+
return new Map(shared.slice(0, MAX_TERMS).map(([term, weight]) => [term, weight / norm]));
|
|
33148
|
+
}
|
|
33149
|
+
similarity(left, right) {
|
|
33150
|
+
let sum = 0;
|
|
33151
|
+
for (const [term, weight] of left) sum += weight * (right.get(term) ?? 0);
|
|
33152
|
+
return sum;
|
|
33153
|
+
}
|
|
33154
|
+
sentenceScore(sentence, key) {
|
|
33155
|
+
if (!key.size) return 0;
|
|
33156
|
+
const seen = new Set(terms(sentence));
|
|
33157
|
+
let hit = 0;
|
|
33158
|
+
let total = 0;
|
|
33159
|
+
for (const [term, place] of key) {
|
|
33160
|
+
const weight = this.idf(term) * place;
|
|
33161
|
+
total += weight;
|
|
33162
|
+
if (seen.has(term)) hit += weight;
|
|
33163
|
+
}
|
|
33164
|
+
return total ? hit / total : 0;
|
|
33165
|
+
}
|
|
33166
|
+
};
|
|
33167
|
+
function anchorCandidates(sentence, key, corpus, limit) {
|
|
33168
|
+
const scored = /* @__PURE__ */ new Map();
|
|
33169
|
+
const parts = sentence.split(/\s+/).map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean);
|
|
33170
|
+
for (let size = 2; size <= 4; size += 1) for (let start = 0; start + size <= parts.length; start += 1) {
|
|
33171
|
+
const gram = parts.slice(start, start + size);
|
|
33172
|
+
const first = (gram[0] ?? "").toLowerCase();
|
|
33173
|
+
const last = (gram[size - 1] ?? "").toLowerCase();
|
|
33174
|
+
if (FUNCTION_WORDS.has(first) || FUNCTION_WORDS.has(last) || /^\d+$/.test(first) || /^\d+$/.test(last)) continue;
|
|
33175
|
+
const stems = terms(gram.join(" "));
|
|
33176
|
+
if (stems.length < 2) continue;
|
|
33177
|
+
const hits = stems.filter((term) => key.has(term)).length;
|
|
33178
|
+
const named = stems.reduce((sum, term) => sum + (key.get(term) ?? 0) * corpus.idf(term), 0);
|
|
33179
|
+
if (!stems.some((term) => key.get(term) === NAME_WEIGHT)) continue;
|
|
33180
|
+
if (Number(key.has(terms(first)[0] ?? "")) + Number(key.has(terms(last)[0] ?? "")) < 2) continue;
|
|
33181
|
+
const text = gram.join(" ");
|
|
33182
|
+
const lower = text.toLowerCase();
|
|
33183
|
+
if (GENERIC_ANCHORS.test(lower)) continue;
|
|
33184
|
+
const rarity = stems.reduce((sum, term) => sum + corpus.idf(term), 0);
|
|
33185
|
+
const score = named * (1 + hits / key.size) + rarity * .05 - (size === 4 ? .2 : 0);
|
|
33186
|
+
const existing = scored.get(lower);
|
|
33187
|
+
if (!existing || existing.score < score) scored.set(lower, {
|
|
33188
|
+
text,
|
|
33189
|
+
score
|
|
33190
|
+
});
|
|
33191
|
+
}
|
|
33192
|
+
return [...scored.values()].sort((left, right) => right.score - left.score || (left.text < right.text ? -1 : left.text > right.text ? 1 : 0)).slice(0, limit).map((item) => item.text);
|
|
33193
|
+
}
|
|
33194
|
+
function linkPlacement(source, target, corpus) {
|
|
33195
|
+
const key = keyTerms(target);
|
|
33196
|
+
const contentSimilarity = corpus.similarity(corpus.vector(termCounts(source)), corpus.vector(termCounts(target)));
|
|
33197
|
+
const seen = /* @__PURE__ */ new Set();
|
|
33198
|
+
const sentences = source.sentences.filter((sentence) => {
|
|
33199
|
+
if (seen.has(sentence.text)) return false;
|
|
33200
|
+
seen.add(sentence.text);
|
|
33201
|
+
return !sentence.hasLink && sentence.text.split(/\s+/).length >= MIN_SENTENCE_WORDS;
|
|
33202
|
+
}).map((sentence, index) => ({
|
|
33203
|
+
sentence,
|
|
33204
|
+
index,
|
|
33205
|
+
match: corpus.sentenceScore(sentence.text, key)
|
|
33206
|
+
})).filter((item) => item.match > 0).sort((left, right) => right.match - left.match || left.index - right.index).slice(0, PLACEMENT_SENTENCES).map(({ sentence, match }) => ({
|
|
33207
|
+
text: sentence.text,
|
|
33208
|
+
section: sentence.section,
|
|
33209
|
+
match: Math.round(match * 100) / 100,
|
|
33210
|
+
anchors: anchorCandidates(sentence.text, key, corpus, ANCHORS_PER_SENTENCE)
|
|
33211
|
+
})).filter((item) => item.anchors.length > 0);
|
|
33212
|
+
return {
|
|
33213
|
+
contentSimilarity: Math.round(contentSimilarity * 100) / 100,
|
|
33214
|
+
sentences
|
|
33215
|
+
};
|
|
33216
|
+
}
|
|
33217
|
+
function placementPage(input) {
|
|
33218
|
+
return {
|
|
33219
|
+
url: input.url,
|
|
33220
|
+
title: input.title ?? "",
|
|
33221
|
+
h1: input.headings.find((heading) => heading.level === 1)?.text ?? "",
|
|
33222
|
+
description: input.metaDescription ?? "",
|
|
33223
|
+
headings: input.headings.filter((heading) => heading.level === 2 || heading.level === 3).slice(0, 8).map((heading) => heading.text),
|
|
33224
|
+
sentences: pageSentences(input.html)
|
|
33225
|
+
};
|
|
33226
|
+
}
|
|
33227
|
+
//#endregion
|
|
33228
|
+
//#region packages/core/dist/analyze/internal-links-report.js
|
|
33229
|
+
const DAY$1 = 864e5;
|
|
33230
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
33231
|
+
function internalLinksReportRange(input, fallbackDays, now) {
|
|
33232
|
+
if (!input.startDate && !input.endDate) return {
|
|
33233
|
+
...finalGscDateRange(fallbackDays, now),
|
|
33234
|
+
days: fallbackDays
|
|
33235
|
+
};
|
|
33236
|
+
const { startDate, endDate } = input;
|
|
33237
|
+
if (!startDate || !endDate || !ISO_DAY.test(startDate) || !ISO_DAY.test(endDate) || startDate > endDate) throw new SeoError("INVALID_INPUT", "Send a start date and an end date together, in order, as YYYY-MM-DD.");
|
|
33238
|
+
return {
|
|
33239
|
+
startDate,
|
|
33240
|
+
endDate,
|
|
33241
|
+
days: Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / DAY$1) + 1
|
|
33242
|
+
};
|
|
32827
33243
|
}
|
|
32828
33244
|
function completeInternalLinksSelection(analysis, runtime) {
|
|
32829
33245
|
return {
|
|
@@ -32881,9 +33297,8 @@ async function verifyInternalLinkTarget(input) {
|
|
|
32881
33297
|
let fetched;
|
|
32882
33298
|
try {
|
|
32883
33299
|
fetched = await input.dependencies.fetch(input.targetUrl, {
|
|
32884
|
-
js:
|
|
32885
|
-
refresh: input.refresh
|
|
32886
|
-
rate: input.rate
|
|
33300
|
+
js: "auto",
|
|
33301
|
+
refresh: input.refresh
|
|
32887
33302
|
});
|
|
32888
33303
|
} catch (error) {
|
|
32889
33304
|
return {
|
|
@@ -32953,7 +33368,12 @@ async function verifyInternalLinkTarget(input) {
|
|
|
32953
33368
|
verification: fatalSignals.length ? "technical-issue" : "verified",
|
|
32954
33369
|
technicalSignals: signals,
|
|
32955
33370
|
fetchDiagnostics: fetched.diagnostics,
|
|
32956
|
-
warnings
|
|
33371
|
+
warnings,
|
|
33372
|
+
text: placementPage({
|
|
33373
|
+
...page,
|
|
33374
|
+
url: page.finalUrl,
|
|
33375
|
+
html: fetched.html
|
|
33376
|
+
})
|
|
32957
33377
|
};
|
|
32958
33378
|
}
|
|
32959
33379
|
function linksToAliases(page, aliases) {
|
|
@@ -32987,9 +33407,8 @@ async function verifyInternalLinkCandidate(input) {
|
|
|
32987
33407
|
let fetched;
|
|
32988
33408
|
try {
|
|
32989
33409
|
fetched = await input.dependencies.fetch(input.candidate.sourceUrl, {
|
|
32990
|
-
js:
|
|
32991
|
-
refresh: input.refresh
|
|
32992
|
-
rate: input.rate
|
|
33410
|
+
js: "auto",
|
|
33411
|
+
refresh: input.refresh
|
|
32993
33412
|
});
|
|
32994
33413
|
} catch (error) {
|
|
32995
33414
|
return {
|
|
@@ -33079,12 +33498,21 @@ async function verifyInternalLinkCandidate(input) {
|
|
|
33079
33498
|
confidence
|
|
33080
33499
|
}
|
|
33081
33500
|
},
|
|
33501
|
+
text: placementPage({
|
|
33502
|
+
...page,
|
|
33503
|
+
url: page.finalUrl,
|
|
33504
|
+
html: fetched.html
|
|
33505
|
+
}),
|
|
33082
33506
|
warnings
|
|
33083
33507
|
};
|
|
33084
33508
|
}
|
|
33085
33509
|
//#endregion
|
|
33086
33510
|
//#region packages/core/dist/analyze/internal-links.js
|
|
33087
33511
|
const MAX_GSC_ROWS = 1e5;
|
|
33512
|
+
const DEFAULT_DAYS$1 = 90;
|
|
33513
|
+
const RESULT_LIMIT = 20;
|
|
33514
|
+
const CHECK_LIMIT = 40;
|
|
33515
|
+
const MIN_IMPRESSIONS = 1;
|
|
33088
33516
|
const defaultDependencies$2 = {
|
|
33089
33517
|
searchAnalytics: querySearchAnalytics,
|
|
33090
33518
|
fetch: fetchPage,
|
|
@@ -33095,43 +33523,17 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33095
33523
|
const targetPage = new URL(assertUrlMatchesGscProperty(input.site, input.targetUrl));
|
|
33096
33524
|
targetPage.hash = "";
|
|
33097
33525
|
const targetUrl = targetPage.toString();
|
|
33098
|
-
const
|
|
33099
|
-
|
|
33100
|
-
|
|
33101
|
-
minimum: 1,
|
|
33102
|
-
maximum: 548,
|
|
33103
|
-
label: "days"
|
|
33104
|
-
});
|
|
33105
|
-
const limit = internalLinksIntegerOption({
|
|
33106
|
-
value: input.limit,
|
|
33107
|
-
fallback: 20,
|
|
33108
|
-
minimum: 1,
|
|
33109
|
-
maximum: 100,
|
|
33110
|
-
label: "limit"
|
|
33111
|
-
});
|
|
33112
|
-
const checkLimit = internalLinksIntegerOption({
|
|
33113
|
-
value: input.checkLimit,
|
|
33114
|
-
fallback: Math.max(limit, 40),
|
|
33115
|
-
minimum: 1,
|
|
33116
|
-
maximum: 200,
|
|
33117
|
-
label: "checkLimit"
|
|
33118
|
-
});
|
|
33119
|
-
const minImpressions = internalLinksNumberOption({
|
|
33120
|
-
value: input.minImpressions,
|
|
33121
|
-
fallback: 1,
|
|
33122
|
-
minimum: 0,
|
|
33123
|
-
maximum: 1e9,
|
|
33124
|
-
label: "minImpressions"
|
|
33125
|
-
});
|
|
33526
|
+
const limit = RESULT_LIMIT;
|
|
33527
|
+
const checkLimit = CHECK_LIMIT;
|
|
33528
|
+
const minImpressions = MIN_IMPRESSIONS;
|
|
33126
33529
|
const now = dependencies.now();
|
|
33127
|
-
const range = internalLinksReportRange(
|
|
33530
|
+
const { days, ...range } = internalLinksReportRange(input, DEFAULT_DAYS$1, now);
|
|
33531
|
+
const brandTerms = deriveBrandTerms({ siteUrl: input.site });
|
|
33128
33532
|
const ledger = new SessionLedger();
|
|
33129
33533
|
const target = await verifyInternalLinkTarget({
|
|
33130
33534
|
site: input.site,
|
|
33131
33535
|
targetUrl,
|
|
33132
|
-
js: input.js,
|
|
33133
33536
|
refresh: input.refresh,
|
|
33134
|
-
rate: input.rate,
|
|
33135
33537
|
dependencies
|
|
33136
33538
|
});
|
|
33137
33539
|
const targetRequests = await Promise.all(target.aliases.map(async (alias) => {
|
|
@@ -33163,8 +33565,7 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33163
33565
|
site: input.site,
|
|
33164
33566
|
targetAliases: target.aliases,
|
|
33165
33567
|
minImpressions,
|
|
33166
|
-
brandTerms
|
|
33167
|
-
includeBrand: input.includeBrand
|
|
33568
|
+
brandTerms
|
|
33168
33569
|
});
|
|
33169
33570
|
const queryCandidates = target.verification === "verified" && preliminary.selection.targetEligibleQueries > 0;
|
|
33170
33571
|
const sourceResult = queryCandidates ? await dependencies.searchAnalytics(input.site, {
|
|
@@ -33185,10 +33586,9 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33185
33586
|
site: input.site,
|
|
33186
33587
|
targetAliases: target.aliases,
|
|
33187
33588
|
minImpressions,
|
|
33188
|
-
brandTerms
|
|
33189
|
-
includeBrand: input.includeBrand
|
|
33589
|
+
brandTerms
|
|
33190
33590
|
});
|
|
33191
|
-
const
|
|
33591
|
+
const found = [];
|
|
33192
33592
|
const warnings = [...target.warnings];
|
|
33193
33593
|
let attemptedSources = 0;
|
|
33194
33594
|
let checkedSources = 0;
|
|
@@ -33197,25 +33597,27 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33197
33597
|
let selfAliasExclusions = 0;
|
|
33198
33598
|
let failedChecks = 0;
|
|
33199
33599
|
for (const candidate of analysis.candidates) {
|
|
33200
|
-
if (attemptedSources >= checkLimit ||
|
|
33600
|
+
if (attemptedSources >= checkLimit || found.length >= limit) break;
|
|
33201
33601
|
attemptedSources += 1;
|
|
33202
33602
|
const verified = await verifyInternalLinkCandidate({
|
|
33203
33603
|
site: input.site,
|
|
33204
33604
|
candidate,
|
|
33205
33605
|
target,
|
|
33206
|
-
js: input.js,
|
|
33207
33606
|
refresh: input.refresh,
|
|
33208
|
-
rate: input.rate,
|
|
33209
33607
|
dependencies
|
|
33210
33608
|
});
|
|
33211
33609
|
warnings.push(...verified.warnings);
|
|
33212
|
-
if (verified.item)
|
|
33610
|
+
if (verified.item) found.push({
|
|
33611
|
+
item: verified.item,
|
|
33612
|
+
text: verified.text
|
|
33613
|
+
});
|
|
33213
33614
|
if (verified.exclusion === "existing-link") existingLinkExclusions += 1;
|
|
33214
33615
|
if (verified.exclusion === "technical") technicalExclusions += 1;
|
|
33215
33616
|
if (verified.exclusion === "self-alias") selfAliasExclusions += 1;
|
|
33216
33617
|
if (verified.exclusion === "failed") failedChecks += 1;
|
|
33217
33618
|
else checkedSources += 1;
|
|
33218
33619
|
}
|
|
33620
|
+
const items = placeLinks(found, target.text);
|
|
33219
33621
|
const uncheckedCandidates = Math.max(0, analysis.candidates.length - attemptedSources);
|
|
33220
33622
|
const targetTruncated = targetRequests.some(({ result }) => result.rowsFetched >= MAX_GSC_ROWS);
|
|
33221
33623
|
const sourceTruncated = sourceResult.rowsFetched >= MAX_GSC_ROWS;
|
|
@@ -33277,13 +33679,14 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33277
33679
|
},
|
|
33278
33680
|
methodology: {
|
|
33279
33681
|
id: "gsc_internal_link_candidates",
|
|
33280
|
-
version:
|
|
33682
|
+
version: 4,
|
|
33281
33683
|
lexicalTargetLimit: 50,
|
|
33282
33684
|
matchedQueryEvidenceLimit: 10,
|
|
33283
33685
|
observedLinkEvidenceLimit: 20,
|
|
33284
33686
|
matching: "pairwise_exact_then_precision_lexical",
|
|
33285
33687
|
ranking: "exact_matches_then_matched_query_impressions_then_relevance",
|
|
33286
|
-
contextualPlacementVerified: true
|
|
33688
|
+
contextualPlacementVerified: true,
|
|
33689
|
+
placement: "tfidf_sentence_match_then_phrase_anchors"
|
|
33287
33690
|
},
|
|
33288
33691
|
filters: {
|
|
33289
33692
|
minImpressions,
|
|
@@ -33315,18 +33718,20 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33315
33718
|
failedChecks,
|
|
33316
33719
|
uncheckedCandidates,
|
|
33317
33720
|
matchedQueryImpressions: items.reduce((sum, item) => sum + item.matchedQueryImpressions, 0),
|
|
33318
|
-
brandFiltering:
|
|
33721
|
+
brandFiltering: "excluded",
|
|
33319
33722
|
verdict: summaryVerdict
|
|
33320
33723
|
},
|
|
33321
33724
|
items,
|
|
33322
33725
|
warnings: structuredWarnings,
|
|
33323
33726
|
caveats: [
|
|
33324
|
-
`Date window: ${range.startDate} to ${range.endDate} (${days} days), using final GSC data where available
|
|
33727
|
+
`Date window: ${range.startDate} to ${range.endDate} (${days} days), using final GSC data where available.${input.startDate ? "" : ` Without dates the window is the last ${DEFAULT_DAYS$1} days.`}`,
|
|
33728
|
+
`Limits: up to ${checkLimit} source pages are checked live and up to ${limit} are returned. Queries need at least ${minImpressions} impression.`,
|
|
33325
33729
|
`Selection: ${analysis.selection.targetEligibleQueries} eligible target queries, ${analysis.selection.candidateUrls} matched source pages, ${attemptedSources} attempted, ${checkedSources} successfully checked, ${items.length} returned.`,
|
|
33326
|
-
`
|
|
33730
|
+
`Brand queries were left out. Brand terms found from the domain: ${brandTerms.join(", ") || "none"}.`,
|
|
33327
33731
|
"GSC query rows omit anonymized queries and lower-volume rows. A 100,000-row cap is reported separately when reached.",
|
|
33328
33732
|
"Exact query overlap is medium-confidence affinity evidence; lexical matches are low-confidence review evidence. Neither predicts traffic lift or link equity.",
|
|
33329
33733
|
"The fetch checks HTML link placement, but it cannot prove editorial relevance. Review intent and reader usefulness before adding a link.",
|
|
33734
|
+
"Placement sentences are the source sentences without a link that share the most rare words with the target title, h1, description and URL. Anchors are phrases from those sentences. Content similarity compares the words of the checked pages only. All three are text heuristics, not ranking evidence.",
|
|
33330
33735
|
uncheckedCandidates ? `${uncheckedCandidates} matched source page${uncheckedCandidates === 1 ? " was" : "s were"} not checked because the output or check limit was reached.` : "",
|
|
33331
33736
|
failedChecks ? `${failedChecks} source verification attempt${failedChecks === 1 ? "" : "s"} failed. Failed fetches or extraction attempts are not counted as checked pages and do not support a missing-link conclusion.` : ""
|
|
33332
33737
|
].filter(Boolean),
|
|
@@ -33334,6 +33739,29 @@ async function internalLinksReport(input, dependencies = defaultDependencies$2)
|
|
|
33334
33739
|
ledgerSummary: ledger.summary()
|
|
33335
33740
|
};
|
|
33336
33741
|
}
|
|
33742
|
+
function placeLinks(found, target) {
|
|
33743
|
+
const corpus = new LinkCorpus([target, ...found.map((entry) => entry.text)].filter((page) => Boolean(page)).map(termCounts));
|
|
33744
|
+
const placed = found.map(({ item, text }) => {
|
|
33745
|
+
const placement = target && text ? linkPlacement(text, target, corpus) : {
|
|
33746
|
+
contentSimilarity: 0,
|
|
33747
|
+
sentences: []
|
|
33748
|
+
};
|
|
33749
|
+
const best = placement.sentences[0];
|
|
33750
|
+
const recommendation = best ? {
|
|
33751
|
+
...item.recommendation,
|
|
33752
|
+
action: `${item.recommendation.action} It fits in: "${best.text}" Anchor text could be ${best.anchors.map((anchor) => `"${anchor}"`).join(" or ")}.`
|
|
33753
|
+
} : {
|
|
33754
|
+
...item.recommendation,
|
|
33755
|
+
action: `${item.recommendation.action} No sentence on this page names the target, so a link here needs new text.`
|
|
33756
|
+
};
|
|
33757
|
+
return {
|
|
33758
|
+
...item,
|
|
33759
|
+
placement,
|
|
33760
|
+
recommendation
|
|
33761
|
+
};
|
|
33762
|
+
});
|
|
33763
|
+
return [...placed.filter((item) => item.placement.sentences.length > 0), ...placed.filter((item) => item.placement.sentences.length === 0)];
|
|
33764
|
+
}
|
|
33337
33765
|
//#endregion
|
|
33338
33766
|
//#region packages/core/dist/analyze/url-templates.js
|
|
33339
33767
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -36920,6 +37348,61 @@ async function updatePostmortemWorkflow(input, dependencies = {}) {
|
|
|
36920
37348
|
}
|
|
36921
37349
|
});
|
|
36922
37350
|
}
|
|
37351
|
+
//#endregion
|
|
37352
|
+
//#region packages/core/dist/clicky/api.js
|
|
37353
|
+
const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
|
|
37354
|
+
const MAX_BYTES$1 = 5e6;
|
|
37355
|
+
var ClickyError = class extends Error {
|
|
37356
|
+
code;
|
|
37357
|
+
constructor(code, message) {
|
|
37358
|
+
super(message);
|
|
37359
|
+
this.code = code;
|
|
37360
|
+
}
|
|
37361
|
+
};
|
|
37362
|
+
function isClickySiteId(value) {
|
|
37363
|
+
return /^\d{1,30}$/u.test(value.trim());
|
|
37364
|
+
}
|
|
37365
|
+
function isClickySitekey(value) {
|
|
37366
|
+
return /^[A-Za-z0-9]{12,64}$/u.test(value.trim());
|
|
37367
|
+
}
|
|
37368
|
+
async function clickyStats(site, types, options = {}) {
|
|
37369
|
+
const url = new URL(ENDPOINT$1);
|
|
37370
|
+
url.searchParams.set("site_id", site.siteId);
|
|
37371
|
+
url.searchParams.set("sitekey", site.sitekey);
|
|
37372
|
+
url.searchParams.set("type", types.join(","));
|
|
37373
|
+
url.searchParams.set("output", "json");
|
|
37374
|
+
url.searchParams.set("limit", String(options.limit ?? 1e3));
|
|
37375
|
+
url.searchParams.set("app", "seo");
|
|
37376
|
+
if (options.date) url.searchParams.set("date", options.date);
|
|
37377
|
+
const response = await fetch(url).catch(() => {
|
|
37378
|
+
throw new ClickyError("remote", "Could not reach Clicky. Try again.");
|
|
37379
|
+
});
|
|
37380
|
+
if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
|
|
37381
|
+
const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$1 });
|
|
37382
|
+
if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
|
|
37383
|
+
const failed = groups.find((group) => typeof group?.error === "string");
|
|
37384
|
+
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)}`);
|
|
37385
|
+
const out = {};
|
|
37386
|
+
for (const type of types) out[type] = [];
|
|
37387
|
+
for (const group of groups) {
|
|
37388
|
+
if (!group?.type || !(group.type in out)) continue;
|
|
37389
|
+
for (const date of group.dates ?? []) for (const item of date.items ?? []) if (item && typeof item === "object") out[group.type]?.push(item);
|
|
37390
|
+
}
|
|
37391
|
+
return out;
|
|
37392
|
+
}
|
|
37393
|
+
async function clickyDomain(site) {
|
|
37394
|
+
const { pages } = await clickyStats(site, ["pages"], {
|
|
37395
|
+
date: "last-30-days",
|
|
37396
|
+
limit: 1
|
|
37397
|
+
});
|
|
37398
|
+
const url = pages?.find((item) => item.url)?.url;
|
|
37399
|
+
if (!url) return null;
|
|
37400
|
+
try {
|
|
37401
|
+
return new URL(url).hostname.replace(/^www\./u, "");
|
|
37402
|
+
} catch {
|
|
37403
|
+
return null;
|
|
37404
|
+
}
|
|
37405
|
+
}
|
|
36923
37406
|
function rowsText(input) {
|
|
36924
37407
|
const total = input.rows.length;
|
|
36925
37408
|
if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
|
|
@@ -37104,7 +37587,8 @@ async function runAnalyticsRows(input, google, options) {
|
|
|
37104
37587
|
//#endregion
|
|
37105
37588
|
//#region packages/core/dist/commands/bing.js
|
|
37106
37589
|
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."];
|
|
37107
|
-
const
|
|
37590
|
+
const CLI_LINK = "If you have a shell, link the CLI to the same account once: run `npx -y seo login`, show the person the link it prints, and after they approve it run `npx -y seo login` again. `npx -y seo` commands then use the same Google accounts, Bing and keys.";
|
|
37591
|
+
const LOCAL_BING = `Bing blocks the hosted server, so Bing data comes from the CLI on a machine. ${CLI_LINK} Connect Bing on the account page if it is not connected, then run \`npx -y seo report --site <site> --section bing --json\`.`;
|
|
37108
37592
|
const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing; the CLI reads them with the same account: `npx -y seo login` once, then `npx -y seo report --site <site> --section bing --json`.";
|
|
37109
37593
|
const BING_HOME = "https://www.bing.com/webmasters";
|
|
37110
37594
|
const noBingData = (site) => `Bing has no data for ${site} yet. Newly added or newly verified sites take a few days to show data in Bing Webmaster Tools.`;
|
|
@@ -37322,6 +37806,161 @@ async function runBingRows(input, deps) {
|
|
|
37322
37806
|
};
|
|
37323
37807
|
}
|
|
37324
37808
|
//#endregion
|
|
37809
|
+
//#region packages/core/dist/commands/clicky.js
|
|
37810
|
+
const CLICKY_KEY_PREFIX = "x-clicky-";
|
|
37811
|
+
function clickySites(keys) {
|
|
37812
|
+
const sites = [];
|
|
37813
|
+
for (const [name, value] of keys) {
|
|
37814
|
+
if (!name.startsWith("x-clicky-")) continue;
|
|
37815
|
+
const siteId = name.slice(9);
|
|
37816
|
+
if (!isClickySiteId(siteId)) continue;
|
|
37817
|
+
let sitekey = value.trim();
|
|
37818
|
+
let domain = null;
|
|
37819
|
+
let account = false;
|
|
37820
|
+
if (sitekey.startsWith("{")) try {
|
|
37821
|
+
const parsed = JSON.parse(sitekey);
|
|
37822
|
+
sitekey = typeof parsed.sitekey === "string" ? parsed.sitekey : "";
|
|
37823
|
+
domain = typeof parsed.domain === "string" ? parsed.domain : null;
|
|
37824
|
+
account = parsed.account === true;
|
|
37825
|
+
} catch {
|
|
37826
|
+
continue;
|
|
37827
|
+
}
|
|
37828
|
+
if (isClickySitekey(sitekey)) sites.push({
|
|
37829
|
+
siteId,
|
|
37830
|
+
sitekey,
|
|
37831
|
+
domain,
|
|
37832
|
+
account
|
|
37833
|
+
});
|
|
37834
|
+
}
|
|
37835
|
+
return sites.sort((left, right) => left.siteId < right.siteId ? -1 : left.siteId > right.siteId ? 1 : 0);
|
|
37836
|
+
}
|
|
37837
|
+
const clickyLabel = (site) => site.domain ? `${site.siteId} (${site.domain})` : site.siteId;
|
|
37838
|
+
function hostOf(value) {
|
|
37839
|
+
const text = value.trim().toLowerCase().replace(/^sc-domain:/u, "");
|
|
37840
|
+
try {
|
|
37841
|
+
return new URL(text.includes("://") ? text : `https://${text}`).hostname.replace(/^www\./u, "");
|
|
37842
|
+
} catch {
|
|
37843
|
+
return text;
|
|
37844
|
+
}
|
|
37845
|
+
}
|
|
37846
|
+
async function pickClickySite(saved, target, connect, refresh) {
|
|
37847
|
+
const host = hostOf(target);
|
|
37848
|
+
const match = (site) => site.siteId === target.trim() || site.domain === host;
|
|
37849
|
+
let sites = saved;
|
|
37850
|
+
if (!sites.some(match) && refresh) sites = await refresh() ?? sites;
|
|
37851
|
+
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}`);
|
|
37852
|
+
const found = sites.find(match);
|
|
37853
|
+
if (found) return found;
|
|
37854
|
+
if (!isClickySiteId(target)) {
|
|
37855
|
+
for (const site of sites.filter((item) => !item.domain).slice(0, 10)) site.domain = await clickyDomain(site).catch(() => null);
|
|
37856
|
+
const named = sites.find(match);
|
|
37857
|
+
if (named) return named;
|
|
37858
|
+
}
|
|
37859
|
+
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}`);
|
|
37860
|
+
}
|
|
37861
|
+
const DAY = 864e5;
|
|
37862
|
+
const isoDay = (time) => new Date(time).toISOString().slice(0, 10);
|
|
37863
|
+
function clickyRange(input) {
|
|
37864
|
+
if (!input.startDate && !input.endDate) {
|
|
37865
|
+
const end = Date.now() - DAY;
|
|
37866
|
+
return {
|
|
37867
|
+
startDate: isoDay(end - 27 * DAY),
|
|
37868
|
+
endDate: isoDay(end)
|
|
37869
|
+
};
|
|
37870
|
+
}
|
|
37871
|
+
const start = Date.parse(`${input.startDate}T00:00:00Z`);
|
|
37872
|
+
const end = Date.parse(`${input.endDate}T00:00:00Z`);
|
|
37873
|
+
if (!input.startDate || !input.endDate || !(start <= end)) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
|
|
37874
|
+
if ((end - start) / DAY + 1 > 31) throw new CommandError("INVALID_INPUT", "Clicky reads at most 31 days at a time. Send a shorter range.");
|
|
37875
|
+
return {
|
|
37876
|
+
startDate: input.startDate,
|
|
37877
|
+
endDate: input.endDate
|
|
37878
|
+
};
|
|
37879
|
+
}
|
|
37880
|
+
const count = (item) => {
|
|
37881
|
+
const value = Number(item.value);
|
|
37882
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
37883
|
+
};
|
|
37884
|
+
const totals = (items) => items.map((item) => ({
|
|
37885
|
+
name: item.title ?? "",
|
|
37886
|
+
visits: count(item)
|
|
37887
|
+
})).filter((item) => Boolean(item.name) && item.visits !== null);
|
|
37888
|
+
const line = (items) => {
|
|
37889
|
+
const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
|
|
37890
|
+
const rest = items.slice(10);
|
|
37891
|
+
if (rest.length) top.push(`${rest.length} others ${rest.reduce((sum, item) => sum + item.visits, 0)}`);
|
|
37892
|
+
return top.join("; ");
|
|
37893
|
+
};
|
|
37894
|
+
const LIMIT = 1e3;
|
|
37895
|
+
async function runClickyRows(input, keys, options) {
|
|
37896
|
+
const refresh = options.refresh;
|
|
37897
|
+
const site = await pickClickySite(clickySites(keys), input.site, options.connect, refresh && (async () => {
|
|
37898
|
+
const fresh = await refresh().catch(() => null);
|
|
37899
|
+
return fresh && clickySites(fresh);
|
|
37900
|
+
}));
|
|
37901
|
+
const range = clickyRange(input);
|
|
37902
|
+
const offset = input.offset ?? 0;
|
|
37903
|
+
const stats = await clickyStats(site, [
|
|
37904
|
+
"pages-entrance",
|
|
37905
|
+
"traffic-sources",
|
|
37906
|
+
"searches-engines"
|
|
37907
|
+
], {
|
|
37908
|
+
date: `${range.startDate},${range.endDate}`,
|
|
37909
|
+
limit: LIMIT
|
|
37910
|
+
}).catch((error) => {
|
|
37911
|
+
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);
|
|
37912
|
+
throw error;
|
|
37913
|
+
});
|
|
37914
|
+
const entrances = stats["pages-entrance"] ?? [];
|
|
37915
|
+
const visitsByPage = /* @__PURE__ */ new Map();
|
|
37916
|
+
for (const item of entrances) {
|
|
37917
|
+
const visits = count(item);
|
|
37918
|
+
if (visits === null || !item.url) continue;
|
|
37919
|
+
let page;
|
|
37920
|
+
try {
|
|
37921
|
+
page = new URL(item.url).pathname;
|
|
37922
|
+
} catch {
|
|
37923
|
+
continue;
|
|
37924
|
+
}
|
|
37925
|
+
visitsByPage.set(page, (visitsByPage.get(page) ?? 0) + visits);
|
|
37926
|
+
}
|
|
37927
|
+
const rows = [...visitsByPage].map(([page, visits]) => ({
|
|
37928
|
+
page,
|
|
37929
|
+
visits
|
|
37930
|
+
})).sort((left, right) => right.visits - left.visits || (left.page < right.page ? -1 : left.page > right.page ? 1 : 0));
|
|
37931
|
+
const sources = totals(stats["traffic-sources"] ?? []);
|
|
37932
|
+
const engines = totals(stats["searches-engines"] ?? []);
|
|
37933
|
+
const notes = [
|
|
37934
|
+
"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.",
|
|
37935
|
+
sources.length ? `Traffic sources (visits): ${line(sources)}.` : "Traffic sources: none in this range.",
|
|
37936
|
+
engines.length ? `Search engines (visits): ${line(engines)}.` : "Search engines: none in this range."
|
|
37937
|
+
];
|
|
37938
|
+
if (entrances.length >= LIMIT) notes.push(`Clicky returned its ${LIMIT}-row limit; pages past it are missing, not zero.`);
|
|
37939
|
+
return {
|
|
37940
|
+
text: rowsText({
|
|
37941
|
+
title: `Clicky site ${clickyLabel(site)}: landing pages, ${range.startDate} to ${range.endDate}.`,
|
|
37942
|
+
rows,
|
|
37943
|
+
offset,
|
|
37944
|
+
limit: 50,
|
|
37945
|
+
detail: input.detail ?? "summary",
|
|
37946
|
+
notes,
|
|
37947
|
+
more: options.call
|
|
37948
|
+
}),
|
|
37949
|
+
data: {
|
|
37950
|
+
siteId: site.siteId,
|
|
37951
|
+
domain: site.domain,
|
|
37952
|
+
range,
|
|
37953
|
+
metric: "visits",
|
|
37954
|
+
sources,
|
|
37955
|
+
searchEngines: engines,
|
|
37956
|
+
capped: entrances.length >= LIMIT,
|
|
37957
|
+
total: rows.length,
|
|
37958
|
+
offset,
|
|
37959
|
+
rows: rows.slice(offset, offset + 50)
|
|
37960
|
+
}
|
|
37961
|
+
};
|
|
37962
|
+
}
|
|
37963
|
+
//#endregion
|
|
37325
37964
|
//#region packages/core/dist/commands/domain-rating.js
|
|
37326
37965
|
const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
|
|
37327
37966
|
const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
|
|
@@ -37547,7 +38186,7 @@ function nextFor(section, call) {
|
|
|
37547
38186
|
"second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
|
|
37548
38187
|
"content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
|
|
37549
38188
|
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.`,
|
|
38189
|
+
"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
38190
|
bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
|
|
37552
38191
|
}[section];
|
|
37553
38192
|
}
|
|
@@ -38235,11 +38874,13 @@ async function runPage(input, deps) {
|
|
|
38235
38874
|
}]
|
|
38236
38875
|
}]
|
|
38237
38876
|
});
|
|
38238
|
-
const
|
|
38877
|
+
const full = input.detail === "full";
|
|
38878
|
+
const [[facts], indexed, now, then, markdown] = await Promise.all([
|
|
38239
38879
|
deps.checkPages([input.url]),
|
|
38240
38880
|
deps.inspect(input.site, input.url),
|
|
38241
38881
|
query(range),
|
|
38242
|
-
query(before)
|
|
38882
|
+
query(before),
|
|
38883
|
+
full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
|
|
38243
38884
|
]);
|
|
38244
38885
|
const old = new Map(then.rows.map((row) => [row.keys[0], row]));
|
|
38245
38886
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -38274,23 +38915,32 @@ async function runPage(input, deps) {
|
|
|
38274
38915
|
const sum = (list, key) => list.reduce((total, row) => total + row[key], 0);
|
|
38275
38916
|
const offset = input.offset ?? 0;
|
|
38276
38917
|
return {
|
|
38277
|
-
text:
|
|
38278
|
-
|
|
38279
|
-
|
|
38280
|
-
|
|
38281
|
-
|
|
38282
|
-
|
|
38283
|
-
|
|
38284
|
-
|
|
38285
|
-
|
|
38286
|
-
|
|
38287
|
-
|
|
38288
|
-
|
|
38289
|
-
|
|
38290
|
-
|
|
38291
|
-
|
|
38292
|
-
|
|
38293
|
-
|
|
38918
|
+
text: [
|
|
38919
|
+
rowsText({
|
|
38920
|
+
title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
38921
|
+
notes: [
|
|
38922
|
+
`Live page (fetched now, no redirects followed):\n${facts ? pageFactsTable([facts], null) : "Not fetched."}`,
|
|
38923
|
+
indexed ? `Google index status:\n${table([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
|
|
38924
|
+
`Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
|
|
38925
|
+
deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
|
|
38926
|
+
url: input.url,
|
|
38927
|
+
detail: "full"
|
|
38928
|
+
})}.` : `Main content as Markdown: only in the CLI and the local MCP server, with \`npx -y seo page --url ${input.url} --detail full\`.`
|
|
38929
|
+
],
|
|
38930
|
+
rows,
|
|
38931
|
+
offset,
|
|
38932
|
+
limit: 50,
|
|
38933
|
+
detail: input.detail ?? "summary",
|
|
38934
|
+
more: (next) => deps.call("page", {
|
|
38935
|
+
url: input.url,
|
|
38936
|
+
detail: "rows",
|
|
38937
|
+
offset: next
|
|
38938
|
+
})
|
|
38939
|
+
}),
|
|
38940
|
+
...full ? [markdownText(markdown, input.url)] : [],
|
|
38941
|
+
`Next: ${deps.call("research")} serp with the top query to see who else ranks.`,
|
|
38942
|
+
FACTS_ONLY
|
|
38943
|
+
].join("\n\n"),
|
|
38294
38944
|
data: {
|
|
38295
38945
|
url: input.url,
|
|
38296
38946
|
range,
|
|
@@ -38298,10 +38948,17 @@ async function runPage(input, deps) {
|
|
|
38298
38948
|
page: facts ?? null,
|
|
38299
38949
|
index: indexed,
|
|
38300
38950
|
totalQueries: rows.length,
|
|
38301
|
-
queries: rows.slice(offset, offset + 50)
|
|
38951
|
+
queries: rows.slice(offset, offset + 50),
|
|
38952
|
+
...full ? { markdown } : {}
|
|
38302
38953
|
}
|
|
38303
38954
|
};
|
|
38304
38955
|
}
|
|
38956
|
+
function markdownText(markdown, url) {
|
|
38957
|
+
if (!markdown) return `Main content as Markdown: not on this server; it runs in the CLI and the local MCP server. ${CLI_LINK} Then run \`npx -y seo page --url ${url} --detail full\`.`;
|
|
38958
|
+
if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
|
|
38959
|
+
if (!markdown.markdown) return "Main content as Markdown: the page has no main content to read.";
|
|
38960
|
+
return `Main content as Markdown (live page${markdown.truncated ? `, cut to ${markdown.markdown.length.toLocaleString("en-US")} of ${markdown.characters.toLocaleString("en-US")} characters` : ""}):\n\n${markdown.markdown}`;
|
|
38961
|
+
}
|
|
38305
38962
|
async function runCompare(input, deps) {
|
|
38306
38963
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) throw new CommandError("INVALID_INPUT", "The date must be YYYY-MM-DD.");
|
|
38307
38964
|
const last = latestFinalGscDate(deps.now?.() ?? /* @__PURE__ */ new Date());
|
|
@@ -38337,7 +38994,39 @@ const LOST_SHARE = .2;
|
|
|
38337
38994
|
const MIN_LOST = 3;
|
|
38338
38995
|
const CHECKED = 20;
|
|
38339
38996
|
const INSPECTED = 10;
|
|
38340
|
-
const
|
|
38997
|
+
const SITE_WIDE_MIN = 5;
|
|
38998
|
+
function indexSummary(inspected, home) {
|
|
38999
|
+
const known = inspected.filter((item) => item?.verdict != null);
|
|
39000
|
+
const indexed = known.filter((item) => item.verdict === "PASS").length;
|
|
39001
|
+
const coverage = /* @__PURE__ */ new Map();
|
|
39002
|
+
for (const item of known) {
|
|
39003
|
+
if (item.verdict === "PASS") continue;
|
|
39004
|
+
const state = item.coverage ?? item.verdict ?? "unknown";
|
|
39005
|
+
coverage.set(state, (coverage.get(state) ?? 0) + 1);
|
|
39006
|
+
}
|
|
39007
|
+
const notIndexed = known.length - indexed;
|
|
39008
|
+
const homeIndexed = home?.status?.verdict ? home.status.verdict === "PASS" : null;
|
|
39009
|
+
const siteWide = notIndexed >= SITE_WIDE_MIN && notIndexed * 2 > known.length && homeIndexed;
|
|
39010
|
+
const byCoverage = [...coverage].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([state, count]) => ({
|
|
39011
|
+
state,
|
|
39012
|
+
count
|
|
39013
|
+
}));
|
|
39014
|
+
return {
|
|
39015
|
+
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"),
|
|
39016
|
+
data: {
|
|
39017
|
+
inspected: known.length,
|
|
39018
|
+
indexed,
|
|
39019
|
+
notIndexed,
|
|
39020
|
+
byCoverage,
|
|
39021
|
+
homePage: home && homeIndexed !== null ? {
|
|
39022
|
+
url: home.url,
|
|
39023
|
+
indexed: homeIndexed
|
|
39024
|
+
} : null,
|
|
39025
|
+
siteWide: Boolean(siteWide)
|
|
39026
|
+
}
|
|
39027
|
+
};
|
|
39028
|
+
}
|
|
39029
|
+
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
39030
|
async function runLostPages(input, deps) {
|
|
38342
39031
|
const range = dateWindow(input, 28);
|
|
38343
39032
|
const before = earlierRange(range);
|
|
@@ -38355,7 +39044,17 @@ async function runLostPages(input, deps) {
|
|
|
38355
39044
|
clicks: current.get(row.keys[0]) ?? 0
|
|
38356
39045
|
})).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
39046
|
const top = lost.slice(0, CHECKED);
|
|
38358
|
-
const
|
|
39047
|
+
const homeUrl = top[0] ? `${new URL(top[0].url).origin}/` : null;
|
|
39048
|
+
const [facts, indexed, homeStatus] = await Promise.all([
|
|
39049
|
+
deps.checkPages(top.map((row) => row.url)),
|
|
39050
|
+
Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url))),
|
|
39051
|
+
homeUrl && !top.slice(0, INSPECTED).some((row) => row.url === homeUrl) ? deps.inspect(input.site, homeUrl).catch(() => null) : null
|
|
39052
|
+
]);
|
|
39053
|
+
const homeIndex = top.slice(0, INSPECTED).findIndex((row) => row.url === homeUrl);
|
|
39054
|
+
const summary = indexSummary(homeIndex >= 0 ? indexed.filter((_, index) => index !== homeIndex) : indexed, homeUrl ? {
|
|
39055
|
+
url: homeUrl,
|
|
39056
|
+
status: homeIndex >= 0 ? indexed[homeIndex] ?? null : homeStatus
|
|
39057
|
+
} : null);
|
|
38359
39058
|
const rows = top.map((row, index) => ({
|
|
38360
39059
|
...row,
|
|
38361
39060
|
status: facts[index]?.status ?? null,
|
|
@@ -38371,6 +39070,7 @@ async function runLostPages(input, deps) {
|
|
|
38371
39070
|
`Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
38372
39071
|
`${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
39072
|
rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
|
|
39073
|
+
summary.text,
|
|
38374
39074
|
"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
39075
|
temporaryRedirectNote(rows),
|
|
38376
39076
|
`Next: ${deps.call("page")} for one URL; ${deps.call("report", {
|
|
@@ -38384,6 +39084,7 @@ async function runLostPages(input, deps) {
|
|
|
38384
39084
|
comparisonRange: before,
|
|
38385
39085
|
rule: LOST_PAGES_RULE,
|
|
38386
39086
|
lostPages: lost.length,
|
|
39087
|
+
index: summary.data,
|
|
38387
39088
|
pages: rows
|
|
38388
39089
|
}
|
|
38389
39090
|
};
|
|
@@ -38436,9 +39137,9 @@ const detail = {
|
|
|
38436
39137
|
const DATA_SOURCES = [
|
|
38437
39138
|
"search_console",
|
|
38438
39139
|
"google_analytics",
|
|
38439
|
-
"bing"
|
|
39140
|
+
"bing",
|
|
39141
|
+
"clicky"
|
|
38440
39142
|
];
|
|
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
39143
|
function tool(spec) {
|
|
38443
39144
|
return spec;
|
|
38444
39145
|
}
|
|
@@ -38452,6 +39153,7 @@ const SHARED_TOOLS = [
|
|
|
38452
39153
|
run: async (surface) => runSites({
|
|
38453
39154
|
accounts: await surface.accounts(),
|
|
38454
39155
|
keys: await surface.keys(),
|
|
39156
|
+
clicky: clickySites(await surface.keys()).map(clickyLabel),
|
|
38455
39157
|
signIn: surface.signIn,
|
|
38456
39158
|
bing: surface.deps.bing ? await surface.deps.bing() : void 0,
|
|
38457
39159
|
connect: surface.connect
|
|
@@ -38549,10 +39251,10 @@ const SHARED_TOOLS = [
|
|
|
38549
39251
|
name: "seo_data",
|
|
38550
39252
|
command: "data",
|
|
38551
39253
|
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.
|
|
39254
|
+
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
39255
|
input: z.object({
|
|
38554
39256
|
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."),
|
|
39257
|
+
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
39258
|
dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
|
|
38557
39259
|
page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
|
|
38558
39260
|
query: z.string().min(1).optional().describe("search_console only: query text."),
|
|
@@ -38560,6 +39262,17 @@ const SHARED_TOOLS = [
|
|
|
38560
39262
|
...detail
|
|
38561
39263
|
}),
|
|
38562
39264
|
run: async (surface, input) => {
|
|
39265
|
+
if (input.source === "clicky") return runClickyRows({
|
|
39266
|
+
site: input.site,
|
|
39267
|
+
startDate: input.start_date,
|
|
39268
|
+
endDate: input.end_date,
|
|
39269
|
+
detail: input.detail,
|
|
39270
|
+
offset: input.offset
|
|
39271
|
+
}, await surface.keys(), {
|
|
39272
|
+
connect: surface.connect,
|
|
39273
|
+
call: (offset) => `seo_data with source "clicky", site "${input.site}", detail "rows", offset ${offset}`,
|
|
39274
|
+
refresh: surface.refreshClicky
|
|
39275
|
+
});
|
|
38563
39276
|
if (input.source === "bing") return runBingRows({
|
|
38564
39277
|
site: input.site,
|
|
38565
39278
|
dimension: input.dimensions[0] === "page" ? "page" : "query",
|
|
@@ -38603,10 +39316,10 @@ async function runSharedTool(tool, surface, input) {
|
|
|
38603
39316
|
if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
|
|
38604
39317
|
return tool.run(surface, parsed.data);
|
|
38605
39318
|
}
|
|
38606
|
-
const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools and optional keyword
|
|
39319
|
+
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
39320
|
1. seo_sites: the properties the connected accounts can read.
|
|
38608
39321
|
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.
|
|
39322
|
+
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
39323
|
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
39324
|
const mcpCall = (command, args) => {
|
|
38612
39325
|
const name = `seo_${command.replace("-", "_")}`;
|
|
@@ -38811,6 +39524,7 @@ function nodeCommandDeps(options) {
|
|
|
38811
39524
|
return null;
|
|
38812
39525
|
}
|
|
38813
39526
|
},
|
|
39527
|
+
pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
|
|
38814
39528
|
call: options.call,
|
|
38815
39529
|
bing: localBing,
|
|
38816
39530
|
bingConnect: LOCAL_BING_CONNECT
|
|
@@ -38833,12 +39547,17 @@ async function localAccounts() {
|
|
|
38833
39547
|
}));
|
|
38834
39548
|
}
|
|
38835
39549
|
async function localResearchKeys() {
|
|
38836
|
-
const [dataForSeo, ahrefs, saved] = await Promise.all([
|
|
39550
|
+
const [dataForSeo, ahrefs, clicky, saved] = await Promise.all([
|
|
38837
39551
|
readDataForSeoCredentials().catch(() => void 0),
|
|
38838
39552
|
readAhrefsApiKey().catch(() => void 0),
|
|
39553
|
+
readClickySiteKeys().catch(() => ({})),
|
|
38839
39554
|
cloudLinked() ? cloudKeys().catch(() => ({})) : {}
|
|
38840
39555
|
]);
|
|
38841
39556
|
const keys = new Headers(saved);
|
|
39557
|
+
for (const [siteId, sitekey] of Object.entries(clicky)) {
|
|
39558
|
+
const name = `${CLICKY_KEY_PREFIX}${siteId}`;
|
|
39559
|
+
if (!keys.has(name)) keys.set(name, sitekey);
|
|
39560
|
+
}
|
|
38842
39561
|
if (dataForSeo) {
|
|
38843
39562
|
keys.set("x-dataforseo-login", dataForSeo.login);
|
|
38844
39563
|
keys.set("x-dataforseo-password", dataForSeo.password);
|
|
@@ -38846,6 +39565,11 @@ async function localResearchKeys() {
|
|
|
38846
39565
|
if (ahrefs?.apiKey) keys.set("x-ahrefs-key", ahrefs.apiKey);
|
|
38847
39566
|
return keys;
|
|
38848
39567
|
}
|
|
39568
|
+
async function localRefreshClicky() {
|
|
39569
|
+
if (!cloudLinked()) return null;
|
|
39570
|
+
await cloudCredentials({ refreshClicky: true });
|
|
39571
|
+
return localResearchKeys();
|
|
39572
|
+
}
|
|
38849
39573
|
async function resolveLocalSite(site, options = {}) {
|
|
38850
39574
|
const found = matchProperty((await listSites(options.refresh, options.account)).map((entry) => entry.siteUrl), site);
|
|
38851
39575
|
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.`);
|
|
@@ -40835,46 +41559,18 @@ function registerOpportunityTools(server, dependencies = {}) {
|
|
|
40835
41559
|
server.registerTool("seo_internal_links", {
|
|
40836
41560
|
description: "Find verified internal-link review candidates for a target URL",
|
|
40837
41561
|
inputSchema: {
|
|
40838
|
-
...mcpReportInputSchema([
|
|
40839
|
-
"site",
|
|
40840
|
-
"days",
|
|
40841
|
-
"limit",
|
|
40842
|
-
"checkLimit",
|
|
40843
|
-
"minImpressions",
|
|
40844
|
-
"includeBrand",
|
|
40845
|
-
"js",
|
|
40846
|
-
"fetchConcurrency",
|
|
40847
|
-
"fetchIntervalCap",
|
|
40848
|
-
"fetchIntervalMs",
|
|
40849
|
-
"refresh"
|
|
40850
|
-
]),
|
|
41562
|
+
...mcpReportInputSchema(["site", "refresh"]),
|
|
40851
41563
|
targetUrl: z$1.string().url().refine((value) => /^https?:\/\//.test(value), "Use an HTTP(S) URL."),
|
|
40852
|
-
|
|
40853
|
-
|
|
40854
|
-
checkLimit: z$1.number().int().min(1).max(200).optional(),
|
|
40855
|
-
minImpressions: z$1.number().min(0).max(1e9).optional(),
|
|
40856
|
-
brandTerms: z$1.array(z$1.string().trim().min(1).max(200)).max(20).optional(),
|
|
40857
|
-
fetchConcurrency: z$1.number().int().min(1).max(16).optional(),
|
|
40858
|
-
fetchIntervalCap: z$1.number().int().min(1).max(60).optional(),
|
|
40859
|
-
fetchIntervalMs: z$1.number().int().min(100).max(6e4).optional()
|
|
41564
|
+
startDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
41565
|
+
endDate: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
|
40860
41566
|
}
|
|
40861
|
-
}, async ({ site, targetUrl,
|
|
41567
|
+
}, async ({ site, targetUrl, startDate, endDate, refresh }) => {
|
|
40862
41568
|
try {
|
|
40863
41569
|
const result = await (dependencies.internalLinksReport ?? internalLinksReport)({
|
|
40864
41570
|
site,
|
|
40865
41571
|
targetUrl,
|
|
40866
|
-
|
|
40867
|
-
|
|
40868
|
-
checkLimit,
|
|
40869
|
-
minImpressions,
|
|
40870
|
-
brandTerms,
|
|
40871
|
-
includeBrand,
|
|
40872
|
-
js: resolveJsOption(js, void 0),
|
|
40873
|
-
rate: fetchRateInput({
|
|
40874
|
-
fetchConcurrency,
|
|
40875
|
-
fetchIntervalCap,
|
|
40876
|
-
fetchIntervalMs
|
|
40877
|
-
}),
|
|
41572
|
+
startDate,
|
|
41573
|
+
endDate,
|
|
40878
41574
|
refresh
|
|
40879
41575
|
});
|
|
40880
41576
|
return toolSuccess(result.summary.verdict, result);
|
|
@@ -41183,12 +41879,14 @@ const REPORT_DEPTH = {
|
|
|
41183
41879
|
readOrder: [
|
|
41184
41880
|
"dataStatus, target verification, aliases, canonical, and technical signals",
|
|
41185
41881
|
"source completeness and selection attempted, checked, failed, unchecked, and exclusions",
|
|
41186
|
-
"per-item exact versus lexical matches and linkEvidence"
|
|
41882
|
+
"per-item exact versus lexical matches and linkEvidence",
|
|
41883
|
+
"per-item placement: the sentence where a link fits, its anchors, and contentSimilarity"
|
|
41187
41884
|
],
|
|
41188
41885
|
doNotClaim: [
|
|
41189
|
-
"
|
|
41886
|
+
"The live check stops at 40 source pages, so unchecked candidates can remain and absence is not proven.",
|
|
41190
41887
|
"Query overlap does not prove a link belongs or that adding one improves rankings.",
|
|
41191
|
-
"Priority is a heuristic, not estimated impact."
|
|
41888
|
+
"Priority is a heuristic, not estimated impact.",
|
|
41889
|
+
"Placement sentences, anchors and contentSimilarity are text heuristics; they do not prove the link helps a reader."
|
|
41192
41890
|
],
|
|
41193
41891
|
verify: "Read the source passage, add one contextual link if the target is genuinely useful, and re-crawl to confirm it.",
|
|
41194
41892
|
related: [
|
|
@@ -42362,6 +43060,7 @@ function registerSharedTools(server, options = {}) {
|
|
|
42362
43060
|
deps: nodeCommandDeps({ call: mcpCall }),
|
|
42363
43061
|
resolveSite: (site) => resolveLocalSite(site),
|
|
42364
43062
|
keys: localResearchKeys,
|
|
43063
|
+
refreshClicky: localRefreshClicky,
|
|
42365
43064
|
drKey: null,
|
|
42366
43065
|
accounts: localAccounts,
|
|
42367
43066
|
signIn: LOCAL_SIGN_IN.signIn,
|
|
@@ -42386,14 +43085,20 @@ function registerSharedTools(server, options = {}) {
|
|
|
42386
43085
|
})));
|
|
42387
43086
|
server.registerTool("seo_internal_links", {
|
|
42388
43087
|
title: "Internal link opportunities",
|
|
42389
|
-
description: "Pages of the same site that could link to a target page, from Search Console and live page checks on this machine.",
|
|
42390
|
-
inputSchema: {
|
|
43088
|
+
description: "Pages of the same site that could link to a target page, from Search Console and live page checks on this machine. Each one names the sentence where the link fits and anchor text from it. Takes the target URL and optional dates (default: the last 90 days).",
|
|
43089
|
+
inputSchema: {
|
|
43090
|
+
url: z$1.url(),
|
|
43091
|
+
start_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with end_date."),
|
|
43092
|
+
end_date: z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD. Optional; send with start_date.")
|
|
43093
|
+
},
|
|
42391
43094
|
annotations: readOnly
|
|
42392
|
-
}, async ({ url }) => counted("internal-links", {}, options.telemetry, async () => {
|
|
43095
|
+
}, async ({ url, start_date, end_date }) => counted("internal-links", {}, options.telemetry, async () => {
|
|
42393
43096
|
try {
|
|
42394
43097
|
return await executeReport("internal-links", {
|
|
42395
43098
|
site: await resolveLocalSite(url),
|
|
42396
|
-
targetUrl: url
|
|
43099
|
+
targetUrl: url,
|
|
43100
|
+
startDate: start_date,
|
|
43101
|
+
endDate: end_date
|
|
42397
43102
|
});
|
|
42398
43103
|
} catch (error) {
|
|
42399
43104
|
return toolError(localError(error));
|