/** * Discover painting influence candidates from art-history web sources. * Returns normalized source refs for painting_influence_sources (artist / movement / painting). */ const { fetchHtml, searchWikipediaTitle, sleep } = require('./image-fetcher'); const { normalizeArtist } = require('./influence-resolver'); const SEARCH_SITES = [ 'theartstory.org', 'metmuseum.org', 'artsandculture.google.com', 'nga.gov', 'artic.edu', 'moma.org', 'britannica.com', 'jstor.org', 'oxfordartonline.com', 'wikiart.org', 'wikipedia.org', ]; const MOVEMENT_HINTS = [ 'Impressionism', 'Post-Impressionism', 'Cubism', 'Expressionism', 'Surrealism', 'Baroque', 'Renaissance', 'Romanticism', 'Realism', 'Symbolism', 'Fauvism', 'Abstract Expressionism', 'Pop Art', 'Neoclassicism', 'Rococo', 'Mannerism', 'Gothic', 'Byzantine', 'Art Nouveau', 'Futurism', 'Dada', 'Minimalism', ]; function uniqueByKey(items, keyFn) { const seen = new Set(); return items.filter((item) => { const key = keyFn(item); if (seen.has(key)) return false; seen.add(key); return true; }); } function stripHtml(text) { return (text || '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/\s+/g, ' ') .trim(); } function extractInfluenceNames(text) { if (!text) return []; const names = []; const patterns = [ /influenced by ([^.;\n]{4,120})/gi, /draw(?:s|n)? on ([^.;\n]{4,120})/gi, /indebted to ([^.;\n]{4,120})/gi, /following the example of ([^.;\n]{4,120})/gi, ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(text)) !== null) { const chunk = stripHtml(match[1]); chunk .split(/,| and |;|\//) .map((s) => s.trim()) .filter((s) => s.length > 3 && s.length < 80) .forEach((s) => names.push(s)); } } return names; } function detectMovements(text) { const lower = (text || '').toLowerCase(); return MOVEMENT_HINTS.filter((m) => lower.includes(m.toLowerCase())); } async function fetchJson(url) { const res = await fetch(url, { headers: { 'User-Agent': 'GalleryInfluenceBot/1.0 (personal art history project)' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function discoverFromWikipedia(artistName, paintingTitle) { const results = []; const wikiTitle = await searchWikipediaTitle(paintingTitle, artistName); if (!wikiTitle) return results; try { const parseUrl = `https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(wikiTitle)}` + '&prop=wikitext&format=json&origin=*'; const data = await fetchJson(parseUrl); const wikitext = data?.parse?.wikitext?.['*'] || ''; const plain = wikitext .replace(/\{\{[^{}]*\}\}/g, ' ') .replace(/\[\[Category:[^\]]+\]\]/gi, ' ') .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2') .replace(/\[\[([^\]]+)\]\]/g, '$1') .replace(/''+/g, ''); for (const name of extractInfluenceNames(plain)) { results.push({ type: 'artist', artist: name, period: { duringCreation: true }, notes: `Mentioned on Wikipedia article for "${wikiTitle}".`, source: 'Wikipedia', source_url: `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiTitle.replace(/ /g, '_'))}`, discovered_via: 'wikipedia', confidence: 'discovered', }); } for (const movement of detectMovements(plain)) { results.push({ type: 'movement', movement, period: { duringCreation: true }, notes: `Movement referenced on Wikipedia article for "${wikiTitle}".`, source: 'Wikipedia', source_url: `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiTitle.replace(/ /g, '_'))}`, discovered_via: 'wikipedia', confidence: 'discovered', }); } } catch { // skip } return results; } async function discoverFromWikidata(artistName) { const results = []; try { const searchUrl = 'https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&language=en&type=item&limit=1' + `&search=${encodeURIComponent(artistName)}`; const search = await fetchJson(searchUrl); const entityId = search?.search?.[0]?.id; if (!entityId) return results; const entityUrl = `https://www.wikidata.org/wiki/Special:EntityData/${entityId}.json`; const entityData = await fetchJson(entityUrl); const entity = entityData?.entities?.[entityId]; const claims = entity?.claims?.P737 || []; for (const claim of claims.slice(0, 8)) { const influencedId = claim?.mainsnak?.datavalue?.value?.id; if (!influencedId) continue; const labelUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${influencedId}` + '&props=labels&languages=en&format=json'; const labelData = await fetchJson(labelUrl); const label = labelData?.entities?.[influencedId]?.labels?.en?.value; if (!label) continue; results.push({ type: 'artist', artist: label, period: { duringCreation: true }, notes: `Wikidata P737 (influenced by) on ${artistName}.`, source: 'Wikidata', source_url: `https://www.wikidata.org/wiki/${entityId}`, discovered_via: 'wikidata', confidence: 'discovered', }); } } catch { // skip } return results; } async function discoverFromWebSearch(artistName, paintingTitle) { const results = []; const query = `"${artistName}" "${paintingTitle}" influenced by ${SEARCH_SITES.map((s) => `site:${s}`).join(' OR ')}`; try { const html = await fetchHtml('https://html.duckduckgo.com/html/', { method: 'POST', body: `q=${encodeURIComponent(query)}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); const links = [...html.matchAll(/uddg=([^&"]+)/g)] .map((m) => decodeURIComponent(m[1])) .filter((url) => SEARCH_SITES.some((site) => url.includes(site))) .slice(0, 5); for (const url of links) { try { const page = await fetchHtml(url, { headers: { Referer: 'https://html.duckduckgo.com/' }, }); const text = stripHtml(page); const site = SEARCH_SITES.find((s) => url.includes(s)) || 'web'; for (const name of extractInfluenceNames(text).slice(0, 4)) { results.push({ type: 'artist', artist: name, period: { duringCreation: true }, notes: `Found via art-history web search (${site}).`, source: site, source_url: url, discovered_via: `web:${site}`, confidence: 'discovered', }); } for (const movement of detectMovements(text).slice(0, 3)) { results.push({ type: 'movement', movement, period: { duringCreation: true }, notes: `Movement cited on ${site}.`, source: site, source_url: url, discovered_via: `web:${site}`, confidence: 'discovered', }); } await sleep(1200); } catch { // next result } } } catch { // skip } return results; } async function discoverFromMet(artistName, paintingTitle) { const results = []; try { const searchUrl = `https://collectionapi.metmuseum.org/public/collection/v1/search?q=${encodeURIComponent( `${artistName} ${paintingTitle}` )}&hasImages=true`; const search = await fetchJson(searchUrl); const objectId = search?.objectIDs?.[0]; if (!objectId) return results; const object = await fetchJson( `https://collectionapi.metmuseum.org/public/collection/v1/objects/${objectId}` ); const text = [object.creditLine, object.culture, object.period, object.classification, object.medium] .filter(Boolean) .join('. '); for (const movement of detectMovements(text)) { results.push({ type: 'movement', movement, period: { duringCreation: true }, notes: `Met Museum object metadata for ${object.title || paintingTitle}.`, source: 'The Metropolitan Museum of Art', source_url: object.objectURL || `https://www.metmuseum.org/art/collection/search/${objectId}`, discovered_via: 'metmuseum', confidence: 'discovered', }); } } catch { // skip } return results; } async function discoverInfluencesForWork({ artist, title, year }) { const artistName = normalizeArtist(artist); const chunks = await Promise.all([ discoverFromWikipedia(artistName, title), discoverFromWikidata(artistName), discoverFromMet(artistName, title), discoverFromWebSearch(artistName, title), ]); return uniqueByKey(chunks.flat(), (item) => item.type === 'movement' ? `movement:${item.movement}` : item.type === 'artist' ? `artist:${item.artist}` : `painting:${item.artist}:${item.title}` ).map((item) => ({ ...item, period: item.period || (year ? { duringCreation: true } : undefined), })); } module.exports = { discoverInfluencesForWork, discoverFromWikipedia, discoverFromWikidata, discoverFromWebSearch, discoverFromMet, SEARCH_SITES, };