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,463 @@
|
|
|
1
|
+
import { domainOf, truncate } from "../../util.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Folding: case- and diacritics-insensitive text with a map back to the original offsets.
|
|
4
|
+
const EXTRA_FOLD = {
|
|
5
|
+
ı: "i", ß: "ss", æ: "ae", Æ: "ae", ø: "o", Ø: "o", œ: "oe", Œ: "oe", đ: "d", Đ: "d", ł: "l", Ł: "l", þ: "th", Þ: "th",
|
|
6
|
+
"’": "'", "‘": "'", "‐": "-", "‑": "-", "–": "-", "—": "-",
|
|
7
|
+
};
|
|
8
|
+
function foldChar(ch) {
|
|
9
|
+
const x = EXTRA_FOLD[ch];
|
|
10
|
+
if (x !== undefined)
|
|
11
|
+
return x;
|
|
12
|
+
if (ch.charCodeAt(0) < 128)
|
|
13
|
+
return ch.toLowerCase();
|
|
14
|
+
return ch.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
export function foldWithMap(s) {
|
|
17
|
+
let text = "";
|
|
18
|
+
const map = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
for (const ch of s) {
|
|
21
|
+
const f = foldChar(ch);
|
|
22
|
+
for (let k = 0; k < f.length; k++)
|
|
23
|
+
map.push(i);
|
|
24
|
+
text += f;
|
|
25
|
+
i += ch.length;
|
|
26
|
+
}
|
|
27
|
+
map.push(i);
|
|
28
|
+
return { text, map };
|
|
29
|
+
}
|
|
30
|
+
export function fold(s) {
|
|
31
|
+
return foldWithMap(s).text;
|
|
32
|
+
}
|
|
33
|
+
/** Comparison key: folded words joined by single spaces ("Çözüm-AI" → "cozum ai"). */
|
|
34
|
+
export function foldKey(s) {
|
|
35
|
+
return fold(s).split(/[^\p{L}\p{N}]+/u).filter(Boolean).join(" ");
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Domains
|
|
39
|
+
/** Two-level public suffixes common for our users' markets. */
|
|
40
|
+
const TWO_LEVEL = new Set([
|
|
41
|
+
"co.uk", "org.uk", "ac.uk", "me.uk", "com.tr", "net.tr", "org.tr", "gen.tr", "web.tr", "biz.tr", "info.tr", "av.tr", "k12.tr",
|
|
42
|
+
"com.au", "net.au", "org.au", "co.nz", "co.za", "com.br", "com.mx", "co.jp", "ne.jp", "or.jp", "co.kr", "co.in", "net.in",
|
|
43
|
+
"com.cn", "com.hk", "com.sg", "com.ar", "com.co", "com.ua", "com.pl", "co.il", "com.my", "com.ph", "com.vn", "co.id", "co.th",
|
|
44
|
+
"com.eg", "com.sa", "com.es", "com.pt", "co.at", "or.at",
|
|
45
|
+
]);
|
|
46
|
+
/** Hosting domains where the site is the subdomain (the label must not become "vercel", "github"…). */
|
|
47
|
+
const HOSTING_SUFFIXES = [
|
|
48
|
+
"vercel.app", "netlify.app", "github.io", "gitlab.io", "pages.dev", "workers.dev", "web.app", "firebaseapp.com", "herokuapp.com",
|
|
49
|
+
"onrender.com", "fly.dev", "lovable.app", "replit.app", "repl.co", "glitch.me", "surge.sh", "framer.website", "framer.app",
|
|
50
|
+
"webflow.io", "wixsite.com", "carrd.co", "notion.site", "super.site", "azurewebsites.net", "amplifyapp.com", "up.railway.app",
|
|
51
|
+
"railway.app", "deno.dev", "appspot.com", "bolt.host", "netlify.com", "vercel.com",
|
|
52
|
+
];
|
|
53
|
+
/** Platforms whose domain says nothing about a competitor listed there (github.com/foo, apps.apple.com/…). */
|
|
54
|
+
const PLATFORM_DOMAINS = new Set([
|
|
55
|
+
"github.com", "gitlab.com", "apple.com", "google.com", "microsoft.com", "producthunt.com", "youtube.com", "medium.com",
|
|
56
|
+
"substack.com", "notion.so", "notion.site", "chromewebstore.google.com", "linkedin.com", "facebook.com", "instagram.com",
|
|
57
|
+
"x.com", "twitter.com", "tiktok.com", "reddit.com", "npmjs.com", "pypi.org", "huggingface.co", "wordpress.org", "shopify.com",
|
|
58
|
+
]);
|
|
59
|
+
export function normalizeHost(input) {
|
|
60
|
+
if (!input)
|
|
61
|
+
return "";
|
|
62
|
+
const s = input.trim();
|
|
63
|
+
if (!s)
|
|
64
|
+
return "";
|
|
65
|
+
return domainOf(/^[a-z]+:\/\//i.test(s) ? s : `https://${s}`).replace(/\.$/, "");
|
|
66
|
+
}
|
|
67
|
+
export function registrableDomain(host) {
|
|
68
|
+
const h = normalizeHost(host);
|
|
69
|
+
for (const suf of HOSTING_SUFFIXES) {
|
|
70
|
+
if (h.endsWith("." + suf)) {
|
|
71
|
+
const sub = h.slice(0, -suf.length - 1).split(".").pop();
|
|
72
|
+
return sub ? `${sub}.${suf}` : suf;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const parts = h.split(".").filter(Boolean);
|
|
76
|
+
if (parts.length <= 2)
|
|
77
|
+
return parts.join(".");
|
|
78
|
+
const last2 = parts.slice(-2).join(".");
|
|
79
|
+
return TWO_LEVEL.has(last2) ? parts.slice(-3).join(".") : last2;
|
|
80
|
+
}
|
|
81
|
+
/** "example.shop" → "example"; "app.acme.com.tr" → "acme"; "myapp.vercel.app" → "myapp". */
|
|
82
|
+
export function domainLabel(host) {
|
|
83
|
+
const reg = registrableDomain(host);
|
|
84
|
+
return reg.split(".")[0] || undefined;
|
|
85
|
+
}
|
|
86
|
+
export function sameSite(domain, own) {
|
|
87
|
+
if (!domain || !own)
|
|
88
|
+
return false;
|
|
89
|
+
return domain === own || domain.endsWith("." + own);
|
|
90
|
+
}
|
|
91
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
const B_BEFORE = "(?<![\\p{L}\\p{N}])";
|
|
93
|
+
const B_AFTER = "(?![\\p{L}\\p{N}])";
|
|
94
|
+
/** Whole-word phrase on folded text; words may be joined by space, hyphen, dot, underscore, +, &, / or nothing ("Acme Books" = "acme-books" = "AcmeBooks", "C++ Builder"). Not by commas. */
|
|
95
|
+
function phrasePattern(term) {
|
|
96
|
+
const tokens = fold(term).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
97
|
+
if (!tokens.length)
|
|
98
|
+
return undefined;
|
|
99
|
+
return new RegExp(B_BEFORE + tokens.map(escapeRe).join("[\\s\\-_.·+&/]{0,4}") + B_AFTER, "gu");
|
|
100
|
+
}
|
|
101
|
+
/** "katman.pro" also matches "www.katman.pro" and "app.katman.pro", not "notkatman.pro" or "katman.pro.evil.com". */
|
|
102
|
+
function domainPattern(domain) {
|
|
103
|
+
return new RegExp("(?<![\\p{L}\\p{N}-])" + escapeRe(fold(domain)) + "(?![\\p{L}\\p{N}-]|\\.[\\p{L}\\p{N}])", "gu");
|
|
104
|
+
}
|
|
105
|
+
const looksLikeDomain = (s) => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s.trim());
|
|
106
|
+
export function buildEntity(name, o = {}) {
|
|
107
|
+
const patterns = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const addPhrase = (term, kind) => {
|
|
110
|
+
const key = foldKey(term);
|
|
111
|
+
if (!key || seen.has(key))
|
|
112
|
+
return;
|
|
113
|
+
const re = phrasePattern(term);
|
|
114
|
+
if (!re)
|
|
115
|
+
return;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
patterns.push({ kind, term: key, re });
|
|
118
|
+
};
|
|
119
|
+
const domains = [];
|
|
120
|
+
const addDomain = (d, withLabel) => {
|
|
121
|
+
const host = normalizeHost(d);
|
|
122
|
+
if (!host || !host.includes(".") || domains.includes(host))
|
|
123
|
+
return;
|
|
124
|
+
if (o.platformCheck && PLATFORM_DOMAINS.has(registrableDomain(host)))
|
|
125
|
+
return;
|
|
126
|
+
domains.push(host);
|
|
127
|
+
patterns.push({ kind: "domain", term: host, re: domainPattern(host) });
|
|
128
|
+
const label = domainLabel(host);
|
|
129
|
+
if (withLabel && label && label.length >= 4)
|
|
130
|
+
addPhrase(label, "label");
|
|
131
|
+
};
|
|
132
|
+
addPhrase(name, "name");
|
|
133
|
+
for (const a of o.alternateNames ?? []) {
|
|
134
|
+
if (typeof a !== "string" || fold(a).replace(/[^\p{L}\p{N}]/gu, "").length < 3)
|
|
135
|
+
continue;
|
|
136
|
+
if (looksLikeDomain(a))
|
|
137
|
+
addDomain(a, false);
|
|
138
|
+
else
|
|
139
|
+
addPhrase(a, "alt");
|
|
140
|
+
}
|
|
141
|
+
const d = o.domain || (o.url ? domainOf(o.url) : "");
|
|
142
|
+
if (d)
|
|
143
|
+
addDomain(d, true);
|
|
144
|
+
return { name, patterns, domains };
|
|
145
|
+
}
|
|
146
|
+
export function findHits(text, e) {
|
|
147
|
+
const f = typeof text === "string" ? foldWithMap(text) : text;
|
|
148
|
+
const hits = [];
|
|
149
|
+
for (const p of e.patterns) {
|
|
150
|
+
p.re.lastIndex = 0;
|
|
151
|
+
for (let m = p.re.exec(f.text); m; m = p.re.exec(f.text)) {
|
|
152
|
+
hits.push({ index: f.map[m.index] ?? 0, kind: p.kind });
|
|
153
|
+
if (m[0].length === 0)
|
|
154
|
+
p.re.lastIndex++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return hits.sort((a, b) => a.index - b.index);
|
|
158
|
+
}
|
|
159
|
+
export function buildContext(r) {
|
|
160
|
+
const domain = normalizeHost(r.site?.domain || r.site?.url || "");
|
|
161
|
+
const brand = buildEntity(r.brand.name.trim(), { alternateNames: r.brand.alternateNames, domain });
|
|
162
|
+
const own = [domain, ...(r.site?.previewUrls ?? []).map((u) => normalizeHost(u))].filter(Boolean);
|
|
163
|
+
const brandKey = foldKey(r.brand.name);
|
|
164
|
+
const competitors = (r.competitors ?? [])
|
|
165
|
+
.filter((c) => c && typeof c.name === "string" && c.name.trim() && foldKey(c.name) !== brandKey)
|
|
166
|
+
.map((c) => buildEntity(c.name.trim(), { url: c.url, platformCheck: true }));
|
|
167
|
+
return { brand, competitors, ownDomains: [...new Set(own)] };
|
|
168
|
+
}
|
|
169
|
+
export function mentions(ctx, text) {
|
|
170
|
+
return findHits(text, ctx.brand).length > 0;
|
|
171
|
+
}
|
|
172
|
+
/** Canonical research competitor name for a product string, if it is one of them. */
|
|
173
|
+
export function canonicalCompetitor(ctx, name) {
|
|
174
|
+
const f = foldWithMap(name);
|
|
175
|
+
return ctx.competitors.find((c) => findHits(f, c).length > 0)?.name;
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Links and citations
|
|
179
|
+
const TRAILING = /[.,;:!?'"»”’>*_\]]+$/u;
|
|
180
|
+
function trimUrl(raw) {
|
|
181
|
+
let s = raw.trim();
|
|
182
|
+
for (;;) {
|
|
183
|
+
const before = s;
|
|
184
|
+
s = s.replace(TRAILING, "");
|
|
185
|
+
// Keep ")" only when balanced, e.g. https://en.wikipedia.org/wiki/Foo_(bar)
|
|
186
|
+
while (s.endsWith(")") && (s.match(/\(/g)?.length ?? 0) < (s.match(/\)/g)?.length ?? 0))
|
|
187
|
+
s = s.slice(0, -1);
|
|
188
|
+
if (s === before)
|
|
189
|
+
return s;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function normalizeUrl(raw) {
|
|
193
|
+
let s = trimUrl(raw);
|
|
194
|
+
if (/^www\./i.test(s))
|
|
195
|
+
s = `https://${s}`;
|
|
196
|
+
let u;
|
|
197
|
+
try {
|
|
198
|
+
u = new URL(s);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
if (!/^https?:$/.test(u.protocol) || !u.hostname.includes("."))
|
|
204
|
+
return undefined;
|
|
205
|
+
u.hash = "";
|
|
206
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
207
|
+
if (/^utm_/i.test(k) || /^(gclid|fbclid|srsltid|mc_cid|mc_eid)$/i.test(k))
|
|
208
|
+
u.searchParams.delete(k);
|
|
209
|
+
}
|
|
210
|
+
const out = u.toString();
|
|
211
|
+
return u.pathname === "/" && !u.search ? out.replace(/\/$/, "") : out;
|
|
212
|
+
}
|
|
213
|
+
/** Start of a markdown link whose target is an http(s) URL; the URL itself is read by readMdUrl. */
|
|
214
|
+
const MD_LINK_START = /\[([^\]\n]{0,300})\]\(\s*<?(?=https?:\/\/)/giu;
|
|
215
|
+
/**
|
|
216
|
+
* Read a markdown link target starting at `start`, balancing parentheses so URLs like
|
|
217
|
+
* https://en.wikipedia.org/wiki/Mercury_(planet) survive intact. Returns the URL and the index after
|
|
218
|
+
* the link's closing ")" (optional <…> and "title" allowed), or undefined if it isn't a well-formed link.
|
|
219
|
+
*/
|
|
220
|
+
function readMdUrl(text, start) {
|
|
221
|
+
let i = start;
|
|
222
|
+
let depth = 0;
|
|
223
|
+
while (i < text.length) {
|
|
224
|
+
const ch = text[i];
|
|
225
|
+
if (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "<" || ch === ">")
|
|
226
|
+
break;
|
|
227
|
+
if (ch === "(")
|
|
228
|
+
depth++;
|
|
229
|
+
else if (ch === ")") {
|
|
230
|
+
if (depth === 0)
|
|
231
|
+
break;
|
|
232
|
+
depth--;
|
|
233
|
+
}
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
const url = text.slice(start, i);
|
|
237
|
+
let j = i;
|
|
238
|
+
if (text[j] === ">")
|
|
239
|
+
j++;
|
|
240
|
+
const rest = /^\s*(?:"[^"\n]*"\s*)?\)/.exec(text.slice(j, j + 400));
|
|
241
|
+
if (!url || !rest)
|
|
242
|
+
return undefined;
|
|
243
|
+
return { url, end: j + rest[0].length };
|
|
244
|
+
}
|
|
245
|
+
const BARE_URL = /(?:https?:\/\/|\bwww\.)[^\s<>"'`[\]{}|\\^]+/giu;
|
|
246
|
+
/** Markdown links and bare URLs written in the answer text. */
|
|
247
|
+
export function extractLinks(text) {
|
|
248
|
+
const out = [];
|
|
249
|
+
const spans = [];
|
|
250
|
+
for (const m of text.matchAll(MD_LINK_START)) {
|
|
251
|
+
const i = m.index ?? 0;
|
|
252
|
+
const link = readMdUrl(text, i + m[0].length);
|
|
253
|
+
if (!link)
|
|
254
|
+
continue;
|
|
255
|
+
spans.push([i, link.end]);
|
|
256
|
+
const title = m[1].replace(/[*_`]/g, "").trim();
|
|
257
|
+
out.push({ url: link.url, title: title.length > 2 && !/^\d+$/.test(title) ? title : undefined });
|
|
258
|
+
}
|
|
259
|
+
for (const m of text.matchAll(BARE_URL)) {
|
|
260
|
+
const i = m.index ?? 0;
|
|
261
|
+
if (spans.some(([a, b]) => i >= a && i < b))
|
|
262
|
+
continue;
|
|
263
|
+
out.push({ url: m[0] });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
export function toCitations(items, limit = 40) {
|
|
268
|
+
const byKey = new Map();
|
|
269
|
+
for (const it of items) {
|
|
270
|
+
const url = normalizeUrl(it.url);
|
|
271
|
+
if (!url)
|
|
272
|
+
continue;
|
|
273
|
+
const key = url.toLowerCase().replace(/\/$/, "");
|
|
274
|
+
const existing = byKey.get(key);
|
|
275
|
+
if (existing) {
|
|
276
|
+
if (!existing.title && it.title)
|
|
277
|
+
existing.title = truncate(it.title, 200);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (byKey.size >= limit)
|
|
281
|
+
continue;
|
|
282
|
+
const c = { url, domain: domainOf(url) };
|
|
283
|
+
if (it.title)
|
|
284
|
+
c.title = truncate(it.title, 200);
|
|
285
|
+
byKey.set(key, c);
|
|
286
|
+
}
|
|
287
|
+
return [...byKey.values()];
|
|
288
|
+
}
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Heuristic product list (fallback when the judge is off or fails)
|
|
291
|
+
const STOP_ITEMS = new Set([
|
|
292
|
+
"pros", "cons", "pricing", "price", "prices", "cost", "features", "feature", "key features", "best for", "ideal for", "good for",
|
|
293
|
+
"why", "how", "how it works", "what it does", "note", "notes", "tip", "tips", "summary", "conclusion", "overview", "verdict",
|
|
294
|
+
"bottom line", "limitations", "drawbacks", "downsides", "advantages", "disadvantages", "example", "examples", "use case",
|
|
295
|
+
"use cases", "steps", "step", "option", "options", "alternatives", "recommendation", "recommendations", "free", "paid",
|
|
296
|
+
"free plan", "website", "link", "links", "source", "sources", "reference", "references", "faq", "yes", "no", "quick answer",
|
|
297
|
+
"short answer", "my recommendation", "final thoughts", "comparison", "table", "tool", "tools", "platform", "name",
|
|
298
|
+
"artilar", "eksiler", "fiyat", "fiyatlandirma", "ozellikler", "neden", "nasil", "ipucu", "ozet", "sonuc", "avantajlar",
|
|
299
|
+
"dezavantajlar", "ornek", "kimler icin", "ucretsiz", "ucretli", "kaynaklar", "kaynak", "adim", "arac", "araclar", "oneri",
|
|
300
|
+
"evet", "hayir", "diger secenekler", "secenekler", "other options",
|
|
301
|
+
].map((s) => foldKey(s)));
|
|
302
|
+
function nameFromItem(raw) {
|
|
303
|
+
let t = raw.trim().replace(/^[^\p{L}\p{N}*_[]+/u, "");
|
|
304
|
+
const bold = t.match(/^(?:\*\*|__)(.+?)(?:\*\*|__)/);
|
|
305
|
+
const link = t.match(/^\[([^\]]+)\]\([^)]*\)/);
|
|
306
|
+
if (bold)
|
|
307
|
+
t = bold[1];
|
|
308
|
+
else if (link)
|
|
309
|
+
t = link[1];
|
|
310
|
+
else {
|
|
311
|
+
const m = t.match(/^(.{2,60}?)(?:\s[-–—]\s|:\s|\s\(|,\s|\s\|)/);
|
|
312
|
+
if (m)
|
|
313
|
+
t = m[1];
|
|
314
|
+
else if (t.length > 45)
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
t = t
|
|
318
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
319
|
+
.replace(/[*_`#]/g, "")
|
|
320
|
+
.replace(/^\d{1,2}[.)]\s*/, "")
|
|
321
|
+
.replace(/[:.,;–—-]+$/u, "")
|
|
322
|
+
.trim();
|
|
323
|
+
if (!t || t.length > 60 || !/\p{L}/u.test(t))
|
|
324
|
+
return undefined;
|
|
325
|
+
const words = t.split(/\s+/);
|
|
326
|
+
if (words.length > 6)
|
|
327
|
+
return undefined;
|
|
328
|
+
if (words.length >= 4 && /^\p{Ll}/u.test(t))
|
|
329
|
+
return undefined; // sentence fragment, not a name
|
|
330
|
+
if (STOP_ITEMS.has(foldKey(t)) || /^(step|adim|option|secenek)\s*\d+$/i.test(foldKey(t)))
|
|
331
|
+
return undefined;
|
|
332
|
+
return t;
|
|
333
|
+
}
|
|
334
|
+
/** Candidate product names in order of appearance: known entities (first mention) + list items, headings, bold line starts, table first cells. */
|
|
335
|
+
export function heuristicProducts(text, known) {
|
|
336
|
+
const folded = foldWithMap(text);
|
|
337
|
+
const cands = [];
|
|
338
|
+
for (const e of known) {
|
|
339
|
+
const h = findHits(folded, e)[0];
|
|
340
|
+
if (h)
|
|
341
|
+
cands.push({ pos: h.index, name: e.name });
|
|
342
|
+
}
|
|
343
|
+
const lines = text.split("\n");
|
|
344
|
+
let offset = 0;
|
|
345
|
+
for (let li = 0; li < lines.length; li++) {
|
|
346
|
+
const line = lines[li];
|
|
347
|
+
const pos = offset;
|
|
348
|
+
offset += line.length + 1;
|
|
349
|
+
let raw;
|
|
350
|
+
const table = line.match(/^\s*\|(.+)\|\s*$/);
|
|
351
|
+
if (table) {
|
|
352
|
+
const cells = table[1].split("|").map((c) => c.trim());
|
|
353
|
+
if (cells.every((c) => /^:?-{2,}:?$/.test(c)))
|
|
354
|
+
continue;
|
|
355
|
+
if (/^\s*\|?\s*:?-{2,}/.test(lines[li + 1] ?? ""))
|
|
356
|
+
continue; // header row
|
|
357
|
+
raw = cells[0];
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const item = line.match(/^( ?)(?:#{1,6}\s*)?(?:\d{1,2}[.)]|[-*•+])\s+(.+)$/);
|
|
361
|
+
const heading = line.match(/^#{1,6}\s+(.+)$/);
|
|
362
|
+
const bold = line.match(/^ ?(\*\*.+?\*\*.*)$/);
|
|
363
|
+
if (!item && heading) {
|
|
364
|
+
// A heading counts only when it is itself a name ("### Acme", "### 2. **Example Pro**"), not a sentence.
|
|
365
|
+
const h = heading[1].replace(/^\d{1,2}[.)]\s*/, "").trim();
|
|
366
|
+
const b = h.match(/^\*\*(.+?)\*\*/);
|
|
367
|
+
raw = b ? b[1] : h.split(/\s+/).length <= 4 && !/[,.?!;]/.test(h.replace(/:$/, "")) ? h : undefined;
|
|
368
|
+
}
|
|
369
|
+
else
|
|
370
|
+
raw = item?.[2] ?? bold?.[1];
|
|
371
|
+
}
|
|
372
|
+
if (!raw)
|
|
373
|
+
continue;
|
|
374
|
+
const name = nameFromItem(raw);
|
|
375
|
+
if (!name)
|
|
376
|
+
continue;
|
|
377
|
+
const f = foldWithMap(name);
|
|
378
|
+
const knownName = known.find((e) => findHits(f, e).length > 0)?.name;
|
|
379
|
+
cands.push({ pos, name: knownName ?? name });
|
|
380
|
+
}
|
|
381
|
+
cands.sort((a, b) => a.pos - b.pos);
|
|
382
|
+
return dedupeNames(cands.map((c) => c.name)).slice(0, 15);
|
|
383
|
+
}
|
|
384
|
+
/** Dedupe by folded key; "Acme" and "Acme AI" count as one (the first spelling wins). */
|
|
385
|
+
export function dedupeNames(names) {
|
|
386
|
+
const out = [];
|
|
387
|
+
const keys = [];
|
|
388
|
+
for (const n of names) {
|
|
389
|
+
const k = foldKey(n);
|
|
390
|
+
if (!k)
|
|
391
|
+
continue;
|
|
392
|
+
if (keys.some((x) => x === k || k.startsWith(x + " ") || x.startsWith(k + " ")))
|
|
393
|
+
continue;
|
|
394
|
+
keys.push(k);
|
|
395
|
+
out.push(n.trim());
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
export function excerptAround(text, index, max = 600) {
|
|
400
|
+
const clean = (s) => s.replace(/\s+/g, " ").trim();
|
|
401
|
+
if (index === undefined)
|
|
402
|
+
return truncate(clean(text), max);
|
|
403
|
+
const start = Math.max(0, index - Math.floor(max / 3));
|
|
404
|
+
const end = Math.min(text.length, start + max);
|
|
405
|
+
return (start > 0 ? "…" : "") + clean(text.slice(start, end)) + (end < text.length ? "…" : "");
|
|
406
|
+
}
|
|
407
|
+
export function cleanProducts(list) {
|
|
408
|
+
const names = list
|
|
409
|
+
.filter((x) => typeof x === "string")
|
|
410
|
+
.map((x) => x.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[*_`]/g, "").trim())
|
|
411
|
+
.filter((x) => x.length > 0 && x.length <= 80 && /[\p{L}\p{N}]/u.test(x));
|
|
412
|
+
return dedupeNames(names).slice(0, 20);
|
|
413
|
+
}
|
|
414
|
+
export function analyzeAnswer(ctx, input) {
|
|
415
|
+
const folded = foldWithMap(input.text);
|
|
416
|
+
const hits = findHits(folded, ctx.brand);
|
|
417
|
+
const mentionKinds = [...new Set(hits.map((h) => h.kind))];
|
|
418
|
+
const j = input.judge ?? null;
|
|
419
|
+
let mentioned = hits.length > 0;
|
|
420
|
+
let vetoed = false;
|
|
421
|
+
// A bare-name match can be an ordinary word ("katman" = "layer" in Turkish). Only then may the judge overrule presence.
|
|
422
|
+
if (mentioned && j && j.brand_mentioned === false && mentionKinds.every((k) => k === "name" || k === "label")) {
|
|
423
|
+
mentioned = false;
|
|
424
|
+
vetoed = true;
|
|
425
|
+
}
|
|
426
|
+
const competitorsNamed = ctx.competitors
|
|
427
|
+
.map((e) => ({ e, h: findHits(folded, e)[0] }))
|
|
428
|
+
.filter((x) => !!x.h)
|
|
429
|
+
.sort((a, b) => a.h.index - b.h.index)
|
|
430
|
+
.map((x) => x.e.name);
|
|
431
|
+
const method = j ? "judge" : "heuristic";
|
|
432
|
+
const namedProducts = j ? cleanProducts(j.products) : heuristicProducts(input.text, [ctx.brand, ...ctx.competitors]);
|
|
433
|
+
let rank = null;
|
|
434
|
+
if (mentioned) {
|
|
435
|
+
const idx = namedProducts.findIndex((p) => findHits(p, ctx.brand).length > 0);
|
|
436
|
+
if (idx >= 0)
|
|
437
|
+
rank = idx + 1;
|
|
438
|
+
else if (j && typeof j.brand_rank === "number" && j.brand_rank >= 1 && j.brand_rank <= namedProducts.length)
|
|
439
|
+
rank = j.brand_rank;
|
|
440
|
+
}
|
|
441
|
+
const citations = toCitations([
|
|
442
|
+
...(input.sources ?? []),
|
|
443
|
+
...extractLinks(input.text),
|
|
444
|
+
...(input.followUpSources ?? []).map((s) => ({ url: s.url, title: s.title ? `(follow-up) ${s.title}` : "(follow-up)" })),
|
|
445
|
+
]);
|
|
446
|
+
const cited = citations.some((c) => ctx.ownDomains.some((d) => sameSite(c.domain, d)));
|
|
447
|
+
const result = {
|
|
448
|
+
mentioned,
|
|
449
|
+
mentionKinds,
|
|
450
|
+
vetoed,
|
|
451
|
+
recommendedFirst: rank === 1,
|
|
452
|
+
rank,
|
|
453
|
+
cited,
|
|
454
|
+
namedProducts,
|
|
455
|
+
competitorsNamed,
|
|
456
|
+
citations,
|
|
457
|
+
answerExcerpt: excerptAround(input.text, mentioned ? hits[0]?.index : undefined),
|
|
458
|
+
method,
|
|
459
|
+
};
|
|
460
|
+
if (input.questionType === "diagnostic")
|
|
461
|
+
result.identityCorrect = j ? j.identity_correct : null;
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// CLI: `katman-mcp check` (dry run unless --yes; used by the GitHub Actions workflow) and
|
|
2
|
+
// `katman-mcp models` (default assistant models with live OpenRouter prices).
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "../../cli.js";
|
|
5
|
+
import { mdTable } from "../../util.js";
|
|
6
|
+
import { money, moneyRange, perAnswer } from "./estimate.js";
|
|
7
|
+
import { MODELS_VERIFIED_ON, profileFor, resolveJudge, resolvePanel } from "./models.js";
|
|
8
|
+
import { isInCatalog, loadCatalog, priceOf, searchUnitPrice } from "./openrouter.js";
|
|
9
|
+
import { searchLabel } from "./render.js";
|
|
10
|
+
import { visibilityRun } from "./run.js";
|
|
11
|
+
const list = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
12
|
+
const num = (v) => (typeof v === "string" && v.trim() && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
13
|
+
const on = (v) => v === true || v === "true" || v === "1";
|
|
14
|
+
async function check(args) {
|
|
15
|
+
const { flags } = parseArgs(args);
|
|
16
|
+
if (on(flags.help) || on(flags.h)) {
|
|
17
|
+
console.log(help.join("\n"));
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const root = path.resolve(typeof flags.root === "string" ? flags.root : process.cwd());
|
|
21
|
+
const runArgs = {
|
|
22
|
+
dry_run: !(on(flags.yes) || on(flags.y)),
|
|
23
|
+
models: list(flags.models),
|
|
24
|
+
question_ids: list(flags.questions),
|
|
25
|
+
max_cost_usd: num(flags["max-cost"]),
|
|
26
|
+
samples: num(flags.samples),
|
|
27
|
+
concurrency: num(flags.concurrency),
|
|
28
|
+
temperature: num(flags.temperature),
|
|
29
|
+
judge: on(flags["no-judge"]) ? false : undefined,
|
|
30
|
+
follow_up_sources: on(flags["follow-up"]) || undefined,
|
|
31
|
+
allow_google_grounding: on(flags["google-grounding"]) || undefined,
|
|
32
|
+
};
|
|
33
|
+
const out = await visibilityRun(root, runArgs, {
|
|
34
|
+
onProgress: (done, total, message) => process.stderr.write(`[${done}/${total}] ${message}\n`),
|
|
35
|
+
});
|
|
36
|
+
console.log(out.text);
|
|
37
|
+
const status = out.structured?.status;
|
|
38
|
+
if (out.isError)
|
|
39
|
+
return status === "refused_over_cap" || status === "key_limit_too_low" ? 2 : 1;
|
|
40
|
+
return status === "partial" ? 3 : 0;
|
|
41
|
+
}
|
|
42
|
+
async function models() {
|
|
43
|
+
const catalog = await loadCatalog(undefined, {});
|
|
44
|
+
const isAvailable = (s) => isInCatalog(catalog, s);
|
|
45
|
+
const L = [
|
|
46
|
+
`# Katman visibility models (defaults verified ${MODELS_VERIFIED_ON}; prices ${catalog.source === "live" ? "live from openrouter.ai" : "built-in"})`,
|
|
47
|
+
"",
|
|
48
|
+
];
|
|
49
|
+
if (catalog.warning)
|
|
50
|
+
L.push(catalog.warning, "");
|
|
51
|
+
for (const preset of ["full", "economy", "probe"]) {
|
|
52
|
+
const panel = resolvePanel({ input: preset, isAvailable });
|
|
53
|
+
L.push(`## ${preset}${preset === "full" ? " (default)" : ""}`, "");
|
|
54
|
+
L.push(mdTable(["assistant", "OpenRouter model", "web search", "$/M in / out", "per search", "≈ per answer"], panel.models.map((m) => {
|
|
55
|
+
const p = priceOf(catalog, m.id);
|
|
56
|
+
return [
|
|
57
|
+
m.family,
|
|
58
|
+
m.id + (isAvailable(m.id) === false ? " (not in catalog)" : ""),
|
|
59
|
+
searchLabel(m),
|
|
60
|
+
`${(p.prompt * 1e6).toFixed(2)} / ${(p.completion * 1e6).toFixed(2)}`,
|
|
61
|
+
money(searchUnitPrice(m, p)),
|
|
62
|
+
moneyRange(perAnswer(p, profileFor(m).profile, searchUnitPrice(m, p))),
|
|
63
|
+
];
|
|
64
|
+
})), "");
|
|
65
|
+
for (const n of panel.notes)
|
|
66
|
+
L.push(`- ${n}`);
|
|
67
|
+
if (panel.notes.length)
|
|
68
|
+
L.push("");
|
|
69
|
+
}
|
|
70
|
+
const judge = resolveJudge(undefined, isAvailable);
|
|
71
|
+
const jp = priceOf(catalog, judge);
|
|
72
|
+
L.push(`Judge (extracts the recommended products): ${judge}, $${(jp.prompt * 1e6).toFixed(2)} / $${(jp.completion * 1e6).toFixed(2)} per M tokens.`, "");
|
|
73
|
+
L.push("Per-answer ranges include the search results that native search adds to the prompt (measured Sep 2026). Run `katman-mcp check --root .` for a full estimate of your question set.", 'Override in .katman/config.json: {"models": [{"id": "openai/gpt-6-luna", "family": "ChatGPT (OpenAI)", "search": "native"}], "judgeModel": "openai/gpt-6-luna", "temperature": 0.7}');
|
|
74
|
+
console.log(L.join("\n"));
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
export const commands = {
|
|
78
|
+
check,
|
|
79
|
+
models: () => models(),
|
|
80
|
+
};
|
|
81
|
+
export const help = [
|
|
82
|
+
" katman-mcp check [--root .] [--yes] [--max-cost 3] [--models a,b]",
|
|
83
|
+
" AI visibility run via OpenRouter (OPENROUTER_API_KEY); dry run unless --yes",
|
|
84
|
+
" also: --questions q1,q2 --samples 3 --concurrency 4 --no-judge --follow-up --google-grounding",
|
|
85
|
+
" katman-mcp models default assistant models (full / economy / probe) with live OpenRouter prices",
|
|
86
|
+
];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export const ZERO = { low: 0, high: 0, expected: 0 };
|
|
2
|
+
export function addRange(a, b) {
|
|
3
|
+
return { low: a.low + b.low, high: a.high + b.high, expected: a.expected + b.expected };
|
|
4
|
+
}
|
|
5
|
+
export function scaleRange(a, k) {
|
|
6
|
+
return { low: a.low * k, high: a.high * k, expected: a.expected * k };
|
|
7
|
+
}
|
|
8
|
+
const mid = (a) => (a[0] + a[1]) / 2;
|
|
9
|
+
/** Cost of one answer from token and search ranges (USD). */
|
|
10
|
+
export function perAnswer(price, p, searchUnit) {
|
|
11
|
+
const at = (inT, outT, s) => inT * price.prompt + outT * price.completion + s * searchUnit + price.request;
|
|
12
|
+
return {
|
|
13
|
+
low: at(p.inputTokens[0], p.outputTokens[0], p.searches[0]),
|
|
14
|
+
high: at(p.inputTokens[1], p.outputTokens[1], p.searches[1]),
|
|
15
|
+
expected: at(mid(p.inputTokens), mid(p.outputTokens), mid(p.searches)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Judge call: extraction prompt + the answer in, a short JSON verdict out. */
|
|
19
|
+
export const JUDGE_PROFILE = {
|
|
20
|
+
inputTokens: [700, 3200], outputTokens: [60, 400], searches: [0, 0],
|
|
21
|
+
basis: "extraction prompt (~500 tokens) + answer (≤ ~2.5k tokens), JSON out",
|
|
22
|
+
};
|
|
23
|
+
/** Follow-up turn: the conversation so far plus "which pages did you use?", searching again. */
|
|
24
|
+
export function followUpProfile(p) {
|
|
25
|
+
return {
|
|
26
|
+
inputTokens: [p.inputTokens[0] + 600, p.inputTokens[1] + 2600],
|
|
27
|
+
outputTokens: [150, 800],
|
|
28
|
+
searches: p.searches,
|
|
29
|
+
basis: "conversation so far + the follow-up question",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function estimateRun(i) {
|
|
33
|
+
const samples = Math.max(1, Math.floor(i.samples));
|
|
34
|
+
const answersPerModel = i.questions * samples;
|
|
35
|
+
const rows = i.models.map((m) => {
|
|
36
|
+
let pa = perAnswer(m.price, m.profile, m.searchUnit);
|
|
37
|
+
if (m.lastActual !== undefined && m.lastActual > 0) {
|
|
38
|
+
// Calibrate on what this model actually cost last time, keep the profile's spread.
|
|
39
|
+
pa = { low: Math.min(pa.low, m.lastActual * 0.6), high: Math.max(pa.high, m.lastActual * 1.6), expected: m.lastActual };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
spec: m.spec,
|
|
43
|
+
answers: answersPerModel,
|
|
44
|
+
perAnswer: pa,
|
|
45
|
+
total: scaleRange(pa, answersPerModel),
|
|
46
|
+
price: m.price,
|
|
47
|
+
lastActual: m.lastActual,
|
|
48
|
+
basis: m.profile.basis,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const answers = answersPerModel * i.models.length;
|
|
52
|
+
let total = rows.reduce((acc, r) => addRange(acc, r.total), ZERO);
|
|
53
|
+
let judge;
|
|
54
|
+
if (i.judge) {
|
|
55
|
+
const perCall = perAnswer(i.judge.price, JUDGE_PROFILE, 0);
|
|
56
|
+
judge = { model: i.judge.model, calls: answers, perCall, total: scaleRange(perCall, answers) };
|
|
57
|
+
total = addRange(total, judge.total);
|
|
58
|
+
}
|
|
59
|
+
let followUps;
|
|
60
|
+
if (i.followUps) {
|
|
61
|
+
// Only answers that don't name the brand get a follow-up: assume 50–100 % (expected 80 %) of scored answers.
|
|
62
|
+
const scored = i.scoredQuestions * samples;
|
|
63
|
+
let t = ZERO;
|
|
64
|
+
i.models.forEach((m, k) => {
|
|
65
|
+
const f = perAnswer(m.price, followUpProfile(m.profile), m.searchUnit);
|
|
66
|
+
rows[k].followUpPerCall = f;
|
|
67
|
+
t = addRange(t, { low: f.low * scored * 0.5, high: f.high * scored, expected: f.expected * scored * 0.8 });
|
|
68
|
+
});
|
|
69
|
+
followUps = { maxCalls: scored * i.models.length, total: t };
|
|
70
|
+
total = addRange(total, t);
|
|
71
|
+
}
|
|
72
|
+
return { rows, answers, judge, followUps, total };
|
|
73
|
+
}
|
|
74
|
+
/** Reservation for one (question, model) task in the live cost guard. */
|
|
75
|
+
export function taskCost(row, est, followUp) {
|
|
76
|
+
let expected = row.perAnswer.expected;
|
|
77
|
+
let high = row.perAnswer.high;
|
|
78
|
+
if (est.judge) {
|
|
79
|
+
expected += est.judge.perCall.expected;
|
|
80
|
+
high += est.judge.perCall.high;
|
|
81
|
+
}
|
|
82
|
+
if (followUp && row.followUpPerCall) {
|
|
83
|
+
expected += row.followUpPerCall.expected * 0.8;
|
|
84
|
+
high += row.followUpPerCall.high;
|
|
85
|
+
}
|
|
86
|
+
return { expected, high };
|
|
87
|
+
}
|
|
88
|
+
export function money(n) {
|
|
89
|
+
if (!Number.isFinite(n))
|
|
90
|
+
return "?";
|
|
91
|
+
if (n === 0)
|
|
92
|
+
return "$0";
|
|
93
|
+
if (n < 0.01)
|
|
94
|
+
return `$${n.toFixed(4)}`;
|
|
95
|
+
if (n < 1)
|
|
96
|
+
return `$${n.toFixed(3)}`;
|
|
97
|
+
return `$${n.toFixed(2)}`;
|
|
98
|
+
}
|
|
99
|
+
export function moneyRange(r) {
|
|
100
|
+
return `${money(r.low)}–${money(r.high)}`;
|
|
101
|
+
}
|
|
102
|
+
/** Round a cap suggestion up to the next 50 cents. */
|
|
103
|
+
export function suggestCap(high) {
|
|
104
|
+
return Math.max(0.5, Math.ceil(high * 2) / 2);
|
|
105
|
+
}
|