Add debug More/Clear/Upload tools for paintings and artist portraits.

Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 13:14:06 +03:00
co-authored by Cursor
parent b4425445bb
commit 0972b5df99
56 changed files with 1980 additions and 92 deletions
+226 -12
View File
@@ -1305,19 +1305,27 @@ function isLikelyImageUrl(url) {
return true;
}
function pickBestGoogleImageUrl(candidates) {
function scoreImageUrl(url) {
let score = 0;
if (/upload\.wikimedia\.org/i.test(url)) score += 40;
if (/googleusercontent\.com/i.test(url)) score += 30;
if (/\.(jpe?g|png|webp)(\?|$)/i.test(url)) score += 20;
if (/thumb|thumbnail|small|icon|logo|avatar/i.test(url)) score -= 25;
if (/=\s*[sS]\d{2,3}(-c|-rw)?(\||$)/.test(url) || /[?&]w=\d{2,3}(&|$)/.test(url)) score -= 15;
return score;
}
function rankImageCandidates(candidates, limit = 20) {
const unique = [...new Set(candidates.filter(isLikelyImageUrl))];
const scored = unique.map((url) => {
let score = 0;
if (/upload\.wikimedia\.org/i.test(url)) score += 40;
if (/googleusercontent\.com/i.test(url)) score += 30;
if (/\.(jpe?g|png|webp)(\?|$)/i.test(url)) score += 20;
if (/thumb|thumbnail|small|icon|logo|avatar/i.test(url)) score -= 25;
if (/=\s*[sS]\d{2,3}(-c|-rw)?(\||$)/.test(url) || /[?&]w=\d{2,3}(&|$)/.test(url)) score -= 15;
return { url, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.url || unique[0] || null;
return unique
.map((url) => ({ url, score: scoreImageUrl(url) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.url);
}
function pickBestGoogleImageUrl(candidates) {
return rankImageCandidates(candidates, 1)[0] || null;
}
function extractGoogleImageCandidates(html) {
@@ -1346,6 +1354,33 @@ function extractGoogleImageCandidates(html) {
return candidates;
}
async function searchGoogleCustomSearchImagesMany(query, limit = 20) {
const apiKey = process.env.GOOGLE_CSE_API_KEY;
const cx = process.env.GOOGLE_CSE_CX;
if (!apiKey || !cx) return [];
const out = [];
for (let start = 1; start <= 91 && out.length < limit; start += 10) {
const num = Math.min(10, limit - out.length);
const url =
`https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}` +
`&cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(query)}&searchType=image` +
`&num=${num}&start=${start}&safe=active`;
const data = await fetchJson(url);
for (const item of data?.items || []) {
if (!item?.link) continue;
out.push({
imageUrl: item.link,
thumbUrl: item.image?.thumbnailLink || item.link,
source: 'google-custom-search',
});
if (out.length >= limit) break;
}
if (!data?.items?.length) break;
}
return out;
}
async function searchGoogleCustomSearchImagesFirst(query) {
const apiKey = process.env.GOOGLE_CSE_API_KEY;
const cx = process.env.GOOGLE_CSE_CX;
@@ -1364,6 +1399,63 @@ async function searchGoogleCustomSearchImagesFirst(query) {
};
}
async function searchDuckDuckGoImagesMany(query, limit = 20) {
const html = await fetchHtml(
`https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
}
);
const vqd =
html.match(/vqd=['"]([^'"]+)['"]/)?.[1] ||
html.match(/vqd=([\d-]+)/)?.[1] ||
null;
if (!vqd) return [];
const out = [];
for (let page = 1; page <= 4 && out.length < limit; page++) {
await throttle();
const jsUrl = `https://duckduckgo.com/i.js?o=json&q=${encodeURIComponent(query)}&l=us-en&vqd=${encodeURIComponent(vqd)}&f=,,,&p=${page}`;
const payload = await new Promise((resolve, reject) => {
https
.get(
jsUrl,
{ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } },
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
}
)
.on('error', reject);
});
for (const item of payload?.results || []) {
const imageUrl = item?.image || item?.thumbnail;
if (!imageUrl) continue;
out.push({
imageUrl,
thumbUrl: item?.thumbnail || item?.image,
source: 'duckduckgo-images',
});
if (out.length >= limit) break;
}
if (!payload?.results?.length) break;
}
return out;
}
async function searchDuckDuckGoImagesFirst(query) {
const html = await fetchHtml(
`https://duckduckgo.com/?q=${encodeURIComponent(query)}&iar=images&iax=images&ia=images`,
@@ -1469,6 +1561,125 @@ async function searchGoogleImagesFirst(artistName, paintingTitle) {
return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' };
}
/** First Google-family portrait result for an artist (developer portrait audit). */
async function searchArtistPortraitFirst(artistName) {
const query = `${artistName} portrait`.trim();
const searchUrl =
`https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&hl=en&ijn=0`;
const custom = await searchGoogleCustomSearchImagesFirst(query).catch(() => null);
if (custom?.imageUrl) {
return { query, searchUrl, ...custom };
}
const gac = await getGoogleArtsCultureImages(artistName, artistName).catch(() => null);
if (gac?.fullUrl) {
return {
query,
imageUrl: gac.fullUrl,
thumbUrl: gac.thumbUrl,
searchUrl: `https://artsandculture.google.com/search?q=${encodeURIComponent(query)}`,
source: 'google-arts-culture',
sourceLabel: 'Google Arts & Culture',
};
}
try {
const html = await fetchHtml(searchUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
});
const imageUrl = pickBestGoogleImageUrl(extractGoogleImageCandidates(html));
if (imageUrl) {
return {
query,
imageUrl,
searchUrl,
source: 'google-images',
sourceLabel: 'Google Images',
};
}
} catch {
// continue to fallback
}
const ddg = await searchDuckDuckGoImagesFirst(query).catch(() => null);
if (ddg?.imageUrl) {
return { query, searchUrl, ...ddg };
}
return { query, imageUrl: null, searchUrl, source: 'google-images', sourceLabel: 'Google Images' };
}
const GOOGLE_ISCH_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
};
async function searchDebugImagesMany(query, limit = 20) {
const searchUrl =
`https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&hl=en&ijn=0`;
const merged = [];
const seen = new Set();
const push = (item) => {
const url = item?.imageUrl;
if (!url || !isLikelyImageUrl(url) || seen.has(url)) return;
seen.add(url);
merged.push({
imageUrl: url,
thumbUrl: item.thumbUrl || url,
source: item.source || 'image-search',
});
};
for (const item of await searchGoogleCustomSearchImagesMany(query, limit).catch(() => [])) {
push(item);
}
if (merged.length < limit) {
try {
const html = await fetchHtml(searchUrl, { headers: GOOGLE_ISCH_HEADERS });
for (const url of rankImageCandidates(extractGoogleImageCandidates(html), limit - merged.length)) {
push({ imageUrl: url, source: 'google-images' });
}
} catch {
// continue
}
}
if (merged.length < limit) {
for (const item of await searchDuckDuckGoImagesMany(query, limit - merged.length).catch(() => [])) {
push(item);
}
}
return {
query,
searchUrl,
results: merged.slice(0, limit),
source: 'mixed',
sourceLabel: 'Image search results',
};
}
async function searchPaintingImagesMany(artistName, paintingTitle, limit = 20) {
const simple = simplifyPaintingTitle(paintingTitle);
const query = `${artistName} ${simple} painting`.trim();
return searchDebugImagesMany(query, limit);
}
async function searchArtistPortraitMany(artistName, limit = 20) {
const query = `${artistName} portrait`.trim();
return searchDebugImagesMany(query, limit);
}
async function generateThumbnailFromFull(fullDest, thumbDest, width = THUMB_WIDTH) {
const sharp = require('sharp');
if (!fs.existsSync(fullDest)) return false;
@@ -1601,6 +1812,9 @@ module.exports = {
searchWikipediaTitle,
searchWebForPaintingImages,
searchGoogleImagesFirst,
searchArtistPortraitFirst,
searchPaintingImagesMany,
searchArtistPortraitMany,
fetchImageBuffer,
friendlyImageFetchError,
pickExt,
+25
View File
@@ -0,0 +1,25 @@
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-artist-checkup-flags.sql');
await pool.query(fs.readFileSync(sqlPath, 'utf8'));
await pool.query(
`UPDATE artists SET checkup_checked = true WHERE checkup_fixed = true AND NOT checkup_checked`
);
const { rows } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE checkup_checked)::int AS checked,
COUNT(*) FILTER (WHERE checkup_fixed)::int AS fixed
FROM artists
`);
console.log(`artist checkup flags ready (${rows[0].checked} checked, ${rows[0].fixed} fixed)`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});