import { VercelRequest, VercelResponse } from '@vercel/node'; import { readFileSync } from 'fs'; import { join } from 'path'; // Bot detection configuration const SOCIAL_BOTS = { 'facebookexternalhit': 'facebook', 'facebot': 'facebook', 'facebookcatalog': 'facebook', 'twitterbot': 'twitter', 'x-bot': 'twitter', 'linkedinbot': 'linkedin', 'discordbot': 'discord', 'slackbot': 'slack', 'slack-imgproxy': 'slack', 'whatsapp': 'whatsapp', 'telegrambot': 'telegram', 'pinterestbot': 'pinterest', 'redditbot': 'reddit', 'apple-pcs': 'imessage', 'mastodon': 'mastodon', 'ms-teams': 'teams', 'googlebot': 'google', 'bingbot': 'bing', 'slurp': 'yahoo', 'duckduckbot': 'duckduckgo', 'baiduspider': 'baidu', 'yandexbot': 'yandex' }; interface BotDetection { isBot: boolean; platform: string | null; } function detectBot(userAgent: string): BotDetection { if (!userAgent) { return { isBot: false, platform: null }; } const ua = userAgent.toLowerCase(); for (const [pattern, platform] of Object.entries(SOCIAL_BOTS)) { if (ua.includes(pattern)) { return { isBot: true, platform }; } } return { isBot: false, platform: null }; } interface PageData { title: string; description: string; image: string; url: string; type: string; } async function getPageData(pathname: string, fullUrl: string): Promise { const normalizedPath = pathname.replace(/\/+$/, '') || '/'; // Individual park page: /parks/{slug} if (normalizedPath.startsWith('/parks/') && normalizedPath.split('/').length === 3) { const slug = normalizedPath.split('/')[2]; try { const response = await fetch( `${process.env.SUPABASE_URL}/rest/v1/parks?slug=eq.${slug}&select=name,description,banner_image_id,banner_image_url`, { headers: { 'apikey': process.env.SUPABASE_ANON_KEY!, 'Authorization': `Bearer ${process.env.SUPABASE_ANON_KEY}` } } ); if (response.ok) { const data = await response.json(); if (data && data.length > 0) { const park = data[0]; const imageUrl = park.banner_image_url || (park.banner_image_id ? `https://imagedelivery.net/${process.env.CLOUDFLARE_ACCOUNT_HASH}/${park.banner_image_id}/original` : process.env.DEFAULT_OG_IMAGE); return { title: `${park.name} - ThrillWiki`, description: park.description || `Discover ${park.name} on ThrillWiki`, image: imageUrl, url: fullUrl, type: 'website' }; } } } catch (error) { console.error(`[SSR-OG] Error fetching park data: ${error}`); } } // Individual ride page: /parks/{park-slug}/rides/{ride-slug} if (normalizedPath.match(/^\/parks\/[^\/]+\/rides\/[^\/]+$/)) { const parts = normalizedPath.split('/'); const rideSlug = parts[4]; try { const response = await fetch( `${process.env.SUPABASE_URL}/rest/v1/rides?slug=eq.${rideSlug}&select=name,description,banner_image_id,banner_image_url`, { headers: { 'apikey': process.env.SUPABASE_ANON_KEY!, 'Authorization': `Bearer ${process.env.SUPABASE_ANON_KEY}` } } ); if (response.ok) { const data = await response.json(); if (data && data.length > 0) { const ride = data[0]; const imageUrl = ride.banner_image_url || (ride.banner_image_id ? `https://imagedelivery.net/${process.env.CLOUDFLARE_ACCOUNT_HASH}/${ride.banner_image_id}/original` : process.env.DEFAULT_OG_IMAGE); return { title: `${ride.name} - ThrillWiki`, description: ride.description || `Discover ${ride.name} on ThrillWiki`, image: imageUrl, url: fullUrl, type: 'website' }; } } } catch (error) { console.error(`[SSR-OG] Error fetching ride data: ${error}`); } } // Parks listing if (normalizedPath === '/parks' || normalizedPath === '/parks/') { return { title: 'Theme Parks - ThrillWiki', description: 'Browse theme parks and amusement parks from around the world', image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original', url: fullUrl, type: 'website' }; } // Rides listing if (normalizedPath === '/rides' || normalizedPath === '/rides/') { return { title: 'Roller Coasters & Rides - ThrillWiki', description: 'Explore roller coasters and theme park rides from around the world', image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original', url: fullUrl, type: 'website' }; } // Default fallback return { title: 'ThrillWiki - Theme Park & Roller Coaster Database', description: 'Explore theme parks and roller coasters worldwide with ThrillWiki', image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original', url: fullUrl, type: 'website' }; } function generateOGTags(pageData: PageData): string { const { title, description, image, url, type } = pageData; return ` `.trim(); } function escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } function injectOGTags(html: string, ogTags: string): string { // Remove existing OG tags html = html.replace(/]*>/gi, ''); // Inject new tags before const headEndIndex = html.indexOf(''); if (headEndIndex !== -1) { return html.slice(0, headEndIndex) + ogTags + '\n' + html.slice(headEndIndex); } return html; } export default async function handler(req: VercelRequest, res: VercelResponse) { try { const userAgent = req.headers['user-agent'] || ''; const fullUrl = `https://${req.headers.host}${req.url}`; const pathname = new URL(fullUrl).pathname; console.log(`[SSR-OG] ${req.method} ${pathname} | UA: ${userAgent.substring(0, 60)}`); // Bot detection const botDetection = detectBot(userAgent); // Read the built index.html const htmlPath = join(process.cwd(), 'dist', 'index.html'); let html = readFileSync(htmlPath, 'utf-8'); if (botDetection.isBot) { console.log(`[SSR-OG] Bot detected: ${botDetection.platform}`); // Fetch page-specific data const pageData = await getPageData(pathname, fullUrl); // Generate and inject OG tags const ogTags = generateOGTags(pageData); html = injectOGTags(html, ogTags); res.setHeader('X-Bot-Platform', botDetection.platform || 'unknown'); res.setHeader('X-SSR-Modified', 'true'); } else { console.log('[SSR-OG] Regular user - serving original HTML'); } res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=300'); res.status(200).send(html); } catch (error) { console.error('[SSR-OG] Error:', error); // Fallback: serve original HTML try { const htmlPath = join(process.cwd(), 'dist', 'index.html'); const html = readFileSync(htmlPath, 'utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.status(200).send(html); } catch (fallbackError) { res.status(500).send('Internal Server Error'); } } }