Add parquet floor, museum-style exit, movement-tinted walls, and gold/black frames with gallery sync after Fix it. Debug panel gets Checked and Fix it buttons; documentation and image-fetch reliability updates included. Co-authored-by: Cursor <cursoragent@cursor.com>
1614 lines
55 KiB
JavaScript
1614 lines
55 KiB
JavaScript
const https = require('https');
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
|
|
const BROWSER_USER_AGENT =
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
|
|
const WIKI_LANGS = ['de', 'fr', 'it', 'ru'];
|
|
|
|
const TRUSTED_IMAGE_HOSTS = [
|
|
'upload.wikimedia.org',
|
|
'commons.wikimedia.org',
|
|
'collections.louvre.fr',
|
|
'artic.edu',
|
|
'images.metmuseum.org',
|
|
'collectionapi.metmuseum.org',
|
|
'ids.si.edu',
|
|
'harvardartmuseums.org',
|
|
'rijksmuseum.nl',
|
|
'openaccess-api.clevelandart.org',
|
|
'api.europeana.eu',
|
|
'hermitagemuseum.org',
|
|
'pushkinmuseum.art',
|
|
'googleusercontent.com',
|
|
'artsandculture.google.com',
|
|
];
|
|
|
|
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;
|
|
|
|
const PAINTING_WIKI_OVERRIDES = {
|
|
'Job Cigarette Papers': 'Job (advertising)',
|
|
'Charing Cross Bridge': 'Charing Cross Bridge (Derain)',
|
|
'Cut with the Dada Kitchen Knife':
|
|
'Cut with the Dada Kitchen Knife through the Last Weimar Beer-Belly Cultural Epoch in Germany',
|
|
'Cherubs of the Sistine Madonna': "Raphael's Cherubs",
|
|
'Madonna and Child (Madonna della Seggiola)': 'Madonna della Seggiola',
|
|
'Madonna della seggiola': 'Madonna della Seggiola',
|
|
'La Madonna della sedia': 'Madonna della Seggiola',
|
|
'Madonna della sedia': 'Madonna della Seggiola',
|
|
'Raphael::Madonna and Child': 'Small Cowper Madonna',
|
|
};
|
|
|
|
const MADONNA_SEGGIOLA_IMAGE = {
|
|
thumbUrl:
|
|
'https://upload.wikimedia.org/wikipedia/commons/thumb/0/09/Raphael_Madonna_della_seggiola.jpg/960px-Raphael_Madonna_della_seggiola.jpg',
|
|
fullUrl: 'https://upload.wikimedia.org/wikipedia/commons/0/09/Raphael_Madonna_della_seggiola.jpg',
|
|
source: 'Wikimedia Commons (Palazzo Pitti, Florence)',
|
|
};
|
|
|
|
const SMALL_COWPER_MADONNA_IMAGE = {
|
|
thumbUrl:
|
|
'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Raffaello_Madonna_Cowper.jpg/960px-Raffaello_Madonna_Cowper.jpg',
|
|
fullUrl: 'https://upload.wikimedia.org/wikipedia/commons/b/b1/Raffaello_Madonna_Cowper.jpg',
|
|
source: 'Wikimedia Commons (National Gallery of Art, Washington)',
|
|
};
|
|
|
|
function artistPaintingKey(artistName, title) {
|
|
return artistName && title ? `${artistName}::${title}` : title;
|
|
}
|
|
|
|
/** Strip duplicate parentheticals and museum catalog noise from seed titles. */
|
|
function simplifyPaintingTitle(title) {
|
|
if (!title) return '';
|
|
let t = title.trim();
|
|
const paren = t.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
if (paren) {
|
|
const outer = paren[1].trim();
|
|
const inner = paren[2].trim();
|
|
const outerNorm = outer.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
const innerNorm = inner.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
if (innerNorm.includes(outerNorm.slice(0, 12)) || outerNorm.includes(innerNorm.slice(0, 12))) {
|
|
return inner.length >= outer.length ? inner : outer;
|
|
}
|
|
return outer;
|
|
}
|
|
return t;
|
|
}
|
|
|
|
function artistSurname(name) {
|
|
if (!name) return '';
|
|
const parts = name.trim().split(/\s+/);
|
|
return parts[parts.length - 1].toLowerCase();
|
|
}
|
|
|
|
function normalizeForMatch(text) {
|
|
return (text || '')
|
|
.normalize('NFD')
|
|
.replace(/\p{M}/gu, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function titleMatchScore(candidate, searchTitle, artistName) {
|
|
const c = normalizeForMatch(candidate);
|
|
const tokens = normalizeForMatch(simplifyPaintingTitle(searchTitle))
|
|
.split(/\s+/)
|
|
.filter((w) => w.length > 3);
|
|
if (!tokens.length) return 0;
|
|
const hits = tokens.filter((t) => c.includes(t)).length;
|
|
const surname = artistSurname(artistName);
|
|
const artistHit = surname && c.includes(normalizeForMatch(surname)) ? 1 : 0;
|
|
return hits + artistHit;
|
|
}
|
|
|
|
const DIRECT_IMAGE_OVERRIDES = {
|
|
'The Persistence of Memory': {
|
|
thumbUrl: 'https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg',
|
|
fullUrl: 'https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg',
|
|
source: 'Wikimedia Commons (Museum of Modern Art, New York)',
|
|
},
|
|
'Self-Portrait Hesitating': {
|
|
thumbUrl:
|
|
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg/960px-Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg',
|
|
fullUrl:
|
|
'https://upload.wikimedia.org/wikipedia/commons/2/2c/Angelica_Kauffman._Self-Portrait_Hesitating_Between_the_Arts_of_Music_and_Painting.jpg',
|
|
source: 'Wikimedia Commons (National Trust, Nostell Priory)',
|
|
},
|
|
'Cherubs of the Sistine Madonna': {
|
|
thumbUrl:
|
|
'https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Raffaels_Angels.jpg/960px-Raffaels_Angels.jpg',
|
|
fullUrl: 'https://upload.wikimedia.org/wikipedia/commons/5/54/Raffaels_Angels.jpg',
|
|
source: 'Wikimedia Commons (detail from Sistine Madonna, Gemäldegalerie Alte Meister)',
|
|
},
|
|
'Madonna and Child (Madonna della Seggiola)': MADONNA_SEGGIOLA_IMAGE,
|
|
'Madonna della seggiola': MADONNA_SEGGIOLA_IMAGE,
|
|
'La Madonna della sedia': MADONNA_SEGGIOLA_IMAGE,
|
|
'Madonna della sedia': MADONNA_SEGGIOLA_IMAGE,
|
|
'Raphael::Madonna and Child': SMALL_COWPER_MADONNA_IMAGE,
|
|
};
|
|
|
|
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;
|
|
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();
|
|
}
|
|
|
|
function fetchBuffer(url, redirectCount = 0, referer = null, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
if (redirectCount > 8) return reject(new Error('Too many redirects'));
|
|
const client = url.startsWith('https') ? https : http;
|
|
const useBrowser = options.browser !== false && (options.browser === true || !!referer);
|
|
const headers = {
|
|
'User-Agent': useBrowser ? BROWSER_USER_AGENT : USER_AGENT,
|
|
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
};
|
|
if (referer) headers.Referer = referer;
|
|
if (url.includes('wikimedia.org') || url.includes('wikipedia.org')) {
|
|
headers.Referer = 'https://commons.wikimedia.org/';
|
|
headers['User-Agent'] = USER_AGENT;
|
|
}
|
|
if (url.includes('artic.edu')) {
|
|
headers.Referer = 'https://www.artic.edu/';
|
|
}
|
|
if (url.includes('collections.louvre.fr')) {
|
|
headers.Referer = 'https://collections.louvre.fr/';
|
|
}
|
|
if (url.includes('hermitagemuseum.org') || url.includes('pushkinmuseum.art')) {
|
|
headers.Referer = url.split('/').slice(0, 3).join('/') + '/';
|
|
}
|
|
if (url.includes('googleusercontent.com') || url.includes('gstatic.com') || url.includes('artsandculture.google.com')) {
|
|
headers.Referer = headers.Referer || 'https://artsandculture.google.com/';
|
|
}
|
|
const req = client.get(url, { headers }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
const next = res.headers.location.startsWith('http')
|
|
? res.headers.location
|
|
: new URL(res.headers.location, url).href;
|
|
return resolve(fetchBuffer(next, redirectCount + 1, referer, options));
|
|
}
|
|
if (res.statusCode === 429) {
|
|
res.resume();
|
|
return reject(new Error('HTTP 429'));
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
}
|
|
);
|
|
attachRequestTimeout(req, reject);
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
await throttle();
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.startsWith('https') ? https : http;
|
|
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);
|
|
}
|
|
let data = '';
|
|
res.on('data', (c) => (data += c));
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
})
|
|
.on('error', reject);
|
|
attachRequestTimeout(req, reject);
|
|
});
|
|
}
|
|
|
|
async function fetchHtml(url, options = {}) {
|
|
await throttle();
|
|
const { method = 'GET', body = null, headers = {} } = options;
|
|
return new Promise((resolve, reject) => {
|
|
const parsed = new URL(url);
|
|
const client = parsed.protocol === 'https:' ? https : http;
|
|
const req = client.request(
|
|
{
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
path: parsed.pathname + parsed.search,
|
|
method,
|
|
headers: {
|
|
'User-Agent': USER_AGENT,
|
|
Accept: 'text/html,application/xhtml+xml',
|
|
...headers,
|
|
},
|
|
},
|
|
(res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
const next = res.headers.location.startsWith('http')
|
|
? res.headers.location
|
|
: new URL(res.headers.location, url).href;
|
|
return fetchHtml(next, options).then(resolve).catch(reject);
|
|
}
|
|
let data = '';
|
|
res.on('data', (c) => (data += c));
|
|
res.on('end', () => {
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
resolve(data);
|
|
});
|
|
}
|
|
);
|
|
attachRequestTimeout(req, reject);
|
|
req.on('error', reject);
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function wikiHost(lang) {
|
|
return lang === 'en' ? 'en.wikipedia.org' : `${lang}.wikipedia.org`;
|
|
}
|
|
|
|
function isTrustedImageUrl(url) {
|
|
try {
|
|
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
return TRUSTED_IMAGE_HOSTS.some((h) => host === h || host.endsWith('.' + h));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pickBestImageUrl(urls) {
|
|
for (const url of urls) {
|
|
if (isTrustedImageUrl(url) && /\.(jpe?g|png|webp)(\?|$)/i.test(url)) {
|
|
return url;
|
|
}
|
|
}
|
|
for (const url of urls) {
|
|
if (isTrustedImageUrl(url)) return url;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractImageUrlsFromText(text) {
|
|
const urls = new Set();
|
|
for (const match of text.matchAll(/https?:\/\/[^\s"'<>\\]+?\.(?:jpe?g|png|webp)(?:\?[^\s"'<>\\]*)?/gi)) {
|
|
urls.add(match[0].replace(/\\$/g, ''));
|
|
}
|
|
for (const match of text.matchAll(/https?:\/\/upload\.wikimedia\.org\/[^\s"'<>\\]+/gi)) {
|
|
urls.add(match[0].replace(/\\$/g, ''));
|
|
}
|
|
for (const match of text.matchAll(/https?:\/\/collections\.louvre\.fr\/media\/[^\s"'<>\\]+/gi)) {
|
|
urls.add(match[0].replace(/\\$/g, ''));
|
|
}
|
|
return [...urls];
|
|
}
|
|
|
|
async function withRetry(fn, retries = 6) {
|
|
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 (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);
|
|
if (activeDeadline?.active) {
|
|
const capped = Math.min(wait, activeDeadline.remainingMs() - 50);
|
|
if (capped <= 0) activeDeadline.throwIfExpired();
|
|
await sleep(capped);
|
|
} else {
|
|
await sleep(wait);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function pickExt(url) {
|
|
try {
|
|
const ext = path.extname(new URL(url).pathname) || '.jpg';
|
|
return ext.length > 5 ? '.jpg' : ext;
|
|
} catch {
|
|
return '.jpg';
|
|
}
|
|
}
|
|
|
|
async function getWikidataImageFilename(wikiTitle) {
|
|
const propsUrl = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(wikiTitle)}&prop=pageprops&ppprop=wikibase_item&format=json`;
|
|
const props = await fetchJson(propsUrl);
|
|
const page = Object.values(props.query?.pages || {})[0];
|
|
const qid = page?.pageprops?.wikibase_item;
|
|
if (!qid) return null;
|
|
|
|
const entityUrl = `https://www.wikidata.org/wiki/Special:EntityData/${qid}.json`;
|
|
const entity = await fetchJson(entityUrl);
|
|
const claims = entity.entities?.[qid]?.claims?.P18;
|
|
if (!claims?.length) return null;
|
|
return claims[0].mainsnak?.datavalue?.value;
|
|
}
|
|
|
|
async function getCommonsFileUrls(filename) {
|
|
const fileTitle = filename.startsWith('File:') ? filename : `File:${filename}`;
|
|
const url = `https://commons.wikimedia.org/w/api.php?action=query&titles=${encodeURIComponent(fileTitle)}&prop=imageinfo&iiprop=url&iiurlwidth=${THUMB_WIDTH}&format=json`;
|
|
const data = await fetchJson(url);
|
|
const page = Object.values(data.query?.pages || {})[0];
|
|
const info = page?.imageinfo?.[0];
|
|
if (!info) return null;
|
|
return {
|
|
thumbUrl: info.thumburl || info.url,
|
|
fullUrl: info.url || info.thumburl,
|
|
source: 'Wikimedia Commons',
|
|
};
|
|
}
|
|
|
|
async function getWikipediaImages(wikiTitle) {
|
|
return getWikipediaImagesLang(wikiTitle, 'en');
|
|
}
|
|
|
|
async function getWikipediaImagesLang(wikiTitle, lang = 'en') {
|
|
const host = wikiHost(lang);
|
|
const langLabel = lang === 'en' ? 'English' : lang.toUpperCase();
|
|
const url = `https://${host}/w/api.php?action=query&titles=${encodeURIComponent(wikiTitle)}&prop=pageimages&piprop=original|thumbnail&pithumbsize=${THUMB_WIDTH}&format=json`;
|
|
const data = await fetchJson(url);
|
|
const page = Object.values(data.query?.pages || {})[0];
|
|
if (!page || page.missing) return null;
|
|
const thumbUrl = page.thumbnail?.source;
|
|
const fullUrl = page.original?.source || thumbUrl;
|
|
if (!fullUrl) return null;
|
|
return {
|
|
thumbUrl: thumbUrl || fullUrl,
|
|
fullUrl,
|
|
source: `Wikipedia ${langLabel} (Wikimedia Commons)`,
|
|
resolvedWikiTitle: wikiTitle,
|
|
wikiLang: lang,
|
|
};
|
|
}
|
|
|
|
async function searchCommonsImages(artistName, paintingTitle) {
|
|
const queries = [
|
|
[artistName, paintingTitle].filter(Boolean).join(' '),
|
|
paintingTitle,
|
|
[artistName, ...(paintingTitle || '').split(/\s+/).slice(0, 4)].join(' '),
|
|
[artistName, 'painting', (paintingTitle || '').split('(')[0].trim()].filter(Boolean).join(' '),
|
|
];
|
|
const seen = new Set();
|
|
|
|
for (const query of queries) {
|
|
if (!query || seen.has(query)) continue;
|
|
seen.add(query);
|
|
|
|
const url = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gssearch=${encodeURIComponent(query)}&gsnamespace=6&gslimit=8&prop=imageinfo&iiprop=url&iiurlwidth=${THUMB_WIDTH}&format=json`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
const pages = data.query?.pages;
|
|
if (!pages) continue;
|
|
|
|
const artistToken = (artistName || '').toLowerCase().split(' ').pop() || '';
|
|
const titleTokens = (paintingTitle || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter((t) => t.length > 3)
|
|
.slice(0, 4);
|
|
|
|
for (const page of Object.values(pages)) {
|
|
const info = page.imageinfo?.[0];
|
|
if (!info?.url) continue;
|
|
const fileTitle = (page.title || '').toLowerCase();
|
|
if (artistToken && !fileTitle.includes(artistToken) && !fileTitle.includes('raphael') && !fileTitle.includes('raffael')) {
|
|
continue;
|
|
}
|
|
const matchCount = titleTokens.filter((t) => fileTitle.includes(t)).length;
|
|
if (titleTokens.length > 0 && matchCount === 0 && !fileTitle.includes('uffizi')) continue;
|
|
|
|
return {
|
|
thumbUrl: info.thumburl || info.url,
|
|
fullUrl: info.url,
|
|
source: 'Wikimedia Commons (search)',
|
|
};
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getMetMuseumImages(searchTerm, artistName) {
|
|
const query = [artistName, searchTerm].filter(Boolean).join(' ');
|
|
const searchUrl = `https://collectionapi.metmuseum.org/public/collection/v1/search?q=${encodeURIComponent(query)}&hasImages=true`;
|
|
try {
|
|
await throttle();
|
|
const search = await fetchJson(searchUrl);
|
|
if (!search.objectIDs?.length) return null;
|
|
|
|
for (const id of search.objectIDs.slice(0, 10)) {
|
|
await throttle();
|
|
const obj = await fetchJson(
|
|
`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`
|
|
);
|
|
if (!obj.isPublicDomain || !obj.primaryImage) continue;
|
|
const titleMatch =
|
|
!searchTerm ||
|
|
(obj.title || '').toLowerCase().includes(searchTerm.toLowerCase().slice(0, 10));
|
|
if (!titleMatch && search.objectIDs.length > 3) continue;
|
|
return {
|
|
thumbUrl: obj.primaryImageSmall || obj.primaryImage,
|
|
fullUrl: obj.primaryImage,
|
|
source: 'The Metropolitan Museum of Art (Open Access)',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getArtInstituteImages(searchTerm, artistName) {
|
|
const query = [artistName, searchTerm].filter(Boolean).join(' ');
|
|
const url = `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(query)}&fields=id,title,artist_title,image_id&limit=6`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const art of data.data || []) {
|
|
if (!art.image_id) continue;
|
|
const titleOk =
|
|
!searchTerm ||
|
|
(art.title || '').toLowerCase().includes(searchTerm.toLowerCase().slice(0, 8));
|
|
const artistOk =
|
|
!artistName ||
|
|
(art.artist_title || '').toLowerCase().includes(artistName.toLowerCase().split(' ').pop());
|
|
if (!titleOk && !artistOk) continue;
|
|
const base = `https://www.artic.edu/iiif/2/${art.image_id}`;
|
|
return {
|
|
thumbUrl: `${base}/full/200,/0/default.jpg`,
|
|
fullUrl: `${base}/full/1686,/0/default.jpg`,
|
|
source: 'Art Institute of Chicago (Open Access)',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getRijksmuseumImages(searchTerm, artistName) {
|
|
const query = [artistName, searchTerm].filter(Boolean).join(' ');
|
|
const url = `https://www.rijksmuseum.nl/api/en/collection?key=0&format=json&ps=8&s=relevance&q=${encodeURIComponent(query)}&imgonly=true`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const item of data.artObjects || []) {
|
|
if (!item.webImage?.url) continue;
|
|
return {
|
|
thumbUrl: item.webImage.url,
|
|
fullUrl: item.webImage.url,
|
|
source: 'Rijksmuseum',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getWikidataImagesBySearch(artistName, paintingTitle) {
|
|
const label = (paintingTitle || '').replace(/"/g, '\\"').slice(0, 120);
|
|
const query = `
|
|
SELECT ?img ?imgLabel WHERE {
|
|
?work rdfs:label ?workLabel .
|
|
FILTER(LANG(?workLabel) = "en")
|
|
FILTER(CONTAINS(LCASE(?workLabel), LCASE("${label.slice(0, 40)}")))
|
|
?work wdt:P170 ?artist .
|
|
?artist rdfs:label ?artistLabel .
|
|
FILTER(CONTAINS(LCASE(?artistLabel), "${(artistName || '').split(' ').pop().toLowerCase()}"))
|
|
?work wdt:P18 ?img .
|
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
|
}
|
|
LIMIT 3`;
|
|
try {
|
|
const url = `https://query.wikidata.org/sparql?format=json&query=${encodeURIComponent(query)}`;
|
|
const data = await fetchJson(url);
|
|
const row = data.results?.bindings?.[0];
|
|
if (!row?.img?.value) return null;
|
|
const filename = decodeURIComponent(row.img.value.split('/').pop());
|
|
return getCommonsFileUrls(filename);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function searchWikipediaTitle(artistName, paintingTitle) {
|
|
const hit = await searchWikipediaTitleMultilingual(artistName, paintingTitle);
|
|
return hit?.title || null;
|
|
}
|
|
|
|
async function searchWikipediaTitleLang(artistName, paintingTitle, lang = 'en') {
|
|
const simple = simplifyPaintingTitle(paintingTitle);
|
|
const host = wikiHost(lang);
|
|
const queries = [
|
|
`${artistName} ${simple}`,
|
|
`${simple} ${artistSurname(artistName)}`,
|
|
`${artistName} ${simple.split(' ').slice(0, 4).join(' ')}`,
|
|
simple,
|
|
];
|
|
const seen = new Set();
|
|
let best = null;
|
|
let bestScore = 0;
|
|
|
|
for (const query of queries) {
|
|
if (!query || seen.has(query)) continue;
|
|
seen.add(query);
|
|
|
|
const apiUrl = `https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=8&format=json`;
|
|
const restUrl = `https://${host}/w/rest.php/v1/search/page?q=${encodeURIComponent(query)}&limit=8`;
|
|
|
|
for (const url of [apiUrl, restUrl]) {
|
|
try {
|
|
const data = await fetchJson(url);
|
|
const hits = data.query?.search || data.pages || [];
|
|
for (const hit of hits) {
|
|
const title = hit.title || '';
|
|
if (/disambiguation|list of|category:|liste von|liste des|elenco|categoría:/i.test(title)) {
|
|
continue;
|
|
}
|
|
const score =
|
|
titleMatchScore(title, paintingTitle, artistName) + (hit.score || hit.key || 0) / 1000;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = title;
|
|
}
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return bestScore >= 2 ? best : null;
|
|
}
|
|
|
|
async function searchWikipediaTitleMultilingual(artistName, paintingTitle) {
|
|
const langs = ['en', ...WIKI_LANGS];
|
|
let best = null;
|
|
let bestScore = 0;
|
|
|
|
for (const lang of langs) {
|
|
const title = await searchWikipediaTitleLang(artistName, paintingTitle, lang);
|
|
if (!title) continue;
|
|
const score = titleMatchScore(title, paintingTitle, artistName);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { title, lang };
|
|
}
|
|
}
|
|
return bestScore >= 2 ? best : null;
|
|
}
|
|
|
|
async function getMultilingualWikipediaImages(artistName, searchTitle) {
|
|
for (const lang of WIKI_LANGS) {
|
|
const title = await searchWikipediaTitleLang(artistName, searchTitle, lang);
|
|
if (!title) continue;
|
|
const images = await getWikipediaImagesLang(title, lang);
|
|
if (images) return images;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getClevelandArtImages(searchTerm, artistName) {
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
const url = `https://openaccess-api.clevelandart.org/api/artworks/?q=${encodeURIComponent(query)}&has_image=1&cc0=1&limit=12`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const item of data.data || []) {
|
|
const img = item.images?.web?.url || item.images?.full?.url || item.images?.print?.url;
|
|
if (!img) continue;
|
|
const score = titleMatchScore(`${item.title || ''}`, searchTerm, artistName);
|
|
if (score < 1 && (data.data?.length || 0) > 2) continue;
|
|
return {
|
|
thumbUrl: item.images?.web?.url || img,
|
|
fullUrl: item.images?.full?.url || item.images?.web?.url || img,
|
|
source: 'Cleveland Museum of Art (Open Access)',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getSmithsonianImages(searchTerm, artistName) {
|
|
const apiKey = process.env.SMITHSONIAN_API_KEY;
|
|
if (!apiKey) return null;
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
const url = `https://api.si.edu/openaccess/api/v1.0/search?q=${encodeURIComponent(query)}&api_key=${apiKey}&rows=12&sort=Relevance`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const row of data.response?.rows || []) {
|
|
const content = row.content || {};
|
|
const title = content.title?.content || content.label?.content || '';
|
|
const score = titleMatchScore(title, searchTerm, artistName);
|
|
if (score < 1 && (data.response?.rows?.length || 0) > 3) continue;
|
|
|
|
const onlineMedia = content.online_media?.content || [];
|
|
for (const media of onlineMedia) {
|
|
const ids = media.ids || media.id || [];
|
|
const idList = Array.isArray(ids) ? ids : [ids];
|
|
for (const id of idList) {
|
|
if (!id || typeof id !== 'string') continue;
|
|
const imgUrl = id.startsWith('http')
|
|
? id
|
|
: `https://ids.si.edu/ids/deliveryService?id=${encodeURIComponent(id)}&max=1600`;
|
|
return {
|
|
thumbUrl: imgUrl.replace('max=1600', 'max=400'),
|
|
fullUrl: imgUrl,
|
|
source: 'Smithsonian Open Access',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getHarvardArtMuseumsImages(searchTerm, artistName) {
|
|
const apiKey = process.env.HARVARD_ART_API_KEY;
|
|
if (!apiKey) return null;
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
const url = `https://api.harvardartmuseums.org/object?q=${encodeURIComponent(query)}&apikey=${apiKey}&size=10&hasimage=1&fields=id,title,primaryimageurl,people`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const rec of data.records || []) {
|
|
if (!rec.primaryimageurl) continue;
|
|
const people = (rec.people || []).map((p) => p.name).join(' ');
|
|
const score = titleMatchScore(`${rec.title} ${people}`, searchTerm, artistName);
|
|
if (score < 1 && (data.records?.length || 0) > 3) continue;
|
|
return {
|
|
thumbUrl: rec.primaryimageurl,
|
|
fullUrl: rec.primaryimageurl,
|
|
source: 'Harvard Art Museums',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getLouvreImages(searchTerm, artistName) {
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
try {
|
|
const html = await fetchHtml(
|
|
`https://collections.louvre.fr/recherche?q=${encodeURIComponent(query)}&page=1`,
|
|
{ headers: { Referer: 'https://collections.louvre.fr/' } }
|
|
);
|
|
const arks = [
|
|
...new Set([...html.matchAll(/href="(\/ark:[^"]+)"/g)].map((m) => m[1])),
|
|
].slice(0, 8);
|
|
|
|
for (const ark of arks) {
|
|
const data = await fetchJson(`https://collections.louvre.fr${ark}.json`);
|
|
const title = data.title || data.denominationTitle || '';
|
|
const score = titleMatchScore(`${title} ${(data.creator || []).map((c) => c.label).join(' ')}`, searchTerm, artistName);
|
|
const images = data.image || [];
|
|
const primary = images.find((img) => img.urlImage) || images[0];
|
|
if (!primary?.urlImage) continue;
|
|
if (score < 1 && arks.length > 2) continue;
|
|
|
|
const fullUrl = primary.urlImage.startsWith('http')
|
|
? primary.urlImage
|
|
: `https://collections.louvre.fr/${primary.urlImage.replace(/^\//, '')}`;
|
|
const thumbUrl = primary.urlThumbnail
|
|
? primary.urlThumbnail.startsWith('http')
|
|
? primary.urlThumbnail
|
|
: `https://collections.louvre.fr/${primary.urlThumbnail.replace(/^\//, '')}`
|
|
: fullUrl;
|
|
|
|
return {
|
|
thumbUrl,
|
|
fullUrl,
|
|
source: 'Musée du Louvre (collections.louvre.fr)',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getEuropeanaImages(searchTerm, artistName) {
|
|
const apiKey = process.env.EUROPEANA_API_KEY;
|
|
if (!apiKey) return null;
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
const url =
|
|
`https://api.europeana.eu/record/v2/search.json?wskey=${encodeURIComponent(apiKey)}` +
|
|
`&query=${encodeURIComponent(query)}&qf=TYPE:IMAGE&reusability=open&rows=12&profile=rich`;
|
|
try {
|
|
const data = await fetchJson(url);
|
|
for (const item of data.items || []) {
|
|
const title = item.title?.[0] || item.dcTitle?.[0] || '';
|
|
const score = titleMatchScore(`${title} ${item.dcCreator?.join(' ') || ''}`, searchTerm, artistName);
|
|
if (score < 1 && (data.items?.length || 0) > 3) continue;
|
|
|
|
const fullUrl =
|
|
item.edmIsShownBy?.[0] ||
|
|
item.edmPreview?.[0] ||
|
|
item.link ||
|
|
item.guid;
|
|
if (!fullUrl || !isTrustedImageUrl(fullUrl)) continue;
|
|
|
|
return {
|
|
thumbUrl: item.edmPreview?.[0] || fullUrl,
|
|
fullUrl,
|
|
source: 'Europeana (European museums)',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function upgradeGoogleUserContentUrl(url, width = FULL_WIDTH) {
|
|
if (!url || !url.includes('googleusercontent.com')) return url;
|
|
const base = url.replace(/=w[^=]*$/, '').replace(/=s\d+$/, '');
|
|
return `${base}=w${width}-no`;
|
|
}
|
|
|
|
function extractGoogleArtsAssetLinks(html) {
|
|
const assets = [];
|
|
const seen = new Set();
|
|
for (const match of html.matchAll(/\/asset\/([^/"'?]+)\/([A-Za-z0-9_-]{8,})/g)) {
|
|
const slug = match[1];
|
|
const id = match[2];
|
|
const key = `${slug}/${id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
assets.push({ slug, id, path: `/asset/${slug}/${id}` });
|
|
}
|
|
return assets;
|
|
}
|
|
|
|
async function getGoogleArtsCultureAssetImage(assetPath) {
|
|
const html = await fetchHtml(`https://artsandculture.google.com${assetPath}`, {
|
|
headers: { Referer: 'https://artsandculture.google.com/' },
|
|
});
|
|
const ogImage = html.match(/property="og:image" content="([^"]+)"/)?.[1];
|
|
const jsonImage = html.match(/"image":"(https:\/\/lh3\.googleusercontent\.com\/[^"]+)"/)?.[1];
|
|
const imageUrl = ogImage || jsonImage;
|
|
if (!imageUrl) return null;
|
|
|
|
const fullUrl = upgradeGoogleUserContentUrl(imageUrl, FULL_WIDTH);
|
|
const thumbUrl = upgradeGoogleUserContentUrl(imageUrl, THUMB_WIDTH);
|
|
return { thumbUrl, fullUrl };
|
|
}
|
|
|
|
async function getGoogleArtsCultureImages(searchTerm, artistName) {
|
|
const query = [artistName, simplifyPaintingTitle(searchTerm)].filter(Boolean).join(' ');
|
|
try {
|
|
const html = await fetchHtml(
|
|
`https://artsandculture.google.com/search?q=${encodeURIComponent(query)}`,
|
|
{ headers: { Referer: 'https://artsandculture.google.com/' } }
|
|
);
|
|
|
|
const assets = extractGoogleArtsAssetLinks(html);
|
|
const ranked = assets
|
|
.map((asset) => ({
|
|
...asset,
|
|
score: titleMatchScore(asset.slug.replace(/-/g, ' '), searchTerm, artistName),
|
|
}))
|
|
.filter((a) => a.score >= 1)
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
const candidates = ranked.length ? ranked : assets.slice(0, 6).map((a) => ({ ...a, score: 0 }));
|
|
|
|
for (const asset of candidates.slice(0, 5)) {
|
|
const images = await getGoogleArtsCultureAssetImage(asset.path);
|
|
if (!images) continue;
|
|
if (asset.score < 1 && candidates.length > 2) continue;
|
|
return {
|
|
...images,
|
|
source: 'Google Arts & Culture',
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getWikidataImagesMultilingual(artistName, paintingTitle) {
|
|
const label = simplifyPaintingTitle(paintingTitle).replace(/"/g, '\\"').slice(0, 80);
|
|
const surname = (artistName || '').split(' ').pop().toLowerCase();
|
|
const langs = ['en', ...WIKI_LANGS];
|
|
const langFilter = langs.map((l) => `"${l}"`).join(', ');
|
|
const query = `
|
|
SELECT ?img ?workLabel WHERE {
|
|
?work rdfs:label ?workLabel .
|
|
FILTER(LANG(?workLabel) IN (${langFilter}))
|
|
FILTER(CONTAINS(LCASE(STR(?workLabel)), LCASE("${label.slice(0, 40)}")))
|
|
?work wdt:P170 ?artist .
|
|
?artist rdfs:label ?artistLabel .
|
|
FILTER(LANG(?artistLabel) IN (${langFilter}))
|
|
FILTER(CONTAINS(LCASE(STR(?artistLabel)), "${surname}"))
|
|
?work wdt:P18 ?img .
|
|
}
|
|
LIMIT 5`;
|
|
try {
|
|
const url = `https://query.wikidata.org/sparql?format=json&query=${encodeURIComponent(query)}`;
|
|
const data = await fetchJson(url);
|
|
for (const row of data.results?.bindings || []) {
|
|
if (!row.img?.value) continue;
|
|
const filename = decodeURIComponent(row.img.value.split('/').pop());
|
|
const commons = await getCommonsFileUrls(filename);
|
|
if (commons) {
|
|
return { ...commons, source: 'Wikimedia Commons (Wikidata multilingual)' };
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractDdgResultLinks(html) {
|
|
const links = [];
|
|
for (const match of html.matchAll(/class="result__a"[^>]*href="([^"]+)"/g)) {
|
|
links.push(match[1].replace(/&/g, '&'));
|
|
}
|
|
for (const match of html.matchAll(/uddg=([^&"]+)/g)) {
|
|
try {
|
|
links.push(decodeURIComponent(match[1]));
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return links;
|
|
}
|
|
|
|
async function resolveCommonsFilePageUrl(pageUrl) {
|
|
if (!pageUrl || !/commons\.wikimedia\.org\/wiki\/File:/i.test(pageUrl)) return null;
|
|
const fileTitle = decodeURIComponent(pageUrl.split('/wiki/')[1].replace(/_/g, ' '));
|
|
const commons = await getCommonsFileUrls(fileTitle);
|
|
if (commons) return commons;
|
|
const filePathUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(fileTitle.replace(/^File:/i, ''))}`;
|
|
return {
|
|
thumbUrl: filePathUrl,
|
|
fullUrl: filePathUrl,
|
|
source: 'Web search → Wikimedia Commons',
|
|
};
|
|
}
|
|
|
|
async function searchWebForPaintingImages(artistName, searchTitle) {
|
|
const simple = simplifyPaintingTitle(searchTitle);
|
|
const queries = [
|
|
`${artistName} ${simple} site:commons.wikimedia.org`,
|
|
`${artistName} ${simple} site:artsandculture.google.com`,
|
|
`${artistName} ${simple} painting filetype:jpg`,
|
|
`${artistName} ${simple} site:collections.louvre.fr`,
|
|
`${artistName} ${simple} Gemälde`,
|
|
`${artistName} ${simple} peinture`,
|
|
`${artistName} ${simple} quadro`,
|
|
`${artistName} ${simple} картина`,
|
|
];
|
|
|
|
const seenQueries = new Set();
|
|
const candidateUrls = [];
|
|
|
|
for (const query of queries.slice(0, 4)) {
|
|
if (!query || seenQueries.has(query)) continue;
|
|
seenQueries.add(query);
|
|
|
|
try {
|
|
const body = `q=${encodeURIComponent(query)}`;
|
|
const html = await fetchHtml('https://html.duckduckgo.com/html/', {
|
|
method: 'POST',
|
|
body,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; VirtualArtGallery/1.0)',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Content-Length': String(Buffer.byteLength(body)),
|
|
},
|
|
});
|
|
|
|
candidateUrls.push(...extractDdgResultLinks(html));
|
|
candidateUrls.push(...extractImageUrlsFromText(html));
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const seenUrls = new Set();
|
|
for (const pageUrl of candidateUrls) {
|
|
if (!pageUrl?.startsWith('http') || seenUrls.has(pageUrl)) continue;
|
|
seenUrls.add(pageUrl);
|
|
|
|
try {
|
|
if (/commons\.wikimedia\.org\/wiki\/File:/i.test(pageUrl)) {
|
|
const commons = await resolveCommonsFilePageUrl(pageUrl);
|
|
if (commons) return commons;
|
|
continue;
|
|
}
|
|
|
|
if (/\.(jpe?g|png|webp)(\?|$)/i.test(pageUrl) && isTrustedImageUrl(pageUrl)) {
|
|
return {
|
|
thumbUrl: pageUrl,
|
|
fullUrl: pageUrl,
|
|
source: 'Web search (image URL)',
|
|
};
|
|
}
|
|
|
|
if (!/wikipedia\.org|collections\.louvre|artic\.edu|metmuseum|europeana|artsandculture\.google/i.test(pageUrl)) {
|
|
continue;
|
|
}
|
|
|
|
if (/artsandculture\.google\.com\/asset\//i.test(pageUrl)) {
|
|
const assetPath = pageUrl.replace(/^https?:\/\/artsandculture\.google\.com/i, '').split('?')[0];
|
|
const gac = await getGoogleArtsCultureAssetImage(assetPath);
|
|
if (gac) return { ...gac, source: 'Web search → Google Arts & Culture' };
|
|
continue;
|
|
}
|
|
|
|
const html = await fetchHtml(pageUrl);
|
|
const imageUrl = pickBestImageUrl(extractImageUrlsFromText(html));
|
|
if (imageUrl) {
|
|
return {
|
|
thumbUrl: imageUrl,
|
|
fullUrl: imageUrl,
|
|
source: 'Web search (museum/Wikipedia page)',
|
|
};
|
|
}
|
|
|
|
const wikiMatch = pageUrl.match(/\/wiki\/([^#?]+)/);
|
|
if (wikiMatch && /wikipedia\.org/.test(pageUrl)) {
|
|
const lang = pageUrl.match(/\/\/([a-z]{2})\.wikipedia\.org/)?.[1] || 'en';
|
|
const title = decodeURIComponent(wikiMatch[1].replace(/_/g, ' '));
|
|
const images = await getWikipediaImagesLang(title, lang);
|
|
if (images) return { ...images, source: `Web search → Wikipedia ${lang.toUpperCase()}` };
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
for (const lang of WIKI_LANGS) {
|
|
const title = await searchWikipediaTitleLang(artistName, searchTitle, lang);
|
|
if (!title) continue;
|
|
const images = await getWikipediaImagesLang(title, lang);
|
|
if (images) return { ...images, source: `Web search → Wikipedia ${lang.toUpperCase()}` };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function resolveFromLookupTitle(lookupTitle, options) {
|
|
const { artistName, paintingTitle } = options;
|
|
const searchTitle = paintingTitle || lookupTitle;
|
|
|
|
const sources = [
|
|
async () => {
|
|
const filename = await getWikidataImageFilename(lookupTitle);
|
|
if (!filename) return null;
|
|
const commons = await getCommonsFileUrls(filename);
|
|
if (commons) return commons;
|
|
const filePathUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(filename)}`;
|
|
return { thumbUrl: filePathUrl, fullUrl: filePathUrl, source: 'Wikimedia Commons (Wikidata)' };
|
|
},
|
|
() => getWikipediaImages(lookupTitle),
|
|
() => searchCommonsImages(artistName, searchTitle),
|
|
() => getMetMuseumImages(searchTitle, artistName),
|
|
() => getArtInstituteImages(searchTitle, artistName),
|
|
() => getClevelandArtImages(searchTitle, artistName),
|
|
() => getRijksmuseumImages(searchTitle, artistName),
|
|
() => getLouvreImages(searchTitle, artistName),
|
|
() => getGoogleArtsCultureImages(searchTitle, artistName),
|
|
() => getEuropeanaImages(searchTitle, artistName),
|
|
() => getMultilingualWikipediaImages(artistName, searchTitle),
|
|
() => getSmithsonianImages(searchTitle, artistName),
|
|
() => getHarvardArtMuseumsImages(searchTitle, artistName),
|
|
() => getWikidataImagesBySearch(artistName, searchTitle),
|
|
() => getWikidataImagesMultilingual(artistName, searchTitle),
|
|
() => getWikipediaImages(searchTitle),
|
|
() => searchCommonsImages(artistName, lookupTitle),
|
|
() => searchWebForPaintingImages(artistName, searchTitle),
|
|
];
|
|
|
|
for (const fn of sources) {
|
|
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
|
try {
|
|
const result = await fn();
|
|
if (result?.fullUrl || result?.thumbUrl) {
|
|
return {
|
|
thumbUrl: result.thumbUrl || result.fullUrl,
|
|
fullUrl: result.fullUrl || result.thumbUrl,
|
|
source: result.source,
|
|
resolvedWikiTitle: lookupTitle,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
if (err.code === 'FETCH_TIME_LIMIT') throw err;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolvePaintingImages(wikiTitle, options = {}) {
|
|
const { artistName, paintingTitle, webSearchOnly } = options;
|
|
const searchTitle = paintingTitle || wikiTitle;
|
|
|
|
if (webSearchOnly) {
|
|
return searchWebForPaintingImages(artistName, searchTitle);
|
|
}
|
|
|
|
const scopedKey = artistPaintingKey(artistName, searchTitle);
|
|
|
|
const directOverride =
|
|
DIRECT_IMAGE_OVERRIDES[scopedKey] || DIRECT_IMAGE_OVERRIDES[searchTitle];
|
|
if (directOverride) {
|
|
return { ...directOverride, resolvedWikiTitle: wikiTitle };
|
|
}
|
|
|
|
const overrideTitle =
|
|
PAINTING_WIKI_OVERRIDES[scopedKey] || PAINTING_WIKI_OVERRIDES[searchTitle];
|
|
const simplified = simplifyPaintingTitle(searchTitle);
|
|
|
|
const lookupCandidates = [];
|
|
const seen = new Set();
|
|
for (const t of [overrideTitle, wikiTitle, simplified, searchTitle]) {
|
|
if (t && !seen.has(t)) {
|
|
seen.add(t);
|
|
lookupCandidates.push(t);
|
|
}
|
|
}
|
|
|
|
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 };
|
|
} else {
|
|
const images = await getWikipediaImagesLang(discovered.title, discovered.lang);
|
|
if (images) {
|
|
return {
|
|
...images,
|
|
resolvedWikiTitle: discovered.title,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
|
|
|
const web = await searchWebForPaintingImages(artistName, searchTitle);
|
|
if (web) return web;
|
|
|
|
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @deprecated use resolvePaintingImages */
|
|
async function resolveImageUrl(wikiTitle, options = {}) {
|
|
const pair = await resolvePaintingImages(wikiTitle, options);
|
|
if (!pair) return null;
|
|
return { url: pair.fullUrl, source: pair.source };
|
|
}
|
|
|
|
async function downloadImageToFile(url, destPath, { force = false } = {}) {
|
|
if (!force && fs.existsSync(destPath)) return destPath;
|
|
await throttle();
|
|
const buffer = await withRetry(() => fetchBuffer(url));
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
fs.writeFileSync(destPath, buffer);
|
|
return destPath;
|
|
}
|
|
|
|
function upgradeWikimediaToFull(url) {
|
|
const thumbMatch = url.match(
|
|
/^(https?:\/\/upload\.wikimedia\.org\/wikipedia\/(?:commons|en)\/)thumb\/(.+)\/\d+px-[^/]+$/
|
|
);
|
|
if (thumbMatch) return `${thumbMatch[1]}${thumbMatch[2]}`;
|
|
return url;
|
|
}
|
|
|
|
function prepareDownloadCandidates(url, context = {}) {
|
|
const candidates = [url];
|
|
if (context.thumbUrl && context.thumbUrl !== url) candidates.push(context.thumbUrl);
|
|
const google = upgradeGoogleUserContentUrl(url, FULL_WIDTH);
|
|
if (google !== url) candidates.push(google);
|
|
const wiki = upgradeWikimediaToFull(url);
|
|
if (wiki !== url) candidates.push(wiki);
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.search) candidates.push(`${parsed.origin}${parsed.pathname}`);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
function referersForDownload(url, context = {}) {
|
|
const refs = [];
|
|
if (context.pageUrl) refs.push(context.pageUrl);
|
|
if (context.searchUrl) refs.push(context.searchUrl);
|
|
if (context.source === 'google-arts-culture') refs.push('https://artsandculture.google.com/');
|
|
if (context.source === 'google-images' || context.source === 'google-custom-search') {
|
|
refs.push('https://www.google.com/');
|
|
}
|
|
if (context.source === 'duckduckgo-images') refs.push('https://duckduckgo.com/');
|
|
if (/googleusercontent\.com|gstatic\.com/i.test(url)) refs.push('https://artsandculture.google.com/');
|
|
if (/wikimedia\.org|wikipedia\.org/i.test(url)) refs.push('https://commons.wikimedia.org/');
|
|
try {
|
|
refs.push(new URL(url).origin + '/');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return [...new Set(refs)];
|
|
}
|
|
|
|
/** Download with browser headers, referer fallbacks, and URL variants (fix / debug). */
|
|
async function downloadImageForFix(url, destPath, context = {}) {
|
|
const candidates = prepareDownloadCandidates(url, context);
|
|
const referers = referersForDownload(url, context);
|
|
let lastError = null;
|
|
|
|
await throttle();
|
|
|
|
for (const candidate of candidates) {
|
|
for (const referer of referers) {
|
|
try {
|
|
const buffer = await fetchBuffer(candidate, 0, referer, { browser: true });
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
fs.writeFileSync(destPath, buffer);
|
|
return destPath;
|
|
} catch (err) {
|
|
lastError = err;
|
|
await sleep(300);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Download failed');
|
|
}
|
|
|
|
async function fetchImageBuffer(url, context = {}) {
|
|
const candidates = prepareDownloadCandidates(url, context);
|
|
const referers = referersForDownload(url, context);
|
|
let lastError = null;
|
|
|
|
await throttle();
|
|
|
|
for (const candidate of candidates) {
|
|
for (const referer of referers) {
|
|
try {
|
|
return await fetchBuffer(candidate, 0, referer, { browser: true });
|
|
} catch (err) {
|
|
lastError = err;
|
|
await sleep(300);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Download failed');
|
|
}
|
|
|
|
function friendlyImageFetchError(err) {
|
|
const msg = err?.message || '';
|
|
if (/ECONNRESET|ETIMEDOUT|EPIPE|socket hang up/i.test(msg)) {
|
|
return 'Could not download this image — the host closed the connection. Try Search again; Wikimedia and Google Arts results work best.';
|
|
}
|
|
if (/HTTP 403|HTTP 401|HTTP 429/i.test(msg)) {
|
|
return 'Could not download this image — the host blocked the request. Try Search again or pick a different preview.';
|
|
}
|
|
if (/Too many redirects/i.test(msg)) {
|
|
return 'Could not download this image — too many redirects from the image URL.';
|
|
}
|
|
return msg || 'Could not download image';
|
|
}
|
|
|
|
function decodeGoogleEmbeddedUrl(raw) {
|
|
return raw
|
|
.replace(/\\u003d/g, '=')
|
|
.replace(/\\u0026/g, '&')
|
|
.replace(/\\u0027/g, "'")
|
|
.replace(/\\\//g, '/');
|
|
}
|
|
|
|
function isLikelyImageUrl(url) {
|
|
if (!url || !/^https?:\/\//i.test(url)) return false;
|
|
if (/^data:/i.test(url)) return false;
|
|
if (/gstatic\.com\/images\?/i.test(url)) return false;
|
|
return true;
|
|
}
|
|
|
|
function pickBestGoogleImageUrl(candidates) {
|
|
const unique = [...new Set(candidates.filter(isLikelyImageUrl))];
|
|
const scored = unique.map((url) => {
|
|
let score = 0;
|
|
if (/upload\.wikimedia\.org/i.test(url)) score += 40;
|
|
if (/googleusercontent\.com/i.test(url)) score += 30;
|
|
if (/\.(jpe?g|png|webp)(\?|$)/i.test(url)) score += 20;
|
|
if (/thumb|thumbnail|small|icon|logo|avatar/i.test(url)) score -= 25;
|
|
if (/=\s*[sS]\d{2,3}(-c|-rw)?(\||$)/.test(url) || /[?&]w=\d{2,3}(&|$)/.test(url)) score -= 15;
|
|
return { url, score };
|
|
});
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored[0]?.url || unique[0] || null;
|
|
}
|
|
|
|
function extractGoogleImageCandidates(html) {
|
|
const candidates = [];
|
|
|
|
for (const match of html.matchAll(/"ou":"((?:https?:\\\/\\\/|\\\/\\\/)[^"\\]+)"/g)) {
|
|
candidates.push(decodeGoogleEmbeddedUrl(match[1]));
|
|
}
|
|
|
|
for (const match of html.matchAll(/"ou":\s*"((?:https?:\/\/)[^"]+)"/g)) {
|
|
candidates.push(match[1]);
|
|
}
|
|
|
|
for (const match of html.matchAll(/\["(https:\\\/\\\/[^"]+)",(?:\d+,\d+|\d+)\]/g)) {
|
|
candidates.push(decodeGoogleEmbeddedUrl(match[1]));
|
|
}
|
|
|
|
for (const match of html.matchAll(/imgurl=([^&"]+)/g)) {
|
|
try {
|
|
candidates.push(decodeURIComponent(match[1]));
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
async function searchGoogleCustomSearchImagesFirst(query) {
|
|
const apiKey = process.env.GOOGLE_CSE_API_KEY;
|
|
const cx = process.env.GOOGLE_CSE_CX;
|
|
if (!apiKey || !cx) return null;
|
|
|
|
const url =
|
|
`https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}` +
|
|
`&cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(query)}&searchType=image&num=1&safe=active`;
|
|
const data = await fetchJson(url);
|
|
const item = data?.items?.[0];
|
|
if (!item?.link) return null;
|
|
return {
|
|
imageUrl: item.link,
|
|
source: 'google-custom-search',
|
|
sourceLabel: 'Google Custom Search',
|
|
};
|
|
}
|
|
|
|
async function searchDuckDuckGoImagesFirst(query) {
|
|
const html = await fetchHtml(
|
|
`https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`,
|
|
{
|
|
headers: {
|
|
'User-Agent':
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
},
|
|
}
|
|
);
|
|
const vqd =
|
|
html.match(/vqd=['"]([^'"]+)['"]/)?.[1] ||
|
|
html.match(/vqd=([\d-]+)/)?.[1] ||
|
|
null;
|
|
if (!vqd) return null;
|
|
|
|
await throttle();
|
|
const jsUrl = `https://duckduckgo.com/i.js?o=json&q=${encodeURIComponent(query)}&l=us-en&vqd=${encodeURIComponent(vqd)}&f=,,,&p=1`;
|
|
const payload = await new Promise((resolve, reject) => {
|
|
https
|
|
.get(
|
|
jsUrl,
|
|
{ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } },
|
|
(res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
)
|
|
.on('error', reject);
|
|
});
|
|
|
|
const first = payload?.results?.[0];
|
|
const imageUrl = first?.image || first?.thumbnail;
|
|
if (!imageUrl) return null;
|
|
return {
|
|
imageUrl,
|
|
source: 'duckduckgo-images',
|
|
sourceLabel: 'DuckDuckGo Images (fallback)',
|
|
};
|
|
}
|
|
|
|
/** First Google-family image result for artist + painting title (developer image audit). */
|
|
async function searchGoogleImagesFirst(artistName, paintingTitle) {
|
|
const simple = simplifyPaintingTitle(paintingTitle);
|
|
const query = `${artistName} ${simple} painting`.trim();
|
|
const searchUrl =
|
|
`https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&hl=en&ijn=0`;
|
|
|
|
const custom = await searchGoogleCustomSearchImagesFirst(query).catch(() => null);
|
|
if (custom?.imageUrl) {
|
|
return { query, searchUrl, ...custom };
|
|
}
|
|
|
|
const gac = await getGoogleArtsCultureImages(simple, artistName).catch(() => null);
|
|
if (gac?.fullUrl) {
|
|
return {
|
|
query,
|
|
imageUrl: gac.fullUrl,
|
|
thumbUrl: gac.thumbUrl,
|
|
searchUrl: `https://artsandculture.google.com/search?q=${encodeURIComponent(query)}`,
|
|
source: 'google-arts-culture',
|
|
sourceLabel: 'Google Arts & Culture',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const html = await fetchHtml(searchUrl, {
|
|
headers: {
|
|
'User-Agent':
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
Accept: 'text/html,application/xhtml+xml',
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
},
|
|
});
|
|
const imageUrl = pickBestGoogleImageUrl(extractGoogleImageCandidates(html));
|
|
if (imageUrl) {
|
|
return {
|
|
query,
|
|
imageUrl,
|
|
searchUrl,
|
|
source: 'google-images',
|
|
sourceLabel: 'Google Images',
|
|
};
|
|
}
|
|
} catch {
|
|
// continue to fallback
|
|
}
|
|
|
|
const ddg = await searchDuckDuckGoImagesFirst(query).catch(() => null);
|
|
if (ddg?.imageUrl) {
|
|
return { query, searchUrl, ...ddg };
|
|
}
|
|
|
|
return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' };
|
|
}
|
|
|
|
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
|
|
const sharp = require('sharp');
|
|
if (!fs.existsSync(fullDest)) return false;
|
|
|
|
const ext = path.extname(thumbDest).toLowerCase();
|
|
let pipeline = sharp(fullDest).rotate().resize({ width, withoutEnlargement: true });
|
|
|
|
if (ext === '.png') {
|
|
pipeline = pipeline.png({ quality: 85 });
|
|
} else if (ext === '.webp') {
|
|
pipeline = pipeline.webp({ quality: 85 });
|
|
} else {
|
|
pipeline = pipeline.jpeg({ quality: 85 });
|
|
}
|
|
|
|
await pipeline.toFile(thumbDest);
|
|
return true;
|
|
}
|
|
|
|
async function savePaintingImages(wikiTitle, baseFilename, imageDir, options = {}) {
|
|
const deadline = deadlineFromOptions(options);
|
|
activeDeadline = deadline.active ? deadline : null;
|
|
|
|
try {
|
|
const resolved = await resolvePaintingImages(wikiTitle, options);
|
|
if (!resolved) return { imagePath: null, thumbnailPath: null, source: null, resolvedWikiTitle: null };
|
|
|
|
if (activeDeadline?.expired()) activeDeadline.throwIfExpired();
|
|
|
|
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 = {}) {
|
|
if (options.type === 'painting') {
|
|
const result = await savePaintingImages(wikiTitle, filename, imageDir, options);
|
|
return {
|
|
path: result.imagePath,
|
|
thumbnailPath: result.thumbnailPath,
|
|
source: result.source,
|
|
};
|
|
}
|
|
|
|
const resolved = await resolveImageUrl(wikiTitle, options);
|
|
if (!resolved) return { path: null, source: null };
|
|
|
|
const dir = path.join(imageDir, subdir);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
|
|
const ext = pickExt(resolved.url);
|
|
const safeName = filename.replace(/[^a-zA-Z0-9_-]/g, '_') + ext;
|
|
const destPath = path.join(dir, safeName);
|
|
|
|
try {
|
|
await downloadImageToFile(resolved.url, destPath);
|
|
return {
|
|
path: path.join(subdir, safeName).replace(/\\/g, '/'),
|
|
source: resolved.source,
|
|
};
|
|
} catch (err) {
|
|
console.warn(` Download failed (${resolved.source}): ${err.message}`);
|
|
return { path: null, source: resolved.source };
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
resolveImageUrl,
|
|
resolvePaintingImages,
|
|
saveImageForItem,
|
|
savePaintingImages,
|
|
downloadImageToFile,
|
|
downloadImageForFix,
|
|
generateThumbnailFromFull,
|
|
searchWikipediaTitle,
|
|
searchWebForPaintingImages,
|
|
searchGoogleImagesFirst,
|
|
fetchImageBuffer,
|
|
friendlyImageFetchError,
|
|
pickExt,
|
|
getGoogleArtsCultureImages,
|
|
simplifyPaintingTitle,
|
|
fetchHtml,
|
|
DEFAULT_BATCH_MAX_WAIT_MS,
|
|
FetchDeadline,
|
|
sleep,
|
|
};
|