Expand the gallery with prev/next browsing and fullscreen detail view, golden influence lamps and chronological wall layout in 3D halls, and scripts/docs for catalog expansion, influence edges, and multi-source image fetching. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
5.3 KiB
JavaScript
195 lines
5.3 KiB
JavaScript
require('dotenv').config();
|
|
const https = require('https');
|
|
const pool = require('../server/db');
|
|
|
|
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
|
|
const MIN_DELAY_MS = 3500;
|
|
const MAX_RETRIES = 8;
|
|
|
|
const ARTIST_WIKI_OVERRIDES = {
|
|
Zeuxis: 'Zeuxis (painter)',
|
|
'Ivan Klyun': 'Ivan Kliun',
|
|
'Jean-Antoine Watteau': 'Antoine Watteau',
|
|
};
|
|
|
|
let lastRequestTime = 0;
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function throttle() {
|
|
const elapsed = Date.now() - lastRequestTime;
|
|
if (elapsed < MIN_DELAY_MS) await sleep(MIN_DELAY_MS - elapsed);
|
|
lastRequestTime = Date.now();
|
|
}
|
|
|
|
function fetchJson(url) {
|
|
return new Promise((resolve, reject) => {
|
|
https
|
|
.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
return fetchJson(res.headers.location).then(resolve).catch(reject);
|
|
}
|
|
let data = '';
|
|
res.on('data', (c) => (data += c));
|
|
res.on('end', () => {
|
|
if (res.statusCode === 429) {
|
|
return reject(new Error('HTTP 429'));
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (e) {
|
|
reject(new Error(`Invalid JSON response (${data.slice(0, 40)}…)`));
|
|
}
|
|
});
|
|
})
|
|
.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function withRetry(fn) {
|
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (i === MAX_RETRIES - 1) throw err;
|
|
const wait = err.message.includes('429') || err.message.includes('Invalid JSON')
|
|
? 12000 * (i + 1)
|
|
: 4000 * (i + 1);
|
|
console.warn(` retry ${i + 1}/${MAX_RETRIES} in ${wait / 1000}s (${err.message})`);
|
|
await sleep(wait);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isDisambiguation(page, extract) {
|
|
if (page?.pageprops?.disambiguation !== undefined) return true;
|
|
if (!extract) return true;
|
|
return /^.+\s+may refer to:\s*$/m.test(extract.split('\n')[0]);
|
|
}
|
|
|
|
function firstSentences(text, count = 2) {
|
|
const parts = text.match(/[^.!?]+[.!?]+(?:\s|$)/g);
|
|
if (!parts?.length) return text.trim();
|
|
return parts.slice(0, count).join('').trim();
|
|
}
|
|
|
|
function uniqueTitles(candidates) {
|
|
const seen = new Set();
|
|
return candidates.filter((title) => {
|
|
if (!title || seen.has(title)) return false;
|
|
seen.add(title);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function candidateTitles(artist) {
|
|
const { name, wikipedia_title: wikiTitle } = artist;
|
|
return uniqueTitles([
|
|
ARTIST_WIKI_OVERRIDES[name],
|
|
ARTIST_WIKI_OVERRIDES[wikiTitle],
|
|
wikiTitle,
|
|
name,
|
|
`${name} (painter)`,
|
|
`${wikiTitle} (painter)`,
|
|
`${name} (artist)`,
|
|
`${wikiTitle} (artist)`,
|
|
]);
|
|
}
|
|
|
|
async function fetchPages(titles) {
|
|
if (!titles.length) return {};
|
|
return withRetry(async () => {
|
|
await throttle();
|
|
const params = new URLSearchParams({
|
|
action: 'query',
|
|
format: 'json',
|
|
prop: 'extracts|pageprops',
|
|
explaintext: '1',
|
|
exintro: '1',
|
|
ppprop: 'disambiguation',
|
|
titles: titles.join('|'),
|
|
});
|
|
const data = await fetchJson(`https://en.wikipedia.org/w/api.php?${params}`);
|
|
return data.query?.pages || {};
|
|
});
|
|
}
|
|
|
|
async function resolveArtistBio(artist) {
|
|
for (const title of candidateTitles(artist)) {
|
|
const pages = await fetchPages([title]);
|
|
const page = Object.values(pages)[0];
|
|
if (!page || page.missing !== undefined) continue;
|
|
|
|
const extract = page.extract?.trim();
|
|
if (!extract || isDisambiguation(page, extract)) continue;
|
|
|
|
return {
|
|
bio_short: firstSentences(extract, 2),
|
|
bio_full: extract,
|
|
wikipedia_title: page.title,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function needsBio(artist, force) {
|
|
if (force) return true;
|
|
if (!artist.bio_full) return true;
|
|
if (artist.bio_full.trim() === '') return true;
|
|
if (isDisambiguation(null, artist.bio_short || artist.bio_full)) return true;
|
|
return false;
|
|
}
|
|
|
|
async function main() {
|
|
const force = process.argv.includes('--force');
|
|
|
|
const { rows: artists } = await pool.query(`
|
|
SELECT id, name, wikipedia_title, bio_short, bio_full
|
|
FROM artists
|
|
ORDER BY name
|
|
`);
|
|
|
|
const targets = artists.filter((a) => needsBio(a, force));
|
|
console.log(`Artists total: ${artists.length}, to fetch: ${targets.length}${force ? ' (force)' : ''}`);
|
|
|
|
let updated = 0;
|
|
let failed = 0;
|
|
|
|
for (const artist of targets) {
|
|
try {
|
|
const bio = await resolveArtistBio(artist);
|
|
if (!bio) {
|
|
console.warn(`✗ ${artist.name} — no suitable Wikipedia article found`);
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
|
|
await pool.query(
|
|
`UPDATE artists
|
|
SET bio_short = $1, bio_full = $2, wikipedia_title = $3
|
|
WHERE id = $4`,
|
|
[bio.bio_short, bio.bio_full, bio.wikipedia_title, artist.id]
|
|
);
|
|
|
|
console.log(`✓ ${artist.name} (${bio.wikipedia_title}, ${bio.bio_full.length} chars)`);
|
|
updated += 1;
|
|
} catch (err) {
|
|
console.error(`✗ ${artist.name} — ${err.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone: ${updated} updated, ${failed} failed`);
|
|
await pool.end();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|