430 lines
14 KiB
JavaScript
430 lines
14 KiB
JavaScript
//
|
|
// The only module in the server that opens an outbound connection.
|
|
//
|
|
// Everything that made the old scraper unreliable is handled here, in one place:
|
|
// a single AbortController-based timeout (the old code raced request's socket
|
|
// timeout against a manual setTimeout that could fire against a null reference
|
|
// and crash the process), manual redirect following so the SSRF gate runs on
|
|
// every hop, hard size caps enforced while streaming, a Content-Type gate before
|
|
// any body is read, real charset decoding, and retries that only fire for
|
|
// transient failures.
|
|
//
|
|
|
|
const config = require('@config/scrape')
|
|
const UrlSafety = require('@helpers/UrlSafety')
|
|
const BrowserProfiles = require('@helpers/BrowserProfiles')
|
|
|
|
let HttpFetch = module.exports = {}
|
|
|
|
const HTML_TYPES = ['text/html', 'application/xhtml+xml', 'text/plain', 'application/xml', 'text/xml']
|
|
|
|
class FetchError extends Error {
|
|
constructor(kind, message, extra = {}) {
|
|
super(message)
|
|
this.name = 'FetchError'
|
|
this.kind = kind // timeout|http|blocked|too_large|bad_content_type|redirect_loop|too_many_redirects|network|aborted
|
|
this.status = extra.status || null
|
|
this.retryAfterMs = extra.retryAfterMs || null
|
|
this.url = extra.url || null
|
|
this.reason = extra.reason || null
|
|
}
|
|
}
|
|
HttpFetch.FetchError = FetchError
|
|
|
|
// Injectable so tests don't spend real wall-time in backoff. Far simpler than
|
|
// interleaving fake timers with promises.
|
|
let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
|
HttpFetch.__setSleep = (fn) => { sleep = fn }
|
|
HttpFetch.__resetSleep = () => { sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) }
|
|
|
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 507, 509, 521, 522, 523, 524])
|
|
|
|
const isRetryable = (error) => {
|
|
if (!(error instanceof FetchError)) { return false }
|
|
if (error.kind === 'network' || error.kind === 'timeout') { return true }
|
|
if (error.kind === 'http') { return RETRYABLE_STATUS.has(error.status) }
|
|
return false
|
|
}
|
|
|
|
// Retry-After is either delta-seconds or an HTTP-date.
|
|
const parseRetryAfter = (value) => {
|
|
if (!value) { return null }
|
|
|
|
const seconds = Number(value)
|
|
if (Number.isFinite(seconds)) { return Math.max(0, seconds * 1000) }
|
|
|
|
const when = Date.parse(value)
|
|
if (Number.isFinite(when)) { return Math.max(0, when - Date.now()) }
|
|
|
|
return null
|
|
}
|
|
|
|
const backoffFor = (attempt) => {
|
|
const base = Math.min(config.backoffMaxMs, config.backoffBaseMs * Math.pow(2, attempt))
|
|
return Math.round(base * (0.5 + Math.random())) // +/-50% jitter
|
|
}
|
|
|
|
const contentTypeOf = (response) => String(response.headers.get('content-type') || '').toLowerCase()
|
|
const mimeOf = (contentType) => contentType.split(';')[0].trim()
|
|
|
|
const charsetFromContentType = (contentType) => {
|
|
const match = contentType.match(/charset=\s*"?([^";\s]+)"?/i)
|
|
return match ? match[1].toLowerCase() : null
|
|
}
|
|
|
|
// Last resort: sniff a <meta charset> out of the head, read as latin1 so every
|
|
// byte maps to a character and the regex can't miss.
|
|
const charsetFromMeta = (buffer) => {
|
|
const head = buffer.subarray(0, 2048).toString('latin1')
|
|
|
|
const metaCharset = head.match(/<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_\-:]+)/i)
|
|
if (metaCharset) { return metaCharset[1].toLowerCase() }
|
|
|
|
const httpEquiv = head.match(/<meta[^>]+content\s*=\s*["'][^"']*charset=\s*([a-z0-9_\-:]+)/i)
|
|
if (httpEquiv) { return httpEquiv[1].toLowerCase() }
|
|
|
|
return null
|
|
}
|
|
|
|
const charsetFromBom = (buffer) => {
|
|
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { return 'utf-8' }
|
|
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return 'utf-16le' }
|
|
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { return 'utf-16be' }
|
|
return null
|
|
}
|
|
|
|
const decodeBody = (buffer, declaredCharset) => {
|
|
const label = declaredCharset || charsetFromBom(buffer) || charsetFromMeta(buffer) || 'utf-8'
|
|
|
|
try {
|
|
return { text: new TextDecoder(label, { fatal: false }).decode(buffer), charset: label }
|
|
} catch (error) {
|
|
// Unknown/bogus label - utf-8 with replacement chars beats throwing.
|
|
return { text: new TextDecoder('utf-8', { fatal: false }).decode(buffer), charset: 'utf-8' }
|
|
}
|
|
}
|
|
|
|
//
|
|
// Read the body with a running byte count, aborting mid-stream once the cap is
|
|
// passed. Content-Length is checked by the caller too, but it is optional and
|
|
// can lie, so this is the cap that actually holds.
|
|
//
|
|
const readCapped = async (response, maxBytes, controller, url) => {
|
|
if (!response.body) { return Buffer.alloc(0) }
|
|
|
|
const chunks = []
|
|
let total = 0
|
|
|
|
for await (const chunk of response.body) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
total += buffer.length
|
|
|
|
if (total > maxBytes) {
|
|
try { controller.abort() } catch (error) { /* already done */ }
|
|
throw new FetchError('too_large', `Body exceeded ${maxBytes} bytes`, { url })
|
|
}
|
|
|
|
chunks.push(buffer)
|
|
}
|
|
|
|
return Buffer.concat(chunks, total)
|
|
}
|
|
|
|
//
|
|
// One HTTP request, following redirects by hand.
|
|
//
|
|
// Manual redirects are the whole point: fetch's own 'follow' would hop to
|
|
// 127.0.0.1 without ever re-consulting the safety gate.
|
|
//
|
|
const requestOnce = async (startUrl, options) => {
|
|
const {
|
|
maxBytes,
|
|
acceptMimes,
|
|
headersFor,
|
|
conditional,
|
|
timeoutMs,
|
|
deadline,
|
|
referer,
|
|
} = options
|
|
|
|
let currentUrl = typeof startUrl === 'string' ? startUrl : startUrl.href
|
|
const visited = new Set()
|
|
|
|
for (let hop = 0; hop <= config.maxRedirects; hop++) {
|
|
if (visited.has(currentUrl)) {
|
|
throw new FetchError('redirect_loop', `Redirect loop at ${currentUrl}`, { url: currentUrl })
|
|
}
|
|
visited.add(currentUrl)
|
|
|
|
// Re-validate on every hop. This is the check the old code never had.
|
|
try {
|
|
await UrlSafety.assertFetchable(currentUrl)
|
|
} catch (error) {
|
|
// A DNS failure is transient - the host may simply be down right
|
|
// now - so it must not be classified alongside "this address is
|
|
// private", which is a permanent refusal. Collapsing the two meant
|
|
// a briefly-unresolvable domain got parked for the full cache TTL
|
|
// and had its attachment row deleted.
|
|
const kind = error.reason === 'dns_failed' ? 'network' : 'blocked'
|
|
throw new FetchError(kind, error.message, { url: currentUrl, reason: error.reason })
|
|
}
|
|
|
|
const remaining = deadline - Date.now()
|
|
if (remaining <= 0) {
|
|
throw new FetchError('timeout', 'Total budget exhausted', { url: currentUrl })
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, remaining))
|
|
|
|
let response = null
|
|
try {
|
|
const headers = headersFor(currentUrl, referer)
|
|
|
|
if (conditional && conditional.etag) { headers['If-None-Match'] = conditional.etag }
|
|
if (conditional && conditional.lastModified) { headers['If-Modified-Since'] = conditional.lastModified }
|
|
|
|
response = await fetch(currentUrl, {
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
signal: controller.signal,
|
|
headers,
|
|
})
|
|
} catch (error) {
|
|
clearTimeout(timer)
|
|
|
|
if (controller.signal.aborted) {
|
|
throw new FetchError('timeout', `Timed out after ${timeoutMs}ms`, { url: currentUrl })
|
|
}
|
|
throw new FetchError('network', error.message || 'Network failure', { url: currentUrl })
|
|
}
|
|
|
|
// Redirect?
|
|
if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
|
|
clearTimeout(timer)
|
|
try { if (response.body) { await response.body.cancel() } } catch (error) { /* nothing to drain */ }
|
|
|
|
const location = response.headers.get('location')
|
|
let next = null
|
|
try {
|
|
next = new URL(location, currentUrl)
|
|
} catch (error) {
|
|
throw new FetchError('network', `Unparseable redirect target ${location}`, { url: currentUrl })
|
|
}
|
|
|
|
// Never follow https -> http. A downgrade mid-chain is either an
|
|
// attack or a misconfiguration, and is not worth the preview.
|
|
if (new URL(currentUrl).protocol === 'https:' && next.protocol === 'http:') {
|
|
throw new FetchError('blocked', `Refusing https->http downgrade to ${next.href}`, { url: currentUrl })
|
|
}
|
|
|
|
currentUrl = next.href
|
|
continue
|
|
}
|
|
|
|
clearTimeout(timer)
|
|
|
|
if (response.status === 304) {
|
|
try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ }
|
|
return { notModified: true, status: 304, finalUrl: currentUrl, response }
|
|
}
|
|
|
|
if (!response.ok) {
|
|
try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ }
|
|
throw new FetchError('http', `HTTP ${response.status} for ${currentUrl}`, {
|
|
status: response.status,
|
|
retryAfterMs: parseRetryAfter(response.headers.get('retry-after')),
|
|
url: currentUrl,
|
|
})
|
|
}
|
|
|
|
const contentType = contentTypeOf(response)
|
|
const mime = mimeOf(contentType)
|
|
|
|
// Gate on type BEFORE reading a byte. This is what stops a 100MB binary
|
|
// being handed to cheerio.
|
|
if (acceptMimes && !acceptMimes(mime)) {
|
|
try { controller.abort() } catch (error) { /* ignore */ }
|
|
throw new FetchError('bad_content_type', `Refusing content-type ${mime || '(none)'}`, { url: currentUrl })
|
|
}
|
|
|
|
const declaredLength = Number(response.headers.get('content-length'))
|
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
try { controller.abort() } catch (error) { /* ignore */ }
|
|
throw new FetchError('too_large', `Declared length ${declaredLength} over cap`, { url: currentUrl })
|
|
}
|
|
|
|
const buffer = await readCapped(response, maxBytes, controller, currentUrl)
|
|
|
|
return {
|
|
notModified: false,
|
|
status: response.status,
|
|
finalUrl: currentUrl,
|
|
contentType,
|
|
mime,
|
|
buffer,
|
|
response,
|
|
}
|
|
}
|
|
|
|
throw new FetchError('too_many_redirects', `More than ${config.maxRedirects} redirects`, { url: currentUrl })
|
|
}
|
|
|
|
//
|
|
// Retry wrapper. Only transient classes are retried - a 404 is an answer.
|
|
//
|
|
const withRetries = async (attemptFn, { retries, deadline }) => {
|
|
let lastError = null
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
return await attemptFn()
|
|
} catch (error) {
|
|
lastError = error
|
|
|
|
if (!isRetryable(error) || attempt === retries) { throw error }
|
|
|
|
// A server asking us to come back in an hour is not something to
|
|
// sit on - surface it so the cache can park the URL instead.
|
|
if (error.retryAfterMs && error.retryAfterMs > config.maxRetryAfterMs) { throw error }
|
|
|
|
const wait = error.retryAfterMs != null
|
|
? Math.min(error.retryAfterMs, config.maxRetryAfterMs)
|
|
: backoffFor(attempt)
|
|
|
|
if (Date.now() + wait >= deadline) { throw error }
|
|
|
|
await sleep(wait)
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
//
|
|
// Fetch an HTML document.
|
|
//
|
|
HttpFetch.fetchDocument = async (url, options = {}) => {
|
|
const timeoutMs = options.timeoutMs || config.pageTimeoutMs
|
|
const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs)
|
|
const maxBytes = options.maxBytes || config.maxHtmlBytes
|
|
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
|
|
|
const result = await withRetries(() => requestOnce(url, {
|
|
maxBytes,
|
|
acceptMimes: (mime) => HTML_TYPES.includes(mime),
|
|
headersFor: (target, referer) => BrowserProfiles.documentHeaders(profile, {
|
|
url: target,
|
|
// A browser arriving at a page has *some* referer; its own origin is
|
|
// the least surprising one and pairs with Sec-Fetch-Site: same-origin.
|
|
referer: referer || originOf(target),
|
|
}),
|
|
conditional: options.conditional,
|
|
timeoutMs,
|
|
deadline,
|
|
referer: options.referer,
|
|
}), { retries: options.retries != null ? options.retries : config.retries, deadline })
|
|
|
|
if (result.notModified) {
|
|
return {
|
|
notModified: true,
|
|
status: 304,
|
|
finalUrl: result.finalUrl,
|
|
html: null,
|
|
profileId: profile.id,
|
|
}
|
|
}
|
|
|
|
const declaredCharset = charsetFromContentType(result.contentType)
|
|
const { text, charset } = decodeBody(result.buffer, declaredCharset)
|
|
|
|
return {
|
|
notModified: false,
|
|
status: result.status,
|
|
finalUrl: result.finalUrl,
|
|
html: text,
|
|
charset,
|
|
contentType: result.contentType,
|
|
bytes: result.buffer.length,
|
|
etag: result.response.headers.get('etag') || null,
|
|
lastModified: result.response.headers.get('last-modified') || null,
|
|
profileId: profile.id,
|
|
}
|
|
}
|
|
|
|
//
|
|
// Fetch a binary (thumbnail image).
|
|
//
|
|
HttpFetch.fetchBinary = async (url, options = {}) => {
|
|
const timeoutMs = options.timeoutMs || config.imageTimeoutMs
|
|
const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs)
|
|
const maxBytes = options.maxBytes || config.maxImageBytes
|
|
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
|
|
|
const acceptMimes = (mime) => {
|
|
if (!mime.startsWith('image/')) { return false }
|
|
if (mime === 'image/svg+xml' && !config.allowSvgThumbnails) { return false }
|
|
return true
|
|
}
|
|
|
|
const result = await withRetries(() => requestOnce(url, {
|
|
maxBytes,
|
|
acceptMimes,
|
|
headersFor: (target) => BrowserProfiles.imageHeaders(profile, {
|
|
url: target,
|
|
pageUrl: options.pageUrl,
|
|
}),
|
|
timeoutMs,
|
|
deadline,
|
|
}), { retries: options.retries != null ? options.retries : config.retries, deadline })
|
|
|
|
return {
|
|
status: result.status,
|
|
finalUrl: result.finalUrl,
|
|
contentType: result.contentType,
|
|
mime: result.mime,
|
|
buffer: result.buffer,
|
|
profileId: profile.id,
|
|
}
|
|
}
|
|
|
|
//
|
|
// Fetch robots.txt. Short leash, no retries - fail-open is handled by the caller.
|
|
//
|
|
HttpFetch.fetchText = async (url, options = {}) => {
|
|
const timeoutMs = options.timeoutMs || 5000
|
|
const deadline = Date.now() + (options.budgetMs || timeoutMs + 1000)
|
|
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
|
|
|
const result = await requestOnce(url, {
|
|
maxBytes: options.maxBytes || config.maxRobotsBytes,
|
|
acceptMimes: null, // robots.txt is served as every type under the sun
|
|
headersFor: () => BrowserProfiles.robotsHeaders(profile),
|
|
timeoutMs,
|
|
deadline,
|
|
})
|
|
|
|
if (result.notModified) { return { status: 304, text: null, finalUrl: result.finalUrl } }
|
|
|
|
const { text } = decodeBody(result.buffer, charsetFromContentType(result.contentType))
|
|
return { status: result.status, text, finalUrl: result.finalUrl }
|
|
}
|
|
|
|
const hostnameOf = (url) => {
|
|
try { return new URL(typeof url === 'string' ? url : url.href).hostname }
|
|
catch (error) { return '' }
|
|
}
|
|
|
|
const originOf = (url) => {
|
|
try { return new URL(url).origin + '/' }
|
|
catch (error) { return undefined }
|
|
}
|
|
|
|
//
|
|
// Deliberately NOT attempted here, so nobody wonders later:
|
|
// - Header ordering: fetch normalises it; controlling order needs raw sockets.
|
|
// - HTTP/2: undici's fetch is h1 unless an Agent with allowH2 is supplied.
|
|
// - TLS/JA3 fingerprinting and proxy rotation: ruled out, we are being polite
|
|
// rather than evasive.
|
|
//
|