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.
Files changed (97) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/cli.js +71 -0
  4. package/dist/context.js +43 -0
  5. package/dist/crawlers.js +86 -0
  6. package/dist/index.js +25 -0
  7. package/dist/license.js +448 -0
  8. package/dist/meta.js +28 -0
  9. package/dist/net.js +128 -0
  10. package/dist/server.js +17 -0
  11. package/dist/state.js +81 -0
  12. package/dist/tools/account/account.js +64 -0
  13. package/dist/tools/account/cli.js +21 -0
  14. package/dist/tools/account/index.js +48 -0
  15. package/dist/tools/account/sync.js +142 -0
  16. package/dist/tools/account/upsell.js +290 -0
  17. package/dist/tools/crawl/audit/analyze.js +719 -0
  18. package/dist/tools/crawl/audit/bots.js +114 -0
  19. package/dist/tools/crawl/audit/checks.js +126 -0
  20. package/dist/tools/crawl/audit/html.js +382 -0
  21. package/dist/tools/crawl/audit/index.js +364 -0
  22. package/dist/tools/crawl/audit/model.js +1 -0
  23. package/dist/tools/crawl/audit/report.js +199 -0
  24. package/dist/tools/crawl/audit/robots.js +199 -0
  25. package/dist/tools/crawl/audit/sitemap.js +147 -0
  26. package/dist/tools/crawl/cli.js +106 -0
  27. package/dist/tools/crawl/common/advice.js +61 -0
  28. package/dist/tools/crawl/common/findings.js +57 -0
  29. package/dist/tools/crawl/common/routes.js +80 -0
  30. package/dist/tools/crawl/common/text.js +89 -0
  31. package/dist/tools/crawl/common/urls.js +64 -0
  32. package/dist/tools/crawl/index.js +226 -0
  33. package/dist/tools/crawl/indexnow.js +271 -0
  34. package/dist/tools/crawl/keywords.js +178 -0
  35. package/dist/tools/crawl/scan/framework.js +645 -0
  36. package/dist/tools/crawl/scan/index.js +175 -0
  37. package/dist/tools/crawl/scan/project.js +180 -0
  38. package/dist/tools/crawl/scan/routes.js +354 -0
  39. package/dist/tools/crawl/scan/signals.js +414 -0
  40. package/dist/tools/method/cli.js +42 -0
  41. package/dist/tools/method/core/brief.js +302 -0
  42. package/dist/tools/method/core/extract.js +435 -0
  43. package/dist/tools/method/core/generate.js +504 -0
  44. package/dist/tools/method/core/lint.js +556 -0
  45. package/dist/tools/method/core/plan.js +405 -0
  46. package/dist/tools/method/core/prompt.js +150 -0
  47. package/dist/tools/method/core/questions.js +196 -0
  48. package/dist/tools/method/core/report.js +182 -0
  49. package/dist/tools/method/core/research-run.js +148 -0
  50. package/dist/tools/method/core/research.js +448 -0
  51. package/dist/tools/method/core/snippets.js +292 -0
  52. package/dist/tools/method/core/start.js +277 -0
  53. package/dist/tools/method/data/anatomy.js +141 -0
  54. package/dist/tools/method/data/appendices.js +202 -0
  55. package/dist/tools/method/data/cloudflare.js +192 -0
  56. package/dist/tools/method/data/evidence.js +137 -0
  57. package/dist/tools/method/data/geo-jobs.js +128 -0
  58. package/dist/tools/method/data/index.js +19 -0
  59. package/dist/tools/method/data/landing.js +67 -0
  60. package/dist/tools/method/data/lessons.js +127 -0
  61. package/dist/tools/method/data/measurement.js +142 -0
  62. package/dist/tools/method/data/method.js +141 -0
  63. package/dist/tools/method/data/offsite.js +148 -0
  64. package/dist/tools/method/data/pages.js +167 -0
  65. package/dist/tools/method/data/prompts.js +633 -0
  66. package/dist/tools/method/data/publish.js +113 -0
  67. package/dist/tools/method/data/render.js +117 -0
  68. package/dist/tools/method/data/research.js +131 -0
  69. package/dist/tools/method/data/roadmap.js +105 -0
  70. package/dist/tools/method/data/traps.js +74 -0
  71. package/dist/tools/method/data/verify.js +88 -0
  72. package/dist/tools/method/data/viral-list.js +39 -0
  73. package/dist/tools/method/index.js +209 -0
  74. package/dist/tools/method/lib/calls.js +53 -0
  75. package/dist/tools/method/lib/framework.js +525 -0
  76. package/dist/tools/method/lib/l10n.js +17 -0
  77. package/dist/tools/method/lib/routes.js +118 -0
  78. package/dist/tools/method/lib/state.js +454 -0
  79. package/dist/tools/method/lib/text.js +165 -0
  80. package/dist/tools/method/mcp/prompts.js +79 -0
  81. package/dist/tools/method/mcp/resources.js +50 -0
  82. package/dist/tools/visibility/analyze.js +463 -0
  83. package/dist/tools/visibility/cli.js +86 -0
  84. package/dist/tools/visibility/estimate.js +105 -0
  85. package/dist/tools/visibility/index.js +108 -0
  86. package/dist/tools/visibility/judge.js +156 -0
  87. package/dist/tools/visibility/manual.js +275 -0
  88. package/dist/tools/visibility/models.js +277 -0
  89. package/dist/tools/visibility/monitoring.js +165 -0
  90. package/dist/tools/visibility/openrouter.js +449 -0
  91. package/dist/tools/visibility/render.js +295 -0
  92. package/dist/tools/visibility/run.js +684 -0
  93. package/dist/tools/visibility/summary.js +164 -0
  94. package/dist/types.js +3 -0
  95. package/dist/util.js +92 -0
  96. package/package.json +54 -0
  97. package/server.json +34 -0
