Support artist and movement influence links with web discovery, a developer checkup table with gallery/detail thumbnails, and debug image search with fix-it workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
9.2 KiB
JavaScript
321 lines
9.2 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|