Adding scrape files for testing
This commit is contained in:
1 parent
7d255fe687
commit
8d60aae65c
40 files changed
+4423
No files matched your search
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// All scraper tunables in one place, so nothing downstream is a magic number.
|
||||||
|
//
|
||||||
|
// Env overrides exist for the two kill switches only. Everything else is a
|
||||||
|
// constant on purpose: the politeness numbers are the difference between
|
||||||
|
// looking like a browser and looking like a bot, and they should not drift
|
||||||
|
// per-environment without a code change.
|
||||||
|
//
|
||||||
|
|
||||||
|
const isTest = process.env.NODE_ENV === 'test'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
// Kill switches
|
||||||
|
enabled: process.env.SCRAPE_ENABLED !== '0',
|
||||||
|
// The background worker is off under Jest so tests drive the queue
|
||||||
|
// deterministically via ScrapeQueue.drain() instead of racing it.
|
||||||
|
workerEnabled: process.env.SCRAPE_WORKER ? process.env.SCRAPE_WORKER === '1' : !isTest,
|
||||||
|
|
||||||
|
// Timeouts
|
||||||
|
pageTimeoutMs: 8000, // single HTTP request
|
||||||
|
totalBudgetMs: 25000, // all hops + retries for one URL
|
||||||
|
imageTimeoutMs: 10000,
|
||||||
|
|
||||||
|
// Size caps. Content-Length is optional and can lie, so these are also
|
||||||
|
// enforced while streaming the body.
|
||||||
|
maxHtmlBytes: 2 * 1024 * 1024,
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
maxRobotsBytes: 100 * 1024,
|
||||||
|
|
||||||
|
// Redirects
|
||||||
|
maxRedirects: 5,
|
||||||
|
|
||||||
|
// Retries. Only for transient classes - a 404 is an answer, not a failure.
|
||||||
|
retries: 2,
|
||||||
|
backoffBaseMs: 500,
|
||||||
|
backoffMaxMs: 8000,
|
||||||
|
maxRetryAfterMs: 60 * 1000,
|
||||||
|
|
||||||
|
// Politeness. One request at a time per host, spaced out, is what actually
|
||||||
|
// stops us looking like a crawler.
|
||||||
|
maxConcurrentFetches: 4,
|
||||||
|
perHostConcurrency: 1,
|
||||||
|
perHostMinSpacingMs: 1000,
|
||||||
|
perHostMaxSpacingMs: 3000,
|
||||||
|
maxCrawlDelayMs: 30 * 1000,
|
||||||
|
|
||||||
|
// Backpressure
|
||||||
|
maxQueueLength: 500,
|
||||||
|
maxPendingPerUser: 50,
|
||||||
|
|
||||||
|
// Identity. Stable salt, so a host always sees the same browser across
|
||||||
|
// restarts. Changing this re-rolls every host's assigned profile.
|
||||||
|
profileSalt: 'ss-scrape-v1',
|
||||||
|
robotsUaToken: 'SolidScribeBot',
|
||||||
|
robotsTtlMs: 24 * 60 * 60 * 1000,
|
||||||
|
robotsNegativeTtlMs: 60 * 60 * 1000,
|
||||||
|
robotsErrorTtlMs: 10 * 60 * 1000,
|
||||||
|
robotsMaxOrigins: 2000,
|
||||||
|
|
||||||
|
// Parsing / storage
|
||||||
|
maxSearchTextBytes: 8000,
|
||||||
|
maxKeywordSourceChars: 6000,
|
||||||
|
maxUrlLength: 1000, // matches attachment.url varchar(1000)
|
||||||
|
|
||||||
|
// staticFiles is served from our own origin via /api/static, so an SVG
|
||||||
|
// with an inline script would be same-origin. Off by default.
|
||||||
|
allowSvgThumbnails: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// Browser identities for outbound fetches.
|
||||||
|
//
|
||||||
|
// The old code sent a single hardcoded Googlebot User-Agent. That was removed
|
||||||
|
// deliberately: Googlebot is verifiable by reverse DNS, so the claim is trivially
|
||||||
|
// disproven, and sites that check it block the source IP outright. Some also
|
||||||
|
// serve crawlers a stripped-down page.
|
||||||
|
//
|
||||||
|
// What replaces it is an honest, *coherent* browser identity. Coherence is the
|
||||||
|
// point - a Chrome User-Agent shipping Firefox's Accept header, or sec-ch-ua on
|
||||||
|
// a Safari request, is a stronger bot signal than any UA string. So each profile
|
||||||
|
// carries the complete header set its family really sends, and nothing mixes.
|
||||||
|
//
|
||||||
|
// Profiles are assigned stably per host, not randomly per request: a site should
|
||||||
|
// see one returning browser, not a rotating cast.
|
||||||
|
//
|
||||||
|
|
||||||
|
const config = require('@config/scrape')
|
||||||
|
|
||||||
|
let BrowserProfiles = module.exports = {}
|
||||||
|
|
||||||
|
const CHROME_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
||||||
|
const FIREFOX_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
|
||||||
|
const SAFARI_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||||
|
|
||||||
|
const PROFILES = BrowserProfiles.PROFILES = [
|
||||||
|
{
|
||||||
|
id: 'chrome-win',
|
||||||
|
family: 'chrome',
|
||||||
|
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
||||||
|
accept: CHROME_ACCEPT,
|
||||||
|
acceptLanguage: 'en-US,en;q=0.9',
|
||||||
|
secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"',
|
||||||
|
secChUaMobile: '?0',
|
||||||
|
secChUaPlatform: '"Windows"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chrome-mac',
|
||||||
|
family: 'chrome',
|
||||||
|
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
||||||
|
accept: CHROME_ACCEPT,
|
||||||
|
acceptLanguage: 'en-US,en;q=0.9',
|
||||||
|
secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"',
|
||||||
|
secChUaMobile: '?0',
|
||||||
|
secChUaPlatform: '"macOS"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'firefox-win',
|
||||||
|
family: 'firefox',
|
||||||
|
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
||||||
|
accept: FIREFOX_ACCEPT,
|
||||||
|
acceptLanguage: 'en-US,en;q=0.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'firefox-linux',
|
||||||
|
family: 'firefox',
|
||||||
|
ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
||||||
|
accept: FIREFOX_ACCEPT,
|
||||||
|
acceptLanguage: 'en-US,en;q=0.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safari-mac',
|
||||||
|
family: 'safari',
|
||||||
|
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15',
|
||||||
|
accept: SAFARI_ACCEPT,
|
||||||
|
acceptLanguage: 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// FNV-1a. Not cryptographic - we only need a stable, well-spread bucket.
|
||||||
|
const fnv1a = (input) => {
|
||||||
|
let hash = 0x811c9dc5
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i)
|
||||||
|
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||||
|
}
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
BrowserProfiles.__fnv1a = fnv1a
|
||||||
|
|
||||||
|
//
|
||||||
|
// Same host always gets the same profile, across requests and across restarts
|
||||||
|
// (the salt is a constant in config, not a per-boot random).
|
||||||
|
//
|
||||||
|
BrowserProfiles.profileForHost = (hostname) => {
|
||||||
|
const key = require('@helpers/UrlSafety').registrableHost(hostname) + config.profileSalt
|
||||||
|
return PROFILES[fnv1a(key) % PROFILES.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserProfiles.profileById = (id) => PROFILES.find(profile => profile.id === id) || PROFILES[0]
|
||||||
|
|
||||||
|
//
|
||||||
|
// Sec-Fetch-Site is relative: "none" for a URL typed into the bar, "same-origin"
|
||||||
|
// when the referrer shares the origin, "cross-site" otherwise. Getting this
|
||||||
|
// wrong while sending a Referer is exactly the kind of incoherence that flags.
|
||||||
|
//
|
||||||
|
const fetchSite = (targetUrl, referer) => {
|
||||||
|
if (!referer) { return 'none' }
|
||||||
|
try {
|
||||||
|
const target = new URL(targetUrl)
|
||||||
|
const from = new URL(referer)
|
||||||
|
if (target.origin === from.origin) { return 'same-origin' }
|
||||||
|
if (UrlSafetyHost(target) === UrlSafetyHost(from)) { return 'same-site' }
|
||||||
|
return 'cross-site'
|
||||||
|
} catch (error) {
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UrlSafetyHost = (url) => require('@helpers/UrlSafety').registrableHost(url.hostname)
|
||||||
|
|
||||||
|
const applyChromiumHints = (headers, profile) => {
|
||||||
|
if (profile.family !== 'chrome') { return headers }
|
||||||
|
headers['sec-ch-ua'] = profile.secChUa
|
||||||
|
headers['sec-ch-ua-mobile'] = profile.secChUaMobile
|
||||||
|
headers['sec-ch-ua-platform'] = profile.secChUaPlatform
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Headers for a top-level document navigation.
|
||||||
|
//
|
||||||
|
BrowserProfiles.documentHeaders = (profile, { url, referer } = {}) => {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': profile.ua,
|
||||||
|
'Accept': profile.accept,
|
||||||
|
'Accept-Language': profile.acceptLanguage,
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari sends neither sec-ch-ua nor Sec-Fetch-*; Firefox sends Sec-Fetch-*
|
||||||
|
// but no client hints; Chromium sends both.
|
||||||
|
if (profile.family !== 'safari') {
|
||||||
|
headers['Sec-Fetch-Dest'] = 'document'
|
||||||
|
headers['Sec-Fetch-Mode'] = 'navigate'
|
||||||
|
headers['Sec-Fetch-Site'] = fetchSite(url, referer)
|
||||||
|
headers['Sec-Fetch-User'] = '?1'
|
||||||
|
headers['Upgrade-Insecure-Requests'] = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChromiumHints(headers, profile)
|
||||||
|
|
||||||
|
if (referer) { headers['Referer'] = referer }
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Headers for a subresource image load, as issued from the page it was found on.
|
||||||
|
//
|
||||||
|
BrowserProfiles.imageHeaders = (profile, { url, pageUrl } = {}) => {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': profile.ua,
|
||||||
|
'Accept': profile.family === 'chrome'
|
||||||
|
? 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
|
||||||
|
: 'image/avif,image/webp,*/*',
|
||||||
|
'Accept-Language': profile.acceptLanguage,
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.family !== 'safari') {
|
||||||
|
headers['Sec-Fetch-Dest'] = 'image'
|
||||||
|
headers['Sec-Fetch-Mode'] = 'no-cors'
|
||||||
|
headers['Sec-Fetch-Site'] = fetchSite(url, pageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChromiumHints(headers, profile)
|
||||||
|
|
||||||
|
if (pageUrl) { headers['Referer'] = pageUrl }
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Headers for robots.txt. Same identity as the page fetch, but it is not a
|
||||||
|
// navigation - a browser would never send Sec-Fetch-Dest: document for this.
|
||||||
|
//
|
||||||
|
BrowserProfiles.robotsHeaders = (profile) => {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': profile.ua,
|
||||||
|
'Accept': 'text/plain,*/*;q=0.8',
|
||||||
|
'Accept-Language': profile.acceptLanguage,
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.family !== 'safari') {
|
||||||
|
headers['Sec-Fetch-Dest'] = 'empty'
|
||||||
|
headers['Sec-Fetch-Mode'] = 'no-cors'
|
||||||
|
headers['Sec-Fetch-Site'] = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChromiumHints(headers, profile)
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// Per-host politeness scheduler. Knows nothing about scraping.
|
||||||
|
//
|
||||||
|
// This is the piece that stops the scraper looking like a crawler. The old code
|
||||||
|
// did foundUrls.forEach(fetch) - a note with 40 links opened 40 simultaneous
|
||||||
|
// connections, which is the most bot-like traffic shape available. Here, each
|
||||||
|
// host gets one request at a time with jittered spacing between them, and a
|
||||||
|
// global cap bounds total concurrency.
|
||||||
|
//
|
||||||
|
|
||||||
|
const config = require('@config/scrape')
|
||||||
|
|
||||||
|
let HostQueue = module.exports = {}
|
||||||
|
|
||||||
|
const jitter = (min, max) => Math.round(min + Math.random() * Math.max(0, max - min))
|
||||||
|
|
||||||
|
class Queue {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.globalConcurrency = options.globalConcurrency != null
|
||||||
|
? options.globalConcurrency : config.maxConcurrentFetches
|
||||||
|
this.perHostConcurrency = options.perHostConcurrency != null
|
||||||
|
? options.perHostConcurrency : config.perHostConcurrency
|
||||||
|
this.minSpacingMs = options.minSpacingMs != null
|
||||||
|
? options.minSpacingMs : config.perHostMinSpacingMs
|
||||||
|
this.maxSpacingMs = options.maxSpacingMs != null
|
||||||
|
? options.maxSpacingMs : config.perHostMaxSpacingMs
|
||||||
|
this.now = options.now || (() => Date.now())
|
||||||
|
// Injectable so tests with a controlled clock don't spend real
|
||||||
|
// wall-time waiting out politeness spacing.
|
||||||
|
this.setTimeoutFn = options.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
this.clearTimeoutFn = options.clearTimeoutFn || ((handle) => clearTimeout(handle))
|
||||||
|
|
||||||
|
this.pending = [] // FIFO
|
||||||
|
this.hosts = new Map() // hostKey -> { running, nextAllowedAt }
|
||||||
|
this.running = 0
|
||||||
|
this.seq = 0
|
||||||
|
this.timer = null
|
||||||
|
this.stopped = false
|
||||||
|
this.drainWaiters = []
|
||||||
|
}
|
||||||
|
|
||||||
|
hostState(hostKey) {
|
||||||
|
if (!this.hosts.has(hostKey)) {
|
||||||
|
this.hosts.set(hostKey, { running: 0, nextAllowedAt: 0 })
|
||||||
|
}
|
||||||
|
return this.hosts.get(hostKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Queue a task for a host. Resolves/rejects with the task's own outcome.
|
||||||
|
// spacingMs overrides the jitter floor (robots.txt Crawl-delay).
|
||||||
|
//
|
||||||
|
schedule(hostKey, taskFn, options = {}) {
|
||||||
|
if (this.stopped) {
|
||||||
|
return Promise.reject(new Error('HostQueue stopped'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.push({
|
||||||
|
hostKey: hostKey || '(none)',
|
||||||
|
taskFn,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
spacingMs: options.spacingMs != null ? options.spacingMs : null,
|
||||||
|
seq: this.seq++,
|
||||||
|
})
|
||||||
|
this.pump()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Run whatever is eligible. FIFO scan rather than per-host round-robin, so
|
||||||
|
// no host can starve another and order stays predictable.
|
||||||
|
//
|
||||||
|
pump() {
|
||||||
|
if (this.stopped) { return }
|
||||||
|
|
||||||
|
this.clearTimer()
|
||||||
|
|
||||||
|
while (this.running < this.globalConcurrency) {
|
||||||
|
const at = this.now()
|
||||||
|
let index = -1
|
||||||
|
let earliest = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < this.pending.length; i++) {
|
||||||
|
const job = this.pending[i]
|
||||||
|
const host = this.hostState(job.hostKey)
|
||||||
|
|
||||||
|
if (host.running >= this.perHostConcurrency) { continue }
|
||||||
|
|
||||||
|
if (host.nextAllowedAt > at) {
|
||||||
|
earliest = Math.min(earliest, host.nextAllowedAt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
// Nothing runnable now. Wake up when the earliest host is due.
|
||||||
|
if (this.pending.length && earliest !== Infinity) {
|
||||||
|
this.armTimer(earliest - at)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.run(this.pending.splice(index, 1)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checkDrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
run(job) {
|
||||||
|
const host = this.hostState(job.hostKey)
|
||||||
|
host.running++
|
||||||
|
this.running++
|
||||||
|
|
||||||
|
const settle = () => {
|
||||||
|
host.running--
|
||||||
|
this.running--
|
||||||
|
|
||||||
|
// Space the NEXT request to this host from the end of this one.
|
||||||
|
const spacing = job.spacingMs != null
|
||||||
|
? job.spacingMs
|
||||||
|
: jitter(this.minSpacingMs, this.maxSpacingMs)
|
||||||
|
host.nextAllowedAt = this.now() + spacing
|
||||||
|
|
||||||
|
this.pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A throwing taskFn must not wedge the pump, so the call itself is
|
||||||
|
// wrapped rather than only its promise.
|
||||||
|
let result = null
|
||||||
|
try {
|
||||||
|
result = Promise.resolve(job.taskFn())
|
||||||
|
} catch (error) {
|
||||||
|
result = Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.then(
|
||||||
|
value => { settle(); job.resolve(value) },
|
||||||
|
error => { settle(); job.reject(error) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
armTimer(delay) {
|
||||||
|
this.clearTimer()
|
||||||
|
this.timer = this.setTimeoutFn(() => {
|
||||||
|
this.timer = null
|
||||||
|
this.pump()
|
||||||
|
}, Math.max(1, delay))
|
||||||
|
|
||||||
|
//
|
||||||
|
// Deliberately NOT unref'd.
|
||||||
|
//
|
||||||
|
// This timer is the only thing that will resume a host which is inside
|
||||||
|
// its politeness gap. unref'ing it means that if nothing else holds the
|
||||||
|
// event loop open, Node exits and every queued job is silently
|
||||||
|
// abandoned with its promise never settling. Inside the server that is
|
||||||
|
// masked by the listening socket, but it makes the queue unusable from
|
||||||
|
// a standalone script and is a footgun either way.
|
||||||
|
//
|
||||||
|
// Shutdown is handled explicitly instead: stop() clears this timer and
|
||||||
|
// rejects whatever is still queued, and it is wired to SIGINT/SIGTERM
|
||||||
|
// in server/index.js.
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer() {
|
||||||
|
if (this.timer) {
|
||||||
|
this.clearTimeoutFn(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size() { return this.pending.length }
|
||||||
|
inflight() { return this.running }
|
||||||
|
|
||||||
|
stats() {
|
||||||
|
return {
|
||||||
|
pending: this.pending.length,
|
||||||
|
running: this.running,
|
||||||
|
hosts: this.hosts.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDrained() {
|
||||||
|
if (this.pending.length || this.running) { return }
|
||||||
|
|
||||||
|
const waiters = this.drainWaiters
|
||||||
|
this.drainWaiters = []
|
||||||
|
waiters.forEach(resolve => resolve())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves once the queue is empty and nothing is in flight.
|
||||||
|
drain() {
|
||||||
|
if (!this.pending.length && !this.running) { return Promise.resolve() }
|
||||||
|
return new Promise(resolve => this.drainWaiters.push(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reject everything still queued. In-flight tasks are not cancelled here -
|
||||||
|
// their own AbortControllers handle that.
|
||||||
|
//
|
||||||
|
stop() {
|
||||||
|
this.stopped = true
|
||||||
|
this.clearTimer()
|
||||||
|
|
||||||
|
const pending = this.pending
|
||||||
|
this.pending = []
|
||||||
|
pending.forEach(job => job.reject(new Error('HostQueue stopped')))
|
||||||
|
|
||||||
|
this.checkDrained()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostQueue.Queue = Queue
|
||||||
|
|
||||||
|
// Shared instance used by the scrape worker. Tests build their own.
|
||||||
|
HostQueue.shared = new Queue()
|
||||||
@@ -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.
|
||||||
|
//
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// Socket.IO emit wrapper.
|
||||||
|
//
|
||||||
|
// Replaces the `if(typeof SocketIo != 'undefined')` guards that were scattered
|
||||||
|
// through the models. Those guards did not just avoid a crash - they silently
|
||||||
|
// disabled ALL scraping under Jest, which is why none of this code was ever
|
||||||
|
// testable. Here the no-socket case is a no-op rather than a skipped branch, so
|
||||||
|
// the surrounding logic runs identically in tests and in production.
|
||||||
|
//
|
||||||
|
|
||||||
|
let Realtime = module.exports = {}
|
||||||
|
|
||||||
|
let warned = false
|
||||||
|
|
||||||
|
const server = () => (typeof global.SocketIo !== 'undefined' && global.SocketIo) ? global.SocketIo : null
|
||||||
|
|
||||||
|
Realtime.isEnabled = () => server() !== null
|
||||||
|
|
||||||
|
const emit = (room, event, payload) => {
|
||||||
|
const io = server()
|
||||||
|
|
||||||
|
if (!io) {
|
||||||
|
if (!warned) {
|
||||||
|
warned = true
|
||||||
|
console.log('[realtime] no socket server bound; emits are no-ops')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (payload === undefined) { io.to(room).emit(event) }
|
||||||
|
else { io.to(room).emit(event, payload) }
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[realtime] emit failed', event, error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Realtime.emitToUser = (userId, event, payload) => emit(`user:${userId}`, event, payload)
|
||||||
|
Realtime.emitToNote = (noteRawTextId, event, payload) => emit(`note:${noteRawTextId}`, event, payload)
|
||||||
|
|
||||||
|
Realtime.__resetWarning = () => { warned = false }
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// Idempotent schema convergence for the scraper tables.
|
||||||
|
//
|
||||||
|
// There is no migration framework in this project and no schema file in git,
|
||||||
|
// so this runs at boot, checks information_schema for each object, and applies
|
||||||
|
// only what is missing. Dev and prod self-heal, and deploy.sh needs no change.
|
||||||
|
//
|
||||||
|
// Portability matters here: dev is MariaDB 11.4 and prod is MySQL 8.
|
||||||
|
// ADD COLUMN IF NOT EXISTS is MariaDB-only and is deliberately not used -
|
||||||
|
// every ALTER is guarded by an explicit existence check instead.
|
||||||
|
//
|
||||||
|
|
||||||
|
const db = require('@config/database')
|
||||||
|
|
||||||
|
let Schema = module.exports = {}
|
||||||
|
|
||||||
|
const SCRAPE_CACHE_TABLE = `
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_cache (
|
||||||
|
id int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
url_hash char(64) NOT NULL,
|
||||||
|
url varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
canonical_url varchar(1000) DEFAULT NULL,
|
||||||
|
status smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
title varchar(512) DEFAULT NULL,
|
||||||
|
site_name varchar(255) DEFAULT NULL,
|
||||||
|
description mediumtext DEFAULT NULL,
|
||||||
|
keywords varchar(512) DEFAULT NULL,
|
||||||
|
search_text mediumtext DEFAULT NULL,
|
||||||
|
image_url varchar(1000) DEFAULT NULL,
|
||||||
|
file_location varchar(500) DEFAULT NULL,
|
||||||
|
etag varchar(255) DEFAULT NULL,
|
||||||
|
last_modified varchar(128) DEFAULT NULL,
|
||||||
|
content_type varchar(128) DEFAULT NULL,
|
||||||
|
fetch_state varchar(20) NOT NULL DEFAULT 'pending',
|
||||||
|
failure_count int(11) NOT NULL DEFAULT 0,
|
||||||
|
last_error varchar(255) DEFAULT NULL,
|
||||||
|
fetched_at int(11) DEFAULT NULL,
|
||||||
|
expires_at int(11) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY scrape_cache_url_hash (url_hash),
|
||||||
|
KEY scrape_cache_expires_at (expires_at),
|
||||||
|
KEY scrape_cache_fetch_state (fetch_state)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`
|
||||||
|
|
||||||
|
// DEFAULT 2 ("done") keeps existing rows out of the boot recovery sweep.
|
||||||
|
const ATTACHMENT_COLUMNS = [
|
||||||
|
{ name: 'scrape_state', ddl: 'ALTER TABLE attachment ADD COLUMN scrape_state tinyint(4) NOT NULL DEFAULT 2' },
|
||||||
|
{ name: 'scrape_attempts', ddl: 'ALTER TABLE attachment ADD COLUMN scrape_attempts tinyint(4) NOT NULL DEFAULT 0' },
|
||||||
|
{ name: 'enqueued_at', ddl: 'ALTER TABLE attachment ADD COLUMN enqueued_at int(11) DEFAULT NULL' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ATTACHMENT_INDEXES = [
|
||||||
|
{ name: 'attachment_scrape_state', ddl: 'ALTER TABLE attachment ADD KEY attachment_scrape_state (scrape_state, enqueued_at)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnExists = async (table, column) => {
|
||||||
|
const [rows] = await db.promise().query(
|
||||||
|
`SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
|
||||||
|
[table, column]
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexExists = async (table, index) => {
|
||||||
|
const [rows] = await db.promise().query(
|
||||||
|
`SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`,
|
||||||
|
[table, index]
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
Schema.__columnExists = columnExists
|
||||||
|
Schema.__indexExists = indexExists
|
||||||
|
|
||||||
|
//
|
||||||
|
// Safe to call repeatedly. Returns the list of statements it actually applied,
|
||||||
|
// so boot logging says something useful on the one run that matters.
|
||||||
|
//
|
||||||
|
Schema.ensureScrapeSchema = async () => {
|
||||||
|
const applied = []
|
||||||
|
|
||||||
|
await db.promise().query(SCRAPE_CACHE_TABLE)
|
||||||
|
|
||||||
|
for (const column of ATTACHMENT_COLUMNS) {
|
||||||
|
if (await columnExists('attachment', column.name)) { continue }
|
||||||
|
await db.promise().query(column.ddl)
|
||||||
|
applied.push(`attachment.${column.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index last: it references the columns above.
|
||||||
|
for (const index of ATTACHMENT_INDEXES) {
|
||||||
|
if (await indexExists('attachment', index.name)) { continue }
|
||||||
|
await db.promise().query(index.ddl)
|
||||||
|
applied.push(`attachment KEY ${index.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applied.length) {
|
||||||
|
console.log('[schema] applied scrape schema changes:', applied.join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
return applied
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// URL normalisation and the SSRF gate.
|
||||||
|
//
|
||||||
|
// Scraped URLs come from untrusted note text and from the /pushmebaby query
|
||||||
|
// param, so every URL is validated before a socket is opened - and again after
|
||||||
|
// every redirect hop, which is the hop that actually matters (a public URL that
|
||||||
|
// 302s to 127.0.0.1 is the classic bypass).
|
||||||
|
//
|
||||||
|
// Pure except for assertFetchable, which resolves DNS.
|
||||||
|
//
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const dns = require('dns').promises
|
||||||
|
const net = require('net')
|
||||||
|
const config = require('@config/scrape')
|
||||||
|
|
||||||
|
let UrlSafety = module.exports = {}
|
||||||
|
|
||||||
|
const FETCHABLE_SCHEMES = new Set(['http:', 'https:'])
|
||||||
|
|
||||||
|
// Hostname suffixes that never belong to a public site.
|
||||||
|
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa', '.lan']
|
||||||
|
|
||||||
|
// Query params that change nothing about the page but would fragment the cache.
|
||||||
|
const TRACKING_PARAMS = [
|
||||||
|
/^utm_/i, /^fbclid$/i, /^gclid$/i, /^dclid$/i, /^msclkid$/i,
|
||||||
|
/^mc_(e|c)id$/i, /^igshid$/i, /^ref_src$/i, /^_hs(enc|mi)$/i, /^vero_/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
class SafetyError extends Error {
|
||||||
|
constructor(reason, message) {
|
||||||
|
super(message || reason)
|
||||||
|
this.name = 'SafetyError'
|
||||||
|
this.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UrlSafety.SafetyError = SafetyError
|
||||||
|
|
||||||
|
//
|
||||||
|
// Parse to a URL object, or null. Does NOT mutate anything that would change
|
||||||
|
// which resource is addressed - callers may store the result.
|
||||||
|
//
|
||||||
|
UrlSafety.parse = (raw, base) => {
|
||||||
|
if (!raw || typeof raw !== 'string') { return null }
|
||||||
|
|
||||||
|
let url = null
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim(), base || undefined)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FETCHABLE_SCHEMES.has(url.protocol)) { return null }
|
||||||
|
if (url.href.length > config.maxUrlLength) { return null }
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Canonical form used ONLY for cache keys. Never store this - it deliberately
|
||||||
|
// discards tracking params and case, which would change what the user wrote.
|
||||||
|
//
|
||||||
|
UrlSafety.normalizeForKey = (raw) => {
|
||||||
|
const url = UrlSafety.parse(raw)
|
||||||
|
if (!url) { return null }
|
||||||
|
|
||||||
|
url.hash = ''
|
||||||
|
url.username = ''
|
||||||
|
url.password = ''
|
||||||
|
|
||||||
|
// Trailing dot on the host is legal DNS but splits the cache key.
|
||||||
|
if (url.hostname.endsWith('.')) {
|
||||||
|
url.hostname = url.hostname.slice(0, -1)
|
||||||
|
}
|
||||||
|
url.hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// new URL already strips the default port, but be explicit.
|
||||||
|
if ((url.protocol === 'http:' && url.port === '80') ||
|
||||||
|
(url.protocol === 'https:' && url.port === '443')) {
|
||||||
|
url.port = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of [...url.searchParams.keys()]) {
|
||||||
|
if (TRACKING_PARAMS.some(pattern => pattern.test(key))) {
|
||||||
|
url.searchParams.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.searchParams.sort()
|
||||||
|
|
||||||
|
if (url.pathname === '') { url.pathname = '/' }
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256 of the normalised href. This is scrape_cache.url_hash - char(64),
|
||||||
|
// because a UNIQUE index on varchar(1000) utf8mb4 would be 4000 bytes and
|
||||||
|
// blow InnoDB's 3072-byte key limit.
|
||||||
|
UrlSafety.urlKey = (raw) => {
|
||||||
|
const url = UrlSafety.normalizeForKey(raw)
|
||||||
|
if (!url) { return null }
|
||||||
|
return crypto.createHash('sha256').update(url.href).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// True when two URLs address the same resource modulo scheme, "www." and a
|
||||||
|
// trailing slash.
|
||||||
|
//
|
||||||
|
// Load-bearing: scanTextForWebsites diffs the URLs currently in a note against
|
||||||
|
// the URLs already stored as attachments. Raw string equality would treat
|
||||||
|
// http://foo.com and https://foo.com as different resources and delete-then-
|
||||||
|
// rescrape the row on every save.
|
||||||
|
//
|
||||||
|
UrlSafety.sameResource = (a, b) => {
|
||||||
|
const left = UrlSafety.normalizeForKey(a)
|
||||||
|
const right = UrlSafety.normalizeForKey(b)
|
||||||
|
if (!left || !right) { return false }
|
||||||
|
|
||||||
|
const host = (url) => url.hostname.replace(/^www\./, '')
|
||||||
|
const path = (url) => url.pathname.replace(/\/+$/, '')
|
||||||
|
|
||||||
|
return host(left) === host(right) &&
|
||||||
|
path(left) === path(right) &&
|
||||||
|
left.search === right.search
|
||||||
|
}
|
||||||
|
|
||||||
|
UrlSafety.registrableHost = (hostname) => {
|
||||||
|
return String(hostname || '').toLowerCase().replace(/^www\./, '').replace(/\.$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Private / non-routable address detection.
|
||||||
|
//
|
||||||
|
// Covers the ranges that matter for SSRF against this app: loopback, RFC1918,
|
||||||
|
// link-local (including the 169.254.169.254 cloud metadata endpoint), CGNAT,
|
||||||
|
// and the IPv6 equivalents.
|
||||||
|
//
|
||||||
|
UrlSafety.isPrivateIp = (address) => {
|
||||||
|
if (!address) { return true }
|
||||||
|
|
||||||
|
const version = net.isIP(address)
|
||||||
|
if (version === 0) { return true } // not an IP - caller resolves first
|
||||||
|
|
||||||
|
if (version === 4) { return isPrivateIpv4(address) }
|
||||||
|
return isPrivateIpv6(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrivateIpv4 = (address) => {
|
||||||
|
const parts = address.split('.').map(Number)
|
||||||
|
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const [a, b] = parts
|
||||||
|
|
||||||
|
if (a === 0) { return true } // 0.0.0.0/8 "this host"
|
||||||
|
if (a === 10) { return true } // RFC1918
|
||||||
|
if (a === 127) { return true } // loopback
|
||||||
|
if (a === 169 && b === 254) { return true } // link-local + metadata
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) { return true } // RFC1918
|
||||||
|
if (a === 192 && b === 168) { return true } // RFC1918
|
||||||
|
if (a === 192 && b === 0) { return true } // 192.0.0/24 + 192.0.2/24
|
||||||
|
if (a === 198 && (b === 18 || b === 19)) { return true } // benchmarking
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) { return true } // CGNAT
|
||||||
|
if (a === 198 && b === 51) { return true } // TEST-NET-2
|
||||||
|
if (a === 203 && b === 0) { return true } // TEST-NET-3
|
||||||
|
if (a >= 224) { return true } // multicast + reserved
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrivateIpv6 = (address) => {
|
||||||
|
const lower = address.toLowerCase().replace(/^\[|\]$/g, '')
|
||||||
|
|
||||||
|
// Unwrap v4-mapped (::ffff:127.0.0.1) and NAT64 (64:ff9b::/96) before
|
||||||
|
// judging, otherwise ::ffff:127.0.0.1 reads as "some v6 address".
|
||||||
|
const mapped = lower.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)
|
||||||
|
if (mapped) { return isPrivateIpv4(mapped[1]) }
|
||||||
|
|
||||||
|
if (lower === '::' || lower === '::1') { return true } // unspecified, loopback
|
||||||
|
if (/^f[cd]/.test(lower)) { return true } // fc00::/7 unique-local
|
||||||
|
if (/^fe[89ab]/.test(lower)) { return true } // fe80::/10 link-local
|
||||||
|
if (/^ff/.test(lower)) { return true } // multicast
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// The gate. Throws SafetyError; resolves to the vetted addresses on success.
|
||||||
|
//
|
||||||
|
// Known limitation (documented, accepted): this resolves DNS and then hands the
|
||||||
|
// hostname to fetch, which resolves again. A hostile 0-TTL record can answer
|
||||||
|
// differently the second time. Closing that properly needs a custom undici
|
||||||
|
// Agent with connect.lookup - deliberately deferred, see the plan's Phase 5.
|
||||||
|
// Residual risk is blind SSRF only: no credentials are sent and no response
|
||||||
|
// body is returned to whoever planted the URL.
|
||||||
|
//
|
||||||
|
UrlSafety.assertFetchable = async (raw) => {
|
||||||
|
const url = raw instanceof URL ? raw : UrlSafety.parse(raw)
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new SafetyError('bad_url', `Not a fetchable URL: ${raw}`)
|
||||||
|
}
|
||||||
|
if (!FETCHABLE_SCHEMES.has(url.protocol)) {
|
||||||
|
throw new SafetyError('bad_scheme', `Refusing scheme ${url.protocol}`)
|
||||||
|
}
|
||||||
|
if (url.username || url.password) {
|
||||||
|
throw new SafetyError('credentials_in_url', 'Refusing URL with embedded credentials')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
|
||||||
|
|
||||||
|
if (!hostname) {
|
||||||
|
throw new SafetyError('bad_url', 'URL has no host')
|
||||||
|
}
|
||||||
|
if (hostname === 'localhost' || BLOCKED_SUFFIXES.some(suffix => hostname.endsWith(suffix))) {
|
||||||
|
throw new SafetyError('blocked_host', `Refusing internal host ${hostname}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Literal IP in the URL - no DNS needed.
|
||||||
|
if (net.isIP(hostname)) {
|
||||||
|
if (UrlSafety.isPrivateIp(hostname)) {
|
||||||
|
throw new SafetyError('private_ip', `Refusing non-routable address ${hostname}`)
|
||||||
|
}
|
||||||
|
return [hostname]
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = []
|
||||||
|
try {
|
||||||
|
resolved = await dns.lookup(hostname, { all: true, verbatim: true })
|
||||||
|
} catch (error) {
|
||||||
|
throw new SafetyError('dns_failed', `Could not resolve ${hostname}: ${error.code || error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.length) {
|
||||||
|
throw new SafetyError('dns_failed', `No addresses for ${hostname}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every answer must be public. One private address is enough to refuse -
|
||||||
|
// otherwise a round-robin record gets us some of the time.
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (UrlSafety.isPrivateIp(entry.address)) {
|
||||||
|
throw new SafetyError('private_ip', `${hostname} resolves to non-routable ${entry.address}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved.map(entry => entry.address)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
--
|
||||||
|
-- Scrape cache + per-attachment scrape lifecycle.
|
||||||
|
--
|
||||||
|
-- This file is the human-readable record. It is NOT applied by hand: the same
|
||||||
|
-- statements live in server/helpers/Schema.js and are applied idempotently at
|
||||||
|
-- boot after an information_schema existence check, so dev and prod converge
|
||||||
|
-- without a migration runner.
|
||||||
|
--
|
||||||
|
-- Portability: dev is MariaDB 11.4, prod is MySQL 8. Nothing here may use
|
||||||
|
-- ADD COLUMN IF NOT EXISTS (MariaDB-only).
|
||||||
|
--
|
||||||
|
|
||||||
|
-- One row per URL, shared across users and notes. url_hash carries the UNIQUE
|
||||||
|
-- index because a UNIQUE on varchar(1000) utf8mb4 is 4000 bytes and exceeds
|
||||||
|
-- InnoDB's 3072-byte key limit.
|
||||||
|
CREATE TABLE IF NOT EXISTS `scrape_cache` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`url_hash` char(64) NOT NULL,
|
||||||
|
`url` varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
`canonical_url` varchar(1000) DEFAULT NULL,
|
||||||
|
`status` smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
`title` varchar(512) DEFAULT NULL,
|
||||||
|
`site_name` varchar(255) DEFAULT NULL,
|
||||||
|
`description` mediumtext DEFAULT NULL,
|
||||||
|
`keywords` varchar(512) DEFAULT NULL,
|
||||||
|
`search_text` mediumtext DEFAULT NULL,
|
||||||
|
`image_url` varchar(1000) DEFAULT NULL,
|
||||||
|
`file_location` varchar(500) DEFAULT NULL,
|
||||||
|
`etag` varchar(255) DEFAULT NULL,
|
||||||
|
`last_modified` varchar(128) DEFAULT NULL,
|
||||||
|
`content_type` varchar(128) DEFAULT NULL,
|
||||||
|
`fetch_state` varchar(20) NOT NULL DEFAULT 'pending',
|
||||||
|
`failure_count` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`last_error` varchar(255) DEFAULT NULL,
|
||||||
|
`fetched_at` int(11) DEFAULT NULL,
|
||||||
|
`expires_at` int(11) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `scrape_cache_url_hash` (`url_hash`),
|
||||||
|
KEY `scrape_cache_expires_at` (`expires_at`),
|
||||||
|
KEY `scrape_cache_fetch_state` (`fetch_state`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Per-attachment lifecycle, so an in-memory queue survives a restart.
|
||||||
|
--
|
||||||
|
-- scrape_state: 0 = pending, 1 = fetching, 2 = done, 3 = failed.
|
||||||
|
-- DEFAULT 2 is load-bearing: it keeps the ~2200 pre-existing rows out of the
|
||||||
|
-- recovery sweep on first boot.
|
||||||
|
ALTER TABLE `attachment` ADD COLUMN `scrape_state` tinyint(4) NOT NULL DEFAULT 2;
|
||||||
|
ALTER TABLE `attachment` ADD COLUMN `scrape_attempts` tinyint(4) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE `attachment` ADD COLUMN `enqueued_at` int(11) DEFAULT NULL;
|
||||||
|
ALTER TABLE `attachment` ADD KEY `attachment_scrape_state` (`scrape_state`, `enqueued_at`);
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//
|
||||||
|
// Read-through cache for scraped URL metadata, keyed by a hash of the
|
||||||
|
// normalised URL and shared across users and notes.
|
||||||
|
//
|
||||||
|
// A fresh hit means zero network I/O. A stale hit means a conditional request
|
||||||
|
// (If-None-Match / If-Modified-Since), so an unchanged page costs one 304
|
||||||
|
// rather than a full page load. Failures are backed off so a dead link is not
|
||||||
|
// refetched on every note save.
|
||||||
|
//
|
||||||
|
// Stores only public page metadata. Note content never reaches this table.
|
||||||
|
//
|
||||||
|
|
||||||
|
const db = require('@config/database')
|
||||||
|
const UrlSafety = require('@helpers/UrlSafety')
|
||||||
|
|
||||||
|
let ScrapeCache = module.exports = {}
|
||||||
|
|
||||||
|
const SUCCESS_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
|
||||||
|
// Widening backoff on repeated failure, then give up and stay failed.
|
||||||
|
const FAILURE_BACKOFF_SECONDS = [60 * 60, 6 * 60 * 60, 24 * 60 * 60]
|
||||||
|
const MAX_FAILURES = FAILURE_BACKOFF_SECONDS.length
|
||||||
|
|
||||||
|
ScrapeCache.SUCCESS_TTL_SECONDS = SUCCESS_TTL_SECONDS
|
||||||
|
ScrapeCache.MAX_FAILURES = MAX_FAILURES
|
||||||
|
|
||||||
|
const now = () => Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
|
const truncate = (value, length) => {
|
||||||
|
if (value == null) { return null }
|
||||||
|
const text = String(value)
|
||||||
|
return text.length > length ? text.substring(0, length) : text
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeCache.get = async (urlOrKey) => {
|
||||||
|
const key = /^[0-9a-f]{64}$/.test(urlOrKey) ? urlOrKey : UrlSafety.urlKey(urlOrKey)
|
||||||
|
if (!key) { return null }
|
||||||
|
|
||||||
|
const [rows] = await db.promise().query('SELECT * FROM scrape_cache WHERE url_hash = ? LIMIT 1', [key])
|
||||||
|
return rows.length ? rows[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh = inside its TTL and successfully fetched.
|
||||||
|
ScrapeCache.isFresh = (row, at) => {
|
||||||
|
if (!row || !row.expires_at) { return false }
|
||||||
|
return row.expires_at > (at || now())
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeCache.isUsable = (row) => {
|
||||||
|
return !!(row && row.fetch_state === 'ok' && row.search_text != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has this URL failed enough times that we should stop trying?
|
||||||
|
ScrapeCache.isExhausted = (row) => {
|
||||||
|
return !!(row && row.fetch_state === 'failed' && row.failure_count >= MAX_FAILURES)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Conditional-request headers for a stale-but-successful row. Null when there
|
||||||
|
// is nothing to revalidate against.
|
||||||
|
//
|
||||||
|
ScrapeCache.conditionalFor = (row) => {
|
||||||
|
if (!row || row.fetch_state !== 'ok') { return null }
|
||||||
|
if (!row.etag && !row.last_modified) { return null }
|
||||||
|
|
||||||
|
return { etag: row.etag || null, lastModified: row.last_modified || null }
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reserve a row before fetching, so a concurrent save for the same URL finds
|
||||||
|
// it rather than starting a second fetch.
|
||||||
|
//
|
||||||
|
ScrapeCache.markPending = async (url) => {
|
||||||
|
const key = UrlSafety.urlKey(url)
|
||||||
|
if (!key) { return null }
|
||||||
|
|
||||||
|
await db.promise().query(
|
||||||
|
`INSERT INTO scrape_cache (url_hash, url, fetch_state, fetched_at)
|
||||||
|
VALUES (?, ?, 'pending', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE fetch_state = IF(fetch_state = 'ok', fetch_state, 'pending')`,
|
||||||
|
[key, truncate(url, 1000), now()]
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Store a successful scrape.
|
||||||
|
//
|
||||||
|
ScrapeCache.putSuccess = async (url, result) => {
|
||||||
|
const key = UrlSafety.urlKey(url)
|
||||||
|
if (!key) { return null }
|
||||||
|
|
||||||
|
const at = now()
|
||||||
|
const fields = {
|
||||||
|
url: truncate(url, 1000),
|
||||||
|
canonical_url: truncate(result.canonicalUrl, 1000),
|
||||||
|
status: result.status || 200,
|
||||||
|
title: truncate(result.title, 512),
|
||||||
|
site_name: truncate(result.siteName, 255),
|
||||||
|
description: result.description || null,
|
||||||
|
keywords: truncate(result.keywords, 512),
|
||||||
|
search_text: result.searchText || null,
|
||||||
|
image_url: truncate(result.imageUrl, 1000),
|
||||||
|
file_location: truncate(result.fileLocation, 500),
|
||||||
|
etag: truncate(result.etag, 255),
|
||||||
|
last_modified: truncate(result.lastModified, 128),
|
||||||
|
content_type: truncate(result.contentType, 128),
|
||||||
|
fetch_state: 'ok',
|
||||||
|
failure_count: 0,
|
||||||
|
last_error: null,
|
||||||
|
fetched_at: at,
|
||||||
|
expires_at: at + SUCCESS_TTL_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(fields)
|
||||||
|
const values = columns.map(column => fields[column])
|
||||||
|
|
||||||
|
await db.promise().query(
|
||||||
|
`INSERT INTO scrape_cache (url_hash, ${columns.join(', ')})
|
||||||
|
VALUES (?, ${columns.map(() => '?').join(', ')})
|
||||||
|
ON DUPLICATE KEY UPDATE ${columns.map(column => `${column} = VALUES(${column})`).join(', ')}`,
|
||||||
|
[key, ...values]
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// A 304. The page is unchanged, so only the freshness window moves.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT touch attachment.last_indexed - Attachment.search
|
||||||
|
// orders by it, so revalidating would silently reshuffle the user's list.
|
||||||
|
//
|
||||||
|
ScrapeCache.touchNotModified = async (url) => {
|
||||||
|
const key = UrlSafety.urlKey(url)
|
||||||
|
if (!key) { return null }
|
||||||
|
|
||||||
|
const at = now()
|
||||||
|
await db.promise().query(
|
||||||
|
`UPDATE scrape_cache
|
||||||
|
SET fetched_at = ?, expires_at = ?, fetch_state = 'ok', failure_count = 0, last_error = NULL
|
||||||
|
WHERE url_hash = ?`,
|
||||||
|
[at, at + SUCCESS_TTL_SECONDS, key]
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Record a failure with a widening retry window.
|
||||||
|
//
|
||||||
|
// `blocked` (robots or the safety gate) is terminal: retrying will not change
|
||||||
|
// the answer, so it is parked for the full success TTL instead of backing off.
|
||||||
|
//
|
||||||
|
ScrapeCache.recordFailure = async (url, error) => {
|
||||||
|
const key = UrlSafety.urlKey(url)
|
||||||
|
if (!key) { return null }
|
||||||
|
|
||||||
|
const existing = await ScrapeCache.get(key)
|
||||||
|
const failureCount = (existing ? existing.failure_count : 0) + 1
|
||||||
|
|
||||||
|
const kind = (error && error.kind) || 'unknown'
|
||||||
|
const terminal = kind === 'blocked' || kind === 'robots_disallowed'
|
||||||
|
|
||||||
|
let ttl = null
|
||||||
|
if (terminal) {
|
||||||
|
ttl = SUCCESS_TTL_SECONDS
|
||||||
|
} else if (error && error.retryAfterMs) {
|
||||||
|
ttl = Math.round(error.retryAfterMs / 1000)
|
||||||
|
} else {
|
||||||
|
ttl = FAILURE_BACKOFF_SECONDS[Math.min(failureCount, MAX_FAILURES) - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = now()
|
||||||
|
const state = terminal ? 'blocked' : 'failed'
|
||||||
|
|
||||||
|
await db.promise().query(
|
||||||
|
`INSERT INTO scrape_cache
|
||||||
|
(url_hash, url, status, fetch_state, failure_count, last_error, fetched_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status),
|
||||||
|
fetch_state = VALUES(fetch_state),
|
||||||
|
failure_count = VALUES(failure_count),
|
||||||
|
last_error = VALUES(last_error),
|
||||||
|
fetched_at = VALUES(fetched_at),
|
||||||
|
expires_at = VALUES(expires_at)`,
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
truncate(url, 1000),
|
||||||
|
(error && error.status) || 0,
|
||||||
|
state,
|
||||||
|
failureCount,
|
||||||
|
truncate(`${kind}: ${(error && error.message) || ''}`, 255),
|
||||||
|
at,
|
||||||
|
at + ttl,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Project a cache row onto the shape Attachment rows want.
|
||||||
|
//
|
||||||
|
ScrapeCache.toAttachmentFields = (row) => {
|
||||||
|
if (!row) { return null }
|
||||||
|
return {
|
||||||
|
text: row.search_text || '',
|
||||||
|
fileLocation: row.file_location || null,
|
||||||
|
url: row.canonical_url || row.url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeCache.purgeExpired = async (limit = 500) => {
|
||||||
|
const [result] = await db.promise().query(
|
||||||
|
`DELETE FROM scrape_cache
|
||||||
|
WHERE expires_at IS NOT NULL AND expires_at < ? AND fetch_state <> 'ok'
|
||||||
|
LIMIT ?`,
|
||||||
|
[now(), limit]
|
||||||
|
)
|
||||||
|
return result.affectedRows || 0
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# HTML fixtures
|
||||||
|
|
||||||
|
Frozen, hand-trimmed snapshots used by `server/tests/helpers/SiteScrape.test.js`.
|
||||||
|
|
||||||
|
These are **not** fetched live. Each file isolates one parsing situation so a
|
||||||
|
failure points at a specific branch. Add a new file rather than growing an
|
||||||
|
existing one when covering a new case.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Icons By Size</title>
|
||||||
|
<link rel="icon" href="https://example.com/small.png" sizes="16x16">
|
||||||
|
<link rel="apple-touch-icon" href="https://example.com/touch-180.png" sizes="180x180">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Generated OG Image</title>
|
||||||
|
<meta property="og:image" content="https://example.com/api/og?title=Hello&theme=dark">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body><h1>Heading Is All We Have</h1><p>No title element, no meta tags.</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Broken Images</title></head>
|
||||||
|
<body>
|
||||||
|
<img>
|
||||||
|
<img alt="no src here">
|
||||||
|
<img src="">
|
||||||
|
<img data-src="https://cdn.example.com/lazy.jpg">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "NewsArticle",
|
||||||
|
"headline": "JSON-LD Headline",
|
||||||
|
"description": "Described in structured data.",
|
||||||
|
"image": ["https://cdn.example.com/ld-primary.jpg", "https://cdn.example.com/ld-second.jpg"]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{ "@type": "Organization", "name": "Not The Article" },
|
||||||
|
{ "@type": "BlogPosting", "headline": "Graph Headline", "image": { "url": "https://cdn.example.com/graph.png" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Survived Bad JSON</title>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{ "@type": "Article", "headline": "unterminated,,, }
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json"></script>
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Beekeeping Guide</title></head>
|
||||||
|
<body>
|
||||||
|
<nav>Home About Contact Login Signup Navigation Navigation</nav>
|
||||||
|
<script>var tracking = "tracking tracking tracking tracking tracking";</script>
|
||||||
|
<style>.beehive { color: red }</style>
|
||||||
|
<article class="content">
|
||||||
|
<p>Beekeeping is rewarding. Beekeeping requires patience. A beehive needs space.</p>
|
||||||
|
<p>The beehive must stay dry. Honey harvest comes later. Honey harvest rewards patience.</p>
|
||||||
|
<p>Beekeeping beginners should buy a beehive and study honey production carefully.</p>
|
||||||
|
<p>Honey production depends on the beehive and on steady beekeeping habits.</p>
|
||||||
|
</article>
|
||||||
|
<footer>Footer footer footer links copyright</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Unclosed Tags
|
||||||
|
<body>
|
||||||
|
<div><p>Paragraph never closed
|
||||||
|
<ul><li>item one<li>item two
|
||||||
|
</html>
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head></head><body></body></html>
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Fallback Title Nobody Should See</title>
|
||||||
|
<meta property="og:title" content="The Real Title">
|
||||||
|
<meta property="og:description" content="A proper social description of the page.">
|
||||||
|
<meta property="og:site_name" content="Example Journal">
|
||||||
|
<meta property="og:image" content="https://cdn.example.com/hero.jpg">
|
||||||
|
<meta property="og:url" content="https://example.com/canonical-post">
|
||||||
|
<meta name="description" content="A lesser meta description.">
|
||||||
|
<link rel="canonical" href="https://example.com/canonical-post">
|
||||||
|
</head>
|
||||||
|
<body><article><p>Body text.</p></article></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Protocol Relative</title>
|
||||||
|
<meta property="og:image" content="//cdn.example.com/proto.jpg">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Relative Assets</title>
|
||||||
|
<link rel="canonical" href="https://example.com/blog/2026/post">
|
||||||
|
<meta property="og:image" content="../images/hero.png">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Icon Only</title>
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Sized</title></head>
|
||||||
|
<body>
|
||||||
|
<img src="https://cdn.example.com/thumb.jpg" width="80" height="60">
|
||||||
|
<img src="https://cdn.example.com/big.jpg" width="1200" height="800">
|
||||||
|
<img src="https://cdn.example.com/mid.jpg" width="400" height="300">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Srcset</title></head>
|
||||||
|
<body>
|
||||||
|
<img srcset="https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1600w, https://cdn.example.com/medium.jpg 800w">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>SVG Only</title>
|
||||||
|
<meta property="og:image" content="https://cdn.example.com/logo.svg">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title> Just A Title </title></head>
|
||||||
|
<body><h1>An H1 Heading</h1><p>No metadata here at all.</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Pixels</title></head>
|
||||||
|
<body>
|
||||||
|
<img src="https://analytics.example.net/1x1.gif" width="1" height="1">
|
||||||
|
<img src="https://cdn.example.com/spacer.gif">
|
||||||
|
<img src="https://cdn.example.com/real-photo.jpg" width="800" height="600">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Document Title</title>
|
||||||
|
<meta name="twitter:title" content="Twitter Card Title">
|
||||||
|
<meta name="twitter:description" content="Twitter card description.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.example.com/twitter.png">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Modern Formats</title>
|
||||||
|
<meta property="og:image" content="https://cdn.example.com/hero.webp">
|
||||||
|
</head>
|
||||||
|
<body><p>x</p></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const HostQueue = require('@helpers/HostQueue')
|
||||||
|
|
||||||
|
//
|
||||||
|
// A controllable clock that also drives the queue's own timers, so advancing
|
||||||
|
// time is instant and spacing assertions are exact rather than flaky.
|
||||||
|
//
|
||||||
|
const makeClock = () => {
|
||||||
|
let t = 1000
|
||||||
|
let nextId = 1
|
||||||
|
const timers = new Map()
|
||||||
|
|
||||||
|
return {
|
||||||
|
now: () => t,
|
||||||
|
setTimeoutFn: (fn, ms) => {
|
||||||
|
const id = nextId++
|
||||||
|
timers.set(id, { fn, dueAt: t + ms })
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
clearTimeoutFn: (id) => { timers.delete(id) },
|
||||||
|
advance: (ms) => {
|
||||||
|
t += ms
|
||||||
|
for (const [id, timer] of [...timers.entries()]) {
|
||||||
|
if (timer.dueAt <= t) {
|
||||||
|
timers.delete(id)
|
||||||
|
timer.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wireClock = (clock) => ({
|
||||||
|
now: clock.now,
|
||||||
|
setTimeoutFn: clock.setTimeoutFn,
|
||||||
|
clearTimeoutFn: clock.clearTimeoutFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve = null
|
||||||
|
let reject = null
|
||||||
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('global concurrency', () => {
|
||||||
|
|
||||||
|
test('never exceeds the cap', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
let peak = 0
|
||||||
|
const gates = []
|
||||||
|
|
||||||
|
const jobs = Array.from({ length: 6 }, (unused, i) => {
|
||||||
|
const gate = deferred()
|
||||||
|
gates.push(gate)
|
||||||
|
return queue.schedule(`host${i}.com`, async () => {
|
||||||
|
active++
|
||||||
|
peak = Math.max(peak, active)
|
||||||
|
await gate.promise
|
||||||
|
active--
|
||||||
|
return i
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Let the first batch start.
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
expect(peak).toBe(2)
|
||||||
|
|
||||||
|
gates.forEach(gate => gate.resolve())
|
||||||
|
await Promise.all(jobs)
|
||||||
|
|
||||||
|
expect(peak).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs different hosts in parallel up to the cap', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 4, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
let peak = 0
|
||||||
|
const gates = []
|
||||||
|
|
||||||
|
const jobs = ['a.com', 'b.com', 'c.com'].map(host => {
|
||||||
|
const gate = deferred()
|
||||||
|
gates.push(gate)
|
||||||
|
return queue.schedule(host, async () => {
|
||||||
|
active++
|
||||||
|
peak = Math.max(peak, active)
|
||||||
|
await gate.promise
|
||||||
|
active--
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
expect(peak).toBe(3)
|
||||||
|
|
||||||
|
gates.forEach(gate => gate.resolve())
|
||||||
|
await Promise.all(jobs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('per-host serialisation', () => {
|
||||||
|
|
||||||
|
test('runs only one request per host at a time', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 4, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
let peak = 0
|
||||||
|
|
||||||
|
const jobs = [1, 2, 3].map(() => queue.schedule('same.com', async () => {
|
||||||
|
active++
|
||||||
|
peak = Math.max(peak, active)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
active--
|
||||||
|
}))
|
||||||
|
|
||||||
|
await Promise.all(jobs)
|
||||||
|
|
||||||
|
expect(peak).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves FIFO order within a host', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
const order = []
|
||||||
|
|
||||||
|
await Promise.all([1, 2, 3, 4].map(n =>
|
||||||
|
queue.schedule('same.com', async () => { order.push(n) })
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('politeness spacing', () => {
|
||||||
|
|
||||||
|
test('leaves a gap between two requests to the same host', async () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const queue = new HostQueue.Queue({
|
||||||
|
globalConcurrency: 4,
|
||||||
|
minSpacingMs: 1000,
|
||||||
|
maxSpacingMs: 1000,
|
||||||
|
...wireClock(clock),
|
||||||
|
})
|
||||||
|
|
||||||
|
const startedAt = []
|
||||||
|
|
||||||
|
const first = queue.schedule('same.com', async () => { startedAt.push(clock.now()) })
|
||||||
|
await first
|
||||||
|
|
||||||
|
const second = queue.schedule('same.com', async () => { startedAt.push(clock.now()) })
|
||||||
|
|
||||||
|
// The second job must not have run yet - the host is spaced out.
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
expect(startedAt).toHaveLength(1)
|
||||||
|
|
||||||
|
clock.advance(1000)
|
||||||
|
await second
|
||||||
|
|
||||||
|
expect(startedAt).toHaveLength(2)
|
||||||
|
expect(startedAt[1] - startedAt[0]).toBeGreaterThanOrEqual(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spacing on one host does not delay a different host', async () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const queue = new HostQueue.Queue({
|
||||||
|
globalConcurrency: 4,
|
||||||
|
minSpacingMs: 5000,
|
||||||
|
maxSpacingMs: 5000,
|
||||||
|
...wireClock(clock),
|
||||||
|
})
|
||||||
|
|
||||||
|
await queue.schedule('slow.com', async () => {})
|
||||||
|
|
||||||
|
// slow.com is now parked for 5s. other.com must be unaffected.
|
||||||
|
const ran = []
|
||||||
|
await queue.schedule('other.com', async () => { ran.push('other') })
|
||||||
|
|
||||||
|
expect(ran).toEqual(['other'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honours an explicit spacing override (robots Crawl-delay)', async () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0, ...wireClock(clock) })
|
||||||
|
|
||||||
|
await queue.schedule('slow.com', async () => {}, { spacingMs: 3000 })
|
||||||
|
|
||||||
|
const ran = []
|
||||||
|
const second = queue.schedule('slow.com', async () => { ran.push(1) })
|
||||||
|
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
expect(ran).toEqual([])
|
||||||
|
|
||||||
|
clock.advance(3000)
|
||||||
|
await second
|
||||||
|
expect(ran).toEqual([1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
|
||||||
|
test('a rejecting task does not wedge the queue', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
await expect(queue.schedule('a.com', async () => { throw new Error('boom') }))
|
||||||
|
.rejects.toThrow('boom')
|
||||||
|
|
||||||
|
await expect(queue.schedule('a.com', async () => 'fine')).resolves.toBe('fine')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a task that throws synchronously is caught', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
await expect(queue.schedule('a.com', () => { throw new Error('sync boom') }))
|
||||||
|
.rejects.toThrow('sync boom')
|
||||||
|
|
||||||
|
expect(queue.inflight()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one failing host does not block others', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
queue.schedule('bad.com', async () => { throw new Error('nope') }),
|
||||||
|
queue.schedule('good.com', async () => 'ok'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(results[0].status).toBe('rejected')
|
||||||
|
expect(results[1].value).toBe('ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('drain and stop', () => {
|
||||||
|
|
||||||
|
test('drain resolves when everything has finished', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 2, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
const done = []
|
||||||
|
|
||||||
|
;[1, 2, 3].forEach(n => queue.schedule(`h${n}.com`, async () => { done.push(n) }).catch(() => {}))
|
||||||
|
|
||||||
|
await queue.drain()
|
||||||
|
|
||||||
|
expect(done).toHaveLength(3)
|
||||||
|
expect(queue.size()).toBe(0)
|
||||||
|
expect(queue.inflight()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drain resolves immediately on an idle queue', async () => {
|
||||||
|
const queue = new HostQueue.Queue()
|
||||||
|
await expect(queue.drain()).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stop rejects queued work and refuses new work', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
const gate = deferred()
|
||||||
|
const first = queue.schedule('a.com', () => gate.promise)
|
||||||
|
const queued = queue.schedule('b.com', async () => 'never')
|
||||||
|
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
await expect(queued).rejects.toThrow('stopped')
|
||||||
|
await expect(queue.schedule('c.com', async () => 'x')).rejects.toThrow('stopped')
|
||||||
|
|
||||||
|
gate.resolve('done')
|
||||||
|
await expect(first).resolves.toBe('done')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('event loop', () => {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Regression: the politeness timer used to be unref'd, so if nothing else
|
||||||
|
// held the event loop open Node exited and queued jobs were abandoned with
|
||||||
|
// their promises never settling.
|
||||||
|
//
|
||||||
|
test('a spaced job still resolves with nothing else holding the loop open', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ minSpacingMs: 25, maxSpacingMs: 25 })
|
||||||
|
|
||||||
|
await queue.schedule('same.com', async () => 'first')
|
||||||
|
|
||||||
|
// Only the internal timer can resume this one.
|
||||||
|
await expect(queue.schedule('same.com', async () => 'second')).resolves.toBe('second')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stop() clears the pending timer so shutdown is not blocked', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ minSpacingMs: 10000, maxSpacingMs: 10000 })
|
||||||
|
|
||||||
|
await queue.schedule('same.com', async () => 'first')
|
||||||
|
const queued = queue.schedule('same.com', async () => 'never')
|
||||||
|
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
await expect(queued).rejects.toThrow('stopped')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stats', () => {
|
||||||
|
|
||||||
|
test('reports pending, running and host counts', async () => {
|
||||||
|
const queue = new HostQueue.Queue({ globalConcurrency: 1, minSpacingMs: 0, maxSpacingMs: 0 })
|
||||||
|
|
||||||
|
const gate = deferred()
|
||||||
|
const first = queue.schedule('a.com', () => gate.promise)
|
||||||
|
const second = queue.schedule('b.com', async () => {})
|
||||||
|
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
|
const stats = queue.stats()
|
||||||
|
expect(stats.running).toBe(1)
|
||||||
|
expect(stats.pending).toBe(1)
|
||||||
|
|
||||||
|
gate.resolve()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const HttpFetch = require('@helpers/HttpFetch')
|
||||||
|
const BrowserProfiles = require('@helpers/BrowserProfiles')
|
||||||
|
const FetchMock = require('../support/fetchMock')
|
||||||
|
|
||||||
|
const dns = require('dns').promises
|
||||||
|
let dnsSpy = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FetchMock.install()
|
||||||
|
// Every hostname in these tests resolves to one public address, so the
|
||||||
|
// safety gate passes and we are testing HttpFetch rather than DNS.
|
||||||
|
dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
|
||||||
|
// No real wall-time in backoff.
|
||||||
|
HttpFetch.__setSleep(() => Promise.resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchMock.restore()
|
||||||
|
dnsSpy.mockRestore()
|
||||||
|
HttpFetch.__resetSleep()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument happy path', () => {
|
||||||
|
|
||||||
|
test('returns decoded html, status and final url', async () => {
|
||||||
|
FetchMock.route('https://example.com/', FetchMock.html('<title>Hello</title>'))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
|
||||||
|
expect(result.status).toBe(200)
|
||||||
|
expect(result.html).toContain('<title>Hello</title>')
|
||||||
|
expect(result.finalUrl).toBe('https://example.com/')
|
||||||
|
expect(result.notModified).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('surfaces etag and last-modified for later conditional requests', async () => {
|
||||||
|
FetchMock.route('https://example.com/', FetchMock.html('<p>x</p>', {
|
||||||
|
headers: { etag: 'W/"abc"', 'last-modified': 'Wed, 01 Jan 2025 00:00:00 GMT' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
|
||||||
|
expect(result.etag).toBe('W/"abc"')
|
||||||
|
expect(result.lastModified).toBe('Wed, 01 Jan 2025 00:00:00 GMT')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument browser identity', () => {
|
||||||
|
|
||||||
|
test('sends a complete, coherent header set and never claims to be Googlebot', async () => {
|
||||||
|
FetchMock.route('https://example.com/', FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
const headers = FetchMock.calls()[0].headers
|
||||||
|
|
||||||
|
expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||||
|
expect(headers['User-Agent']).not.toMatch(/Googlebot/i)
|
||||||
|
expect(headers['Accept']).toContain('text/html')
|
||||||
|
expect(headers['Accept-Language']).toBeTruthy()
|
||||||
|
expect(headers['Accept-Encoding']).toContain('gzip')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives the same host the same profile across calls', async () => {
|
||||||
|
FetchMock.route(/example\.com/, () => FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
const first = await HttpFetch.fetchDocument('https://example.com/a')
|
||||||
|
const second = await HttpFetch.fetchDocument('https://example.com/b')
|
||||||
|
|
||||||
|
expect(first.profileId).toBe(second.profileId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pairs sec-ch-ua only with Chromium profiles', () => {
|
||||||
|
const chrome = BrowserProfiles.profileById('chrome-win')
|
||||||
|
const firefox = BrowserProfiles.profileById('firefox-win')
|
||||||
|
const safari = BrowserProfiles.profileById('safari-mac')
|
||||||
|
|
||||||
|
const chromeHeaders = BrowserProfiles.documentHeaders(chrome, { url: 'https://a.com/' })
|
||||||
|
const firefoxHeaders = BrowserProfiles.documentHeaders(firefox, { url: 'https://a.com/' })
|
||||||
|
const safariHeaders = BrowserProfiles.documentHeaders(safari, { url: 'https://a.com/' })
|
||||||
|
|
||||||
|
expect(chromeHeaders['sec-ch-ua']).toBeTruthy()
|
||||||
|
expect(chromeHeaders['Sec-Fetch-Mode']).toBe('navigate')
|
||||||
|
|
||||||
|
// Firefox sends Sec-Fetch-* but no client hints.
|
||||||
|
expect(firefoxHeaders['sec-ch-ua']).toBeUndefined()
|
||||||
|
expect(firefoxHeaders['Sec-Fetch-Mode']).toBe('navigate')
|
||||||
|
|
||||||
|
// Safari sends neither.
|
||||||
|
expect(safariHeaders['sec-ch-ua']).toBeUndefined()
|
||||||
|
expect(safariHeaders['Sec-Fetch-Mode']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('never pairs a chrome UA with a firefox accept header', () => {
|
||||||
|
BrowserProfiles.PROFILES.forEach(profile => {
|
||||||
|
if (profile.family === 'chrome') {
|
||||||
|
expect(profile.ua).toMatch(/Chrome\//)
|
||||||
|
expect(profile.accept).toContain('application/signed-exchange')
|
||||||
|
}
|
||||||
|
if (profile.family === 'firefox') {
|
||||||
|
expect(profile.ua).toMatch(/Firefox\//)
|
||||||
|
expect(profile.accept).not.toContain('signed-exchange')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument redirects', () => {
|
||||||
|
|
||||||
|
test('follows a single hop and reports the final url', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', FetchMock.redirect('https://example.com/b', 301))
|
||||||
|
FetchMock.route('https://example.com/b', () => FetchMock.html('<title>B</title>'))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/a')
|
||||||
|
|
||||||
|
expect(result.finalUrl).toBe('https://example.com/b')
|
||||||
|
expect(result.html).toContain('B')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves a relative Location header', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', FetchMock.redirect('/c', 302))
|
||||||
|
FetchMock.route('https://example.com/c', () => FetchMock.html('<title>C</title>'))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/a')
|
||||||
|
expect(result.finalUrl).toBe('https://example.com/c')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects a redirect loop', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', () => FetchMock.redirect('https://example.com/b', 302))
|
||||||
|
FetchMock.route('https://example.com/b', () => FetchMock.redirect('https://example.com/a', 302))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/a'))
|
||||||
|
.rejects.toMatchObject({ kind: 'redirect_loop' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives up past the redirect cap', async () => {
|
||||||
|
let n = 0
|
||||||
|
FetchMock.route(/https:\/\/example\.com\/hop/, () => FetchMock.redirect(`https://example.com/hop${++n}`, 302))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/hop'))
|
||||||
|
.rejects.toMatchObject({ kind: 'too_many_redirects' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// The SSRF case that matters: the first URL is perfectly public.
|
||||||
|
test('blocks a redirect pointing at loopback', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', FetchMock.redirect('http://127.0.0.1:3000/admin', 302))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/a'))
|
||||||
|
.rejects.toMatchObject({ kind: 'blocked' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks a redirect pointing at the cloud metadata endpoint', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', FetchMock.redirect('http://169.254.169.254/latest/meta-data/', 302))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/a'))
|
||||||
|
.rejects.toMatchObject({ kind: 'blocked' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses an https to http downgrade', async () => {
|
||||||
|
FetchMock.route('https://example.com/a', FetchMock.redirect('http://example.com/a', 302))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/a'))
|
||||||
|
.rejects.toMatchObject({ kind: 'blocked' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument retries', () => {
|
||||||
|
|
||||||
|
test('retries a 500 and succeeds', async () => {
|
||||||
|
FetchMock.routeSequence('https://example.com/', [
|
||||||
|
FetchMock.status(500),
|
||||||
|
FetchMock.html('<title>Recovered</title>'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
|
||||||
|
expect(result.html).toContain('Recovered')
|
||||||
|
expect(FetchMock.callCount()).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives up after exhausting retries', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.status(503))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/'))
|
||||||
|
.rejects.toMatchObject({ kind: 'http', status: 503 })
|
||||||
|
|
||||||
|
// 1 initial attempt + config.retries
|
||||||
|
expect(FetchMock.callCount()).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does NOT retry a 404 - it is an answer, not a failure', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.status(404))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/'))
|
||||||
|
.rejects.toMatchObject({ kind: 'http', status: 404 })
|
||||||
|
|
||||||
|
expect(FetchMock.callCount()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does NOT retry a 403', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.status(403))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/')).rejects.toMatchObject({ status: 403 })
|
||||||
|
expect(FetchMock.callCount()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honours a short Retry-After on a 429', async () => {
|
||||||
|
const waits = []
|
||||||
|
HttpFetch.__setSleep((ms) => { waits.push(ms); return Promise.resolve() })
|
||||||
|
|
||||||
|
FetchMock.routeSequence('https://example.com/', [
|
||||||
|
FetchMock.status(429, { headers: { 'retry-after': '2' } }),
|
||||||
|
FetchMock.html('<title>OK</title>'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
|
||||||
|
expect(result.html).toContain('OK')
|
||||||
|
expect(waits[0]).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses to sit on an hour-long Retry-After', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.status(429, { headers: { 'retry-after': '3600' } }))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/'))
|
||||||
|
.rejects.toMatchObject({ kind: 'http', status: 429 })
|
||||||
|
|
||||||
|
// Surfaced for the cache to park the URL rather than retried.
|
||||||
|
expect(FetchMock.callCount()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries a network error', async () => {
|
||||||
|
FetchMock.routeSequence('https://example.com/', [
|
||||||
|
FetchMock.networkError('ECONNRESET'),
|
||||||
|
FetchMock.html('<title>Up</title>'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
expect(result.html).toContain('Up')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument limits and gates', () => {
|
||||||
|
|
||||||
|
test('times out on a server that never responds', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.hang())
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/', { timeoutMs: 60, retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'timeout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aborts a body that exceeds the cap mid-stream', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.oversized())
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/', { maxBytes: 128 * 1024, retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'too_large' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an oversized declared content-length without reading the body', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.html('<p>small</p>', {
|
||||||
|
headers: { 'content-length': String(50 * 1024 * 1024) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'too_large' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a non-html content type before parsing', async () => {
|
||||||
|
FetchMock.route('https://example.com/f.pdf', () => FetchMock.buffer(Buffer.alloc(16), {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/f.pdf', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'bad_content_type' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a binary masquerading behind an image content type', async () => {
|
||||||
|
FetchMock.route('https://example.com/x', () => FetchMock.buffer(Buffer.alloc(16), {
|
||||||
|
contentType: 'image/png',
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://example.com/x', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'bad_content_type' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument charset handling', () => {
|
||||||
|
|
||||||
|
test('decodes windows-1252 declared in the content-type header', async () => {
|
||||||
|
// 0x93/0x94 are curly quotes in cp1252 and invalid in utf-8.
|
||||||
|
const body = Buffer.concat([
|
||||||
|
Buffer.from('<title>', 'latin1'),
|
||||||
|
Buffer.from([0x93]),
|
||||||
|
Buffer.from('caf', 'latin1'),
|
||||||
|
Buffer.from([0xe9, 0x94]),
|
||||||
|
Buffer.from('</title>', 'latin1'),
|
||||||
|
])
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.buffer(body, {
|
||||||
|
contentType: 'text/html; charset=windows-1252',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
|
||||||
|
expect(result.html).toContain('“café”')
|
||||||
|
expect(result.charset).toBe('windows-1252')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to a meta charset when the header says nothing', async () => {
|
||||||
|
const body = Buffer.concat([
|
||||||
|
Buffer.from('<meta charset="windows-1252"><title>caf', 'latin1'),
|
||||||
|
Buffer.from([0xe9]),
|
||||||
|
Buffer.from('</title>', 'latin1'),
|
||||||
|
])
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.buffer(body, { contentType: 'text/html' }))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
expect(result.html).toContain('café')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('header charset wins over the meta tag', async () => {
|
||||||
|
const body = Buffer.from('<meta charset="windows-1252"><title>café</title>', 'utf8')
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.buffer(body, {
|
||||||
|
contentType: 'text/html; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
expect(result.html).toContain('café')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('survives a bogus charset label', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.buffer(Buffer.from('<p>ok</p>'), {
|
||||||
|
contentType: 'text/html; charset=not-a-real-charset',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/')
|
||||||
|
expect(result.html).toContain('ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchDocument conditional requests', () => {
|
||||||
|
|
||||||
|
test('sends If-None-Match and If-Modified-Since when given', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
await HttpFetch.fetchDocument('https://example.com/', {
|
||||||
|
conditional: { etag: 'W/"v1"', lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = FetchMock.calls()[0].headers
|
||||||
|
expect(headers['If-None-Match']).toBe('W/"v1"')
|
||||||
|
expect(headers['If-Modified-Since']).toBe('Wed, 01 Jan 2025 00:00:00 GMT')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports a 304 as notModified with no body', async () => {
|
||||||
|
FetchMock.route('https://example.com/', () => FetchMock.status(304))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://example.com/', {
|
||||||
|
conditional: { etag: 'W/"v1"' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.notModified).toBe(true)
|
||||||
|
expect(result.status).toBe(304)
|
||||||
|
expect(result.html).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchBinary', () => {
|
||||||
|
|
||||||
|
const pngMagic = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
|
||||||
|
test('returns the buffer for a real image', async () => {
|
||||||
|
FetchMock.route('https://example.com/i.png', () => FetchMock.buffer(pngMagic, { contentType: 'image/png' }))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchBinary('https://example.com/i.png')
|
||||||
|
|
||||||
|
expect(result.mime).toBe('image/png')
|
||||||
|
expect(result.buffer.equals(pngMagic)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The old code wrote a 404 HTML error page to disk and handed it to gm.
|
||||||
|
test('refuses an html error page served where an image was expected', async () => {
|
||||||
|
FetchMock.route('https://example.com/i.png', () => FetchMock.html('<h1>404</h1>'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchBinary('https://example.com/i.png', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'bad_content_type' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('propagates a 404 status rather than writing anything', async () => {
|
||||||
|
FetchMock.route('https://example.com/i.png', () => FetchMock.status(404))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchBinary('https://example.com/i.png', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'http', status: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces the image size cap', async () => {
|
||||||
|
FetchMock.route('https://example.com/i.png', () => FetchMock.oversized('image/png'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchBinary('https://example.com/i.png', { maxBytes: 64 * 1024, retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'too_large' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects svg by default', async () => {
|
||||||
|
FetchMock.route('https://example.com/i.svg', () => FetchMock.buffer(Buffer.from('<svg/>'), {
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchBinary('https://example.com/i.svg', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'bad_content_type' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends image-appropriate fetch metadata and a page referer', async () => {
|
||||||
|
FetchMock.route('https://cdn.example.net/i.png', () => FetchMock.buffer(pngMagic, { contentType: 'image/png' }))
|
||||||
|
|
||||||
|
await HttpFetch.fetchBinary('https://cdn.example.net/i.png', { pageUrl: 'https://example.com/post' })
|
||||||
|
|
||||||
|
const headers = FetchMock.calls()[0].headers
|
||||||
|
expect(headers['Referer']).toBe('https://example.com/post')
|
||||||
|
expect(headers['Accept']).toContain('image/')
|
||||||
|
if (headers['Sec-Fetch-Dest']) {
|
||||||
|
expect(headers['Sec-Fetch-Dest']).toBe('image')
|
||||||
|
expect(headers['Sec-Fetch-Site']).toBe('cross-site')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transient vs terminal safety failures', () => {
|
||||||
|
|
||||||
|
// A private address is a permanent refusal; a DNS failure means the host
|
||||||
|
// may just be down. Classifying both as "blocked" parked transient
|
||||||
|
// failures for the full cache TTL.
|
||||||
|
test('classifies a private address as blocked', async () => {
|
||||||
|
FetchMock.route(/.*/, () => FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('http://10.0.0.1/', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'blocked', reason: 'private_ip' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies a DNS failure as network, not blocked', async () => {
|
||||||
|
dnsSpy.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTFOUND' }))
|
||||||
|
FetchMock.route(/.*/, () => FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('https://gone.example/', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'network', reason: 'dns_failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries a DNS failure, since it may be transient', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
dnsSpy.mockImplementation(async () => {
|
||||||
|
attempts++
|
||||||
|
if (attempts < 2) { throw Object.assign(new Error('nope'), { code: 'ENOTFOUND' }) }
|
||||||
|
return [{ address: '93.184.216.34', family: 4 }]
|
||||||
|
})
|
||||||
|
FetchMock.route('https://flaky.example/', () => FetchMock.html('<title>Back up</title>'))
|
||||||
|
|
||||||
|
const result = await HttpFetch.fetchDocument('https://flaky.example/')
|
||||||
|
expect(result.html).toContain('Back up')
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does NOT retry a private address', async () => {
|
||||||
|
FetchMock.route(/.*/, () => FetchMock.html('<p>x</p>'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('http://127.0.0.1/')).rejects.toMatchObject({ kind: 'blocked' })
|
||||||
|
expect(FetchMock.callCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safety gate on the initial url', () => {
|
||||||
|
|
||||||
|
test('refuses to open a connection to a private address at all', async () => {
|
||||||
|
FetchMock.route(/.*/, () => FetchMock.html('<p>should never be reached</p>'))
|
||||||
|
|
||||||
|
await expect(HttpFetch.fetchDocument('http://127.0.0.1:3000/', { retries: 0 }))
|
||||||
|
.rejects.toMatchObject({ kind: 'blocked' })
|
||||||
|
|
||||||
|
expect(FetchMock.callCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const Robots = require('@helpers/Robots')
|
||||||
|
const config = require('@config/scrape')
|
||||||
|
const FetchMock = require('../support/fetchMock')
|
||||||
|
|
||||||
|
const dns = require('dns').promises
|
||||||
|
let dnsSpy = null
|
||||||
|
|
||||||
|
const UA = config.robotsUaToken
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FetchMock.install()
|
||||||
|
Robots.__resetCache()
|
||||||
|
dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchMock.restore()
|
||||||
|
dnsSpy.mockRestore()
|
||||||
|
Robots.__resetCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
const serveRobots = (body, origin = 'https://example.com') => {
|
||||||
|
FetchMock.route(`${origin}/robots.txt`, () => new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Robots.parse', () => {
|
||||||
|
|
||||||
|
test('groups consecutive user-agent lines into one rule block', () => {
|
||||||
|
const rules = Robots.parse([
|
||||||
|
'User-agent: alpha',
|
||||||
|
'User-agent: beta',
|
||||||
|
'Disallow: /private',
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
expect(rules.groups).toHaveLength(1)
|
||||||
|
expect(rules.groups[0].agents).toEqual(['alpha', 'beta'])
|
||||||
|
expect(rules.groups[0].rules).toEqual([{ allow: false, path: '/private' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores comments and blank lines', () => {
|
||||||
|
const rules = Robots.parse('# a comment\n\nUser-agent: *\nDisallow: /x # trailing\n')
|
||||||
|
expect(rules.groups[0].rules).toEqual([{ allow: false, path: '/x' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('treats an empty Disallow as no restriction', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow:')
|
||||||
|
expect(rules.groups[0].rules).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses crawl-delay', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nCrawl-delay: 2.5')
|
||||||
|
expect(rules.groups[0].crawlDelayMs).toBe(2500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty file allows everything', () => {
|
||||||
|
expect(Robots.parse('').allowAll).toBe(true)
|
||||||
|
expect(Robots.parse(null).allowAll).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Robots.isPathAllowed', () => {
|
||||||
|
|
||||||
|
test('respects a simple disallow', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow: /private')
|
||||||
|
expect(Robots.isPathAllowed(rules, '/private/x', UA)).toBe(false)
|
||||||
|
expect(Robots.isPathAllowed(rules, '/public/x', UA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('longest match wins, so a nested Allow overrides a broad Disallow', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow: /a\nAllow: /a/b')
|
||||||
|
expect(Robots.isPathAllowed(rules, '/a/x', UA)).toBe(false)
|
||||||
|
expect(Robots.isPathAllowed(rules, '/a/b/c', UA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Allow beats Disallow at equal specificity', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow: /p\nAllow: /p')
|
||||||
|
expect(Robots.isPathAllowed(rules, '/p', UA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports the * wildcard', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow: /*.pdf')
|
||||||
|
expect(Robots.isPathAllowed(rules, '/docs/report.pdf', UA)).toBe(false)
|
||||||
|
expect(Robots.isPathAllowed(rules, '/docs/report.html', UA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports the $ anchor', () => {
|
||||||
|
const rules = Robots.parse('User-agent: *\nDisallow: /x$')
|
||||||
|
expect(Robots.isPathAllowed(rules, '/x', UA)).toBe(false)
|
||||||
|
expect(Robots.isPathAllowed(rules, '/x/y', UA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a named group beats the wildcard group even when listed later', () => {
|
||||||
|
const rules = Robots.parse([
|
||||||
|
'User-agent: *',
|
||||||
|
'Disallow: /',
|
||||||
|
'',
|
||||||
|
`User-agent: ${UA}`,
|
||||||
|
'Disallow:',
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
expect(Robots.isPathAllowed(rules, '/anything', UA)).toBe(true)
|
||||||
|
expect(Robots.isPathAllowed(rules, '/anything', 'SomeOtherBot')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Robots.isAllowed over the network', () => {
|
||||||
|
|
||||||
|
test('refuses a disallowed path', async () => {
|
||||||
|
serveRobots('User-agent: *\nDisallow: /secret')
|
||||||
|
await expect(Robots.isAllowed('https://example.com/secret/page')).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('permits an allowed path', async () => {
|
||||||
|
serveRobots('User-agent: *\nDisallow: /secret')
|
||||||
|
await expect(Robots.isAllowed('https://example.com/blog/post')).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fail-open: a missing robots.txt is not a refusal.
|
||||||
|
test('allows everything when robots.txt 404s', async () => {
|
||||||
|
FetchMock.route('https://example.com/robots.txt', () => FetchMock.status(404))
|
||||||
|
await expect(Robots.isAllowed('https://example.com/anything')).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows everything when robots.txt cannot be reached', async () => {
|
||||||
|
FetchMock.route('https://example.com/robots.txt', FetchMock.networkError())
|
||||||
|
await expect(Robots.isAllowed('https://example.com/anything')).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('caches per origin - two queries, one network call', async () => {
|
||||||
|
serveRobots('User-agent: *\nDisallow: /secret')
|
||||||
|
|
||||||
|
await Robots.isAllowed('https://example.com/a')
|
||||||
|
await Robots.isAllowed('https://example.com/secret/b')
|
||||||
|
|
||||||
|
expect(FetchMock.callsTo('https://example.com/robots.txt')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps origins separate', async () => {
|
||||||
|
serveRobots('User-agent: *\nDisallow: /', 'https://blocked.com')
|
||||||
|
serveRobots('User-agent: *\nDisallow:', 'https://open.com')
|
||||||
|
|
||||||
|
await expect(Robots.isAllowed('https://blocked.com/x')).resolves.toBe(false)
|
||||||
|
await expect(Robots.isAllowed('https://open.com/x')).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for an unparseable url rather than throwing', async () => {
|
||||||
|
await expect(Robots.isAllowed('not a url')).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Robots.crawlDelayMs', () => {
|
||||||
|
|
||||||
|
test('reports the declared delay', async () => {
|
||||||
|
serveRobots('User-agent: *\nCrawl-delay: 5')
|
||||||
|
await expect(Robots.crawlDelayMs('https://example.com/x')).resolves.toBe(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is null when none is declared', async () => {
|
||||||
|
serveRobots('User-agent: *\nDisallow: /x')
|
||||||
|
await expect(Robots.crawlDelayMs('https://example.com/y')).resolves.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags a delay beyond the cap instead of parking the job forever', async () => {
|
||||||
|
expect(Robots.exceedsCrawlDelayCap(3600 * 1000)).toBe(true)
|
||||||
|
expect(Robots.exceedsCrawlDelayCap(2000)).toBe(false)
|
||||||
|
expect(Robots.exceedsCrawlDelayCap(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const cheerio = require('cheerio')
|
||||||
|
|
||||||
|
const SiteScrape = require('@helpers/SiteScrape')
|
||||||
|
|
||||||
|
const FIXTURES = path.join(__dirname, '..', 'fixtures', 'html')
|
||||||
|
|
||||||
|
// Fixtures are frozen snapshots - see fixtures/html/README.md. No network here.
|
||||||
|
const load = (name) => cheerio.load(fs.readFileSync(path.join(FIXTURES, name), 'utf8'))
|
||||||
|
|
||||||
|
const PAGE_URL = 'https://example.com/blog/2026/post'
|
||||||
|
|
||||||
|
describe('getTitle', () => {
|
||||||
|
|
||||||
|
test('prefers og:title over the title element', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('og-complete.html'))).toBe('The Real Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to twitter:title', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('twitter-only.html'))).toBe('Twitter Card Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to JSON-LD headline', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('jsonld-article.html'))).toBe('JSON-LD Headline')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to the title element and collapses whitespace', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('title-only.html'))).toBe('Just A Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to an h1 when there is no title element', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('h1-fallback.html'))).toBe('Heading Is All We Have')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty string for a page with nothing', () => {
|
||||||
|
expect(SiteScrape.getTitle(load('no-metadata.html'))).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDescription', () => {
|
||||||
|
|
||||||
|
test('prefers og:description', () => {
|
||||||
|
expect(SiteScrape.getDescription(load('og-complete.html')))
|
||||||
|
.toBe('A proper social description of the page.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to twitter:description', () => {
|
||||||
|
expect(SiteScrape.getDescription(load('twitter-only.html'))).toBe('Twitter card description.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to JSON-LD description', () => {
|
||||||
|
expect(SiteScrape.getDescription(load('jsonld-article.html'))).toBe('Described in structured data.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is empty when no description exists', () => {
|
||||||
|
expect(SiteScrape.getDescription(load('title-only.html'))).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getJsonLd', () => {
|
||||||
|
|
||||||
|
test('reads a plain block', () => {
|
||||||
|
expect(SiteScrape.getJsonLd(load('jsonld-article.html')).headline).toBe('JSON-LD Headline')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('digs the content node out of an @graph', () => {
|
||||||
|
expect(SiteScrape.getJsonLd(load('jsonld-graph.html')).headline).toBe('Graph Headline')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Malformed JSON-LD is extremely common and must never throw.
|
||||||
|
test('survives malformed JSON without throwing', () => {
|
||||||
|
expect(() => SiteScrape.getJsonLd(load('jsonld-malformed.html'))).not.toThrow()
|
||||||
|
expect(SiteScrape.getTitle(load('jsonld-malformed.html'))).toBe('Survived Bad JSON')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is null when absent', () => {
|
||||||
|
expect(SiteScrape.getJsonLd(load('title-only.html'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCanonicalUrl', () => {
|
||||||
|
|
||||||
|
test('uses link rel=canonical', () => {
|
||||||
|
expect(SiteScrape.getCanonicalUrl(load('og-complete.html'), PAGE_URL))
|
||||||
|
.toBe('https://example.com/canonical-post')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to the requested url', () => {
|
||||||
|
expect(SiteScrape.getCanonicalUrl(load('title-only.html'), PAGE_URL)).toBe(PAGE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayImage', () => {
|
||||||
|
|
||||||
|
test('prefers og:image', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('og-complete.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/hero.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to twitter:image', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('twitter-only.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/twitter.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to the JSON-LD image, taking the first of an array', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('jsonld-article.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/ld-primary.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads an object-form JSON-LD image', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('jsonld-graph.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/graph.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolution against the canonical base, not string concatenation.
|
||||||
|
test('resolves a relative path against the canonical url', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('relative-images.html'), PAGE_URL))
|
||||||
|
.toBe('https://example.com/blog/images/hero.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves a root-relative icon', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('root-relative-icon.html'), PAGE_URL))
|
||||||
|
.toBe('https://example.com/favicon.ico')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves a protocol-relative url', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('protocol-relative-image.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/proto.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The old substring filter dropped all of these.
|
||||||
|
test('accepts .webp', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('webp-og-image.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/hero.webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts an extensionless og:image endpoint', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('extensionless-og-image.html'), PAGE_URL))
|
||||||
|
.toBe('https://example.com/api/og?title=Hello&theme=dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects svg while allowSvgThumbnails is off', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('svg-only-image.html'), PAGE_URL)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('picks the largest srcset candidate', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('srcset.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/large.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefers the largest inline image by declared area', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('sized-images.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/big.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips tracking pixels and spacers', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('tracking-pixels.html'), PAGE_URL))
|
||||||
|
.toBe('https://cdn.example.com/real-photo.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: the old code read element.attribs.src directly and threw.
|
||||||
|
test('does not throw on img tags with no src, and finds a data-src', () => {
|
||||||
|
const $ = load('imgs-missing-src.html')
|
||||||
|
expect(() => SiteScrape.getDisplayImage($, PAGE_URL)).not.toThrow()
|
||||||
|
expect(SiteScrape.getDisplayImage($, PAGE_URL)).toBe('https://cdn.example.com/lazy.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefers a large apple-touch-icon over a 16x16 favicon', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('apple-touch-icon.html'), PAGE_URL))
|
||||||
|
.toBe('https://example.com/touch-180.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty string when there is no usable image', () => {
|
||||||
|
expect(SiteScrape.getDisplayImage(load('no-metadata.html'), PAGE_URL)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('survives malformed markup', () => {
|
||||||
|
expect(() => SiteScrape.getDisplayImage(load('malformed-unclosed.html'), PAGE_URL)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getImageCandidates', () => {
|
||||||
|
|
||||||
|
test('returns an ordered, deduplicated list', () => {
|
||||||
|
const candidates = SiteScrape.getImageCandidates(load('tracking-pixels.html'), PAGE_URL)
|
||||||
|
expect(candidates).toEqual(['https://cdn.example.com/real-photo.jpg'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is empty rather than null for a bare page', () => {
|
||||||
|
expect(SiteScrape.getImageCandidates(load('no-metadata.html'), PAGE_URL)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getKeywords', () => {
|
||||||
|
|
||||||
|
test('extracts page terms and ignores nav, script and footer chrome', () => {
|
||||||
|
const keywords = SiteScrape.getKeywords(load('keyword-content.html'))
|
||||||
|
|
||||||
|
expect(keywords).toMatch(/^Keywords: /)
|
||||||
|
expect(keywords).toMatch(/beekeeping|beehive|honey/)
|
||||||
|
expect(keywords).not.toMatch(/navigation/)
|
||||||
|
expect(keywords).not.toMatch(/tracking/)
|
||||||
|
expect(keywords).not.toMatch(/footer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty string when there is nothing to count', () => {
|
||||||
|
expect(SiteScrape.getKeywords(load('no-metadata.html'))).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not log page content', () => {
|
||||||
|
// The old implementation dumped up to 6000 chars of every page.
|
||||||
|
const spy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
SiteScrape.getKeywords(load('keyword-content.html'))
|
||||||
|
expect(spy).not.toHaveBeenCalled()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getHostName', () => {
|
||||||
|
|
||||||
|
test('returns the https origin', () => {
|
||||||
|
expect(SiteScrape.getHostName('http://www.google.com/search?q=x')).toBe('https://www.google.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty string for a bad url instead of throwing', () => {
|
||||||
|
expect(() => SiteScrape.getHostName('nonsense')).not.toThrow()
|
||||||
|
expect(SiteScrape.getHostName('nonsense')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildSearchText', () => {
|
||||||
|
|
||||||
|
test('joins the parts that exist', () => {
|
||||||
|
const text = SiteScrape.buildSearchText({
|
||||||
|
title: 'T', description: 'D', siteName: 'S', keywords: 'Keywords: a, b',
|
||||||
|
})
|
||||||
|
expect(text).toBe('T\nD\nS\nKeywords: a, b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips empty parts', () => {
|
||||||
|
expect(SiteScrape.buildSearchText({ title: 'T', description: '', siteName: '', keywords: '' })).toBe('T')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('caps the length so it cannot bloat attachment.text', () => {
|
||||||
|
const text = SiteScrape.buildSearchText({ title: 'x'.repeat(50000) })
|
||||||
|
expect(text.length).toBeLessThanOrEqual(8000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sentinel strings used to land in attachment.text and pollute FULLTEXT.
|
||||||
|
test('never produces the old failure sentinels', () => {
|
||||||
|
const text = SiteScrape.buildSearchText({ title: '', description: '', siteName: '', keywords: '' })
|
||||||
|
expect(text).toBe('')
|
||||||
|
expect(text).not.toContain('No site text')
|
||||||
|
expect(text).not.toContain('Request Timeout')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parse', () => {
|
||||||
|
|
||||||
|
test('returns everything a link preview needs in one call', () => {
|
||||||
|
const result = SiteScrape.parse(load('og-complete.html'), PAGE_URL)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: 'The Real Title',
|
||||||
|
description: 'A proper social description of the page.',
|
||||||
|
siteName: 'Example Journal',
|
||||||
|
canonicalUrl: 'https://example.com/canonical-post',
|
||||||
|
imageUrl: 'https://cdn.example.com/hero.jpg',
|
||||||
|
hostname: 'https://example.com',
|
||||||
|
})
|
||||||
|
expect(result.searchText).toContain('The Real Title')
|
||||||
|
expect(result.searchText).toContain('A proper social description')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('degrades to empty fields rather than throwing on a bare page', () => {
|
||||||
|
const result = SiteScrape.parse(load('no-metadata.html'), PAGE_URL)
|
||||||
|
|
||||||
|
expect(result.title).toBe('')
|
||||||
|
expect(result.imageUrl).toBe('')
|
||||||
|
expect(result.searchText).toBe('example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
//
|
||||||
|
// getCleanUrls is deliberately unchanged - it decides which URLs are detected in
|
||||||
|
// note text and what string is stored in attachment.url. These tests pin the
|
||||||
|
// CURRENT behaviour so the rewrite around it cannot drift link detection.
|
||||||
|
//
|
||||||
|
describe('getCleanUrls (pinned, must not change)', () => {
|
||||||
|
|
||||||
|
test('finds bare and full urls', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('see https://example.com/a and www.foo.com here'))
|
||||||
|
.toEqual(['https://example.com/a', 'https://www.foo.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still forces http to https', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('http://example.com/x')).toEqual(['https://example.com/x'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still prepends https to a bare www host', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('www.example.com')).toEqual(['https://www.example.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deduplicates', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('https://a.com https://a.com')).toEqual(['https://a.com'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an empty array when there are no urls', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('just some words')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an empty array for empty text', () => {
|
||||||
|
expect(SiteScrape.getCleanUrls('')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const UrlSafety = require('@helpers/UrlSafety')
|
||||||
|
|
||||||
|
describe('UrlSafety.isPrivateIp', () => {
|
||||||
|
|
||||||
|
const privateAddresses = [
|
||||||
|
'0.0.0.0', '10.0.0.1', '10.255.255.254', '127.0.0.1', '127.1.1.1',
|
||||||
|
'169.254.1.1', '169.254.169.254', // cloud metadata endpoint
|
||||||
|
'172.16.0.1', '172.31.255.254', '192.168.1.1', '192.0.0.1', '192.0.2.5',
|
||||||
|
'198.18.0.1', '100.64.0.1', '100.127.255.254', '224.0.0.1', '255.255.255.255',
|
||||||
|
'::1', '::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff02::1',
|
||||||
|
'::ffff:127.0.0.1', '::ffff:10.0.0.1', '64:ff9b::127.0.0.1',
|
||||||
|
]
|
||||||
|
|
||||||
|
privateAddresses.forEach(address => {
|
||||||
|
test(`blocks ${address}`, () => {
|
||||||
|
expect(UrlSafety.isPrivateIp(address)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicAddresses = ['1.1.1.1', '8.8.8.8', '93.184.216.34', '172.32.0.1', '2606:4700::1111']
|
||||||
|
|
||||||
|
publicAddresses.forEach(address => {
|
||||||
|
test(`allows ${address}`, () => {
|
||||||
|
expect(UrlSafety.isPrivateIp(address)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('treats a non-IP string as unsafe', () => {
|
||||||
|
expect(UrlSafety.isPrivateIp('not-an-ip')).toBe(true)
|
||||||
|
expect(UrlSafety.isPrivateIp('')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UrlSafety.parse', () => {
|
||||||
|
|
||||||
|
test('rejects non-http schemes', () => {
|
||||||
|
expect(UrlSafety.parse('file:///etc/passwd')).toBeNull()
|
||||||
|
expect(UrlSafety.parse('ftp://example.com/x')).toBeNull()
|
||||||
|
expect(UrlSafety.parse('javascript:alert(1)')).toBeNull()
|
||||||
|
expect(UrlSafety.parse('data:text/html,<h1>x</h1>')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects garbage and empty input', () => {
|
||||||
|
expect(UrlSafety.parse('')).toBeNull()
|
||||||
|
expect(UrlSafety.parse(null)).toBeNull()
|
||||||
|
expect(UrlSafety.parse('not a url at all')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects URLs longer than the attachment.url column', () => {
|
||||||
|
const long = 'https://example.com/' + 'a'.repeat(1200)
|
||||||
|
expect(UrlSafety.parse(long)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts ordinary http and https', () => {
|
||||||
|
expect(UrlSafety.parse('https://example.com/a').href).toBe('https://example.com/a')
|
||||||
|
expect(UrlSafety.parse('http://example.com/a').href).toBe('http://example.com/a')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UrlSafety.normalizeForKey / urlKey', () => {
|
||||||
|
|
||||||
|
test('strips tracking params so the cache key is stable', () => {
|
||||||
|
const bare = UrlSafety.urlKey('https://example.com/post')
|
||||||
|
const noisy = UrlSafety.urlKey('https://example.com/post?utm_source=x&utm_medium=y&fbclid=z')
|
||||||
|
expect(noisy).toBe(bare)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps meaningful query params', () => {
|
||||||
|
const a = UrlSafety.urlKey('https://example.com/search?q=cats')
|
||||||
|
const b = UrlSafety.urlKey('https://example.com/search?q=dogs')
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is insensitive to param order, host case, fragment and trailing host dot', () => {
|
||||||
|
const canonical = UrlSafety.urlKey('https://example.com/p?a=1&b=2')
|
||||||
|
expect(UrlSafety.urlKey('https://example.com/p?b=2&a=1')).toBe(canonical)
|
||||||
|
expect(UrlSafety.urlKey('https://EXAMPLE.com/p?a=1&b=2')).toBe(canonical)
|
||||||
|
expect(UrlSafety.urlKey('https://example.com/p?a=1&b=2#section')).toBe(canonical)
|
||||||
|
expect(UrlSafety.urlKey('https://example.com./p?a=1&b=2')).toBe(canonical)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('distinguishes http from https', () => {
|
||||||
|
expect(UrlSafety.urlKey('http://example.com/')).not.toBe(UrlSafety.urlKey('https://example.com/'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a 64-char hex digest', () => {
|
||||||
|
expect(UrlSafety.urlKey('https://example.com/')).toMatch(/^[0-9a-f]{64}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for an unusable URL', () => {
|
||||||
|
expect(UrlSafety.urlKey('javascript:alert(1)')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UrlSafety.sameResource', () => {
|
||||||
|
|
||||||
|
// This is what stops scanTextForWebsites deleting and re-scraping a row
|
||||||
|
// every time a note is saved.
|
||||||
|
test('treats http/https, www. and trailing slash as the same resource', () => {
|
||||||
|
expect(UrlSafety.sameResource('http://foo.com/a', 'https://foo.com/a')).toBe(true)
|
||||||
|
expect(UrlSafety.sameResource('https://www.foo.com/a', 'https://foo.com/a')).toBe(true)
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com/a/', 'https://foo.com/a')).toBe(true)
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com', 'https://foo.com/')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not conflate different paths, hosts or queries', () => {
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com/a', 'https://foo.com/b')).toBe(false)
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com/a', 'https://bar.com/a')).toBe(false)
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com/a?x=1', 'https://foo.com/a?x=2')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is false when either side is unusable', () => {
|
||||||
|
expect(UrlSafety.sameResource('https://foo.com/a', 'javascript:x')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UrlSafety.assertFetchable', () => {
|
||||||
|
|
||||||
|
const expectRejection = async (url, reason) => {
|
||||||
|
await expect(UrlSafety.assertFetchable(url)).rejects.toMatchObject({ name: 'SafetyError', reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refuses non-http schemes', async () => {
|
||||||
|
await expectRejection('file:///etc/passwd', 'bad_url')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses embedded credentials', async () => {
|
||||||
|
await expectRejection('https://user:[email protected]/', 'credentials_in_url')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses localhost and internal suffixes', async () => {
|
||||||
|
await expectRejection('http://localhost:3000/', 'blocked_host')
|
||||||
|
await expectRejection('http://db.internal/', 'blocked_host')
|
||||||
|
await expectRejection('http://thing.local/', 'blocked_host')
|
||||||
|
await expectRejection('http://box.lan/', 'blocked_host')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses literal private addresses without touching DNS', async () => {
|
||||||
|
await expectRejection('http://127.0.0.1:3000/', 'private_ip')
|
||||||
|
await expectRejection('http://169.254.169.254/latest/meta-data/', 'private_ip')
|
||||||
|
await expectRejection('http://10.1.2.3/', 'private_ip')
|
||||||
|
await expectRejection('http://[::1]:8080/', 'private_ip')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows a literal public address', async () => {
|
||||||
|
await expect(UrlSafety.assertFetchable('http://1.1.1.1/')).resolves.toEqual(['1.1.1.1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a hostname that resolves to a private address', async () => {
|
||||||
|
const dns = require('dns').promises
|
||||||
|
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '192.168.0.5', family: 4 }])
|
||||||
|
|
||||||
|
await expectRejection('https://rebind.example.com/', 'private_ip')
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses when ANY address in a round-robin record is private', async () => {
|
||||||
|
const dns = require('dns').promises
|
||||||
|
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([
|
||||||
|
{ address: '93.184.216.34', family: 4 },
|
||||||
|
{ address: '127.0.0.1', family: 4 },
|
||||||
|
])
|
||||||
|
|
||||||
|
await expectRejection('https://mixed.example.com/', 'private_ip')
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows a hostname that resolves entirely to public addresses', async () => {
|
||||||
|
const dns = require('dns').promises
|
||||||
|
const spy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
|
||||||
|
|
||||||
|
await expect(UrlSafety.assertFetchable('https://example.com/')).resolves.toEqual(['93.184.216.34'])
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('surfaces a DNS failure as dns_failed', async () => {
|
||||||
|
const dns = require('dns').promises
|
||||||
|
const spy = jest.spyOn(dns, 'lookup').mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTFOUND' }))
|
||||||
|
|
||||||
|
await expectRejection('https://nonexistent.example/', 'dns_failed')
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
require('module-alias/register')
|
||||||
|
|
||||||
|
const db = require('@config/database')
|
||||||
|
const User = require('@models/User')
|
||||||
|
const Attachment = require('@models/Attachment')
|
||||||
|
const ScrapeCache = require('@models/ScrapeCache')
|
||||||
|
const ScrapeQueue = require('@workers/ScrapeQueue')
|
||||||
|
const Schema = require('@helpers/Schema')
|
||||||
|
const Robots = require('@helpers/Robots')
|
||||||
|
const HttpFetch = require('@helpers/HttpFetch')
|
||||||
|
const UrlSafety = require('@helpers/UrlSafety')
|
||||||
|
const FetchMock = require('../support/fetchMock')
|
||||||
|
|
||||||
|
const dns = require('dns').promises
|
||||||
|
|
||||||
|
const testUserName = 'jestTestUserScrapeQueue'
|
||||||
|
const password = 'Beans19934!!!'
|
||||||
|
|
||||||
|
let userId = null
|
||||||
|
let dnsSpy = null
|
||||||
|
|
||||||
|
const page = (title, description) => FetchMock.html(`
|
||||||
|
<html><head>
|
||||||
|
<title>${title}</title>
|
||||||
|
<meta property="og:description" content="${description || 'a description'}">
|
||||||
|
</head><body><p>body</p></body></html>
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clear cache rows for a URL so each test starts from a real miss.
|
||||||
|
const clearCache = (url) => db.promise()
|
||||||
|
.query('DELETE FROM scrape_cache WHERE url_hash = ?', [UrlSafety.urlKey(url)])
|
||||||
|
|
||||||
|
const attachmentRow = (id) => db.promise()
|
||||||
|
.query('SELECT * FROM attachment WHERE id = ?', [id])
|
||||||
|
.then(([rows]) => rows[0] || null)
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await Schema.ensureScrapeSchema()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await User.getByUserName(testUserName)
|
||||||
|
if (existing && existing.id) { await User.deleteUser(existing.id, password) }
|
||||||
|
} catch (error) { /* first run */ }
|
||||||
|
|
||||||
|
const registered = await User.register(testUserName, password)
|
||||||
|
userId = registered.userId
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FetchMock.install()
|
||||||
|
Robots.__resetCache()
|
||||||
|
dnsSpy = jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
|
||||||
|
HttpFetch.__setSleep(() => Promise.resolve())
|
||||||
|
|
||||||
|
// Exercise the real code path with politeness spacing and robots disabled.
|
||||||
|
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0, checkRobots: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchMock.restore()
|
||||||
|
dnsSpy.mockRestore()
|
||||||
|
HttpFetch.__resetSleep()
|
||||||
|
ScrapeQueue.__reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.promise().query('DELETE FROM attachment WHERE user_id = ?', [userId])
|
||||||
|
db.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetch and persist', () => {
|
||||||
|
|
||||||
|
test('writes parsed metadata onto the attachment row as plaintext', async () => {
|
||||||
|
const url = 'https://example.com/post-one'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => page('Post One', 'the first post'))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
const row = await attachmentRow(attachmentId)
|
||||||
|
|
||||||
|
expect(row.text).toContain('Post One')
|
||||||
|
expect(row.text).toContain('the first post')
|
||||||
|
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
|
||||||
|
// Must stay unencrypted - the FULLTEXT index depends on it.
|
||||||
|
expect(row.text).not.toMatch(/^[0-9a-f]{64}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the url the user wrote rather than the canonical url', async () => {
|
||||||
|
const url = 'https://example.com/original'
|
||||||
|
await clearCache(url)
|
||||||
|
|
||||||
|
FetchMock.route(url, () => FetchMock.html(`
|
||||||
|
<html><head><title>Canonical Test</title>
|
||||||
|
<link rel="canonical" href="https://example.com/different-canonical">
|
||||||
|
</head><body>x</body></html>
|
||||||
|
`))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
const row = await attachmentRow(attachmentId)
|
||||||
|
expect(row.url).toBe(url)
|
||||||
|
|
||||||
|
// The canonical URL is recorded in the cache, not on the attachment.
|
||||||
|
const cached = await ScrapeCache.get(url)
|
||||||
|
expect(cached.canonical_url).toBe('https://example.com/different-canonical')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('caching', () => {
|
||||||
|
|
||||||
|
test('a second request for the same url makes zero network calls', async () => {
|
||||||
|
const url = 'https://example.com/cached'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => page('Cached Page'))
|
||||||
|
|
||||||
|
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
|
||||||
|
|
||||||
|
const callsAfterFirst = FetchMock.callCount()
|
||||||
|
expect(callsAfterFirst).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
|
||||||
|
|
||||||
|
expect(FetchMock.callCount()).toBe(callsAfterFirst)
|
||||||
|
|
||||||
|
// The cached row still populates the second attachment.
|
||||||
|
const row = await attachmentRow(secondId)
|
||||||
|
expect(row.text).toContain('Cached Page')
|
||||||
|
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two notes sharing a url coalesce into one fetch', async () => {
|
||||||
|
const url = 'https://example.com/shared'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => page('Shared Page'))
|
||||||
|
|
||||||
|
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
|
||||||
|
// Enqueued together, so they overlap in flight.
|
||||||
|
await ScrapeQueue.enqueueUrls([
|
||||||
|
{ userId, noteId: null, attachmentId: firstId, url },
|
||||||
|
{ userId, noteId: null, attachmentId: secondId, url },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(FetchMock.callsTo(url)).toHaveLength(1)
|
||||||
|
|
||||||
|
const rows = await Promise.all([attachmentRow(firstId), attachmentRow(secondId)])
|
||||||
|
rows.forEach(row => {
|
||||||
|
expect(row.text).toContain('Shared Page')
|
||||||
|
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stale entry revalidates and a 304 reuses the stored text', async () => {
|
||||||
|
const url = 'https://example.com/revalidate'
|
||||||
|
await clearCache(url)
|
||||||
|
|
||||||
|
FetchMock.routeSequence(url, [
|
||||||
|
FetchMock.html('<html><head><title>Original Title</title></head><body>x</body></html>', {
|
||||||
|
headers: { etag: 'W/"v1"' },
|
||||||
|
}),
|
||||||
|
FetchMock.status(304),
|
||||||
|
])
|
||||||
|
|
||||||
|
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
|
||||||
|
|
||||||
|
// Force staleness so the next pass revalidates instead of hitting cache.
|
||||||
|
await db.promise().query('UPDATE scrape_cache SET expires_at = ? WHERE url_hash = ?',
|
||||||
|
[Math.round(Date.now() / 1000) - 10, UrlSafety.urlKey(url)])
|
||||||
|
|
||||||
|
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
|
||||||
|
|
||||||
|
// Conditional request was sent.
|
||||||
|
const conditional = FetchMock.callsTo(url).find(call => call.headers['If-None-Match'])
|
||||||
|
expect(conditional).toBeTruthy()
|
||||||
|
|
||||||
|
const row = await attachmentRow(secondId)
|
||||||
|
expect(row.text).toContain('Original Title')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('failure handling', () => {
|
||||||
|
|
||||||
|
test('a 500 leaves the row failed and never writes a sentinel string', async () => {
|
||||||
|
const url = 'https://example.com/broken'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => FetchMock.status(500))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
const row = await attachmentRow(attachmentId)
|
||||||
|
|
||||||
|
expect(row.scrape_state).toBe(ScrapeQueue.STATE.FAILED)
|
||||||
|
// The old code stored these in attachment.text and fed them to FULLTEXT.
|
||||||
|
expect(row.text).not.toContain('No site text')
|
||||||
|
expect(row.text).not.toContain('Request Timeout')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a 404 removes the shell row rather than orphaning it', async () => {
|
||||||
|
const url = 'https://example.com/gone'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => FetchMock.status(404))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
expect(await attachmentRow(attachmentId)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a blocked url removes the shell row and makes no request', async () => {
|
||||||
|
const url = 'http://127.0.0.1:9999/internal'
|
||||||
|
FetchMock.route(/.*/, () => page('should never load'))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
expect(await attachmentRow(attachmentId)).toBeNull()
|
||||||
|
expect(FetchMock.callCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one failing url does not strand the rest of a batch', async () => {
|
||||||
|
const good = 'https://example.com/good-one'
|
||||||
|
const bad = 'https://example.com/bad-one'
|
||||||
|
await clearCache(good)
|
||||||
|
await clearCache(bad)
|
||||||
|
|
||||||
|
FetchMock.route(good, () => page('Good One'))
|
||||||
|
FetchMock.route(bad, () => FetchMock.status(500))
|
||||||
|
|
||||||
|
const goodId = await Attachment.createPendingUrlAttachment(userId, null, good)
|
||||||
|
const badId = await Attachment.createPendingUrlAttachment(userId, null, bad)
|
||||||
|
|
||||||
|
// The old counter-based implementation never settled in this case.
|
||||||
|
const texts = await ScrapeQueue.enqueueUrls([
|
||||||
|
{ userId, noteId: null, attachmentId: badId, url: bad },
|
||||||
|
{ userId, noteId: null, attachmentId: goodId, url: good },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(texts).toHaveLength(2)
|
||||||
|
expect((await attachmentRow(goodId)).text).toContain('Good One')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed url is not refetched while inside its backoff window', async () => {
|
||||||
|
const url = 'https://example.com/flaky'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => FetchMock.status(500))
|
||||||
|
|
||||||
|
const firstId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: firstId, url })
|
||||||
|
|
||||||
|
const callsAfterFirst = FetchMock.callCount()
|
||||||
|
|
||||||
|
const secondId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId: secondId, url })
|
||||||
|
|
||||||
|
expect(FetchMock.callCount()).toBe(callsAfterFirst)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('robots.txt', () => {
|
||||||
|
|
||||||
|
test('refuses a disallowed url and records it', async () => {
|
||||||
|
const url = 'https://example.com/no-bots/page'
|
||||||
|
await clearCache(url)
|
||||||
|
|
||||||
|
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 }) // robots ON
|
||||||
|
|
||||||
|
FetchMock.route('https://example.com/robots.txt', () => new Response(
|
||||||
|
'User-agent: *\nDisallow: /no-bots', { status: 200, headers: { 'content-type': 'text/plain' } }
|
||||||
|
))
|
||||||
|
FetchMock.route(url, () => page('Should Not Be Fetched'))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
expect(FetchMock.callsTo(url)).toHaveLength(0)
|
||||||
|
|
||||||
|
const cached = await ScrapeCache.get(url)
|
||||||
|
expect(cached.fetch_state).toBe('blocked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('proceeds when robots.txt allows the path', async () => {
|
||||||
|
const url = 'https://example.com/allowed/page'
|
||||||
|
await clearCache(url)
|
||||||
|
|
||||||
|
ScrapeQueue.configure({ perHostMinSpacingMs: 0, perHostMaxSpacingMs: 0 })
|
||||||
|
|
||||||
|
FetchMock.route('https://example.com/robots.txt', () => new Response(
|
||||||
|
'User-agent: *\nDisallow: /private', { status: 200, headers: { 'content-type': 'text/plain' } }
|
||||||
|
))
|
||||||
|
FetchMock.route(url, () => page('Allowed Page'))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
await ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url })
|
||||||
|
|
||||||
|
expect((await attachmentRow(attachmentId)).text).toContain('Allowed Page')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('crash recovery', () => {
|
||||||
|
|
||||||
|
test('re-enqueues rows left pending by a restart', async () => {
|
||||||
|
const url = 'https://example.com/recovered'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => page('Recovered Page'))
|
||||||
|
|
||||||
|
// Simulate a row that was enqueued but never processed.
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
|
||||||
|
const recovery = await ScrapeQueue.recoverPending()
|
||||||
|
expect(recovery.requeued).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await ScrapeQueue.drain()
|
||||||
|
|
||||||
|
const row = await attachmentRow(attachmentId)
|
||||||
|
expect(row.text).toContain('Recovered Page')
|
||||||
|
expect(row.scrape_state).toBe(ScrapeQueue.STATE.DONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resets rows stuck mid-fetch so they are retried', async () => {
|
||||||
|
const url = 'https://example.com/stuck'
|
||||||
|
await clearCache(url)
|
||||||
|
FetchMock.route(url, () => page('Unstuck Page'))
|
||||||
|
|
||||||
|
const attachmentId = await Attachment.createPendingUrlAttachment(userId, null, url)
|
||||||
|
// Pretend the process died while this one was in flight.
|
||||||
|
await db.promise().query('UPDATE attachment SET scrape_state = ? WHERE id = ?',
|
||||||
|
[ScrapeQueue.STATE.FETCHING, attachmentId])
|
||||||
|
|
||||||
|
await ScrapeQueue.recoverPending()
|
||||||
|
await ScrapeQueue.drain()
|
||||||
|
|
||||||
|
expect((await attachmentRow(attachmentId)).scrape_state).toBe(ScrapeQueue.STATE.DONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not sweep up long-settled rows', async () => {
|
||||||
|
// Rows default to DONE, which is what keeps the pre-existing ~1400
|
||||||
|
// attachment rows out of the recovery queue on first boot.
|
||||||
|
const [rows] = await db.promise().query(
|
||||||
|
'SELECT COUNT(*) AS n FROM attachment WHERE scrape_state = ? AND user_id = ?',
|
||||||
|
[ScrapeQueue.STATE.PENDING, userId]
|
||||||
|
)
|
||||||
|
const recovery = await ScrapeQueue.recoverPending()
|
||||||
|
expect(recovery.requeued).toBeLessThanOrEqual(rows[0].n + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//
|
||||||
|
// Test harness for the outbound HTTP layer.
|
||||||
|
//
|
||||||
|
// Responses are built from the REAL Response/ReadableStream globals rather than
|
||||||
|
// hand-rolled stubs, so the streaming size cap, the charset decoder and the
|
||||||
|
// body-cancel paths in HttpFetch are genuinely exercised instead of being
|
||||||
|
// stubbed around.
|
||||||
|
//
|
||||||
|
|
||||||
|
let FetchMock = module.exports = {}
|
||||||
|
|
||||||
|
let originalFetch = null
|
||||||
|
let routes = []
|
||||||
|
let callLog = []
|
||||||
|
|
||||||
|
FetchMock.install = () => {
|
||||||
|
originalFetch = global.fetch
|
||||||
|
routes = []
|
||||||
|
callLog = []
|
||||||
|
|
||||||
|
global.fetch = jest.fn(async (url, options = {}) => {
|
||||||
|
const href = typeof url === 'string' ? url : url.href
|
||||||
|
callLog.push({ url: href, headers: options.headers || {}, options })
|
||||||
|
|
||||||
|
const route = routes.find(entry => entry.match(href))
|
||||||
|
if (!route) {
|
||||||
|
throw Object.assign(new Error(`No route for ${href}`), { code: 'ENOTFOUND' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hanging route must still honour the abort signal, or the test would
|
||||||
|
// wait forever instead of proving the AbortController fires.
|
||||||
|
if (options.signal) {
|
||||||
|
return await Promise.race([
|
||||||
|
Promise.resolve(route.responder(href, options)),
|
||||||
|
abortPromise(options.signal),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.responder(href, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortPromise = (signal) => new Promise((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
||||||
|
}, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
FetchMock.restore = () => {
|
||||||
|
if (originalFetch) { global.fetch = originalFetch } else { delete global.fetch }
|
||||||
|
originalFetch = null
|
||||||
|
routes = []
|
||||||
|
callLog = []
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// route('https://example.com/', responder) or route(/example/, responder).
|
||||||
|
// Responders are called with (url, options) and may be functions returning a
|
||||||
|
// Response, so a route can answer differently on each call.
|
||||||
|
//
|
||||||
|
FetchMock.route = (pattern, responder) => {
|
||||||
|
const match = pattern instanceof RegExp
|
||||||
|
? (href) => pattern.test(href)
|
||||||
|
: (href) => href === pattern
|
||||||
|
|
||||||
|
routes.push({ match, responder: typeof responder === 'function' ? responder : () => responder })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer with a counter so "retried exactly N times" is assertable.
|
||||||
|
FetchMock.routeSequence = (pattern, responses) => {
|
||||||
|
let index = 0
|
||||||
|
FetchMock.route(pattern, () => {
|
||||||
|
const response = responses[Math.min(index, responses.length - 1)]
|
||||||
|
index++
|
||||||
|
return typeof response === 'function' ? response() : response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchMock.html = (body, { status = 200, headers = {}, contentType = 'text/html; charset=utf-8' } = {}) => {
|
||||||
|
return new Response(body, {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': contentType, ...headers },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw bytes, for the charset tests where the encoding is the point.
|
||||||
|
FetchMock.buffer = (buffer, { status = 200, contentType = 'text/html', headers = {} } = {}) => {
|
||||||
|
return new Response(buffer, {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': contentType, ...headers },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchMock.status = (status, { headers = {}, body = '' } = {}) => {
|
||||||
|
return new Response(body || null, { status, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchMock.redirect = (location, status = 302) => {
|
||||||
|
return new Response(null, { status, headers: { location } })
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// A body that never ends. Proves the size cap aborts mid-stream rather than
|
||||||
|
// buffering until the process dies.
|
||||||
|
//
|
||||||
|
FetchMock.oversized = (contentType = 'text/html') => {
|
||||||
|
const chunk = Buffer.alloc(64 * 1024, 0x61)
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
pull(controller) { controller.enqueue(new Uint8Array(chunk)) },
|
||||||
|
})
|
||||||
|
return new Response(stream, { status: 200, headers: { 'content-type': contentType } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never settles - the AbortController is the only way out.
|
||||||
|
FetchMock.hang = () => new Promise(() => {})
|
||||||
|
|
||||||
|
FetchMock.networkError = (code = 'ECONNRESET') => () => {
|
||||||
|
throw Object.assign(new Error(`socket ${code}`), { code })
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchMock.calls = () => callLog.slice()
|
||||||
|
FetchMock.callsTo = (pattern) => callLog.filter(entry =>
|
||||||
|
pattern instanceof RegExp ? pattern.test(entry.url) : entry.url === pattern)
|
||||||
|
FetchMock.callCount = () => callLog.length
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//
|
||||||
|
// The scrape worker.
|
||||||
|
//
|
||||||
|
// Owns the whole lifecycle for one URL: cache check -> robots -> polite fetch ->
|
||||||
|
// parse -> thumbnail -> persist -> notify. Saving a note enqueues URLs and
|
||||||
|
// returns immediately; nothing in the request path waits on the network.
|
||||||
|
//
|
||||||
|
// Structural fixes over the old implementation:
|
||||||
|
// - Promise.allSettled semantics instead of a completion counter, so a single
|
||||||
|
// rejection can no longer strand a batch forever.
|
||||||
|
// - Every fetch goes through HostQueue, so there is no unbounded fan-out.
|
||||||
|
// - Jobs for the same URL coalesce into one fetch, fanned out to every
|
||||||
|
// attachment row that wanted it.
|
||||||
|
// - Pending work is recoverable after a restart from attachment.scrape_state.
|
||||||
|
//
|
||||||
|
|
||||||
|
const config = require('@config/scrape')
|
||||||
|
const db = require('@config/database')
|
||||||
|
const UrlSafety = require('@helpers/UrlSafety')
|
||||||
|
const HttpFetch = require('@helpers/HttpFetch')
|
||||||
|
const HostQueue = require('@helpers/HostQueue')
|
||||||
|
const Robots = require('@helpers/Robots')
|
||||||
|
const Realtime = require('@helpers/Realtime')
|
||||||
|
const SiteScrape = require('@helpers/SiteScrape')
|
||||||
|
const ScrapeCache = require('@models/ScrapeCache')
|
||||||
|
|
||||||
|
const cheerio = require('cheerio')
|
||||||
|
|
||||||
|
let ScrapeQueue = module.exports = {}
|
||||||
|
|
||||||
|
// attachment.scrape_state
|
||||||
|
const STATE = ScrapeQueue.STATE = { PENDING: 0, FETCHING: 1, DONE: 2, FAILED: 3 }
|
||||||
|
|
||||||
|
let settings = { ...config }
|
||||||
|
let queue = HostQueue.shared
|
||||||
|
let started = false
|
||||||
|
|
||||||
|
// urlKey -> { promise, jobs: [] } so concurrent requests for one URL share a fetch.
|
||||||
|
const inFlight = new Map()
|
||||||
|
// userId -> pending job count, for backpressure.
|
||||||
|
const perUser = new Map()
|
||||||
|
// userId -> debounce timer, so a 10-link note triggers one refresh not ten.
|
||||||
|
const emitTimers = new Map()
|
||||||
|
//
|
||||||
|
// Every job promise currently outstanding. Needed because callers like
|
||||||
|
// recoverPending() and scanTextForWebsites() deliberately do not await their
|
||||||
|
// batches - without this, drain() could return while fire-and-forget work was
|
||||||
|
// still sitting in front of its first await.
|
||||||
|
//
|
||||||
|
const outstanding = new Set()
|
||||||
|
|
||||||
|
const EMIT_DEBOUNCE_MS = 1500
|
||||||
|
|
||||||
|
const now = () => Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
|
//
|
||||||
|
// Test seam. Lets a test run with zero politeness spacing and no robots lookups
|
||||||
|
// while still exercising the real code path.
|
||||||
|
//
|
||||||
|
ScrapeQueue.configure = (overrides = {}) => {
|
||||||
|
settings = { ...config, ...overrides }
|
||||||
|
|
||||||
|
queue = new HostQueue.Queue({
|
||||||
|
globalConcurrency: settings.maxConcurrentFetches,
|
||||||
|
perHostConcurrency: settings.perHostConcurrency,
|
||||||
|
minSpacingMs: settings.perHostMinSpacingMs,
|
||||||
|
maxSpacingMs: settings.perHostMaxSpacingMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeQueue.__state = () => ({
|
||||||
|
inFlight: inFlight.size,
|
||||||
|
perUser: new Map(perUser),
|
||||||
|
queue: queue.stats(),
|
||||||
|
started,
|
||||||
|
})
|
||||||
|
|
||||||
|
ScrapeQueue.__reset = () => {
|
||||||
|
inFlight.clear()
|
||||||
|
outstanding.clear()
|
||||||
|
perUser.clear()
|
||||||
|
emitTimers.forEach(timer => clearTimeout(timer))
|
||||||
|
emitTimers.clear()
|
||||||
|
settings = { ...config }
|
||||||
|
queue = HostQueue.shared
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeQueue.start = () => {
|
||||||
|
started = true
|
||||||
|
return ScrapeQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeQueue.stop = () => {
|
||||||
|
started = false
|
||||||
|
queue.stop()
|
||||||
|
emitTimers.forEach(timer => clearTimeout(timer))
|
||||||
|
emitTimers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Resolves once every queued and in-flight job has settled.
|
||||||
|
//
|
||||||
|
// Loops because jobs enqueue follow-up work: a page fetch spawns a thumbnail
|
||||||
|
// fetch, and a recovery batch is registered asynchronously.
|
||||||
|
//
|
||||||
|
ScrapeQueue.drain = async () => {
|
||||||
|
for (let pass = 0; pass < 20; pass++) {
|
||||||
|
await Promise.allSettled([...outstanding])
|
||||||
|
await queue.drain()
|
||||||
|
await Promise.allSettled([...inFlight.values()].map(entry => entry.promise))
|
||||||
|
|
||||||
|
if (!outstanding.size && !inFlight.size && !queue.size() && !queue.inflight()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrapeQueue.size = () => queue.size() + inFlight.size + outstanding.size
|
||||||
|
|
||||||
|
//
|
||||||
|
// Debounced notification. The client already refetches on these events, so one
|
||||||
|
// per user per window is enough - the old code emitted once per URL.
|
||||||
|
//
|
||||||
|
const notifyUser = (userId) => {
|
||||||
|
if (emitTimers.has(userId)) { return }
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
emitTimers.delete(userId)
|
||||||
|
Realtime.emitToUser(userId, 'update_counts')
|
||||||
|
Realtime.emitToUser(userId, 'update_note_attachments')
|
||||||
|
}, EMIT_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
if (timer.unref) { timer.unref() }
|
||||||
|
emitTimers.set(userId, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests want the emit without waiting out the debounce.
|
||||||
|
ScrapeQueue.__flushNotifications = () => {
|
||||||
|
for (const [userId, timer] of [...emitTimers.entries()]) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
emitTimers.delete(userId)
|
||||||
|
Realtime.emitToUser(userId, 'update_counts')
|
||||||
|
Realtime.emitToUser(userId, 'update_note_attachments')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAttachmentState = async (attachmentId, state) => {
|
||||||
|
if (!attachmentId) { return }
|
||||||
|
await db.promise().query(
|
||||||
|
'UPDATE attachment SET scrape_state = ?, scrape_attempts = scrape_attempts + 1 WHERE id = ?',
|
||||||
|
[state, attachmentId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Write a successful result onto an attachment row.
|
||||||
|
//
|
||||||
|
// attachment.text stays PLAINTEXT - the FULLTEXT index depends on it.
|
||||||
|
//
|
||||||
|
const applyResultToAttachment = async (attachmentId, fields) => {
|
||||||
|
if (!attachmentId) { return }
|
||||||
|
|
||||||
|
await db.promise().query(
|
||||||
|
`UPDATE attachment
|
||||||
|
SET text = ?, url = ?, last_indexed = ?, file_location = ?, scrape_state = ?,
|
||||||
|
scrape_attempts = scrape_attempts + 1
|
||||||
|
WHERE id = ?`,
|
||||||
|
[fields.text, fields.url, now(), fields.fileLocation, STATE.DONE, attachmentId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// A failed scrape must not leave a sentinel string in the search index. The old
|
||||||
|
// code stored 'No site text' / 'Request Timeout' in attachment.text, which then
|
||||||
|
// got concatenated into the note's searchable content.
|
||||||
|
//
|
||||||
|
const applyFailureToAttachment = async (attachmentId, error) => {
|
||||||
|
if (!attachmentId) { return }
|
||||||
|
|
||||||
|
const kind = (error && error.kind) || 'unknown'
|
||||||
|
|
||||||
|
// A blocked or permanently-gone URL has no preview to show, ever. Drop the
|
||||||
|
// shell row rather than leaving an orphan showing the bare URL.
|
||||||
|
const terminal = kind === 'blocked' || kind === 'robots_disallowed' ||
|
||||||
|
(kind === 'http' && (error.status === 404 || error.status === 410))
|
||||||
|
|
||||||
|
if (terminal) {
|
||||||
|
await db.promise().query('DELETE FROM attachment WHERE id = ? AND scrape_state IN (?, ?)',
|
||||||
|
[attachmentId, STATE.PENDING, STATE.FETCHING])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAttachmentState(attachmentId, STATE.FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Fetch and store the preview image. Never fatal: a page with no usable
|
||||||
|
// thumbnail is still a perfectly good link preview.
|
||||||
|
//
|
||||||
|
const fetchThumbnail = async (imageUrl, pageUrl) => {
|
||||||
|
if (!imageUrl) { return null }
|
||||||
|
|
||||||
|
// Lazy require: Attachment requires this module, so a top-level require
|
||||||
|
// would be a cycle.
|
||||||
|
const Attachment = require('@models/Attachment')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await queue.schedule(hostKeyFor(imageUrl), () =>
|
||||||
|
Attachment.downloadThumbnail(imageUrl, pageUrl))
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[scrape] thumbnail failed', imageUrl, error.kind || error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostKeyFor = (url) => {
|
||||||
|
try { return UrlSafety.registrableHost(new URL(url).hostname) }
|
||||||
|
catch (error) { return '(none)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Fetch one URL and write it to the cache. Returns the cache row fields.
|
||||||
|
//
|
||||||
|
const performFetch = async (url) => {
|
||||||
|
const cached = await ScrapeCache.get(url)
|
||||||
|
|
||||||
|
// Fresh and usable - zero network I/O.
|
||||||
|
if (ScrapeCache.isFresh(cached) && ScrapeCache.isUsable(cached)) {
|
||||||
|
return { fromCache: true, fields: ScrapeCache.toAttachmentFields(cached) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parked failure still inside its backoff window.
|
||||||
|
if (ScrapeCache.isFresh(cached) && cached.fetch_state !== 'ok') {
|
||||||
|
const error = new Error(cached.last_error || 'previously failed')
|
||||||
|
error.kind = cached.fetch_state === 'blocked' ? 'blocked' : 'http'
|
||||||
|
error.status = cached.status || null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.checkRobots !== false) {
|
||||||
|
const allowed = await Robots.isAllowed(url)
|
||||||
|
if (!allowed) {
|
||||||
|
const error = new Error(`robots.txt disallows ${url}`)
|
||||||
|
error.kind = 'robots_disallowed'
|
||||||
|
await ScrapeCache.recordFailure(url, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spacingMs = null
|
||||||
|
if (settings.checkRobots !== false) {
|
||||||
|
const crawlDelay = await Robots.crawlDelayMs(url)
|
||||||
|
if (Robots.exceedsCrawlDelayCap(crawlDelay)) {
|
||||||
|
const error = new Error(`Crawl-delay of ${crawlDelay}ms exceeds cap`)
|
||||||
|
error.kind = 'blocked'
|
||||||
|
await ScrapeCache.recordFailure(url, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
spacingMs = crawlDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
await ScrapeCache.markPending(url)
|
||||||
|
|
||||||
|
const conditional = ScrapeCache.conditionalFor(cached)
|
||||||
|
|
||||||
|
let response = null
|
||||||
|
try {
|
||||||
|
response = await queue.schedule(hostKeyFor(url), () => HttpFetch.fetchDocument(url, {
|
||||||
|
timeoutMs: settings.pageTimeoutMs,
|
||||||
|
budgetMs: settings.totalBudgetMs,
|
||||||
|
maxBytes: settings.maxHtmlBytes,
|
||||||
|
conditional,
|
||||||
|
}), { spacingMs })
|
||||||
|
} catch (error) {
|
||||||
|
await ScrapeCache.recordFailure(url, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged since last time - reuse what we already stored.
|
||||||
|
if (response.notModified && cached) {
|
||||||
|
await ScrapeCache.touchNotModified(url)
|
||||||
|
const refreshed = await ScrapeCache.get(url)
|
||||||
|
return { fromCache: true, notModified: true, fields: ScrapeCache.toAttachmentFields(refreshed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.html || '')
|
||||||
|
const parsed = SiteScrape.parse($, response.finalUrl || url)
|
||||||
|
|
||||||
|
// Reuse an existing thumbnail file rather than refetching the image.
|
||||||
|
let fileLocation = (cached && cached.image_url === parsed.imageUrl && cached.file_location)
|
||||||
|
? cached.file_location
|
||||||
|
: await fetchThumbnail(parsed.imageUrl, response.finalUrl || url)
|
||||||
|
|
||||||
|
await ScrapeCache.putSuccess(url, {
|
||||||
|
...parsed,
|
||||||
|
status: response.status,
|
||||||
|
etag: response.etag,
|
||||||
|
lastModified: response.lastModified,
|
||||||
|
contentType: response.contentType,
|
||||||
|
fileLocation,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromCache: false,
|
||||||
|
fields: {
|
||||||
|
text: parsed.searchText,
|
||||||
|
fileLocation,
|
||||||
|
// Keep the URL the user wrote. The canonical URL is recorded in the
|
||||||
|
// cache row; rewriting attachment.url would change link behaviour.
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Coalesce: one network fetch per URL no matter how many notes asked for it.
|
||||||
|
//
|
||||||
|
const fetchCoalesced = (url) => {
|
||||||
|
const key = UrlSafety.urlKey(url)
|
||||||
|
if (!key) { return Promise.reject(Object.assign(new Error(`unusable url ${url}`), { kind: 'blocked' })) }
|
||||||
|
|
||||||
|
const existing = inFlight.get(key)
|
||||||
|
if (existing) { return existing.promise }
|
||||||
|
|
||||||
|
const promise = performFetch(url).finally(() => inFlight.delete(key))
|
||||||
|
inFlight.set(key, { promise, url })
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseUser = (userId) => {
|
||||||
|
const count = (perUser.get(userId) || 0) - 1
|
||||||
|
if (count <= 0) { perUser.delete(userId) } else { perUser.set(userId, count) }
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Queue one URL. Resolves with the search text (or '' on failure) and never
|
||||||
|
// rejects, so a caller can await a whole batch without special handling.
|
||||||
|
//
|
||||||
|
ScrapeQueue.enqueueUrl = ({ userId, noteId, attachmentId, url }) => {
|
||||||
|
const job = runJob({ userId, noteId, attachmentId, url })
|
||||||
|
|
||||||
|
// Registered synchronously, so drain() sees the job even before it reaches
|
||||||
|
// its first await.
|
||||||
|
outstanding.add(job)
|
||||||
|
job.finally(() => outstanding.delete(job))
|
||||||
|
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
const runJob = async ({ userId, noteId, attachmentId, url }) => {
|
||||||
|
if (!settings.enabled) { return '' }
|
||||||
|
|
||||||
|
const pending = perUser.get(userId) || 0
|
||||||
|
if (pending >= settings.maxPendingPerUser) {
|
||||||
|
// Left at scrape_state = 0, so recoverPending() picks it up later.
|
||||||
|
// Deferred, not dropped.
|
||||||
|
console.log(`[scrape] deferring ${url} - user ${userId} at pending cap`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
perUser.set(userId, pending + 1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setAttachmentState(attachmentId, STATE.FETCHING)
|
||||||
|
|
||||||
|
const result = await fetchCoalesced(url)
|
||||||
|
await applyResultToAttachment(attachmentId, result.fields)
|
||||||
|
|
||||||
|
notifyUser(userId)
|
||||||
|
|
||||||
|
return result.fields.text || ''
|
||||||
|
} catch (error) {
|
||||||
|
await applyFailureToAttachment(attachmentId, error)
|
||||||
|
notifyUser(userId)
|
||||||
|
|
||||||
|
console.log(`[scrape] ${url} failed: ${error.kind || 'error'} ${error.message}`)
|
||||||
|
return ''
|
||||||
|
} finally {
|
||||||
|
releaseUser(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Queue a batch. allSettled, so one bad URL cannot strand the rest - this is
|
||||||
|
// the structural replacement for the old completion counter.
|
||||||
|
//
|
||||||
|
ScrapeQueue.enqueueUrls = async (jobs) => {
|
||||||
|
if (!jobs || !jobs.length) { return [] }
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(jobs.map(job => ScrapeQueue.enqueueUrl(job)))
|
||||||
|
|
||||||
|
return results.map(result => result.status === 'fulfilled' ? result.value : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Wait for one URL already queued, with a hard cap. Used by the bookmarklet so
|
||||||
|
// it keeps responding after the scrape without being able to hang forever.
|
||||||
|
//
|
||||||
|
ScrapeQueue.awaitUrl = async (promise, capMs) => {
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const timeout = new Promise(resolve => {
|
||||||
|
timer = setTimeout(() => resolve('__scrape_timeout__'), capMs || settings.totalBudgetMs)
|
||||||
|
if (timer.unref) { timer.unref() }
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeout])
|
||||||
|
} finally {
|
||||||
|
if (timer) { clearTimeout(timer) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Crash recovery.
|
||||||
|
//
|
||||||
|
// The queue is in memory, so a pm2 reload loses it - but every enqueued URL
|
||||||
|
// already has an attachment row, which makes the work re-derivable with no
|
||||||
|
// extra table. Rows stuck mid-fetch are reset, rows still pending are
|
||||||
|
// re-enqueued, and anything older than a day is retired so nothing
|
||||||
|
// recirculates forever.
|
||||||
|
//
|
||||||
|
ScrapeQueue.recoverPending = async () => {
|
||||||
|
if (!settings.enabled) { return { requeued: 0, retired: 0 } }
|
||||||
|
|
||||||
|
// Anything left at FETCHING died with the previous process.
|
||||||
|
await db.promise().query(
|
||||||
|
'UPDATE attachment SET scrape_state = ? WHERE scrape_state = ?',
|
||||||
|
[STATE.PENDING, STATE.FETCHING]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cutoff = now() - (24 * 60 * 60)
|
||||||
|
|
||||||
|
const [retiredResult] = await db.promise().query(
|
||||||
|
`UPDATE attachment SET scrape_state = ?
|
||||||
|
WHERE scrape_state = ? AND enqueued_at IS NOT NULL AND enqueued_at < ?`,
|
||||||
|
[STATE.FAILED, STATE.PENDING, cutoff]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [rows] = await db.promise().query(
|
||||||
|
`SELECT id, user_id, note_id, url FROM attachment
|
||||||
|
WHERE attachment_type = 1 AND visible = 1 AND scrape_state = ?
|
||||||
|
ORDER BY enqueued_at IS NULL, enqueued_at
|
||||||
|
LIMIT ?`,
|
||||||
|
[STATE.PENDING, settings.maxQueueLength]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
console.log(`[scrape] recovering ${rows.length} pending URL(s) after restart`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget: boot must not wait on the network.
|
||||||
|
// Fire and forget, but the individual jobs register themselves in
|
||||||
|
// `outstanding`, so drain() can still wait for them.
|
||||||
|
ScrapeQueue.enqueueUrls(rows.map(row => ({
|
||||||
|
userId: row.user_id,
|
||||||
|
noteId: row.note_id,
|
||||||
|
attachmentId: row.id,
|
||||||
|
url: row.url,
|
||||||
|
}))).catch(error => console.log('[scrape] recovery failed', error.message))
|
||||||
|
|
||||||
|
return { requeued: rows.length, retired: retiredResult.affectedRows || 0 }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user