seo 0.13.1 → 0.13.3
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 +28 -7
- package/dist/cli.js +76 -76
- package/dist/cli.js.map +1 -1
- package/dist/{dist-3A3Vzp7r.js → dist-BbTqJIE_.js} +1 -1
- package/dist/{dist-rmavn5h6.js → dist-DBI-BioS.js} +55 -55
- package/dist/dist-DBI-BioS.js.map +1 -0
- package/dist/{esm-BIXGSxFJ.js → esm-hHlvm1W0.js} +2 -2
- package/dist/{esm-BIXGSxFJ.js.map → esm-hHlvm1W0.js.map} +1 -1
- package/dist/index.d.ts +64 -45
- package/dist/index.js +191 -133
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +191 -133
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/dist-rmavn5h6.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 version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-hHlvm1W0.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
@@ -13874,6 +13874,13 @@ async function failure(response, label, subject, options) {
|
|
|
13874
13874
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
13875
13875
|
const status = response.status;
|
|
13876
13876
|
const what = subject ? `${label} for ${subject}` : label;
|
|
13877
|
+
if (status === 400) {
|
|
13878
|
+
let reason = detail;
|
|
13879
|
+
try {
|
|
13880
|
+
reason = JSON.parse(detail).error?.message ?? detail;
|
|
13881
|
+
} catch {}
|
|
13882
|
+
return new GoogleApiError("INVALID_INPUT", `${label} rejected the request: ${reason}`.trim(), status);
|
|
13883
|
+
}
|
|
13877
13884
|
if (status === 401) return new GoogleApiError("AUTH_EXPIRED", `Google rejected the sign-in. ${options.signIn}`, status);
|
|
13878
13885
|
if (status === 403) return new GoogleApiError("ACCESS_DENIED", `Google denied access to ${what}. Check the Google account, its access to this property, and the granted scopes. ${detail}`.trim(), status);
|
|
13879
13886
|
if (status === 404) return new GoogleApiError("PROPERTY_NOT_FOUND", `Google could not find ${what}. Check the property and account access.`, status);
|
|
@@ -39546,25 +39553,64 @@ function need(headers, provider) {
|
|
|
39546
39553
|
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
|
|
39547
39554
|
return values;
|
|
39548
39555
|
}
|
|
39556
|
+
const TASK_TIMEOUT_MS = 6e4;
|
|
39557
|
+
const READ_TIMEOUT_MS = 3e4;
|
|
39558
|
+
const RETRY_DELAY_MS = 250;
|
|
39559
|
+
const retryStatus = (status) => status === 429 || status >= 500;
|
|
39549
39560
|
async function call(provider, request) {
|
|
39550
|
-
const
|
|
39551
|
-
|
|
39552
|
-
|
|
39553
|
-
|
|
39561
|
+
const read = request.method === "GET";
|
|
39562
|
+
const timeoutMs = read ? READ_TIMEOUT_MS : TASK_TIMEOUT_MS;
|
|
39563
|
+
for (let attempt = 1;; attempt += 1) {
|
|
39564
|
+
const last = attempt === 2;
|
|
39565
|
+
let response;
|
|
39566
|
+
try {
|
|
39567
|
+
response = await fetch(request.clone(), { signal: AbortSignal.timeout(timeoutMs) });
|
|
39568
|
+
} catch (error) {
|
|
39569
|
+
if (read && !last) {
|
|
39570
|
+
await wait(RETRY_DELAY_MS);
|
|
39571
|
+
continue;
|
|
39572
|
+
}
|
|
39573
|
+
throw new CommandError("PROVIDER_ERROR", error.name === "TimeoutError" ? `${provider} did not answer within ${timeoutMs / 1e3} seconds.` : `${provider} could not be reached: ${error.message}`);
|
|
39574
|
+
}
|
|
39575
|
+
if (response.ok) return response;
|
|
39576
|
+
if (retryStatus(response.status) && !last) {
|
|
39577
|
+
await response.body?.cancel();
|
|
39578
|
+
await wait(RETRY_DELAY_MS);
|
|
39579
|
+
continue;
|
|
39580
|
+
}
|
|
39581
|
+
const text = (await response.text()).slice(0, 300);
|
|
39582
|
+
throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : response.status === 429 ? "RATE_LIMITED" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
|
|
39583
|
+
}
|
|
39554
39584
|
}
|
|
39585
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
39555
39586
|
function requireInput(input) {
|
|
39556
39587
|
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
39557
39588
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
39558
39589
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
39559
39590
|
}
|
|
39591
|
+
async function eachKeyword(keywords, missed, rowsFor) {
|
|
39592
|
+
const rows = [];
|
|
39593
|
+
let failure;
|
|
39594
|
+
for (const keyword of keywords) try {
|
|
39595
|
+
rows.push(...await rowsFor(keyword));
|
|
39596
|
+
} catch (error) {
|
|
39597
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
39598
|
+
failure ??= error;
|
|
39599
|
+
missed.push(keyword);
|
|
39600
|
+
}
|
|
39601
|
+
if (missed.length === keywords.length) throw failure;
|
|
39602
|
+
return rows;
|
|
39603
|
+
}
|
|
39560
39604
|
async function research(provider, headers, input) {
|
|
39561
39605
|
requireInput(input);
|
|
39562
39606
|
const keys = need(headers, provider);
|
|
39563
39607
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
39608
|
+
const missed = [];
|
|
39564
39609
|
return {
|
|
39565
|
-
rows: await ahrefs(keys[0] ?? "", input),
|
|
39610
|
+
rows: await ahrefs(keys[0] ?? "", input, missed),
|
|
39566
39611
|
costUsd: null,
|
|
39567
|
-
partial: false
|
|
39612
|
+
partial: false,
|
|
39613
|
+
missed
|
|
39568
39614
|
};
|
|
39569
39615
|
}
|
|
39570
39616
|
function countryName(code) {
|
|
@@ -39630,12 +39676,14 @@ const PARTIAL = 40106;
|
|
|
39630
39676
|
async function dataForSeo(keys, input) {
|
|
39631
39677
|
const spent = {
|
|
39632
39678
|
cost: 0,
|
|
39633
|
-
partial: false
|
|
39679
|
+
partial: false,
|
|
39680
|
+
missed: []
|
|
39634
39681
|
};
|
|
39635
39682
|
return {
|
|
39636
39683
|
rows: await dataForSeoRows(keys, input, spent),
|
|
39637
39684
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
39638
|
-
partial: spent.partial
|
|
39685
|
+
partial: spent.partial,
|
|
39686
|
+
missed: spent.missed
|
|
39639
39687
|
};
|
|
39640
39688
|
}
|
|
39641
39689
|
async function dataForSeoRows(keys, input, spent) {
|
|
@@ -39677,28 +39725,20 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
39677
39725
|
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
39678
39726
|
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
39679
39727
|
}));
|
|
39680
|
-
if (input.action === "serp") {
|
|
39681
|
-
|
|
39682
|
-
|
|
39683
|
-
|
|
39684
|
-
|
|
39685
|
-
|
|
39686
|
-
|
|
39687
|
-
|
|
39688
|
-
|
|
39689
|
-
|
|
39690
|
-
|
|
39691
|
-
|
|
39692
|
-
|
|
39693
|
-
|
|
39694
|
-
domain: "domain",
|
|
39695
|
-
title: "title"
|
|
39696
|
-
})
|
|
39697
|
-
});
|
|
39698
|
-
}
|
|
39699
|
-
}
|
|
39700
|
-
return rows;
|
|
39701
|
-
}
|
|
39728
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
|
|
39729
|
+
return (await post("v3/serp/google/organic/live/advanced", {
|
|
39730
|
+
keyword,
|
|
39731
|
+
depth: Math.min(input.limit, 100)
|
|
39732
|
+
})).filter((item) => item.type === "organic").map((item) => ({
|
|
39733
|
+
keyword,
|
|
39734
|
+
...pick(item, {
|
|
39735
|
+
position: "rank_absolute",
|
|
39736
|
+
url: "url",
|
|
39737
|
+
domain: "domain",
|
|
39738
|
+
title: "title"
|
|
39739
|
+
})
|
|
39740
|
+
}));
|
|
39741
|
+
});
|
|
39702
39742
|
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
39703
39743
|
target: bareTarget(input.target),
|
|
39704
39744
|
limit: input.limit,
|
|
@@ -39727,7 +39767,7 @@ function isPage(target) {
|
|
|
39727
39767
|
function bareTarget(target) {
|
|
39728
39768
|
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
39729
39769
|
}
|
|
39730
|
-
async function ahrefs(key, input) {
|
|
39770
|
+
async function ahrefs(key, input, missed) {
|
|
39731
39771
|
const get = async (path, params) => {
|
|
39732
39772
|
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
39733
39773
|
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
@@ -39751,22 +39791,17 @@ async function ahrefs(key, input) {
|
|
|
39751
39791
|
limit: String(input.limit),
|
|
39752
39792
|
order_by: "sum_traffic:desc"
|
|
39753
39793
|
})).keywords ?? [];
|
|
39754
|
-
if (input.action === "serp") {
|
|
39755
|
-
|
|
39756
|
-
|
|
39757
|
-
|
|
39758
|
-
|
|
39759
|
-
|
|
39760
|
-
|
|
39761
|
-
|
|
39762
|
-
|
|
39763
|
-
|
|
39764
|
-
|
|
39765
|
-
...row
|
|
39766
|
-
});
|
|
39767
|
-
}
|
|
39768
|
-
return rows;
|
|
39769
|
-
}
|
|
39794
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), missed, async (keyword) => {
|
|
39795
|
+
return ((await get("serp-overview/serp-overview", {
|
|
39796
|
+
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
39797
|
+
keyword,
|
|
39798
|
+
country: input.country,
|
|
39799
|
+
top_positions: String(Math.min(input.limit, 100))
|
|
39800
|
+
})).positions ?? []).map((row) => ({
|
|
39801
|
+
keyword,
|
|
39802
|
+
...row
|
|
39803
|
+
}));
|
|
39804
|
+
});
|
|
39770
39805
|
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
39771
39806
|
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
39772
39807
|
target: input.target,
|
|
@@ -40515,7 +40550,7 @@ async function analyticsBeside(access, site, range, before, pages) {
|
|
|
40515
40550
|
//#endregion
|
|
40516
40551
|
//#region packages/core/dist/commands/audit.js
|
|
40517
40552
|
const AUDIT_SECTION = "weekly-audit";
|
|
40518
|
-
const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing or duplicate titles, descriptions and H1s, noindex, canonicals to another page, sitemap URLs that do not return 200, thin pages), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
|
|
40553
|
+
const AUDIT_SECTION_TEXT = "What the weekly crawl of the site found (broken and redirected internal links, missing or duplicate titles, descriptions and H1s, noindex, canonicals to another page, sitemap URLs that do not return 200, thin pages, tags that JavaScript changes), with what is new, back or resolved since the crawl before. Only for sites added to the weekly site audit beta.";
|
|
40519
40554
|
const AUDIT_RULES = {
|
|
40520
40555
|
link_to_error: "Internal links to a page that returns an error",
|
|
40521
40556
|
link_to_redirect: "Internal links to a redirect",
|
|
@@ -40528,7 +40563,8 @@ const AUDIT_RULES = {
|
|
|
40528
40563
|
canonical_elsewhere: "Pages whose canonical is another URL",
|
|
40529
40564
|
sitemap_url_not_ok: "Sitemap URLs that do not return 200",
|
|
40530
40565
|
sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
|
|
40531
|
-
thin_page: "Pages under 100 words (heuristic)"
|
|
40566
|
+
thin_page: "Pages under 100 words (heuristic)",
|
|
40567
|
+
js_changed_tags: "Pages whose raw HTML has another title, canonical or robots tag than the page after JavaScript runs"
|
|
40532
40568
|
};
|
|
40533
40569
|
const PAGE_SIZE$2 = 50;
|
|
40534
40570
|
const SUMMARY_ROWS$1 = 20;
|
|
@@ -40543,6 +40579,17 @@ function evidence(finding, origin) {
|
|
|
40543
40579
|
if (typeof details.location === "string") parts.push(`to ${relative$2(details.location, origin)}`);
|
|
40544
40580
|
if (typeof details.canonical === "string") parts.push(`canonical ${relative$2(details.canonical, origin)}`);
|
|
40545
40581
|
if (typeof details.words === "number") parts.push(`${details.words} words`);
|
|
40582
|
+
if (details.raw && typeof details.raw === "object") {
|
|
40583
|
+
const raw = details.raw;
|
|
40584
|
+
for (const key of [
|
|
40585
|
+
"title",
|
|
40586
|
+
"canonical",
|
|
40587
|
+
"robots"
|
|
40588
|
+
]) if (key in raw) {
|
|
40589
|
+
const value = raw[key];
|
|
40590
|
+
parts.push(`raw ${key} ${typeof value === "string" ? key === "canonical" ? relative$2(value, origin) : value : "none"}`);
|
|
40591
|
+
}
|
|
40592
|
+
}
|
|
40546
40593
|
const linking = typeof details.linking_pages === "number";
|
|
40547
40594
|
const count = linking ? details.linking_pages : details.pages;
|
|
40548
40595
|
if (typeof count === "number") {
|
|
@@ -40573,6 +40620,14 @@ function ordered(findings, since) {
|
|
|
40573
40620
|
};
|
|
40574
40621
|
return [...findings].sort((a, b) => rank(a) - rank(b) || a.rule.localeCompare(b.rule) || (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
|
|
40575
40622
|
}
|
|
40623
|
+
function renderingText(rendering) {
|
|
40624
|
+
if (!rendering) return null;
|
|
40625
|
+
const pages = (n) => n === 1 ? "1 page" : `${n} pages`;
|
|
40626
|
+
if (rendering.mode === "on") return `JavaScript changed what some pages show, so the crawl rendered the parts of the site that need it (${pages(rendering.rendered)} rendered${rendering.failed ? `; ${pages(rendering.failed)} could not be rendered, so ${rendering.failed === 1 ? "it is" : "they are"} left out of the tag and text checks` : ""}).`;
|
|
40627
|
+
if (rendering.mode === "off") return "JavaScript did not change the pages checked, so the crawl read raw HTML.";
|
|
40628
|
+
if (rendering.mode === "unavailable") return "JavaScript rendering failed on the pages checked, so the crawl read raw HTML. If the site builds its pages with JavaScript, titles, canonicals and links may be missing here.";
|
|
40629
|
+
return rendering.failed && !rendering.rendered ? "JavaScript rendering failed, so the crawl read raw HTML." : null;
|
|
40630
|
+
}
|
|
40576
40631
|
function runAuditReport(input, snapshot, call) {
|
|
40577
40632
|
const site = input.site;
|
|
40578
40633
|
const detail = input.detail ?? "summary";
|
|
@@ -40621,7 +40676,8 @@ function runAuditReport(input, snapshot, call) {
|
|
|
40621
40676
|
const head = [
|
|
40622
40677
|
`Weekly site audit of ${site}, crawled ${day$1(snapshot.finished_at)}: ${coverage}.${pages.blocked ? ` ${pages.blocked === 1 ? "1 URL" : `${pages.blocked} URLs`} blocked by robots.txt ${pages.blocked === 1 ? "was" : "were"} not fetched.` : ""}`,
|
|
40623
40678
|
previous ? `Since the crawl of ${day$1(previous.finished_at)}: ${summary.findings.new} new, ${summary.findings.reopened} back, ${summary.findings.resolved} resolved. ${summary.findings.open} open now.` : `This is the first crawl, so every finding is new: ${summary.findings.open} open.`,
|
|
40624
|
-
summary.
|
|
40679
|
+
renderingText(summary.rendering),
|
|
40680
|
+
summary.findings.resolution === "crawled-pages-only" ? summary.complete ? "Because some pages could not be rendered, a finding counts as resolved only when this crawl rendered its page; a finding not seen here is not an all-clear." : "Because the crawl is partial, a finding counts as resolved only when this crawl fetched its page again; a finding not seen here is not an all-clear." : null,
|
|
40625
40681
|
summary.findings.capped_rules.length ? `Capped at 1,000 findings each, so these counts are lower bounds: ${summary.findings.capped_rules.join(", ")}.` : null,
|
|
40626
40682
|
running
|
|
40627
40683
|
].filter(Boolean).join("\n");
|
|
@@ -42488,7 +42544,7 @@ async function runResearch(input, keys, options) {
|
|
|
42488
42544
|
const offset = input.offset ?? 0;
|
|
42489
42545
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
42490
42546
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
42491
|
-
const { rows: found, costUsd, partial } = await research(input.provider, keys, {
|
|
42547
|
+
const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
|
|
42492
42548
|
action: input.action,
|
|
42493
42549
|
keywords: input.keywords ?? [],
|
|
42494
42550
|
target,
|
|
@@ -42514,6 +42570,7 @@ async function runResearch(input, keys, options) {
|
|
|
42514
42570
|
`${input.provider} estimates, in the provider’s own units.`,
|
|
42515
42571
|
...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
42516
42572
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
42573
|
+
...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
42517
42574
|
...note ? [note] : [],
|
|
42518
42575
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
42519
42576
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -42531,6 +42588,7 @@ async function runResearch(input, keys, options) {
|
|
|
42531
42588
|
leftOut: note,
|
|
42532
42589
|
costUsd,
|
|
42533
42590
|
partial,
|
|
42591
|
+
missedKeywords: missed,
|
|
42534
42592
|
total: rated.rows.length,
|
|
42535
42593
|
offset,
|
|
42536
42594
|
rows: rated.rows.slice(offset, offset + 50)
|
|
@@ -43038,6 +43096,7 @@ const siteName = (property) => property.replace(/^sc-domain:|^https?:\/\//, "").
|
|
|
43038
43096
|
const count = (n) => n.toLocaleString("en-US");
|
|
43039
43097
|
const plural = (n, one, many = `${one}s`) => `${count(n)} ${n === 1 ? one : many}`;
|
|
43040
43098
|
const list = (items) => items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
43099
|
+
const sentence = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
|
|
43041
43100
|
function path(url) {
|
|
43042
43101
|
try {
|
|
43043
43102
|
const parsed = new URL(url);
|
|
@@ -43060,60 +43119,58 @@ const percent = (site) => {
|
|
|
43060
43119
|
return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
|
|
43061
43120
|
};
|
|
43062
43121
|
function issues(site, startMs, endMs) {
|
|
43063
|
-
const name = siteName(site.property);
|
|
43064
43122
|
const found = [];
|
|
43123
|
+
if (site.index?.dropped) found.push({
|
|
43124
|
+
rank: 0,
|
|
43125
|
+
heading: `${plural(site.index.dropped, "page")} dropped`
|
|
43126
|
+
});
|
|
43127
|
+
const crawl = site.crawl;
|
|
43128
|
+
const crawled = crawl !== null && crawl.finished_at >= startMs && crawl.finished_at < endMs;
|
|
43129
|
+
if (crawled && crawl.status === "failed") found.push({
|
|
43130
|
+
rank: 1,
|
|
43131
|
+
heading: "crawl stopped early"
|
|
43132
|
+
});
|
|
43133
|
+
else if (!crawled && site.added_at < startMs) found.push({
|
|
43134
|
+
rank: 1,
|
|
43135
|
+
heading: "no crawl this week"
|
|
43136
|
+
});
|
|
43137
|
+
if (clicksChange(site) === "fell") found.push({
|
|
43138
|
+
rank: 2,
|
|
43139
|
+
heading: `clicks down ${percent(site)}%`
|
|
43140
|
+
});
|
|
43141
|
+
if (crawled && crawl.new_issues > 0) found.push({
|
|
43142
|
+
rank: 3,
|
|
43143
|
+
heading: plural(crawl.new_issues, "new issue")
|
|
43144
|
+
});
|
|
43145
|
+
return found;
|
|
43146
|
+
}
|
|
43147
|
+
function siteLines(site, startMs, endMs) {
|
|
43148
|
+
const lines = [];
|
|
43065
43149
|
const index = site.index;
|
|
43066
|
-
if (index
|
|
43150
|
+
if (!index) lines.push("Google index: no checks ran this week yet.");
|
|
43151
|
+
else if (index.dropped) {
|
|
43067
43152
|
const paths = index.dropped_pages.map((page) => path(page.url));
|
|
43068
43153
|
const more = index.dropped - paths.length;
|
|
43069
43154
|
const shown = more > 0 ? `${paths.join(", ")} and ${plural(more, "more")}` : list(paths);
|
|
43070
43155
|
const reasons = new Set(index.dropped_pages.map((page) => page.coverage));
|
|
43071
43156
|
const reason = reasons.size === 1 ? [...reasons][0] : null;
|
|
43072
|
-
|
|
43073
|
-
|
|
43074
|
-
heading: `${plural(index.dropped, "page")} dropped`,
|
|
43075
|
-
line: `${shown} dropped out of Google’s index.${reason ? ` Google says: ${reason}.` : ""}`,
|
|
43076
|
-
next: `check ${index.dropped === 1 ? "it" : "they"} still return${index.dropped === 1 ? "s" : ""} 200 and ${index.dropped === 1 ? "is" : "are"} not noindexed.`,
|
|
43077
|
-
prompt: `Why did ${paths[0] ?? "a page"} on ${name} drop out of Google’s index this week, and how do I fix it?`
|
|
43078
|
-
});
|
|
43079
|
-
}
|
|
43157
|
+
lines.push(`Google index: ${plural(index.dropped, "page")} dropped out (${shown}).${reason ? ` Google says: ${reason}.` : ""}${index.returned ? ` ${plural(index.returned, "page")} came back.` : ""} ${plural(index.checked, "page")} checked.`);
|
|
43158
|
+
} else lines.push(`Google index: ${plural(index.checked, "page")} checked, none dropped${index.returned ? `, ${plural(index.returned, "page")} came back` : ""}.`);
|
|
43080
43159
|
const crawl = site.crawl;
|
|
43081
43160
|
const crawled = crawl !== null && crawl.finished_at >= startMs && crawl.finished_at < endMs;
|
|
43082
|
-
if (crawled && crawl.status === "failed")
|
|
43083
|
-
|
|
43084
|
-
heading: "crawl stopped early",
|
|
43085
|
-
line: `The weekly crawl stopped after ${plural(crawl.pages, "page")}${crawl.error ? `: ${crawl.error}` : "."}`,
|
|
43086
|
-
next: "check the site is up. The next weekly crawl tries again.",
|
|
43087
|
-
prompt: `Why did the weekly crawl of ${name} stop early, and what should I check?`
|
|
43088
|
-
});
|
|
43089
|
-
else if (!crawled && site.added_at < startMs) found.push({
|
|
43090
|
-
rank: 1,
|
|
43091
|
-
heading: "no crawl this week",
|
|
43092
|
-
line: `No weekly crawl finished this week${site.start_error ? `: ${site.start_error}` : "."}`,
|
|
43093
|
-
next: "check the site is up. The crawl tries again each hour.",
|
|
43094
|
-
prompt: `Why has the weekly crawl of ${name} not finished, and what should I check?`
|
|
43095
|
-
});
|
|
43096
|
-
if (clicksChange(site) === "fell") {
|
|
43097
|
-
const clicks = site.clicks;
|
|
43098
|
-
found.push({
|
|
43099
|
-
rank: 2,
|
|
43100
|
-
heading: `clicks down ${percent(site)}%`,
|
|
43101
|
-
line: `Search clicks fell ${percent(site)}%, from ${count(clicks.previous)} to ${count(clicks.clicks)} (${weekLabel(clicks.start, clicks.end)}, against the 7 days before).`,
|
|
43102
|
-
next: "ask your agent which pages and queries lost them.",
|
|
43103
|
-
prompt: `Which pages and queries lost search clicks on ${name} in the last week, and why?`
|
|
43104
|
-
});
|
|
43105
|
-
}
|
|
43106
|
-
if (crawled && crawl.new_issues > 0) {
|
|
43161
|
+
if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
|
|
43162
|
+
else if (crawled) {
|
|
43107
43163
|
const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
|
|
43108
|
-
|
|
43109
|
-
|
|
43110
|
-
|
|
43111
|
-
|
|
43112
|
-
|
|
43113
|
-
|
|
43114
|
-
})
|
|
43115
|
-
|
|
43116
|
-
|
|
43164
|
+
lines.push(crawl.new_issues ? `Weekly crawl: ${plural(crawl.pages, "page")}, ${plural(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural(crawl.pages, "page")}, no new issues.`);
|
|
43165
|
+
} else if (site.added_at < startMs) lines.push(`Weekly crawl: none finished this week${site.start_error ? `: ${sentence(site.start_error)}` : "."} It tries again each hour.`);
|
|
43166
|
+
else lines.push("Weekly crawl: the first crawl is still running.");
|
|
43167
|
+
const clicks = site.clicks;
|
|
43168
|
+
if (clicks) {
|
|
43169
|
+
const change = clicks.previous > 0 ? Math.round((clicks.clicks - clicks.previous) / clicks.previous * 100) : null;
|
|
43170
|
+
const trend = change === null ? `from ${count(clicks.previous)}` : change === 0 ? "level with" : `${change > 0 ? "up" : "down"} ${Math.abs(change)}% on`;
|
|
43171
|
+
lines.push(`Search clicks: ${count(clicks.clicks)} (${weekLabel(clicks.start, clicks.end)}), ${trend}${change === null ? "" : ` ${count(clicks.previous)}`} the 7 days before.`);
|
|
43172
|
+
} else lines.push("Search clicks: not available this week.");
|
|
43173
|
+
return lines;
|
|
43117
43174
|
}
|
|
43118
43175
|
function weekMarkdown(data) {
|
|
43119
43176
|
const startMs = Date.parse(`${data.start}T00:00:00Z`);
|
|
@@ -43121,43 +43178,21 @@ function weekMarkdown(data) {
|
|
|
43121
43178
|
const title = `# ${weekTitle(data)}`;
|
|
43122
43179
|
if (!data.sites.length) return `${title}\n\nNo site is in your weekly audit yet. Add one on the Sites tab: ${data.account}#sites`;
|
|
43123
43180
|
const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
|
|
43124
|
-
const
|
|
43125
|
-
|
|
43126
|
-
|
|
43127
|
-
|
|
43128
|
-
|
|
43129
|
-
|
|
43130
|
-
|
|
43131
|
-
|
|
43132
|
-
|
|
43133
|
-
});
|
|
43134
|
-
else if (!site.crawl || !site.index) waiting.push(site);
|
|
43135
|
-
else quiet.push(site);
|
|
43136
|
-
}
|
|
43137
|
-
attention.sort((a, b) => a.found[0].rank - b.found[0].rank || byName(a.site, b.site));
|
|
43138
|
-
const total = sites.length;
|
|
43181
|
+
const rows = data.sites.map((site) => ({
|
|
43182
|
+
site,
|
|
43183
|
+
found: issues(site, startMs, endMs),
|
|
43184
|
+
waiting: !site.crawl || !site.index
|
|
43185
|
+
}));
|
|
43186
|
+
const attention = rows.filter((row) => row.found.length);
|
|
43187
|
+
const quiet = rows.filter((row) => !row.found.length && !row.waiting);
|
|
43188
|
+
rows.sort((a, b) => (a.found[0]?.rank ?? Number.POSITIVE_INFINITY) - (b.found[0]?.rank ?? Number.POSITIVE_INFINITY) || byName(a.site, b.site));
|
|
43189
|
+
const total = rows.length;
|
|
43139
43190
|
const out = [
|
|
43140
43191
|
title,
|
|
43141
43192
|
"",
|
|
43142
|
-
attention.length ? total === 1 ? "Your site
|
|
43193
|
+
attention.length ? total === 1 ? "Your site had changes worth a look this week." : `${attention.length} of your ${total} sites had changes worth a look this week.` : quiet.length ? total === 1 ? "Your site had a quiet week." : quiet.length === total ? `All ${total} of your sites had a quiet week.` : `${quiet.length} of your ${total} sites had a quiet week.` : "Your sites are still on their first checks."
|
|
43143
43194
|
];
|
|
43144
|
-
|
|
43145
|
-
out.push("", "## Needs attention");
|
|
43146
|
-
for (const { site, found } of attention) {
|
|
43147
|
-
const [first, ...rest] = found;
|
|
43148
|
-
const also = rest.length ? ` Also: ${list(rest.map((item) => item.heading))}.` : "";
|
|
43149
|
-
const rose = clicksChange(site) === "rose" ? ` Search clicks rose ${percent(site)}%.` : "";
|
|
43150
|
-
out.push("", `### ${siteName(site.property)}: ${first.heading}`, "", `- ${first.line}${also}${rose}`, `- Next step: ${first.next}`, ...site.link ? [`- [View site](${site.link})`] : []);
|
|
43151
|
-
}
|
|
43152
|
-
}
|
|
43153
|
-
if (quiet.length) {
|
|
43154
|
-
const risen = quiet.filter((site) => clicksChange(site) === "rose");
|
|
43155
|
-
out.push("", "## All quiet", "", `${list(quiet.map((site) => siteName(site.property)))}: no drops, no new issues.${risen.map((site) => ` Search clicks rose ${percent(site)}% on ${siteName(site.property)}.`).join("")}`);
|
|
43156
|
-
}
|
|
43157
|
-
if (waiting.length) out.push("", "## Not checked yet", "", `${list(waiting.map((site) => siteName(site.property)))}: the first crawl or Google checks are still running.`);
|
|
43158
|
-
const first = attention[0];
|
|
43159
|
-
const prompt = first ? first.found[0].prompt : `What are the best quick wins on ${siteName((quiet[0] ?? sites[0]).property)} this month?`;
|
|
43160
|
-
out.push("", "## Ask your agent", "", "Paste this into your agent:", "", "```", prompt, "```");
|
|
43195
|
+
for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs).map((line) => `- ${line}`), ...site.link ? [`- [View site](${site.link})`] : []);
|
|
43161
43196
|
return out.join("\n");
|
|
43162
43197
|
}
|
|
43163
43198
|
async function runAllSites(deps, sites) {
|
|
@@ -43217,15 +43252,38 @@ const SHARED_TOOLS = [
|
|
|
43217
43252
|
name: "seo_sites",
|
|
43218
43253
|
command: "data",
|
|
43219
43254
|
title: "List sites",
|
|
43220
|
-
description: "Lists the Search Console and Google Analytics properties the connected Google accounts can read, the Bing Webmaster site for each, and which research keys are set. Call it first.",
|
|
43221
|
-
input: z.object({}),
|
|
43222
|
-
run:
|
|
43255
|
+
description: "Lists the Search Console and Google Analytics properties the connected Google accounts can read, the Bing Webmaster site for each, and which research keys are set. Call it first. With add: starts the weekly site audit of that site on the hosted server (a weekly crawl, daily Google index checks, a Monday email and webhook with what changed, and alerts when a page drops out of Google's index). A site that needs paying for returns a Stripe Checkout link: give it to the person to open, and the audit starts once they pay.",
|
|
43256
|
+
input: z.object({ add: site.optional().describe("Optional. A site to add to the weekly site audit. Only when the person asks for it: a site can cost money.") }),
|
|
43257
|
+
run: async (surface, input) => {
|
|
43258
|
+
if (!input.add) return listSites(surface);
|
|
43259
|
+
const start = surface.deps.startAudit;
|
|
43260
|
+
if (!start) throw new CommandError("INVALID_INPUT", "The weekly site audit runs on the hosted server only. Connect https://seoskill.dev/mcp and call seo_sites with add there.");
|
|
43261
|
+
const property = await surface.resolveSite(input.add);
|
|
43262
|
+
const result = await start(property);
|
|
43263
|
+
return {
|
|
43264
|
+
text: {
|
|
43265
|
+
added: `Added ${property} to the weekly site audit. Its first crawl and index checks have started; ask ${surface.deps.call("report", {
|
|
43266
|
+
site: property,
|
|
43267
|
+
section: AUDIT_SECTION
|
|
43268
|
+
})} for the findings once the crawl is done.`,
|
|
43269
|
+
exists: `${property} is already in the weekly site audit.`,
|
|
43270
|
+
pending: "The payment went through and the plan is starting. Call seo_sites with add again in a minute.",
|
|
43271
|
+
checkout: result.status === "checkout" ? `${property} needs a plan: $${result.price} a month per site, cancel at any time. Give the person this Stripe Checkout link to open and pay: ${result.url}\nThe audit of ${property} starts once they pay. Call seo_sites with add again afterwards to confirm.` : "",
|
|
43272
|
+
full: result.status === "full" ? `This account already audits ${result.limit} sites, the most it can. Remove one on the account page first.` : "",
|
|
43273
|
+
payment_failed: `Stripe could not take the payment for another site, so ${property} was not added. The person can check their card under Manage billing on the account page.`
|
|
43274
|
+
}[result.status],
|
|
43275
|
+
data: {
|
|
43276
|
+
property,
|
|
43277
|
+
...result
|
|
43278
|
+
}
|
|
43279
|
+
};
|
|
43280
|
+
}
|
|
43223
43281
|
}),
|
|
43224
43282
|
tool({
|
|
43225
43283
|
name: "seo_report",
|
|
43226
43284
|
command: "report",
|
|
43227
43285
|
title: "Search Console report",
|
|
43228
|
-
description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with
|
|
43286
|
+
description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with each site's Google index, weekly crawl and search clicks, sites with changes first, ending with the site to ask about next; an account outside that beta gets its site list to pick from. With site: the main report for that Search Console property, the overview plus each section's row count and top rows. With site and section: that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}`,
|
|
43229
43287
|
input: z.object({
|
|
43230
43288
|
site: site.optional().describe("Leave it out for the summary of every audited site (weekly site audit beta). Otherwise a Search Console property or site, such as sc-domain:example.com, https://example.com/ or example.com."),
|
|
43231
43289
|
section: z.enum(SECTION_IDS).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
|