diff --git a/frontend/index.html b/frontend/index.html index 2cac58a..019fda3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,6 +1,7 @@ + __SEO_TITLE__
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a1ba486..53d18a9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -356,6 +356,163 @@ function routeLabel(route) { return 'Home' } +function currentPublicOrigin() { + return window.location.origin.replace(/\/$/, '') +} + +function canonicalPathForRoute(route) { + if (route.page === 'team' && route.teamName) return teamPath(route.teamName) + if (route.page === 'teams') return '/teams' + if (route.page === 'battle-logs') return '/battle-logs' + if (route.page === 'uptime') return '/uptime' + if (route.page === 'viewers') return '/viewers' + if (route.page === 'privacy') return '/privacy' + return '/' +} + +function seoForRoute(route, profileDetail = null) { + const teamName = + route.page === 'team' + ? canonicalTeamName(profileDetail, route.teamName).trim() || route.teamName + : '' + const teamSummary = profileDetail?.team_summary || profileDetail?.squadron_summary || null + const teamPoints = Number(teamSummary?.points?.total_points || teamSummary?.total_points || 0) + const teamBattles = Number(teamSummary?.total_battles || 0) + + if (route.page === 'team' && teamName) { + const stats = [ + teamPoints ? `${formatNumber(teamPoints)} points` : '', + teamBattles ? `${formatNumber(teamBattles)} battles` : '', + ].filter(Boolean) + return { + title: `${teamName} TSS Team Profile | Toothless' TSS Bot`, + description: stats.length + ? `${teamName} TSS team profile with ${stats.join(', ')}, roster details, battle history, and recent War Thunder squadron activity.` + : `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`, + robots: 'index, follow', + path: canonicalPathForRoute({ ...route, teamName }), + } + } + + const byPage = { + teams: { + title: "TSS Team Leaderboard | Toothless' TSS Bot", + description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.', + robots: 'index, follow', + path: '/teams', + }, + 'battle-logs': { + title: "TSS Battle Logs | Toothless' TSS Bot", + description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.', + robots: 'index, follow', + path: '/battle-logs', + }, + uptime: { + title: "TSS Bot Uptime Status | Toothless' TSS Bot", + description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.', + robots: 'index, follow', + path: '/uptime', + }, + viewers: { + title: "Viewer Analytics | Toothless' TSS Bot", + description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.', + robots: 'noindex, follow', + path: '/viewers', + }, + privacy: { + title: "Privacy Notice | Toothless' TSS Bot", + description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.', + robots: 'index, follow', + path: '/privacy', + }, + } + + return byPage[route.page] || { + title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs", + description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.', + robots: 'index, follow', + path: '/', + } +} + +function upsertMeta(selector, createAttributes, valueAttribute, value) { + let element = document.head.querySelector(selector) + if (!element) { + element = document.createElement('meta') + Object.entries(createAttributes).forEach(([key, attributeValue]) => { + element.setAttribute(key, attributeValue) + }) + document.head.appendChild(element) + } + element.setAttribute(valueAttribute, value) +} + +function upsertLink(rel, href) { + let element = document.head.querySelector(`link[rel="${rel}"]`) + if (!element) { + element = document.createElement('link') + element.setAttribute('rel', rel) + document.head.appendChild(element) + } + element.setAttribute('href', href) +} + +function structuredDataForSeo(seo, canonicalUrl) { + const base = currentPublicOrigin() + const items = [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: "Toothless' TSS Bot", + url: `${base}/`, + description: "Live War Thunder TSS leaderboards, battle logs, and team profiles.", + potentialAction: { + '@type': 'SearchAction', + target: `${base}/teams/{search_term_string}`, + 'query-input': 'required name=search_term_string', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: seo.title, + url: canonicalUrl, + description: seo.description, + isPartOf: { + '@type': 'WebSite', + name: "Toothless' TSS Bot", + url: `${base}/`, + }, + }, + ] + + return JSON.stringify(items) +} + +function applySeo(route, profileDetail = null) { + const seo = seoForRoute(route, profileDetail) + const canonicalUrl = `${currentPublicOrigin()}${seo.path}` + + document.title = seo.title + upsertMeta('meta[name="description"]', { name: 'description' }, 'content', seo.description) + upsertMeta('meta[name="robots"]', { name: 'robots' }, 'content', seo.robots) + upsertMeta('meta[property="og:title"]', { property: 'og:title' }, 'content', seo.title) + upsertMeta('meta[property="og:description"]', { property: 'og:description' }, 'content', seo.description) + upsertMeta('meta[property="og:url"]', { property: 'og:url' }, 'content', canonicalUrl) + upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, 'content', seo.title) + upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, 'content', seo.description) + upsertLink('canonical', canonicalUrl) + + let structuredData = document.getElementById('site-structured-data') + if (!structuredData) { + structuredData = document.createElement('script') + structuredData.id = 'site-structured-data' + structuredData.type = 'application/ld+json' + document.head.appendChild(structuredData) + } + structuredData.textContent = structuredDataForSeo(seo, canonicalUrl) +} + async function fetchRecentTssGames(teams, signal) { const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12) @@ -748,23 +905,13 @@ function AppContent() { }, [route.page]) useEffect(() => { - const title = - route.page === 'team' && route.teamName - ? `${route.teamName} | Toothless' TSS Bot` - : route.page === 'teams' - ? "Team leaderboard | Toothless' TSS Bot" - : route.page === 'battle-logs' - ? "Battle Logs | Toothless' TSS Bot" - : route.page === 'uptime' - ? "Uptime | Toothless' TSS Bot" - : route.page === 'viewers' - ? "Viewers | Toothless' TSS Bot" - : route.page === 'privacy' - ? "Privacy notice | Toothless' TSS Bot" - : "Toothless' TSS Bot" - - document.title = title - }, [route.page, route.teamName]) + applySeo( + route, + route.page === 'team' && profile.detail.status === 'ready' + ? profile.detail.data + : null, + ) + }, [profile.detail.data, profile.detail.status, route]) useEffect(() => { if (!analyticsPreferences.analytics) return diff --git a/server.cjs b/server.cjs index 571d401..73c2517 100644 --- a/server.cjs +++ b/server.cjs @@ -1899,8 +1899,138 @@ function pagePublicOrigin(req) { return `${String(proto).split(',')[0].trim()}://${String(host).split(',')[0].trim()}`.replace(/\/$/, '') } +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function decodeRouteSegment(value) { + try { + return decodeURIComponent(value || '').replace(/\+/g, ' ').trim() + } catch { + return '' + } +} + +function routeSeo(pathname) { + const cleanPath = pathname.replace(/\/+$/, '') || '/' + + if (cleanPath.startsWith('/teams/')) { + const teamName = decodeRouteSegment(cleanPath.slice('/teams/'.length)) + if (teamName) { + return { + title: `${teamName} TSS Team Profile | Toothless' TSS Bot`, + description: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`, + robots: 'index, follow', + path: `/teams/${encodeURIComponent(teamName)}`, + } + } + } + + const byPath = { + '/': { + title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs", + description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.', + robots: 'index, follow', + path: '/', + }, + '/teams': { + title: "TSS Team Leaderboard | Toothless' TSS Bot", + description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.', + robots: 'index, follow', + path: '/teams', + }, + '/battle-logs': { + title: "TSS Battle Logs | Toothless' TSS Bot", + description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.', + robots: 'index, follow', + path: '/battle-logs', + }, + '/live': { + title: "TSS Battle Logs | Toothless' TSS Bot", + description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.', + robots: 'index, follow', + path: '/battle-logs', + }, + '/uptime': { + title: "TSS Bot Uptime Status | Toothless' TSS Bot", + description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.', + robots: 'index, follow', + path: '/uptime', + }, + '/viewers': { + title: "Viewer Analytics | Toothless' TSS Bot", + description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.', + robots: 'noindex, follow', + path: '/viewers', + }, + '/privacy': { + title: "Privacy Notice | Toothless' TSS Bot", + description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.', + robots: 'index, follow', + path: '/privacy', + }, + } + + return byPath[cleanPath] || byPath['/'] +} + +function routeStructuredData(origin, seo, canonicalUrl) { + return JSON.stringify([ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: "Toothless' TSS Bot", + url: `${origin}/`, + description: 'Live War Thunder TSS leaderboards, battle logs, and team profiles.', + potentialAction: { + '@type': 'SearchAction', + target: `${origin}/teams/{search_term_string}`, + 'query-input': 'required name=search_term_string', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: seo.title, + url: canonicalUrl, + description: seo.description, + isPartOf: { + '@type': 'WebSite', + name: "Toothless' TSS Bot", + url: `${origin}/`, + }, + }, + ]) +} + +function htmlWithSeo(req, data) { + const origin = pagePublicOrigin(req) + let pathname = '/' + try { + pathname = new URL(req.url, origin).pathname + } catch { + pathname = '/' + } + + const seo = routeSeo(pathname) + const canonicalUrl = `${origin}${seo.path}` + + return data.toString('utf8') + .replaceAll('__PUBLIC_ORIGIN__', origin) + .replaceAll('__SEO_TITLE__', escapeHtml(seo.title)) + .replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description)) + .replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots)) + .replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl)) + .replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/ + +${urls.map((url) => ` + ${escapeHtml(`${origin}${url.path}`)} + ${today} + ${url.changefreq} + ${url.priority} + `).join('\n')} + +` + + send(res, 200, body, { + 'content-type': 'application/xml; charset=utf-8', + 'cache-control': 'public, max-age=3600', + }) +} + function serveStatic(req, res) { let requestPath = '/' try { @@ -1985,6 +2159,16 @@ function serveStatic(req, res) { } const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/robots.txt') { + sendRobotsTxt(req, res) + return + } + + if (req.method === 'GET' && req.url === '/sitemap.xml') { + sendSitemapXml(req, res) + return + } + if (req.url === '/health') { sendJson(res, 200, { ok: true }) return