mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
type VercelRequest = IncomingMessage & {
|
|
query: { [key: string]: string | string[] };
|
|
cookies: { [key: string]: string };
|
|
body: any;
|
|
};
|
|
|
|
type VercelResponse = ServerResponse & {
|
|
status: (code: number) => VercelResponse;
|
|
json: (data: any) => VercelResponse;
|
|
send: (body: string) => VercelResponse;
|
|
};
|
|
|
|
import { detectBot } from './botDetection/index';
|
|
|
|
interface PageData {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
url: string;
|
|
type: string;
|
|
}
|
|
|
|
async function getPageData(pathname: string, fullUrl: string): Promise<PageData> {
|
|
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 `
|
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
|
<meta property="og:url" content="${escapeHtml(url)}" />
|
|
<meta property="og:type" content="${type}" />
|
|
<meta property="og:site_name" content="ThrillWiki" />
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
|
<meta name="twitter:url" content="${escapeHtml(url)}" />
|
|
`.trim();
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
const map: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return text.replace(/[&<>"']/g, m => map[m]);
|
|
}
|
|
|
|
function injectOGTags(html: string, ogTags: string): string {
|
|
// Remove existing OG tags
|
|
html = html.replace(/<meta\s+(?:property|name)="(?:og:|twitter:)[^"]*"[^>]*>/gi, '');
|
|
|
|
// Inject new tags before </head>
|
|
const headEndIndex = html.indexOf('</head>');
|
|
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;
|
|
|
|
// Comprehensive bot detection with headers
|
|
const botDetection = detectBot(userAgent, req.headers as Record<string, string | string[] | undefined>);
|
|
|
|
// Enhanced logging with detection details
|
|
if (botDetection.isBot) {
|
|
console.log(`[SSR-OG] ✅ Bot detected: ${botDetection.platform || 'unknown'} | Confidence: ${botDetection.confidence} (${botDetection.score}%) | Method: ${botDetection.detectionMethod}`);
|
|
console.log(`[SSR-OG] Path: ${req.method} ${pathname}`);
|
|
console.log(`[SSR-OG] UA: ${userAgent}`);
|
|
if (botDetection.metadata.signals.length > 0) {
|
|
console.log(`[SSR-OG] Signals: ${botDetection.metadata.signals.slice(0, 5).join(', ')}${botDetection.metadata.signals.length > 5 ? '...' : ''}`);
|
|
}
|
|
} else {
|
|
// Log potential false negatives
|
|
if (botDetection.score > 30) {
|
|
console.warn(`[SSR-OG] ⚠️ Low confidence bot (${botDetection.score}%) - not serving SSR | ${req.method} ${pathname}`);
|
|
console.warn(`[SSR-OG] UA: ${userAgent}`);
|
|
console.warn(`[SSR-OG] Signals: ${botDetection.metadata.signals.join(', ')}`);
|
|
} else {
|
|
console.log(`[SSR-OG] Regular user (score: ${botDetection.score}%) | ${req.method} ${pathname}`);
|
|
}
|
|
}
|
|
|
|
// Read the built index.html
|
|
const htmlPath = join(process.cwd(), 'dist', 'index.html');
|
|
let html = readFileSync(htmlPath, 'utf-8');
|
|
|
|
if (botDetection.isBot) {
|
|
// Fetch page-specific data
|
|
const pageData = await getPageData(pathname, fullUrl);
|
|
console.log(`[SSR-OG] Generated OG tags: ${pageData.title}`);
|
|
|
|
// Generate and inject OG tags
|
|
const ogTags = generateOGTags(pageData);
|
|
html = injectOGTags(html, ogTags);
|
|
|
|
res.setHeader('X-Bot-Platform', botDetection.platform || 'unknown');
|
|
res.setHeader('X-Bot-Confidence', botDetection.confidence);
|
|
res.setHeader('X-Bot-Score', botDetection.score.toString());
|
|
res.setHeader('X-Bot-Method', botDetection.detectionMethod);
|
|
res.setHeader('X-SSR-Modified', 'true');
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|