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,556 @@
|
|
|
1
|
+
// katman_lint_content: checks a page against the content rules (appendix D) and the P6/P7/P8 acceptance criteria.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { httpGet } from "../../../net.js";
|
|
4
|
+
import { mdTable } from "../../../util.js";
|
|
5
|
+
import { loadConfigSafe, loadResearchSafe, loadScan, readProjectFile, staticDirFor } from "../lib/state.js";
|
|
6
|
+
import { brandSentence, containment, containsPhrase, countWords, escapeRegExp, hasNotX, isFilled, normPath, pct, splitSentences } from "../lib/text.js";
|
|
7
|
+
import { HYPE_WORDS_EN, HYPE_WORDS_TR } from "../data/render.js";
|
|
8
|
+
import { extractFile, extractHtml, extractMarkdown, firstSentences, leadText } from "./extract.js";
|
|
9
|
+
import { parsePrices } from "./generate.js";
|
|
10
|
+
import { brandNames } from "./research.js";
|
|
11
|
+
import { situationUrl } from "./prompt.js";
|
|
12
|
+
export const PAGE_TYPES_LINT = ["situation", "comparison", "pricing", "faq", "about", "single-question", "glossary", "tool", "home", "other"];
|
|
13
|
+
const PREAMBLE_RE = /^(welcome to|in today['’]s|in this (article|post|guide)|are you looking|have you ever|looking for|when it comes to|in the world of|hoş geldiniz|günümüzde|bu yazıda|bu makalede|merhaba)/i;
|
|
14
|
+
const PLACEHOLDER_RE = /\[(?:FILL|DOLDUR)[^\]]*\]|\bTODO\b|\bTBD\b|lorem ipsum|\bXXX\b|\{\{\s*\w+\s*\}\}/i;
|
|
15
|
+
const NOT_FOR_RE = /(not for|isn['’]t for|is not for|who shouldn['’]?t|not a (good )?fit|when not to use|skip it if|kimler için değil|kime uygun değil|uygun değil|kullanmayın)/i;
|
|
16
|
+
const ALT_RE = /(alternatives?|compared|comparison|\bvs\.?\b|instead|other tools|other options|alternatif|karşılaştır|yerine|diğer araçlar)/i;
|
|
17
|
+
const OWN_GAPS_RE = /(doesn['’]?t|does not|won['’]?t|can['’]?t|cannot|not for|limitations?|weakness|\bcons\b|missing|neyi yapmıyor|yapmaz|yapamaz|eksik|sınırlama|dezavantaj)/i;
|
|
18
|
+
const SOURCE_MARK_RE = /(source|according to|survey|study|report|data from|measured|as of|kaynak|göre|ölçüm|araştırma|https?:\/\/|\[\d+\]|\b(19|20)\d{2}\b|\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b|\b(ocak|şubat|mart|nisan|mayıs|haziran|temmuz|ağustos|eylül|ekim|kasım|aralık)\b)/i;
|
|
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
|
+
const FREE_RE = /\b(free|for free|free of charge)\b|ücretsiz|bedava|\bgratis\b|kostenlos|gratuit/gi;
|
|
21
|
+
const CURRENCY_NEAR = /[$€£₺]|\b(usd|eur|gbp|try|tl)\b/i;
|
|
22
|
+
function stripQuotes(s) {
|
|
23
|
+
return s.replace(/“[^”]*”|"[^"]*"|«[^»]*»|„[^“]*“/g, " ");
|
|
24
|
+
}
|
|
25
|
+
function context(text, idx, len) {
|
|
26
|
+
const a = Math.max(0, idx - 40);
|
|
27
|
+
const b = Math.min(text.length, idx + len + 40);
|
|
28
|
+
return `…${text.slice(a, b).replace(/\s+/g, " ").trim()}…`;
|
|
29
|
+
}
|
|
30
|
+
/** A short quote of the block around a hit, with the hit marked. */
|
|
31
|
+
function snippet(seg, idx, len) {
|
|
32
|
+
const a = Math.max(0, idx - 50);
|
|
33
|
+
const b = Math.min(seg.length, idx + len + 50);
|
|
34
|
+
const pre = a > 0 ? "…" : "";
|
|
35
|
+
const post = b < seg.length ? "…" : "";
|
|
36
|
+
return `“${pre}${seg.slice(a, idx)}**${seg.slice(idx, idx + len)}**${seg.slice(idx + len, b)}${post}”`.replace(/\s+/g, " ");
|
|
37
|
+
}
|
|
38
|
+
function isInternal(href, domain) {
|
|
39
|
+
const h = href.trim();
|
|
40
|
+
if (!h || h.startsWith("#") || /^(mailto|tel|javascript):/i.test(h))
|
|
41
|
+
return false;
|
|
42
|
+
if (/\.(png|jpe?g|gif|svg|webp|avif|css|js|ico|woff2?|mp4|mp3)(\?|$)/i.test(h))
|
|
43
|
+
return false;
|
|
44
|
+
if (h.startsWith("/") && !h.startsWith("//"))
|
|
45
|
+
return true;
|
|
46
|
+
if (!/^https?:\/\//i.test(h) && !h.startsWith("//"))
|
|
47
|
+
return !h.includes(":"); // relative link
|
|
48
|
+
if (!domain)
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
const host = new URL(h.startsWith("//") ? "https:" + h : h).hostname.replace(/^www\./, "");
|
|
52
|
+
return host === domain || host.endsWith("." + domain);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function llmsBlockquote(llms) {
|
|
59
|
+
if (!llms)
|
|
60
|
+
return undefined;
|
|
61
|
+
const lines = llms.split(/\r?\n/);
|
|
62
|
+
const start = lines.findIndex((l) => /^>\s?/.test(l));
|
|
63
|
+
if (start < 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
const out = [];
|
|
66
|
+
for (let i = start; i < lines.length && /^>\s?/.test(lines[i]); i++)
|
|
67
|
+
out.push(lines[i].replace(/^>\s?/, ""));
|
|
68
|
+
return out.join(" ").trim();
|
|
69
|
+
}
|
|
70
|
+
/** The part of the source that belongs to one entry: its table row, else its section (heading to next heading). */
|
|
71
|
+
function sectionAround(raw, i, len) {
|
|
72
|
+
if (i < 0)
|
|
73
|
+
return "";
|
|
74
|
+
const lineStart = raw.lastIndexOf("\n", i) + 1;
|
|
75
|
+
const lineEnd = raw.indexOf("\n", i) < 0 ? raw.length : raw.indexOf("\n", i);
|
|
76
|
+
const line = raw.slice(lineStart, lineEnd);
|
|
77
|
+
if (/^\s*\|/.test(line) || /<\/?t[dr]\b/i.test(line))
|
|
78
|
+
return line;
|
|
79
|
+
const heads = [...raw.matchAll(/(^|\n)\s{0,3}#{1,6}\s|<h[1-6]\b|<(section|article|li|tr)\b/gi)].map((m) => (m.index ?? 0) + (m[1] ? m[1].length : 0));
|
|
80
|
+
const before = heads.filter((h) => h <= i).pop();
|
|
81
|
+
const after = heads.find((h) => h > i + len);
|
|
82
|
+
const start = before !== undefined && i - before < 1500 ? before : Math.max(0, i - 300);
|
|
83
|
+
const end = after !== undefined && after - i < 2500 ? after : Math.min(raw.length, i + 900);
|
|
84
|
+
return raw.slice(start, end);
|
|
85
|
+
}
|
|
86
|
+
export function lintPage(x, c) {
|
|
87
|
+
const checks = [];
|
|
88
|
+
const t = c.pageType;
|
|
89
|
+
const r = c.research;
|
|
90
|
+
const bs = brandSentence(r);
|
|
91
|
+
const names = r ? brandNames(r) : [];
|
|
92
|
+
const first = firstSentences(x);
|
|
93
|
+
const firstText = first.join(" ");
|
|
94
|
+
const isFileMode = x.source === "file";
|
|
95
|
+
const add = (ch) => checks.push(ch);
|
|
96
|
+
const answerTypes = ["situation", "single-question", "tool", "home", "pricing", "comparison", "other", "about", "glossary"];
|
|
97
|
+
// 1. first sentences answer on their own
|
|
98
|
+
if (answerTypes.includes(t)) {
|
|
99
|
+
if (!first.length) {
|
|
100
|
+
add({
|
|
101
|
+
id: "first-sentences",
|
|
102
|
+
label: "First 1–3 sentences answer on their own",
|
|
103
|
+
status: "fail",
|
|
104
|
+
detail: x.source === "url" ? "No paragraph text after the H1 in the server HTML — the text may be rendered by JavaScript." : "No paragraph text found after the H1.",
|
|
105
|
+
fix: "Put a short answer (what the customer brings → what you do → what they get → which fear is unnecessary) right under the H1 as plain text.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const long = first.filter((s) => countWords(s) > 35);
|
|
110
|
+
const pre = PREAMBLE_RE.test(first[0]);
|
|
111
|
+
const status = pre || countWords(first[0]) > 50 ? "fail" : long.length ? "warn" : "pass";
|
|
112
|
+
add({
|
|
113
|
+
id: "first-sentences",
|
|
114
|
+
label: "First 1–3 sentences answer on their own",
|
|
115
|
+
status,
|
|
116
|
+
detail: first.map((s, i) => `(${i + 1}) ${countWords(s)} words: “${s}”`).join(" "),
|
|
117
|
+
...(status !== "pass"
|
|
118
|
+
? { fix: pre ? "Drop the preamble (“Welcome to…”, “In today's world…”): start with the answer itself." : "Split sentences over 35 words; each of the first three must make sense when quoted alone." }
|
|
119
|
+
: {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// 2. key claim in the lead: the one sentence (else the situation) within the first ~200 characters after the H1
|
|
124
|
+
if (["situation", "home", "tool", "single-question"].includes(t) && first.length) {
|
|
125
|
+
const lead = leadText(x, 200);
|
|
126
|
+
const ref = bs ? { label: "the one sentence", text: bs } : c.situation ? { label: "the situation", text: c.situation.sentence } : undefined;
|
|
127
|
+
if (!ref) {
|
|
128
|
+
add({ id: "key-claim", label: "Key claim in the first ~200 characters", status: "skip", detail: "No one sentence or situation in research to compare with." });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const score = Math.max(containment(ref.text, lead), containment(ref.text, firstText));
|
|
132
|
+
const threshold = t === "home" ? 0.6 : 0.4;
|
|
133
|
+
const status = score >= threshold ? "pass" : score >= threshold / 2 ? "warn" : "fail";
|
|
134
|
+
add({
|
|
135
|
+
id: "key-claim",
|
|
136
|
+
label: "Key claim in the first ~200 characters",
|
|
137
|
+
status,
|
|
138
|
+
detail: `${pct(score)} of ${ref.label}'s words appear right after the H1 (target ≥ ${pct(threshold)}).`,
|
|
139
|
+
...(status !== "pass" ? { fix: `Open with ${bs ? `the one sentence, tailored to the page (“${bs}”)` : "the answer to the situation"}${c.situation ? ` for “${c.situation.sentence}”` : ""}, within the first ~200 characters.` } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// 3. H1
|
|
144
|
+
if (!x.h1.length) {
|
|
145
|
+
add({ id: "h1", label: "H1 present", status: t === "other" ? "warn" : "fail", detail: "No H1 found.", fix: t === "situation" ? "Make the H1 the customer's own sentence." : "Add one H1 that states the page's question or topic." });
|
|
146
|
+
}
|
|
147
|
+
else if (t === "situation" && c.situation) {
|
|
148
|
+
const score = containment(c.situation.sentence, x.h1[0]);
|
|
149
|
+
const branded = names.some((n) => containsPhrase(x.h1[0], n));
|
|
150
|
+
const status = score >= 0.5 && !branded ? "pass" : "warn";
|
|
151
|
+
add({
|
|
152
|
+
id: "h1",
|
|
153
|
+
label: "H1 is the customer's own sentence",
|
|
154
|
+
status,
|
|
155
|
+
detail: `H1 “${x.h1[0]}” — ${pct(score)} of the situation's words${branded ? "; contains the brand name" : ""}.`,
|
|
156
|
+
...(status !== "pass" ? { fix: `Use the customer's sentence as the H1: “${c.situation.sentence}” (no brand name; the URL uses the same words).` } : {}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
add({ id: "h1", label: "H1 present", status: "pass", detail: `“${x.h1[0]}”${x.h1.length > 1 ? ` (+${x.h1.length - 1} more; fine for Google, but one is tidier)` : ""}` });
|
|
161
|
+
}
|
|
162
|
+
// 4. one-sentence consistency (page / meta description / llms.txt vs research)
|
|
163
|
+
if (["home", "about", "situation", "pricing", "other", "tool"].includes(t)) {
|
|
164
|
+
if (!bs) {
|
|
165
|
+
add({ id: "one-sentence", label: "One sentence matches research and llms.txt", status: "skip", detail: "Research has no one sentence yet (katman_research: brand.oneLiner)." });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const firstPara = firstText || x.blocks.slice(x.afterH1, x.afterH1 + 2).join(" ");
|
|
169
|
+
const page = containment(bs, firstPara);
|
|
170
|
+
const meta = x.description !== undefined ? containment(bs, x.description) : undefined;
|
|
171
|
+
const quote = llmsBlockquote(c.llmsTxt);
|
|
172
|
+
const llms = quote !== undefined ? containment(bs, quote) : undefined;
|
|
173
|
+
const parts = [`page lead ${pct(page)}`, meta !== undefined ? `meta description ${pct(meta)}` : "meta description not found", llms !== undefined ? `llms.txt ${pct(llms)}` : c.llmsTxt ? "llms.txt has no > quote line" : "no llms.txt found"];
|
|
174
|
+
let status;
|
|
175
|
+
if (t === "home")
|
|
176
|
+
status = page >= 0.7 && (meta === undefined || meta >= 0.7) ? "pass" : page < 0.4 ? "fail" : "warn";
|
|
177
|
+
else
|
|
178
|
+
status = page >= 0.5 || (meta ?? 0) >= 0.5 ? "pass" : "warn";
|
|
179
|
+
if (llms !== undefined && llms < 0.9 && status === "pass")
|
|
180
|
+
status = "warn";
|
|
181
|
+
const fixes = [];
|
|
182
|
+
if (page < (t === "home" ? 0.7 : 0.5))
|
|
183
|
+
fixes.push(`put the one sentence in the first paragraph (“${bs}”)`);
|
|
184
|
+
if (meta !== undefined && meta < 0.7)
|
|
185
|
+
fixes.push("use the same sentence as the meta description");
|
|
186
|
+
if (llms !== undefined && llms < 0.9)
|
|
187
|
+
fixes.push("regenerate llms.txt so its > line is the same sentence (katman_generate llms.txt)");
|
|
188
|
+
add({ id: "one-sentence", label: "One sentence matches research and llms.txt", status, detail: `Share of the research sentence's words found: ${parts.join(" · ")}.`, ...(fixes.length ? { fix: fixes.join("; ") + "." } : {}) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// 5. hype words (outside quotes)
|
|
192
|
+
const segments = [x.title ?? "", ...x.headings.map((h) => h.text), ...x.blocks].filter(Boolean).map(stripQuotes);
|
|
193
|
+
{
|
|
194
|
+
const hits = [];
|
|
195
|
+
const res = [...HYPE_WORDS_EN, ...HYPE_WORDS_TR].map((w) => w.startsWith("#") ? new RegExp(escapeRegExp(w) + "(?!\\d)", "gi") : new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(w)}(?=$|[^\\p{L}\\p{N}])`, "giu"));
|
|
196
|
+
for (const seg of segments) {
|
|
197
|
+
for (const re of res) {
|
|
198
|
+
for (const m of seg.matchAll(re)) {
|
|
199
|
+
const at = m.index ?? 0;
|
|
200
|
+
if (/best practices?/i.test(seg.slice(at, at + 20)))
|
|
201
|
+
continue;
|
|
202
|
+
hits.push(snippet(seg, at + (m[1]?.length ?? 0), m[0].length - (m[1]?.length ?? 0)));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (hits.length >= 6)
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const status = !hits.length ? "pass" : hits.length >= 3 ? "fail" : "warn";
|
|
209
|
+
add({
|
|
210
|
+
id: "hype",
|
|
211
|
+
label: "No hype words",
|
|
212
|
+
status,
|
|
213
|
+
detail: hits.length ? `${hits.length} found: ${hits.slice(0, 4).join(" | ")}` : "None found.",
|
|
214
|
+
...(hits.length ? { fix: "Replace quality words (best, perfect, #1, en iyi, mükemmel…) with a concrete, sourced fact — or a quote from someone else, in quotation marks." } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// 6. numbers without a nearby source/date
|
|
218
|
+
{
|
|
219
|
+
const unsourced = [];
|
|
220
|
+
const blocks = x.blocks.length ? x.blocks : [x.text];
|
|
221
|
+
for (const b of blocks) {
|
|
222
|
+
const ss = splitSentences(b);
|
|
223
|
+
for (let i = 0; i < ss.length; i++) {
|
|
224
|
+
const s = ss[i];
|
|
225
|
+
const m = CLAIM_NUM_RE.exec(s);
|
|
226
|
+
if (!m)
|
|
227
|
+
continue;
|
|
228
|
+
if (CURRENCY_NEAR.test(s.slice(Math.max(0, (m.index ?? 0) - 3), (m.index ?? 0) + m[0].length + 3)))
|
|
229
|
+
continue;
|
|
230
|
+
if (SOURCE_MARK_RE.test(s) || (ss[i + 1] && SOURCE_MARK_RE.test(ss[i + 1])))
|
|
231
|
+
continue;
|
|
232
|
+
unsourced.push(`“${s.length > 140 ? s.slice(0, 137) + "…" : s}”`);
|
|
233
|
+
if (unsourced.length >= 6)
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (unsourced.length >= 6)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
const status = !unsourced.length ? "pass" : unsourced.length >= 3 ? "fail" : "warn";
|
|
240
|
+
add({
|
|
241
|
+
id: "numbers",
|
|
242
|
+
label: "Numbers have a source or a date",
|
|
243
|
+
status,
|
|
244
|
+
detail: unsourced.length ? `${unsourced.length} claim(s) without a nearby source or date: ${unsourced.slice(0, 4).join(" ")}` : "No unsourced percentages, multipliers or counts found.",
|
|
245
|
+
...(unsourced.length ? { fix: "Next to every number: its source (link) and date — only numbers that are in research.proof. If the test was small, say so; otherwise remove the number." } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// 7. placeholders
|
|
249
|
+
{
|
|
250
|
+
const hay = `${x.title ?? ""}\n${x.description ?? ""}\n${x.headings.map((h) => h.text).join("\n")}\n${x.text}`;
|
|
251
|
+
const m = PLACEHOLDER_RE.exec(hay);
|
|
252
|
+
add({
|
|
253
|
+
id: "placeholders",
|
|
254
|
+
label: "No placeholders left",
|
|
255
|
+
status: m ? "fail" : "pass",
|
|
256
|
+
detail: m ? `Found ${context(hay, m.index, m[0].length)}` : "No [FILL], [DOLDUR], TODO or lorem ipsum.",
|
|
257
|
+
...(m ? { fix: "Fill every placeholder from research, or ask the user; never publish a page with [FILL]." } : {}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// 8. "free" for a paid product
|
|
261
|
+
{
|
|
262
|
+
const pricing = isFilled(r?.brand.pricing) ? r.brand.pricing : undefined;
|
|
263
|
+
if (!pricing) {
|
|
264
|
+
add({ id: "free", label: "No “free” for a paid product", status: "skip", detail: "No pricing in research to compare with." });
|
|
265
|
+
}
|
|
266
|
+
else if (/\bfree\b|ücretsiz|bedava|gratis|\$\s?0\b|\b0\s?(usd|\$|€|£|tl)/i.test(pricing)) {
|
|
267
|
+
add({ id: "free", label: "No “free” for a paid product", status: "pass", detail: `Research pricing mentions a free option (“${pricing}”).` });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// "free" next to a tool/resource word (free VAT calculator, free checker, free guide, ücretsiz araç) is a
|
|
271
|
+
// genuinely free side offer, which the method recommends (free tools are link magnets): not a violation.
|
|
272
|
+
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;
|
|
273
|
+
const hits = [];
|
|
274
|
+
for (const seg of [...segments, stripQuotes(x.description ?? "")]) {
|
|
275
|
+
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);
|
|
277
|
+
if (FREE_SIDE_OFFER.test(after))
|
|
278
|
+
continue;
|
|
279
|
+
if (hits.length < 4)
|
|
280
|
+
hits.push(snippet(seg, m.index ?? 0, m[0].length));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
add({
|
|
284
|
+
id: "free",
|
|
285
|
+
label: "No “free” for a paid product",
|
|
286
|
+
status: hits.length ? "fail" : "pass",
|
|
287
|
+
detail: hits.length
|
|
288
|
+
? `Research pricing is paid (“${pricing}”) but the page says “free” (not about a free tool, guide, trial or plan): ${hits.join(" | ")}`
|
|
289
|
+
: "The page doesn't call the paid product free (free tools, guides and trials are fine).",
|
|
290
|
+
...(hits.length ? { fix: "If this calls the paid product free, remove it (appendix D); if it's a genuinely free tool, trial or plan, say exactly what is free." } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// 9. update date
|
|
295
|
+
if (["situation", "comparison", "single-question", "tool", "other", "pricing", "glossary"].includes(t)) {
|
|
296
|
+
add({
|
|
297
|
+
id: "updated",
|
|
298
|
+
label: "Update date visible",
|
|
299
|
+
status: x.hasDate ? "pass" : t === "comparison" ? "fail" : "warn",
|
|
300
|
+
detail: x.hasDate ? "An update date / dateModified was found." : "No “Updated …” date or dateModified found.",
|
|
301
|
+
...(x.hasDate ? {} : { fix: "Show “Updated: <date> · <author>” at the bottom, and change it only when the words change (the sample site's pages all carried the launch date)." }),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// 10. FAQ 5–8
|
|
305
|
+
{
|
|
306
|
+
const n = x.faqQuestions.length;
|
|
307
|
+
if (["situation", "tool"].includes(t)) {
|
|
308
|
+
const status = n >= 5 && n <= 8 ? "pass" : n === 0 ? "fail" : "warn";
|
|
309
|
+
add({ id: "faq", label: "FAQ with 5–8 questions", status, detail: `${n} question(s) found${n ? `: ${x.faqQuestions.slice(0, 3).map((q) => `“${q}”`).join(", ")}${n > 3 ? "…" : ""}` : ""}.`, ...(status !== "pass" ? { fix: "An FAQ block with 5–8 questions in the customer's words; each answer's first sentence is the answer." } : {}) });
|
|
310
|
+
}
|
|
311
|
+
else if (t === "faq") {
|
|
312
|
+
add({ id: "faq", label: "At least 5 real questions", status: n >= 5 ? "pass" : "warn", detail: `${n} question(s) found.`, ...(n >= 5 ? {} : { fix: "Use the customers' real questions (support e-mails, sales calls)." }) });
|
|
313
|
+
}
|
|
314
|
+
else if (n) {
|
|
315
|
+
add({ id: "faq", label: "FAQ", status: "pass", detail: `${n} question(s) found.` });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 11. not-for section
|
|
319
|
+
if (["situation", "tool"].includes(t)) {
|
|
320
|
+
const hay = `${x.headings.map((h) => h.text).join("\n")}\n${x.text}`;
|
|
321
|
+
const ok = NOT_FOR_RE.test(hay);
|
|
322
|
+
add({ id: "not-for", label: "“Who it's not for” section", status: ok ? "pass" : t === "situation" ? "fail" : "warn", detail: ok ? "Found." : "No “not for” section found.", ...(ok ? {} : { fix: "Add “Who it's not for” with 2–3 honest bullets: assistants quote one-sided pages less." }) });
|
|
323
|
+
}
|
|
324
|
+
// 12. alternatives
|
|
325
|
+
if (["situation", "comparison"].includes(t)) {
|
|
326
|
+
const hay = `${x.headings.map((h) => h.text).join("\n")}\n${x.text}`;
|
|
327
|
+
const compNames = (r?.competitors ?? []).map((cc) => cc.name).filter((n) => containsPhrase(hay, n));
|
|
328
|
+
const ok = ALT_RE.test(hay) || compNames.length >= 2;
|
|
329
|
+
add({
|
|
330
|
+
id: "alternatives",
|
|
331
|
+
label: "Alternatives named honestly",
|
|
332
|
+
status: ok ? "pass" : t === "comparison" ? "fail" : "warn",
|
|
333
|
+
detail: ok ? `Found${compNames.length ? ` (competitors named: ${compNames.join(", ")})` : ""}.` : "No alternatives section and fewer than 2 competitors named.",
|
|
334
|
+
...(ok ? {} : { fix: "Add “Alternatives” naming the competitors from research and whom each one fits (facts from their own sites, with dates)." }),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
// 13–15. comparison-specific
|
|
338
|
+
if (t === "comparison") {
|
|
339
|
+
const raw = x.raw;
|
|
340
|
+
const comps = (r?.competitors ?? []).filter((cc) => containsPhrase(raw, cc.name));
|
|
341
|
+
if (!comps.length) {
|
|
342
|
+
add({ id: "competitor-sources", label: "A source and a date next to every competitor", status: "warn", detail: "None of the research competitors appear on the page.", fix: "Cover the competitors in research (P7), each with its source link and the date you checked." });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const lacking = [];
|
|
346
|
+
for (const cc of comps) {
|
|
347
|
+
const i = raw.search(new RegExp(escapeRegExp(cc.name), "i"));
|
|
348
|
+
const win = sectionAround(raw, i, cc.name.length);
|
|
349
|
+
const hasSrc = /https?:\/\/|href=|\]\(/.test(win);
|
|
350
|
+
const hasDate = /\b(19|20)\d{2}-\d{2}(-\d{2})?\b|\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b|\b(checked|as of|kontrol)\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]* \d{1,2},? \d{4}\b/i.test(win);
|
|
351
|
+
if (!hasSrc || !hasDate)
|
|
352
|
+
lacking.push(`${cc.name}${!hasSrc ? " (no source link)" : ""}${!hasDate ? " (no date)" : ""}`);
|
|
353
|
+
}
|
|
354
|
+
add({ id: "competitor-sources", label: "A source and a date next to every competitor", status: lacking.length ? "fail" : "pass", detail: lacking.length ? `Missing: ${lacking.join("; ")}.` : `All ${comps.length} named competitors have a source and a date nearby.`, ...(lacking.length ? { fix: "Next to each competitor fact: a link to its own page and the date you checked it (a comparison once said a competitor “can't” do what it could)." } : {}) });
|
|
355
|
+
}
|
|
356
|
+
const brand = names[0];
|
|
357
|
+
if (brand) {
|
|
358
|
+
const i = raw.search(new RegExp(escapeRegExp(brand), "i"));
|
|
359
|
+
const win = i >= 0 ? sectionAround(raw, i, brand.length) : "";
|
|
360
|
+
const ok = i >= 0 && OWN_GAPS_RE.test(win);
|
|
361
|
+
add({ id: "own-gaps", label: "Our own gaps are written down", status: ok ? "pass" : "fail", detail: ok ? "The entry for us says what it doesn't do." : i < 0 ? "The brand isn't on the page." : "No “what it doesn't do” near the brand's entry.", ...(ok ? {} : { fix: "Write “who it fits” and “what it doesn't do” for us too (P7)." }) });
|
|
362
|
+
const productHeads = x.headings.filter((h) => h.level >= 2 && h.level <= 3 && (containsPhrase(h.text, brand) || (r?.competitors ?? []).some((cc) => containsPhrase(h.text, cc.name))));
|
|
363
|
+
if (productHeads.length >= 2) {
|
|
364
|
+
const firstIsUs = containsPhrase(productHeads[0].text, brand);
|
|
365
|
+
add({ id: "brand-not-first", label: "Brand not ranked first", status: firstIsUs ? "fail" : "pass", detail: `First tool heading: “${productHeads[0].text}”.`, ...(firstIsUs ? { fix: "Don't put yourself first: self-ranked lists backfire (the brand was left out 69% of the time when its own list was cited) and carry spam-policy risk." } : {}) });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// 16. pricing matches research
|
|
370
|
+
if (t === "pricing" || t === "home") {
|
|
371
|
+
const pricing = isFilled(r?.brand.pricing) ? r.brand.pricing : undefined;
|
|
372
|
+
const prices = pricing ? parsePrices(pricing) : [];
|
|
373
|
+
if (!prices.length) {
|
|
374
|
+
if (t === "pricing")
|
|
375
|
+
add({ id: "pricing-match", label: "Prices match research and llms.txt", status: "skip", detail: pricing ? `No amounts could be read from research pricing (“${pricing}”).` : "No pricing in research." });
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const text = x.text.replace(/\s+/g, " ");
|
|
379
|
+
const missing = prices.filter((p) => !new RegExp(`(^|[^\\d])${escapeRegExp(String(p.price))}([.,]0+)?([^\\d]|$)`).test(text));
|
|
380
|
+
add({ id: "pricing-match", label: "Prices match research and llms.txt", status: missing.length ? (t === "pricing" ? "fail" : "warn") : "pass", detail: missing.length ? `Not on the page: ${missing.map((p) => `${p.price} ${p.currency}`).join(", ")} (research: “${pricing}”).` : "Every research price appears on the page.", ...(missing.length ? { fix: "Prices come from one source: make the page, research, llms.txt and schema say the same thing." } : {}) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// 17. not-X on About
|
|
384
|
+
if (t === "about") {
|
|
385
|
+
if (hasNotX(r?.brand.notX)) {
|
|
386
|
+
const score = containment(r.brand.notX, x.text);
|
|
387
|
+
add({ id: "not-x", label: "Not-X note on the page", status: score >= 0.6 ? "pass" : "warn", detail: `${pct(score)} of the not-X note's words found.`, ...(score >= 0.6 ? {} : { fix: `Say it plainly on About (and FAQ, llms.txt, Organization schema): “${r.brand.notX}”.` }) });
|
|
388
|
+
}
|
|
389
|
+
else
|
|
390
|
+
add({ id: "not-x", label: "Not-X note on the page", status: "skip", detail: "Research has no not-X note (fine if the brand isn't confused with anything)." });
|
|
391
|
+
}
|
|
392
|
+
// 18. internal links
|
|
393
|
+
{
|
|
394
|
+
const pool = x.contentLinks ?? x.links;
|
|
395
|
+
const internal = pool.filter((h) => isInternal(h, c.siteDomain));
|
|
396
|
+
const all = x.links.filter((h) => isInternal(h, c.siteDomain));
|
|
397
|
+
const need = ["situation", "comparison", "single-question", "tool", "home"].includes(t) ? 3 : 1;
|
|
398
|
+
const n = new Set(internal).size;
|
|
399
|
+
let status = n >= need ? "pass" : n === 0 && need > 1 ? "fail" : "warn";
|
|
400
|
+
let detail = `${n} internal link(s) in the content${x.contentLinks && all.length > internal.length ? ` (${new Set(all).size} including navigation)` : ""}.`;
|
|
401
|
+
if (x.kind === "jsx" && !x.links.length) {
|
|
402
|
+
status = "warn";
|
|
403
|
+
detail = "No <a>/<Link> found in the source (links may come from shared components).";
|
|
404
|
+
}
|
|
405
|
+
add({ id: "internal-links", label: `≥${need} internal link${need > 1 ? "s" : ""}`, status, detail, ...(status !== "pass" ? { fix: "Link 3–5 related pages with descriptive anchor text (not “click here”), and make sure other pages link here too." } : {}) });
|
|
406
|
+
}
|
|
407
|
+
// 19. title length
|
|
408
|
+
if (x.title !== undefined) {
|
|
409
|
+
const len = x.title.length;
|
|
410
|
+
const status = len <= 60 ? "pass" : len <= 70 ? "warn" : "fail";
|
|
411
|
+
let detail = `${len} characters: “${x.title}”.`;
|
|
412
|
+
let fix = status !== "pass" ? "About 60 characters; the situation first, then the brand (drop the brand suffix if it goes over)." : undefined;
|
|
413
|
+
if (t === "situation" && c.situation && containment(c.situation.sentence, x.title) < 0.4) {
|
|
414
|
+
detail += " It doesn't start from the situation.";
|
|
415
|
+
fix = `Start the title with the situation (“${c.situation.sentence}”), then the brand.`;
|
|
416
|
+
}
|
|
417
|
+
add({ id: "title", label: "Title ≈60 characters", status: fix && status === "pass" ? "warn" : status, detail, ...(fix ? { fix } : {}) });
|
|
418
|
+
}
|
|
419
|
+
else if (!isFileMode) {
|
|
420
|
+
add({ id: "title", label: "Title ≈60 characters", status: "fail", detail: "No <title> in the HTML.", fix: "Add a unique <title> (P3)." });
|
|
421
|
+
}
|
|
422
|
+
// 20. meta description length
|
|
423
|
+
if (x.description !== undefined) {
|
|
424
|
+
const len = x.description.length;
|
|
425
|
+
const status = len >= 150 && len <= 160 ? "pass" : len < 70 ? "fail" : "warn";
|
|
426
|
+
add({ id: "description", label: "Meta description 150–160 characters", status, detail: `${len} characters.`, ...(status !== "pass" ? { fix: len > 160 ? "Cut it at the last full sentence within 160 characters." : "Write the one sentence as the description, about 150–160 characters." } : {}) });
|
|
427
|
+
}
|
|
428
|
+
else if (!isFileMode) {
|
|
429
|
+
add({ id: "description", label: "Meta description 150–160 characters", status: "fail", detail: "No meta description in the HTML.", fix: "Add a unique meta description built from the one sentence (P3)." });
|
|
430
|
+
}
|
|
431
|
+
// 21. URL mode: JS-off text, robots meta, JSON-LD
|
|
432
|
+
if (x.source === "url") {
|
|
433
|
+
const w = x.wordsJsOff ?? 0;
|
|
434
|
+
// lead-approved thresholds: < 50 words = text is in JavaScript (critical); 50–99 = thin page
|
|
435
|
+
const jsStatus = w < 50 ? "fail" : w < 100 ? "warn" : w < 250 && t === "situation" ? "warn" : "pass";
|
|
436
|
+
add({
|
|
437
|
+
id: "js-off",
|
|
438
|
+
label: "Text visible without JavaScript",
|
|
439
|
+
status: jsStatus,
|
|
440
|
+
detail: `${w} words in the raw HTML (what OAI-SearchBot and other AI crawlers see)${w < 50 ? ": almost nothing to read — the text is rendered by JavaScript, or the page is nearly empty" : w < 100 ? ": a thin page" : ""}.`,
|
|
441
|
+
...(jsStatus !== "pass" ? { fix: w < 50 ? "If the text is rendered by JavaScript, render it on the server or prerender it (P2) — AI crawlers don't run JavaScript; otherwise write the answer as plain HTML text." : "Add the missing written blocks as plain HTML text (how it works, not-for, alternatives, FAQ)." } : {}),
|
|
442
|
+
});
|
|
443
|
+
if (x.robotsMeta && /noindex|nosnippet|none/i.test(x.robotsMeta)) {
|
|
444
|
+
add({ id: "robots-meta", label: "No unwanted noindex / nosnippet", status: "fail", detail: `meta robots: “${x.robotsMeta}”.`, fix: "Remove noindex/nosnippet from pages you want found and quoted (nosnippet keeps AI Overviews from citing the page)." });
|
|
445
|
+
}
|
|
446
|
+
if (x.jsonLdErrors)
|
|
447
|
+
add({ id: "schema", label: "JSON-LD is valid JSON", status: "fail", detail: `${x.jsonLdErrors} block(s) don't parse.`, fix: "Generate JSON-LD with JSON.stringify from the page data (P5)." });
|
|
448
|
+
}
|
|
449
|
+
// 22. thin page
|
|
450
|
+
if (["situation", "comparison", "single-question"].includes(t)) {
|
|
451
|
+
const words = countWords(x.text);
|
|
452
|
+
if (words < 250)
|
|
453
|
+
add({ id: "length", label: "Enough text to answer", status: "warn", detail: `${words} words in the content.`, fix: "Add the missing blocks (how it works, not-for, alternatives, FAQ) rather than filler." });
|
|
454
|
+
}
|
|
455
|
+
return checks;
|
|
456
|
+
}
|
|
457
|
+
async function findLlms(root, staticDir, llmsPath) {
|
|
458
|
+
const candidates = [llmsPath, staticDir ? `${staticDir}/llms.txt` : "llms.txt", "public/llms.txt", "static/llms.txt", "llms.txt"].filter(Boolean);
|
|
459
|
+
for (const c of [...new Set(candidates)]) {
|
|
460
|
+
const f = await readProjectFile(root, c, 500_000);
|
|
461
|
+
if (f.text !== undefined)
|
|
462
|
+
return f.text;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
export async function runLint(root, args) {
|
|
467
|
+
const [research, config, scan] = await Promise.all([loadResearchSafe(root), loadConfigSafe(root), loadScan(root)]);
|
|
468
|
+
let x;
|
|
469
|
+
let pagePath;
|
|
470
|
+
if (args.file) {
|
|
471
|
+
const f = await readProjectFile(root, args.file);
|
|
472
|
+
if (f.text === undefined)
|
|
473
|
+
return { text: `Can't read ${args.file}: ${f.error}`, isError: true };
|
|
474
|
+
x = extractFile(f.text, args.file, path.extname(args.file).toLowerCase());
|
|
475
|
+
if (path.basename(args.file).toLowerCase() === "llms.txt")
|
|
476
|
+
x.notes.push("This is llms.txt: the one-sentence and placeholder checks matter most here.");
|
|
477
|
+
}
|
|
478
|
+
else if (args.url) {
|
|
479
|
+
let u;
|
|
480
|
+
try {
|
|
481
|
+
u = new URL(/^https?:\/\//i.test(args.url) ? args.url : `https://${args.url}`);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return { text: `"${args.url}" isn't a valid URL.`, isError: true };
|
|
485
|
+
}
|
|
486
|
+
const res = await httpGet(u.toString(), { redirect: "follow", timeoutMs: 20000 });
|
|
487
|
+
if (res.error || !res.status)
|
|
488
|
+
return { text: `Couldn't fetch ${u}: ${res.error ?? "no response"}.`, isError: true };
|
|
489
|
+
const ct = res.headers["content-type"] ?? "";
|
|
490
|
+
x = /html/i.test(ct) || /<html|<body/i.test(res.body.slice(0, 2000)) ? extractHtml(res.body, u.toString(), "url") : extractMarkdown(res.body, u.toString(), "url");
|
|
491
|
+
x.status = res.status;
|
|
492
|
+
if (x.kind !== "html")
|
|
493
|
+
x.wordsJsOff = countWords(res.body);
|
|
494
|
+
pagePath = normPath(u.pathname);
|
|
495
|
+
}
|
|
496
|
+
else if (args.text) {
|
|
497
|
+
x = extractMarkdown(args.text, "text", "text");
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
return { text: "Pass one of: file (path in the project), url (a live page) or text (the page copy).", isError: true };
|
|
501
|
+
}
|
|
502
|
+
// situation: by id, by URL, or by H1 similarity
|
|
503
|
+
let situation;
|
|
504
|
+
if (args.situationId) {
|
|
505
|
+
situation = research?.situations.find((s) => s.id === args.situationId);
|
|
506
|
+
if (!situation)
|
|
507
|
+
return { text: `Situation "${args.situationId}" not found in research (${(research?.situations ?? []).map((s) => s.id).join(", ") || "none"}).`, isError: true };
|
|
508
|
+
}
|
|
509
|
+
else if (research?.situations.length && (args.pageType === "situation" || !args.pageType)) {
|
|
510
|
+
if (pagePath)
|
|
511
|
+
situation = research.situations.find((s) => normPath(situationUrl(s, research.site.languages)) === pagePath);
|
|
512
|
+
if (!situation && x.h1[0]) {
|
|
513
|
+
const best = research.situations.map((s) => ({ s, score: containment(s.sentence, x.h1[0]) })).sort((a, b) => b.score - a.score)[0];
|
|
514
|
+
if (best && best.score >= 0.5)
|
|
515
|
+
situation = best.s;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const pageType = args.pageType ?? (situation ? "situation" : "other");
|
|
519
|
+
const staticDir = staticDirFor(root, scan).dir;
|
|
520
|
+
let llmsTxt = await findLlms(root, staticDir, scan?.files.llms.path);
|
|
521
|
+
if (!llmsTxt && args.url) {
|
|
522
|
+
try {
|
|
523
|
+
const res = await httpGet(new URL("/llms.txt", args.url.startsWith("http") ? args.url : `https://${args.url}`).toString(), { redirect: "follow", timeoutMs: 10000 });
|
|
524
|
+
if (res.status === 200 && !/<html/i.test(res.body.slice(0, 500)))
|
|
525
|
+
llmsTxt = res.body;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// ignore
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const domain = research && isFilled(research.site.domain) ? research.site.domain : config.siteUrl ? new URL(config.siteUrl).hostname.replace(/^www\./, "") : pagePath && args.url ? new URL(args.url.startsWith("http") ? args.url : `https://${args.url}`).hostname.replace(/^www\./, "") : undefined;
|
|
532
|
+
const checks = lintPage(x, { pageType, research, situation, llmsTxt, siteDomain: domain });
|
|
533
|
+
if (x.source === "url" && x.status && x.status !== 200)
|
|
534
|
+
checks.unshift({ id: "status", label: "Returns 200", status: "fail", detail: `HTTP ${x.status}.`, fix: "Public pages must return 200 (P14)." });
|
|
535
|
+
const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
|
|
536
|
+
for (const ch of checks)
|
|
537
|
+
counts[ch.status]++;
|
|
538
|
+
const icon = { pass: "PASS", warn: "WARN", fail: "FAIL", skip: "skip" };
|
|
539
|
+
const fixes = checks.filter((ch) => ch.fix && (ch.status === "fail" || ch.status === "warn"));
|
|
540
|
+
const parts = [
|
|
541
|
+
`# Content check: ${x.label}`,
|
|
542
|
+
`Page type: **${pageType}**${situation ? ` · situation \`${situation.id}\` “${situation.sentence}”` : ""} · ${counts.pass} pass · ${counts.warn} warn · ${counts.fail} fail${counts.skip ? ` · ${counts.skip} skipped` : ""}`,
|
|
543
|
+
mdTable(["Check", "Result", "Details"], checks.map((ch) => [ch.label, icon[ch.status], ch.detail])),
|
|
544
|
+
];
|
|
545
|
+
if (fixes.length)
|
|
546
|
+
parts.push(`## Fixes\n${fixes.map((f, i) => `${i + 1}. **${f.label}** — ${f.fix}`).join("\n")}`);
|
|
547
|
+
if (!research)
|
|
548
|
+
parts.push("No research yet: the one-sentence, pricing and situation checks were skipped. Run katman_research.");
|
|
549
|
+
if (x.notes.length)
|
|
550
|
+
parts.push(x.notes.map((n) => `_${n}_`).join("\n"));
|
|
551
|
+
parts.push(counts.fail ? "Fix every FAIL, then run katman_lint_content again. Don't tick the task until it passes." : "No failures. After deploy, verify the live page with katman_audit_site (urls mode).");
|
|
552
|
+
return {
|
|
553
|
+
text: parts.join("\n\n"),
|
|
554
|
+
structured: { pageType, situationId: situation?.id ?? null, counts, checks: checks.map(({ id, label, status, detail, fix }) => ({ id, label, status, detail, ...(fix ? { fix } : {}) })) },
|
|
555
|
+
};
|
|
556
|
+
}
|