mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:51:12 -05:00
305 lines
10 KiB
TypeScript
305 lines
10 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: unknown;
|
|
};
|
|
|
|
type VercelResponse = ServerResponse & {
|
|
status: (code: number) => VercelResponse;
|
|
json: (data: unknown) => VercelResponse;
|
|
send: (body: string) => VercelResponse;
|
|
};
|
|
|
|
import { detectBot } from './botDetection/index.js';
|
|
import { vercelLogger } from './utils/logger.js';
|
|
|
|
interface PageData {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
url: string;
|
|
type: string;
|
|
}
|
|
|
|
interface ParkData {
|
|
name: string;
|
|
description?: string;
|
|
banner_image_id?: string;
|
|
banner_image_url?: string;
|
|
location?: {
|
|
city: string;
|
|
country: string;
|
|
};
|
|
}
|
|
|
|
interface RideData {
|
|
name: string;
|
|
description?: string;
|
|
banner_image_id?: string;
|
|
banner_image_url?: string;
|
|
park?: {
|
|
name: string;
|
|
};
|
|
}
|
|
|
|
async function getPageData(pathname: string, fullUrl: string): Promise<PageData> {
|
|
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
|
|
const DEFAULT_FALLBACK_IMAGE = 'https://cdn.thrillwiki.com/images/4af6a0c6-4450-497d-772f-08da62274100/original';
|
|
|
|
// 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,location(city,country)`,
|
|
{
|
|
headers: {
|
|
'apikey': process.env.SUPABASE_ANON_KEY!,
|
|
'Authorization': `Bearer ${process.env.SUPABASE_ANON_KEY}`
|
|
}
|
|
}
|
|
);
|
|
|
|
if (response.ok) {
|
|
const data: unknown = await response.json();
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
const park = data[0] as ParkData;
|
|
const imageUrl = park.banner_image_url ||
|
|
(park.banner_image_id
|
|
? `https://cdn.thrillwiki.com/images/${park.banner_image_id}/original`
|
|
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
|
|
|
// Match client-side fallback logic
|
|
const description = park.description ??
|
|
(park.location
|
|
? `${park.name} - A theme park in ${park.location.city}, ${park.location.country}`
|
|
: `${park.name} - A theme park`);
|
|
|
|
return {
|
|
title: `${park.name} - ThrillWiki`,
|
|
description,
|
|
image: imageUrl,
|
|
url: fullUrl,
|
|
type: 'website'
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
vercelLogger.error('Error fetching park data', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
slug
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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,park(name)`,
|
|
{
|
|
headers: {
|
|
'apikey': process.env.SUPABASE_ANON_KEY!,
|
|
'Authorization': `Bearer ${process.env.SUPABASE_ANON_KEY}`
|
|
}
|
|
}
|
|
);
|
|
|
|
if (response.ok) {
|
|
const data: unknown = await response.json();
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
const ride = data[0] as RideData;
|
|
const imageUrl = ride.banner_image_url ||
|
|
(ride.banner_image_id
|
|
? `https://cdn.thrillwiki.com/images/${ride.banner_image_id}/original`
|
|
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
|
|
|
// Match client-side fallback logic
|
|
const description = ride.description ||
|
|
(ride.park?.name
|
|
? `${ride.name} - A thrilling ride at ${ride.park.name}`
|
|
: `${ride.name} - A thrilling ride`);
|
|
|
|
return {
|
|
title: `${ride.name} - ThrillWiki`,
|
|
description,
|
|
image: imageUrl,
|
|
url: fullUrl,
|
|
type: 'website'
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
vercelLogger.error('Error fetching ride data', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
slug: rideSlug
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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://cdn.thrillwiki.com/images/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://cdn.thrillwiki.com/images/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://cdn.thrillwiki.com/images/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): Promise<void> {
|
|
let pathname = '/';
|
|
|
|
try {
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
const fullUrl = `https://${req.headers.host}${req.url}`;
|
|
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) {
|
|
vercelLogger.info('Bot detected', {
|
|
platform: botDetection.platform || 'unknown',
|
|
confidence: botDetection.confidence,
|
|
score: botDetection.score,
|
|
method: botDetection.detectionMethod,
|
|
path: `${req.method} ${pathname}`,
|
|
userAgent,
|
|
signals: botDetection.metadata.signals.slice(0, 5)
|
|
});
|
|
} else {
|
|
// Log potential false negatives
|
|
if (botDetection.score > 30) {
|
|
vercelLogger.warn('Low confidence bot - not serving SSR', {
|
|
score: botDetection.score,
|
|
path: `${req.method} ${pathname}`,
|
|
userAgent,
|
|
signals: botDetection.metadata.signals
|
|
});
|
|
} else {
|
|
vercelLogger.info('Regular user request', {
|
|
score: botDetection.score,
|
|
path: `${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);
|
|
vercelLogger.info('Generated OG tags', {
|
|
title: pageData.title,
|
|
pathname
|
|
});
|
|
|
|
// 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) {
|
|
vercelLogger.error('SSR processing failed', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
pathname
|
|
});
|
|
|
|
// 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 {
|
|
res.status(500).send('Internal Server Error');
|
|
}
|
|
}
|
|
}
|