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
+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();