katman-mcp 0.1.0 → 0.3.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/README.md +56 -44
- package/dist/cli.js +27 -14
- package/dist/license.js +331 -98
- package/dist/meta.js +29 -7
- package/dist/plans.js +54 -0
- package/dist/tools/account/account.js +60 -30
- package/dist/tools/account/cli.js +5 -2
- package/dist/tools/account/index.js +7 -7
- package/dist/tools/account/sync.js +2 -2
- package/dist/tools/account/upsell.js +423 -118
- package/dist/tools/crawl/audit/report.js +2 -2
- package/dist/tools/crawl/cli.js +17 -5
- package/dist/tools/crawl/index.js +52 -18
- package/dist/tools/crawl/scan/framework.js +4 -1
- package/dist/tools/method/core/lint.js +15 -1
- package/dist/tools/method/core/prompt.js +2 -1
- package/dist/tools/method/core/start.js +1 -1
- package/dist/tools/method/data/_store.js +17 -0
- package/dist/tools/method/data/anatomy.js +12 -141
- package/dist/tools/method/data/appendices.js +22 -202
- package/dist/tools/method/data/cloudflare.js +28 -192
- package/dist/tools/method/data/evidence.js +18 -137
- package/dist/tools/method/data/geo-jobs.js +8 -128
- package/dist/tools/method/data/landing.js +14 -67
- package/dist/tools/method/data/lessons.js +16 -127
- package/dist/tools/method/data/measurement.js +38 -142
- package/dist/tools/method/data/offsite.js +16 -148
- package/dist/tools/method/data/pages.js +16 -167
- package/dist/tools/method/data/prompts.js +8 -633
- package/dist/tools/method/data/publish.js +14 -113
- package/dist/tools/method/data/research.js +14 -131
- package/dist/tools/method/data/roadmap.js +12 -105
- package/dist/tools/method/data/traps.js +6 -74
- package/dist/tools/method/data/verify.js +12 -88
- package/dist/tools/method/data/viral-list.js +12 -39
- package/dist/tools/method/index.js +27 -17
- package/dist/tools/method/lib/prompt-lookup.js +18 -0
- package/dist/tools/method/mcp/prompts.js +6 -6
- package/dist/tools/method/mcp/resources.js +21 -17
- package/dist/tools/visibility/index.js +10 -10
- package/dist/tools/visibility/monitoring.js +6 -3
- package/package.json +5 -3
- package/server.json +4 -4
|
@@ -21,7 +21,7 @@ export function auditMarkdown(ctx, result, findings, opts = {}) {
|
|
|
21
21
|
// Gates
|
|
22
22
|
L.push("## Gates");
|
|
23
23
|
L.push(mdTable(["Gate", "Pass", "Main reasons"], ["read", "quote", "fit"].map((k) => [
|
|
24
|
-
k === "read" ? "Read
|
|
24
|
+
k === "read" ? "Read" : k === "quote" ? "Quote" : "Fit",
|
|
25
25
|
g[k].total ? `${g[k].pass}/${g[k].total}` : "–",
|
|
26
26
|
g[k].notes.slice(1, 4).join("; ") || (g[k].total ? "all pass" : ""),
|
|
27
27
|
])));
|
|
@@ -171,7 +171,7 @@ function nineCommands(ctx) {
|
|
|
171
171
|
"3",
|
|
172
172
|
"Title, description, canonical, robots, og:image",
|
|
173
173
|
h
|
|
174
|
-
? `title ${h.title ? `${h.title.length} chars ${Y}` : N} · description ${h.description ? `${h.description.length} chars ${Y}` : N} · canonical ${canon === undefined ? N : canon ? `self ${Y}` : `→ ${h.canonical} ${N}`} · robots ${h.metaRobots ?? "none"} ${home.noindex ? N : Y} · og:image ${h.og.image ? Y : N}`
|
|
174
|
+
? `title ${h.title ? `${h.title.length} chars ${Y}` : N} · description ${h.description ? `${h.description.length} chars ${h.description.length >= 70 && h.description.length <= 160 ? Y : `${N} (aim for 70–160)`}` : N} · canonical ${canon === undefined ? N : canon ? `self ${Y}` : `→ ${h.canonical} ${N}`} · robots ${h.metaRobots ?? "none"} ${home.noindex ? N : Y} · og:image ${h.og.image ? Y : N}`
|
|
175
175
|
: "—",
|
|
176
176
|
`curl -s ${o}/ | grep -oiE '<title>[^<]*|name="description" content="[^"]*|rel="canonical" href="[^"]*|name="robots" content="[^"]*|og:image" content="[^"]*'`,
|
|
177
177
|
]);
|
package/dist/tools/crawl/cli.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseArgs } from "../../cli.js";
|
|
4
4
|
import { loadConfig, loadResearch, logEvent, readJson, saveResearch } from "../../state.js";
|
|
5
|
-
import {
|
|
5
|
+
import { EXIT_NEEDS_PLAN, defaultLicense, meterAudit, statusOf } from "../../license.js";
|
|
6
|
+
import { auditCountLine, renderAuditRefusal } from "../account/upsell.js";
|
|
7
|
+
import { AuditRefused, auditAndSave, scanAndSave } from "./index.js";
|
|
6
8
|
import { indexNowMarkdown, runIndexNow } from "./indexnow.js";
|
|
7
9
|
import { keywordsMarkdown, mergeKeywords, runKeywords, termsToSave } from "./keywords.js";
|
|
8
10
|
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
@@ -25,20 +27,25 @@ export const commands = {
|
|
|
25
27
|
return fail(`scan failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
26
28
|
}
|
|
27
29
|
},
|
|
28
|
-
async audit(args) {
|
|
30
|
+
async audit(args, deps) {
|
|
29
31
|
const a = parseArgs(args);
|
|
30
32
|
const root = path.resolve(str(a.flags.root) ?? process.cwd());
|
|
31
33
|
const max = Number(str(a.flags.max) ?? NaN);
|
|
34
|
+
const provider = deps?.license ?? defaultLicense();
|
|
32
35
|
try {
|
|
33
|
-
|
|
36
|
+
// The plan check has passed (runCli); the audit is counted right before it runs.
|
|
37
|
+
const { run, markdown, use } = await auditAndSave(root, {
|
|
34
38
|
site_url: a._[0] ?? str(a.flags.site),
|
|
35
39
|
max_urls: Number.isFinite(max) ? max : undefined,
|
|
36
40
|
urls: list(a.flags.urls),
|
|
37
41
|
include_bot_test: a.flags["no-bots"] ? false : undefined,
|
|
38
42
|
preview_urls: list(a.flags.preview),
|
|
39
|
-
}, (done, total, message) => process.stderr.write(`\r[${done}/${total}] ${message} `), !a.flags["no-save"]);
|
|
43
|
+
}, (done, total, message) => process.stderr.write(`\r[${done}/${total}] ${message} `), !a.flags["no-save"], (domain) => meterAudit(provider, domain));
|
|
40
44
|
process.stderr.write("\n");
|
|
41
45
|
console.log(markdown);
|
|
46
|
+
const count = use ? auditCountLine(use) : undefined;
|
|
47
|
+
if (count)
|
|
48
|
+
console.error(count.replace(/^> /, "").replace(/\*\*/g, ""));
|
|
42
49
|
// --strict: non-zero exit when critical findings exist (for CI / a pre-publish script, book P14)
|
|
43
50
|
if (a.flags.strict && run.findings.some((f) => f.severity === "critical"))
|
|
44
51
|
return 2;
|
|
@@ -46,6 +53,11 @@ export const commands = {
|
|
|
46
53
|
}
|
|
47
54
|
catch (e) {
|
|
48
55
|
process.stderr.write("\n");
|
|
56
|
+
if (e instanceof AuditRefused) {
|
|
57
|
+
const status = await statusOf(provider);
|
|
58
|
+
console.error(renderAuditRefusal({ use: e.use, status, domain: e.domain, surface: "cli", command: "katman-mcp audit", example: ["katman-mcp", "audit", ...args].join(" ") }));
|
|
59
|
+
return EXIT_NEEDS_PLAN;
|
|
60
|
+
}
|
|
49
61
|
return fail(`audit failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
50
62
|
}
|
|
51
63
|
},
|
|
@@ -98,7 +110,7 @@ export const commands = {
|
|
|
98
110
|
export const help = [
|
|
99
111
|
" katman-mcp scan [path] [--no-save] scan the codebase → .katman/scan.json",
|
|
100
112
|
" katman-mcp audit <url> [--max 50] [--root .] [--urls u1,u2] [--preview u1] [--no-bots] [--strict] [--no-save]",
|
|
101
|
-
" audit the live site like AI crawlers see it (--strict: exit 2 on critical)",
|
|
113
|
+
" audit the live site like AI crawlers see it (--strict: exit 2 on critical); counts as one audit of the month",
|
|
102
114
|
" katman-mcp indexnow [--root .] [--site url] [--urls u1,u2] [--submit]",
|
|
103
115
|
" IndexNow key check + new/changed URLs (dry run unless --submit)",
|
|
104
116
|
' katman-mcp keywords "<seed>" [--lang en] [--country us] [--no-expand] [--save]',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Crawl module: katman_scan_code, katman_audit_site, katman_indexnow
|
|
1
|
+
// Crawl module: katman_scan_code, katman_audit_site, katman_keywords and katman_indexnow (Katman Pro). katman_audit_site is
|
|
2
|
+
// counted: it asks katman.pro right before the audit runs (license.ts meterAudit); see auditAndSave().
|
|
2
3
|
// Registration only; the engines live in scan/, audit/, indexnow.ts and keywords.ts (pure or fetcher-injected).
|
|
3
4
|
import { stat } from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
@@ -6,8 +7,9 @@ import path from "node:path";
|
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { resolveRoot, rootWarning } from "../../context.js";
|
|
8
9
|
import { loadConfig, loadResearch, logEvent, readJson, saveConfig, saveResearch, stamp, writeJson, writeText } from "../../state.js";
|
|
9
|
-
import { fail, ok } from "../../util.js";
|
|
10
|
-
import {
|
|
10
|
+
import { domainOf, fail, normalizeSiteUrl, ok } from "../../util.js";
|
|
11
|
+
import { licenseOf, meterAudit, statusOf, tierNote, withPlan } from "../../license.js";
|
|
12
|
+
import { auditCountLine, auditCountStructured, auditRefusalStructured, renderAuditRefusal } from "../account/upsell.js";
|
|
11
13
|
import { topIssues, auditMarkdown } from "./audit/report.js";
|
|
12
14
|
import { runAudit } from "./audit/index.js";
|
|
13
15
|
import { countBySeverity } from "./common/findings.js";
|
|
@@ -35,7 +37,22 @@ export async function scanAndSave(root, maxFiles, save = true) {
|
|
|
35
37
|
const markdown = scanMarkdown(run, { savedTo: save ? ".katman/scan.json" : undefined, warning });
|
|
36
38
|
return { run, markdown };
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
/** The audit was not run: this month's audits or sites are used up, or the plan or key stopped being valid. */
|
|
41
|
+
export class AuditRefused extends Error {
|
|
42
|
+
use;
|
|
43
|
+
domain;
|
|
44
|
+
constructor(use, domain) {
|
|
45
|
+
super(`audit refused for ${domain}: ${use.reason ?? "not allowed"}`);
|
|
46
|
+
this.use = use;
|
|
47
|
+
this.domain = domain;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Audit the live site and save the result. With a `meter` (the real tools and the CLI pass one) the audit is counted
|
|
52
|
+
* first, right before it runs and only once the URL is valid; a refusal throws AuditRefused and nothing is fetched.
|
|
53
|
+
* `use` in the result is the meter's answer (the count, or "couldn't be checked").
|
|
54
|
+
*/
|
|
55
|
+
export async function auditAndSave(root, a, onProgress, save = true, meter) {
|
|
39
56
|
const writable = save && !unsafeRoot(root);
|
|
40
57
|
const readable = !unsafeRoot(root);
|
|
41
58
|
const config = readable ? await loadConfig(root) : {};
|
|
@@ -44,6 +61,14 @@ export async function auditAndSave(root, a, onProgress, save = true) {
|
|
|
44
61
|
const siteUrl = a.site_url?.trim() || config.siteUrl || research?.site?.url;
|
|
45
62
|
if (!siteUrl)
|
|
46
63
|
throw new Error("No site URL: pass site_url (e.g. https://example.com), or save it first with katman_start { site_url }.");
|
|
64
|
+
let use;
|
|
65
|
+
if (meter) {
|
|
66
|
+
// An invalid URL throws here, before anything is counted.
|
|
67
|
+
const domain = domainOf(normalizeSiteUrl(siteUrl));
|
|
68
|
+
use = await meter(domain);
|
|
69
|
+
if (!use.allowed)
|
|
70
|
+
throw new AuditRefused(use, domain);
|
|
71
|
+
}
|
|
47
72
|
const run = await runAudit({
|
|
48
73
|
siteUrl,
|
|
49
74
|
maxUrls: a.max_urls ?? config.maxUrls ?? 50,
|
|
@@ -70,7 +95,7 @@ export async function auditAndSave(root, a, onProgress, save = true) {
|
|
|
70
95
|
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
96
|
}
|
|
72
97
|
const markdown = auditMarkdown(run.ctx, run.result, run.findings, { full: false, jsonPath, mdPath });
|
|
73
|
-
return { run, markdown, jsonPath, writable };
|
|
98
|
+
return { run, markdown, jsonPath, writable, use };
|
|
74
99
|
}
|
|
75
100
|
function auditSummary(result, top, jsonPath) {
|
|
76
101
|
return {
|
|
@@ -87,13 +112,14 @@ function auditSummary(result, top, jsonPath) {
|
|
|
87
112
|
export function registerCrawlTools(server, ctx) {
|
|
88
113
|
server.registerTool("katman_scan_code", {
|
|
89
114
|
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."
|
|
115
|
+
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." +
|
|
116
|
+
tierNote("scan"),
|
|
91
117
|
inputSchema: {
|
|
92
118
|
project_root: z.string().optional().describe("Absolute path of the website's codebase (default: KATMAN_PROJECT_ROOT, MCP roots, or cwd)."),
|
|
93
119
|
max_files: z.number().int().min(50).max(50000).optional().describe("Max files to list (default 5000)."),
|
|
94
120
|
},
|
|
95
121
|
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, idempotentHint: true },
|
|
96
|
-
}, async ({ project_root, max_files }) => {
|
|
122
|
+
}, withPlan(ctx, "scan", async ({ project_root, max_files }) => {
|
|
97
123
|
try {
|
|
98
124
|
const root = await resolveRoot(ctx, project_root);
|
|
99
125
|
if (unsafeRoot(root))
|
|
@@ -115,10 +141,11 @@ export function registerCrawlTools(server, ctx) {
|
|
|
115
141
|
catch (e) {
|
|
116
142
|
return fail(`katman_scan_code failed: ${errText(e)}`);
|
|
117
143
|
}
|
|
118
|
-
});
|
|
144
|
+
}));
|
|
119
145
|
server.registerTool("katman_audit_site", {
|
|
120
146
|
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."
|
|
147
|
+
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. Each run counts as one of the plan's audits for the calendar month (Katman Pro, 1 site: 5 audits and 1 site; 3 sites: 30 audits and 3 sites; katman.pro counts them); re-reading .katman/audit.md and every other tool don't count, so don't re-run it without a reason." +
|
|
148
|
+
tierNote("audit"),
|
|
122
149
|
inputSchema: {
|
|
123
150
|
site_url: z.string().optional().describe("Site to audit, e.g. https://example.com (default: .katman/config.json siteUrl or research site.url)."),
|
|
124
151
|
project_root: z.string().optional().describe("Absolute project path (where .katman/ lives)."),
|
|
@@ -128,7 +155,7 @@ export function registerCrawlTools(server, ctx) {
|
|
|
128
155
|
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
156
|
},
|
|
130
157
|
annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false, idempotentHint: true },
|
|
131
|
-
}, async (args, extra) => {
|
|
158
|
+
}, withPlan(ctx, "audit", async (args, extra) => {
|
|
132
159
|
try {
|
|
133
160
|
const root = await resolveRoot(ctx, args.project_root);
|
|
134
161
|
const token = extra?._meta?.progressToken;
|
|
@@ -137,17 +164,23 @@ export function registerCrawlTools(server, ctx) {
|
|
|
137
164
|
void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: done, total, message } }).catch(() => undefined);
|
|
138
165
|
}
|
|
139
166
|
: undefined;
|
|
140
|
-
const
|
|
167
|
+
const provider = licenseOf(ctx);
|
|
168
|
+
const { run, markdown, jsonPath, writable, use } = await auditAndSave(root, args, onProgress, true, (domain) => meterAudit(provider, domain));
|
|
141
169
|
const note = writable ? "" : `\n\n> Not saved: ${root} is not a project folder (pass project_root to keep the audit in .katman/).`;
|
|
142
|
-
|
|
170
|
+
const count = use ? auditCountLine(use) : undefined;
|
|
171
|
+
return ok(markdown + note + (count ? `\n\n${count}` : ""), { ...auditSummary(run.result, topIssues(run.ctx, run.findings), jsonPath), ...(use ? { auditCount: auditCountStructured(use) } : {}) });
|
|
143
172
|
}
|
|
144
173
|
catch (e) {
|
|
174
|
+
if (e instanceof AuditRefused) {
|
|
175
|
+
const input = { use: e.use, status: await statusOf(licenseOf(ctx)), domain: e.domain, surface: "mcp" };
|
|
176
|
+
return ok(renderAuditRefusal(input), auditRefusalStructured(input));
|
|
177
|
+
}
|
|
145
178
|
return fail(`katman_audit_site failed: ${errText(e)}`);
|
|
146
179
|
}
|
|
147
|
-
});
|
|
180
|
+
}));
|
|
148
181
|
server.registerTool("katman_indexnow", {
|
|
149
182
|
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." +
|
|
183
|
+
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." + tierNote("indexnow"),
|
|
151
184
|
inputSchema: {
|
|
152
185
|
project_root: z.string().optional().describe("Absolute project path (where .katman/ lives)."),
|
|
153
186
|
site_url: z.string().optional().describe("Site, e.g. https://example.com (default: config siteUrl)."),
|
|
@@ -156,7 +189,7 @@ export function registerCrawlTools(server, ctx) {
|
|
|
156
189
|
dry_run: z.boolean().optional().describe("Default true: plan only. false = submit to api.indexnow.org."),
|
|
157
190
|
},
|
|
158
191
|
annotations: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, idempotentHint: false },
|
|
159
|
-
},
|
|
192
|
+
}, withPlan(ctx, "indexnow", async ({ project_root, site_url, urls, key, dry_run }) => {
|
|
160
193
|
try {
|
|
161
194
|
const root = await resolveRoot(ctx, project_root);
|
|
162
195
|
if (unsafeRoot(root))
|
|
@@ -185,7 +218,8 @@ export function registerCrawlTools(server, ctx) {
|
|
|
185
218
|
}));
|
|
186
219
|
server.registerTool("katman_keywords", {
|
|
187
220
|
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."
|
|
221
|
+
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." +
|
|
222
|
+
tierNote("keywords"),
|
|
189
223
|
inputSchema: {
|
|
190
224
|
seed: z.string().min(2).describe("Seed phrase in the customer's words, e.g. \"import invoices into accounting\"."),
|
|
191
225
|
lang: z.string().optional().describe("Interface language (hl), default en; tr uses the Turkish alphabet."),
|
|
@@ -195,7 +229,7 @@ export function registerCrawlTools(server, ctx) {
|
|
|
195
229
|
project_root: z.string().optional().describe("Absolute project path (needed with save)."),
|
|
196
230
|
},
|
|
197
231
|
annotations: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, idempotentHint: true },
|
|
198
|
-
}, async ({ seed, lang, country, expand, save, project_root }) => {
|
|
232
|
+
}, withPlan(ctx, "keywords", async ({ seed, lang, country, expand, save, project_root }) => {
|
|
199
233
|
try {
|
|
200
234
|
const r = await runKeywords({ seed, lang, country, expand });
|
|
201
235
|
let saved;
|
|
@@ -222,5 +256,5 @@ export function registerCrawlTools(server, ctx) {
|
|
|
222
256
|
catch (e) {
|
|
223
257
|
return fail(`katman_keywords failed: ${errText(e)}`);
|
|
224
258
|
}
|
|
225
|
-
});
|
|
259
|
+
}));
|
|
226
260
|
}
|
|
@@ -593,7 +593,10 @@ function detectHosting(p, pkg, builder, wr, findings) {
|
|
|
593
593
|
}
|
|
594
594
|
function staticDirFor(framework, appDir, assetsDir, p) {
|
|
595
595
|
const pre = appDir ? `${appDir}/` : "";
|
|
596
|
-
|
|
596
|
+
// A Worker's assets directory is the folder that gets deployed. Without a build step it is the source folder; with a
|
|
597
|
+
// framework it is the build output (dist/, build/, Hugo's public/…), which the next build wipes, so files go to the
|
|
598
|
+
// framework's own static folder instead.
|
|
599
|
+
if (assetsDir && (framework === "custom-server" || framework === "static-html" || framework === "unknown"))
|
|
597
600
|
return assetsDir;
|
|
598
601
|
switch (framework) {
|
|
599
602
|
case "sveltekit":
|
|
@@ -19,6 +19,7 @@ const SOURCE_MARK_RE = /(source|according to|survey|study|report|data from|measu
|
|
|
19
19
|
const CLAIM_NUM_RE = /(\d+(?:[.,]\d+)?\s?%|%\s?\d+|\b\d+(?:[.,]\d+)?\s?[x×]\b|\b\d{1,3}(?:[.,]\d{3})+\+?|\b\d+\+?\s+(?:users|customers|people|visitors|downloads|companies|teams|businesses|orders|sites|reviews|clients|installs|creators|members|kullanıcı|müşteri|kişi|ziyaretçi|şirket|indirme|yorum|site|üye))/i;
|
|
20
20
|
const FREE_RE = /\b(free|for free|free of charge)\b|ücretsiz|bedava|\bgratis\b|kostenlos|gratuit/gi;
|
|
21
21
|
const CURRENCY_NEAR = /[$€£₺]|\b(usd|eur|gbp|try|tl)\b/i;
|
|
22
|
+
const OFFER_LIMIT_BEFORE = /(?:\b(?:first|next|up to|limited to|max(?:imum)?|only|at most)|\bilk|\ben fazla|\bsadece)\s*$/i;
|
|
22
23
|
function stripQuotes(s) {
|
|
23
24
|
return s.replace(/“[^”]*”|"[^"]*"|«[^»]*»|„[^“]*“/g, " ");
|
|
24
25
|
}
|
|
@@ -227,6 +228,9 @@ export function lintPage(x, c) {
|
|
|
227
228
|
continue;
|
|
228
229
|
if (CURRENCY_NEAR.test(s.slice(Math.max(0, (m.index ?? 0) - 3), (m.index ?? 0) + m[0].length + 3)))
|
|
229
230
|
continue;
|
|
231
|
+
// An offer's limit ("the first 100 customers", "up to 3 sites") is a condition, not a claim that needs a source.
|
|
232
|
+
if (OFFER_LIMIT_BEFORE.test(s.slice(Math.max(0, (m.index ?? 0) - 20), m.index ?? 0)))
|
|
233
|
+
continue;
|
|
230
234
|
if (SOURCE_MARK_RE.test(s) || (ss[i + 1] && SOURCE_MARK_RE.test(ss[i + 1])))
|
|
231
235
|
continue;
|
|
232
236
|
unsourced.push(`“${s.length > 140 ? s.slice(0, 137) + "…" : s}”`);
|
|
@@ -270,12 +274,22 @@ export function lintPage(x, c) {
|
|
|
270
274
|
// "free" next to a tool/resource word (free VAT calculator, free checker, free guide, ücretsiz araç) is a
|
|
271
275
|
// genuinely free side offer, which the method recommends (free tools are link magnets): not a violation.
|
|
272
276
|
const FREE_SIDE_OFFER = /^\W{0,3}(?:\p{L}+[\s-]+){0,3}?(?:tool|tools|finder|checker|calculator|counter|generator|guide|book|ebook|pdf|template|templates|course|webinar|audit|test|trial|plan|tier|version|download|araç|araçlar|araci|aracı|bulucu|hesaplayıcı|sayacı|rehber|kitap|şablon|deneme|sürüm|plan)\b/iu;
|
|
277
|
+
// The same side offer named before the word ("the checker and the guides are free", "AI crawler checker — Free",
|
|
278
|
+
// "check your site first, free"), in the same clause and without a contrast ("our tools are paid, but setup is free").
|
|
279
|
+
const FREE_SIDE_OFFER_BEFORE = /\b(?:tool|tools|finder|checker|checkers|check|calculator|calculators|counter|generator|guide|guides|template|templates|resource|resources|araç|araçlar|araci|aracı|bulucu|hesaplayıcı|sayacı|rehber|rehberler|şablon|kontrol)\b(?:(?!\b(?:but|however|while|whereas|although|except|ama|fakat|ancak)\b)[^.!?;])*$/iu;
|
|
273
280
|
const hits = [];
|
|
274
281
|
for (const seg of [...segments, stripQuotes(x.description ?? "")]) {
|
|
275
282
|
for (const m of seg.matchAll(FREE_RE)) {
|
|
276
|
-
const
|
|
283
|
+
const at = m.index ?? 0;
|
|
284
|
+
const after = seg.slice(at + m[0].length, at + m[0].length + 60);
|
|
277
285
|
if (FREE_SIDE_OFFER.test(after))
|
|
278
286
|
continue;
|
|
287
|
+
if (FREE_SIDE_OFFER_BEFORE.test(seg.slice(Math.max(0, at - 50), at)))
|
|
288
|
+
continue;
|
|
289
|
+
// A question ("Is it free?") asks, it doesn't promise; the answer is checked on its own.
|
|
290
|
+
const end = seg.slice(at).search(/[.!?](?:\s|$)/);
|
|
291
|
+
if (end >= 0 && seg[at + end] === "?")
|
|
292
|
+
continue;
|
|
279
293
|
if (hits.length < 4)
|
|
280
294
|
hits.push(snippet(seg, m.index ?? 0, m[0].length));
|
|
281
295
|
}
|
|
@@ -5,7 +5,8 @@ import { t } from "../lib/l10n.js";
|
|
|
5
5
|
import { appPathsText, knownPublicPaths, pathKnown } from "../lib/routes.js";
|
|
6
6
|
import { loadConfigSafe, loadLatestAudit, loadResearchSafe, loadScan, staticDirFor } from "../lib/state.js";
|
|
7
7
|
import { brandSentence, codeBlock, fillTemplate, hasNotX, isFilled, slugify } from "../lib/text.js";
|
|
8
|
-
import { PLACEHOLDERS, PROMPTS
|
|
8
|
+
import { PLACEHOLDERS, PROMPTS } from "../data/prompts.js";
|
|
9
|
+
import { getPrompt, promptPlaceholders } from "../lib/prompt-lookup.js";
|
|
9
10
|
import { detectLang } from "./questions.js";
|
|
10
11
|
export function siteUrlOf(r, cfg) {
|
|
11
12
|
if (r && isFilled(r.site.url) && /^https?:\/\//i.test(r.site.url))
|
|
@@ -17,7 +17,7 @@ export function firstRunText(root) {
|
|
|
17
17
|
"**First run in this project. What happens next:**",
|
|
18
18
|
`1. Katman keeps its notes in \`${root}/.katman/\` (safe to commit, never secrets). It writes nowhere else unless you call katman_generate or katman_setup_monitoring with write: true, and it never overwrites without overwrite: true.`,
|
|
19
19
|
"2. It reads your code (skipping node_modules, .git and build output; never .env*, keys or secrets) and, when you run the audit tools, your own live site.",
|
|
20
|
-
"3. Network only when a tool needs it: your site, openrouter.ai with YOUR key after a cost estimate (dry run first), api.indexnow.org on request, and katman.pro only to check KATMAN_KEY (at most once a day,
|
|
20
|
+
"3. Network only when a tool needs it: your site, openrouter.ai with YOUR key after a cost estimate (dry run first), api.indexnow.org on request, and katman.pro only to check KATMAN_KEY (at most once a day, and again when a plan ends) or when you call katman_sync. No telemetry.",
|
|
21
21
|
"4. The order: research (who you are, your customers' own sentences) → plan → build with prompts P0–P14 → verify, then re-measure every 14 days.",
|
|
22
22
|
"5. It never invents facts: unknowns stay [FILL] and you'll be asked.",
|
|
23
23
|
].join("\n");
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Where the method data comes from at run time.
|
|
2
|
+
// In the source tree (tests, `npm run dev`) every data module holds the method inline and METHOD_INLINE is true.
|
|
3
|
+
// The published package carries no method text: scripts/postbuild.mjs replaces each data module in dist/ with a stub
|
|
4
|
+
// that exports the same names, empty until katman.pro sends the data for a paid key (POST /api/web/mcp/method, see
|
|
5
|
+
// src/license.ts loadMethod), and sets METHOD_INLINE to false. ES module bindings are live, so every importer sees
|
|
6
|
+
// the values once applyMethod has run.
|
|
7
|
+
export const METHOD_INLINE = false;
|
|
8
|
+
const setters = [];
|
|
9
|
+
/** Called by each stub module to receive its exports. */
|
|
10
|
+
export function onMethod(set) {
|
|
11
|
+
setters.push(set);
|
|
12
|
+
}
|
|
13
|
+
/** Fills every stub module from the data katman.pro sent. */
|
|
14
|
+
export function applyMethod(data) {
|
|
15
|
+
for (const set of setters)
|
|
16
|
+
set(data);
|
|
17
|
+
}
|
|
@@ -1,141 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
tr: "Title önce durum, sonra marka, yaklaşık 60 karakter. Meta açıklama asistanın söylemesini istediğiniz cümle; Google snippet’i çoğunlukla sayfa metninden kurar, bu yüzden aynı cümle 3. blokta da olmalı.",
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
n: 2,
|
|
18
|
-
tags: ["SEO", "GEO"],
|
|
19
|
-
name: { en: "H1", tr: "H1" },
|
|
20
|
-
example: { en: "I type my invoices in by hand and it takes hours", tr: "Faturalarımı elle giriyorum, saatlerimi alıyor" },
|
|
21
|
-
rule: { en: "The H1 is the customer's own sentence. The URL uses the same words.", tr: "H1 müşterinin kendi cümlesi. Adres de aynı kelimelerle." },
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
n: 3,
|
|
25
|
-
tags: ["GEO"],
|
|
26
|
-
name: { en: "The first 1–3 sentences", tr: "İlk 1–3 cümle" },
|
|
27
|
-
example: {
|
|
28
|
-
en: "Upload your invoice. We move the line items into your accounting software in 30 seconds and flag the ones that don't match. Your document is never stored.",
|
|
29
|
-
tr: "Faturanı yükle. Kalemleri 30 saniyede muhasebe programına aktarırız, eşleşmeyenleri işaretleriz. Belgen saklanmaz.",
|
|
30
|
-
},
|
|
31
|
-
rule: {
|
|
32
|
-
en: "The most important block. The assistant takes a sentence for its answer from here: short, concrete, understandable on its own. ChatGPT repeated the sample site's sentence almost word for word. Keep the key claim within the H1 and the first ~200 characters.",
|
|
33
|
-
tr: "Sayfanın en önemli bloğu. Asistan cevabına buradan bir cümle alır: kısa, somut, tek başına anlaşılır. Örnek sitenin cümlesini ChatGPT neredeyse kelimesi kelimesine tekrar etti. Asıl iddiayı H1’de ve ilk ~200 karakterde tutun.",
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
n: 4,
|
|
38
|
-
tags: [],
|
|
39
|
-
name: { en: "Try it now / one clear action", tr: "Hemen deneme / tek net eylem" },
|
|
40
|
-
example: { en: "Drop your invoice · try it free", tr: "Faturanı bırak · ücretsiz dene" },
|
|
41
|
-
rule: {
|
|
42
|
-
en: "The visitor arrives with the situation they told ChatGPT. On the sample site we put a file drop box on the two pages ChatGPT sent people to; 52 of the first 68 generations in week one used the visitor's own file.",
|
|
43
|
-
tr: "Ziyaretçi ChatGPT’ye anlattığı durumla gelir. Örnek sitede ChatGPT’nin gönderdiği iki sayfaya dosya kutusu koyduk; ilk haftadaki 68 ilk üretimin 52’si ziyaretçinin kendi dosyasıyla yapıldı.",
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
n: 5,
|
|
48
|
-
tags: ["GEO"],
|
|
49
|
-
name: { en: "Proof: number + date", tr: "Kanıt: sayı + tarih" },
|
|
50
|
-
example: { en: "A verifiable number with its date", tr: "Doğrulanabilir sayı, tarihiyle" },
|
|
51
|
-
rule: {
|
|
52
|
-
en: "A verifiable number, with its date. Don't claim more than your proof: the assistant repeats it and a competitor refutes it.",
|
|
53
|
-
tr: "Doğrulanabilir sayı, tarihiyle. Kanıtından büyük iddia yazmayın; asistan tekrar eder, rakip çürütür.",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
n: 6,
|
|
58
|
-
tags: ["SEO", "GEO"],
|
|
59
|
-
name: { en: "How it works: 3–5 steps", tr: "Nasıl çalışır: 3–5 adım" },
|
|
60
|
-
example: { en: "3–5 plain-text steps", tr: "3–5 düz metin adım" },
|
|
61
|
-
rule: {
|
|
62
|
-
en: "Plain HTML text; a demo drawn with JavaScript is an empty page to a bot.",
|
|
63
|
-
tr: "Düz HTML metin; JavaScript ile çizilen bir demo bot için boş sayfadır.",
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
n: 7,
|
|
68
|
-
tags: ["GEO"],
|
|
69
|
-
name: { en: "Who it's not for", tr: "Kimler için değil" },
|
|
70
|
-
example: { en: "2–3 bullets", tr: "2–3 madde" },
|
|
71
|
-
rule: {
|
|
72
|
-
en: "Saying who it's not for and naming competitors honestly builds trust; assistants quote one-sided pages less.",
|
|
73
|
-
tr: "“Kime uygun değil” ve rakipleri dürüstçe saymak güven verir; asistan tek taraflı sayfayı daha az alıntılar.",
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
n: 8,
|
|
78
|
-
tags: ["GEO"],
|
|
79
|
-
name: { en: "Alternatives, honestly", tr: "Alternatifler, dürüstçe" },
|
|
80
|
-
example: { en: "Competitor: who it fits", tr: "Rakip: kime uygun" },
|
|
81
|
-
rule: {
|
|
82
|
-
en: "Name the alternatives and say whom each one fits; take competitor facts only from their own pages, with a date.",
|
|
83
|
-
tr: "Alternatifleri sayın, hangisinin kime uygun olduğunu yazın; rakip bilgisini yalnızca kendi sayfasından, tarihiyle alın.",
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
n: 9,
|
|
88
|
-
tags: ["GEO"],
|
|
89
|
-
name: { en: "FAQ: 5–8 questions", tr: "SSS: 5–8 soru" },
|
|
90
|
-
example: { en: "Questions in the customer's words", tr: "Müşterinin ağzından sorular" },
|
|
91
|
-
rule: {
|
|
92
|
-
en: "Questions in the customer's words, answers whose first sentence is the answer. Google no longer shows an FAQ rich result; this block is for the assistant.",
|
|
93
|
-
tr: "Müşterinin ağzından sorular, ilk cümlesi cevap olan yanıtlar. Google’da SSS zengin sonucu yok; bu blok asistan için.",
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
n: 10,
|
|
98
|
-
tags: ["SEO"],
|
|
99
|
-
name: { en: "Related pages", tr: "İlgili sayfalar" },
|
|
100
|
-
example: { en: "3–5 text links", tr: "3–5 metinli link" },
|
|
101
|
-
rule: {
|
|
102
|
-
en: "Internal links with descriptive text; every page gets a link from somewhere.",
|
|
103
|
-
tr: "Metinli iç linkler; her sayfa en az bir yerden link alsın.",
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
n: 11,
|
|
108
|
-
tags: ["SEO", "GEO"],
|
|
109
|
-
name: { en: "Updated date · author · JSON-LD", tr: "Güncellendi · yazar · JSON-LD" },
|
|
110
|
-
example: { en: "Updated: 29 Sep 2026 · Author · JSON-LD", tr: "Güncellendi: 29.09.2026 · Yazar · JSON-LD" },
|
|
111
|
-
rule: {
|
|
112
|
-
en: "A real update date (changed only when the text changes), the author, valid JSON-LD. In the sample site's first version all 85 pages carried the launch date; we fixed it.",
|
|
113
|
-
tr: "Gerçek güncelleme tarihi (yalnızca metin değişince), yazar, geçerli JSON-LD. Örnek sitede ilk sürümde 85 sayfanın hepsi yayın tarihini taşıyordu; düzelttik.",
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
];
|
|
117
|
-
/** The page brief the book tells you to start P6 with. */
|
|
118
|
-
export const PAGE_BRIEF_TEMPLATE = {
|
|
119
|
-
en: `Situation: [the customer's sentence]
|
|
120
|
-
URL: /[words] Title: [situation] — [brand]
|
|
121
|
-
First sentence: [brings] → [we do] → [they get]. [fear is unnecessary].
|
|
122
|
-
Action: [file drop / trial / pricing]
|
|
123
|
-
Proof: [number, source, date]
|
|
124
|
-
Not for: [2–3 bullets]
|
|
125
|
-
Alternatives: [competitor: who it fits]
|
|
126
|
-
FAQ: [5–8 questions, in the customer's words]
|
|
127
|
-
Related pages: [3–5 URLs]`,
|
|
128
|
-
tr: `Durum: [müşterinin cümlesi]
|
|
129
|
-
Adres: /[kelimeler] Title: [durum] — [marka]
|
|
130
|
-
İlk cümle: [getirir] → [yaparız] → [eline geçer]. [korku gereksiz].
|
|
131
|
-
Eylem: [dosya kutusu / deneme / fiyat]
|
|
132
|
-
Kanıt: [sayı, kaynak, tarih]
|
|
133
|
-
Kimler için değil: [2-3 madde]
|
|
134
|
-
Alternatifler: [rakip: kime uygun]
|
|
135
|
-
SSS: [5-8 soru, müşterinin ağzından]
|
|
136
|
-
İlgili sayfalar: [3-5 adres]`,
|
|
137
|
-
};
|
|
138
|
-
export const ANATOMY_EXERCISE = {
|
|
139
|
-
en: "Write your own four-part sentence (brings → we do → gets → fear is unnecessary). Put the same sentence in three places: the first paragraph of your most important page, that page's meta description, and llms.txt.",
|
|
140
|
-
tr: "Kendi dört parçalı cümlenizi yazın (getirir → yaparız → eline geçer → korku gereksiz). Aynı cümleyi üç yere koyun: en önemli sayfanın ilk paragrafı, o sayfanın meta açıklaması, llms.txt.",
|
|
141
|
-
};
|
|
1
|
+
// Filled from katman.pro at run time for a paid key (see _store.js); the package carries no method text.
|
|
2
|
+
import { onMethod } from "./_store.js";
|
|
3
|
+
export let ANATOMY = [];
|
|
4
|
+
export let ANATOMY_EXERCISE = {};
|
|
5
|
+
export let ANATOMY_INTRO = {};
|
|
6
|
+
export let PAGE_BRIEF_TEMPLATE = {};
|
|
7
|
+
onMethod((d) => {
|
|
8
|
+
if (d.ANATOMY !== undefined) ANATOMY = d.ANATOMY;
|
|
9
|
+
if (d.ANATOMY_EXERCISE !== undefined) ANATOMY_EXERCISE = d.ANATOMY_EXERCISE;
|
|
10
|
+
if (d.ANATOMY_INTRO !== undefined) ANATOMY_INTRO = d.ANATOMY_INTRO;
|
|
11
|
+
if (d.PAGE_BRIEF_TEMPLATE !== undefined) PAGE_BRIEF_TEMPLATE = d.PAGE_BRIEF_TEMPLATE;
|
|
12
|
+
});
|