@@ -0,0 +1,364 @@
1
+ // katman_audit_site engine: crawl the live site the way a non-JavaScript crawler does, run the site-level checks,
2
+ // then build findings, gates and the markdown report. Network goes through an injectable fetcher (tests use a fake).
3
+ import { randomBytes } from "node:crypto";
4
+ import { httpGet, mapLimit } from "../../../net.js";
5
+ import { normalizeSiteUrl } from "../../../util.js";
6
+ import { classifyPath } from "../common/routes.js";
7
+ import { isAssetPath, originOf, safeUrl, sameSite, urlKey } from "../common/urls.js";
8
+ import { buildFindings, canonicalSelf, computeGates } from "./analyze.js";
9
+ import { detectCdn, runBotTest } from "./bots.js";
10
+ import { PAGE_HEADERS, checkHostRedirect, checkHttpsRedirect, checkImages, checkPreview, checkSoft404, checkTextFile, followRedirects, wwwTwin, } from "./checks.js";
11
+ import { analyzeHtml, headerNoindex } from "./html.js";
12
+ import { analyzeRobots } from "./robots.js";
13
+ import { lastmodStats, loadSitemaps } from "./sitemap.js";
14
+ import { composeOneSentence } from "../../../util.js";
15
+ export function researchSentence(r) {
16
+ if (!r)
17
+ return undefined;
18
+ const s = r.brand?.sentence?.trim();
19
+ if (s && !/\[FILL\]/i.test(s))
20
+ return s;
21
+ return composeOneSentence(r.brand?.oneLiner, 2);
22
+ }
23
+ function isHtmlResponse(res) {
24
+ const ct = res.headers["content-type"] ?? "";
25
+ if (/text\/html|application\/xhtml/i.test(ct))
26
+ return true;
27
+ return !ct && /^\s*</.test(res.body);
28
+ }
29
+ function makePage(url, res, source) {
30
+ const isHtml = isHtmlResponse(res);
31
+ const html = res.status === 200 && isHtml ? analyzeHtml(res.body, url) : undefined;
32
+ const hNo = headerNoindex(res.headers);
33
+ return {
34
+ url,
35
+ key: urlKey(url),
36
+ source,
37
+ kind: classifyPath(safeUrl(url)?.pathname ?? url),
38
+ status: res.status,
39
+ contentType: res.headers["content-type"],
40
+ location: res.location ? (safeUrl(res.location, url)?.href ?? res.location) : undefined,
41
+ error: res.error,
42
+ bytes: res.bytes,
43
+ ms: res.ms,
44
+ headers: res.headers,
45
+ isHtml,
46
+ html,
47
+ noindex: hNo || !!html?.noindexMeta,
48
+ headerNoindex: hNo,
49
+ inSitemap: false,
50
+ linksIn: new Set(),
51
+ linkedFrom: [],
52
+ robotsBlocked: [],
53
+ lovable: LOVABLE_RE.test(res.body.slice(0, 20_000)),
54
+ };
55
+ }
56
+ const LOVABLE_RE = /lovable\.(dev|app)|gpteng\.co|lovable-tagger|content=["']Lovable/i;
57
+ export async function runAudit(o) {
58
+ const fetcher = o.fetcher ?? httpGet;
59
+ const now = o.now ?? new Date();
60
+ const maxUrls = Math.max(1, Math.min(500, Math.floor(o.maxUrls ?? 50)));
61
+ const verify = !!o.urls?.length;
62
+ const progress = o.onProgress ?? (() => undefined);
63
+ let origin = normalizeSiteUrl(o.siteUrl);
64
+ const redirectForms = new Map();
65
+ // 1. Homepage (follow redirects: the site URL may move to www/https or a language path)
66
+ progress(0, 6, "homepage");
67
+ const start = await followRedirects(origin + "/", fetcher, 5, { headers: PAGE_HEADERS });
68
+ const last = start.chain[start.chain.length - 1];
69
+ if (start.final.status > 0)
70
+ origin = originOf(last.url);
71
+ const host = new URL(origin).host;
72
+ const home = makePage(last.url, start.final, "home");
73
+ const ctx = {
74
+ mode: verify ? "verify" : "full",
75
+ maxUrls,
76
+ inputUrl: o.siteUrl,
77
+ origin,
78
+ host,
79
+ homeUrl: home.url,
80
+ homeKey: home.key,
81
+ startChain: start.chain,
82
+ home,
83
+ pages: [home],
84
+ byKey: new Map([[home.key, home]]),
85
+ sitemap: { sources: [], entries: [], declared: false, truncated: false },
86
+ sitemapByKey: new Map(),
87
+ lastmod: lastmodStats([], now),
88
+ previews: [],
89
+ ogImages: new Map(),
90
+ research: o.research,
91
+ sentence: researchSentence(o.research),
92
+ situations: [],
93
+ coverage: { sitemapTotal: 0, sitemapCrawled: 0, complete: true },
94
+ skippedByRobots: [],
95
+ scan: o.scan,
96
+ lovableHint: o.scan?.framework.builder === "lovable" || !!home.lovable,
97
+ notes: [],
98
+ now,
99
+ };
100
+ if (home.status === 0 || home.status >= 500) {
101
+ return finish(ctx, redirectForms);
102
+ }
103
+ ctx.cdn = detectCdn(home.headers);
104
+ // 2. robots.txt
105
+ progress(1, 6, "robots.txt");
106
+ const rb = await followRedirects(origin + "/robots.txt", fetcher, 3, { headers: { accept: "text/plain,*/*" } });
107
+ ctx.robots = analyzeRobots(rb.final.body, origin + "/robots.txt", rb.final.status, rb.final.headers["content-type"]);
108
+ // 3. Sitemaps
109
+ progress(2, 6, "sitemap");
110
+ ctx.sitemap = await loadSitemaps(origin, ctx.robots.exists ? ctx.robots.sitemaps : [], fetcher);
111
+ for (const e of ctx.sitemap.entries)
112
+ ctx.sitemapByKey.set(urlKey(e.loc), e);
113
+ ctx.lastmod = lastmodStats(ctx.sitemap.entries, now);
114
+ // 4. Crawl
115
+ const originUrl = new URL(origin);
116
+ const seen = new Set([home.key]);
117
+ const queue = [];
118
+ // ?sort=new, ?plan=pro…: sample one query variant per path instead of crawling every filter/state combination.
119
+ const variants = new Map();
120
+ const enqueue = (raw, source) => {
121
+ const x = safeUrl(raw, origin);
122
+ if (!x || !/^https?:$/.test(x.protocol) || !sameSite(x.hostname, host))
123
+ return;
124
+ x.hash = "";
125
+ if (source === "link") {
126
+ if (isAssetPath(x.pathname) || classifyPath(x.pathname) === "api")
127
+ return;
128
+ x.protocol = originUrl.protocol;
129
+ x.host = originUrl.host;
130
+ if (ctx.robots?.exists && !ctx.robots.isAllowed(x.href, "*")) {
131
+ if (ctx.skippedByRobots.length < 50 && !ctx.skippedByRobots.includes(x.href))
132
+ ctx.skippedByRobots.push(x.href);
133
+ return;
134
+ }
135
+ }
136
+ const k = urlKey(x);
137
+ if (seen.has(k) || seen.size >= maxUrls)
138
+ return;
139
+ if (source === "link" && x.search) {
140
+ const base = urlKey(x.origin + x.pathname);
141
+ const n = variants.get(base) ?? 0;
142
+ if (n >= 1)
143
+ return;
144
+ variants.set(base, n + 1);
145
+ }
146
+ seen.add(k);
147
+ queue.push({ url: x.href, source });
148
+ };
149
+ // Following a URL to its own canonical form (slash / www / http) costs extra requests: capped per audit.
150
+ let followBudget = 25;
151
+ const fetchPage = async (url, source) => {
152
+ let cur = url;
153
+ let res = await fetcher(cur, { redirect: "manual", headers: PAGE_HEADERS });
154
+ for (let i = 0; i < 2 && followBudget > 0 && res.status >= 300 && res.status < 400 && res.location; i++) {
155
+ followBudget--;
156
+ const to = safeUrl(res.location, cur);
157
+ if (!to || urlKey(to) !== urlKey(url) || to.href === cur)
158
+ break;
159
+ redirectForms.set(cur, { url: cur, status: res.status, location: to.href });
160
+ cur = to.href;
161
+ res = await fetcher(cur, { redirect: "manual", headers: PAGE_HEADERS });
162
+ }
163
+ return makePage(cur, res, source);
164
+ };
165
+ if (verify) {
166
+ for (const u of o.urls)
167
+ enqueue(u, "input");
168
+ }
169
+ else {
170
+ for (const l of home.html?.links ?? [])
171
+ enqueue(l.href, "link");
172
+ for (const e of ctx.sitemap.entries)
173
+ enqueue(e.loc, "sitemap");
174
+ }
175
+ let fetched = 1;
176
+ while (queue.length) {
177
+ const batch = queue.splice(0, queue.length);
178
+ progress(3, 6, `pages ${fetched}/${Math.min(maxUrls, fetched + batch.length)}`);
179
+ const recs = await mapLimit(batch, 4, (q) => fetchPage(q.url, q.source));
180
+ fetched += recs.length;
181
+ for (const r of recs) {
182
+ if (ctx.byKey.has(r.key))
183
+ continue;
184
+ ctx.pages.push(r);
185
+ ctx.byKey.set(r.key, r);
186
+ }
187
+ if (!verify)
188
+ for (const r of recs)
189
+ for (const l of r.html?.links ?? [])
190
+ enqueue(l.href, "link");
191
+ }
192
+ // Inbound links, sitemap membership, robots decisions
193
+ for (const p of ctx.pages) {
194
+ for (const l of p.html?.links ?? []) {
195
+ if (l.key === p.key)
196
+ continue;
197
+ const t = ctx.byKey.get(l.key);
198
+ if (!t)
199
+ continue;
200
+ t.linksIn.add(p.key);
201
+ if (t.linkedFrom.length < 5 && !t.linkedFrom.includes(p.url))
202
+ t.linkedFrom.push(p.url);
203
+ }
204
+ }
205
+ for (const p of ctx.pages) {
206
+ p.inSitemap = ctx.sitemapByKey.has(p.key);
207
+ if (ctx.robots?.exists)
208
+ p.robotsBlocked = ["Googlebot", "OAI-SearchBot"].filter((t) => !ctx.robots.isAllowed(p.url, t));
209
+ }
210
+ ctx.lovableHint ||= ctx.pages.some((p) => p.lovable);
211
+ const smSame = ctx.sitemap.entries.filter((e) => sameSite(safeUrl(e.loc)?.hostname ?? "", host));
212
+ ctx.coverage = {
213
+ sitemapTotal: smSame.length,
214
+ sitemapCrawled: ctx.pages.filter((p) => p.inSitemap).length,
215
+ complete: !ctx.sitemap.truncated && ctx.pages.filter((p) => p.inSitemap).length >= smSame.length,
216
+ };
217
+ // 5. Site-level checks
218
+ progress(4, 6, "site checks");
219
+ const token = o.token ?? randomBytes(4).toString("hex");
220
+ // At most 4 requests in flight, like the page crawl.
221
+ const twin = wwwTwin(host);
222
+ const [https, hostAlt, soft404, llms, llmsFull] = (await mapLimit([
223
+ () => checkHttpsRedirect(host, fetcher),
224
+ () => (twin ? checkHostRedirect(host, fetcher) : Promise.resolve({ from: "", status: 0, altHost: "", error: "skipped" })),
225
+ () => checkSoft404(origin, fetcher, token),
226
+ () => checkTextFile(origin + "/llms.txt", fetcher),
227
+ () => checkTextFile(origin + "/llms-full.txt", fetcher),
228
+ ], 4, (fn) => fn()));
229
+ Object.assign(ctx, { https, hostAlt: hostAlt.status === 0 ? undefined : hostAlt, soft404, llms, llmsFull });
230
+ if (!twin)
231
+ ctx.notes.push(`${host} is a subdomain: the www/apex check was skipped.`);
232
+ else if (hostAlt.status === 0)
233
+ ctx.notes.push(`${hostAlt.altHost} does not answer (${hostAlt.error ?? "no DNS/TLS"}): fine, but visitors who type it get an error.`);
234
+ // 6. Bot access
235
+ if (o.includeBotTest !== false) {
236
+ progress(5, 6, "AI bot access");
237
+ ctx.bots = await runBotTest(home.url, fetcher);
238
+ ctx.cdn = ctx.cdn ?? ctx.bots.cdn;
239
+ }
240
+ // 7. Previews, og:image, research situations
241
+ const previews = [...new Set([...(o.previewUrls ?? []), ...(o.research?.site?.previewUrls ?? [])].map((p) => (/^https?:\/\//i.test(p) ? p : `https://${p}`)))].filter((p) => safeUrl(p) && safeUrl(p).hostname.toLowerCase() !== host.toLowerCase());
242
+ ctx.previews = await mapLimit(previews.slice(0, 5), 4, (p) => checkPreview(p, host, fetcher, home.html?.title));
243
+ const ogUrls = ctx.pages.map((p) => p.html?.og.image).filter((x) => !!x && /^https?:\/\//i.test(x));
244
+ ctx.ogImages = await checkImages(ogUrls, fetcher, 5);
245
+ ctx.situations = await checkSituations(ctx, fetchPage);
246
+ return finish(ctx, redirectForms);
247
+ }
248
+ async function checkSituations(ctx, fetchPage) {
249
+ const list = ctx.research?.situations ?? [];
250
+ const out = list.map((s) => ({ id: s.id, sentence: s.sentence, url: s.url?.trim() || undefined }));
251
+ const todo = out.filter((s) => s.url && !/\[FILL\]/i.test(s.url)).slice(0, 10);
252
+ await mapLimit(todo, 4, async (s) => {
253
+ const abs = safeUrl(s.url, ctx.origin);
254
+ if (!abs)
255
+ return;
256
+ s.abs = abs.href;
257
+ const known = ctx.byKey.get(urlKey(abs));
258
+ const rec = known ?? (await fetchPage(abs.href, "situation"));
259
+ s.status = rec.status;
260
+ s.noindex = rec.noindex;
261
+ s.live = rec.status === 200 && rec.isHtml && !rec.noindex;
262
+ s.h1 = !!rec.html?.h1Text;
263
+ s.linksIn = known ? known.linksIn.size : 0;
264
+ });
265
+ return out;
266
+ }
267
+ function finish(ctx, redirectForms) {
268
+ // Redirecting forms of sitemap URLs and links count as redirects in the findings.
269
+ const findings = buildFindings(ctx);
270
+ addRedirectFormFindings(ctx, redirectForms, findings);
271
+ const gates = computeGates(ctx);
272
+ return { result: toAuditResult(ctx, findings, gates), ctx, findings, redirectForms };
273
+ }
274
+ function addRedirectFormFindings(ctx, forms, findings) {
275
+ if (!forms.size)
276
+ return;
277
+ const smRedirects = ctx.sitemap.entries.filter((e) => forms.has(e.loc)).map((e) => `${e.loc} → ${forms.get(e.loc).status} ${forms.get(e.loc).location}`);
278
+ if (smRedirects.length) {
279
+ findings.push({
280
+ id: "sitemap-redirect-forms",
281
+ title: `${smRedirects.length} sitemap URL${smRedirects.length === 1 ? " is" : "s are"} listed in a redirecting form (slash/www/http)`,
282
+ severity: "medium",
283
+ gate: "read",
284
+ prompt: "P4",
285
+ detail: "The sitemap should list the final canonical URL; these redirect to the same page with a different trailing slash, host or protocol.",
286
+ evidence: smRedirects.slice(0, 5),
287
+ urls: smRedirects.map((s) => s.split(" → ")[0]),
288
+ fix: "Emit sitemap URLs in exactly the canonical form (same trailing-slash style as the canonical tag).",
289
+ accept: "Every sitemap URL returns 200 directly",
290
+ });
291
+ }
292
+ const linked = [];
293
+ for (const p of ctx.pages)
294
+ for (const l of p.html?.links ?? [])
295
+ if (forms.has(l.href) && linked.length < 50)
296
+ linked.push(`${l.href} (on ${p.url})`);
297
+ if (linked.length) {
298
+ findings.push({
299
+ id: "links-redirect-forms",
300
+ title: `${linked.length} internal link${linked.length === 1 ? " goes" : "s go"} through a redirect (slash/www/http)`,
301
+ severity: "low",
302
+ gate: "read",
303
+ prompt: "P10",
304
+ detail: "Each link costs crawlers an extra hop.",
305
+ evidence: linked.slice(0, 5),
306
+ fix: "Link to the canonical form directly (match the trailing-slash style of the canonical tag).",
307
+ accept: "Internal links point at 200 URLs",
308
+ });
309
+ }
310
+ }
311
+ export function toAuditResult(ctx, findings, gates) {
312
+ const home = ctx.home;
313
+ return {
314
+ version: 1,
315
+ ts: ctx.now.toISOString(),
316
+ siteUrl: ctx.origin,
317
+ pages: ctx.pages.map((p) => ({
318
+ url: p.url,
319
+ status: p.status,
320
+ contentType: p.contentType,
321
+ noindex: p.noindex,
322
+ title: p.html?.title,
323
+ description: p.html?.description,
324
+ canonical: p.html?.canonical,
325
+ canonicalSelf: canonicalSelf(p),
326
+ h1Count: p.html?.h1Count ?? 0,
327
+ headingSkips: p.html?.headingSkips ?? 0,
328
+ wordsJsOff: p.html?.wordsJsOff ?? 0,
329
+ firstParagraph: p.html?.firstParagraph,
330
+ jsonLdTypes: p.html?.jsonLdTypes ?? [],
331
+ jsonLdErrors: p.html?.jsonLdErrors ?? 0,
332
+ ogImage: p.html?.og.image,
333
+ lang: p.html?.lang,
334
+ hreflang: p.html?.hreflang ?? [],
335
+ imagesMissingAlt: p.html?.imagesMissingAlt ?? 0,
336
+ internalLinksOut: (p.html?.links ?? []).filter((l) => l.key !== p.key).length,
337
+ internalLinksIn: p.linksIn.size,
338
+ inSitemap: p.inSitemap,
339
+ dateModified: p.html?.dateModified,
340
+ bytes: p.bytes,
341
+ ms: p.ms,
342
+ })),
343
+ site: {
344
+ httpsRedirect: ctx.https ? (ctx.https.status === 0 ? undefined : !!ctx.https.ok) : undefined,
345
+ hostRedirect: ctx.hostAlt ? !!ctx.hostAlt.ok : undefined,
346
+ hsts: home && home.status === 200 ? !!home.headers["strict-transport-security"] : undefined,
347
+ compression: home && home.status === 200 ? (home.headers["content-encoding"] ?? null) : undefined,
348
+ soft404: ctx.soft404?.soft404,
349
+ llmsTxt: { exists: !!ctx.llms?.exists, bytes: ctx.llms?.exists ? ctx.llms.bytes : undefined },
350
+ robots: {
351
+ exists: !!ctx.robots?.exists,
352
+ sitemaps: ctx.robots?.sitemaps ?? [],
353
+ blocksAll: !!ctx.robots?.blocksAll,
354
+ blockedAiTokens: ctx.robots?.blockedTokens ?? [],
355
+ invalidLines: ctx.robots?.invalidLines ?? [],
356
+ },
357
+ sitemapUrls: ctx.sitemap.entries.length,
358
+ cdn: ctx.cdn,
359
+ bots: (ctx.bots?.probes ?? []).map((p) => ({ token: p.crawler.token, purpose: p.crawler.purpose, status: p.status, blocked: p.blocked, reason: p.reason })),
360
+ },
361
+ gates,
362
+ findings,
363
+ };
364
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,199 @@
1
+ import { mdTable, truncate } from "../../../util.js";
2
+ import { LOVABLE_CAVEAT } from "../common/advice.js";
3
+ import { findingsList, rankByImpact, severityLine } from "../common/findings.js";
4
+ import { shortUrl } from "../common/urls.js";
5
+ import { canonicalSelf, descUnique, gatePopulation, htmlOk, titleUnique, uniqueness } from "./analyze.js";
6
+ const Y = "✓";
7
+ const N = "✗";
8
+ export function topIssues(ctx, findings, n = 10) {
9
+ const actionable = findings.filter((f) => f.severity !== "info");
10
+ return rankByImpact(actionable, { pages: gatePopulation(ctx).length, homeUrl: ctx.home?.url }).slice(0, n);
11
+ }
12
+ export function auditMarkdown(ctx, result, findings, opts = {}) {
13
+ const L = [];
14
+ const g = result.gates;
15
+ const crawled = ctx.pages.length;
16
+ L.push(`# Katman site audit — ${ctx.origin}`);
17
+ L.push(`${result.ts.slice(0, 16).replace("T", " ")} UTC · ${ctx.mode === "verify" ? "quick verify" : "full audit"} · ${crawled} URL${crawled === 1 ? "" : "s"} fetched · sitemap: ${ctx.sitemap.entries.length} URL${ctx.sitemap.entries.length === 1 ? "" : "s"}${ctx.cdn ? ` · CDN: ${ctx.cdn}` : ""} · ${severityLine(findings)}`);
18
+ if (opts.mdPath || opts.jsonPath)
19
+ L.push(`Saved: ${[opts.mdPath, opts.jsonPath].filter(Boolean).join(" · ")}`);
20
+ L.push("");
21
+ // Gates
22
+ L.push("## Gates");
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)",
25
+ g[k].total ? `${g[k].pass}/${g[k].total}` : "–",
26
+ g[k].notes.slice(1, 4).join("; ") || (g[k].total ? "all pass" : ""),
27
+ ])));
28
+ L.push("");
29
+ L.push(`- ${g.read.notes[0]}`);
30
+ L.push(`- ${g.quote.notes[0]}`);
31
+ L.push(`- ${g.fit.notes[0]}`);
32
+ L.push("");
33
+ // Top issues
34
+ const top = topIssues(ctx, findings);
35
+ L.push("## Top issues by traffic impact");
36
+ L.push(top.length ? findingsList(top, { urlLimit: 5 }) : "No actionable issues found.");
37
+ L.push("");
38
+ // Bots
39
+ if (ctx.bots) {
40
+ L.push("## AI bot access (homepage, user-agent test)");
41
+ const rows = [["Browser (baseline)", "—", "—", ctx.bots.browser.status, ctx.bots.browserBlocked ? `${N} ${ctx.bots.browserBlocked}` : `${Y} ${kb(ctx.bots.browser.bytes)}`]];
42
+ for (const p of ctx.bots.probes) {
43
+ rows.push([
44
+ p.crawler.token,
45
+ p.crawler.org,
46
+ p.crawler.purpose,
47
+ p.status || "—",
48
+ p.blocked ? `${N} blocked: ${p.reason ?? ""}` : p.different ? `! ${p.reason}` : `${Y} ${kb(p.bytes)}`,
49
+ ]);
50
+ }
51
+ L.push(mdTable(["Bot", "Org", "Purpose", "Status", "Result"], rows));
52
+ L.push("");
53
+ L.push("Spoofed user agents only test UA-based rules; CDNs verify real Googlebot/Bingbot/Applebot (and increasingly AI bots) by IP, so their real result can differ. Confirm Google in Search Console → Settings → Crawl stats.");
54
+ if (ctx.lovableHint)
55
+ L.push(`Lovable detected: ${LOVABLE_CAVEAT}`);
56
+ L.push("");
57
+ }
58
+ // The nine commands (book ch. 16)
59
+ L.push("## The book's nine checks (ch. 16), run for you");
60
+ L.push(mdTable(["#", "Check", "Result", "Command to re-run"], nineCommands(ctx)));
61
+ L.push("");
62
+ // P14 table
63
+ L.push(`## Pre-publish table (P14)${gatePopulation(ctx).length > 25 ? " — first 25 pages" : ""}`);
64
+ L.push(p14Table(ctx));
65
+ L.push("");
66
+ L.push("Columns: status 200 · no unwanted noindex · unique title · unique description · canonical to itself · og:image (✓ loads when checked) · JSON-LD parses (– none) · H1 + first paragraph without JavaScript · in sitemap · internal links in (target ≥ 3).");
67
+ L.push("");
68
+ // Other findings
69
+ const rest = findings.filter((f) => !top.includes(f));
70
+ if (rest.length) {
71
+ L.push(`## Other findings (${rest.length})`);
72
+ if (opts.full) {
73
+ L.push(findingsList(rankByImpact(rest, { pages: gatePopulation(ctx).length, homeUrl: ctx.home?.url }), { urlLimit: 10 }));
74
+ }
75
+ else {
76
+ for (const f of rankByImpact(rest, { pages: gatePopulation(ctx).length, homeUrl: ctx.home?.url }).slice(0, 40)) {
77
+ L.push(`- [${f.severity.toUpperCase()}] ${f.title} (${f.gate}${f.prompt ? ` · ${f.prompt}` : ""})${f.urls?.length ? ` — ${f.urls.length} URL${f.urls.length === 1 ? "" : "s"}` : ""}`);
78
+ }
79
+ if (rest.length > 40)
80
+ L.push(`- …${rest.length - 40} more in audit.md`);
81
+ L.push("Full details (fix + acceptance criterion for every finding): .katman/audit.md");
82
+ }
83
+ L.push("");
84
+ }
85
+ // Coverage
86
+ L.push("## Coverage");
87
+ const src = count(ctx.pages.map((p) => p.source));
88
+ L.push(`- Fetched ${crawled} URLs (${Object.entries(src).map(([k, v]) => `${v} ${k}`).join(", ")}); cap ${ctx.maxUrls}.`);
89
+ if (ctx.mode === "full") {
90
+ L.push(`- Sitemap coverage: ${ctx.coverage.sitemapCrawled}/${ctx.coverage.sitemapTotal} sitemap URLs fetched${ctx.coverage.complete ? "" : " — inbound-link counts are lower bounds; raise max_urls for a full check"}.`);
91
+ }
92
+ for (const pv of ctx.previews)
93
+ L.push(`- Preview copy ${pv.url}: ${pv.how} ${pv.ok ? Y : N}`);
94
+ if (ctx.skippedByRobots.length)
95
+ L.push(`- Not fetched (disallowed in robots.txt): ${ctx.skippedByRobots.slice(0, 5).join(", ")}${ctx.skippedByRobots.length > 5 ? " …" : ""}`);
96
+ if (ctx.research)
97
+ L.push(`- Research: ${ctx.sentence ? "one sentence checked on the homepage" : "no one sentence yet"}; ${ctx.situations.length} situation(s).`);
98
+ else
99
+ L.push("- No .katman/research.json: the fit gate and one-sentence checks were skipped (run katman_research).");
100
+ for (const n of ctx.notes)
101
+ L.push(`- ${n}`);
102
+ L.push("");
103
+ L.push("Next: fix the top issue with the prompt shown (katman_prompt), then re-verify just those URLs with katman_audit_site { urls: [...] }.");
104
+ return L.join("\n");
105
+ }
106
+ function count(xs) {
107
+ const o = {};
108
+ for (const x of xs)
109
+ o[x] = (o[x] ?? 0) + 1;
110
+ return o;
111
+ }
112
+ function kb(n) {
113
+ return n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
114
+ }
115
+ function mark(v) {
116
+ return v === undefined ? "–" : v ? Y : N;
117
+ }
118
+ export function p14Rows(ctx) {
119
+ const u = uniqueness(ctx);
120
+ const pop = gatePopulation(ctx);
121
+ const ordered = [...pop].sort((a, b) => (a === ctx.home ? -1 : b === ctx.home ? 1 : 0));
122
+ return ordered.slice(0, 25).map((p) => {
123
+ const ok = htmlOk(p);
124
+ const h = p.html;
125
+ const og = h?.og.image ? (ctx.ogImages.get(h.og.image)?.ok ?? true) : false;
126
+ const ld = !h || !h.jsonLdBlocks ? "–" : h.jsonLdErrors ? N : Y;
127
+ const jsOff = !!h && !!h.h1Text && !!h.firstParagraph && h.wordsJsOff >= 100;
128
+ return {
129
+ page: p,
130
+ cells: [
131
+ truncate(shortUrl(p.url, ctx.host), 48),
132
+ String(p.status || "—"),
133
+ ok ? mark(!p.noindex) : "–",
134
+ ok ? mark(titleUnique(u, p)) : "–",
135
+ ok ? mark(descUnique(u, p)) : "–",
136
+ ok ? mark(canonicalSelf(p) ?? false) : "–",
137
+ ok ? mark(og) : "–",
138
+ ok ? ld : "–",
139
+ ok ? mark(jsOff) : "–",
140
+ mark(p.inSitemap),
141
+ ctx.mode === "verify" && p.linksIn.size === 0 ? "0*" : String(p.linksIn.size),
142
+ ],
143
+ };
144
+ });
145
+ }
146
+ function p14Table(ctx) {
147
+ const rows = p14Rows(ctx);
148
+ if (!rows.length)
149
+ return "No public pages were fetched.";
150
+ const t = mdTable(["URL", "Status", "No noindex", "Title unique", "Desc unique", "Canonical self", "og:image", "JSON-LD", "JS-off H1+¶", "In sitemap", "Links in"], rows.map((r) => r.cells));
151
+ return ctx.mode === "verify" ? `${t}\n\n\\* quick verify mode: only links among the checked pages are counted.` : t;
152
+ }
153
+ function nineCommands(ctx) {
154
+ const o = ctx.origin;
155
+ const home = ctx.home;
156
+ const h = home?.html;
157
+ const rows = [];
158
+ const https = ctx.https;
159
+ const httpsTxt = !https
160
+ ? "not tested"
161
+ : https.status === 0
162
+ ? `http:// not reachable (${https.error ?? "error"})`
163
+ : https.location
164
+ ? `http:// → ${https.status} ${https.location} ${https.ok && https.permanent ? Y : N}`
165
+ : `http:// → ${https.status} ${N}`;
166
+ rows.push(["1", "Homepage 200 · http → https", `${home?.status ?? "—"} ${home?.status === 200 ? Y : N} · ${httpsTxt}`, `curl -sI ${o}/ | head -1; curl -sI http://${ctx.host}/ | grep -iE "^HTTP|^location"`]);
167
+ const s = ctx.soft404;
168
+ rows.push(["2", "Unknown URL is a real 404", s ? `${s.status} ${s.status === 404 || s.status === 410 ? Y : N}${s.soft404 && s.noindex ? " (noindex)" : ""}` : "not tested", `curl -s -o /dev/null -w "%{http_code}" ${o}/does-not-exist`]);
169
+ const canon = home ? canonicalSelf(home) : undefined;
170
+ rows.push([
171
+ "3",
172
+ "Title, description, canonical, robots, og:image",
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}`
175
+ : "—",
176
+ `curl -s ${o}/ | grep -oiE '<title>[^<]*|name="description" content="[^"]*|rel="canonical" href="[^"]*|name="robots" content="[^"]*|og:image" content="[^"]*'`,
177
+ ]);
178
+ rows.push(["4", "Words without JavaScript (homepage)", h ? `${h.wordsJsOff} visible words ${h.wordsJsOff >= 100 ? Y : N}` : "—", `curl -s ${o}/ | sed 's/<[^>]*>/ /g' | wc -w (counts script text too)`]);
179
+ const want = ["GPTBot", "OAI-SearchBot", "ClaudeBot", "PerplexityBot"];
180
+ const bots = ctx.bots?.probes.filter((p) => want.includes(p.crawler.token)) ?? [];
181
+ rows.push([
182
+ "5",
183
+ "AI bots get in (all 200)",
184
+ bots.length ? bots.map((b) => `${b.crawler.token} ${b.status}${b.blocked ? ` ${N}` : ""}`).join(" · ") : "not tested",
185
+ `for b in GPTBot/1.4 OAI-SearchBot/1.4 ClaudeBot/1.0 PerplexityBot/1.0; do echo "$b $(curl -s -o /dev/null -w '%{http_code}' -A "Mozilla/5.0 (compatible; $b)" ${o}/)"; done`,
186
+ ]);
187
+ const r = ctx.robots;
188
+ rows.push([
189
+ "6",
190
+ "robots.txt and sitemap",
191
+ `${r ? (r.exists ? `robots.txt 200, ${r.groups.length} groups, Sitemap line ${r.sitemaps.length ? Y : N}` : r.isHtml ? `robots.txt is HTML ${N}` : `robots.txt ${r.status} ${N}`) : "—"} · sitemap ${ctx.sitemap.entries.length} <loc> ${ctx.sitemap.entries.length ? Y : N}`,
192
+ `curl -s ${o}/robots.txt; curl -s ${o}/sitemap.xml | grep -c "<loc>"`,
193
+ ]);
194
+ rows.push(["7", "JSON-LD parses (homepage)", h ? (h.jsonLdBlocks ? `${h.jsonLdBlocks} block(s), ${h.jsonLdErrors ? `${h.jsonLdErrors} invalid ${N}` : `all valid ${Y}`}` : `none ${N}`) : "—", `curl -s ${o}/ | python3 -c "import sys,re,json; b=re.findall(r'application/ld\\+json\\">(.*?)</script', sys.stdin.read(), re.S); [json.loads(x) for x in b]; print(len(b), 'valid')"`]);
195
+ rows.push(["8", "Mobile speed + SEO (Lighthouse)", "not run by Katman (needs Chrome): targets LCP ≤ 2.5 s, CLS ≤ 0.1", `npx lighthouse ${o}/ --form-factor=mobile --only-categories=performance,seo --view`]);
196
+ const enc = home?.headers["content-encoding"];
197
+ rows.push(["9", "HTML compressed", home ? (enc ? `${enc} ${Y}` : `none ${N}`) : "—", `curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' ${o}/ | grep -i content-encoding`]);
198
+ return rows;
199
+ }