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.
Files changed (43) hide show
  1. package/README.md +56 -44
  2. package/dist/cli.js +27 -14
  3. package/dist/license.js +331 -98
  4. package/dist/meta.js +29 -7
  5. package/dist/plans.js +54 -0
  6. package/dist/tools/account/account.js +60 -30
  7. package/dist/tools/account/cli.js +5 -2
  8. package/dist/tools/account/index.js +7 -7
  9. package/dist/tools/account/sync.js +2 -2
  10. package/dist/tools/account/upsell.js +423 -118
  11. package/dist/tools/crawl/audit/report.js +2 -2
  12. package/dist/tools/crawl/cli.js +17 -5
  13. package/dist/tools/crawl/index.js +52 -18
  14. package/dist/tools/crawl/scan/framework.js +4 -1
  15. package/dist/tools/method/core/lint.js +15 -1
  16. package/dist/tools/method/core/prompt.js +2 -1
  17. package/dist/tools/method/core/start.js +1 -1
  18. package/dist/tools/method/data/_store.js +17 -0
  19. package/dist/tools/method/data/anatomy.js +12 -141
  20. package/dist/tools/method/data/appendices.js +22 -202
  21. package/dist/tools/method/data/cloudflare.js +28 -192
  22. package/dist/tools/method/data/evidence.js +18 -137
  23. package/dist/tools/method/data/geo-jobs.js +8 -128
  24. package/dist/tools/method/data/landing.js +14 -67
  25. package/dist/tools/method/data/lessons.js +16 -127
  26. package/dist/tools/method/data/measurement.js +38 -142
  27. package/dist/tools/method/data/offsite.js +16 -148
  28. package/dist/tools/method/data/pages.js +16 -167
  29. package/dist/tools/method/data/prompts.js +8 -633
  30. package/dist/tools/method/data/publish.js +14 -113
  31. package/dist/tools/method/data/research.js +14 -131
  32. package/dist/tools/method/data/roadmap.js +12 -105
  33. package/dist/tools/method/data/traps.js +6 -74
  34. package/dist/tools/method/data/verify.js +12 -88
  35. package/dist/tools/method/data/viral-list.js +12 -39
  36. package/dist/tools/method/index.js +27 -17
  37. package/dist/tools/method/lib/prompt-lookup.js +18 -0
  38. package/dist/tools/method/mcp/prompts.js +6 -6
  39. package/dist/tools/method/mcp/resources.js +21 -17
  40. package/dist/tools/visibility/index.js +10 -10
  41. package/dist/tools/visibility/monitoring.js +6 -3
  42. package/package.json +5 -3
  43. 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 (Okunmak)" : k === "quote" ? "Quote (Alıntılanmak)" : "Fit (Soruya uymak)",
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
  ]);
@@ -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 { auditAndSave, scanAndSave } from "./index.js";
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
- const { run, markdown } = await auditAndSave(root, {
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, katman_keywords.
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 { PRO_NOTE, withPro } from "../../license.js";
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
- export async function auditAndSave(root, a, onProgress, save = true) {
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 { run, markdown, jsonPath, writable } = await auditAndSave(root, args, onProgress);
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
- return ok(markdown + note, auditSummary(run.result, topIssues(run.ctx, run.findings), jsonPath));
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." + PRO_NOTE,
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
- }, withPro(ctx, "indexnow", async ({ project_root, site_url, urls, key, dry_run }) => {
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
- if (assetsDir)
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 after = seg.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 60);
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, getPrompt, promptPlaceholders } from "../data/prompts.js";
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, only when it is set) or when you call katman_sync. No telemetry.",
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
- export const ANATOMY_INTRO = {
2
- en: "A page is written for two readers at once: Google and the assistant. The invoice example below is invented; its structure is the same as the sample site's situation pages. Blocks tagged SEO are for the search engine, GEO blocks are for the assistant to quote you. Most serve both.",
3
- tr: "Bir sayfa iki okuyucuya birden yazılır: Google’a ve asistana. Aşağıdaki fatura örneği uydurmadır; yapı, örnek sitenin durum sayfalarıyla aynı. SEO etiketli bloklar arama motoru için, GEO etiketliler asistanın sizi alıntılaması için. Çoğu ikisine birden yarıyor.",
4
- };
5
- export const ANATOMY = [
6
- {
7
- n: 1,
8
- tags: ["SEO", "GEO"],
9
- name: { en: "<title> · meta description · canonical · og:image", tr: "<title> · meta description · canonical · og:image" },
10
- example: { en: "yoursite.com/import-pdf-invoices", tr: "siteniz.com/faturami-muhasebeye-aktar" },
11
- rule: {
12
- en: "Title: the situation first, then the brand, about 60 characters. Meta description: the sentence you want the assistant to say; Google mostly builds the snippet from the page text, so the same sentence must also be in block 3.",
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
+ });