Adding scrape files for testing

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

No files matched your search

+466
View File
@@ -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 }
}