feat: Implement Vercel serverless OG injection

This commit is contained in:
gpt-engineer-app[bot]
2025-10-29 19:35:14 +00:00
parent af8da12c58
commit c85bdcb070
4 changed files with 1475 additions and 71 deletions

262
api/ssrOG.ts Normal file
View 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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
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');
}
}
}

1267
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@
"@uppy/status-bar": "^5.0.1", "@uppy/status-bar": "^5.0.1",
"@uppy/xhr-upload": "^5.0.1", "@uppy/xhr-upload": "^5.0.1",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@vercel/node": "^5.5.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

View File

@@ -1,8 +1,20 @@
{ {
"rewrites": [ "rewrites": [
{ {
"source": "/(.*)", "source": "/api/(.*)",
"destination": "/index.html" "destination": "/api/$1"
},
{
"source": "/(.*\\.(?:js|css|jpg|jpeg|png|gif|svg|ico|woff|woff2|ttf|eot|webp|json|xml))$",
"continue": true
},
{
"source": "/[^.]+",
"destination": "/api/ssrOG"
},
{
"source": "/",
"destination": "/api/ssrOG"
} }
], ],
"headers": [ "headers": [