Adding scrape files for testing

This commit is contained in:
Max committed 2026-11-03 11:20:03 -07:00
1 parent 7d255fe687
commit 8d60aae65c
40 files changed
+4423

No files matched your search

+429
View File
@@ -0,0 +1,429 @@
//
// 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.
//