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,504 @@
|
|
|
1
|
+
// katman_generate: artifacts built from research (the single source of truth). write:true writes static
|
|
2
|
+
// artifacts into the project's static dir (never overwriting unless overwrite:true); code snippets and JSON-LD
|
|
3
|
+
// go to .katman/generated/ because they must be merged into the codebase by hand.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
7
|
+
import { ROBOTS_TOKENS } from "../../../crawlers.js";
|
|
8
|
+
import { logEvent, saveConfig, writeText } from "../../../state.js";
|
|
9
|
+
import { domainOf } from "../../../util.js";
|
|
10
|
+
import { fwKind, suggestBotPlatform } from "../lib/framework.js";
|
|
11
|
+
import { appPrefixes, findPage, knownPublicPaths, noindexRoutes } from "../lib/routes.js";
|
|
12
|
+
import { insideRoot, joinRel, loadConfigSafe, loadLatestAudit, loadResearchSafe, loadScan, staticDirFor } from "../lib/state.js";
|
|
13
|
+
import { FILL, brandSentence, codeBlock, firstSentence, hasNotX, hasPlaceholder, isFilled, normPath } from "../lib/text.js";
|
|
14
|
+
import { analyticsChannelSnippet, botCounterSnippet } from "./snippets.js";
|
|
15
|
+
import { siteUrlOf, situationUrl } from "./prompt.js";
|
|
16
|
+
export const ARTIFACTS = [
|
|
17
|
+
"robots.txt", "llms.txt", "organization-jsonld", "website-jsonld", "software-jsonld", "faq-jsonld", "breadcrumb-jsonld",
|
|
18
|
+
"analytics-channel-snippet", "bot-counter-snippet", "indexnow-key",
|
|
19
|
+
];
|
|
20
|
+
const GEN_DIR = "generated";
|
|
21
|
+
function jsonLd(obj) {
|
|
22
|
+
return JSON.stringify(obj, null, 2);
|
|
23
|
+
}
|
|
24
|
+
function scriptTag(json) {
|
|
25
|
+
return `<script type="application/ld+json">\n${json}\n</script>`;
|
|
26
|
+
}
|
|
27
|
+
function abs(siteUrl, p) {
|
|
28
|
+
if (/^https?:\/\//i.test(p))
|
|
29
|
+
return p;
|
|
30
|
+
return `${siteUrl ?? `https://${FILL}`}${p.startsWith("/") ? "" : "/"}${p}`;
|
|
31
|
+
}
|
|
32
|
+
/** Prices in a free-text pricing line: "$9/month", "€12", "99 TL". */
|
|
33
|
+
export function parsePrices(text) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const sym = { $: "USD", "€": "EUR", "£": "GBP", "₺": "TRY" };
|
|
36
|
+
const codes = "USD|EUR|GBP|TRY|TL|CAD|AUD|CHF|JPY|INR|BRL";
|
|
37
|
+
const re = new RegExp(`([$€£₺])\\s?(\\d+(?:[.,]\\d{1,2})?)|(\\d+(?:[.,]\\d{1,2})?)\\s?([$€£₺]|${codes})\\b|\\b(${codes})\\s?(\\d+(?:[.,]\\d{1,2})?)`, "gi");
|
|
38
|
+
for (const m of text.matchAll(re)) {
|
|
39
|
+
const [, s1, n1, n2, s2, c3, n3] = m;
|
|
40
|
+
const n = n1 ?? n2 ?? n3;
|
|
41
|
+
const cur = s1 ? sym[s1] : s2 ? sym[s2] ?? s2.toUpperCase() : c3?.toUpperCase();
|
|
42
|
+
if (!n || !cur)
|
|
43
|
+
continue;
|
|
44
|
+
out.push({ price: Number(n.replace(",", ".")), currency: cur === "TL" ? "TRY" : cur });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// robots.txt (appendix A)
|
|
50
|
+
function sitemapUrl(ctx, o, siteUrl) {
|
|
51
|
+
if (o.sitemap_url)
|
|
52
|
+
return o.sitemap_url;
|
|
53
|
+
const fromAudit = ctx.audit?.site.robots.sitemaps.find((u) => /^https?:\/\//.test(u));
|
|
54
|
+
if (fromAudit)
|
|
55
|
+
return fromAudit;
|
|
56
|
+
const sm = ctx.scan?.files.sitemap;
|
|
57
|
+
if (sm?.exists && sm.path) {
|
|
58
|
+
const base = path.basename(sm.path);
|
|
59
|
+
if (/\.xml$/i.test(base))
|
|
60
|
+
return abs(siteUrl, `/${base}`);
|
|
61
|
+
if (fwKind(ctx.scan) === "astro")
|
|
62
|
+
return abs(siteUrl, "/sitemap-index.xml");
|
|
63
|
+
}
|
|
64
|
+
return abs(siteUrl, "/sitemap.xml");
|
|
65
|
+
}
|
|
66
|
+
export function genRobots(ctx, o) {
|
|
67
|
+
const siteUrl = siteUrlOf(ctx.research, ctx.config);
|
|
68
|
+
const ap = appPrefixes(ctx.scan, o.app_paths ?? []);
|
|
69
|
+
const training = o.training ?? "allow";
|
|
70
|
+
const blockTraining = training === "block";
|
|
71
|
+
const trainingOnly = new Set(["GPTBot", "ClaudeBot", "CCBot", "Bytespider", "Applebot-Extended", "meta-externalagent"]);
|
|
72
|
+
const rules = ["Allow: /"];
|
|
73
|
+
for (const p of ap.prefixes) {
|
|
74
|
+
if (p.startsWith("/*/"))
|
|
75
|
+
rules.push(`Disallow: ${p}/`);
|
|
76
|
+
else
|
|
77
|
+
rules.push(`Disallow: ${p}$`, `Disallow: ${p}/`);
|
|
78
|
+
}
|
|
79
|
+
const domain = siteUrl ? domainOf(siteUrl) : FILL;
|
|
80
|
+
const lines = [
|
|
81
|
+
`# robots.txt for ${domain} — generated by Katman (katman_generate) from .katman/research.json and scan.json.`,
|
|
82
|
+
blockTraining ? "# Open to search engines and AI assistants; AI training crawlers are not allowed." : "# Open to search engines, AI assistants and crawlers.",
|
|
83
|
+
`# Content signals: search=yes, ai-input=yes, ai-train=${blockTraining ? "no" : "yes"}`,
|
|
84
|
+
"# (Written as a comment: as a rule line, Lighthouse reports \"robots.txt is not valid\".)",
|
|
85
|
+
"# A bot that has its own group ignores the * group, so every group repeats the same rules.",
|
|
86
|
+
"# Login, sign-up and checkout pages aren't closed here: they carry <meta name=\"robots\" content=\"noindex,follow\"> instead.",
|
|
87
|
+
"",
|
|
88
|
+
"User-agent: *",
|
|
89
|
+
...rules,
|
|
90
|
+
"",
|
|
91
|
+
];
|
|
92
|
+
const tokens = [...ROBOTS_TOKENS, ...(blockTraining ? ["CCBot", "Bytespider"] : [])];
|
|
93
|
+
for (const tok of tokens) {
|
|
94
|
+
lines.push(`User-agent: ${tok}`);
|
|
95
|
+
if (blockTraining && trainingOnly.has(tok))
|
|
96
|
+
lines.push("Disallow: /");
|
|
97
|
+
else
|
|
98
|
+
lines.push(...rules);
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
lines.push(`Sitemap: ${sitemapUrl(ctx, o, siteUrl)}`);
|
|
102
|
+
const llmsKnown = !!(ctx.scan?.files.llms.exists || ctx.audit?.site.llmsTxt.exists);
|
|
103
|
+
lines.push(`# For AI assistants: ${abs(siteUrl, "/llms.txt")}`);
|
|
104
|
+
const notes = [];
|
|
105
|
+
const missing = [];
|
|
106
|
+
if (!siteUrl)
|
|
107
|
+
missing.push("site URL (katman_start with site_url) for the Sitemap line");
|
|
108
|
+
if (ap.defaulted)
|
|
109
|
+
notes.push("No scan yet, so the book's default app paths are closed (/app, /admin, /api, /dashboard). Run katman_scan_code, or pass options.app_paths.");
|
|
110
|
+
if (!ap.prefixes.length)
|
|
111
|
+
notes.push("The scan found no app or API routes, so only the site-wide Allow is written. Pass options.app_paths if the app lives under a path.");
|
|
112
|
+
if (ap.conflicts.length)
|
|
113
|
+
notes.push(`Not closed because public pages live under them: ${ap.conflicts.join(", ")} — close the specific app paths by hand (never a bare prefix).`);
|
|
114
|
+
const noindex = noindexRoutes(ctx.scan, ap.prefixes);
|
|
115
|
+
if (noindex.length)
|
|
116
|
+
notes.push(`Give these routes <meta name="robots" content="noindex,follow"> (and keep them out of the sitemap) instead of closing them here: ${noindex.join(", ")}.`);
|
|
117
|
+
notes.push("Each app path is closed twice (`/app$` and `/app/`): `/app/` alone leaves `/app` open, and a bare `/app` would also close `/apple`.");
|
|
118
|
+
if (blockTraining)
|
|
119
|
+
notes.push("Training bots are blocked by name; Google-Extended stays allowed because blocking it also removes you from Gemini's grounded answers (not from Search). Make the CDN say the same thing (ch. 6).");
|
|
120
|
+
if (!llmsKnown)
|
|
121
|
+
notes.push("The last comment points at /llms.txt: create it with katman_generate {\"artifact\":\"llms.txt\"} or remove the line.");
|
|
122
|
+
notes.push("robots.txt is a request; the CDN/firewall decides at the door. After deploying, katman_audit_site checks that every AI bot gets 200.");
|
|
123
|
+
let conflict;
|
|
124
|
+
if (ctx.scan?.files.robots.generated && ctx.scan.files.robots.path) {
|
|
125
|
+
conflict = `robots.txt is generated by ${ctx.scan.files.robots.path}; port these rules there instead of adding a static file (both would conflict).`;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
artifact: "robots.txt",
|
|
129
|
+
content: lines.join("\n") + "\n",
|
|
130
|
+
target: joinRel(ctx.staticDir, "robots.txt"),
|
|
131
|
+
targetKind: "static",
|
|
132
|
+
fence: "text",
|
|
133
|
+
notes,
|
|
134
|
+
missing,
|
|
135
|
+
...(conflict ? { conflict } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// llms.txt (appendix B)
|
|
140
|
+
export function genLlms(ctx) {
|
|
141
|
+
const r = ctx.research;
|
|
142
|
+
const siteUrl = siteUrlOf(r, ctx.config);
|
|
143
|
+
const tr = (r?.site.languages[0] ?? "en").toLowerCase().startsWith("tr");
|
|
144
|
+
const L = (en, trs) => (tr ? trs : en);
|
|
145
|
+
const missing = [];
|
|
146
|
+
const brand = isFilled(r?.brand.name) ? r.brand.name.trim() : (missing.push("brand.name"), FILL);
|
|
147
|
+
const sentence = brandSentence(r) ?? (missing.push("the one sentence (brand.oneLiner or brand.sentence)"), FILL);
|
|
148
|
+
const pricing = isFilled(r?.brand.pricing) ? r.brand.pricing.trim() : (missing.push("brand.pricing"), FILL);
|
|
149
|
+
const known = knownPublicPaths(ctx.scan, ctx.audit);
|
|
150
|
+
const pages = new Map((ctx.audit?.pages ?? []).map((p) => [normPath(p.url), p]));
|
|
151
|
+
const lines = [`# ${brand}`, "", `> ${sentence}`];
|
|
152
|
+
if (isFilled(r?.brand.company))
|
|
153
|
+
lines.push(`> ${L("Operated by", "İşleten")} ${r.brand.company}${isFilled(r?.brand.country) ? ` (${r.brand.country})` : ""}.`);
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push(`${L("Key facts", "Temel bilgiler")}: ${pricing}${/[.!?]$/.test(pricing) ? "" : "."}`);
|
|
156
|
+
if (hasNotX(r?.brand.notX))
|
|
157
|
+
lines.push(`${L("Not to be confused with", "Karıştırılmasın")}: ${r.brand.notX.trim()}`);
|
|
158
|
+
const proof = (r?.proof ?? []).filter((p) => isFilled(p.source));
|
|
159
|
+
if (proof.length)
|
|
160
|
+
lines.push(`${L("Evidence (and its limits)", "Kanıt (ve sınırı)")}: ${proof.map((p) => `${p.claim}${p.value ? `: ${p.value}` : ""} (${p.source}${p.date ? `, ${p.date}` : ""})`).join("; ")}.`);
|
|
161
|
+
if ((r?.site.languages.length ?? 0) > 1)
|
|
162
|
+
lines.push(`${L("Languages", "Diller")}: ${r.site.languages.join(", ")}`);
|
|
163
|
+
lines.push("", `## ${L("Product", "Ürün")}`);
|
|
164
|
+
lines.push(`- [${L("Home", "Ana sayfa")}](${abs(siteUrl, "/")}): ${isFilled(r?.brand.category) ? r.brand.category : L("what it is", "ne olduğu")}`);
|
|
165
|
+
const pricingPage = findPage(known, "pricing");
|
|
166
|
+
if (pricingPage)
|
|
167
|
+
lines.push(`- [${L("Pricing", "Fiyatlar")}](${abs(siteUrl, pricingPage.path)}): ${L("plans and prices", "planlar ve fiyatlar")}`);
|
|
168
|
+
let liveSituations = 0;
|
|
169
|
+
for (const s of r?.situations ?? []) {
|
|
170
|
+
const url = situationUrl(s, r?.site.languages);
|
|
171
|
+
const live = s.status === "live" || known.get(normPath(url)) === "live";
|
|
172
|
+
if (!live)
|
|
173
|
+
continue;
|
|
174
|
+
const p = pages.get(normPath(url)) ?? pages.get(normPath(abs(siteUrl, url)));
|
|
175
|
+
const desc = p?.firstParagraph ? firstSentence(p.firstParagraph) : undefined;
|
|
176
|
+
lines.push(`- [${s.sentence.replace(/[[\]]/g, "")}](${abs(siteUrl, url)})${desc ? `: ${desc}` : ""}`);
|
|
177
|
+
liveSituations++;
|
|
178
|
+
if (liveSituations >= 20)
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const section = (title, key) => {
|
|
182
|
+
const items = [];
|
|
183
|
+
for (const [p, src] of known) {
|
|
184
|
+
if (src !== "live" && src !== "scan")
|
|
185
|
+
continue;
|
|
186
|
+
const re = key === "guide" ? /\/(guides?|rehber\w*|blog|learn|resources)\/[^/]+/ : /(\/(compare|alternatives?|vs|karsilastirma|alternatif\w*)\/[^/]+)|-vs-/;
|
|
187
|
+
if (!re.test(p) || /[[:*$]/.test(p))
|
|
188
|
+
continue;
|
|
189
|
+
const title2 = pages.get(p)?.title ?? p;
|
|
190
|
+
items.push(`- [${title2.replace(/[[\]]/g, "")}](${abs(siteUrl, p)})`);
|
|
191
|
+
if (items.length >= 10)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (items.length)
|
|
195
|
+
lines.push("", `## ${title}`, ...items);
|
|
196
|
+
};
|
|
197
|
+
section(L("Guides", "Rehberler"), "guide");
|
|
198
|
+
section(L("Comparisons", "Karşılaştırmalar"), "comparison");
|
|
199
|
+
const legal = [findPage(known, "terms"), findPage(known, "privacy"), findPage(known, "about"), findPage(known, "faq")].filter(Boolean);
|
|
200
|
+
if (legal.length) {
|
|
201
|
+
lines.push("", "## Optional");
|
|
202
|
+
for (const l of legal)
|
|
203
|
+
lines.push(`- [${l.path}](${abs(siteUrl, l.path)})`);
|
|
204
|
+
}
|
|
205
|
+
const notes = [];
|
|
206
|
+
if (!siteUrl)
|
|
207
|
+
missing.push("site URL (katman_start with site_url)");
|
|
208
|
+
if (!liveSituations)
|
|
209
|
+
notes.push("No live situation pages yet (research status \"live\" or a 200 in the latest audit): add each one here after it ships (P6).");
|
|
210
|
+
notes.push("Every price and claim here must match the pages word for word (P8). Keep it short: the sample site's llms.txt grew to 65 KB and had to be trimmed.");
|
|
211
|
+
notes.push("llms.txt is hygiene, not a lever (no citation effect across 300k domains): ship it after rendering (P2) is fixed.");
|
|
212
|
+
if (!tr && (r?.site.languages[0] ?? "en").toLowerCase().split("-")[0] !== "en")
|
|
213
|
+
notes.push("Link labels are in English; translate them to the site's language.");
|
|
214
|
+
let conflict;
|
|
215
|
+
if (ctx.scan?.files.llms.generated && ctx.scan.files.llms.path)
|
|
216
|
+
conflict = `llms.txt is generated by ${ctx.scan.files.llms.path}; update that generator instead.`;
|
|
217
|
+
return {
|
|
218
|
+
artifact: "llms.txt",
|
|
219
|
+
content: lines.join("\n") + "\n",
|
|
220
|
+
target: joinRel(ctx.staticDir, "llms.txt"),
|
|
221
|
+
targetKind: "static",
|
|
222
|
+
fence: "md",
|
|
223
|
+
notes,
|
|
224
|
+
missing,
|
|
225
|
+
...(conflict ? { conflict } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// JSON-LD (appendix C)
|
|
230
|
+
function genOrganization(ctx, o) {
|
|
231
|
+
const r = ctx.research;
|
|
232
|
+
const siteUrl = siteUrlOf(r, ctx.config);
|
|
233
|
+
const missing = [];
|
|
234
|
+
const notes = [];
|
|
235
|
+
const name = isFilled(r?.brand.name) ? r.brand.name.trim() : (missing.push("brand.name"), FILL);
|
|
236
|
+
const obj = {
|
|
237
|
+
"@context": "https://schema.org",
|
|
238
|
+
"@type": "Organization",
|
|
239
|
+
"@id": `${siteUrl ?? `https://${FILL}`}/#org`,
|
|
240
|
+
name,
|
|
241
|
+
};
|
|
242
|
+
if (r?.brand.alternateNames?.length)
|
|
243
|
+
obj.alternateName = r.brand.alternateNames;
|
|
244
|
+
const sentence = brandSentence(r);
|
|
245
|
+
if (sentence)
|
|
246
|
+
obj.description = sentence;
|
|
247
|
+
if (hasNotX(r?.brand.notX))
|
|
248
|
+
obj.disambiguatingDescription = r.brand.notX.trim();
|
|
249
|
+
obj.url = siteUrl ?? (missing.push("site URL"), `https://${FILL}`);
|
|
250
|
+
obj.logo = o.logo ? abs(siteUrl, o.logo) : (missing.push("logo (options.logo: an absolute or root-relative URL of a square logo)"), `${siteUrl ?? `https://${FILL}`}/${FILL}-logo-512.png`);
|
|
251
|
+
if (r?.brand.socials?.length)
|
|
252
|
+
obj.sameAs = r.brand.socials;
|
|
253
|
+
if (isFilled(r?.brand.company) && r.brand.company.trim().toLowerCase() !== name.toLowerCase())
|
|
254
|
+
obj.parentOrganization = { "@type": "Organization", name: r.brand.company.trim() };
|
|
255
|
+
if (isFilled(r?.brand.country))
|
|
256
|
+
obj.address = { "@type": "PostalAddress", addressCountry: r.brand.country.trim() };
|
|
257
|
+
if (!hasNotX(r?.brand.notX))
|
|
258
|
+
notes.push("No not-X note in research, so there's no disambiguatingDescription. Add brand.notX if assistants confuse the brand with something (ask \"What is <brand>?\").");
|
|
259
|
+
if (name !== FILL && !/\s/.test(name) && hasNotX(r?.brand.notX))
|
|
260
|
+
notes.push("The book used a distinctive two-word name (\"[Brand] [Category]\") with the short name in alternateName to end the confusion.");
|
|
261
|
+
notes.push("Put it in the site-wide layout (every page). Everything here must also be visible on the site.");
|
|
262
|
+
const json = jsonLd(obj);
|
|
263
|
+
return { artifact: "organization-jsonld", content: json, embed: scriptTag(json), target: `.katman/${GEN_DIR}/organization.jsonld.json`, targetKind: "katman", fence: "json", notes, missing };
|
|
264
|
+
}
|
|
265
|
+
function genWebsite(ctx) {
|
|
266
|
+
const r = ctx.research;
|
|
267
|
+
const siteUrl = siteUrlOf(r, ctx.config);
|
|
268
|
+
const missing = [];
|
|
269
|
+
const obj = {
|
|
270
|
+
"@context": "https://schema.org",
|
|
271
|
+
"@type": "WebSite",
|
|
272
|
+
"@id": `${siteUrl ?? `https://${FILL}`}/#website`,
|
|
273
|
+
url: siteUrl ?? (missing.push("site URL"), `https://${FILL}`),
|
|
274
|
+
name: isFilled(r?.brand.name) ? r.brand.name.trim() : (missing.push("brand.name"), FILL),
|
|
275
|
+
publisher: { "@id": `${siteUrl ?? `https://${FILL}`}/#org` },
|
|
276
|
+
};
|
|
277
|
+
if (r?.site.languages.length)
|
|
278
|
+
obj.inLanguage = r.site.languages.length === 1 ? r.site.languages[0] : r.site.languages;
|
|
279
|
+
const json = jsonLd(obj);
|
|
280
|
+
return { artifact: "website-jsonld", content: json, embed: scriptTag(json), target: `.katman/${GEN_DIR}/website.jsonld.json`, targetKind: "katman", fence: "json", notes: ["Site-wide, next to Organization (publisher points at its @id)."], missing };
|
|
281
|
+
}
|
|
282
|
+
function genSoftware(ctx, o) {
|
|
283
|
+
const r = ctx.research;
|
|
284
|
+
const siteUrl = siteUrlOf(r, ctx.config);
|
|
285
|
+
const missing = [];
|
|
286
|
+
const notes = [];
|
|
287
|
+
const obj = {
|
|
288
|
+
"@context": "https://schema.org",
|
|
289
|
+
"@type": o.type ?? "WebApplication",
|
|
290
|
+
name: isFilled(r?.brand.name) ? r.brand.name.trim() : (missing.push("brand.name"), FILL),
|
|
291
|
+
url: o.page_url ? abs(siteUrl, o.page_url) : siteUrl ?? (missing.push("site URL"), `https://${FILL}`),
|
|
292
|
+
};
|
|
293
|
+
const sentence = brandSentence(r);
|
|
294
|
+
if (sentence)
|
|
295
|
+
obj.description = sentence;
|
|
296
|
+
if (o.application_category)
|
|
297
|
+
obj.applicationCategory = o.application_category;
|
|
298
|
+
else
|
|
299
|
+
notes.push("No applicationCategory: pass options.application_category (e.g. \"MultimediaApplication\", \"BusinessApplication\") if you want it.");
|
|
300
|
+
obj.publisher = { "@id": `${siteUrl ?? `https://${FILL}`}/#org` };
|
|
301
|
+
const pricing = isFilled(r?.brand.pricing) ? r.brand.pricing : undefined;
|
|
302
|
+
if (pricing) {
|
|
303
|
+
const prices = parsePrices(pricing);
|
|
304
|
+
const free = /\bfree\b|ücretsiz|gratis/i.test(pricing);
|
|
305
|
+
const currency = prices[0]?.currency;
|
|
306
|
+
const sameCurrency = prices.every((p) => p.currency === currency);
|
|
307
|
+
if (prices.length === 1)
|
|
308
|
+
obj.offers = { "@type": "Offer", price: String(prices[0].price), priceCurrency: prices[0].currency };
|
|
309
|
+
else if (prices.length > 1 && sameCurrency) {
|
|
310
|
+
const vals = prices.map((p) => p.price).concat(free ? [0] : []);
|
|
311
|
+
obj.offers = { "@type": "AggregateOffer", lowPrice: String(Math.min(...vals)), highPrice: String(Math.max(...vals)), priceCurrency: currency, offerCount: String(vals.length) };
|
|
312
|
+
}
|
|
313
|
+
else if (!prices.length && free)
|
|
314
|
+
obj.offers = { "@type": "Offer", price: "0", priceCurrency: "USD" };
|
|
315
|
+
if (obj.offers)
|
|
316
|
+
notes.push(`offers come from research pricing ("${pricing}") and must match the pricing page exactly; fix research if they don't.`);
|
|
317
|
+
else
|
|
318
|
+
notes.push(`Couldn't read prices from research pricing ("${pricing}"), so there are no offers. Add them by hand exactly as the pricing page shows them.`);
|
|
319
|
+
}
|
|
320
|
+
else
|
|
321
|
+
notes.push("No pricing in research, so no offers (never invent a price). Fill brand.pricing, then regenerate.");
|
|
322
|
+
notes.push("Use it on the product/pricing page; only information visible on that page.");
|
|
323
|
+
const json = jsonLd(obj);
|
|
324
|
+
return { artifact: "software-jsonld", content: json, embed: scriptTag(json), target: `.katman/${GEN_DIR}/software.jsonld.json`, targetKind: "katman", fence: "json", notes, missing };
|
|
325
|
+
}
|
|
326
|
+
function genFaq(ctx, o) {
|
|
327
|
+
const missing = [];
|
|
328
|
+
const faq = o.faq?.filter((x) => isFilled(x.q) && isFilled(x.a)) ?? [];
|
|
329
|
+
if (!faq.length)
|
|
330
|
+
missing.push("options.faq: [{q, a}] — the exact questions and answers visible on the page");
|
|
331
|
+
const items = faq.length ? faq : [{ q: `${FILL} question as shown on the page`, a: `${FILL} answer as shown on the page` }];
|
|
332
|
+
const obj = {
|
|
333
|
+
"@context": "https://schema.org",
|
|
334
|
+
"@type": "FAQPage",
|
|
335
|
+
mainEntity: items.map((x) => ({ "@type": "Question", name: x.q.trim(), acceptedAnswer: { "@type": "Answer", text: x.a.trim() } })),
|
|
336
|
+
};
|
|
337
|
+
const notes = ["The questions and answers must be word for word the ones visible on the page. Google retired the FAQ rich result in May 2026: this is for assistants."];
|
|
338
|
+
if (faq.length && (faq.length < 5 || faq.length > 8))
|
|
339
|
+
notes.push(`${faq.length} questions: the book's situation pages use 5–8.`);
|
|
340
|
+
const json = jsonLd(obj);
|
|
341
|
+
return { artifact: "faq-jsonld", content: json, embed: scriptTag(json), target: `.katman/${GEN_DIR}/faq.jsonld.json`, targetKind: "katman", fence: "json", notes, missing };
|
|
342
|
+
}
|
|
343
|
+
function genBreadcrumb(ctx, o) {
|
|
344
|
+
const siteUrl = siteUrlOf(ctx.research, ctx.config);
|
|
345
|
+
const missing = [];
|
|
346
|
+
const items = o.items?.filter((x) => isFilled(x.name) && isFilled(x.url)) ?? [];
|
|
347
|
+
if (!items.length)
|
|
348
|
+
missing.push("options.items: [{name, url}] from Home to the current page");
|
|
349
|
+
if (!siteUrl)
|
|
350
|
+
missing.push("site URL");
|
|
351
|
+
const list = items.length ? items : [{ name: "Home", url: "/" }, { name: `${FILL} page name`, url: `/${FILL}` }];
|
|
352
|
+
const obj = {
|
|
353
|
+
"@context": "https://schema.org",
|
|
354
|
+
"@type": "BreadcrumbList",
|
|
355
|
+
itemListElement: list.map((x, i) => ({ "@type": "ListItem", position: i + 1, name: x.name, item: abs(siteUrl, x.url) })),
|
|
356
|
+
};
|
|
357
|
+
const json = jsonLd(obj);
|
|
358
|
+
return { artifact: "breadcrumb-jsonld", content: json, embed: scriptTag(json), target: `.katman/${GEN_DIR}/breadcrumb.jsonld.json`, targetKind: "katman", fence: "json", notes: ["Generate it per page from the same route data as the visible breadcrumb."], missing };
|
|
359
|
+
}
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// snippets + IndexNow key
|
|
362
|
+
function detectTarget(scan) {
|
|
363
|
+
const p = (scan?.analytics.providers ?? []).map((x) => x.toLowerCase()).join(" ");
|
|
364
|
+
if (/gtm|tag ?manager/.test(p))
|
|
365
|
+
return "gtm";
|
|
366
|
+
if (/ga4|gtag|google/.test(p))
|
|
367
|
+
return "gtag";
|
|
368
|
+
if (/plausible/.test(p))
|
|
369
|
+
return "plausible";
|
|
370
|
+
if (/posthog/.test(p))
|
|
371
|
+
return "posthog";
|
|
372
|
+
return "auto";
|
|
373
|
+
}
|
|
374
|
+
function genAnalytics(ctx, o) {
|
|
375
|
+
const target = o.target ?? detectTarget(ctx.scan);
|
|
376
|
+
const notes = [
|
|
377
|
+
`Push target: ${target}${o.target ? "" : " (from the scan; pass options.target to change)"}. Events: katman_session (once per session, with katman_channel/katman_source/katman_landing) and katman_human (once the session passes the human filter).`,
|
|
378
|
+
`Add <script src="/katman-channel.js" defer></script> to the shared layout of every public page. Call window.katmanChannel.convert("signup") (or "payment", "quiz_finished") on real conversions.`,
|
|
379
|
+
"In GA4 register katman_channel as an event-scoped custom dimension; report only sessions with katman_human = true.",
|
|
380
|
+
"Trust the utm_source=chatgpt.com tag, not the referrer: 79% of the sample site's ChatGPT visitors sent no referrer.",
|
|
381
|
+
"Bots don't run this script: count them on the server with the bot-counter-snippet.",
|
|
382
|
+
];
|
|
383
|
+
return {
|
|
384
|
+
artifact: "analytics-channel-snippet",
|
|
385
|
+
content: analyticsChannelSnippet(target),
|
|
386
|
+
target: joinRel(ctx.staticDir, "katman-channel.js"),
|
|
387
|
+
targetKind: "static",
|
|
388
|
+
fence: "js",
|
|
389
|
+
notes,
|
|
390
|
+
missing: [],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function genBotCounter(ctx, o) {
|
|
394
|
+
const platform = o.platform ?? suggestBotPlatform(ctx.scan);
|
|
395
|
+
const s = botCounterSnippet(platform);
|
|
396
|
+
return {
|
|
397
|
+
artifact: "bot-counter-snippet",
|
|
398
|
+
content: s.code,
|
|
399
|
+
target: `.katman/${GEN_DIR}/${s.fileName}`,
|
|
400
|
+
targetKind: "katman",
|
|
401
|
+
fence: "ts",
|
|
402
|
+
notes: [`Platform: ${platform}${o.platform ? "" : " (suggested from the scan; options.platform: cloudflare-worker | nextjs-middleware | express | astro-middleware | sveltekit-hooks)"}. Put it in ${s.placeAt}.`, ...s.notes],
|
|
403
|
+
missing: [],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function genIndexNowKey(ctx) {
|
|
407
|
+
const existing = ctx.config?.indexnowKey;
|
|
408
|
+
const key = existing && /^[a-zA-Z0-9-]{8,128}$/.test(existing) ? existing : (ctx.newKey ?? (() => randomBytes(16).toString("hex")))();
|
|
409
|
+
const siteUrl = siteUrlOf(ctx.research, ctx.config);
|
|
410
|
+
return {
|
|
411
|
+
artifact: "indexnow-key",
|
|
412
|
+
content: key + "\n",
|
|
413
|
+
target: joinRel(ctx.staticDir, `${key}.txt`),
|
|
414
|
+
targetKind: "static",
|
|
415
|
+
fence: "text",
|
|
416
|
+
notes: [
|
|
417
|
+
existing === key ? "Reusing the key saved in .katman/config.json." : "New key saved to .katman/config.json (indexnowKey) so katman_indexnow uses the same one.",
|
|
418
|
+
`The file must be served at ${abs(siteUrl, `/${key}.txt`)} with the key as its only content. katman_indexnow checks it before submitting.`,
|
|
419
|
+
"Submit only new or changed URLs (the sample site once sent all 173 URLs on every release).",
|
|
420
|
+
],
|
|
421
|
+
missing: [],
|
|
422
|
+
...(existing === key ? {} : { configUpdate: { indexnowKey: key } }),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function generateArtifact(ctx, artifact, o = {}) {
|
|
426
|
+
switch (artifact) {
|
|
427
|
+
case "robots.txt": return genRobots(ctx, o);
|
|
428
|
+
case "llms.txt": return genLlms(ctx);
|
|
429
|
+
case "organization-jsonld": return genOrganization(ctx, o);
|
|
430
|
+
case "website-jsonld": return genWebsite(ctx);
|
|
431
|
+
case "software-jsonld": return genSoftware(ctx, o);
|
|
432
|
+
case "faq-jsonld": return genFaq(ctx, o);
|
|
433
|
+
case "breadcrumb-jsonld": return genBreadcrumb(ctx, o);
|
|
434
|
+
case "analytics-channel-snippet": return genAnalytics(ctx, o);
|
|
435
|
+
case "bot-counter-snippet": return genBotCounter(ctx, o);
|
|
436
|
+
case "indexnow-key": return genIndexNowKey(ctx);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Write a generated artifact. Static files: inside the project root only, never over an existing file unless overwrite. */
|
|
440
|
+
export async function writeGenerated(root, g, overwrite = false) {
|
|
441
|
+
if (g.missing.length || hasPlaceholder(g.content)) {
|
|
442
|
+
return { written: false, path: g.target, reason: `still has [FILL] values (${g.missing.join("; ") || "see content"}); fill research/options and generate again.` };
|
|
443
|
+
}
|
|
444
|
+
if (g.targetKind === "katman") {
|
|
445
|
+
const rel = g.target.replace(/^\.katman\//, "");
|
|
446
|
+
const p = await writeText(root, rel, g.content);
|
|
447
|
+
return { written: true, path: p };
|
|
448
|
+
}
|
|
449
|
+
if (g.conflict && !overwrite)
|
|
450
|
+
return { written: false, path: g.target, reason: g.conflict + " Pass overwrite: true to write the static file anyway." };
|
|
451
|
+
const absPath = insideRoot(root, g.target);
|
|
452
|
+
if (!absPath)
|
|
453
|
+
return { written: false, path: g.target, reason: "target is outside the project root" };
|
|
454
|
+
if (existsSync(absPath) && !overwrite)
|
|
455
|
+
return { written: false, path: absPath, reason: "the file already exists; review the diff and pass overwrite: true to replace it" };
|
|
456
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
457
|
+
await fs.writeFile(absPath, g.content, "utf8");
|
|
458
|
+
return { written: true, path: absPath };
|
|
459
|
+
}
|
|
460
|
+
export async function runGenerate(root, args) {
|
|
461
|
+
const [research, config, scan, loaded] = await Promise.all([loadResearchSafe(root), loadConfigSafe(root), loadScan(root), loadLatestAudit(root)]);
|
|
462
|
+
const sd = staticDirFor(root, scan);
|
|
463
|
+
const ctx = { root, research, config, scan, audit: loaded?.audit, staticDir: sd.dir };
|
|
464
|
+
const g = generateArtifact(ctx, args.artifact, args.options ?? {});
|
|
465
|
+
if (g.configUpdate)
|
|
466
|
+
await saveConfig(root, { ...config, ...g.configUpdate });
|
|
467
|
+
let wr;
|
|
468
|
+
if (args.write) {
|
|
469
|
+
wr = await writeGenerated(root, g, !!args.overwrite);
|
|
470
|
+
if (wr.written)
|
|
471
|
+
await logEvent(root, `katman_generate: wrote ${args.artifact} to ${wr.path}`);
|
|
472
|
+
}
|
|
473
|
+
const where = g.targetKind === "static" ? `\`${g.target}\` (static dir: ${sd.dir || "project root"}, ${sd.source})` : `\`${g.target}\` (merge it into the codebase by hand)`;
|
|
474
|
+
const parts = [`# ${g.artifact}`];
|
|
475
|
+
parts.push(`**Target:** ${where}`);
|
|
476
|
+
if (wr)
|
|
477
|
+
parts.push(wr.written ? `**Written:** ${wr.path}` : `**Not written:** ${wr.reason}`);
|
|
478
|
+
else
|
|
479
|
+
parts.push(g.targetKind === "static" ? "Not written (pass write: true to write it; existing files are never overwritten without overwrite: true)." : "Not written (pass write: true to save it under .katman/generated/).");
|
|
480
|
+
if (!research && ["llms.txt", "organization-jsonld", "website-jsonld", "software-jsonld"].includes(g.artifact))
|
|
481
|
+
parts.push("No research yet: values are [FILL]. Run katman_research first.");
|
|
482
|
+
if (g.missing.length)
|
|
483
|
+
parts.push(`**[FILL] to resolve:** ${g.missing.join("; ")}`);
|
|
484
|
+
if (g.conflict)
|
|
485
|
+
parts.push(`**Conflict:** ${g.conflict}`);
|
|
486
|
+
parts.push(codeBlock(g.content, g.fence));
|
|
487
|
+
if (g.embed)
|
|
488
|
+
parts.push(`**Embed (in the page's HTML, rendered on the server):**\n${codeBlock(g.embed, "html")}`);
|
|
489
|
+
if (g.notes.length)
|
|
490
|
+
parts.push(`**Notes:**\n${g.notes.map((n) => `- ${n}`).join("\n")}`);
|
|
491
|
+
return {
|
|
492
|
+
text: parts.join("\n\n"),
|
|
493
|
+
structured: {
|
|
494
|
+
artifact: g.artifact,
|
|
495
|
+
target: g.target,
|
|
496
|
+
targetKind: g.targetKind,
|
|
497
|
+
written: wr?.written ?? false,
|
|
498
|
+
...(wr?.path ? { path: wr.path } : {}),
|
|
499
|
+
...(wr && !wr.written ? { reason: wr.reason } : {}),
|
|
500
|
+
missing: g.missing,
|
|
501
|
+
content: g.content,
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|