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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1267
package-lock.json
generated
1267
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
16
vercel.json
16
vercel.json
@@ -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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user