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,199 @@
|
|
|
1
|
+
// robots.txt analysis (pure). Allow/deny decisions use robots-parser (Google's matching algorithm); the line-level
|
|
2
|
+
// validation mirrors Lighthouse's robots-txt audit so "Content-Signal:" and friends are reported the way site owners
|
|
3
|
+
// will see them in Lighthouse / Search Console.
|
|
4
|
+
import robotsParserModule from "robots-parser";
|
|
5
|
+
import { CRAWLERS, PERMISSION_TOKENS, ROBOTS_TOKENS } from "../../../crawlers.js";
|
|
6
|
+
const robotsParser = robotsParserModule.default ?? robotsParserModule;
|
|
7
|
+
const KNOWN = new Set([
|
|
8
|
+
"user-agent", "disallow", "allow", "sitemap", "crawl-delay", "clean-param", "host", "request-rate", "visit-time", "noindex",
|
|
9
|
+
]);
|
|
10
|
+
/** Tokens Cloudflare's managed robots.txt blocks (developers.cloudflare.com/bots/additional-configurations/managed-robots-txt). */
|
|
11
|
+
export const CF_MANAGED_TOKENS = [
|
|
12
|
+
"Amazonbot", "Applebot-Extended", "Bytespider", "CCBot", "ClaudeBot", "Google-Extended", "GPTBot", "meta-externalagent",
|
|
13
|
+
];
|
|
14
|
+
export function allRobotsTokens() {
|
|
15
|
+
const seen = new Map();
|
|
16
|
+
for (const t of [...ROBOTS_TOKENS, ...CRAWLERS.map((c) => c.token), ...PERMISSION_TOKENS]) {
|
|
17
|
+
if (!seen.has(t.toLowerCase()))
|
|
18
|
+
seen.set(t.toLowerCase(), t);
|
|
19
|
+
}
|
|
20
|
+
return [...seen.values()];
|
|
21
|
+
}
|
|
22
|
+
export function looksLikeHtml(body, contentType) {
|
|
23
|
+
return /text\/html/i.test(contentType ?? "") || /^\s*(<!doctype html|<html[\s>]|<head[\s>])/i.test(body);
|
|
24
|
+
}
|
|
25
|
+
export function analyzeRobots(text, robotsUrl, status = 200, contentType) {
|
|
26
|
+
let origin = "https://example.invalid";
|
|
27
|
+
try {
|
|
28
|
+
origin = new URL(robotsUrl).origin;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* keep */
|
|
32
|
+
}
|
|
33
|
+
const isHtml = status === 200 && looksLikeHtml(text, contentType);
|
|
34
|
+
const usable = status === 200 && !isHtml;
|
|
35
|
+
const body = usable ? text : "";
|
|
36
|
+
const lines = body.split(/\r\n|\r|\n/);
|
|
37
|
+
const groups = [];
|
|
38
|
+
const invalidLines = [];
|
|
39
|
+
const contentSignals = [];
|
|
40
|
+
const sitemaps = [];
|
|
41
|
+
let current;
|
|
42
|
+
let lastWasAgent = false;
|
|
43
|
+
lines.forEach((raw, i) => {
|
|
44
|
+
const lineNo = i + 1;
|
|
45
|
+
const clean = raw.replace(/#.*$/, "").trim();
|
|
46
|
+
if (!clean)
|
|
47
|
+
return;
|
|
48
|
+
const idx = clean.indexOf(":");
|
|
49
|
+
if (idx < 0) {
|
|
50
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — syntax not understood`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const key = clean.slice(0, idx).trim().toLowerCase();
|
|
54
|
+
const val = clean.slice(idx + 1).trim();
|
|
55
|
+
if (!KNOWN.has(key)) {
|
|
56
|
+
if (key === "content-signal") {
|
|
57
|
+
contentSignals.push(val);
|
|
58
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — unknown directive (Lighthouse: "robots.txt is not valid"; write it as a # comment)`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — unknown directive`);
|
|
62
|
+
}
|
|
63
|
+
lastWasAgent = false;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (key === "user-agent") {
|
|
67
|
+
if (!val)
|
|
68
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — no user-agent specified`);
|
|
69
|
+
if (!lastWasAgent || !current) {
|
|
70
|
+
current = { agents: [], rules: [], line: lineNo };
|
|
71
|
+
groups.push(current);
|
|
72
|
+
}
|
|
73
|
+
if (val)
|
|
74
|
+
current.agents.push(val);
|
|
75
|
+
lastWasAgent = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
lastWasAgent = false;
|
|
79
|
+
if (key === "allow" || key === "disallow") {
|
|
80
|
+
if (!current) {
|
|
81
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — no user-agent specified before this rule`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (val && !/^[/*]/.test(val)) {
|
|
85
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — pattern should be empty or start with "/" or "*"`);
|
|
86
|
+
}
|
|
87
|
+
const dollar = val.indexOf("$");
|
|
88
|
+
if (dollar >= 0 && dollar !== val.length - 1) {
|
|
89
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — "$" should only be used at the end of the pattern`);
|
|
90
|
+
}
|
|
91
|
+
if (val)
|
|
92
|
+
current.rules.push({ type: key, pattern: val, line: lineNo });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key === "sitemap") {
|
|
96
|
+
let okUrl = false;
|
|
97
|
+
try {
|
|
98
|
+
okUrl = /^https?:$/.test(new URL(val).protocol);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
okUrl = false;
|
|
102
|
+
}
|
|
103
|
+
if (!okUrl)
|
|
104
|
+
invalidLines.push(`line ${lineNo}: "${clip(clean)}" — invalid sitemap URL (must be absolute)`);
|
|
105
|
+
else
|
|
106
|
+
sitemaps.push(val);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const robot = robotsParser(origin + "/robots.txt", body);
|
|
110
|
+
const isAllowed = (url, token) => {
|
|
111
|
+
if (!usable)
|
|
112
|
+
return true;
|
|
113
|
+
let u;
|
|
114
|
+
try {
|
|
115
|
+
u = new URL(url, origin);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const o = new URL(origin);
|
|
121
|
+
u.protocol = o.protocol;
|
|
122
|
+
u.host = o.host;
|
|
123
|
+
return robot.isAllowed(u.href, token) !== false;
|
|
124
|
+
};
|
|
125
|
+
const blocksAll = usable && !isAllowed(origin + "/", "*");
|
|
126
|
+
const blockedTokens = usable ? allRobotsTokens().filter((t) => !isAllowed(origin + "/", t)) : [];
|
|
127
|
+
const managedMarker = /BEGIN Cloudflare Managed content/i.test(body);
|
|
128
|
+
const trainNo = contentSignals.some((v) => /ai-train\s*=\s*no/i.test(v));
|
|
129
|
+
const managedHits = CF_MANAGED_TOKENS.filter((t) => blockedTokens.some((b) => b.toLowerCase() === t.toLowerCase()));
|
|
130
|
+
const cloudflareManaged = managedMarker || (trainNo && managedHits.length >= 3);
|
|
131
|
+
const barePrefixes = [];
|
|
132
|
+
const slashOnly = [];
|
|
133
|
+
for (const g of groups) {
|
|
134
|
+
const dis = g.rules.filter((r) => r.type === "disallow").map((r) => r.pattern);
|
|
135
|
+
for (const r of g.rules) {
|
|
136
|
+
if (r.type !== "disallow")
|
|
137
|
+
continue;
|
|
138
|
+
const p = r.pattern;
|
|
139
|
+
if (p === "/" || /[*$?]/.test(p))
|
|
140
|
+
continue;
|
|
141
|
+
if (p.endsWith("/")) {
|
|
142
|
+
const bare = p.slice(0, -1);
|
|
143
|
+
if (bare && !dis.includes(bare) && !dis.includes(bare + "$"))
|
|
144
|
+
slashOnly.push({ pattern: p, line: r.line, agents: g.agents });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const last = p.split("/").pop() ?? "";
|
|
148
|
+
if (/\.[a-z0-9]{2,5}$/i.test(last))
|
|
149
|
+
continue; // a file like /private.html
|
|
150
|
+
barePrefixes.push({ pattern: p, line: r.line, agents: g.agents });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
url: robotsUrl,
|
|
155
|
+
status,
|
|
156
|
+
exists: usable && body.trim().length > 0,
|
|
157
|
+
isHtml,
|
|
158
|
+
bytes: text.length,
|
|
159
|
+
sitemaps,
|
|
160
|
+
groups,
|
|
161
|
+
blocksAll,
|
|
162
|
+
blockedTokens,
|
|
163
|
+
invalidLines,
|
|
164
|
+
contentSignals,
|
|
165
|
+
cloudflareManaged,
|
|
166
|
+
managedBlocked: cloudflareManaged ? managedHits : [],
|
|
167
|
+
barePrefixes: dedupePrefix(barePrefixes),
|
|
168
|
+
slashOnly: dedupePrefix(slashOnly),
|
|
169
|
+
isAllowed,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function dedupePrefix(list) {
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
for (const p of list) {
|
|
175
|
+
const k = p.pattern;
|
|
176
|
+
const prev = seen.get(k);
|
|
177
|
+
if (prev)
|
|
178
|
+
prev.agents = [...new Set([...prev.agents, ...p.agents])];
|
|
179
|
+
else
|
|
180
|
+
seen.set(k, { ...p, agents: [...p.agents] });
|
|
181
|
+
}
|
|
182
|
+
return [...seen.values()];
|
|
183
|
+
}
|
|
184
|
+
/** Paths a bare prefix blocks beyond its own subtree: `/t` → `/tools`, `/tr`. */
|
|
185
|
+
export function prefixCollateral(pattern, paths) {
|
|
186
|
+
const out = new Set();
|
|
187
|
+
for (const p of paths) {
|
|
188
|
+
if (!p.startsWith(pattern))
|
|
189
|
+
continue;
|
|
190
|
+
const rest = p.slice(pattern.length);
|
|
191
|
+
if (rest === "" || rest.startsWith("/") || rest.startsWith("?") || rest.startsWith(".") || rest.startsWith("#"))
|
|
192
|
+
continue;
|
|
193
|
+
out.add(p);
|
|
194
|
+
}
|
|
195
|
+
return [...out];
|
|
196
|
+
}
|
|
197
|
+
function clip(s, n = 90) {
|
|
198
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
199
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { decodeEntities } from "../common/text.js";
|
|
2
|
+
import { looksLikeHtml } from "./robots.js";
|
|
3
|
+
function tag(block, name) {
|
|
4
|
+
const m = block.match(new RegExp(`<(?:[a-z0-9]+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:[a-z0-9]+:)?${name}>`, "i"));
|
|
5
|
+
return m ? decodeEntities(m[1].trim()) : undefined;
|
|
6
|
+
}
|
|
7
|
+
export function parseSitemapXml(xml) {
|
|
8
|
+
const body = xml.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/<!--[\s\S]*?-->/g, "");
|
|
9
|
+
if (/<(?:[a-z0-9]+:)?sitemapindex[\s>]/i.test(body)) {
|
|
10
|
+
const children = [...body.matchAll(/<(?:[a-z0-9]+:)?sitemap\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9]+:)?sitemap>/gi)]
|
|
11
|
+
.map((m) => ({ loc: tag(m[1], "loc") ?? "", lastmod: tag(m[1], "lastmod") }))
|
|
12
|
+
.filter((c) => c.loc);
|
|
13
|
+
return { kind: "index", entries: [], children };
|
|
14
|
+
}
|
|
15
|
+
if (/<(?:[a-z0-9]+:)?urlset[\s>]/i.test(body)) {
|
|
16
|
+
const entries = [];
|
|
17
|
+
for (const m of body.matchAll(/<(?:[a-z0-9]+:)?url\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9]+:)?url>/gi)) {
|
|
18
|
+
const loc = tag(m[1], "loc");
|
|
19
|
+
if (!loc)
|
|
20
|
+
continue;
|
|
21
|
+
const alternates = [];
|
|
22
|
+
for (const l of m[1].matchAll(/<xhtml:link\b([^>]*?)\/?>/gi)) {
|
|
23
|
+
const attrs = l[1];
|
|
24
|
+
const lang = attrs.match(/hreflang\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
25
|
+
const href = attrs.match(/href\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
26
|
+
if (lang && href)
|
|
27
|
+
alternates.push({ lang, href: decodeEntities(href) });
|
|
28
|
+
}
|
|
29
|
+
entries.push({ loc, lastmod: tag(m[1], "lastmod"), alternates });
|
|
30
|
+
}
|
|
31
|
+
return { kind: "urlset", entries, children: [] };
|
|
32
|
+
}
|
|
33
|
+
const lines = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
34
|
+
if (lines.length && lines.every((l) => /^https?:\/\/\S+$/i.test(l))) {
|
|
35
|
+
return { kind: "text", entries: lines.map((loc) => ({ loc, alternates: [] })), children: [] };
|
|
36
|
+
}
|
|
37
|
+
return { kind: "invalid", entries: [], children: [] };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load sitemaps: the robots.txt Sitemap: lines, else /sitemap.xml, /sitemap_index.xml, /sitemap-index.xml (first
|
|
41
|
+
* that works). Follows index files breadth-first, at most `maxFiles` fetches.
|
|
42
|
+
*/
|
|
43
|
+
export async function loadSitemaps(origin, declared, fetcher, opts = {}) {
|
|
44
|
+
const maxFiles = opts.maxFiles ?? 11;
|
|
45
|
+
const maxUrls = opts.maxUrls ?? 50_000;
|
|
46
|
+
const sources = [];
|
|
47
|
+
const entries = new Map();
|
|
48
|
+
let truncated = false;
|
|
49
|
+
let fetched = 0;
|
|
50
|
+
const fetchOne = async (url) => {
|
|
51
|
+
if (/\.gz($|\?)/i.test(url)) {
|
|
52
|
+
sources.push({ url, status: -1, kind: "gzip-skipped", count: 0 });
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
fetched++;
|
|
56
|
+
const res = await fetcher(url, {
|
|
57
|
+
redirect: "follow",
|
|
58
|
+
maxBytes: 25_000_000,
|
|
59
|
+
headers: { accept: "application/xml,text/xml,text/plain,*/*" },
|
|
60
|
+
});
|
|
61
|
+
if (res.status !== 200) {
|
|
62
|
+
sources.push({ url, status: res.status, kind: "error", count: 0, error: res.error });
|
|
63
|
+
return { res };
|
|
64
|
+
}
|
|
65
|
+
if (looksLikeHtml(res.body, res.headers["content-type"])) {
|
|
66
|
+
sources.push({ url, status: res.status, kind: "html", count: 0, error: "returned an HTML page" });
|
|
67
|
+
return { res };
|
|
68
|
+
}
|
|
69
|
+
const parsed = parseSitemapXml(res.body);
|
|
70
|
+
sources.push({ url, status: res.status, kind: parsed.kind, count: parsed.kind === "index" ? parsed.children.length : parsed.entries.length });
|
|
71
|
+
return { res, parsed };
|
|
72
|
+
};
|
|
73
|
+
const queue = [];
|
|
74
|
+
if (declared.length) {
|
|
75
|
+
queue.push(...[...new Set(declared)].slice(0, 5));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
for (const cand of ["/sitemap.xml", "/sitemap_index.xml", "/sitemap-index.xml"]) {
|
|
79
|
+
const { parsed } = await fetchOne(origin + cand);
|
|
80
|
+
if (parsed && parsed.kind !== "invalid") {
|
|
81
|
+
absorb(parsed);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function absorb(parsed) {
|
|
87
|
+
if (parsed.kind === "index")
|
|
88
|
+
for (const c of parsed.children)
|
|
89
|
+
queue.push(c.loc);
|
|
90
|
+
for (const e of parsed.entries) {
|
|
91
|
+
if (entries.size >= maxUrls) {
|
|
92
|
+
truncated = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (!entries.has(e.loc))
|
|
96
|
+
entries.set(e.loc, e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const seen = new Set(sources.map((s) => s.url));
|
|
100
|
+
while (queue.length && fetched < maxFiles && entries.size < maxUrls) {
|
|
101
|
+
const next = queue.shift();
|
|
102
|
+
if (seen.has(next))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(next);
|
|
105
|
+
const { parsed } = await fetchOne(next);
|
|
106
|
+
if (parsed)
|
|
107
|
+
absorb(parsed);
|
|
108
|
+
}
|
|
109
|
+
if (queue.length)
|
|
110
|
+
truncated = true;
|
|
111
|
+
return { sources, entries: [...entries.values()], declared: declared.length > 0, truncated };
|
|
112
|
+
}
|
|
113
|
+
export function lastmodStats(entries, now = new Date()) {
|
|
114
|
+
const days = new Map();
|
|
115
|
+
let withLastmod = 0;
|
|
116
|
+
let future = 0;
|
|
117
|
+
const today = now.toISOString().slice(0, 10);
|
|
118
|
+
let todayCount = 0;
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
if (!e.lastmod)
|
|
121
|
+
continue;
|
|
122
|
+
const d = e.lastmod.slice(0, 10);
|
|
123
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
124
|
+
continue;
|
|
125
|
+
withLastmod++;
|
|
126
|
+
days.set(d, (days.get(d) ?? 0) + 1);
|
|
127
|
+
if (d === today)
|
|
128
|
+
todayCount++;
|
|
129
|
+
const t = Date.parse(e.lastmod);
|
|
130
|
+
if (Number.isFinite(t) && t > now.getTime() + 36 * 3600 * 1000)
|
|
131
|
+
future++;
|
|
132
|
+
}
|
|
133
|
+
let commonDay;
|
|
134
|
+
let max = 0;
|
|
135
|
+
for (const [d, n] of days)
|
|
136
|
+
if (n > max)
|
|
137
|
+
[commonDay, max] = [d, n];
|
|
138
|
+
return {
|
|
139
|
+
total: entries.length,
|
|
140
|
+
withLastmod,
|
|
141
|
+
distinctDays: days.size,
|
|
142
|
+
identical: withLastmod >= 5 && days.size === 1,
|
|
143
|
+
stampedToday: withLastmod >= 5 && todayCount / withLastmod >= 0.9,
|
|
144
|
+
future,
|
|
145
|
+
commonDay,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// CLI commands for the crawl module: scan, audit, indexnow, keywords. They print the same markdown as the MCP tools.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "../../cli.js";
|
|
4
|
+
import { loadConfig, loadResearch, logEvent, readJson, saveResearch } from "../../state.js";
|
|
5
|
+
import { auditAndSave, scanAndSave } from "./index.js";
|
|
6
|
+
import { indexNowMarkdown, runIndexNow } from "./indexnow.js";
|
|
7
|
+
import { keywordsMarkdown, mergeKeywords, runKeywords, termsToSave } from "./keywords.js";
|
|
8
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
9
|
+
const list = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
10
|
+
function fail(msg) {
|
|
11
|
+
console.error(msg);
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
export const commands = {
|
|
15
|
+
async scan(args) {
|
|
16
|
+
const a = parseArgs(args);
|
|
17
|
+
const root = path.resolve(a._[0] ?? str(a.flags.root) ?? process.cwd());
|
|
18
|
+
const max = Number(str(a.flags["max-files"]) ?? NaN);
|
|
19
|
+
try {
|
|
20
|
+
const { markdown } = await scanAndSave(root, Number.isFinite(max) ? max : undefined, !a.flags["no-save"]);
|
|
21
|
+
console.log(markdown);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return fail(`scan failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
async audit(args) {
|
|
29
|
+
const a = parseArgs(args);
|
|
30
|
+
const root = path.resolve(str(a.flags.root) ?? process.cwd());
|
|
31
|
+
const max = Number(str(a.flags.max) ?? NaN);
|
|
32
|
+
try {
|
|
33
|
+
const { run, markdown } = await auditAndSave(root, {
|
|
34
|
+
site_url: a._[0] ?? str(a.flags.site),
|
|
35
|
+
max_urls: Number.isFinite(max) ? max : undefined,
|
|
36
|
+
urls: list(a.flags.urls),
|
|
37
|
+
include_bot_test: a.flags["no-bots"] ? false : undefined,
|
|
38
|
+
preview_urls: list(a.flags.preview),
|
|
39
|
+
}, (done, total, message) => process.stderr.write(`\r[${done}/${total}] ${message} `), !a.flags["no-save"]);
|
|
40
|
+
process.stderr.write("\n");
|
|
41
|
+
console.log(markdown);
|
|
42
|
+
// --strict: non-zero exit when critical findings exist (for CI / a pre-publish script, book P14)
|
|
43
|
+
if (a.flags.strict && run.findings.some((f) => f.severity === "critical"))
|
|
44
|
+
return 2;
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
process.stderr.write("\n");
|
|
49
|
+
return fail(`audit failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
async indexnow(args) {
|
|
53
|
+
const a = parseArgs(args);
|
|
54
|
+
const root = path.resolve(str(a.flags.root) ?? process.cwd());
|
|
55
|
+
try {
|
|
56
|
+
const config = await loadConfig(root);
|
|
57
|
+
const research = await loadResearch(root);
|
|
58
|
+
const siteUrl = str(a.flags.site) ?? a._[0] ?? config.siteUrl ?? research?.site?.url;
|
|
59
|
+
if (!siteUrl)
|
|
60
|
+
return fail("No site URL: pass --site https://example.com (or run `katman-mcp audit <url>` first).");
|
|
61
|
+
const scan = await readJson(root, "scan.json");
|
|
62
|
+
const run = await runIndexNow({ root, siteUrl, urls: list(a.flags.urls), key: str(a.flags.key), dryRun: !a.flags.submit, staticDir: scan?.staticDir });
|
|
63
|
+
await logEvent(root, `katman indexnow ${run.host}: ${run.dryRun ? "dry run" : run.submitted ? `HTTP ${run.submitted.status}` : "nothing sent"}`);
|
|
64
|
+
console.log(indexNowMarkdown(run));
|
|
65
|
+
return run.submitted && run.submitted.status !== 200 && run.submitted.status !== 202 ? 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return fail(`indexnow failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async keywords(args) {
|
|
72
|
+
const a = parseArgs(args);
|
|
73
|
+
const seed = a._.join(" ").trim();
|
|
74
|
+
if (!seed)
|
|
75
|
+
return fail('Usage: katman-mcp keywords "<seed>" [--lang en] [--country us] [--no-expand] [--save] [--root .]');
|
|
76
|
+
try {
|
|
77
|
+
const r = await runKeywords({ seed, lang: str(a.flags.lang), country: str(a.flags.country), expand: !a.flags["no-expand"] });
|
|
78
|
+
let saved;
|
|
79
|
+
if (a.flags.save) {
|
|
80
|
+
const root = path.resolve(str(a.flags.root) ?? process.cwd());
|
|
81
|
+
const research = await loadResearch(root);
|
|
82
|
+
if (!research)
|
|
83
|
+
saved = { added: 0, note: "Not saved: no .katman/research.json (run katman_research first)." };
|
|
84
|
+
else {
|
|
85
|
+
const added = mergeKeywords(research, termsToSave(r));
|
|
86
|
+
await saveResearch(root, research);
|
|
87
|
+
saved = { added };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log(keywordsMarkdown(r, saved));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return fail(`keywords failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
export const help = [
|
|
99
|
+
" katman-mcp scan [path] [--no-save] scan the codebase → .katman/scan.json",
|
|
100
|
+
" katman-mcp audit <url> [--max 50] [--root .] [--urls u1,u2] [--preview u1] [--no-bots] [--strict] [--no-save]",
|
|
101
|
+
" audit the live site like AI crawlers see it (--strict: exit 2 on critical)",
|
|
102
|
+
" katman-mcp indexnow [--root .] [--site url] [--urls u1,u2] [--submit]",
|
|
103
|
+
" IndexNow key check + new/changed URLs (dry run unless --submit)",
|
|
104
|
+
' katman-mcp keywords "<seed>" [--lang en] [--country us] [--no-expand] [--save]',
|
|
105
|
+
" Google autocomplete ideas (≤40 requests)",
|
|
106
|
+
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Fix texts shared by scan and audit findings. Sources: the Katman book (ch. 5, 6, 12, 13) and
|
|
2
|
+
// research/tech-web-indexing.md (Cloudflare's 2026-07-01 / 2026-09-15 AI-bot policy changes).
|
|
3
|
+
export const JS_RENDER_NOTE = "No AI crawler runs JavaScript (only Googlebot, Bingbot and Applebot render pages), so a client-rendered page can appear in Google, Bing, Copilot and Gemini while looking empty to ChatGPT, Claude and Perplexity.";
|
|
4
|
+
export const LOVABLE_CAVEAT = "Lovable may serve prerendered HTML to verified bots only; confirm with Search Console URL Inspection (Test live URL → View crawled page). Lovable apps created since 2026-05-13 are server-rendered (TanStack Start); older ones can use \"upgrade to TanStack Start\".";
|
|
5
|
+
export const CSR_ACCEPT = "JavaScript disabled, every public page's H1 and first paragraph are readable";
|
|
6
|
+
export const CLOUDFLARE_STEPS = [
|
|
7
|
+
"Cloudflare dashboard → your domain → Security → Settings → Configure AI bot policies: Search = Allow, Agent = Allow.",
|
|
8
|
+
'Training is your business decision. To opt out, choose "Disallow AI Training" (a no-training signal that keeps Googlebot, Bingbot and Applebot allowed). Never choose "Block" or "Block on pages with ads": since 15 Sep 2026 that also blocks Googlebot, Bingbot and Applebot and drops the site from Google and Bing.',
|
|
9
|
+
'Turn off the legacy "Block AI bots" toggle if it is still shown, Managed robots.txt (publish your own, book appendix A), AI Labyrinth, and Bot Fight Mode if it challenges bots. Turn off Email Address Obfuscation so assistants can read your email address as text.',
|
|
10
|
+
"Security → Analytics → Events shows which rule refused the request; AI Crawl Control → Metrics shows the status codes AI bots receive.",
|
|
11
|
+
'API: GET/PUT /zones/<zone_id>/bot_management with {"ai_search":"disabled","ai_user":"disabled","ai_training":"disallow","ai_bots_protection":"disabled","fight_mode":false,"is_robots_txt_managed":false} ("disabled" = not blocked; use "disabled" for ai_training too if you allow training).',
|
|
12
|
+
"Re-run katman_audit_site. OpenAI says changes take about 24 hours to reach ChatGPT search.",
|
|
13
|
+
];
|
|
14
|
+
export function cloudflareFix() {
|
|
15
|
+
return CLOUDFLARE_STEPS.map((s, i) => `(${i + 1}) ${s}`).join(" ");
|
|
16
|
+
}
|
|
17
|
+
export function genericCdnFix(cdn) {
|
|
18
|
+
switch (cdn) {
|
|
19
|
+
case "vercel":
|
|
20
|
+
return "Vercel → Project → Firewall: check the Bot Management / AI Bots ruleset (it should be Allow or Log, not Deny) and any custom rules matching these user agents; Attack Challenge Mode also challenges bots.";
|
|
21
|
+
case "netlify":
|
|
22
|
+
return "Netlify: remove the User Agent Blocker extension rules (or any edge function) that match these user agents.";
|
|
23
|
+
case "cloudfront":
|
|
24
|
+
return "AWS: check AWS WAF Bot Control / managed rule groups on the CloudFront distribution; allow verified AI search and user bots.";
|
|
25
|
+
case "akamai":
|
|
26
|
+
return "Akamai: check Bot Manager categories for AI crawlers and search engines; allow them.";
|
|
27
|
+
default:
|
|
28
|
+
return "Check the CDN/WAF/firewall in front of the site (bot protection, rate limits, user-agent block lists) and allow the AI search and user bots; make it say the same thing as robots.txt.";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Framework-specific way to put page text into the server HTML (P2). */
|
|
32
|
+
export function csrFixFor(framework, router) {
|
|
33
|
+
switch (framework) {
|
|
34
|
+
case "vite-react":
|
|
35
|
+
case "cra":
|
|
36
|
+
return "Pre-render the public routes at build time (vite-react-ssg, or vite-prerender-plugin with react-dom/server; react-snap is unmaintained), or move the public pages to an SSR/SSG framework (TanStack Start, React Router framework mode with prerender, Next.js, Astro). Keep the logged-in app client-side. Use real paths (BrowserRouter), not #.";
|
|
37
|
+
case "vite-vue":
|
|
38
|
+
return "Pre-render the public routes with vite-ssg, or move the marketing pages to Nuxt (SSR/SSG). Keep the app screens client-side.";
|
|
39
|
+
case "vite-svelte":
|
|
40
|
+
return "Move the public pages to SvelteKit with `export const prerender = true`, or prerender them at build time.";
|
|
41
|
+
case "vite-solid":
|
|
42
|
+
return "Move the public pages to SolidStart (SSR or prerender).";
|
|
43
|
+
case "angular":
|
|
44
|
+
return "Add @angular/ssr (ng add @angular/ssr) and prerender the public routes.";
|
|
45
|
+
case "nuxt":
|
|
46
|
+
return "Remove `ssr: false` from nuxt.config (or set routeRules with prerender: true for public pages).";
|
|
47
|
+
case "sveltekit":
|
|
48
|
+
return "Remove `export const ssr = false` from the root +layout, and set `export const prerender = true` on public routes (or use adapter-static without the SPA fallback).";
|
|
49
|
+
case "remix":
|
|
50
|
+
case "react-router":
|
|
51
|
+
return "Turn SPA mode off (ssr: true) or list the public routes in `prerender` in react-router.config.ts.";
|
|
52
|
+
case "solid-start":
|
|
53
|
+
return "Remove `ssr: false` from app.config.";
|
|
54
|
+
case "next":
|
|
55
|
+
return router === "pages"
|
|
56
|
+
? "Render the page text on the server (getStaticProps/getServerSideProps) instead of fetching it in useEffect; avoid dynamic(..., { ssr: false }) for content."
|
|
57
|
+
: "Fetch the content in a Server Component (or generateStaticParams + static rendering) instead of useEffect in a \"use client\" page; avoid dynamic(..., { ssr: false }) for content.";
|
|
58
|
+
default:
|
|
59
|
+
return "Render the public pages' text on the server (SSR), at build time (SSG) or with a prerender step; keep the logged-in app as it is.";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const SEV = { critical: 100, high: 45, medium: 18, low: 6, info: 1 };
|
|
2
|
+
const GATE = { read: 1.5, quote: 1.2, fit: 1.15, measure: 0.8, mention: 0.7 };
|
|
3
|
+
/**
|
|
4
|
+
* Traffic-impact score. Severity dominates; within a severity, the read gate (nothing else matters if bots can't
|
|
5
|
+
* read the page) outranks quote, which outranks fit, and findings touching many pages or the homepage rank higher.
|
|
6
|
+
*/
|
|
7
|
+
export function impactScore(f, ctx) {
|
|
8
|
+
const n = f.urls?.length ?? 0;
|
|
9
|
+
const breadth = ctx.pages > 0 ? Math.min(1, n / Math.max(3, ctx.pages)) : 0;
|
|
10
|
+
const home = ctx.homeUrl && f.urls?.some((u) => u === ctx.homeUrl) ? 8 : 0;
|
|
11
|
+
const siteWide = /^(site|robots|bots|sitemap|https|host|soft-404|compression|cdn)/.test(f.id) ? 6 : 0;
|
|
12
|
+
return SEV[f.severity] * GATE[f.gate] * (1 + breadth * 0.6) + home + siteWide;
|
|
13
|
+
}
|
|
14
|
+
export function rankByImpact(fs, ctx) {
|
|
15
|
+
return [...fs].sort((a, b) => impactScore(b, ctx) - impactScore(a, ctx));
|
|
16
|
+
}
|
|
17
|
+
/** Markdown list in the given order (util.findingsToMarkdown re-sorts by severity). */
|
|
18
|
+
export function findingsList(fs, opts = {}) {
|
|
19
|
+
const limit = opts.limit ?? fs.length;
|
|
20
|
+
if (!fs.length)
|
|
21
|
+
return "No issues found.";
|
|
22
|
+
return fs
|
|
23
|
+
.slice(0, limit)
|
|
24
|
+
.map((f, i) => {
|
|
25
|
+
const lines = [
|
|
26
|
+
`${i + 1}. **[${f.severity.toUpperCase()}] ${f.title}** (gate: ${f.gate}${f.prompt ? ` · fix with ${f.prompt}` : ""})`,
|
|
27
|
+
` ${f.detail}`,
|
|
28
|
+
];
|
|
29
|
+
if (f.evidence?.length)
|
|
30
|
+
lines.push(` Evidence: ${f.evidence.slice(0, 6).join(" · ")}`);
|
|
31
|
+
if (f.files?.length)
|
|
32
|
+
lines.push(` Files: ${f.files.slice(0, 8).join(", ")}`);
|
|
33
|
+
if (f.urls?.length) {
|
|
34
|
+
const n = opts.urlLimit ?? 8;
|
|
35
|
+
lines.push(` URLs: ${f.urls.slice(0, n).join(", ")}${f.urls.length > n ? ` …(+${f.urls.length - n})` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
if (f.fix)
|
|
38
|
+
lines.push(` Fix: ${f.fix}`);
|
|
39
|
+
if (f.accept)
|
|
40
|
+
lines.push(` Accept when: ${f.accept}`);
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
})
|
|
43
|
+
.join("\n");
|
|
44
|
+
}
|
|
45
|
+
export function countBySeverity(fs) {
|
|
46
|
+
const out = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
47
|
+
for (const f of fs)
|
|
48
|
+
out[f.severity]++;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
export function severityLine(fs) {
|
|
52
|
+
const c = countBySeverity(fs);
|
|
53
|
+
return ["critical", "high", "medium", "low", "info"]
|
|
54
|
+
.filter((s) => c[s])
|
|
55
|
+
.map((s) => `${c[s]} ${s}`)
|
|
56
|
+
.join(" · ") || "no findings";
|
|
57
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { fold } from "./text.js";
|
|
2
|
+
const API = new Set(["api", "graphql", "trpc", "webhook", "webhooks", "_next", "cdn-cgi", "rpc", "_vercel", "__data.json"]);
|
|
3
|
+
const AUTH = new Set([
|
|
4
|
+
"login", "log-in", "signin", "sign-in", "signup", "sign-up", "register", "logout", "log-out", "signout", "sign-out",
|
|
5
|
+
"forgot", "forgot-password", "reset", "reset-password", "verify", "verify-email", "auth", "oauth", "callback", "sso",
|
|
6
|
+
"magic-link", "password", "invite", "invited", "accept-invite",
|
|
7
|
+
// tr / es / de / fr / it / pt
|
|
8
|
+
"giris", "giris-yap", "kayit", "kayit-ol", "uye-ol", "cikis", "sifremi-unuttum", "sifre-sifirla",
|
|
9
|
+
"iniciar-sesion", "registro", "registrarse", "olvide-mi-contrasena", "anmelden", "registrieren", "abmelden",
|
|
10
|
+
"passwort-vergessen", "connexion", "inscription", "deconnexion", "mot-de-passe-oublie", "accedi", "registrati",
|
|
11
|
+
"password-dimenticata", "entrar", "cadastro", "sair", "esqueci-a-senha",
|
|
12
|
+
]);
|
|
13
|
+
const CHECKOUT = new Set([
|
|
14
|
+
"checkout", "cart", "basket", "payment", "payments", "pay", "thank-you", "thankyou", "thanks", "success",
|
|
15
|
+
"order-confirmation", "purchase", "order", "orders", "sepet", "odeme", "tesekkurler", "danke", "gracias", "merci",
|
|
16
|
+
"grazie", "obrigado", "kasse", "paiement", "pago", "billing-success",
|
|
17
|
+
]);
|
|
18
|
+
const APP = new Set([
|
|
19
|
+
"dashboard", "app", "account", "accounts", "settings", "profile", "admin", "billing", "workspace", "workspaces",
|
|
20
|
+
"console", "inbox", "notifications", "onboarding", "portal", "my-account", "me", "hesabim", "hesap", "ayarlar",
|
|
21
|
+
"konto", "compte", "mi-cuenta", "minha-conta", "il-mio-account", "panel", "admin-panel",
|
|
22
|
+
]);
|
|
23
|
+
const SEARCH = new Set(["search", "arama", "buscar", "busqueda", "suche", "recherche", "cerca", "pesquisa", "results"]);
|
|
24
|
+
const LEGAL = new Set([
|
|
25
|
+
"legal", "privacy", "privacy-policy", "terms", "terms-of-service", "terms-of-use", "terms-and-conditions", "tos",
|
|
26
|
+
"cookies", "cookie", "cookie-policy", "imprint", "impressum", "gdpr", "dpa", "disclaimer", "acceptable-use", "aup",
|
|
27
|
+
"refund", "refunds", "refund-policy", "kvkk", "gizlilik", "gizlilik-politikasi", "kullanim-kosullari",
|
|
28
|
+
"cerez-politikasi", "cerezler", "aydinlatma-metni", "mesafeli-satis-sozlesmesi", "datenschutz", "agb",
|
|
29
|
+
"mentions-legales", "confidentialite", "privacidad", "terminos", "aviso-legal", "privacy-notice", "copyright",
|
|
30
|
+
"dmca", "licenses", "usage-rights",
|
|
31
|
+
]);
|
|
32
|
+
/** Classify a URL path (or route pattern) by its segments. */
|
|
33
|
+
export function classifyPath(pathOrUrl) {
|
|
34
|
+
let p = pathOrUrl;
|
|
35
|
+
const q = p.indexOf("?");
|
|
36
|
+
let query = "";
|
|
37
|
+
if (q >= 0) {
|
|
38
|
+
query = p.slice(q + 1);
|
|
39
|
+
p = p.slice(0, q);
|
|
40
|
+
}
|
|
41
|
+
if (/^https?:\/\//i.test(p)) {
|
|
42
|
+
try {
|
|
43
|
+
p = new URL(p).pathname;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* keep */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const segs = p
|
|
50
|
+
.split("/")
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((s) => fold(decodeSafe(s)).replace(/^[:$[(]+|[\])?*]+$/g, ""));
|
|
53
|
+
if (segs[0] && API.has(segs[0]))
|
|
54
|
+
return "api";
|
|
55
|
+
if (segs.some((s) => API.has(s) && s !== "rpc"))
|
|
56
|
+
return "api";
|
|
57
|
+
if (segs.some((s) => AUTH.has(s)))
|
|
58
|
+
return "auth";
|
|
59
|
+
if (segs.some((s) => CHECKOUT.has(s)))
|
|
60
|
+
return "checkout";
|
|
61
|
+
if (segs.some((s) => APP.has(s)))
|
|
62
|
+
return "app";
|
|
63
|
+
if (segs.some((s) => SEARCH.has(s)) || /(^|&)(q|query|s|search)=/.test(query))
|
|
64
|
+
return "search";
|
|
65
|
+
if (segs.some((s) => LEGAL.has(s)))
|
|
66
|
+
return "legal";
|
|
67
|
+
return "public";
|
|
68
|
+
}
|
|
69
|
+
function decodeSafe(s) {
|
|
70
|
+
try {
|
|
71
|
+
return decodeURIComponent(s);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Kinds whose pages are public content (count toward the gates). */
|
|
78
|
+
export function isContentKind(k) {
|
|
79
|
+
return k === "public" || k === "legal" || k === "unknown";
|
|
80
|
+
}
|