Add influence rework, image checkup, debug mode, and fetched paintings.
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>
This commit is contained in:
co-authored by
Cursor
parent
0ece1195fa
commit
bf7db9b25e
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user