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:
Danila Khodjaef
2026-06-20 10:29:43 +03:00
co-authored by Cursor
parent 0ece1195fa
commit bf7db9b25e
246 changed files with 2429 additions and 301 deletions
+320
View File
@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/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,
};