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>
229 lines
7.1 KiB
JavaScript
229 lines
7.1 KiB
JavaScript
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);
|
|
});
|