mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -05:00
feat: Implement Vercel serverless OG injection
This commit is contained in:
262
api/ssrOG.ts
Normal file
262
api/ssrOG.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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<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;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user