Fix option card readability and image loading
This commit is contained in:
79
server.js
79
server.js
@@ -28,6 +28,8 @@ const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
||||
: DEFAULT_PRICE_HISTORY_FILE;
|
||||
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
|
||||
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
|
||||
const PREVIEW_IMAGE_CACHE_MS = 1000 * 60 * 60 * 24;
|
||||
const previewImageCache = new Map();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@@ -84,6 +86,48 @@ function normalizeSourceLabel(value) {
|
||||
return String(value || 'Unknown source').trim() || 'Unknown source';
|
||||
}
|
||||
|
||||
function parseHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || '').trim());
|
||||
if (!['http:', 'https:'].includes(url.protocol)) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHtmlUrl(value, baseUrl) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const resolved = new URL(value, baseUrl);
|
||||
if (!['http:', 'https:'].includes(resolved.protocol)) return null;
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreviewImage(html, pageUrl) {
|
||||
const patterns = [
|
||||
/<meta[^>]+property=["']og:image:secure_url["'][^>]+content=["']([^"']+)["'][^>]*>/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:secure_url["'][^>]*>/i,
|
||||
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["'][^>]*>/i,
|
||||
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
|
||||
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["'][^>]*>/i,
|
||||
/<link[^>]+rel=["'][^"']*image_src[^"']*["'][^>]+href=["']([^"']+)["'][^>]*>/i,
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*image_src[^"']*["'][^>]*>/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
const imageUrl = resolveHtmlUrl(match?.[1], pageUrl);
|
||||
if (imageUrl) return imageUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCurrencyValue(value, currency = 'USD') {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
@@ -800,6 +844,41 @@ app.get('/api/options', (req, res) => {
|
||||
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
|
||||
});
|
||||
|
||||
app.get('/api/preview-image', async (req, res) => {
|
||||
const pageUrl = parseHttpUrl(req.query.url);
|
||||
if (!pageUrl) return res.status(400).send('A valid http(s) url is required');
|
||||
|
||||
const cacheKey = pageUrl.toString();
|
||||
const cached = previewImageCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.checkedAt < PREVIEW_IMAGE_CACHE_MS) {
|
||||
return res.redirect(302, cached.imageUrl);
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL('/favicon.ico', pageUrl.origin).toString();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const response = await fetch(pageUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; CaboVotePreview/1.0)',
|
||||
},
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) throw new Error(`Preview fetch failed: ${response.status}`);
|
||||
const html = await response.text();
|
||||
const imageUrl = extractPreviewImage(html.slice(0, 250000), pageUrl.toString()) || fallbackUrl;
|
||||
previewImageCache.set(cacheKey, { imageUrl, checkedAt: Date.now() });
|
||||
return res.redirect(302, imageUrl);
|
||||
} catch (error) {
|
||||
previewImageCache.set(cacheKey, { imageUrl: fallbackUrl, checkedAt: Date.now() });
|
||||
return res.redirect(302, fallbackUrl);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/results', (req, res) => {
|
||||
const priceHistoryState = loadPriceHistoryState();
|
||||
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
|
||||
|
||||
Reference in New Issue
Block a user