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,448 @@
|
|
|
1
|
+
import { domainOf, mdTable, normalizeSiteUrl } from "../../../util.js";
|
|
2
|
+
import { FILL, brandSentence, containsPhrase, hasNotX, hasPlaceholder, isFilled, isoDay, normKey } from "../lib/text.js";
|
|
3
|
+
import { runDate } from "../lib/state.js";
|
|
4
|
+
export function emptyResearch(siteUrl, languages = []) {
|
|
5
|
+
let url = "";
|
|
6
|
+
let domain = "";
|
|
7
|
+
if (siteUrl) {
|
|
8
|
+
try {
|
|
9
|
+
url = normalizeSiteUrl(siteUrl);
|
|
10
|
+
domain = domainOf(url);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// leave empty
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
version: 1,
|
|
18
|
+
updatedAt: new Date().toISOString(),
|
|
19
|
+
site: { url, domain, languages },
|
|
20
|
+
brand: { name: "", category: "" },
|
|
21
|
+
situations: [],
|
|
22
|
+
competitors: [],
|
|
23
|
+
proof: [],
|
|
24
|
+
keywords: [],
|
|
25
|
+
questions: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** research.json skeleton with [FILL] markers (book ch. 7 template). */
|
|
29
|
+
export function templateResearch(siteUrl, languages = []) {
|
|
30
|
+
const r = emptyResearch(siteUrl, languages);
|
|
31
|
+
if (!r.site.url) {
|
|
32
|
+
r.site.url = FILL;
|
|
33
|
+
r.site.domain = FILL;
|
|
34
|
+
}
|
|
35
|
+
if (!r.site.languages.length)
|
|
36
|
+
r.site.languages = ["en"];
|
|
37
|
+
r.brand = {
|
|
38
|
+
name: FILL,
|
|
39
|
+
alternateNames: [],
|
|
40
|
+
category: FILL,
|
|
41
|
+
notX: FILL,
|
|
42
|
+
oneLiner: { brings: FILL, weDo: FILL, gets: FILL, fear: FILL },
|
|
43
|
+
pricing: FILL,
|
|
44
|
+
company: FILL,
|
|
45
|
+
country: FILL,
|
|
46
|
+
socials: [],
|
|
47
|
+
};
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// merge
|
|
52
|
+
const cleanStr = (s) => (typeof s === "string" && s.trim() ? s.trim() : undefined);
|
|
53
|
+
function nextId(prefix, existing) {
|
|
54
|
+
let max = 0;
|
|
55
|
+
for (const id of existing) {
|
|
56
|
+
const m = new RegExp(`^${prefix}(\\d+)$`).exec(id);
|
|
57
|
+
if (m)
|
|
58
|
+
max = Math.max(max, Number(m[1]));
|
|
59
|
+
}
|
|
60
|
+
return `${prefix}${max + 1}`;
|
|
61
|
+
}
|
|
62
|
+
function unionStrings(a = [], b = []) {
|
|
63
|
+
const out = [...a];
|
|
64
|
+
for (const x of b)
|
|
65
|
+
if (x && !out.some((y) => normKey(y) === normKey(x)))
|
|
66
|
+
out.push(x);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function normUrlPath(u) {
|
|
70
|
+
let s = u.trim();
|
|
71
|
+
if (/^https?:\/\//i.test(s)) {
|
|
72
|
+
try {
|
|
73
|
+
s = new URL(s).pathname;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// keep
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!s.startsWith("/"))
|
|
80
|
+
s = "/" + s;
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
/** Merge `input` into `base`. Lists merge by id / name / sentence unless replaceLists (then given lists replace). */
|
|
84
|
+
export function mergeResearch(base, input, replaceLists = false) {
|
|
85
|
+
const r = JSON.parse(JSON.stringify(base));
|
|
86
|
+
const changes = [];
|
|
87
|
+
if (input.site) {
|
|
88
|
+
if (cleanStr(input.site.url)) {
|
|
89
|
+
try {
|
|
90
|
+
const url = normalizeSiteUrl(input.site.url);
|
|
91
|
+
r.site.url = url;
|
|
92
|
+
r.site.domain = domainOf(url);
|
|
93
|
+
changes.push(`site.url = ${url}`);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
r.site.url = input.site.url.trim();
|
|
97
|
+
changes.push(`site.url = ${r.site.url} (not a valid URL)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(input.site.languages) && input.site.languages.length) {
|
|
101
|
+
r.site.languages = [...new Set(input.site.languages.map((l) => l.trim().toLowerCase()).filter(Boolean))];
|
|
102
|
+
changes.push(`site.languages = ${r.site.languages.join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(input.site.previewUrls)) {
|
|
105
|
+
r.site.previewUrls = replaceLists ? input.site.previewUrls : unionStrings(r.site.previewUrls, input.site.previewUrls);
|
|
106
|
+
changes.push("site.previewUrls updated");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (input.brand) {
|
|
110
|
+
const b = input.brand;
|
|
111
|
+
const scalar = ["name", "category", "notX", "sentence", "pricing", "company", "country"];
|
|
112
|
+
for (const k of scalar) {
|
|
113
|
+
const v = cleanStr(b[k]);
|
|
114
|
+
if (v !== undefined) {
|
|
115
|
+
r.brand[k] = v;
|
|
116
|
+
changes.push(`brand.${k}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (b.oneLiner) {
|
|
120
|
+
const cur = { brings: "", weDo: "", gets: "", fear: "", ...(r.brand.oneLiner ?? {}) };
|
|
121
|
+
for (const k of ["brings", "weDo", "gets", "fear"]) {
|
|
122
|
+
const v = cleanStr(b.oneLiner[k]);
|
|
123
|
+
if (v !== undefined) {
|
|
124
|
+
cur[k] = v;
|
|
125
|
+
changes.push(`brand.oneLiner.${k}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
r.brand.oneLiner = cur;
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(b.alternateNames)) {
|
|
131
|
+
r.brand.alternateNames = replaceLists ? b.alternateNames.filter(Boolean) : unionStrings(r.brand.alternateNames, b.alternateNames);
|
|
132
|
+
changes.push("brand.alternateNames");
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(b.socials)) {
|
|
135
|
+
r.brand.socials = replaceLists ? b.socials.filter(Boolean) : unionStrings(r.brand.socials, b.socials);
|
|
136
|
+
changes.push("brand.socials");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (input.situations) {
|
|
140
|
+
const list = replaceLists ? [] : r.situations.map((s) => ({ ...s }));
|
|
141
|
+
let added = 0;
|
|
142
|
+
let updated = 0;
|
|
143
|
+
for (const s of input.situations) {
|
|
144
|
+
const sentence = cleanStr(s.sentence);
|
|
145
|
+
if (!sentence)
|
|
146
|
+
continue;
|
|
147
|
+
const found = (s.id ? list.find((x) => x.id === s.id) : undefined) ?? list.find((x) => normKey(x.sentence) === normKey(sentence));
|
|
148
|
+
if (found) {
|
|
149
|
+
found.sentence = sentence;
|
|
150
|
+
if (cleanStr(s.url))
|
|
151
|
+
found.url = normUrlPath(s.url);
|
|
152
|
+
if (s.status)
|
|
153
|
+
found.status = s.status;
|
|
154
|
+
updated++;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const id = cleanStr(s.id) && !list.some((x) => x.id === s.id) ? s.id : nextId("s", list.map((x) => x.id));
|
|
158
|
+
list.push({ id, sentence, ...(cleanStr(s.url) ? { url: normUrlPath(s.url) } : {}), status: s.status ?? "planned" });
|
|
159
|
+
added++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ids for any legacy entries without one
|
|
163
|
+
for (const s of list)
|
|
164
|
+
if (!s.id)
|
|
165
|
+
s.id = nextId("s", list.map((x) => x.id));
|
|
166
|
+
r.situations = list;
|
|
167
|
+
changes.push(`situations: ${added} added, ${updated} updated${replaceLists ? " (list replaced)" : ""}`);
|
|
168
|
+
}
|
|
169
|
+
if (input.competitors) {
|
|
170
|
+
const list = replaceLists ? [] : r.competitors.map((c) => ({ ...c }));
|
|
171
|
+
let added = 0;
|
|
172
|
+
let updated = 0;
|
|
173
|
+
for (const c of input.competitors) {
|
|
174
|
+
const name = cleanStr(c.name);
|
|
175
|
+
if (!name)
|
|
176
|
+
continue;
|
|
177
|
+
const found = list.find((x) => normKey(x.name) === normKey(name));
|
|
178
|
+
const fields = {};
|
|
179
|
+
for (const k of ["url", "what", "price", "forWhom", "source", "checkedAt"]) {
|
|
180
|
+
const v = cleanStr(c[k]);
|
|
181
|
+
if (v !== undefined)
|
|
182
|
+
fields[k] = v;
|
|
183
|
+
}
|
|
184
|
+
if (found) {
|
|
185
|
+
Object.assign(found, fields, { name });
|
|
186
|
+
updated++;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
list.push({ name, url: "", ...fields });
|
|
190
|
+
added++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
r.competitors = list;
|
|
194
|
+
changes.push(`competitors: ${added} added, ${updated} updated${replaceLists ? " (list replaced)" : ""}`);
|
|
195
|
+
}
|
|
196
|
+
if (input.proof) {
|
|
197
|
+
const list = replaceLists ? [] : r.proof.map((p) => ({ ...p }));
|
|
198
|
+
let n = 0;
|
|
199
|
+
for (const p of input.proof) {
|
|
200
|
+
const claim = cleanStr(p.claim);
|
|
201
|
+
if (!claim)
|
|
202
|
+
continue;
|
|
203
|
+
const found = list.find((x) => normKey(x.claim) === normKey(claim));
|
|
204
|
+
const fields = {};
|
|
205
|
+
for (const k of ["value", "source", "date"]) {
|
|
206
|
+
const v = cleanStr(p[k]);
|
|
207
|
+
if (v !== undefined)
|
|
208
|
+
fields[k] = v;
|
|
209
|
+
}
|
|
210
|
+
if (found)
|
|
211
|
+
Object.assign(found, fields);
|
|
212
|
+
else
|
|
213
|
+
list.push({ claim, source: "", ...fields });
|
|
214
|
+
n++;
|
|
215
|
+
}
|
|
216
|
+
r.proof = list;
|
|
217
|
+
changes.push(`proof: ${n} merged${replaceLists ? " (list replaced)" : ""}`);
|
|
218
|
+
}
|
|
219
|
+
if (input.keywords) {
|
|
220
|
+
const list = replaceLists ? [] : r.keywords.map((k) => ({ ...k }));
|
|
221
|
+
let n = 0;
|
|
222
|
+
for (const k of input.keywords) {
|
|
223
|
+
const term = cleanStr(k.term);
|
|
224
|
+
if (!term)
|
|
225
|
+
continue;
|
|
226
|
+
const found = list.find((x) => normKey(x.term) === normKey(term));
|
|
227
|
+
if (found) {
|
|
228
|
+
if (cleanStr(k.page))
|
|
229
|
+
found.page = normUrlPath(k.page);
|
|
230
|
+
if (k.source)
|
|
231
|
+
found.source = k.source;
|
|
232
|
+
}
|
|
233
|
+
else
|
|
234
|
+
list.push({ term, ...(cleanStr(k.page) ? { page: normUrlPath(k.page) } : {}), ...(k.source ? { source: k.source } : {}) });
|
|
235
|
+
n++;
|
|
236
|
+
}
|
|
237
|
+
r.keywords = list;
|
|
238
|
+
changes.push(`keywords: ${n} merged${replaceLists ? " (list replaced)" : ""}`);
|
|
239
|
+
}
|
|
240
|
+
if (input.questions) {
|
|
241
|
+
r.questions = mergeQuestions(replaceLists ? [] : r.questions, input.questions);
|
|
242
|
+
changes.push(`questions: now ${r.questions.length}${replaceLists ? " (list replaced)" : ""}`);
|
|
243
|
+
}
|
|
244
|
+
return { research: r, changes };
|
|
245
|
+
}
|
|
246
|
+
export function mergeQuestions(base, incoming = []) {
|
|
247
|
+
const list = base.map((q) => ({ ...q }));
|
|
248
|
+
for (const q of incoming) {
|
|
249
|
+
const text = cleanStr(q.text);
|
|
250
|
+
if (!text)
|
|
251
|
+
continue;
|
|
252
|
+
const found = (q.id ? list.find((x) => x.id === q.id) : undefined) ?? list.find((x) => normKey(x.text) === normKey(text));
|
|
253
|
+
const type = q.type ?? found?.type ?? "situation";
|
|
254
|
+
if (found) {
|
|
255
|
+
found.text = text;
|
|
256
|
+
found.type = type;
|
|
257
|
+
if (q.lang)
|
|
258
|
+
found.lang = q.lang;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const prefix = type === "diagnostic" ? "t" : "q";
|
|
262
|
+
const id = cleanStr(q.id) && !list.some((x) => x.id === q.id) ? q.id : nextId(prefix, list.map((x) => x.id));
|
|
263
|
+
list.push({ id, type, text, ...(q.lang ? { lang: q.lang } : {}) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return list;
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// validation
|
|
270
|
+
function placeholderPaths(x, path = "") {
|
|
271
|
+
if (typeof x === "string")
|
|
272
|
+
return hasPlaceholder(x) ? [path] : [];
|
|
273
|
+
if (Array.isArray(x))
|
|
274
|
+
return x.flatMap((v, i) => placeholderPaths(v, `${path}[${i}]`));
|
|
275
|
+
if (x && typeof x === "object")
|
|
276
|
+
return Object.entries(x).flatMap(([k, v]) => placeholderPaths(v, path ? `${path}.${k}` : k));
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const DATE_RE = /^\d{4}-\d{2}(-\d{2})?$/;
|
|
280
|
+
const SLUG_RE = /^\/[a-z0-9\-/]*$/;
|
|
281
|
+
export function brandNames(r) {
|
|
282
|
+
const names = [r.brand.name, ...(r.brand.alternateNames ?? [])].filter(isFilled).map((s) => s.trim());
|
|
283
|
+
// also the bare first word of a multi-word brand when it's distinctive (≥4 letters)
|
|
284
|
+
return [...new Set(names)];
|
|
285
|
+
}
|
|
286
|
+
/** Warnings the agent must fix (book rules: unbranded situations, sourced competitors and proof, no [FILL]). */
|
|
287
|
+
export function validateResearch(r) {
|
|
288
|
+
const w = [];
|
|
289
|
+
const names = brandNames(r);
|
|
290
|
+
if (!isFilled(r.site.url))
|
|
291
|
+
w.push("site.url is missing: pass site_url to katman_start or data.site.url.");
|
|
292
|
+
if (!isFilled(r.brand.name))
|
|
293
|
+
w.push("brand.name is missing: one distinctive name (the book used a two-word name to avoid confusion).");
|
|
294
|
+
if (!isFilled(r.brand.category))
|
|
295
|
+
w.push("brand.category is missing (e.g. \"AI invoice scanner\"): assistants answer \"best <category>\" questions with it.");
|
|
296
|
+
const ol = r.brand.oneLiner;
|
|
297
|
+
const missingParts = ["brings", "weDo", "gets", "fear"].filter((k) => !isFilled(ol?.[k]));
|
|
298
|
+
if (missingParts.length)
|
|
299
|
+
w.push(`One sentence: missing part(s) ${missingParts.join(", ")} (brings → weDo → gets → fear is unnecessary).`);
|
|
300
|
+
if (!isFilled(r.brand.notX))
|
|
301
|
+
w.push("brand.notX is missing: ask ChatGPT logged out \"What is <brand>?\"; write what it gets confused with, or \"none\".");
|
|
302
|
+
if (!isFilled(r.brand.pricing))
|
|
303
|
+
w.push("brand.pricing is missing: copy it exactly from the pricing page (it feeds llms.txt and schema).");
|
|
304
|
+
for (const s of r.situations) {
|
|
305
|
+
for (const n of names) {
|
|
306
|
+
if (containsPhrase(s.sentence, n))
|
|
307
|
+
w.push(`Situation ${s.id} contains the brand name "${n}": situations must be the customer's unbranded sentence.`);
|
|
308
|
+
}
|
|
309
|
+
if (s.url && !SLUG_RE.test(s.url))
|
|
310
|
+
w.push(`Situation ${s.id} URL "${s.url}" isn't lowercase ASCII with hyphens.`);
|
|
311
|
+
}
|
|
312
|
+
if (r.situations.length < 10)
|
|
313
|
+
w.push(`${r.situations.length} situation(s): the book asks for 10–12 unbranded customer sentences.`);
|
|
314
|
+
for (const c of r.competitors) {
|
|
315
|
+
if (!isFilled(c.url))
|
|
316
|
+
w.push(`Competitor "${c.name}" has no url: take its facts from its own site.`);
|
|
317
|
+
if (!isFilled(c.checkedAt))
|
|
318
|
+
w.push(`Competitor "${c.name}" has no checkedAt date (YYYY-MM-DD).`);
|
|
319
|
+
else if (!DATE_RE.test(c.checkedAt))
|
|
320
|
+
w.push(`Competitor "${c.name}" checkedAt "${c.checkedAt}" isn't YYYY-MM-DD.`);
|
|
321
|
+
}
|
|
322
|
+
if (r.competitors.length < 3)
|
|
323
|
+
w.push(`${r.competitors.length} competitor(s): list at least 3 the assistants name, each with url + checkedAt.`);
|
|
324
|
+
for (const p of r.proof) {
|
|
325
|
+
if (!isFilled(p.source))
|
|
326
|
+
w.push(`Proof "${p.claim}" has no source: only verifiable numbers go on the site.`);
|
|
327
|
+
if (!isFilled(p.date))
|
|
328
|
+
w.push(`Proof "${p.claim}" has no date.`);
|
|
329
|
+
}
|
|
330
|
+
for (const q of r.questions) {
|
|
331
|
+
if (q.type !== "diagnostic" && names.some((n) => containsPhrase(q.text, n))) {
|
|
332
|
+
w.push(`Question ${q.id} mentions the brand: only the 2 diagnostic questions may be branded (branded questions inflate visibility).`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const leftovers = placeholderPaths(r).filter((p) => !p.startsWith("questions"));
|
|
336
|
+
if (leftovers.length)
|
|
337
|
+
w.push(`[FILL]/[DOLDUR] left in: ${leftovers.slice(0, 12).join(", ")}${leftovers.length > 12 ? ` (+${leftovers.length - 12})` : ""}.`);
|
|
338
|
+
return w;
|
|
339
|
+
}
|
|
340
|
+
export function isSourcedCompetitor(c) {
|
|
341
|
+
return isFilled(c.url) && isFilled(c.checkedAt);
|
|
342
|
+
}
|
|
343
|
+
export function researchCompleteness(r) {
|
|
344
|
+
if (!r)
|
|
345
|
+
return { percent: 0, items: [], gaps: ["No research yet."], blocking: ["research"] };
|
|
346
|
+
const ol = r.brand.oneLiner;
|
|
347
|
+
const parts = ["brings", "weDo", "gets", "fear"].filter((k) => isFilled(ol?.[k])).length;
|
|
348
|
+
const sourced = r.competitors.filter(isSourcedCompetitor).length;
|
|
349
|
+
const primary = (r.site.languages[0] ?? "en").toLowerCase();
|
|
350
|
+
const primaryQs = r.questions.filter((q) => !q.lang || q.lang.toLowerCase() === primary);
|
|
351
|
+
const nonDiag = primaryQs.filter((q) => q.type !== "diagnostic").length;
|
|
352
|
+
const diag = primaryQs.filter((q) => q.type === "diagnostic").length;
|
|
353
|
+
const items = [
|
|
354
|
+
{ key: "name", label: "brand name", score: isFilled(r.brand.name) ? 1 : 0, detail: isFilled(r.brand.name) ? r.brand.name : "missing" },
|
|
355
|
+
{ key: "category", label: "category", score: isFilled(r.brand.category) ? 1 : 0, detail: isFilled(r.brand.category) ? r.brand.category : "missing" },
|
|
356
|
+
{ key: "oneLiner", label: "one sentence (4 parts)", score: parts / 4, detail: `${parts}/4 parts` },
|
|
357
|
+
{ key: "notX", label: "not-X note", score: isFilled(r.brand.notX) ? 1 : 0, detail: isFilled(r.brand.notX) ? (hasNotX(r.brand.notX) ? "set" : "none (checked)") : "missing" },
|
|
358
|
+
{ key: "situations", label: "≥10 situations", score: Math.min(1, r.situations.length / 10), detail: `${r.situations.length}/10` },
|
|
359
|
+
{ key: "competitors", label: "≥3 competitors with url + date", score: Math.min(1, sourced / 3), detail: `${sourced}/3 sourced (${r.competitors.length} listed)` },
|
|
360
|
+
{ key: "questions", label: "question set (12 + 2)", score: Math.min(1, (Math.min(nonDiag, 12) + Math.min(diag, 2)) / 14), detail: `${nonDiag} + ${diag}` },
|
|
361
|
+
];
|
|
362
|
+
const percent = Math.round((items.reduce((a, i) => a + i.score, 0) / items.length) * 100);
|
|
363
|
+
const gaps = items.filter((i) => i.score < 1).map((i) => `${i.label}: ${i.detail}`);
|
|
364
|
+
if (!isFilled(r.site.url))
|
|
365
|
+
gaps.push("site URL: missing");
|
|
366
|
+
if (!isFilled(r.brand.pricing))
|
|
367
|
+
gaps.push("pricing: missing");
|
|
368
|
+
if (!r.proof.length)
|
|
369
|
+
gaps.push("proof: none yet (optional, but only sourced numbers may appear on pages)");
|
|
370
|
+
const blocking = [];
|
|
371
|
+
if (!isFilled(r.brand.name))
|
|
372
|
+
blocking.push("brand name");
|
|
373
|
+
if (!isFilled(r.brand.category))
|
|
374
|
+
blocking.push("category");
|
|
375
|
+
if (parts < 4)
|
|
376
|
+
blocking.push("one sentence (4 parts)");
|
|
377
|
+
if (r.situations.length < 3)
|
|
378
|
+
blocking.push("at least 3 situations");
|
|
379
|
+
if (!r.questions.length)
|
|
380
|
+
blocking.push("question set");
|
|
381
|
+
return { percent, items, gaps, blocking };
|
|
382
|
+
}
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// research.md
|
|
385
|
+
function v(x, fill = FILL) {
|
|
386
|
+
return isFilled(x) ? String(x).trim() : fill;
|
|
387
|
+
}
|
|
388
|
+
export function renderResearchMd(r, runs, lang = "en") {
|
|
389
|
+
const L = (en, tr) => (lang === "tr" ? tr : en);
|
|
390
|
+
const fill = lang === "tr" ? "[DOLDUR]" : FILL;
|
|
391
|
+
const out = [];
|
|
392
|
+
out.push(`# research.md`);
|
|
393
|
+
out.push(L(`_Generated by Katman from .katman/research.json on ${isoDay(new Date())}. Don't edit this file: change research through \`katman_research\` (mode "update") — this file is rewritten._`, `_Katman tarafından .katman/research.json’dan ${isoDay(new Date())} tarihinde üretildi. Bu dosyayı düzenlemeyin: araştırmayı \`katman_research\` (mode "update") ile değiştirin — bu dosya yeniden yazılır._`));
|
|
394
|
+
out.push(`## ${L("Brand", "Marka")}`);
|
|
395
|
+
const sentence = brandSentence(r);
|
|
396
|
+
const ol = r.brand.oneLiner;
|
|
397
|
+
out.push([
|
|
398
|
+
`- ${L("Name", "Ad")}: ${v(r.brand.name, fill)}${r.brand.alternateNames?.length ? ` (${L("also", "diğer adlar")}: ${r.brand.alternateNames.join(", ")})` : ""}`,
|
|
399
|
+
`- ${L("Category", "Kategori")}: ${v(r.brand.category, fill)}`,
|
|
400
|
+
`- ${L("Is not", "Değildir")}: ${v(r.brand.notX, fill)}`,
|
|
401
|
+
`- ${L("One sentence", "Tek cümle")}: ${sentence ?? fill}`,
|
|
402
|
+
` - ${L("brings", "getirir")}: ${v(ol?.brings, fill)}`,
|
|
403
|
+
` - ${L("we do", "yaparız")}: ${v(ol?.weDo, fill)}`,
|
|
404
|
+
` - ${L("they get", "eline geçer")}: ${v(ol?.gets, fill)}`,
|
|
405
|
+
` - ${L("fear is unnecessary", "korku gereksiz")}: ${v(ol?.fear, fill)}`,
|
|
406
|
+
`- ${L("Pricing (exactly as on the site)", "Fiyat (sitedeki ile aynı)")}: ${v(r.brand.pricing, fill)}`,
|
|
407
|
+
`- ${L("Company", "Şirket")}: ${v(r.brand.company, fill)}${isFilled(r.brand.country) ? ` (${r.brand.country})` : ""}`,
|
|
408
|
+
`- ${L("Site", "Site")}: ${v(r.site.url, fill)} · ${L("languages", "diller")}: ${r.site.languages.join(", ") || fill}`,
|
|
409
|
+
...(r.brand.socials?.length ? [`- ${L("Social accounts", "Sosyal hesaplar")}: ${r.brand.socials.join(", ")}`] : []),
|
|
410
|
+
...(r.site.previewUrls?.length ? [`- ${L("Preview URLs (must redirect or be noindex)", "Önizleme adresleri (yönlenmeli ya da noindex olmalı)")}: ${r.site.previewUrls.join(", ")}`] : []),
|
|
411
|
+
].join("\n"));
|
|
412
|
+
out.push(`## ${L("Customer situations (10–12, unbranded)", "Müşteri durumları (10-12, markasız)")}`);
|
|
413
|
+
out.push(r.situations.length
|
|
414
|
+
? r.situations.map((s, i) => `${i + 1}. "${s.sentence}" → ${L("page", "sayfa")}: ${s.url ?? fill} (${s.status ?? "planned"}) \`${s.id}\``).join("\n")
|
|
415
|
+
: `1. "${L("[the sentence your customer would type into ChatGPT]", "[müşterinin ChatGPT'ye yazacağı cümle]")}" → ${L("page", "sayfa")}: /[url]`);
|
|
416
|
+
const last = runs[runs.length - 1];
|
|
417
|
+
out.push(`## ${L("Assistant measurement", "Asistan ölçümü")}${last ? ` (${isoDay(runDate(last) ?? last.ts)}, ${last.answers.some((a) => a.source === "manual") ? L("includes manual checks", "elle kontroller dahil") : "API"})` : ""}`);
|
|
418
|
+
if (last && last.answers.length) {
|
|
419
|
+
const rows = last.answers.slice(0, 60).map((a) => {
|
|
420
|
+
const own = a.citations.find((c) => r.site.domain && c.domain.endsWith(r.site.domain));
|
|
421
|
+
return [
|
|
422
|
+
a.question,
|
|
423
|
+
a.family || a.model,
|
|
424
|
+
a.error ? `error: ${a.error}` : a.mentioned ? L(`yes${a.rank ? ` (#${a.rank})` : ""}`, `evet${a.rank ? ` (#${a.rank})` : ""}`) : L("no", "hayır"),
|
|
425
|
+
a.namedProducts.slice(0, 3).join(", "),
|
|
426
|
+
own ? own.url : a.citations[0]?.url ?? "",
|
|
427
|
+
];
|
|
428
|
+
});
|
|
429
|
+
out.push(mdTable([L("question", "soru"), L("assistant", "asistan"), L("mentioned us?", "bizi andı mı"), L("whom it recommended", "kimi önerdi"), L("page it cited", "kaynak gösterdiği sayfa")], rows));
|
|
430
|
+
out.push(L(`Rates: mentioned ${Math.round(last.summary.mentionRate * 100)}% · recommended first ${Math.round(last.summary.recommendedFirstRate * 100)}% · cited ${Math.round(last.summary.citationRate * 100)}% (${last.summary.answered} answers; one run is a low sample).`, `Oranlar: anılma %${Math.round(last.summary.mentionRate * 100)} · ilk öneri %${Math.round(last.summary.recommendedFirstRate * 100)} · kaynak gösterilme %${Math.round(last.summary.citationRate * 100)} (${last.summary.answered} cevap; tek ölçüm düşük örnektir).`));
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
out.push(`| ${L("question | assistant | mentioned us? | whom it recommended | page it cited", "soru | asistan | bizi andı mı | kimi önerdi | kaynak gösterdiği sayfa")} |\n| --- | --- | --- | --- | --- |\n\n${L("No measurement yet: katman_visibility_run (dry_run first) or katman_manual_check.", "Henüz ölçüm yok: katman_visibility_run (önce dry_run) ya da katman_manual_check.")}`);
|
|
434
|
+
}
|
|
435
|
+
out.push(`## ${L("Competitors (each from its own site, with the date checked)", "Rakipler (her biri kendi sitesinden, kontrol tarihiyle)")}`);
|
|
436
|
+
out.push(mdTable([L("name", "ad"), L("what it does", "ne yapar"), L("price", "fiyat"), L("for whom", "kime uygun"), L("source", "kaynak"), L("date", "tarih")], r.competitors.length
|
|
437
|
+
? r.competitors.map((c) => [c.name, c.what ?? "", c.price ?? "", c.forWhom ?? "", c.source ?? c.url ?? "", c.checkedAt ?? fill])
|
|
438
|
+
: [[fill, "", "", "", "", ""]]));
|
|
439
|
+
out.push(`## ${L("Proof (verifiable only)", "Kanıt (yalnızca doğrulanabilir)")}`);
|
|
440
|
+
out.push(r.proof.length ? r.proof.map((p) => `- ${p.claim}${p.value ? `: ${p.value}` : ""} — ${v(p.source, fill)} — ${v(p.date, fill)}`).join("\n") : `- [${L("number", "sayı")}] — [${L("source", "kaynak")}] — [${L("date", "tarih")}]`);
|
|
441
|
+
out.push(`## ${L("Search keywords", "Aranan kelimeler")}`);
|
|
442
|
+
out.push(r.keywords.length ? r.keywords.map((k) => `- ${k.term} → ${k.page ?? fill}${k.source ? ` (${k.source})` : ""}`).join("\n") : `- [${L("keyword", "kelime")}] → [${L("which page", "hangi sayfa")}]`);
|
|
443
|
+
out.push(`## ${L("Question set (12 unbranded + 2 diagnostic)", "Soru seti (12 markasız + 2 teşhis)")}`);
|
|
444
|
+
out.push(r.questions.length
|
|
445
|
+
? r.questions.map((q) => `- \`${q.id}\` [${q.type}${q.lang ? `, ${q.lang}` : ""}] ${q.text}`).join("\n")
|
|
446
|
+
: L("No questions yet: katman_research {\"generate_questions\": true} once there are ≥3 situations.", "Henüz soru yok: en az 3 durum olunca katman_research {\"generate_questions\": true}."));
|
|
447
|
+
return out.join("\n\n") + "\n";
|
|
448
|
+
}
|