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
+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