Improve batch image fetch speed and document the pipeline.
Add random queue sampling and a 10s per-painting deadline for fetch-images batches, cap HTTP timeouts to the remaining budget, and update docs plus newly fetched artwork files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d8385d83d6
commit
35c337253d
+151
-56
@@ -27,6 +27,8 @@ const TRUSTED_IMAGE_HOSTS = [
|
||||
|
||||
let lastRequestTime = 0;
|
||||
const MIN_DELAY_MS = 2500;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_BATCH_MAX_WAIT_MS = 10000;
|
||||
const THUMB_WIDTH = 400;
|
||||
const FULL_WIDTH = 1600;
|
||||
|
||||
@@ -137,9 +139,64 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class FetchDeadline {
|
||||
constructor(maxMs) {
|
||||
this.deadline = maxMs ? Date.now() + maxMs : null;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.deadline !== null;
|
||||
}
|
||||
|
||||
expired() {
|
||||
return this.deadline !== null && Date.now() >= this.deadline;
|
||||
}
|
||||
|
||||
remainingMs() {
|
||||
return this.deadline ? Math.max(0, this.deadline - Date.now()) : Infinity;
|
||||
}
|
||||
|
||||
throwIfExpired() {
|
||||
if (this.expired()) {
|
||||
const err = new Error('Painting fetch time limit exceeded');
|
||||
err.code = 'FETCH_TIME_LIMIT';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-operation deadline for batch fetches; cleared after savePaintingImages finishes. */
|
||||
let activeDeadline = null;
|
||||
|
||||
function deadlineFromOptions(options = {}) {
|
||||
if (options.deadline instanceof FetchDeadline) return options.deadline;
|
||||
if (options.maxWaitMs) return new FetchDeadline(options.maxWaitMs);
|
||||
return new FetchDeadline(null);
|
||||
}
|
||||
|
||||
function requestTimeoutMs() {
|
||||
if (!activeDeadline?.active) return REQUEST_TIMEOUT_MS;
|
||||
return Math.min(REQUEST_TIMEOUT_MS, Math.max(500, activeDeadline.remainingMs() - 50));
|
||||
}
|
||||
|
||||
function attachRequestTimeout(req, reject) {
|
||||
const timeoutMs = requestTimeoutMs();
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
}
|
||||
|
||||
async function throttle() {
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
const elapsed = Date.now() - lastRequestTime;
|
||||
if (elapsed < MIN_DELAY_MS) await sleep(MIN_DELAY_MS - elapsed);
|
||||
const minDelay = activeDeadline?.active ? 0 : MIN_DELAY_MS;
|
||||
let wait = Math.max(0, minDelay - elapsed);
|
||||
if (activeDeadline?.active) {
|
||||
wait = Math.min(wait, activeDeadline.remainingMs() - 50);
|
||||
if (wait <= 0) activeDeadline.throwIfExpired();
|
||||
}
|
||||
if (wait > 0) await sleep(wait);
|
||||
lastRequestTime = Date.now();
|
||||
}
|
||||
|
||||
@@ -187,6 +244,7 @@ function fetchBuffer(url, redirectCount = 0, referer = null) {
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
}
|
||||
);
|
||||
attachRequestTimeout(req, reject);
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -195,7 +253,7 @@ async function fetchJson(url) {
|
||||
await throttle();
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
client
|
||||
const req = client
|
||||
.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchJson(res.headers.location).then(resolve).catch(reject);
|
||||
@@ -211,6 +269,7 @@ async function fetchJson(url) {
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
attachRequestTimeout(req, reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,6 +308,7 @@ async function fetchHtml(url, options = {}) {
|
||||
});
|
||||
}
|
||||
);
|
||||
attachRequestTimeout(req, reject);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
@@ -295,13 +355,22 @@ function extractImageUrlsFromText(text) {
|
||||
}
|
||||
|
||||
async function withRetry(fn, retries = 6) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const maxRetries = activeDeadline?.active ? Math.min(retries, 2) : retries;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (i === retries - 1) throw err;
|
||||
if (err.code === 'FETCH_TIME_LIMIT') throw err;
|
||||
if (i === maxRetries - 1) throw err;
|
||||
const wait = err.message.includes('429') ? 8000 * (i + 1) : 2000 * (i + 1);
|
||||
await sleep(wait);
|
||||
if (activeDeadline?.active) {
|
||||
const capped = Math.min(wait, activeDeadline.remainingMs() - 50);
|
||||
if (capped <= 0) activeDeadline.throwIfExpired();
|
||||
await sleep(capped);
|
||||
} else {
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1015,6 +1084,7 @@ async function resolveFromLookupTitle(lookupTitle, options) {
|
||||
];
|
||||
|
||||
for (const fn of sources) {
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result?.fullUrl || result?.thumbUrl) {
|
||||
@@ -1025,7 +1095,9 @@ async function resolveFromLookupTitle(lookupTitle, options) {
|
||||
resolvedWikiTitle: lookupTitle,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
if (err.code === 'FETCH_TIME_LIMIT') throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1060,12 +1132,16 @@ async function resolvePaintingImages(wikiTitle, options = {}) {
|
||||
}
|
||||
|
||||
for (const title of lookupCandidates) {
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
const result = await resolveFromLookupTitle(title, options);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
|
||||
const discovered = await searchWikipediaTitleMultilingual(artistName, searchTitle);
|
||||
if (discovered && !seen.has(discovered.title)) {
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
if (discovered.lang === 'en') {
|
||||
const result = await resolveFromLookupTitle(discovered.title, options);
|
||||
if (result) return { ...result, resolvedWikiTitle: discovered.title };
|
||||
@@ -1080,9 +1156,13 @@ async function resolvePaintingImages(wikiTitle, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
|
||||
const web = await searchWebForPaintingImages(artistName, searchTitle);
|
||||
if (web) return web;
|
||||
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1121,60 +1201,73 @@ async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDT
|
||||
}
|
||||
|
||||
async function savePaintingImages(wikiTitle, baseFilename, imageDir, options = {}) {
|
||||
const resolved = await resolvePaintingImages(wikiTitle, options);
|
||||
if (!resolved) return { imagePath: null, thumbnailPath: null, source: null, resolvedWikiTitle: null };
|
||||
|
||||
const paintingsDir = path.join(imageDir, 'paintings');
|
||||
const thumbsDir = path.join(imageDir, 'paintings', 'thumbs');
|
||||
if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true });
|
||||
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
|
||||
|
||||
const safeBase = baseFilename.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const fullExt = pickExt(resolved.fullUrl);
|
||||
|
||||
const fullDest = path.join(paintingsDir, safeBase + fullExt);
|
||||
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
|
||||
|
||||
let imagePath = null;
|
||||
let thumbnailPath = null;
|
||||
const deadline = deadlineFromOptions(options);
|
||||
activeDeadline = deadline.active ? deadline : null;
|
||||
|
||||
try {
|
||||
await downloadImageToFile(resolved.fullUrl, fullDest);
|
||||
imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
|
||||
} catch (err) {
|
||||
console.warn(` Full image download failed: ${err.message}`);
|
||||
}
|
||||
const resolved = await resolvePaintingImages(wikiTitle, options);
|
||||
if (!resolved) return { imagePath: null, thumbnailPath: null, source: null, resolvedWikiTitle: null };
|
||||
|
||||
if (imagePath) {
|
||||
try {
|
||||
await generateThumbnailFromFull(fullDest, thumbDest);
|
||||
thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/');
|
||||
} catch (err) {
|
||||
console.warn(` Thumbnail generation failed: ${err.message}`);
|
||||
thumbnailPath = imagePath;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const thumbSrc =
|
||||
resolved.thumbUrl !== resolved.fullUrl ? resolved.thumbUrl : resolved.fullUrl;
|
||||
const thumbExt = pickExt(resolved.thumbUrl);
|
||||
const fallbackThumbDest = path.join(thumbsDir, safeBase + '_thumb' + thumbExt);
|
||||
await downloadImageToFile(thumbSrc, fallbackThumbDest);
|
||||
thumbnailPath = path
|
||||
.join('paintings', 'thumbs', safeBase + '_thumb' + thumbExt)
|
||||
.replace(/\\/g, '/');
|
||||
imagePath = thumbnailPath;
|
||||
} catch (err) {
|
||||
console.warn(` Thumbnail download failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
|
||||
return {
|
||||
imagePath,
|
||||
thumbnailPath,
|
||||
source: resolved.source,
|
||||
resolvedWikiTitle: resolved.resolvedWikiTitle || null,
|
||||
};
|
||||
const paintingsDir = path.join(imageDir, 'paintings');
|
||||
const thumbsDir = path.join(imageDir, 'paintings', 'thumbs');
|
||||
if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true });
|
||||
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
|
||||
|
||||
const safeBase = baseFilename.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const fullExt = pickExt(resolved.fullUrl);
|
||||
|
||||
const fullDest = path.join(paintingsDir, safeBase + fullExt);
|
||||
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
|
||||
|
||||
let imagePath = null;
|
||||
let thumbnailPath = null;
|
||||
|
||||
try {
|
||||
await downloadImageToFile(resolved.fullUrl, fullDest);
|
||||
imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
|
||||
} catch (err) {
|
||||
if (err.code === 'FETCH_TIME_LIMIT') throw err;
|
||||
console.warn(` Full image download failed: ${err.message}`);
|
||||
}
|
||||
|
||||
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
||||
|
||||
if (imagePath) {
|
||||
try {
|
||||
await generateThumbnailFromFull(fullDest, thumbDest);
|
||||
thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/');
|
||||
} catch (err) {
|
||||
console.warn(` Thumbnail generation failed: ${err.message}`);
|
||||
thumbnailPath = imagePath;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const thumbSrc =
|
||||
resolved.thumbUrl !== resolved.fullUrl ? resolved.thumbUrl : resolved.fullUrl;
|
||||
const thumbExt = pickExt(resolved.thumbUrl);
|
||||
const fallbackThumbDest = path.join(thumbsDir, safeBase + '_thumb' + thumbExt);
|
||||
await downloadImageToFile(thumbSrc, fallbackThumbDest);
|
||||
thumbnailPath = path
|
||||
.join('paintings', 'thumbs', safeBase + '_thumb' + thumbExt)
|
||||
.replace(/\\/g, '/');
|
||||
imagePath = thumbnailPath;
|
||||
} catch (err) {
|
||||
if (err.code === 'FETCH_TIME_LIMIT') throw err;
|
||||
console.warn(` Thumbnail download failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imagePath,
|
||||
thumbnailPath,
|
||||
source: resolved.source,
|
||||
resolvedWikiTitle: resolved.resolvedWikiTitle || wikiTitle,
|
||||
};
|
||||
} finally {
|
||||
activeDeadline = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImageForItem(wikiTitle, subdir, filename, imageDir, options = {}) {
|
||||
@@ -1220,5 +1313,7 @@ module.exports = {
|
||||
searchWebForPaintingImages,
|
||||
getGoogleArtsCultureImages,
|
||||
simplifyPaintingTitle,
|
||||
DEFAULT_BATCH_MAX_WAIT_MS,
|
||||
FetchDeadline,
|
||||
sleep,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user