// author: simon-pierre boucher export interface RobotsRule { readonly allow: boolean; readonly pattern: string; } export interface RobotsGroup { readonly agents: string[]; rules: RobotsRule[]; crawlDelay?: number; } export interface Robots { readonly groups: RobotsGroup[]; readonly sitemaps: string[]; } /** * Parse robots.txt (RFC 9309 subset, ยง10.1): User-agent groups, Allow/Disallow * with `*`/`$` wildcards, Crawl-delay, and global Sitemap directives. Consecutive * User-agent lines share the following rule block. */ export function parseRobots(text: string): Robots { const groups: RobotsGroup[] = []; const sitemaps: string[] = []; let current: RobotsGroup | null = null; let expectingAgent = false; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.replace(/#.*$/, "").trim(); if (line === "") continue; const idx = line.indexOf(":"); if (idx === -1) continue; const field = line.slice(0, idx).trim().toLowerCase(); const value = line.slice(idx + 1).trim(); switch (field) { case "user-agent": { if (current === null || !expectingAgent) { current = { agents: [], rules: [] }; groups.push(current); } current.agents.push(value.toLowerCase()); expectingAgent = true; break; } case "allow": case "disallow": { if (current === null) { current = { agents: ["*"], rules: [] }; groups.push(current); } expectingAgent = false; current.rules.push({ allow: field === "allow", pattern: value }); break; } case "crawl-delay": { if (current !== null) { const n = Number(value); if (Number.isFinite(n)) current.crawlDelay = n; } expectingAgent = false; break; } case "sitemap": { if (value !== "") sitemaps.push(value); break; } default: break; } } return { groups, sitemaps }; } function patternToRegex(pattern: string): RegExp { let anchoredEnd = false; let p = pattern; if (p.endsWith("$")) { anchoredEnd = true; p = p.slice(0, -1); } const escaped = p .split("*") .map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) .join(".*"); return new RegExp("^" + escaped + (anchoredEnd ? "$" : "")); } function selectGroup(robots: Robots, userAgent: string): RobotsGroup | null { const ua = userAgent.toLowerCase(); let best: RobotsGroup | null = null; let bestLen = -1; let star: RobotsGroup | null = null; for (const group of robots.groups) { for (const agent of group.agents) { if (agent === "*") { star = group; continue; } if (ua.includes(agent) && agent.length > bestLen) { best = group; bestLen = agent.length; } } } return best ?? star; } /** * Decide whether a path is crawlable for our agent. Longest matching rule wins; * ties resolve to Allow (RFC 9309). No matching group, or an empty rule set, * means allow-all. */ export function isAllowed(robots: Robots, path: string, userAgent = "Tendril"): boolean { const group = selectGroup(robots, userAgent); if (group === null || group.rules.length === 0) return true; let decision = true; let bestLen = -1; for (const rule of group.rules) { if (rule.pattern === "") { if (!rule.allow) continue; continue; } if (patternToRegex(rule.pattern).test(path) && rule.pattern.length > bestLen) { bestLen = rule.pattern.length; decision = rule.allow; } else if (patternToRegex(rule.pattern).test(path) && rule.pattern.length === bestLen && rule.allow) { decision = true; } } return decision; } export function crawlDelay(robots: Robots, userAgent = "Tendril"): number | undefined { return selectGroup(robots, userAgent)?.crawlDelay; }