// // robots.txt fetching, parsing and caching. // // Posture: polite, not evasive. An explicit Disallow is respected (fail-closed); // a robots.txt we cannot retrieve is treated as permissive (fail-open), because // a 404 or a flaky server is not a refusal and these are single-page fetches of // URLs a user explicitly saved. // const config = require('@config/scrape') const HttpFetch = require('@helpers/HttpFetch') let Robots = module.exports = {} // origin -> { rules, expiresAt } const cache = new Map() Robots.__resetCache = () => cache.clear() Robots.__cacheSize = () => cache.size const ALLOW_ALL = { groups: [], allowAll: true } // // Parse robots.txt into user-agent groups. // // Consecutive User-agent lines share one rule block, which is the part naive // parsers get wrong. // Robots.parse = (text) => { if (!text || typeof text !== 'string') { return ALLOW_ALL } const groups = [] let current = null let expectingAgents = false for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.replace(/#.*$/, '').trim() if (!line) { continue } const separator = line.indexOf(':') if (separator === -1) { continue } const field = line.slice(0, separator).trim().toLowerCase() const value = line.slice(separator + 1).trim() if (field === 'user-agent') { // A User-agent line directly after another starts a shared group. if (!current || !expectingAgents) { current = { agents: [], rules: [], crawlDelayMs: null } groups.push(current) } current.agents.push(value.toLowerCase()) expectingAgents = true continue } if (!current) { continue } // directive before any user-agent expectingAgents = false if (field === 'disallow' || field === 'allow') { // "Disallow:" with an empty value means allow everything. if (field === 'disallow' && value === '') { continue } current.rules.push({ allow: field === 'allow', path: value }) continue } if (field === 'crawl-delay') { const seconds = Number(value) if (Number.isFinite(seconds) && seconds >= 0) { current.crawlDelayMs = Math.round(seconds * 1000) } } } return { groups, allowAll: groups.length === 0 } } // // Pick the group whose agent token best matches us. A named match always beats // the "*" wildcard, regardless of file order. // const groupFor = (rules, userAgentToken) => { if (!rules || rules.allowAll) { return null } const token = userAgentToken.toLowerCase() let best = null let bestScore = -1 for (const group of rules.groups) { for (const agent of group.agents) { let score = -1 if (agent === '*') { score = 0 } else if (token.includes(agent)) { score = agent.length } if (score > bestScore) { bestScore = score best = group } } } return best } // // Match a robots path pattern, supporting the * and $ wildcards. // const matchesPattern = (pattern, path) => { if (pattern === '') { return false } if (!pattern.includes('*') && !pattern.includes('$')) { return path.startsWith(pattern) } const anchored = pattern.endsWith('$') const body = anchored ? pattern.slice(0, -1) : pattern const expression = body .split('*') .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('.*') return new RegExp('^' + expression + (anchored ? '$' : '')).test(path) } // // Longest matching rule wins; Allow beats Disallow at equal length. // Robots.isPathAllowed = (rules, path, userAgentToken) => { const group = groupFor(rules, userAgentToken) if (!group) { return true } let decision = true let decisionLength = -1 for (const rule of group.rules) { if (!matchesPattern(rule.path, path)) { continue } const length = rule.path.length if (length > decisionLength || (length === decisionLength && rule.allow)) { decision = rule.allow decisionLength = length } } return decision } const evict = () => { if (cache.size <= config.robotsMaxOrigins) { return } // Map preserves insertion order, so the oldest key is first. const oldest = cache.keys().next().value cache.delete(oldest) } const getRules = async (origin) => { const cached = cache.get(origin) if (cached && cached.expiresAt > Date.now()) { return cached.rules } let rules = ALLOW_ALL let ttl = config.robotsTtlMs try { const result = await HttpFetch.fetchText(`${origin}/robots.txt`, { maxBytes: config.maxRobotsBytes, }) rules = Robots.parse(result.text) } catch (error) { // No robots.txt, or we could not reach it. Permissive, but cached for a // short while only so a transient outage does not grant a day's pass. rules = ALLOW_ALL ttl = (error && error.kind === 'http' && error.status === 404) ? config.robotsNegativeTtlMs : config.robotsErrorTtlMs } cache.set(origin, { rules, expiresAt: Date.now() + ttl }) evict() return rules } Robots.__getRules = getRules const originAndPath = (url) => { const parsed = new URL(typeof url === 'string' ? url : url.href) return { origin: parsed.origin, path: parsed.pathname + parsed.search } } // // Is this URL allowed by its host's robots.txt? // Robots.isAllowed = async (url, userAgentToken) => { let parts = null try { parts = originAndPath(url) } catch (error) { return false } const rules = await getRules(parts.origin) return Robots.isPathAllowed(rules, parts.path, userAgentToken || config.robotsUaToken) } // // Crawl-delay for this host, or null. Capped: a site asking for an hour between // requests gets its job failed rather than parked in the queue forever. // Robots.crawlDelayMs = async (url, userAgentToken) => { let parts = null try { parts = originAndPath(url) } catch (error) { return null } const rules = await getRules(parts.origin) const group = groupFor(rules, (userAgentToken || config.robotsUaToken).toLowerCase()) if (!group || group.crawlDelayMs == null) { return null } return group.crawlDelayMs } Robots.exceedsCrawlDelayCap = (delayMs) => { return delayMs != null && delayMs > config.maxCrawlDelayMs }