Add movement gallery wings with period interiors and expand timeline features.
Movement galleries split large catalogs into chronological wings (~55 works), use era-themed 3D interiors with side-wall windows, wing navigator on the back exit, and front archways between wings. Also adds painting annotations, timeline event guides, portrait hover highlights, and documentation/API updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
0972b5df99
commit
df29848d89
@@ -1373,6 +1373,8 @@ async function searchGoogleCustomSearchImagesMany(query, limit = 20) {
|
||||
imageUrl: item.link,
|
||||
thumbUrl: item.image?.thumbnailLink || item.link,
|
||||
source: 'google-custom-search',
|
||||
width: item.image?.width,
|
||||
height: item.image?.height,
|
||||
});
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
@@ -1448,6 +1450,8 @@ async function searchDuckDuckGoImagesMany(query, limit = 20) {
|
||||
imageUrl,
|
||||
thumbUrl: item?.thumbnail || item?.image,
|
||||
source: 'duckduckgo-images',
|
||||
width: item?.width,
|
||||
height: item?.height,
|
||||
});
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
@@ -1636,6 +1640,8 @@ async function searchDebugImagesMany(query, limit = 20) {
|
||||
imageUrl: url,
|
||||
thumbUrl: item.thumbUrl || url,
|
||||
source: item.source || 'image-search',
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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-painting-annotations.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await pool.query(sql);
|
||||
const { rows } = await pool.query(`SELECT COUNT(*)::int AS total FROM painting_annotations`);
|
||||
console.log(`painting_annotations ready (${rows[0].total} rows)`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Curated art-history annotations for painting detail overlays.
|
||||
* Each entry: { artist, title, annotations: [{ label, body, category, pos_x?, pos_y?, source_* }] }
|
||||
*/
|
||||
module.exports = [
|
||||
{
|
||||
artist: 'Leonardo da Vinci',
|
||||
title: 'Mona Lisa',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Sfumato',
|
||||
body: 'Leonardo dissolves hard edges into smoky transitions, letting light model the face without outline.',
|
||||
category: 'technique',
|
||||
pos_x: 48,
|
||||
pos_y: 38,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Landscape',
|
||||
body: 'The winding paths and hazy mountains behind the sitter use aerial perspective to push space into depth.',
|
||||
category: 'composition',
|
||||
pos_x: 72,
|
||||
pos_y: 68,
|
||||
source_author: 'National Gallery',
|
||||
source: 'Leonardo da Vinci: The Mona Lisa',
|
||||
source_url: 'https://www.nationalgallery.org.uk/paintings/leonardo-da-vinci-the-mona-lisa',
|
||||
},
|
||||
{
|
||||
label: 'Enigmatic smile',
|
||||
body: 'The mouth’s subtle turn became a touchstone for Renaissance discussions of inner life and portraiture.',
|
||||
category: 'subject',
|
||||
pos_x: 52,
|
||||
pos_y: 52,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Leonardo da Vinci',
|
||||
title: 'The Last Supper',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Perspective',
|
||||
body: 'Orthogonal lines converge on Christ’s head, anchoring the drama in a mathematically ordered refectory.',
|
||||
category: 'composition',
|
||||
pos_x: 50,
|
||||
pos_y: 42,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Gesture',
|
||||
body: 'Each apostle reacts with a distinct bodily pose, turning the moment of betrayal into readable human psychology.',
|
||||
category: 'subject',
|
||||
pos_x: 28,
|
||||
pos_y: 55,
|
||||
source_author: 'H. W. Janson',
|
||||
source: 'History of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Michelangelo',
|
||||
title: 'The Creation of Adam',
|
||||
annotations: [
|
||||
{
|
||||
label: 'The spark',
|
||||
body: 'God and Adam reach across a gap barely bridged by touching fingers—the instant before life is given.',
|
||||
category: 'symbolism',
|
||||
pos_x: 62,
|
||||
pos_y: 48,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Anatomy',
|
||||
body: 'Adam’s body echoes antique sculpture while God’s billowing mantle suggests the brain’s form in Renaissance thought.',
|
||||
category: 'technique',
|
||||
pos_x: 38,
|
||||
pos_y: 58,
|
||||
source_author: 'Michelangelo: Divine Draftsman and Designer',
|
||||
source: 'The Metropolitan Museum of Art',
|
||||
source_url: 'https://www.metmuseum.org/exhibitions/listings/2017/michelangelo',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Raphael',
|
||||
title: 'The School of Athens',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Architecture',
|
||||
body: 'A grand Bramante-like vault frames philosophers as actors on a classical stage of reason.',
|
||||
category: 'composition',
|
||||
pos_x: 50,
|
||||
pos_y: 22,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Plato & Aristotle',
|
||||
body: 'Plato points upward to ideals; Aristotle gestures earthward—two paths of knowledge in dialogue.',
|
||||
category: 'symbolism',
|
||||
pos_x: 54,
|
||||
pos_y: 52,
|
||||
source_author: 'Stanford Encyclopedia of Philosophy',
|
||||
source: 'Plato and Aristotle in Raphael\'s School of Athens',
|
||||
source_url: 'https://plato.stanford.edu/entries/plato/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Vincent van Gogh',
|
||||
title: 'The Starry Night',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Swirling sky',
|
||||
body: 'Thick, rhythmic strokes turn the night into an animated force rather than a static backdrop.',
|
||||
category: 'technique',
|
||||
pos_x: 50,
|
||||
pos_y: 28,
|
||||
source_author: 'Museum of Modern Art',
|
||||
source: 'Vincent van Gogh: The Starry Night',
|
||||
source_url: 'https://www.moma.org/collection/works/79802',
|
||||
},
|
||||
{
|
||||
label: 'Cypress',
|
||||
body: 'The dark flame-like tree links earth and heaven, a traditional symbol of mourning and eternity.',
|
||||
category: 'symbolism',
|
||||
pos_x: 22,
|
||||
pos_y: 62,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Village',
|
||||
body: 'The quiet hamlet below contrasts with the turbulent heavens, suggesting inner turmoil against outward calm.',
|
||||
category: 'subject',
|
||||
pos_x: 48,
|
||||
pos_y: 78,
|
||||
source_author: 'Van Gogh Museum',
|
||||
source: 'Collection catalogue',
|
||||
source_url: 'https://www.vangoghmuseum.nl/en/collection/s0176V1962',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Rembrandt',
|
||||
title: 'The Night Watch',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Chiaroscuro',
|
||||
body: 'A sharp light picks out faces and weapons from deep shadow, heightening the militia’s forward motion.',
|
||||
category: 'technique',
|
||||
pos_x: 42,
|
||||
pos_y: 45,
|
||||
source_author: 'Rijksmuseum',
|
||||
source: 'The Night Watch',
|
||||
source_url: 'https://www.rijksmuseum.nl/en/collection/SK-C-5',
|
||||
},
|
||||
{
|
||||
label: 'Group portrait',
|
||||
body: 'Rembrandt breaks the static row format—figures overlap and move, turning a commission into narrative action.',
|
||||
category: 'composition',
|
||||
pos_x: 58,
|
||||
pos_y: 55,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Vermeer',
|
||||
title: 'Girl with a Pearl Earring',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Pearl',
|
||||
body: 'A single highlight on the earring demonstrates Vermeer’s control of light on a tiny reflective surface.',
|
||||
category: 'technique',
|
||||
pos_x: 58,
|
||||
pos_y: 52,
|
||||
source_author: 'Mauritshuis',
|
||||
source: 'Girl with a Pearl Earring',
|
||||
source_url: 'https://www.mauritshuis.nl/en/our-collection/artworks/670-girl-with-a-pearl-earring/',
|
||||
},
|
||||
{
|
||||
label: 'Turned glance',
|
||||
body: 'The sitter looks over her shoulder as if interrupted—a intimate moment rare in formal Dutch portraiture.',
|
||||
category: 'subject',
|
||||
pos_x: 46,
|
||||
pos_y: 38,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Diego Velázquez',
|
||||
title: 'Las Meninas',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Mirror',
|
||||
body: 'The reflected king and queen place the viewer where royalty would stand, collapsing court and spectator.',
|
||||
category: 'symbolism',
|
||||
pos_x: 72,
|
||||
pos_y: 42,
|
||||
source_author: 'Museo del Prado',
|
||||
source: 'Las Meninas',
|
||||
source_url: 'https://www.museodelprado.es/en/the-collection/art-work/las-meninas',
|
||||
},
|
||||
{
|
||||
label: 'Painter',
|
||||
body: 'Velázquez inserts himself at the easel, making painting itself the subject of a royal chamber scene.',
|
||||
category: 'history',
|
||||
pos_x: 18,
|
||||
pos_y: 48,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Pablo Picasso',
|
||||
title: 'Guernica',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Monochrome',
|
||||
body: 'Grey, black, and white evoke newspaper reportage, tying the mural to the bombing’s documentary shock.',
|
||||
category: 'technique',
|
||||
pos_x: 50,
|
||||
pos_y: 35,
|
||||
source_author: 'Museo Reina Sofía',
|
||||
source: 'Guernica',
|
||||
source_url: 'https://www.museoreinasofia.es/en/collection/artwork/guernica',
|
||||
},
|
||||
{
|
||||
label: 'Broken forms',
|
||||
body: 'Cubist fragmentation shatters bodies and tools into signs of suffering rather than readable illusion.',
|
||||
category: 'composition',
|
||||
pos_x: 35,
|
||||
pos_y: 58,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Bull & horse',
|
||||
body: 'Spanish symbols of brutality and the people collide in a tableau Picasso designed as political testimony.',
|
||||
category: 'symbolism',
|
||||
pos_x: 68,
|
||||
pos_y: 62,
|
||||
source_author: 'H. W. Janson',
|
||||
source: 'History of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Claude Monet',
|
||||
title: 'Impression, Sunrise',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Loose brushwork',
|
||||
body: 'Quick strokes suggest the harbor rather than describing it—giving a critic the name “Impressionism.”',
|
||||
category: 'technique',
|
||||
pos_x: 55,
|
||||
pos_y: 40,
|
||||
source_author: 'Musée Marmottan Monet',
|
||||
source: 'Impression, Sunrise',
|
||||
source_url: 'https://www.marmottan.fr/en/collections/impression-sunrise',
|
||||
},
|
||||
{
|
||||
label: 'Orange sun',
|
||||
body: 'The sun’s reflection on water becomes the focal note against a cool, industrial Le Havre skyline.',
|
||||
category: 'composition',
|
||||
pos_x: 38,
|
||||
pos_y: 32,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Jan van Eyck',
|
||||
title: 'The Arnolfini Portrait',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Mirror',
|
||||
body: 'The convex mirror reflects two additional figures—often read as witnesses to a marital contract.',
|
||||
category: 'symbolism',
|
||||
pos_x: 78,
|
||||
pos_y: 38,
|
||||
source_author: 'National Gallery',
|
||||
source: 'The Arnolfini Portrait',
|
||||
source_url: 'https://www.nationalgallery.org.uk/paintings/jan-van-eyck-the-arnolfini-portrait',
|
||||
},
|
||||
{
|
||||
label: 'Oil detail',
|
||||
body: 'Van Eyck layers transparent glazes to render textures from fur to brass with microscopic clarity.',
|
||||
category: 'technique',
|
||||
pos_x: 45,
|
||||
pos_y: 72,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Sandro Botticelli',
|
||||
title: 'The Birth of Venus',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Classical myth',
|
||||
body: 'Venus arrives on a shell, reviving antique pagan beauty for Medici Florence.',
|
||||
category: 'subject',
|
||||
pos_x: 52,
|
||||
pos_y: 48,
|
||||
source_author: 'Uffizi Galleries',
|
||||
source: 'Birth of Venus',
|
||||
source_url: 'https://www.uffizi.it/en/artworks/birth-of-venus',
|
||||
},
|
||||
{
|
||||
label: 'Linear grace',
|
||||
body: 'Flowing outlines and floating figures prioritize decorative rhythm over solid Renaissance volume.',
|
||||
category: 'composition',
|
||||
pos_x: 30,
|
||||
pos_y: 55,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Andrei Rublev',
|
||||
title: 'Trinity',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Icon symmetry',
|
||||
body: 'Three angels share a single table in near-perfect balance, expressing theological unity through form.',
|
||||
category: 'composition',
|
||||
pos_x: 50,
|
||||
pos_y: 50,
|
||||
source_author: 'Tretyakov Gallery',
|
||||
source: 'The Trinity by Andrei Rublev',
|
||||
source_url: 'https://www.tretyakovgallery.ru/en/collection/_show/image/_id/208',
|
||||
},
|
||||
{
|
||||
label: 'Reverse perspective',
|
||||
body: 'Lines subtly open toward the viewer, inviting contemplation into the sacred space rather than away from it.',
|
||||
category: 'technique',
|
||||
pos_x: 50,
|
||||
pos_y: 72,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Edvard Munch',
|
||||
title: 'The Scream',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Wavy horizon',
|
||||
body: 'Undulating lines transmit inner anxiety into the landscape itself—a key Symbolist device.',
|
||||
category: 'technique',
|
||||
pos_x: 50,
|
||||
pos_y: 30,
|
||||
source_author: 'Munch Museum',
|
||||
source: 'The Scream',
|
||||
source_url: 'https://www.munchmuseet.no/en/the-scream/',
|
||||
},
|
||||
{
|
||||
label: 'Open mouth',
|
||||
body: 'The figure’s silent shriek became an emblem of modern alienation in fin-de-siècle Europe.',
|
||||
category: 'symbolism',
|
||||
pos_x: 48,
|
||||
pos_y: 42,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Salvador Dalí',
|
||||
title: 'The Persistence of Memory',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Soft watches',
|
||||
body: 'Liquid clocks parody rigid time, linking Surrealist dream logic to Einstein’s relativity in popular imagination.',
|
||||
category: 'symbolism',
|
||||
pos_x: 42,
|
||||
pos_y: 62,
|
||||
source_author: 'Museum of Modern Art',
|
||||
source: 'The Persistence of Memory',
|
||||
source_url: 'https://www.moma.org/collection/works/79018',
|
||||
},
|
||||
{
|
||||
label: 'Dead landscape',
|
||||
body: 'The barren Catalonian coast stretches behind the table, grounding impossible objects in precise illusionism.',
|
||||
category: 'composition',
|
||||
pos_x: 55,
|
||||
pos_y: 28,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
artist: 'Caravaggio',
|
||||
title: 'The Calling of Saint Matthew',
|
||||
annotations: [
|
||||
{
|
||||
label: 'Tenebrism',
|
||||
body: 'A beam of light cuts the tavern darkness to isolate Christ’s gesture and Matthew’s hesitation.',
|
||||
category: 'technique',
|
||||
pos_x: 62,
|
||||
pos_y: 38,
|
||||
source_author: 'E. H. Gombrich',
|
||||
source: 'The Story of Art',
|
||||
},
|
||||
{
|
||||
label: 'Everyday setting',
|
||||
body: 'Sacred drama unfolds among ordinary tax collectors, a Counter-Reformation strategy to make faith immediate.',
|
||||
category: 'history',
|
||||
pos_x: 40,
|
||||
pos_y: 58,
|
||||
source_author: 'San Luigi dei Francesi',
|
||||
source: 'Contarelli Chapel cycle',
|
||||
source_url: 'https://www.sanluigideifrancesi.it/en/contarelli-chapel/',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,228 @@
|
||||
require('dotenv').config();
|
||||
const https = require('https');
|
||||
const pool = require('../server/db');
|
||||
const ANNOTATIONS = require('./painting-annotations-data');
|
||||
const { findArtist, findPainting } = require('./influence-resolver');
|
||||
|
||||
const FROM_WIKIPEDIA = process.argv.includes('--wikipedia');
|
||||
const REPLACE = !process.argv.includes('--no-replace');
|
||||
const WIKI_DELAY_MS = parseInt(process.argv.find((a) => a.startsWith('--wiki-delay='))?.split('=')[1] || '1200', 10);
|
||||
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function fetchJson(url, attempt = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', async () => {
|
||||
if (res.statusCode === 429 && attempt < 4) {
|
||||
const wait = 1500 * (attempt + 1);
|
||||
console.warn(`Wikipedia rate limit; retrying in ${wait}ms…`);
|
||||
await sleep(wait);
|
||||
fetchJson(url, attempt + 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 80)}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from Wikipedia: ${data.slice(0, 80)}`));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function wikiTitleKey(title) {
|
||||
return String(title || '').toLowerCase().replace(/ /g, '_');
|
||||
}
|
||||
|
||||
function extractIntroFromPage(page) {
|
||||
const extract = page?.extract?.trim();
|
||||
if (!extract || page?.missing) return null;
|
||||
const sentence = extract.split(/(?<=[.!?])\s+/).find((s) => s.length > 40);
|
||||
return sentence || extract.slice(0, 280);
|
||||
}
|
||||
|
||||
async function fetchWikipediaIntro(wikipediaTitle) {
|
||||
if (!wikipediaTitle) return null;
|
||||
const url =
|
||||
`https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(wikipediaTitle)}` +
|
||||
'&prop=extracts&explaintext=1&exintro=1&format=json';
|
||||
try {
|
||||
const data = await fetchJson(url);
|
||||
const page = Object.values(data.query?.pages || {})[0];
|
||||
return extractIntroFromPage(page);
|
||||
} catch (err) {
|
||||
console.warn(`✗ Wikipedia fetch failed for "${wikipediaTitle}": ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWikipediaIntroBatch(titles) {
|
||||
if (!titles.length) return new Map();
|
||||
const url =
|
||||
`https://en.wikipedia.org/w/api.php?action=query&titles=${titles.map(encodeURIComponent).join('|')}` +
|
||||
'&prop=extracts&explaintext=1&exintro=1&format=json';
|
||||
const data = await fetchJson(url);
|
||||
const out = new Map();
|
||||
for (const page of Object.values(data.query?.pages || {})) {
|
||||
if (!page?.title) continue;
|
||||
const body = extractIntroFromPage(page);
|
||||
if (body) out.set(wikiTitleKey(page.title), body);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function insertAnnotation(paintingId, row, sortOrder) {
|
||||
await pool.query(
|
||||
`INSERT INTO painting_annotations
|
||||
(painting_id, label, body, category, pos_x, pos_y, source_author, source, source_url, sort_order, confidence)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
paintingId,
|
||||
row.label || null,
|
||||
row.body,
|
||||
row.category || 'subject',
|
||||
row.pos_x ?? null,
|
||||
row.pos_y ?? null,
|
||||
row.source_author || null,
|
||||
row.source || null,
|
||||
row.source_url || null,
|
||||
sortOrder,
|
||||
row.confidence || 'curated',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearCurated(paintingId) {
|
||||
await pool.query(
|
||||
`DELETE FROM painting_annotations WHERE painting_id = $1 AND confidence = 'curated'`,
|
||||
[paintingId]
|
||||
);
|
||||
}
|
||||
|
||||
async function applyCuratedEntry(entry, stats) {
|
||||
const artist = await findArtist(pool, entry.artist);
|
||||
if (!artist) {
|
||||
stats.missing += 1;
|
||||
console.warn(`✗ artist not found: ${entry.artist}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const painting = await findPainting(pool, artist.id, entry.title);
|
||||
if (!painting) {
|
||||
stats.missing += 1;
|
||||
console.warn(`✗ painting not found: ${entry.artist} / ${entry.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (REPLACE) {
|
||||
await clearCurated(painting.id);
|
||||
}
|
||||
|
||||
let order = 0;
|
||||
for (const ann of entry.annotations || []) {
|
||||
await insertAnnotation(painting.id, ann, order++);
|
||||
stats.added += 1;
|
||||
}
|
||||
console.log(`→ ${entry.artist} / ${painting.title}: ${entry.annotations.length} annotation(s)`);
|
||||
}
|
||||
|
||||
async function applyWikipediaFallback(stats) {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT p.id, p.title, p.wikipedia_title, a.name AS artist_name
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
WHERE p.wikipedia_title IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM painting_annotations pa WHERE pa.painting_id = p.id)
|
||||
ORDER BY p.id
|
||||
`);
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
const batch = rows.slice(i, i + BATCH_SIZE);
|
||||
let extracts = new Map();
|
||||
try {
|
||||
extracts = await fetchWikipediaIntroBatch(batch.map((r) => r.wikipedia_title));
|
||||
} catch (err) {
|
||||
console.warn(`✗ Wikipedia batch failed (${i + 1}-${i + batch.length}): ${err.message}`);
|
||||
stats.wikiFailed += batch.length;
|
||||
await sleep(WIKI_DELAY_MS * 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const row of batch) {
|
||||
try {
|
||||
const body = extracts.get(wikiTitleKey(row.wikipedia_title)) || null;
|
||||
if (!body) {
|
||||
stats.wikiSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
await insertAnnotation(
|
||||
row.id,
|
||||
{
|
||||
label: 'Overview',
|
||||
body,
|
||||
category: 'subject',
|
||||
source_author: 'Wikipedia',
|
||||
source: row.wikipedia_title,
|
||||
source_url: `https://en.wikipedia.org/wiki/${encodeURIComponent(row.wikipedia_title.replace(/ /g, '_'))}`,
|
||||
confidence: 'discovered',
|
||||
},
|
||||
0
|
||||
);
|
||||
stats.wiki += 1;
|
||||
console.log(`+ wiki: ${row.artist_name} / ${row.title}`);
|
||||
} catch (err) {
|
||||
stats.wikiFailed += 1;
|
||||
console.warn(`✗ wiki insert failed for ${row.artist_name} / ${row.title}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (i + BATCH_SIZE < rows.length) {
|
||||
await sleep(WIKI_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stats = { added: 0, missing: 0, wiki: 0, wikiSkipped: 0, wikiFailed: 0 };
|
||||
|
||||
for (const entry of ANNOTATIONS) {
|
||||
await applyCuratedEntry(entry, stats);
|
||||
}
|
||||
|
||||
if (FROM_WIKIPEDIA) {
|
||||
await applyWikipediaFallback(stats);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT COUNT(*)::int AS total,
|
||||
COUNT(DISTINCT painting_id)::int AS paintings
|
||||
FROM painting_annotations
|
||||
`);
|
||||
console.log(
|
||||
`Done: ${stats.added} curated inserted, ${stats.missing} unmatched, ${stats.wiki} from Wikipedia` +
|
||||
(stats.wikiSkipped ? ` (${stats.wikiSkipped} skipped)` : '') +
|
||||
(stats.wikiFailed ? ` (${stats.wikiFailed} failed)` : '') +
|
||||
`; ${rows[0].total} total on ${rows[0].paintings} painting(s)`
|
||||
);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user