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/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { n as version, t as load } from "./esm-
|
|
2
|
+
import { n as version, t as load } from "./esm-hHlvm1W0.js";
|
|
3
3
|
import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
|
|
4
4
|
import crypto, { createHash, randomUUID, sign } from "node:crypto";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -1638,6 +1638,13 @@ async function failure(response, label, subject, options) {
|
|
|
1638
1638
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1639
1639
|
const status = response.status;
|
|
1640
1640
|
const what = subject ? `${label} for ${subject}` : label;
|
|
1641
|
+
if (status === 400) {
|
|
1642
|
+
let reason = detail;
|
|
1643
|
+
try {
|
|
1644
|
+
reason = JSON.parse(detail).error?.message ?? detail;
|
|
1645
|
+
} catch {}
|
|
1646
|
+
return new GoogleApiError("INVALID_INPUT", `${label} rejected the request: ${reason}`.trim(), status);
|
|
1647
|
+
}
|
|
1641
1648
|
if (status === 401) return new GoogleApiError("AUTH_EXPIRED", `Google rejected the sign-in. ${options.signIn}`, status);
|
|
1642
1649
|
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);
|
|
1643
1650
|
if (status === 404) return new GoogleApiError("PROPERTY_NOT_FOUND", `Google could not find ${what}. Check the property and account access.`, status);
|
|
@@ -29357,25 +29364,64 @@ function need(headers, provider) {
|
|
|
29357
29364
|
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\`.`);
|
|
29358
29365
|
return values;
|
|
29359
29366
|
}
|
|
29367
|
+
const TASK_TIMEOUT_MS = 6e4;
|
|
29368
|
+
const READ_TIMEOUT_MS = 3e4;
|
|
29369
|
+
const RETRY_DELAY_MS = 250;
|
|
29370
|
+
const retryStatus = (status) => status === 429 || status >= 500;
|
|
29360
29371
|
async function call(provider, request) {
|
|
29361
|
-
const
|
|
29362
|
-
|
|
29363
|
-
|
|
29364
|
-
|
|
29372
|
+
const read = request.method === "GET";
|
|
29373
|
+
const timeoutMs = read ? READ_TIMEOUT_MS : TASK_TIMEOUT_MS;
|
|
29374
|
+
for (let attempt = 1;; attempt += 1) {
|
|
29375
|
+
const last = attempt === 2;
|
|
29376
|
+
let response;
|
|
29377
|
+
try {
|
|
29378
|
+
response = await fetch(request.clone(), { signal: AbortSignal.timeout(timeoutMs) });
|
|
29379
|
+
} catch (error) {
|
|
29380
|
+
if (read && !last) {
|
|
29381
|
+
await wait(RETRY_DELAY_MS);
|
|
29382
|
+
continue;
|
|
29383
|
+
}
|
|
29384
|
+
throw new CommandError("PROVIDER_ERROR", error.name === "TimeoutError" ? `${provider} did not answer within ${timeoutMs / 1e3} seconds.` : `${provider} could not be reached: ${error.message}`);
|
|
29385
|
+
}
|
|
29386
|
+
if (response.ok) return response;
|
|
29387
|
+
if (retryStatus(response.status) && !last) {
|
|
29388
|
+
await response.body?.cancel();
|
|
29389
|
+
await wait(RETRY_DELAY_MS);
|
|
29390
|
+
continue;
|
|
29391
|
+
}
|
|
29392
|
+
const text = (await response.text()).slice(0, 300);
|
|
29393
|
+
throw new CommandError(response.status === 401 || response.status === 403 ? "NO_KEY" : response.status === 429 ? "RATE_LIMITED" : "PROVIDER_ERROR", `${provider} answered ${response.status}. ${text}`);
|
|
29394
|
+
}
|
|
29365
29395
|
}
|
|
29396
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
29366
29397
|
function requireInput(input) {
|
|
29367
29398
|
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
29368
29399
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
29369
29400
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
29370
29401
|
}
|
|
29402
|
+
async function eachKeyword(keywords, missed, rowsFor) {
|
|
29403
|
+
const rows = [];
|
|
29404
|
+
let failure;
|
|
29405
|
+
for (const keyword of keywords) try {
|
|
29406
|
+
rows.push(...await rowsFor(keyword));
|
|
29407
|
+
} catch (error) {
|
|
29408
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
29409
|
+
failure ??= error;
|
|
29410
|
+
missed.push(keyword);
|
|
29411
|
+
}
|
|
29412
|
+
if (missed.length === keywords.length) throw failure;
|
|
29413
|
+
return rows;
|
|
29414
|
+
}
|
|
29371
29415
|
async function research(provider, headers, input) {
|
|
29372
29416
|
requireInput(input);
|
|
29373
29417
|
const keys = need(headers, provider);
|
|
29374
29418
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
29419
|
+
const missed = [];
|
|
29375
29420
|
return {
|
|
29376
|
-
rows: await ahrefs(keys[0] ?? "", input),
|
|
29421
|
+
rows: await ahrefs(keys[0] ?? "", input, missed),
|
|
29377
29422
|
costUsd: null,
|
|
29378
|
-
partial: false
|
|
29423
|
+
partial: false,
|
|
29424
|
+
missed
|
|
29379
29425
|
};
|
|
29380
29426
|
}
|
|
29381
29427
|
function countryName(code) {
|
|
@@ -29441,12 +29487,14 @@ const PARTIAL = 40106;
|
|
|
29441
29487
|
async function dataForSeo(keys, input) {
|
|
29442
29488
|
const spent = {
|
|
29443
29489
|
cost: 0,
|
|
29444
|
-
partial: false
|
|
29490
|
+
partial: false,
|
|
29491
|
+
missed: []
|
|
29445
29492
|
};
|
|
29446
29493
|
return {
|
|
29447
29494
|
rows: await dataForSeoRows(keys, input, spent),
|
|
29448
29495
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
29449
|
-
partial: spent.partial
|
|
29496
|
+
partial: spent.partial,
|
|
29497
|
+
missed: spent.missed
|
|
29450
29498
|
};
|
|
29451
29499
|
}
|
|
29452
29500
|
async function dataForSeoRows(keys, input, spent) {
|
|
@@ -29488,28 +29536,20 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
29488
29536
|
keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
|
|
29489
29537
|
main_intent: "keyword_data.search_intent_info.main_intent"
|
|
29490
29538
|
}));
|
|
29491
|
-
if (input.action === "serp") {
|
|
29492
|
-
|
|
29493
|
-
|
|
29494
|
-
|
|
29495
|
-
|
|
29496
|
-
|
|
29497
|
-
|
|
29498
|
-
|
|
29499
|
-
|
|
29500
|
-
|
|
29501
|
-
|
|
29502
|
-
|
|
29503
|
-
|
|
29504
|
-
|
|
29505
|
-
domain: "domain",
|
|
29506
|
-
title: "title"
|
|
29507
|
-
})
|
|
29508
|
-
});
|
|
29509
|
-
}
|
|
29510
|
-
}
|
|
29511
|
-
return rows;
|
|
29512
|
-
}
|
|
29539
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
|
|
29540
|
+
return (await post("v3/serp/google/organic/live/advanced", {
|
|
29541
|
+
keyword,
|
|
29542
|
+
depth: Math.min(input.limit, 100)
|
|
29543
|
+
})).filter((item) => item.type === "organic").map((item) => ({
|
|
29544
|
+
keyword,
|
|
29545
|
+
...pick(item, {
|
|
29546
|
+
position: "rank_absolute",
|
|
29547
|
+
url: "url",
|
|
29548
|
+
domain: "domain",
|
|
29549
|
+
title: "title"
|
|
29550
|
+
})
|
|
29551
|
+
}));
|
|
29552
|
+
});
|
|
29513
29553
|
if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
|
|
29514
29554
|
target: bareTarget(input.target),
|
|
29515
29555
|
limit: input.limit,
|
|
@@ -29538,7 +29578,7 @@ function isPage(target) {
|
|
|
29538
29578
|
function bareTarget(target) {
|
|
29539
29579
|
return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
29540
29580
|
}
|
|
29541
|
-
async function ahrefs(key, input) {
|
|
29581
|
+
async function ahrefs(key, input, missed) {
|
|
29542
29582
|
const get = async (path, params) => {
|
|
29543
29583
|
const url = new URL(`https://api.ahrefs.com/v3/${path}`);
|
|
29544
29584
|
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
@@ -29562,22 +29602,17 @@ async function ahrefs(key, input) {
|
|
|
29562
29602
|
limit: String(input.limit),
|
|
29563
29603
|
order_by: "sum_traffic:desc"
|
|
29564
29604
|
})).keywords ?? [];
|
|
29565
|
-
if (input.action === "serp") {
|
|
29566
|
-
|
|
29567
|
-
|
|
29568
|
-
|
|
29569
|
-
|
|
29570
|
-
|
|
29571
|
-
|
|
29572
|
-
|
|
29573
|
-
|
|
29574
|
-
|
|
29575
|
-
|
|
29576
|
-
...row
|
|
29577
|
-
});
|
|
29578
|
-
}
|
|
29579
|
-
return rows;
|
|
29580
|
-
}
|
|
29605
|
+
if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), missed, async (keyword) => {
|
|
29606
|
+
return ((await get("serp-overview/serp-overview", {
|
|
29607
|
+
select: "position,type,url,title,domain_rating,traffic,keywords",
|
|
29608
|
+
keyword,
|
|
29609
|
+
country: input.country,
|
|
29610
|
+
top_positions: String(Math.min(input.limit, 100))
|
|
29611
|
+
})).positions ?? []).map((row) => ({
|
|
29612
|
+
keyword,
|
|
29613
|
+
...row
|
|
29614
|
+
}));
|
|
29615
|
+
});
|
|
29581
29616
|
if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
|
|
29582
29617
|
select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
|
|
29583
29618
|
target: input.target,
|
|
@@ -30331,7 +30366,7 @@ async function analyticsBeside(access, site, range, before, pages) {
|
|
|
30331
30366
|
//#endregion
|
|
30332
30367
|
//#region packages/core/src/commands/audit.ts
|
|
30333
30368
|
const AUDIT_SECTION = "weekly-audit";
|
|
30334
|
-
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.";
|
|
30369
|
+
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.";
|
|
30335
30370
|
const AUDIT_RULES = {
|
|
30336
30371
|
link_to_error: "Internal links to a page that returns an error",
|
|
30337
30372
|
link_to_redirect: "Internal links to a redirect",
|
|
@@ -30344,7 +30379,8 @@ const AUDIT_RULES = {
|
|
|
30344
30379
|
canonical_elsewhere: "Pages whose canonical is another URL",
|
|
30345
30380
|
sitemap_url_not_ok: "Sitemap URLs that do not return 200",
|
|
30346
30381
|
sitemap_page_unlinked: "Sitemap pages no crawled page links to (heuristic: the crawl may not reach every page)",
|
|
30347
|
-
thin_page: "Pages under 100 words (heuristic)"
|
|
30382
|
+
thin_page: "Pages under 100 words (heuristic)",
|
|
30383
|
+
js_changed_tags: "Pages whose raw HTML has another title, canonical or robots tag than the page after JavaScript runs"
|
|
30348
30384
|
};
|
|
30349
30385
|
const PAGE_SIZE$1 = 50;
|
|
30350
30386
|
const SUMMARY_ROWS = 20;
|
|
@@ -30359,6 +30395,17 @@ function evidence(finding, origin) {
|
|
|
30359
30395
|
if (typeof details.location === "string") parts.push(`to ${relative$2(details.location, origin)}`);
|
|
30360
30396
|
if (typeof details.canonical === "string") parts.push(`canonical ${relative$2(details.canonical, origin)}`);
|
|
30361
30397
|
if (typeof details.words === "number") parts.push(`${details.words} words`);
|
|
30398
|
+
if (details.raw && typeof details.raw === "object") {
|
|
30399
|
+
const raw = details.raw;
|
|
30400
|
+
for (const key of [
|
|
30401
|
+
"title",
|
|
30402
|
+
"canonical",
|
|
30403
|
+
"robots"
|
|
30404
|
+
]) if (key in raw) {
|
|
30405
|
+
const value = raw[key];
|
|
30406
|
+
parts.push(`raw ${key} ${typeof value === "string" ? key === "canonical" ? relative$2(value, origin) : value : "none"}`);
|
|
30407
|
+
}
|
|
30408
|
+
}
|
|
30362
30409
|
const linking = typeof details.linking_pages === "number";
|
|
30363
30410
|
const count = linking ? details.linking_pages : details.pages;
|
|
30364
30411
|
if (typeof count === "number") {
|
|
@@ -30389,6 +30436,14 @@ function ordered(findings, since) {
|
|
|
30389
30436
|
};
|
|
30390
30437
|
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));
|
|
30391
30438
|
}
|
|
30439
|
+
function renderingText(rendering) {
|
|
30440
|
+
if (!rendering) return null;
|
|
30441
|
+
const pages = (n) => n === 1 ? "1 page" : `${n} pages`;
|
|
30442
|
+
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` : ""}).`;
|
|
30443
|
+
if (rendering.mode === "off") return "JavaScript did not change the pages checked, so the crawl read raw HTML.";
|
|
30444
|
+
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.";
|
|
30445
|
+
return rendering.failed && !rendering.rendered ? "JavaScript rendering failed, so the crawl read raw HTML." : null;
|
|
30446
|
+
}
|
|
30392
30447
|
function runAuditReport(input, snapshot, call) {
|
|
30393
30448
|
const site = input.site;
|
|
30394
30449
|
const detail = input.detail ?? "summary";
|
|
@@ -30437,7 +30492,8 @@ function runAuditReport(input, snapshot, call) {
|
|
|
30437
30492
|
const head = [
|
|
30438
30493
|
`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.` : ""}`,
|
|
30439
30494
|
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.`,
|
|
30440
|
-
summary.
|
|
30495
|
+
renderingText(summary.rendering),
|
|
30496
|
+
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,
|
|
30441
30497
|
summary.findings.capped_rules.length ? `Capped at 1,000 findings each, so these counts are lower bounds: ${summary.findings.capped_rules.join(", ")}.` : null,
|
|
30442
30498
|
running
|
|
30443
30499
|
].filter(Boolean).join("\n");
|
|
@@ -32245,7 +32301,7 @@ async function runResearch(input, keys, options) {
|
|
|
32245
32301
|
const offset = input.offset ?? 0;
|
|
32246
32302
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
32247
32303
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
32248
|
-
const { rows: found, costUsd, partial } = await research(input.provider, keys, {
|
|
32304
|
+
const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
|
|
32249
32305
|
action: input.action,
|
|
32250
32306
|
keywords: input.keywords ?? [],
|
|
32251
32307
|
target,
|
|
@@ -32271,6 +32327,7 @@ async function runResearch(input, keys, options) {
|
|
|
32271
32327
|
`${input.provider} estimates, in the provider’s own units.`,
|
|
32272
32328
|
...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
32273
32329
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
32330
|
+
...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
32274
32331
|
...note ? [note] : [],
|
|
32275
32332
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
32276
32333
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -32288,6 +32345,7 @@ async function runResearch(input, keys, options) {
|
|
|
32288
32345
|
leftOut: note,
|
|
32289
32346
|
costUsd,
|
|
32290
32347
|
partial,
|
|
32348
|
+
missedKeywords: missed,
|
|
32291
32349
|
total: rated.rows.length,
|
|
32292
32350
|
offset,
|
|
32293
32351
|
rows: rated.rows.slice(offset, offset + 50)
|
|
@@ -32765,6 +32823,7 @@ const siteName = (property) => property.replace(/^sc-domain:|^https?:\/\//, "").
|
|
|
32765
32823
|
const count = (n) => n.toLocaleString("en-US");
|
|
32766
32824
|
const plural$1 = (n, one, many = `${one}s`) => `${count(n)} ${n === 1 ? one : many}`;
|
|
32767
32825
|
const list = (items) => items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
32826
|
+
const sentence = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
|
|
32768
32827
|
function path(url) {
|
|
32769
32828
|
try {
|
|
32770
32829
|
const parsed = new URL(url);
|
|
@@ -32787,60 +32846,58 @@ const percent = (site) => {
|
|
|
32787
32846
|
return Math.round(Math.abs(clicks.clicks - clicks.previous) / clicks.previous * 100);
|
|
32788
32847
|
};
|
|
32789
32848
|
function issues(site, startMs, endMs) {
|
|
32790
|
-
const name = siteName(site.property);
|
|
32791
32849
|
const found = [];
|
|
32850
|
+
if (site.index?.dropped) found.push({
|
|
32851
|
+
rank: 0,
|
|
32852
|
+
heading: `${plural$1(site.index.dropped, "page")} dropped`
|
|
32853
|
+
});
|
|
32854
|
+
const crawl = site.crawl;
|
|
32855
|
+
const crawled = crawl !== null && crawl.finished_at >= startMs && crawl.finished_at < endMs;
|
|
32856
|
+
if (crawled && crawl.status === "failed") found.push({
|
|
32857
|
+
rank: 1,
|
|
32858
|
+
heading: "crawl stopped early"
|
|
32859
|
+
});
|
|
32860
|
+
else if (!crawled && site.added_at < startMs) found.push({
|
|
32861
|
+
rank: 1,
|
|
32862
|
+
heading: "no crawl this week"
|
|
32863
|
+
});
|
|
32864
|
+
if (clicksChange(site) === "fell") found.push({
|
|
32865
|
+
rank: 2,
|
|
32866
|
+
heading: `clicks down ${percent(site)}%`
|
|
32867
|
+
});
|
|
32868
|
+
if (crawled && crawl.new_issues > 0) found.push({
|
|
32869
|
+
rank: 3,
|
|
32870
|
+
heading: plural$1(crawl.new_issues, "new issue")
|
|
32871
|
+
});
|
|
32872
|
+
return found;
|
|
32873
|
+
}
|
|
32874
|
+
function siteLines(site, startMs, endMs) {
|
|
32875
|
+
const lines = [];
|
|
32792
32876
|
const index = site.index;
|
|
32793
|
-
if (index
|
|
32877
|
+
if (!index) lines.push("Google index: no checks ran this week yet.");
|
|
32878
|
+
else if (index.dropped) {
|
|
32794
32879
|
const paths = index.dropped_pages.map((page) => path(page.url));
|
|
32795
32880
|
const more = index.dropped - paths.length;
|
|
32796
32881
|
const shown = more > 0 ? `${paths.join(", ")} and ${plural$1(more, "more")}` : list(paths);
|
|
32797
32882
|
const reasons = new Set(index.dropped_pages.map((page) => page.coverage));
|
|
32798
32883
|
const reason = reasons.size === 1 ? [...reasons][0] : null;
|
|
32799
|
-
|
|
32800
|
-
|
|
32801
|
-
heading: `${plural$1(index.dropped, "page")} dropped`,
|
|
32802
|
-
line: `${shown} dropped out of Google’s index.${reason ? ` Google says: ${reason}.` : ""}`,
|
|
32803
|
-
next: `check ${index.dropped === 1 ? "it" : "they"} still return${index.dropped === 1 ? "s" : ""} 200 and ${index.dropped === 1 ? "is" : "are"} not noindexed.`,
|
|
32804
|
-
prompt: `Why did ${paths[0] ?? "a page"} on ${name} drop out of Google’s index this week, and how do I fix it?`
|
|
32805
|
-
});
|
|
32806
|
-
}
|
|
32884
|
+
lines.push(`Google index: ${plural$1(index.dropped, "page")} dropped out (${shown}).${reason ? ` Google says: ${reason}.` : ""}${index.returned ? ` ${plural$1(index.returned, "page")} came back.` : ""} ${plural$1(index.checked, "page")} checked.`);
|
|
32885
|
+
} else lines.push(`Google index: ${plural$1(index.checked, "page")} checked, none dropped${index.returned ? `, ${plural$1(index.returned, "page")} came back` : ""}.`);
|
|
32807
32886
|
const crawl = site.crawl;
|
|
32808
32887
|
const crawled = crawl !== null && crawl.finished_at >= startMs && crawl.finished_at < endMs;
|
|
32809
|
-
if (crawled && crawl.status === "failed")
|
|
32810
|
-
|
|
32811
|
-
heading: "crawl stopped early",
|
|
32812
|
-
line: `The weekly crawl stopped after ${plural$1(crawl.pages, "page")}${crawl.error ? `: ${crawl.error}` : "."}`,
|
|
32813
|
-
next: "check the site is up. The next weekly crawl tries again.",
|
|
32814
|
-
prompt: `Why did the weekly crawl of ${name} stop early, and what should I check?`
|
|
32815
|
-
});
|
|
32816
|
-
else if (!crawled && site.added_at < startMs) found.push({
|
|
32817
|
-
rank: 1,
|
|
32818
|
-
heading: "no crawl this week",
|
|
32819
|
-
line: `No weekly crawl finished this week${site.start_error ? `: ${site.start_error}` : "."}`,
|
|
32820
|
-
next: "check the site is up. The crawl tries again each hour.",
|
|
32821
|
-
prompt: `Why has the weekly crawl of ${name} not finished, and what should I check?`
|
|
32822
|
-
});
|
|
32823
|
-
if (clicksChange(site) === "fell") {
|
|
32824
|
-
const clicks = site.clicks;
|
|
32825
|
-
found.push({
|
|
32826
|
-
rank: 2,
|
|
32827
|
-
heading: `clicks down ${percent(site)}%`,
|
|
32828
|
-
line: `Search clicks fell ${percent(site)}%, from ${count(clicks.previous)} to ${count(clicks.clicks)} (${weekLabel(clicks.start, clicks.end)}, against the 7 days before).`,
|
|
32829
|
-
next: "ask your agent which pages and queries lost them.",
|
|
32830
|
-
prompt: `Which pages and queries lost search clicks on ${name} in the last week, and why?`
|
|
32831
|
-
});
|
|
32832
|
-
}
|
|
32833
|
-
if (crawled && crawl.new_issues > 0) {
|
|
32888
|
+
if (crawled && crawl.status === "failed") lines.push(`Weekly crawl: stopped after ${plural$1(crawl.pages, "page")}${crawl.error ? `: ${sentence(crawl.error)}` : "."} It tries again next week.`);
|
|
32889
|
+
else if (crawled) {
|
|
32834
32890
|
const rule = crawl.top_rule ? AUDIT_RULES[crawl.top_rule] : void 0;
|
|
32835
|
-
|
|
32836
|
-
|
|
32837
|
-
|
|
32838
|
-
|
|
32839
|
-
|
|
32840
|
-
|
|
32841
|
-
})
|
|
32842
|
-
|
|
32843
|
-
|
|
32891
|
+
lines.push(crawl.new_issues ? `Weekly crawl: ${plural$1(crawl.pages, "page")}, ${plural$1(crawl.new_issues, "new issue")}${rule ? `, most of them ${rule.charAt(0).toLowerCase()}${rule.slice(1)}` : ""}.` : `Weekly crawl: ${plural$1(crawl.pages, "page")}, no new issues.`);
|
|
32892
|
+
} 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.`);
|
|
32893
|
+
else lines.push("Weekly crawl: the first crawl is still running.");
|
|
32894
|
+
const clicks = site.clicks;
|
|
32895
|
+
if (clicks) {
|
|
32896
|
+
const change = clicks.previous > 0 ? Math.round((clicks.clicks - clicks.previous) / clicks.previous * 100) : null;
|
|
32897
|
+
const trend = change === null ? `from ${count(clicks.previous)}` : change === 0 ? "level with" : `${change > 0 ? "up" : "down"} ${Math.abs(change)}% on`;
|
|
32898
|
+
lines.push(`Search clicks: ${count(clicks.clicks)} (${weekLabel(clicks.start, clicks.end)}), ${trend}${change === null ? "" : ` ${count(clicks.previous)}`} the 7 days before.`);
|
|
32899
|
+
} else lines.push("Search clicks: not available this week.");
|
|
32900
|
+
return lines;
|
|
32844
32901
|
}
|
|
32845
32902
|
function weekMarkdown(data) {
|
|
32846
32903
|
const startMs = Date.parse(`${data.start}T00:00:00Z`);
|
|
@@ -32848,43 +32905,21 @@ function weekMarkdown(data) {
|
|
|
32848
32905
|
const title = `# ${weekTitle(data)}`;
|
|
32849
32906
|
if (!data.sites.length) return `${title}\n\nNo site is in your weekly audit yet. Add one on the Sites tab: ${data.account}#sites`;
|
|
32850
32907
|
const byName = (a, b) => siteName(a.property).localeCompare(siteName(b.property)) || a.property.localeCompare(b.property);
|
|
32851
|
-
const
|
|
32852
|
-
|
|
32853
|
-
|
|
32854
|
-
|
|
32855
|
-
|
|
32856
|
-
|
|
32857
|
-
|
|
32858
|
-
|
|
32859
|
-
|
|
32860
|
-
});
|
|
32861
|
-
else if (!site.crawl || !site.index) waiting.push(site);
|
|
32862
|
-
else quiet.push(site);
|
|
32863
|
-
}
|
|
32864
|
-
attention.sort((a, b) => a.found[0].rank - b.found[0].rank || byName(a.site, b.site));
|
|
32865
|
-
const total = sites.length;
|
|
32908
|
+
const rows = data.sites.map((site) => ({
|
|
32909
|
+
site,
|
|
32910
|
+
found: issues(site, startMs, endMs),
|
|
32911
|
+
waiting: !site.crawl || !site.index
|
|
32912
|
+
}));
|
|
32913
|
+
const attention = rows.filter((row) => row.found.length);
|
|
32914
|
+
const quiet = rows.filter((row) => !row.found.length && !row.waiting);
|
|
32915
|
+
rows.sort((a, b) => (a.found[0]?.rank ?? Number.POSITIVE_INFINITY) - (b.found[0]?.rank ?? Number.POSITIVE_INFINITY) || byName(a.site, b.site));
|
|
32916
|
+
const total = rows.length;
|
|
32866
32917
|
const out = [
|
|
32867
32918
|
title,
|
|
32868
32919
|
"",
|
|
32869
|
-
attention.length ? total === 1 ? "Your site
|
|
32920
|
+
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."
|
|
32870
32921
|
];
|
|
32871
|
-
|
|
32872
|
-
out.push("", "## Needs attention");
|
|
32873
|
-
for (const { site, found } of attention) {
|
|
32874
|
-
const [first, ...rest] = found;
|
|
32875
|
-
const also = rest.length ? ` Also: ${list(rest.map((item) => item.heading))}.` : "";
|
|
32876
|
-
const rose = clicksChange(site) === "rose" ? ` Search clicks rose ${percent(site)}%.` : "";
|
|
32877
|
-
out.push("", `### ${siteName(site.property)}: ${first.heading}`, "", `- ${first.line}${also}${rose}`, `- Next step: ${first.next}`, ...site.link ? [`- [View site](${site.link})`] : []);
|
|
32878
|
-
}
|
|
32879
|
-
}
|
|
32880
|
-
if (quiet.length) {
|
|
32881
|
-
const risen = quiet.filter((site) => clicksChange(site) === "rose");
|
|
32882
|
-
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("")}`);
|
|
32883
|
-
}
|
|
32884
|
-
if (waiting.length) out.push("", "## Not checked yet", "", `${list(waiting.map((site) => siteName(site.property)))}: the first crawl or Google checks are still running.`);
|
|
32885
|
-
const first = attention[0];
|
|
32886
|
-
const prompt = first ? first.found[0].prompt : `What are the best quick wins on ${siteName((quiet[0] ?? sites[0]).property)} this month?`;
|
|
32887
|
-
out.push("", "## Ask your agent", "", "Paste this into your agent:", "", "```", prompt, "```");
|
|
32922
|
+
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})`] : []);
|
|
32888
32923
|
return out.join("\n");
|
|
32889
32924
|
}
|
|
32890
32925
|
async function runAllSites(deps, sites) {
|
|
@@ -32944,15 +32979,38 @@ const SHARED_TOOLS = [
|
|
|
32944
32979
|
name: "seo_sites",
|
|
32945
32980
|
command: "data",
|
|
32946
32981
|
title: "List sites",
|
|
32947
|
-
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.",
|
|
32948
|
-
input: z.object({}),
|
|
32949
|
-
run:
|
|
32982
|
+
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.",
|
|
32983
|
+
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.") }),
|
|
32984
|
+
run: async (surface, input) => {
|
|
32985
|
+
if (!input.add) return listSites$1(surface);
|
|
32986
|
+
const start = surface.deps.startAudit;
|
|
32987
|
+
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.");
|
|
32988
|
+
const property = await surface.resolveSite(input.add);
|
|
32989
|
+
const result = await start(property);
|
|
32990
|
+
return {
|
|
32991
|
+
text: {
|
|
32992
|
+
added: `Added ${property} to the weekly site audit. Its first crawl and index checks have started; ask ${surface.deps.call("report", {
|
|
32993
|
+
site: property,
|
|
32994
|
+
section: AUDIT_SECTION
|
|
32995
|
+
})} for the findings once the crawl is done.`,
|
|
32996
|
+
exists: `${property} is already in the weekly site audit.`,
|
|
32997
|
+
pending: "The payment went through and the plan is starting. Call seo_sites with add again in a minute.",
|
|
32998
|
+
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.` : "",
|
|
32999
|
+
full: result.status === "full" ? `This account already audits ${result.limit} sites, the most it can. Remove one on the account page first.` : "",
|
|
33000
|
+
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.`
|
|
33001
|
+
}[result.status],
|
|
33002
|
+
data: {
|
|
33003
|
+
property,
|
|
33004
|
+
...result
|
|
33005
|
+
}
|
|
33006
|
+
};
|
|
33007
|
+
}
|
|
32950
33008
|
}),
|
|
32951
33009
|
tool({
|
|
32952
33010
|
name: "seo_report",
|
|
32953
33011
|
command: "report",
|
|
32954
33012
|
title: "Search Console report",
|
|
32955
|
-
description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with
|
|
33013
|
+
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")}`,
|
|
32956
33014
|
input: z.object({
|
|
32957
33015
|
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."),
|
|
32958
33016
|
section: z.enum(SECTION_IDS).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
|