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
+17 -4
View File
@@ -409,10 +409,23 @@ module.exports = [
source: 'The Story of Art',
},
{
work: { artist: 'Pablo Picasso', title: 'Les Demoiselles d\'Avignon' },
influencedBy: { artist: 'Henri Matisse', title: 'The Dance' },
notes: 'Picasso\'s Demoiselles responded to Matisse\'s Fauve primitivism with Iberian and African formal violence.',
aspects: 'primitivism, mask-like faces, color, rivalry',
work: { artist: 'Pablo Picasso', title: 'Les Demoiselles d\'Avignon', year: 1907 },
influencedBy: [
{ type: 'painting', artist: 'Henri Matisse', title: 'The Dance' },
{ type: 'painting', artist: 'Paul Cézanne', title: 'The Bathers' },
{
type: 'artist',
artist: 'Paul Cézanne',
period: { duringCreation: true },
},
{
type: 'movement',
movement: 'Fauvism',
period: { start: 1905, end: 1907, note: 'Paris avant-garde rivalry with Matisse' },
},
],
notes: 'Picasso\'s Demoiselles responded to Fauve primitivism while absorbing Cézanne\'s structural bathers and Iberian/African mask forms.',
aspects: 'primitivism, mask-like faces, structure, rivalry',
source_author: 'E. H. Gombrich',
source: 'The Story of Art',
},
+191 -2
View File
@@ -1173,14 +1173,199 @@ async function resolveImageUrl(wikiTitle, options = {}) {
return { url: pair.fullUrl, source: pair.source };
}
async function downloadImageToFile(url, destPath) {
if (fs.existsSync(destPath)) return destPath;
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 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 fetchImageBuffer(url) {
await throttle();
return withRetry(() => fetchBuffer(url));
}
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
const sharp = require('sharp');
if (!fs.existsSync(fullDest)) return false;
@@ -1311,8 +1496,12 @@ module.exports = {
generateThumbnailFromFull,
searchWikipediaTitle,
searchWebForPaintingImages,
searchGoogleImagesFirst,
fetchImageBuffer,
pickExt,
getGoogleArtsCultureImages,
simplifyPaintingTitle,
fetchHtml,
DEFAULT_BATCH_MAX_WAIT_MS,
FetchDeadline,
sleep,
+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,
};
+307
View File
@@ -0,0 +1,307 @@
const ARTIST_ALIASES = {
'Camille Corot': 'Jean-Baptiste-Camille Corot',
};
function normalizeTitle(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function normalizeArtist(name) {
return ARTIST_ALIASES[name] || name;
}
function scoreTitleMatch(dbTitle, hint) {
const normDb = normalizeTitle(dbTitle);
const keywords = normalizeTitle(hint).split(' ').filter((w) => w.length > 2);
if (!keywords.length) return 0;
const hits = keywords.filter((k) => normDb.includes(k)).length;
const required = keywords.length === 1 ? 1 : Math.min(2, keywords.length);
return hits >= required ? hits : 0;
}
function normalizeInfluencedBy(ref) {
if (!ref) return [];
if (Array.isArray(ref)) return ref;
return [{ type: 'painting', ...ref }];
}
function periodFields(ref, workYear) {
const period = ref.period || {};
let start = period.start ?? period.startYear ?? null;
let end = period.end ?? period.endYear ?? null;
let note = period.note || null;
if (period.duringCreation && workYear != null) {
start = start ?? workYear - 3;
end = end ?? workYear + 1;
note = note || `around the creation of the work (${workYear})`;
}
return { period_start_year: start, period_end_year: end, period_note: note };
}
async function loadMovements(pool) {
const { rows } = await pool.query('SELECT id, name FROM art_movements');
return new Map(rows.map((r) => [r.name.toLowerCase(), r.id]));
}
async function findArtist(pool, name) {
const canonical = normalizeArtist(name);
const { rows } = await pool.query(
`SELECT id, name FROM artists WHERE name = $1 OR name ILIKE $2 LIMIT 1`,
[canonical, canonical]
);
return rows[0] || null;
}
async function ensureArtist(pool, ref, movementsByName) {
const name = normalizeArtist(ref.artist);
let artist = await findArtist(pool, name);
if (artist) return artist.id;
const meta = ref.artistMeta;
if (!meta) return null;
const movementId = meta.movement
? movementsByName.get(meta.movement.toLowerCase()) || null
: null;
const century =
meta.century || (meta.birth_year ? Math.floor(meta.birth_year / 100) * 100 : null);
const insert = await pool.query(
`INSERT INTO artists (name, birth_year, death_year, movement_id, wikipedia_title, century, portrait_path)
VALUES ($1, $2, $3, $4, $5, $6, NULL)
RETURNING id, name`,
[
name,
meta.birth_year ?? null,
meta.death_year ?? null,
movementId,
meta.wikipedia_title || name,
century,
]
);
console.log(`+ artist: ${name}`);
return insert.rows[0].id;
}
async function findPainting(pool, artistId, titleHint) {
const { rows } = await pool.query(
'SELECT id, title, year FROM paintings WHERE artist_id = $1',
[artistId]
);
let best = null;
let bestScore = 0;
for (const row of rows) {
const score = scoreTitleMatch(row.title, titleHint);
if (score > bestScore) {
bestScore = score;
best = row;
}
}
return best;
}
async function ensurePainting(pool, artistId, ref, artistName, fetchImages, imageDir, savePaintingImages) {
const existing = await findPainting(pool, artistId, ref.title);
if (existing) return existing;
const sortRes = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM paintings WHERE artist_id = $1',
[artistId]
);
const wikiTitle = ref.wikipedia_title || ref.title;
const insert = await pool.query(
`INSERT INTO paintings (artist_id, title, year, wikipedia_title, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[artistId, ref.title, ref.year ?? null, wikiTitle, sortRes.rows[0].next]
);
console.log(`+ painting: ${artistName}${ref.title}`);
if (fetchImages && savePaintingImages) {
try {
const base = `${artistName}_${ref.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
const saved = await savePaintingImages(wikiTitle, base, imageDir, {
artistName,
paintingTitle: ref.title,
});
if (saved.imagePath || saved.thumbnailPath) {
await pool.query(
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
[saved.imagePath, saved.thumbnailPath, insert.rows[0].id]
);
}
} catch (err) {
console.warn(` image fetch failed: ${artistName}${ref.title}: ${err.message}`);
}
}
return insert.rows[0];
}
async function resolveMovement(pool, movementName, movementsByName) {
if (!movementName) return null;
const id = movementsByName.get(movementName.toLowerCase());
if (id) return id;
const { rows } = await pool.query(
`SELECT id FROM art_movements WHERE name ILIKE $1 LIMIT 1`,
[movementName]
);
return rows[0]?.id ?? null;
}
async function resolvePaintingRef(pool, ref, movementsByName, fetchImages, imageDir, savePaintingImages) {
const artistName = normalizeArtist(ref.artist);
const artistId = await ensureArtist(pool, ref, movementsByName);
if (!artistId) return null;
return ensurePainting(pool, artistId, ref, artistName, fetchImages, imageDir, savePaintingImages);
}
async function resolveWorkRef(pool, workRef, movementsByName, fetchImages, imageDir, savePaintingImages) {
const artistName = normalizeArtist(workRef.artist);
const artistId = await ensureArtist(pool, workRef, movementsByName);
if (!artistId) return null;
const painting = await ensurePainting(
pool,
artistId,
workRef,
artistName,
fetchImages,
imageDir,
savePaintingImages
);
if (!painting) return null;
return { id: painting.id, year: painting.year ?? workRef.year ?? null, artist_id: artistId };
}
async function insertLegacyPaintingEdge(pool, workId, sourcePaintingId, edge) {
await pool.query(
`INSERT INTO painting_influences
(painting_id, influenced_by_painting_id, notes, source, aspects, quote, source_author, source_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (painting_id, influenced_by_painting_id) DO NOTHING`,
[
workId,
sourcePaintingId,
edge.notes || null,
edge.source || null,
edge.aspects || null,
edge.quote || null,
edge.source_author || null,
edge.source_url || null,
]
);
}
async function insertInfluenceSource(pool, workId, sourceType, sourceIds, edge, workYear) {
const period = periodFields(edge, workYear);
const result = await pool.query(
`INSERT INTO painting_influence_sources (
painting_id, source_type, source_painting_id, source_artist_id, source_movement_id,
period_note, period_start_year, period_end_year,
notes, source, aspects, quote, source_author, source_url, discovered_via, confidence
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT DO NOTHING
RETURNING id`,
[
workId,
sourceType,
sourceIds.source_painting_id ?? null,
sourceIds.source_artist_id ?? null,
sourceIds.source_movement_id ?? null,
period.period_note,
period.period_start_year,
period.period_end_year,
edge.notes || null,
edge.source || null,
edge.aspects || null,
edge.quote || null,
edge.source_author || null,
edge.source_url || null,
edge.discovered_via || null,
edge.confidence || 'curated',
]
);
return result.rowCount > 0;
}
async function resolveAndInsertSource(
pool,
work,
sourceRef,
edge,
movementsByName,
fetchImages,
imageDir,
savePaintingImages
) {
const type = (sourceRef.type || 'painting').toLowerCase();
const mergedEdge = { ...edge, ...sourceRef, type };
if (type === 'painting') {
const sourcePainting = await resolvePaintingRef(
pool,
sourceRef,
movementsByName,
fetchImages,
imageDir,
savePaintingImages
);
if (!sourcePainting) return { ok: false, reason: 'unresolved painting' };
if (sourcePainting.id === work.id) return { ok: false, reason: 'self' };
await insertLegacyPaintingEdge(pool, work.id, sourcePainting.id, mergedEdge);
const inserted = await insertInfluenceSource(
pool,
work.id,
'painting',
{ source_painting_id: sourcePainting.id },
mergedEdge,
work.year
);
return { ok: true, inserted, label: `${sourceRef.artist} / ${sourceRef.title}` };
}
if (type === 'artist') {
const artistId = await ensureArtist(pool, sourceRef, movementsByName);
if (!artistId) return { ok: false, reason: 'unresolved artist' };
if (artistId === work.artist_id) return { ok: false, reason: 'self artist' };
const inserted = await insertInfluenceSource(
pool,
work.id,
'artist',
{ source_artist_id: artistId },
mergedEdge,
work.year
);
return { ok: true, inserted, label: sourceRef.artist };
}
if (type === 'movement') {
const movementId = await resolveMovement(pool, sourceRef.movement, movementsByName);
if (!movementId) return { ok: false, reason: `unresolved movement: ${sourceRef.movement}` };
const inserted = await insertInfluenceSource(
pool,
work.id,
'movement',
{ source_movement_id: movementId },
mergedEdge,
work.year
);
return { ok: true, inserted, label: sourceRef.movement };
}
return { ok: false, reason: `unknown type: ${type}` };
}
module.exports = {
normalizeArtist,
normalizeInfluencedBy,
loadMovements,
resolveWorkRef,
resolveAndInsertSource,
findArtist,
findPainting,
resolveMovement,
periodFields,
};
+18
View File
@@ -0,0 +1,18 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
async function main() {
const sqlPath = path.join(__dirname, '../db/migrate-influence-sources.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await pool.query(sql);
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM painting_influence_sources');
console.log(`painting_influence_sources ready (${rows[0].n} rows)`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+143 -178
View File
@@ -3,208 +3,173 @@ const path = require('path');
const pool = require('../server/db');
const INFLUENCES = require('./art-influences-data');
const { savePaintingImages } = require('./image-fetcher');
const { discoverInfluencesForWork } = require('./influence-discovery');
const {
normalizeInfluencedBy,
loadMovements,
resolveWorkRef,
resolveAndInsertSource,
} = require('./influence-resolver');
const FETCH_IMAGES = process.argv.includes('--fetch-images');
const DISCOVER = process.argv.includes('--discover');
const DISCOVER_ONLY = process.argv.includes('--discover-only');
const LIMIT = parseInt(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10);
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || path.join(__dirname, '../data/images'));
const ARTIST_ALIASES = {
'Camille Corot': 'Jean-Baptiste-Camille Corot',
};
function normalizeTitle(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function normalizeArtist(name) {
return ARTIST_ALIASES[name] || name;
}
function scoreTitleMatch(dbTitle, hint) {
const normDb = normalizeTitle(dbTitle);
const keywords = normalizeTitle(hint).split(' ').filter((w) => w.length > 2);
if (!keywords.length) return 0;
const hits = keywords.filter((k) => normDb.includes(k)).length;
const required = keywords.length === 1 ? 1 : Math.min(2, keywords.length);
return hits >= required ? hits : 0;
}
async function loadMovements() {
const { rows } = await pool.query('SELECT id, name FROM art_movements');
const byName = new Map(rows.map((r) => [r.name.toLowerCase(), r.id]));
return byName;
}
async function findArtist(name) {
const canonical = normalizeArtist(name);
const { rows } = await pool.query(
`SELECT id, name FROM artists WHERE name = $1 OR name ILIKE $2 LIMIT 1`,
[canonical, canonical]
async function applyEdge(edge, movementsByName, stats) {
const work = await resolveWorkRef(
pool,
edge.work,
movementsByName,
FETCH_IMAGES,
IMAGE_DIR,
savePaintingImages
);
return rows[0] || null;
}
async function ensureArtist(ref, movementsByName) {
const name = normalizeArtist(ref.artist);
let artist = await findArtist(name);
if (artist) return artist.id;
const meta = ref.artistMeta;
if (!meta) return null;
const movementId = meta.movement
? movementsByName.get(meta.movement.toLowerCase()) || null
: null;
const century =
meta.century ||
(meta.birth_year ? Math.floor(meta.birth_year / 100) * 100 : null);
const insert = await pool.query(
`INSERT INTO artists (name, birth_year, death_year, movement_id, wikipedia_title, century, portrait_path)
VALUES ($1, $2, $3, $4, $5, $6, NULL)
RETURNING id, name`,
[
name,
meta.birth_year ?? null,
meta.death_year ?? null,
movementId,
meta.wikipedia_title || name,
century,
]
);
console.log(`+ artist: ${name}`);
return insert.rows[0].id;
}
async function findPainting(artistId, titleHint) {
const { rows } = await pool.query(
'SELECT id, title FROM paintings WHERE artist_id = $1',
[artistId]
);
let best = null;
let bestScore = 0;
for (const row of rows) {
const score = scoreTitleMatch(row.title, titleHint);
if (score > bestScore) {
bestScore = score;
best = row;
}
if (!work) {
stats.failed += 1;
console.warn(`✗ unresolved work: ${edge.work.artist} / ${edge.work.title}`);
return;
}
return best;
}
async function ensurePainting(artistId, ref, artistName) {
const existing = await findPainting(artistId, ref.title);
if (existing) return existing.id;
const sortRes = await pool.query(
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM paintings WHERE artist_id = $1',
[artistId]
);
const wikiTitle = ref.wikipedia_title || ref.title;
const insert = await pool.query(
`INSERT INTO paintings (artist_id, title, year, wikipedia_title, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[artistId, ref.title, ref.year ?? null, wikiTitle, sortRes.rows[0].next]
);
console.log(`+ painting: ${artistName}${ref.title}`);
if (FETCH_IMAGES) {
try {
const base = `${artistName}_${ref.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
const saved = await savePaintingImages(wikiTitle, base, IMAGE_DIR, {
artistName,
paintingTitle: ref.title,
});
if (saved.imagePath || saved.thumbnailPath) {
await pool.query(
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
[saved.imagePath, saved.thumbnailPath, insert.rows[0].id]
const sources = normalizeInfluencedBy(edge.influencedBy);
for (const sourceRef of sources) {
const result = await resolveAndInsertSource(
pool,
work,
sourceRef,
edge,
movementsByName,
FETCH_IMAGES,
IMAGE_DIR,
savePaintingImages
);
if (!result.ok) {
if (result.reason !== 'self' && result.reason !== 'self artist') {
stats.failed += 1;
console.warn(
`${edge.work.artist} / ${edge.work.title} <- ${sourceRef.type || 'painting'} ${result.reason}`
);
} else {
stats.skipped += 1;
}
} catch (err) {
console.warn(` image fetch failed: ${artistName}${ref.title}: ${err.message}`);
continue;
}
if (result.inserted) {
stats.added += 1;
console.log(`${edge.work.artist} / ${edge.work.title} ← [${sourceRef.type || 'painting'}] ${result.label}`);
} else {
stats.skipped += 1;
}
}
return insert.rows[0].id;
if (DISCOVER || DISCOVER_ONLY) {
const discovered = await discoverInfluencesForWork({
artist: edge.work.artist,
title: edge.work.title,
year: edge.year ?? edge.work.year ?? work.year,
});
for (const sourceRef of discovered) {
const result = await resolveAndInsertSource(
pool,
work,
sourceRef,
sourceRef,
movementsByName,
false,
IMAGE_DIR,
null
);
if (result.inserted) {
stats.discovered += 1;
console.log(`~ discovered ${edge.work.artist} / ${edge.work.title} ← [${sourceRef.type}] ${result.label}`);
}
}
}
}
async function resolvePaintingRef(ref, movementsByName) {
const artistName = normalizeArtist(ref.artist);
const artistId = await ensureArtist(ref, movementsByName);
if (!artistId) return null;
return ensurePainting(artistId, ref, artistName);
}
async function insertInfluence(workId, sourceId, edge) {
const result = await pool.query(
`INSERT INTO painting_influences
(painting_id, influenced_by_painting_id, notes, source, aspects, quote, source_author, source_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (painting_id, influenced_by_painting_id) DO NOTHING
RETURNING id`,
[
workId,
sourceId,
edge.notes || null,
edge.source || null,
edge.aspects || null,
edge.quote || null,
edge.source_author || null,
edge.source_url || null,
]
async function discoverCatalog(limit) {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
ORDER BY a.name, p.sort_order, p.year NULLS LAST`
);
return result.rowCount > 0;
const targets = limit > 0 ? rows.slice(0, limit) : rows;
const movementsByName = await loadMovements(pool);
let discovered = 0;
for (const row of targets) {
const refs = await discoverInfluencesForWork({
artist: row.artist_name,
title: row.title,
year: row.year,
});
for (const sourceRef of refs) {
const result = await resolveAndInsertSource(
pool,
{ id: row.id, year: row.year, artist_id: null },
sourceRef,
sourceRef,
movementsByName,
false,
IMAGE_DIR,
null
);
if (result.inserted) {
discovered += 1;
console.log(`~ discovered ${row.artist_name} / ${row.title} ← [${sourceRef.type}] ${result.label}`);
}
}
}
return discovered;
}
async function main() {
const movementsByName = await loadMovements();
let added = 0;
let skipped = 0;
let failed = 0;
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'painting_influence_sources'
) AS ok
`);
if (!tableCheck.rows[0]?.ok) {
console.error('Run npm run migrate:influence-sources before update-influences.');
process.exit(1);
}
for (const edge of INFLUENCES) {
try {
const workId = await resolvePaintingRef(edge.work, movementsByName);
const sourceId = await resolvePaintingRef(edge.influencedBy, movementsByName);
const movementsByName = await loadMovements(pool);
const stats = { added: 0, skipped: 0, failed: 0, discovered: 0 };
if (!workId || !sourceId) {
failed += 1;
console.warn(
`✗ unresolved: ${edge.work.artist} / ${edge.work.title} <- ${edge.influencedBy?.artist} / ${edge.influencedBy?.title}`
);
continue;
}
if (workId === sourceId) {
skipped += 1;
continue;
}
const inserted = await insertInfluence(workId, sourceId, edge);
if (inserted) {
added += 1;
console.log(`${edge.work.artist} / ${edge.work.title}${edge.influencedBy.artist} / ${edge.influencedBy.title}`);
} else {
skipped += 1;
}
} catch (err) {
failed += 1;
console.warn(`${edge.work?.artist}: ${err.message}`);
if (!DISCOVER_ONLY) {
const edges = LIMIT > 0 ? INFLUENCES.slice(0, LIMIT) : INFLUENCES;
for (const edge of edges) {
await applyEdge(edge, movementsByName, stats);
}
}
const total = await pool.query('SELECT COUNT(*)::int AS n FROM painting_influences');
const connected = await pool.query(`
SELECT COUNT(DISTINCT a.id)::int AS n
FROM artists a
JOIN paintings p ON p.artist_id = a.id
JOIN painting_influences pi ON pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
`);
if (DISCOVER_ONLY) {
stats.discovered += await discoverCatalog(LIMIT);
}
console.log(`\nDone: ${added} added, ${skipped} skipped (duplicate/self), ${failed} failed`);
console.log(`Total influence edges: ${total.rows[0].n}`);
const [totalSources, totalLegacy, connected] = await Promise.all([
pool.query('SELECT COUNT(*)::int AS n FROM painting_influence_sources'),
pool.query('SELECT COUNT(*)::int AS n FROM painting_influences'),
pool.query(`
SELECT COUNT(DISTINCT a.id)::int AS n
FROM artists a
JOIN paintings p ON p.artist_id = a.id
LEFT JOIN painting_influence_sources pis ON pis.painting_id = p.id
LEFT JOIN painting_influences pi ON pi.painting_id = p.id OR pi.influenced_by_painting_id = p.id
WHERE pis.id IS NOT NULL OR pi.id IS NOT NULL
`),
]);
console.log(
`\nDone: ${stats.added} curated added, ${stats.discovered} discovered, ${stats.skipped} skipped, ${stats.failed} failed`
);
console.log(`Total influence sources: ${totalSources.rows[0].n} (legacy painting edges: ${totalLegacy.rows[0].n})`);
console.log(`Artists connected to influence graph: ${connected.rows[0].n}`);
await pool.end();