'use strict'; // Local copies of the Greatest catalog's cover images. Hotlinking ~100 covers per page // load makes the image CDN drop some of them at random; one polite download each fixes that. const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const TYPES = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/avif': '.avif' }; const EXTENSIONS = new Set(Object.values(TYPES)); const USER_AGENT = 'MangaCircus-Greatest/1.0'; class GreatestCovers { constructor(dir, { fetchImpl = fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), delayMs = 1000, maxBytes = 2 * 1024 * 1024, timeoutMs = 20000 } = {}) { this.dir = path.resolve(dir); Object.assign(this, { fetchImpl, sleep, delayMs, maxBytes, timeoutMs }); this.files = new Map(); // key -> file name try { for (const name of fs.readdirSync(this.dir)) { const ext = path.extname(name); if (EXTENSIONS.has(ext)) this.files.set(path.basename(name, ext), name); } } catch (error) { if (error.code !== 'ENOENT') throw error; } } // A hash of the URL names the file, so a changed catalog URL is fetched again. version(url) { return crypto.createHash('sha256').update(String(url)).digest('hex').slice(0, 16); } file(url) { const name = url ? this.files.get(this.version(url)) : null; return name ? path.join(this.dir, name) : null; } // Sequential, paced, and stops on 403/429 like every other outbound client here. async warm(urls) { const result = { fetched: 0, skipped: 0, failed: 0, stopped: null }; let requested = false; for (const url of new Set(urls.filter(url => /^https:\/\/\S+$/i.test(String(url || ''))))) { if (this.file(url)) { result.skipped++; continue; } if (requested) await this.sleep(this.delayMs); requested = true; try { await this.download(url); result.fetched++; } catch (error) { if (error.stop) { result.stopped = error.message; break; } result.failed++; } } // Duplicates and repeats of stored covers count as skipped. result.skipped += urls.filter(Boolean).length - new Set(urls.filter(Boolean)).size; return result; } async download(url) { const response = await this.fetchImpl(url, { headers: { 'user-agent': USER_AGENT }, signal: AbortSignal.timeout(this.timeoutMs) }); if ([401, 403, 429].includes(response.status)) throw Object.assign(new Error(`HTTP ${response.status}`), { stop: true }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const ext = TYPES[String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()]; if (!ext) throw new Error('Not an image'); if (Number(response.headers.get('content-length')) > this.maxBytes) throw new Error('Image too large'); const body = Buffer.from(await response.arrayBuffer()); if (!body.length || body.length > this.maxBytes) throw new Error('Image too large or empty'); const key = this.version(url), name = key + ext; fs.mkdirSync(this.dir, { recursive: true }); const temp = path.join(this.dir, `.${key}.${crypto.randomBytes(4).toString('hex')}.tmp`); try { fs.writeFileSync(temp, body); fs.renameSync(temp, path.join(this.dir, name)); } finally { fs.rmSync(temp, { force: true }); } this.files.set(key, name); } } module.exports = { GreatestCovers };