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:
co-authored by
Cursor
parent
b4425445bb
commit
0972b5df99
+226
-12
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user