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,295 @@
|
|
|
1
|
+
import { mdTable, truncate } from "../../util.js";
|
|
2
|
+
import { sameSite } from "./analyze.js";
|
|
3
|
+
import { money, moneyRange } from "./estimate.js";
|
|
4
|
+
import { engineOf, vendorOf } from "./models.js";
|
|
5
|
+
import { ratio } from "./summary.js";
|
|
6
|
+
export const PROXY_NOTE = "API answers are a proxy for the consumer apps. Confirm key questions with katman_manual_check.";
|
|
7
|
+
export const SAMPLE_NOTE = "One run is a small sample: the same question can get a different answer in the next session. Named and cited are the reliable signals; who is ranked first changes a lot between sessions.";
|
|
8
|
+
export function pct(k, n) {
|
|
9
|
+
if (!n)
|
|
10
|
+
return "—";
|
|
11
|
+
return `${k}/${n} (${Math.round((100 * k) / n)}%)`;
|
|
12
|
+
}
|
|
13
|
+
export function pts(before, beforeN, after, afterN) {
|
|
14
|
+
if (!beforeN || !afterN)
|
|
15
|
+
return "—";
|
|
16
|
+
const d = Math.round(100 * (ratio(after, afterN) - ratio(before, beforeN)));
|
|
17
|
+
return d === 0 ? "±0 pts" : `${d > 0 ? "+" : ""}${d} pts`;
|
|
18
|
+
}
|
|
19
|
+
export function shortFamily(f) {
|
|
20
|
+
if (f.startsWith("ChatGPT"))
|
|
21
|
+
return "ChatGPT";
|
|
22
|
+
if (/exa search/i.test(f))
|
|
23
|
+
return "Gemini*";
|
|
24
|
+
return f;
|
|
25
|
+
}
|
|
26
|
+
export function searchLabel(spec) {
|
|
27
|
+
const engine = engineOf(spec);
|
|
28
|
+
if (engine === "none")
|
|
29
|
+
return "off";
|
|
30
|
+
if (vendorOf(spec.id) === "perplexity")
|
|
31
|
+
return "built-in (Perplexity index)";
|
|
32
|
+
if (engine === "exa")
|
|
33
|
+
return "Exa (proxy index)";
|
|
34
|
+
const v = vendorOf(spec.id);
|
|
35
|
+
return v === "google" ? "native (Google grounding)" : "native";
|
|
36
|
+
}
|
|
37
|
+
const utc = (iso) => iso.slice(0, 16).replace("T", " ") + " UTC";
|
|
38
|
+
export function minutes(answers, concurrency) {
|
|
39
|
+
const waves = Math.ceil(answers / Math.max(1, concurrency));
|
|
40
|
+
const lo = Math.max(1, Math.round((waves * 15) / 60));
|
|
41
|
+
const hi = Math.max(lo + 1, Math.round((waves * 45) / 60));
|
|
42
|
+
return `${lo}–${hi} min`;
|
|
43
|
+
}
|
|
44
|
+
export function renderDryRun(v) {
|
|
45
|
+
const e = v.estimate;
|
|
46
|
+
const L = [];
|
|
47
|
+
L.push("# AI visibility run: dry run (nothing was spent)", "");
|
|
48
|
+
if (v.activeRun)
|
|
49
|
+
L.push(`> ${v.activeRun}`, "");
|
|
50
|
+
if (v.lastRun) {
|
|
51
|
+
const r = v.lastRun;
|
|
52
|
+
L.push(`Last API run: ${utc(r.ts)} · named in ${pct(r.mentioned, r.answered)} unbranded answers · cited in ${pct(r.cited, r.citedOf)}${r.cost ? ` · cost ${money(r.cost)}` : ""} (details: .katman/measurements.md).`, "");
|
|
53
|
+
}
|
|
54
|
+
const qDesc = `${v.questions.length} questions (${v.scoredCount} unbranded + ${v.diagnosticCount} diagnostic${v.brandedCount ? `, ${v.brandedCount} naming the brand` : ""})`;
|
|
55
|
+
L.push(`**${v.brand}** (${v.domain}) · ${qDesc} × ${v.models.length} assistant${v.models.length === 1 ? "" : "s"}${v.samples > 1 ? ` × ${v.samples} samples` : ""} = **${e.answers} answers**, live web search, temperature ${v.temperature}.`, "");
|
|
56
|
+
const rows = e.rows.map((r) => [
|
|
57
|
+
r.spec.family, r.spec.id, searchLabel(r.spec), r.answers, moneyRange(r.perAnswer), moneyRange(r.total) + (r.lastActual ? ` (last run: ${money(r.lastActual)}/answer)` : ""),
|
|
58
|
+
]);
|
|
59
|
+
if (e.judge)
|
|
60
|
+
rows.push(["judge (extracts recommended products)", e.judge.model, "—", e.judge.calls, moneyRange(e.judge.perCall), moneyRange(e.judge.total)]);
|
|
61
|
+
if (e.followUps)
|
|
62
|
+
rows.push(["follow-up \"which pages did you use?\"", "same models", "same", `≤ ${e.followUps.maxCalls}`, "—", moneyRange(e.followUps.total)]);
|
|
63
|
+
rows.push(["**total**", "", "", `**${e.answers}**`, "", `**${moneyRange(e.total)} (expected ≈ ${money(e.total.expected)})**`]);
|
|
64
|
+
L.push(mdTable(["assistant", "OpenRouter model", "web search", "answers", "per answer", "total"], rows), "");
|
|
65
|
+
const fits = e.total.expected <= v.maxCost;
|
|
66
|
+
L.push(`**Cost cap (max_cost_usd): ${money(v.maxCost)}.** ${fits
|
|
67
|
+
? e.total.high <= v.maxCost
|
|
68
|
+
? "The whole estimate fits under the cap."
|
|
69
|
+
: "The expected cost fits; if answers run long, the run stops starting new calls at the cap and marks the rest as skipped (never silently)."
|
|
70
|
+
: "The expected cost is above the cap, so a real run with these settings refuses to start."}`);
|
|
71
|
+
if (!fits || e.total.high > v.maxCost) {
|
|
72
|
+
const opts = [`raise the cap (\`max_cost_usd: ${Math.max(v.maxCost, Math.ceil(e.total.high * 2) / 2)}\`)`];
|
|
73
|
+
for (const a of v.alternatives)
|
|
74
|
+
opts.push(`${a.label} ≈ ${money(a.expected)} (max ${money(a.high)}): \`${a.call}\``);
|
|
75
|
+
opts.push("fewer questions (`question_ids`)");
|
|
76
|
+
L.push(`Options: ${opts.join("; ")}.`);
|
|
77
|
+
}
|
|
78
|
+
const capArg = fits ? v.maxCost : Math.max(v.maxCost, Math.ceil(e.total.high * 2) / 2);
|
|
79
|
+
L.push("", `Time: about ${minutes(e.answers, v.concurrency)} (${v.concurrency} calls at a time). If your MCP client times out, the run keeps going and saves its results; or run \`npx -y katman-mcp check --root . --yes --max-cost ${capArg}\` in a terminal.`, "");
|
|
80
|
+
if (v.key.present) {
|
|
81
|
+
const limit = v.key.error ? `could not read key info (${v.key.error})`
|
|
82
|
+
: v.key.limitRemaining === null || v.key.limitRemaining === undefined
|
|
83
|
+
? "this key has no spending limit; check the account balance at https://openrouter.ai/settings/credits"
|
|
84
|
+
: `key limit remaining: ${money(v.key.limitRemaining)}${v.key.limitRemaining < e.total.high ? " (less than the high estimate: the run may stop early with 402)" : ""}`;
|
|
85
|
+
L.push(`OpenRouter key: found in ${v.key.source} · ${limit}.`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
L.push("OpenRouter key: **not set**. See below.");
|
|
89
|
+
}
|
|
90
|
+
L.push(`Prices: ${v.catalog.source === "live" ? "live OpenRouter catalog" : v.catalog.source === "cache" ? "OpenRouter catalog (cached)" : v.catalog.source === "stale-cache" ? "an older cached catalog" : "built-in prices (verified 2026-09-30)"}${v.catalog.fetchedAt ? `, fetched ${utc(v.catalog.fetchedAt)}` : ""}.`, "", "How the estimate works: native web search puts the search results into the prompt, so an answer costs far more than the question itself. Ranges come from measured native-search runs (Sep 2026):");
|
|
91
|
+
const bases = [...new Set(e.rows.map((r) => r.basis))];
|
|
92
|
+
for (const b of bases)
|
|
93
|
+
L.push(`- ${b}`);
|
|
94
|
+
L.push("After your first run, estimates use your own measured cost per answer.", "");
|
|
95
|
+
if (v.notes.length) {
|
|
96
|
+
L.push("Notes:");
|
|
97
|
+
for (const n of v.notes)
|
|
98
|
+
L.push(`- ${n}`);
|
|
99
|
+
L.push("");
|
|
100
|
+
}
|
|
101
|
+
L.push(PROXY_NOTE, "");
|
|
102
|
+
if (v.keyHelp)
|
|
103
|
+
L.push(v.keyHelp, "");
|
|
104
|
+
if (!v.key.present) {
|
|
105
|
+
L.push(`**Next:** tell the user what a run costs (about ${money(e.total.expected)}, at most ${money(Math.max(e.total.high, e.total.expected))}) and that it needs their own OpenRouter key (steps above). Until then, \`katman_manual_check\` costs nothing (no API credits).`);
|
|
106
|
+
return L.join("\n");
|
|
107
|
+
}
|
|
108
|
+
L.push(`**Next:** ask the user: "This asks ${e.answers} AI answers and costs about ${money(e.total.expected)} (at most ${money(Math.min(capArg, Math.max(e.total.high, e.total.expected)))}) from your OpenRouter credit. Run it?" Only after a clear yes, call \`katman_visibility_run\` with \`dry_run: false\`${capArg !== 2 ? ` and \`max_cost_usd: ${capArg}\`` : ""} (same other arguments). No-cost alternative: \`katman_manual_check\`.`);
|
|
109
|
+
return L.join("\n");
|
|
110
|
+
}
|
|
111
|
+
function cell(answers, diagnostic) {
|
|
112
|
+
if (!answers.length)
|
|
113
|
+
return "";
|
|
114
|
+
const ok = answers.filter((a) => !a.error);
|
|
115
|
+
if (!ok.length)
|
|
116
|
+
return /^(skipped|not run)/i.test(answers[0].error ?? "") ? "skip" : "err";
|
|
117
|
+
if (answers.length > 1) {
|
|
118
|
+
const m = ok.filter((a) => a.mentioned).length;
|
|
119
|
+
const c = ok.filter((a) => a.cited).length;
|
|
120
|
+
return `${m}/${ok.length} named${c ? ` · ${c} cited` : ""}`;
|
|
121
|
+
}
|
|
122
|
+
const a = ok[0];
|
|
123
|
+
if (diagnostic) {
|
|
124
|
+
const id = a.identityCorrect === true ? "correct" : a.identityCorrect === false ? "wrong" : "unclear";
|
|
125
|
+
return `${a.mentioned ? "✓" : "✗"} ${id}${a.cited ? " · cited" : ""}`;
|
|
126
|
+
}
|
|
127
|
+
if (!a.mentioned)
|
|
128
|
+
return a.cited ? "✗ · cited" : "✗";
|
|
129
|
+
return `✓${a.rank ? ` #${a.rank}` : ""}${a.cited ? " · cited" : ""}`;
|
|
130
|
+
}
|
|
131
|
+
export function renderGrid(run, questions) {
|
|
132
|
+
const families = [...new Set(run.models.map((m) => m.family))];
|
|
133
|
+
const qIds = [...new Set(run.answers.map((a) => a.questionId))];
|
|
134
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
135
|
+
const rows = qIds.map((id) => {
|
|
136
|
+
const any = run.answers.find((a) => a.questionId === id);
|
|
137
|
+
const diagnostic = any.questionType === "diagnostic";
|
|
138
|
+
const q = byId.get(id)?.text ?? any.question;
|
|
139
|
+
return [
|
|
140
|
+
id,
|
|
141
|
+
truncate(q, 60) + (diagnostic ? " (diagnostic)" : ""),
|
|
142
|
+
...families.map((f) => cell(run.answers.filter((a) => a.questionId === id && a.family === f), diagnostic)),
|
|
143
|
+
];
|
|
144
|
+
});
|
|
145
|
+
return mdTable(["#", "question", ...families.map(shortFamily)], rows);
|
|
146
|
+
}
|
|
147
|
+
function avgRank(answers) {
|
|
148
|
+
const r = answers.map((a) => a.rank).filter((x) => typeof x === "number");
|
|
149
|
+
return r.length ? (r.reduce((s, x) => s + x, 0) / r.length).toFixed(1) : "—";
|
|
150
|
+
}
|
|
151
|
+
export function renderRunReport(v) {
|
|
152
|
+
const { run, extras: x } = v;
|
|
153
|
+
const s = run.summary;
|
|
154
|
+
const L = [];
|
|
155
|
+
const brand = run.brand;
|
|
156
|
+
L.push(`# AI visibility run: ${brand} (${run.domain}) · ${utc(run.ts)}`, "");
|
|
157
|
+
const total = (run.totalCostUsd ?? 0);
|
|
158
|
+
const answered = run.answers.filter((a) => !a.error).length;
|
|
159
|
+
L.push(`${answered} of ${run.answers.length} answers received from ${run.models.length} assistant${run.models.length === 1 ? "" : "s"} (OpenRouter API, live web search, temperature ${run.temperature ?? "n/a"}) · spent ${money(total)} (answers ${money(v.costs.answers)}${v.costs.followUps ? `, follow-ups ${money(v.costs.followUps)}` : ""}${v.costs.judge ? `, judge ${money(v.costs.judge)}` : ""}).`);
|
|
160
|
+
if (v.stopped)
|
|
161
|
+
L.push("", `> **Incomplete run:** ${v.stopped} The answers that did come back are saved and counted below; the missing ones are marked "skip".`);
|
|
162
|
+
L.push("");
|
|
163
|
+
const citedPart = x.citationDenominator ? `cited in ${pct(x.citedCount, x.citationDenominator)}` : "no assistant returned sources";
|
|
164
|
+
const totals = Object.values(s.byFamily).reduce((t, b) => ({ mentioned: t.mentioned + b.mentioned, first: t.first + b.recommendedFirst }), { mentioned: 0, first: 0 });
|
|
165
|
+
L.push(`**Named in ${pct(totals.mentioned, s.answered)} unbranded answers · ${citedPart}.** These are the headline numbers. Recommended first: ${pct(totals.first, s.answered)}, a secondary signal (the first-named brand changes between sessions).`, "");
|
|
166
|
+
L.push("## By assistant", "");
|
|
167
|
+
const famRows = run.models.map((m) => {
|
|
168
|
+
const b = s.byFamily[m.family] ?? { answered: 0, mentioned: 0, recommendedFirst: 0, cited: 0 };
|
|
169
|
+
const answersF = run.answers.filter((a) => a.family === m.family && !a.error);
|
|
170
|
+
const returned = [...new Set(answersF.map((a) => a.model))].join(", ") || m.id;
|
|
171
|
+
const d = v.diff?.byFamily[m.family];
|
|
172
|
+
const searched = answersF.filter((a) => a.searched === true).length;
|
|
173
|
+
return [
|
|
174
|
+
m.family,
|
|
175
|
+
returned,
|
|
176
|
+
answersF.length ? `${searched}/${answersF.length}` : "—",
|
|
177
|
+
pct(b.mentioned, b.answered),
|
|
178
|
+
x.citationBlind.includes(m.family) ? "no sources returned" : pct(b.cited, b.answered),
|
|
179
|
+
pct(b.recommendedFirst, b.answered),
|
|
180
|
+
avgRank(answersF.filter((a) => v.branded.has(a.questionId) === false && a.questionType !== "diagnostic")),
|
|
181
|
+
...(v.diff ? [d ? pts(d.before.mentioned, d.before.answered, d.after.mentioned, d.after.answered) : "new"] : []),
|
|
182
|
+
];
|
|
183
|
+
});
|
|
184
|
+
L.push(mdTable(["assistant", "model (as answered)", "searched the web", "named", "cited", "recommended first", "avg rank", ...(v.diff ? [`Δ named vs ${v.diff.previousTs.slice(0, 10)}`] : [])], famRows), "");
|
|
185
|
+
if (v.multiLanguage) {
|
|
186
|
+
L.push("## By language", "");
|
|
187
|
+
L.push(mdTable(["language", "answers", "named", "cited", "recommended first"], Object.entries(x.byLanguage).map(([lang, b]) => [lang, b.answered, pct(b.mentioned, b.answered), pct(b.cited, b.answered), pct(b.recommendedFirst, b.answered)])), "");
|
|
188
|
+
}
|
|
189
|
+
L.push("## Per question", "", renderGrid(run, v.questions), "");
|
|
190
|
+
L.push(`✓ named (#n = its position among the recommended products) · cited = a page of ${run.domain} was among the sources · ✗ not named · err = the call failed · skip = not run.`, "");
|
|
191
|
+
if (x.stability.length) {
|
|
192
|
+
const unstable = x.stability.filter((st) => st.mentioned > 0 && st.mentioned < st.samples);
|
|
193
|
+
L.push(`Stability across samples: ${x.stability.filter((st) => st.mentioned === st.samples).length} question × assistant pairs named every time, ${unstable.length} only sometimes, ${x.stability.filter((st) => st.mentioned === 0).length} never.`, "");
|
|
194
|
+
}
|
|
195
|
+
if (v.diff) {
|
|
196
|
+
const d = v.diff;
|
|
197
|
+
L.push(`## Changes since ${d.previousTs.slice(0, 10)} (run ${d.previousId})`, "");
|
|
198
|
+
if (!d.pairs)
|
|
199
|
+
L.push("No question × assistant pair was answered in both runs, so there is nothing to compare.", "");
|
|
200
|
+
else {
|
|
201
|
+
L.push(`Compared on the ${d.pairs} question × assistant pairs answered in both runs${!d.sameModels || !d.sameQuestions ? " (the model panel or question set changed since then; only matching pairs count)" : ""}.`, `- Named: ${pct(d.before.mentioned, d.before.answered)} → ${pct(d.after.mentioned, d.after.answered)} (${pts(d.before.mentioned, d.before.answered, d.after.mentioned, d.after.answered)})`, `- Cited: ${pct(d.before.cited, d.before.answered)} → ${pct(d.after.cited, d.after.answered)} · recommended first: ${pct(d.before.recommendedFirst, d.before.answered)} → ${pct(d.after.recommendedFirst, d.after.answered)}`);
|
|
202
|
+
if (d.gained.length)
|
|
203
|
+
L.push(`- Newly named: ${d.gained.slice(0, 12).map((g) => `${g.questionId} × ${shortFamily(g.family)}`).join(", ")}`);
|
|
204
|
+
if (d.lost.length)
|
|
205
|
+
L.push(`- No longer named: ${d.lost.slice(0, 12).map((g) => `${g.questionId} × ${shortFamily(g.family)}`).join(", ")}`);
|
|
206
|
+
L.push("");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
L.push("## Recommended instead of you (top competitors)", "");
|
|
210
|
+
if (s.topCompetitors.length) {
|
|
211
|
+
const fresh = new Set(x.newCompetitors);
|
|
212
|
+
L.push(mdTable(["product", "answers", "in research?"], s.topCompetitors.map((c) => [c.name, c.count, fresh.has(c.name) ? "new: add it with its own page as source and today's date" : "yes"])), "");
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
L.push("No other products were named.", "");
|
|
216
|
+
L.push("## Sources the assistants used = your off-site targets", "");
|
|
217
|
+
if (s.topCitedDomains.length) {
|
|
218
|
+
L.push(mdTable(["domain", "answers citing it"], s.topCitedDomains.map((d) => [d.domain, d.count])), "");
|
|
219
|
+
L.push("Get your brand onto these pages: independent lists, reviews, directories, forums (book ch. 14). Pitch the authors with facts and disclose any paid placement.", "");
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
L.push("No sources were returned.", "");
|
|
223
|
+
const diag = run.answers.filter((a) => a.questionType === "diagnostic" && !a.error);
|
|
224
|
+
if (diag.length) {
|
|
225
|
+
L.push("## Diagnostics (T1/T2, not counted in the rates)", "");
|
|
226
|
+
L.push(mdTable(["question", "assistant", "knows you", "describes you correctly", "cited"], diag.map((a) => [truncate(a.question, 50), shortFamily(a.family), a.mentioned ? "yes" : "no", a.identityCorrect === true ? "yes" : a.identityCorrect === false ? "no (confused)" : "unclear", a.cited ? "yes" : "no"])), "");
|
|
227
|
+
if (diag.some((a) => a.identityCorrect === false))
|
|
228
|
+
L.push("An assistant confuses you with something else: make the one sentence and the \"not X\" note identical on the homepage, llms.txt and schema (katman_prompt P4/P5).", "");
|
|
229
|
+
}
|
|
230
|
+
L.push("## Notes", "");
|
|
231
|
+
for (const w of v.warnings)
|
|
232
|
+
L.push(`- ${w}`);
|
|
233
|
+
L.push(`- ${SAMPLE_NOTE} This run scored ${s.answered} answers.`, `- Re-run the same questions on **${v.nextRun}** (+14 days) and compare.`, `- ${PROXY_NOTE}`, `- Saved: ${v.runFile} and .katman/measurements.md.`);
|
|
234
|
+
return L.join("\n");
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// measurements.md
|
|
238
|
+
function citedPages(a, own) {
|
|
239
|
+
const pages = a.citations.filter((c) => own.some((d) => sameSite(c.domain, d))).map((c) => {
|
|
240
|
+
try {
|
|
241
|
+
const u = new URL(c.url);
|
|
242
|
+
return u.pathname === "/" ? u.hostname : u.pathname;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return c.url;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return pages.length ? [...new Set(pages)].slice(0, 3).join(", ") : "—";
|
|
249
|
+
}
|
|
250
|
+
function runSource(r) {
|
|
251
|
+
const first = r.answers[0];
|
|
252
|
+
if (first?.source === "manual")
|
|
253
|
+
return `manual · ${first.model}`;
|
|
254
|
+
return "API (OpenRouter)";
|
|
255
|
+
}
|
|
256
|
+
export function renderMeasurements(runs, o) {
|
|
257
|
+
const sorted = [...runs].sort((a, b) => b.ts.localeCompare(a.ts));
|
|
258
|
+
const L = [];
|
|
259
|
+
L.push(`# AI visibility measurements: ${o.brand} (${o.domain})`, "");
|
|
260
|
+
L.push(`Regenerated by Katman on ${o.now.toISOString().slice(0, 10)} from .katman/runs/ (edits here are overwritten). Method (book ch. 7 and 15): the same questions every 14 days, logged out, web search on; record who was named, the position, who was recommended and which of your pages was cited.`, "", `${SAMPLE_NOTE} ${PROXY_NOTE}`, "", "## Runs", "");
|
|
261
|
+
if (!sorted.length) {
|
|
262
|
+
L.push("No runs yet. Start with `katman_visibility_run` (dry run first) or `katman_manual_check` (no API cost).");
|
|
263
|
+
return L.join("\n") + "\n";
|
|
264
|
+
}
|
|
265
|
+
L.push(mdTable(["date", "source", "assistants", "scored answers", "named", "recommended first", "cited", "cost"], sorted.map((r) => {
|
|
266
|
+
const s = r.summary;
|
|
267
|
+
const answered = s?.answered ?? 0;
|
|
268
|
+
return [
|
|
269
|
+
utc(r.ts),
|
|
270
|
+
runSource(r),
|
|
271
|
+
[...new Set(r.models.map((m) => shortFamily(m.family)))].join(", "),
|
|
272
|
+
answered,
|
|
273
|
+
pct(Math.round((s?.mentionRate ?? 0) * answered), answered),
|
|
274
|
+
pct(Math.round((s?.recommendedFirstRate ?? 0) * answered), answered),
|
|
275
|
+
`${Math.round((s?.citationRate ?? 0) * 100)}%`,
|
|
276
|
+
r.totalCostUsd ? money(r.totalCostUsd) : r.answers[0]?.source === "manual" ? "free" : "—",
|
|
277
|
+
];
|
|
278
|
+
})), "");
|
|
279
|
+
const detail = sorted.slice(0, o.detailRuns ?? 8);
|
|
280
|
+
for (const r of detail) {
|
|
281
|
+
L.push(`## ${utc(r.ts)} · ${runSource(r)} · ${r.id}`, "");
|
|
282
|
+
L.push(mdTable(["date", "question", "assistant", "mentioned", "rank", "who was recommended", "cited page"], r.answers.map((a) => [
|
|
283
|
+
a.ts.slice(0, 10),
|
|
284
|
+
truncate(a.question, 70) + (a.questionType === "diagnostic" ? " (diagnostic)" : ""),
|
|
285
|
+
shortFamily(a.family) + (a.sample && a.sample > 1 ? ` (sample ${a.sample})` : ""),
|
|
286
|
+
a.error ? truncate(a.error, 40) : a.mentioned ? "yes" : "no",
|
|
287
|
+
a.rank ?? "—",
|
|
288
|
+
a.namedProducts.slice(0, 4).join(", ") || "—",
|
|
289
|
+
citedPages(a, o.ownDomains),
|
|
290
|
+
])), "");
|
|
291
|
+
}
|
|
292
|
+
if (sorted.length > detail.length)
|
|
293
|
+
L.push(`Older runs: see .katman/runs/ (${sorted.length - detail.length} more).`, "");
|
|
294
|
+
return L.join("\n");
|
|
295
|
+
}
|