Files
SolidScribe/server/helpers/Robots.js
T
2026-11-03 11:20:03 -07:00

217 lines
5.8 KiB
JavaScript

//
// 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
}