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__
-
+
+
+
+
+
-
-
+
+
@@ -32,16 +40,18 @@
-
+
- Toothless' TSS Bot
+
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