katman-mcp 0.1.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/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Text helpers shared by the scan and audit tools. Pure functions, no I/O.
|
|
2
|
+
const NAMED = {
|
|
3
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…",
|
|
4
|
+
rsquo: "’", lsquo: "‘", ldquo: "“", rdquo: "”", copy: "©", reg: "®", trade: "™", middot: "·", bull: "•",
|
|
5
|
+
};
|
|
6
|
+
export function decodeEntities(s) {
|
|
7
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
8
|
+
if (e[0] === "#") {
|
|
9
|
+
const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
10
|
+
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
|
11
|
+
}
|
|
12
|
+
return NAMED[e.toLowerCase()] ?? m;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Collapse whitespace (incl. nbsp) and trim. */
|
|
16
|
+
export function collapse(s) {
|
|
17
|
+
return s.replace(/[\s ]+/g, " ").trim();
|
|
18
|
+
}
|
|
19
|
+
/** Words = whitespace-separated tokens containing a letter or digit (same idea as `sed 's/<[^>]*>/ /g' | wc -w`). */
|
|
20
|
+
export function countWords(s) {
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (const t of s.split(/[\s ]+/))
|
|
23
|
+
if (/[\p{L}\p{N}]/u.test(t))
|
|
24
|
+
n++;
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
/** Lowercase and strip diacritics (Turkish dotless ı → i) for matching. */
|
|
28
|
+
export function fold(s) {
|
|
29
|
+
return s
|
|
30
|
+
.toLocaleLowerCase("en")
|
|
31
|
+
.replace(/ı/g, "i")
|
|
32
|
+
.normalize("NFKD")
|
|
33
|
+
.replace(/[̀-ͯ]/g, "");
|
|
34
|
+
}
|
|
35
|
+
const STOP = new Set((
|
|
36
|
+
// English
|
|
37
|
+
"the and for with your our you are from that this into over under about have has had was were will would can could " +
|
|
38
|
+
"not but all any its it's their them they what when where which who why how than then there these those been being " +
|
|
39
|
+
"also just more most very such only own same other each both few some out off per via get got use used using " +
|
|
40
|
+
// Turkish
|
|
41
|
+
"ile icin bir bu da de ve veya ama gibi daha cok en her hic mi mu ne ki olan olarak ise sonra once kadar diye " +
|
|
42
|
+
"size sizin bizim onun benim senin").split(/\s+/));
|
|
43
|
+
/** Content tokens (≥3 chars, folded, no stopwords). */
|
|
44
|
+
export function contentTokens(s) {
|
|
45
|
+
return fold(s)
|
|
46
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
47
|
+
.filter((t) => t.length >= 3 && !STOP.has(t));
|
|
48
|
+
}
|
|
49
|
+
function tokenHit(t, set, list) {
|
|
50
|
+
if (set.has(t))
|
|
51
|
+
return true;
|
|
52
|
+
// Tolerate suffixes (plural, Turkish case endings): shared prefix of ≥5 chars.
|
|
53
|
+
if (t.length >= 5) {
|
|
54
|
+
const stem = t.slice(0, Math.max(5, t.length - 3));
|
|
55
|
+
return list.some((o) => o.length >= 5 && (o.startsWith(stem) || t.startsWith(o.slice(0, Math.max(5, o.length - 3)))));
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Share of `needle`'s content tokens that also appear in `hay` (0–1). */
|
|
60
|
+
export function containment(needle, hay) {
|
|
61
|
+
const a = [...new Set(contentTokens(needle))];
|
|
62
|
+
if (!a.length)
|
|
63
|
+
return 1;
|
|
64
|
+
const bl = [...new Set(contentTokens(hay))];
|
|
65
|
+
const bs = new Set(bl);
|
|
66
|
+
let hit = 0;
|
|
67
|
+
for (const t of a)
|
|
68
|
+
if (tokenHit(t, bs, bl))
|
|
69
|
+
hit++;
|
|
70
|
+
return hit / a.length;
|
|
71
|
+
}
|
|
72
|
+
export function pct(n, d) {
|
|
73
|
+
return d ? `${Math.round((100 * n) / d)}%` : "–";
|
|
74
|
+
}
|
|
75
|
+
export function plural(n, one, many = one + "s") {
|
|
76
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
77
|
+
}
|
|
78
|
+
export function slugify(s) {
|
|
79
|
+
return fold(s).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "x";
|
|
80
|
+
}
|
|
81
|
+
export function escapeRe(s) {
|
|
82
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
83
|
+
}
|
|
84
|
+
/** First n items + "…and N more". */
|
|
85
|
+
export function listSome(items, n = 5) {
|
|
86
|
+
if (items.length <= n)
|
|
87
|
+
return items.join(", ");
|
|
88
|
+
return `${items.slice(0, n).join(", ")} …and ${items.length - n} more`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// URL helpers shared by the audit and IndexNow tools.
|
|
2
|
+
export function safeUrl(u, base) {
|
|
3
|
+
try {
|
|
4
|
+
return base ? new URL(u, base) : new URL(u);
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function stripWww(host) {
|
|
11
|
+
return host.toLowerCase().replace(/^www\./, "");
|
|
12
|
+
}
|
|
13
|
+
/** Same site = same host ignoring a leading www. */
|
|
14
|
+
export function sameSite(hostA, hostB) {
|
|
15
|
+
return stripWww(hostA) === stripWww(hostB);
|
|
16
|
+
}
|
|
17
|
+
const TRACKING = /^(utm_[a-z]+|fbclid|gclid|msclkid|mc_cid|mc_eid|ref|ref_src)$/i;
|
|
18
|
+
/**
|
|
19
|
+
* Stable key for "the same page": host without www, path without trailing slash, query minus tracking params,
|
|
20
|
+
* no fragment, protocol ignored. Used to match links, sitemap entries and crawled pages.
|
|
21
|
+
*/
|
|
22
|
+
export function urlKey(input) {
|
|
23
|
+
const u = typeof input === "string" ? safeUrl(input) : input;
|
|
24
|
+
if (!u)
|
|
25
|
+
return String(input);
|
|
26
|
+
let p = u.pathname || "/";
|
|
27
|
+
if (p.length > 1)
|
|
28
|
+
p = p.replace(/\/+$/, "") || "/";
|
|
29
|
+
const params = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k));
|
|
30
|
+
params.sort(([a], [b]) => a.localeCompare(b));
|
|
31
|
+
const q = params.length ? "?" + params.map(([k, v]) => `${k}=${v}`).join("&") : "";
|
|
32
|
+
return `${stripWww(u.hostname)}${u.port ? ":" + u.port : ""}${p}${q}`;
|
|
33
|
+
}
|
|
34
|
+
/** Exact-URL comparison used for "canonical points to itself": scheme + host + path + query, no fragment. */
|
|
35
|
+
export function sameUrlExact(a, b) {
|
|
36
|
+
const x = safeUrl(a);
|
|
37
|
+
const y = safeUrl(b);
|
|
38
|
+
if (!x || !y)
|
|
39
|
+
return false;
|
|
40
|
+
x.hash = "";
|
|
41
|
+
y.hash = "";
|
|
42
|
+
return x.href === y.href;
|
|
43
|
+
}
|
|
44
|
+
const ASSET_EXT = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|pdf|zip|gz|tgz|tar|rar|7z|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|flac|css|js|mjs|json|xml|txt|csv|xlsx?|docx?|pptx?|woff2?|ttf|otf|eot|map|rss|atom|dmg|exe|apk|ipa|wasm|webmanifest)$/i;
|
|
45
|
+
export function isAssetPath(pathname) {
|
|
46
|
+
return ASSET_EXT.test(pathname);
|
|
47
|
+
}
|
|
48
|
+
export function originOf(u) {
|
|
49
|
+
const x = safeUrl(u);
|
|
50
|
+
return x ? x.origin : u;
|
|
51
|
+
}
|
|
52
|
+
export function pathOf(u) {
|
|
53
|
+
const x = safeUrl(u);
|
|
54
|
+
return x ? x.pathname + x.search : u;
|
|
55
|
+
}
|
|
56
|
+
/** Shorten a URL for tables: path only when it is on the audited host. */
|
|
57
|
+
export function shortUrl(u, host) {
|
|
58
|
+
const x = safeUrl(u);
|
|
59
|
+
if (!x)
|
|
60
|
+
return u;
|
|
61
|
+
if (sameSite(x.hostname, host))
|
|
62
|
+
return (x.pathname || "/") + x.search;
|
|
63
|
+
return x.href;
|
|
64
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Crawl module: katman_scan_code, katman_audit_site, katman_indexnow, katman_keywords.
|
|
2
|
+
// Registration only; the engines live in scan/, audit/, indexnow.ts and keywords.ts (pure or fetcher-injected).
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { resolveRoot, rootWarning } from "../../context.js";
|
|
8
|
+
import { loadConfig, loadResearch, logEvent, readJson, saveConfig, saveResearch, stamp, writeJson, writeText } from "../../state.js";
|
|
9
|
+
import { fail, ok } from "../../util.js";
|
|
10
|
+
import { PRO_NOTE, withPro } from "../../license.js";
|
|
11
|
+
import { topIssues, auditMarkdown } from "./audit/report.js";
|
|
12
|
+
import { runAudit } from "./audit/index.js";
|
|
13
|
+
import { countBySeverity } from "./common/findings.js";
|
|
14
|
+
import { indexNowMarkdown, runIndexNow } from "./indexnow.js";
|
|
15
|
+
import { keywordsMarkdown, mergeKeywords, runKeywords, termsToSave } from "./keywords.js";
|
|
16
|
+
import { runScan, scanMarkdown } from "./scan/index.js";
|
|
17
|
+
function unsafeRoot(root) {
|
|
18
|
+
return root === path.parse(root).root || root === os.homedir();
|
|
19
|
+
}
|
|
20
|
+
function errText(e) {
|
|
21
|
+
return e instanceof Error ? e.message : String(e);
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
24
|
+
// Shared runners (used by the MCP tools and the CLI)
|
|
25
|
+
export async function scanAndSave(root, maxFiles, save = true) {
|
|
26
|
+
const st = await stat(root).catch(() => undefined);
|
|
27
|
+
if (!st?.isDirectory())
|
|
28
|
+
throw new Error(`${root} is not a directory: pass project_root with the absolute path of the website's codebase.`);
|
|
29
|
+
const warning = rootWarning(root);
|
|
30
|
+
const run = await runScan(root, { maxFiles });
|
|
31
|
+
if (save) {
|
|
32
|
+
await writeJson(root, "scan.json", run.result);
|
|
33
|
+
await logEvent(root, `katman_scan_code: ${run.result.framework.name} · ${run.result.rendering.verdict} · ${run.result.findings.length} findings`);
|
|
34
|
+
}
|
|
35
|
+
const markdown = scanMarkdown(run, { savedTo: save ? ".katman/scan.json" : undefined, warning });
|
|
36
|
+
return { run, markdown };
|
|
37
|
+
}
|
|
38
|
+
export async function auditAndSave(root, a, onProgress, save = true) {
|
|
39
|
+
const writable = save && !unsafeRoot(root);
|
|
40
|
+
const readable = !unsafeRoot(root);
|
|
41
|
+
const config = readable ? await loadConfig(root) : {};
|
|
42
|
+
const research = readable ? await loadResearch(root) : undefined;
|
|
43
|
+
const scan = readable ? await readJson(root, "scan.json") : undefined;
|
|
44
|
+
const siteUrl = a.site_url?.trim() || config.siteUrl || research?.site?.url;
|
|
45
|
+
if (!siteUrl)
|
|
46
|
+
throw new Error("No site URL: pass site_url (e.g. https://example.com), or save it first with katman_start { site_url }.");
|
|
47
|
+
const run = await runAudit({
|
|
48
|
+
siteUrl,
|
|
49
|
+
maxUrls: a.max_urls ?? config.maxUrls ?? 50,
|
|
50
|
+
urls: a.urls,
|
|
51
|
+
includeBotTest: a.include_bot_test,
|
|
52
|
+
previewUrls: a.preview_urls,
|
|
53
|
+
research,
|
|
54
|
+
scan,
|
|
55
|
+
onProgress,
|
|
56
|
+
});
|
|
57
|
+
let jsonPath;
|
|
58
|
+
let mdPath;
|
|
59
|
+
if (writable) {
|
|
60
|
+
const rel = `audits/${stamp(new Date(run.result.ts))}.json`;
|
|
61
|
+
await writeJson(root, rel, run.result);
|
|
62
|
+
jsonPath = `.katman/${rel}`;
|
|
63
|
+
mdPath = ".katman/audit.md";
|
|
64
|
+
await writeText(root, "audit.md", auditMarkdown(run.ctx, run.result, run.findings, { full: true, jsonPath, mdPath }) + "\n");
|
|
65
|
+
if (!config.siteUrl && run.ctx.home && run.ctx.home.status > 0) {
|
|
66
|
+
config.siteUrl = run.ctx.origin;
|
|
67
|
+
await saveConfig(root, config);
|
|
68
|
+
}
|
|
69
|
+
const g = run.result.gates;
|
|
70
|
+
await logEvent(root, `katman_audit_site ${run.ctx.origin}: ${run.ctx.pages.length} URLs · read ${g.read.pass}/${g.read.total} · quote ${g.quote.pass}/${g.quote.total} · fit ${g.fit.pass}/${g.fit.total} · ${run.findings.length} findings`);
|
|
71
|
+
}
|
|
72
|
+
const markdown = auditMarkdown(run.ctx, run.result, run.findings, { full: false, jsonPath, mdPath });
|
|
73
|
+
return { run, markdown, jsonPath, writable };
|
|
74
|
+
}
|
|
75
|
+
function auditSummary(result, top, jsonPath) {
|
|
76
|
+
return {
|
|
77
|
+
siteUrl: result.siteUrl,
|
|
78
|
+
pages: result.pages.length,
|
|
79
|
+
gates: Object.fromEntries(Object.entries(result.gates).map(([k, v]) => [k, { pass: v.pass, total: v.total }])),
|
|
80
|
+
severities: countBySeverity(result.findings),
|
|
81
|
+
topIssues: top.map((f) => ({ id: f.id, severity: f.severity, gate: f.gate, prompt: f.prompt, title: f.title })),
|
|
82
|
+
bots: result.site.bots.filter((b) => b.blocked).map((b) => b.token),
|
|
83
|
+
auditFile: jsonPath,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
87
|
+
export function registerCrawlTools(server, ctx) {
|
|
88
|
+
server.registerTool("katman_scan_code", {
|
|
89
|
+
title: "Scan the codebase (framework, rendering, SEO files)",
|
|
90
|
+
description: "Read-only scan of the project's code (no network). Detects framework and builder (Lovable/Bolt/v0/Replit), the rendering verdict (SSR/SSG/CSR + evidence), hosting and preview-URL patterns, public vs app/auth routes, robots/sitemap/llms.txt/404 (static or generated), head-tag source, JSON-LD, i18n, analytics/AI-channel tracking, onClick navigation, hash routing and images without alt. Never opens .env*, keys or secret files. Writes .katman/scan.json; returns findings with gate, P-code and acceptance criterion. Run it first, and after big code changes.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
project_root: z.string().optional().describe("Absolute path of the website's codebase (default: KATMAN_PROJECT_ROOT, MCP roots, or cwd)."),
|
|
93
|
+
max_files: z.number().int().min(50).max(50000).optional().describe("Max files to list (default 5000)."),
|
|
94
|
+
},
|
|
95
|
+
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, idempotentHint: true },
|
|
96
|
+
}, async ({ project_root, max_files }) => {
|
|
97
|
+
try {
|
|
98
|
+
const root = await resolveRoot(ctx, project_root);
|
|
99
|
+
if (unsafeRoot(root))
|
|
100
|
+
return fail(rootWarning(root) ?? `Refusing to scan ${root}: pass project_root.`);
|
|
101
|
+
const { run, markdown } = await scanAndSave(root, max_files);
|
|
102
|
+
const r = run.result;
|
|
103
|
+
return ok(markdown, {
|
|
104
|
+
scanFile: ".katman/scan.json",
|
|
105
|
+
framework: r.framework,
|
|
106
|
+
rendering: r.rendering,
|
|
107
|
+
hosting: r.hosting,
|
|
108
|
+
staticDir: r.staticDir,
|
|
109
|
+
files: r.files,
|
|
110
|
+
routes: r.routes.length,
|
|
111
|
+
severities: countBySeverity(r.findings),
|
|
112
|
+
findings: r.findings.map((f) => ({ id: f.id, severity: f.severity, gate: f.gate, prompt: f.prompt, title: f.title })),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return fail(`katman_scan_code failed: ${errText(e)}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
server.registerTool("katman_audit_site", {
|
|
120
|
+
title: "Audit the live site like AI crawlers see it",
|
|
121
|
+
description: "Fetches the live site without JavaScript, like AI crawlers: robots.txt, sitemap, http→https, www/apex, HSTS, compression, soft 404, llms.txt, AI-bot access through the CDN/firewall (GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot…), and per page: status, noindex, title/description, canonical, H1, JS-off words and first paragraph, JSON-LD, og:image, hreflang, internal links. Returns gates, top-10 issues (P-codes + acceptance criteria) and the P14 table. Network: the site only, ≤4 requests at a time. Pass urls to re-verify a fix. Writes .katman/audits/<stamp>.json + .katman/audit.md.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
site_url: z.string().optional().describe("Site to audit, e.g. https://example.com (default: .katman/config.json siteUrl or research site.url)."),
|
|
124
|
+
project_root: z.string().optional().describe("Absolute project path (where .katman/ lives)."),
|
|
125
|
+
max_urls: z.number().int().min(1).max(500).optional().describe("Max pages to fetch: homepage, its links and sitemap URLs (default 50)."),
|
|
126
|
+
urls: z.array(z.string()).optional().describe("Quick verify mode: audit only these URLs (+ homepage and site-level checks)."),
|
|
127
|
+
include_bot_test: z.boolean().optional().describe("Test the homepage with each AI crawler user agent (default true)."),
|
|
128
|
+
preview_urls: z.array(z.string()).optional().describe("Preview/staging copies (e.g. https://proj.vercel.app) that must 301 to the main host or send noindex."),
|
|
129
|
+
},
|
|
130
|
+
annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false, idempotentHint: true },
|
|
131
|
+
}, async (args, extra) => {
|
|
132
|
+
try {
|
|
133
|
+
const root = await resolveRoot(ctx, args.project_root);
|
|
134
|
+
const token = extra?._meta?.progressToken;
|
|
135
|
+
const onProgress = token !== undefined
|
|
136
|
+
? (done, total, message) => {
|
|
137
|
+
void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: done, total, message } }).catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
: undefined;
|
|
140
|
+
const { run, markdown, jsonPath, writable } = await auditAndSave(root, args, onProgress);
|
|
141
|
+
const note = writable ? "" : `\n\n> Not saved: ${root} is not a project folder (pass project_root to keep the audit in .katman/).`;
|
|
142
|
+
return ok(markdown + note, auditSummary(run.result, topIssues(run.ctx, run.findings), jsonPath));
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
return fail(`katman_audit_site failed: ${errText(e)}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
server.registerTool("katman_indexnow", {
|
|
149
|
+
title: "Notify search engines of new/changed pages (IndexNow)",
|
|
150
|
+
description: "Tells Bing (→ Copilot, ChatGPT search, DuckDuckGo), Yandex, Seznam, Naver and Yep about new or changed URLs via IndexNow. Creates a 32-hex key (saved in .katman/config.json), checks https://<host>/<key>.txt serves it, and selects only new/changed URLs (sitemap lastmod, else a hash of the page text) against .katman/indexnow.json. dry_run (default true) sends nothing; dry_run:false POSTs to api.indexnow.org and records the URLs. Always returns the manual checklist for Google Search Console, Bing Webmaster Tools and Brave." + PRO_NOTE,
|
|
151
|
+
inputSchema: {
|
|
152
|
+
project_root: z.string().optional().describe("Absolute project path (where .katman/ lives)."),
|
|
153
|
+
site_url: z.string().optional().describe("Site, e.g. https://example.com (default: config siteUrl)."),
|
|
154
|
+
urls: z.array(z.string()).optional().describe("Submit exactly these URLs instead of diffing the sitemap."),
|
|
155
|
+
key: z.string().optional().describe("Existing IndexNow key (8–128 chars a-z A-Z 0-9 -); default: config or a new one."),
|
|
156
|
+
dry_run: z.boolean().optional().describe("Default true: plan only. false = submit to api.indexnow.org."),
|
|
157
|
+
},
|
|
158
|
+
annotations: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, idempotentHint: false },
|
|
159
|
+
}, withPro(ctx, "indexnow", async ({ project_root, site_url, urls, key, dry_run }) => {
|
|
160
|
+
try {
|
|
161
|
+
const root = await resolveRoot(ctx, project_root);
|
|
162
|
+
if (unsafeRoot(root))
|
|
163
|
+
return fail(`Pass project_root: ${root} is not a project folder (the key and records are saved in .katman/).`);
|
|
164
|
+
const config = await loadConfig(root);
|
|
165
|
+
const research = await loadResearch(root);
|
|
166
|
+
const siteUrl = site_url?.trim() || config.siteUrl || research?.site?.url;
|
|
167
|
+
if (!siteUrl)
|
|
168
|
+
return fail("No site URL: pass site_url (e.g. https://example.com).");
|
|
169
|
+
const scan = await readJson(root, "scan.json");
|
|
170
|
+
const run = await runIndexNow({ root, siteUrl, urls, key, dryRun: dry_run !== false, staticDir: scan?.staticDir });
|
|
171
|
+
await logEvent(root, `katman_indexnow ${run.host}: ${run.dryRun ? "dry run" : run.submitted ? `HTTP ${run.submitted.status}` : "nothing sent"} · ${run.candidates.length} new/changed`);
|
|
172
|
+
return ok(indexNowMarkdown(run), {
|
|
173
|
+
host: run.host,
|
|
174
|
+
keyFileOk: run.keyFile.ok,
|
|
175
|
+
keyLocation: run.keyLocation,
|
|
176
|
+
dryRun: run.dryRun,
|
|
177
|
+
toSubmit: run.candidates.length,
|
|
178
|
+
unchanged: run.unchanged,
|
|
179
|
+
submitted: run.submitted,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return fail(`katman_indexnow failed: ${errText(e)}`);
|
|
184
|
+
}
|
|
185
|
+
}));
|
|
186
|
+
server.registerTool("katman_keywords", {
|
|
187
|
+
title: "Google autocomplete keyword ideas",
|
|
188
|
+
description: "Expands a seed phrase with Google autocomplete (seed + a–z/0–9; ≤40 requests, 350 ms apart; network: suggestqueries.google.com only) into real search phrases, clustered, and marks single-question, situation and comparison page candidates (English and Turkish question words). Use during research to find the customer's own words. save:true merges them into .katman/research.json keywords (source \"autocomplete\"); otherwise nothing is written.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
seed: z.string().min(2).describe("Seed phrase in the customer's words, e.g. \"import invoices into accounting\"."),
|
|
191
|
+
lang: z.string().optional().describe("Interface language (hl), default en; tr uses the Turkish alphabet."),
|
|
192
|
+
country: z.string().optional().describe("Country (gl), default us."),
|
|
193
|
+
expand: z.boolean().optional().describe("Expand with letters/digits (default true); false = one request."),
|
|
194
|
+
save: z.boolean().optional().describe("Merge the candidates into research.json keywords."),
|
|
195
|
+
project_root: z.string().optional().describe("Absolute project path (needed with save)."),
|
|
196
|
+
},
|
|
197
|
+
annotations: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, idempotentHint: true },
|
|
198
|
+
}, async ({ seed, lang, country, expand, save, project_root }) => {
|
|
199
|
+
try {
|
|
200
|
+
const r = await runKeywords({ seed, lang, country, expand });
|
|
201
|
+
let saved;
|
|
202
|
+
if (save) {
|
|
203
|
+
const root = await resolveRoot(ctx, project_root);
|
|
204
|
+
const research = unsafeRoot(root) ? undefined : await loadResearch(root);
|
|
205
|
+
if (!research)
|
|
206
|
+
saved = { added: 0, note: "Not saved: no .katman/research.json yet — run katman_research (mode: template) first, then save again." };
|
|
207
|
+
else {
|
|
208
|
+
const added = mergeKeywords(research, termsToSave(r));
|
|
209
|
+
await saveResearch(root, research);
|
|
210
|
+
await logEvent(root, `katman_keywords "${r.seed}": ${added} keywords saved`);
|
|
211
|
+
saved = { added };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return ok(keywordsMarkdown(r, saved), {
|
|
215
|
+
seed: r.seed,
|
|
216
|
+
requests: r.requests,
|
|
217
|
+
suggestions: r.suggestions.length,
|
|
218
|
+
candidates: r.candidates,
|
|
219
|
+
clusters: r.clusters.slice(0, 20).map((c) => ({ key: c.key, count: c.items.length })),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
return fail(`katman_keywords failed: ${errText(e)}`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// katman_indexnow engine: key setup, key-file verification, "only new or changed URLs" selection (sitemap lastmod,
|
|
2
|
+
// else a hash of the page's visible text), submission to api.indexnow.org, and the manual checklist for Google,
|
|
3
|
+
// Bing and Brave (book ch. 13 + research/tech-web-indexing.md §3).
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { httpGet, httpPostJson, mapLimit } from "../../net.js";
|
|
6
|
+
import { loadConfig, readJson, saveConfig, writeJson } from "../../state.js";
|
|
7
|
+
import { mdTable, normalizeSiteUrl } from "../../util.js";
|
|
8
|
+
import { collapse, decodeEntities } from "./common/text.js";
|
|
9
|
+
import { safeUrl } from "./common/urls.js";
|
|
10
|
+
import { followRedirects } from "./audit/checks.js";
|
|
11
|
+
import { analyzeRobots } from "./audit/robots.js";
|
|
12
|
+
import { lastmodStats, loadSitemaps } from "./audit/sitemap.js";
|
|
13
|
+
export const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
14
|
+
const KEY_RE = /^[A-Za-z0-9-]{8,128}$/;
|
|
15
|
+
export function generateKey() {
|
|
16
|
+
return randomBytes(16).toString("hex");
|
|
17
|
+
}
|
|
18
|
+
/** Hash of the page's visible text: markup, scripts and build hashes don't count as "changed". */
|
|
19
|
+
export function textHash(html) {
|
|
20
|
+
const t = collapse(decodeEntities(html
|
|
21
|
+
.replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, " ")
|
|
22
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
23
|
+
.replace(/<[^>]+>/g, " ")));
|
|
24
|
+
return createHash("sha256").update(t).digest("hex").slice(0, 32);
|
|
25
|
+
}
|
|
26
|
+
export function responseMessage(status) {
|
|
27
|
+
switch (status) {
|
|
28
|
+
case 200:
|
|
29
|
+
return "OK: URLs submitted and the key is valid.";
|
|
30
|
+
case 202:
|
|
31
|
+
return "Accepted: IndexNow received the URLs; key validation is pending (usually minutes).";
|
|
32
|
+
case 400:
|
|
33
|
+
return "Bad request: invalid format (every URL must be an absolute URL on the host).";
|
|
34
|
+
case 403:
|
|
35
|
+
return "Forbidden: the key is not valid — the key file was not found at keyLocation or its content doesn't match the key.";
|
|
36
|
+
case 422:
|
|
37
|
+
return "Unprocessable: the URLs don't belong to the host, or the key doesn't match the protocol's format.";
|
|
38
|
+
case 429:
|
|
39
|
+
return "Too many requests: IndexNow suspects spam. Send only new or changed URLs and wait before retrying.";
|
|
40
|
+
case 0:
|
|
41
|
+
return "No response from api.indexnow.org (network error or timeout).";
|
|
42
|
+
default:
|
|
43
|
+
return `Unexpected HTTP ${status} from api.indexnow.org.`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function runIndexNow(o) {
|
|
47
|
+
const fetcher = o.fetcher ?? httpGet;
|
|
48
|
+
const post = o.post ?? ((u, d) => httpPostJson(u, d));
|
|
49
|
+
const now = o.now ?? new Date();
|
|
50
|
+
const dryRun = o.dryRun !== false;
|
|
51
|
+
const notes = [];
|
|
52
|
+
// Canonical origin (the key file and every URL must be on the same host)
|
|
53
|
+
let origin = normalizeSiteUrl(o.siteUrl);
|
|
54
|
+
const home = await followRedirects(origin + "/", fetcher, 5);
|
|
55
|
+
if (home.final.status > 0)
|
|
56
|
+
origin = new URL(home.chain[home.chain.length - 1].url).origin;
|
|
57
|
+
const host = new URL(origin).host;
|
|
58
|
+
// Key
|
|
59
|
+
const config = await loadConfig(o.root);
|
|
60
|
+
let key = (o.key ?? config.indexnowKey ?? "").trim();
|
|
61
|
+
let keyGenerated = false;
|
|
62
|
+
if (!key) {
|
|
63
|
+
key = generateKey();
|
|
64
|
+
keyGenerated = true;
|
|
65
|
+
}
|
|
66
|
+
if (!KEY_RE.test(key))
|
|
67
|
+
throw new Error(`Invalid IndexNow key "${key}": use 8–128 characters from a-z, A-Z, 0-9 and "-".`);
|
|
68
|
+
if (config.indexnowKey !== key) {
|
|
69
|
+
config.indexnowKey = key;
|
|
70
|
+
await saveConfig(o.root, config);
|
|
71
|
+
}
|
|
72
|
+
const keyLocation = `${origin}/${key}.txt`;
|
|
73
|
+
const kf = await fetcher(keyLocation, { redirect: "follow", headers: { accept: "text/plain,*/*" } });
|
|
74
|
+
const keyOk = kf.status === 200 && kf.body.trim() === key;
|
|
75
|
+
const keyFile = {
|
|
76
|
+
ok: keyOk,
|
|
77
|
+
status: kf.status,
|
|
78
|
+
detail: keyOk
|
|
79
|
+
? "serves the key"
|
|
80
|
+
: kf.status === 200
|
|
81
|
+
? /<html|<!doctype/i.test(kf.body)
|
|
82
|
+
? "answers with an HTML page (SPA fallback), not the key"
|
|
83
|
+
: "answers 200 but the content is not exactly the key"
|
|
84
|
+
: kf.status === 0
|
|
85
|
+
? `no response (${kf.error ?? "error"})`
|
|
86
|
+
: `HTTP ${kf.status}`,
|
|
87
|
+
};
|
|
88
|
+
// State
|
|
89
|
+
const stored = await readJson(o.root, "indexnow.json");
|
|
90
|
+
const state = stored ?? { version: 1, host, urls: {}, submissions: [] };
|
|
91
|
+
if (!stored && !o.urls?.length) {
|
|
92
|
+
notes.push("First Katman run for this host: every sitemap URL counts as new. If the site is already submitted by another script (or Cloudflare Crawler Hints), send only the pages you changed (pass urls) so IndexNow isn't flooded.");
|
|
93
|
+
}
|
|
94
|
+
if (state.host !== host) {
|
|
95
|
+
notes.push(`indexnow.json was recorded for ${state.host}; starting a fresh record for ${host}.`);
|
|
96
|
+
state.host = host;
|
|
97
|
+
state.urls = {};
|
|
98
|
+
}
|
|
99
|
+
// Candidate URLs
|
|
100
|
+
const dropped = [];
|
|
101
|
+
let entries = [];
|
|
102
|
+
let source = "sitemap";
|
|
103
|
+
if (o.urls?.length) {
|
|
104
|
+
source = "urls";
|
|
105
|
+
for (const raw of o.urls) {
|
|
106
|
+
const u = safeUrl(raw.trim(), origin);
|
|
107
|
+
if (!u || u.host !== host || !/^https?:$/.test(u.protocol))
|
|
108
|
+
dropped.push(raw);
|
|
109
|
+
else
|
|
110
|
+
entries.push({ loc: u.href, alternates: [] });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const rb = await followRedirects(origin + "/robots.txt", fetcher, 3);
|
|
115
|
+
const robots = analyzeRobots(rb.final.body, origin + "/robots.txt", rb.final.status, rb.final.headers["content-type"]);
|
|
116
|
+
const sm = await loadSitemaps(origin, robots.exists ? robots.sitemaps : [], fetcher);
|
|
117
|
+
for (const e of sm.entries) {
|
|
118
|
+
const u = safeUrl(e.loc);
|
|
119
|
+
if (!u || u.host !== host)
|
|
120
|
+
dropped.push(e.loc);
|
|
121
|
+
else
|
|
122
|
+
entries.push(e);
|
|
123
|
+
}
|
|
124
|
+
if (!sm.entries.length)
|
|
125
|
+
notes.push("No sitemap URLs found: pass urls, or publish a sitemap (P4).");
|
|
126
|
+
}
|
|
127
|
+
entries = [...new Map(entries.map((e) => [e.loc, e])).values()];
|
|
128
|
+
const stats = lastmodStats(entries, now);
|
|
129
|
+
const lastmodTrusted = stats.withLastmod > 0 && !stats.identical && !stats.stampedToday;
|
|
130
|
+
if (source === "sitemap" && stats.withLastmod && !lastmodTrusted) {
|
|
131
|
+
notes.push("The sitemap's lastmod values look stamped at build time (all the same / all today), so Katman compares the pages' text instead.");
|
|
132
|
+
}
|
|
133
|
+
const candidates = [];
|
|
134
|
+
let unchanged = 0;
|
|
135
|
+
const needHash = [];
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
const rec = state.urls[e.loc];
|
|
138
|
+
if (source === "urls") {
|
|
139
|
+
candidates.push({ url: e.loc, reason: rec ? "requested" : "new" });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!rec) {
|
|
143
|
+
candidates.push({ url: e.loc, reason: "new", lastmod: e.lastmod });
|
|
144
|
+
// Baseline hash for next time: only worth fetching when we are about to record the submission.
|
|
145
|
+
if (!dryRun && (!lastmodTrusted || !e.lastmod))
|
|
146
|
+
needHash.push(e);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (lastmodTrusted && e.lastmod && rec.lastmod) {
|
|
150
|
+
if (Date.parse(e.lastmod) > Date.parse(rec.lastmod))
|
|
151
|
+
candidates.push({ url: e.loc, reason: "lastmod changed", lastmod: e.lastmod });
|
|
152
|
+
else
|
|
153
|
+
unchanged++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
needHash.push(e);
|
|
157
|
+
}
|
|
158
|
+
const maxHash = o.maxHash ?? 200;
|
|
159
|
+
const toHash = needHash.slice(0, maxHash);
|
|
160
|
+
if (needHash.length > maxHash)
|
|
161
|
+
notes.push(`Only the first ${maxHash} pages were fetched to compare their text; the rest count as unchanged this time.`);
|
|
162
|
+
const hashes = await mapLimit(toHash, 4, async (e) => {
|
|
163
|
+
const r = await fetcher(e.loc, { redirect: "follow" });
|
|
164
|
+
return r.status === 200 ? textHash(r.body) : undefined;
|
|
165
|
+
});
|
|
166
|
+
const hashOf = new Map(toHash.map((e, i) => [e.loc, hashes[i]]));
|
|
167
|
+
for (const e of needHash) {
|
|
168
|
+
const rec = state.urls[e.loc];
|
|
169
|
+
const h = hashOf.get(e.loc);
|
|
170
|
+
const c = candidates.find((x) => x.url === e.loc);
|
|
171
|
+
if (c) {
|
|
172
|
+
c.hash = h;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (rec && h && rec.hash && h !== rec.hash)
|
|
176
|
+
candidates.push({ url: e.loc, reason: "text changed", hash: h, lastmod: e.lastmod });
|
|
177
|
+
else {
|
|
178
|
+
unchanged++;
|
|
179
|
+
if (rec && h && !rec.hash && !dryRun)
|
|
180
|
+
rec.hash = h; // baseline for next time
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const run = {
|
|
184
|
+
host, key, keyGenerated, keyLocation, keyFile, source, candidates, unchanged, dropped, lastmodTrusted, hashed: toHash.length, notes, dryRun,
|
|
185
|
+
staticDir: o.staticDir,
|
|
186
|
+
};
|
|
187
|
+
if (!dryRun && candidates.length) {
|
|
188
|
+
if (!keyOk) {
|
|
189
|
+
run.submitted = { status: -1, message: `Not submitted: ${keyLocation} ${keyFile.detail}. Publish the key file first.`, count: 0 };
|
|
190
|
+
return run;
|
|
191
|
+
}
|
|
192
|
+
let last = { status: 0, message: "", count: 0 };
|
|
193
|
+
for (let i = 0; i < candidates.length; i += 10_000) {
|
|
194
|
+
const batch = candidates.slice(i, i + 10_000);
|
|
195
|
+
const res = await post(INDEXNOW_ENDPOINT, { host, key, keyLocation, urlList: batch.map((c) => c.url) });
|
|
196
|
+
last = { status: res.status, message: responseMessage(res.status), count: batch.length };
|
|
197
|
+
state.submissions.push({ ts: now.toISOString(), count: batch.length, status: res.status, message: last.message });
|
|
198
|
+
if (res.status === 200 || res.status === 202) {
|
|
199
|
+
for (const c of batch)
|
|
200
|
+
state.urls[c.url] = { lastmod: c.lastmod ?? state.urls[c.url]?.lastmod, hash: c.hash ?? state.urls[c.url]?.hash, submittedAt: now.toISOString() };
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
state.submissions = state.submissions.slice(-50);
|
|
206
|
+
run.submitted = last;
|
|
207
|
+
}
|
|
208
|
+
if (!dryRun)
|
|
209
|
+
run.statePath = await writeJson(o.root, "indexnow.json", state);
|
|
210
|
+
return run;
|
|
211
|
+
}
|
|
212
|
+
export const MANUAL_CHECKLIST = [
|
|
213
|
+
"### Tell the search engines yourself (book ch. 13)",
|
|
214
|
+
"- [ ] **Google Search Console** (Google does not use IndexNow): add a **Domain property** verified by a DNS TXT record (covers every subdomain and protocol) → Sitemaps → submit `https://HOST/sitemap.xml` → URL Inspection → \"Request indexing\" for the important new pages (about 10 a day; if a page isn't indexed, find out why before resubmitting). Watch real AI citations in the **Generative AI performance report** (worldwide since 31 Aug 2026) — it feeds AI Overviews, AI Mode and Gemini.",
|
|
215
|
+
"- [ ] **Bing Webmaster Tools**: My Sites → Import from Search Console (or verify with a meta tag) → submit the sitemap → URL Submission for new URLs (about 100 a day). Bing's index feeds **Copilot, ChatGPT search and DuckDuckGo**. Its **AI Performance** report (Feb 2026) shows Copilot citations and grounding queries.",
|
|
216
|
+
"- [ ] **Brave Search**: no console, no sitemap submission, no IndexNow — use the re-fetch form at https://search.brave.com/submit-url for each new URL. **Claude's web search uses Brave.** Brave's crawler only fetches what robots.txt allows Googlebot; use noindex (not robots.txt) to keep a page out.",
|
|
217
|
+
"- [ ] **IndexNow** (this tool; reaches Bing, Yandex, Seznam, Naver, Yep): on every publish send only new or changed URLs — never the whole sitemap each time (the book's example site sent 173 URLs 20 times in 3 days).",
|
|
218
|
+
"- [ ] **Check the CDN once**: Cloudflare AI bot policies Search = Allow, Agent = Allow, Training ≠ Block (katman_audit_site tests this).",
|
|
219
|
+
"- [ ] **In 14 days**: Search Console → Pages. \"Discovered – currently not indexed\" usually means too few internal links; \"Crawled – currently not indexed\" means the page isn't distinct enough. Re-ask the question set (katman_visibility_run).",
|
|
220
|
+
];
|
|
221
|
+
export function indexNowMarkdown(r) {
|
|
222
|
+
const L = [];
|
|
223
|
+
L.push(`# IndexNow — ${r.host}${r.dryRun ? " (dry run: nothing sent)" : ""}`);
|
|
224
|
+
L.push("");
|
|
225
|
+
L.push(`Key: \`${r.key}\`${r.keyGenerated ? " (new — saved in .katman/config.json)" : ""}`);
|
|
226
|
+
if (r.keyFile.ok)
|
|
227
|
+
L.push(`Key file: ✓ ${r.keyLocation} ${r.keyFile.detail}`);
|
|
228
|
+
else {
|
|
229
|
+
L.push(`Key file: ✗ ${r.keyLocation} — ${r.keyFile.detail}. IndexNow rejects submissions (403) until this file serves exactly the key.`);
|
|
230
|
+
L.push(keyFileHelp(r.key, r.staticDir));
|
|
231
|
+
}
|
|
232
|
+
L.push("");
|
|
233
|
+
L.push(`Source: ${r.source === "urls" ? "the URLs you passed" : "the sitemap"} · ${r.candidates.length} to submit (${countBy(r.candidates.map((c) => c.reason))}) · ${r.unchanged} unchanged${r.hashed ? ` · ${r.hashed} page(s) fetched to compare text` : ""}${r.dropped.length ? ` · ${r.dropped.length} skipped (other host)` : ""}`);
|
|
234
|
+
if (r.candidates.length) {
|
|
235
|
+
L.push("");
|
|
236
|
+
L.push(mdTable(["URL", "Why"], r.candidates.slice(0, 50).map((c) => [c.url, c.reason + (c.lastmod ? ` (lastmod ${c.lastmod.slice(0, 10)})` : "")])));
|
|
237
|
+
if (r.candidates.length > 50)
|
|
238
|
+
L.push(`…and ${r.candidates.length - 50} more`);
|
|
239
|
+
}
|
|
240
|
+
for (const n of r.notes)
|
|
241
|
+
L.push(`- ${n}`);
|
|
242
|
+
if (r.dropped.length)
|
|
243
|
+
L.push(`- Skipped (IndexNow only accepts URLs on ${r.host}): ${r.dropped.slice(0, 5).join(", ")}`);
|
|
244
|
+
L.push("");
|
|
245
|
+
if (r.submitted) {
|
|
246
|
+
L.push(`**Submission:** ${r.submitted.status > 0 ? `HTTP ${r.submitted.status} — ` : ""}${r.submitted.message}${r.submitted.count ? ` (${r.submitted.count} URLs)` : ""}`);
|
|
247
|
+
if (r.statePath)
|
|
248
|
+
L.push(`Recorded in .katman/indexnow.json.`);
|
|
249
|
+
}
|
|
250
|
+
else if (r.dryRun) {
|
|
251
|
+
L.push(r.candidates.length
|
|
252
|
+
? `Next: ${r.keyFile.ok ? "" : "publish the key file, then "}run katman_indexnow with dry_run: false to send these ${r.candidates.length} URL(s) (POST ${INDEXNOW_ENDPOINT}).`
|
|
253
|
+
: "Nothing new or changed to send.");
|
|
254
|
+
}
|
|
255
|
+
else if (!r.candidates.length) {
|
|
256
|
+
L.push("Nothing new or changed: nothing sent.");
|
|
257
|
+
}
|
|
258
|
+
L.push("");
|
|
259
|
+
L.push(MANUAL_CHECKLIST.join("\n").replace(/HOST/g, r.host));
|
|
260
|
+
return L.join("\n");
|
|
261
|
+
}
|
|
262
|
+
export function keyFileHelp(key, staticDir) {
|
|
263
|
+
const dir = staticDir && staticDir !== "." ? `${staticDir}/` : staticDir === "." ? "" : "public/";
|
|
264
|
+
return `Publish the key file: create \`${dir}${key}.txt\` containing exactly \`${key}\` (no newline needed), deploy, then re-run. (katman_generate can write it: artifact "indexnow-key". If a Worker/server handles every path, add a route that returns the key as text/plain.)`;
|
|
265
|
+
}
|
|
266
|
+
function countBy(xs) {
|
|
267
|
+
const m = new Map();
|
|
268
|
+
for (const x of xs)
|
|
269
|
+
m.set(x, (m.get(x) ?? 0) + 1);
|
|
270
|
+
return [...m.entries()].map(([k, v]) => `${v} ${k}`).join(", ") || "none";
|
|
271
|
+
}
|