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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user