SEO changes
This commit is contained in:
+253
-32
@@ -66,6 +66,10 @@ const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
||||
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
||||
const SITE_SESSION_SECRET = process.env.SITE_SESSION_SECRET || process.env.API_SESSION_SECRET || TURNSTILE_SECRET_KEY
|
||||
const DIST_DIR = path.join(__dirname, 'dist')
|
||||
const SEO_IMAGE_PATH = '/embed.png'
|
||||
const INDEX_ROBOTS = 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1'
|
||||
const NOINDEX_ROBOTS = 'noindex, follow'
|
||||
const BLOG_POSTS_DIR = path.join(__dirname, 'frontend', 'src', 'blog', 'posts')
|
||||
const VEHICLE_ICONS_DIR = path.resolve(
|
||||
__dirname,
|
||||
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
|
||||
@@ -2369,6 +2373,80 @@ function decodeRouteSegment(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function parseBlogFrontMatter(raw) {
|
||||
const source = String(raw || '')
|
||||
const match = source.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
|
||||
if (!match) return { attributes: {}, content: source }
|
||||
|
||||
const attributes = {}
|
||||
match[1].split('\n').forEach((line) => {
|
||||
const separator = line.indexOf(':')
|
||||
if (separator === -1) return
|
||||
const key = line.slice(0, separator).trim()
|
||||
const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')
|
||||
if (key) attributes[key] = value
|
||||
})
|
||||
|
||||
return { attributes, content: match[2] }
|
||||
}
|
||||
|
||||
function excerptFromMarkdown(markdown) {
|
||||
return String(markdown || '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^#{1,6}\s+/, '').trim())
|
||||
.find((line) => line && !line.startsWith('![') && !line.startsWith('>')) || ''
|
||||
}
|
||||
|
||||
function blogPosts() {
|
||||
let entries = []
|
||||
try {
|
||||
entries = fs.readdirSync(BLOG_POSTS_DIR, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
||||
.map((entry) => {
|
||||
const filePath = path.join(BLOG_POSTS_DIR, entry.name)
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
const { attributes, content } = parseBlogFrontMatter(raw)
|
||||
const filename = entry.name.replace(/\.md$/i, '')
|
||||
const slug = slugify(attributes.slug || filename)
|
||||
const date = attributes.date || ''
|
||||
const timestamp = date ? Date.parse(date) : 0
|
||||
return {
|
||||
author: attributes.author || '',
|
||||
date,
|
||||
excerpt: attributes.excerpt || excerptFromMarkdown(content),
|
||||
path: `/blog/${encodeURIComponent(slug)}`,
|
||||
slug,
|
||||
timestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
||||
title: attributes.title || filename.replace(/[-_]+/g, ' ') || 'Untitled post',
|
||||
}
|
||||
})
|
||||
.filter((post) => post.slug && post.title)
|
||||
.sort((a, b) => b.timestamp - a.timestamp || a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
function blogPostBySlug(slug) {
|
||||
return blogPosts().find((post) => post.slug === slug) || null
|
||||
}
|
||||
|
||||
function openGraphType(seo) {
|
||||
return seo.type === 'BlogPosting' ? 'article' : 'website'
|
||||
}
|
||||
|
||||
function routeSeo(pathname) {
|
||||
const cleanPath = pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
@@ -2376,10 +2454,11 @@ function routeSeo(pathname) {
|
||||
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',
|
||||
title: `${teamName} War Thunder TSS Team Profile | Toothless' TSS Bot`,
|
||||
description: `${teamName} War Thunder TSS team profile with roster details, battle history, and recent squadron activity.`,
|
||||
robots: INDEX_ROBOTS,
|
||||
path: `/teams/${encodeURIComponent(teamName)}`,
|
||||
type: 'ProfilePage',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2389,67 +2468,140 @@ function routeSeo(pathname) {
|
||||
if (uid) {
|
||||
return {
|
||||
title: `Player ${uid} | Toothless' TSS Bot`,
|
||||
description: `TSS career stats for player ${uid} — battles, win rate, kills, and teams seen with.`,
|
||||
robots: 'noindex, follow',
|
||||
description: `War Thunder TSS career stats for player ${uid}: battles, win rate, kills, and teams seen with.`,
|
||||
robots: NOINDEX_ROBOTS,
|
||||
path: `/players/${encodeURIComponent(uid)}`,
|
||||
type: 'ProfilePage',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanPath.startsWith('/blog/')) {
|
||||
const slug = slugify(decodeRouteSegment(cleanPath.slice('/blog/'.length)))
|
||||
const post = blogPostBySlug(slug)
|
||||
if (slug) {
|
||||
return {
|
||||
title: `${post?.title || 'Blog post'} | Toothless' TSS Bot`,
|
||||
description: post?.excerpt || 'News, feature updates, and War Thunder TSS Bot announcements from Toothless TSS Bot.',
|
||||
robots: post ? INDEX_ROBOTS : NOINDEX_ROBOTS,
|
||||
path: `/blog/${encodeURIComponent(slug)}`,
|
||||
type: 'BlogPosting',
|
||||
publishedAt: post?.date || '',
|
||||
author: post?.author || "Toothless' TSS Bot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanPath.startsWith('/games/')) {
|
||||
const gameId = decodeRouteSegment(cleanPath.slice('/games/'.length))
|
||||
if (gameId) {
|
||||
return {
|
||||
title: `Game ${gameId} | Toothless' TSS Bot`,
|
||||
description: `War Thunder TSS battle log details for game ${gameId}, including participants, map, player counts, and combat stats.`,
|
||||
robots: NOINDEX_ROBOTS,
|
||||
path: `/games/${encodeURIComponent(gameId)}`,
|
||||
type: 'WebPage',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanPath.startsWith('/tournaments/')) {
|
||||
const tournamentId = decodeRouteSegment(cleanPath.slice('/tournaments/'.length))
|
||||
if (tournamentId) {
|
||||
return {
|
||||
title: `War Thunder TSS Tournament ${tournamentId} | Toothless' TSS Bot`,
|
||||
description: `War Thunder TSS tournament ${tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
||||
robots: INDEX_ROBOTS,
|
||||
path: `/tournaments/${encodeURIComponent(tournamentId)}`,
|
||||
type: 'CollectionPage',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
title: "War Thunder TSS Leaderboards, Teams and Battle Logs | Toothless' TSS Bot",
|
||||
description: 'Track War Thunder Tournament Service teams, live TSS leaderboards, squadron profiles, battle logs, player stats, and tournament brackets.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/',
|
||||
type: 'WebApplication',
|
||||
},
|
||||
'/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',
|
||||
title: "War Thunder TSS Team Leaderboard | Toothless' TSS Bot",
|
||||
description: 'Browse the live War Thunder TSS team leaderboard with squadron rankings, points, players, win rates, battles, and recent activity.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/teams',
|
||||
type: 'CollectionPage',
|
||||
},
|
||||
'/players': {
|
||||
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
||||
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
||||
robots: 'index, follow',
|
||||
title: "War Thunder TSS Player Leaderboard | Toothless' TSS Bot",
|
||||
description: 'Browse the War Thunder TSS player leaderboard and compare player score, kills, assists, win rate, KDR, teams, and battle activity.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/players',
|
||||
type: 'CollectionPage',
|
||||
},
|
||||
'/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',
|
||||
title: "War Thunder TSS Battle Logs | Toothless' TSS Bot",
|
||||
description: 'Read recent War Thunder TSS battle logs with teams, maps, player counts, battle times, replays, and match context.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/battle-logs',
|
||||
type: 'CollectionPage',
|
||||
},
|
||||
'/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',
|
||||
title: "War Thunder TSS Battle Logs | Toothless' TSS Bot",
|
||||
description: 'Read recent War Thunder TSS battle logs with teams, maps, player counts, battle times, replays, and match context.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/battle-logs',
|
||||
type: 'CollectionPage',
|
||||
},
|
||||
'/tournaments': {
|
||||
title: 'War Thunder TSS Tournaments | Brackets and Standings',
|
||||
description: "Browse War Thunder TSS tournaments with brackets, standings, teams, matches, and replay links tracked by Toothless' TSS Bot.",
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/tournaments',
|
||||
type: 'CollectionPage',
|
||||
},
|
||||
'/blog': {
|
||||
title: "War Thunder TSS Bot Blog | Toothless' TSS Bot",
|
||||
description: 'News, feature updates, tournament tracking notes, and War Thunder TSS Bot announcements from Toothless TSS Bot.',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/blog',
|
||||
type: 'Blog',
|
||||
},
|
||||
'/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',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/uptime',
|
||||
type: 'WebPage',
|
||||
},
|
||||
'/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',
|
||||
robots: NOINDEX_ROBOTS,
|
||||
path: '/viewers',
|
||||
type: 'WebPage',
|
||||
},
|
||||
'/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',
|
||||
robots: INDEX_ROBOTS,
|
||||
path: '/privacy',
|
||||
type: 'WebPage',
|
||||
},
|
||||
'/docs': {
|
||||
title: "Docs | Toothless' TSS Bot",
|
||||
description: 'Documentation and command reference for Toothless TSS Bot.',
|
||||
robots: 'noindex, follow',
|
||||
robots: NOINDEX_ROBOTS,
|
||||
path: '/docs',
|
||||
type: 'WebPage',
|
||||
},
|
||||
'/settings': {
|
||||
title: "Settings | Toothless' TSS Bot",
|
||||
description: 'Customize layout and appearance preferences for Toothless TSS Bot, including custom theme accent colors.',
|
||||
robots: 'noindex, nofollow',
|
||||
path: '/settings',
|
||||
type: 'WebPage',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2457,13 +2609,18 @@ function routeSeo(pathname) {
|
||||
}
|
||||
|
||||
function routeStructuredData(origin, seo, canonicalUrl) {
|
||||
return JSON.stringify([
|
||||
const siteId = `${origin}/#website`
|
||||
const pageId = `${canonicalUrl}#webpage`
|
||||
const imageUrl = `${origin}${SEO_IMAGE_PATH}`
|
||||
const items = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': siteId,
|
||||
name: "Toothless' TSS Bot",
|
||||
alternateName: ['TSS Bot', 'War Thunder TSS Bot', 'Toothless TSS Bot'],
|
||||
url: `${origin}/`,
|
||||
description: 'Live War Thunder TSS leaderboards, battle logs, and team profiles.',
|
||||
description: 'Live War Thunder Tournament Service leaderboards, battle logs, tournaments, and team profiles.',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${origin}/teams/{search_term_string}`,
|
||||
@@ -2472,17 +2629,65 @@ function routeStructuredData(origin, seo, canonicalUrl) {
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
'@type': seo.type || 'WebPage',
|
||||
'@id': pageId,
|
||||
name: seo.title,
|
||||
url: canonicalUrl,
|
||||
description: seo.description,
|
||||
image: imageUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: "Toothless' TSS Bot",
|
||||
url: `${origin}/`,
|
||||
'@id': siteId,
|
||||
},
|
||||
},
|
||||
])
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: `${origin}/`,
|
||||
},
|
||||
...(seo.path === '/' ? [] : [{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||
item: canonicalUrl,
|
||||
}]),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (seo.type === 'BlogPosting') {
|
||||
items.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: seo.title.replace(/\s+\|\s+Toothless' TSS Bot$/, ''),
|
||||
description: seo.description,
|
||||
url: canonicalUrl,
|
||||
image: imageUrl,
|
||||
datePublished: seo.publishedAt || undefined,
|
||||
dateModified: seo.publishedAt || undefined,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: seo.author || "Toothless' TSS Bot",
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: "Toothless' TSS Bot",
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: imageUrl,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@id': pageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return JSON.stringify(items)
|
||||
}
|
||||
|
||||
function htmlWithSeo(req, data) {
|
||||
@@ -2504,6 +2709,11 @@ function htmlWithSeo(req, data) {
|
||||
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description))
|
||||
.replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots))
|
||||
.replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl))
|
||||
.replaceAll('__SEO_OG_TYPE__', escapeHtml(openGraphType(seo)))
|
||||
.replaceAll('__SEO_IMAGE__', escapeHtml(`${origin}${SEO_IMAGE_PATH}`))
|
||||
.replaceAll('__SEO_AUTHOR__', escapeHtml(seo.author || "Toothless' TSS Bot"))
|
||||
.replaceAll('__SEO_PUBLISHED_TIME__', escapeHtml(seo.publishedAt || ''))
|
||||
.replaceAll('__SEO_MODIFIED_TIME__', escapeHtml(seo.publishedAt || ''))
|
||||
.replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/</g, '\\u003c'))
|
||||
.replaceAll('__TURNSTILE_SESSION__', isTurnstileSessionVerified(req) ? 'verified' : 'required')
|
||||
}
|
||||
@@ -2523,7 +2733,9 @@ function sendRobotsTxt(req, res) {
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'Disallow: /api/',
|
||||
'Disallow: /data/',
|
||||
'Disallow: /viewers',
|
||||
'Disallow: /settings',
|
||||
`Sitemap: ${origin}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -2540,15 +2752,24 @@ function sendSitemapXml(req, res) {
|
||||
const urls = [
|
||||
{ path: '/', priority: '1.0', changefreq: 'hourly' },
|
||||
{ path: '/teams', priority: '0.9', changefreq: 'hourly' },
|
||||
{ path: '/players', priority: '0.85', changefreq: 'hourly' },
|
||||
{ path: '/battle-logs', priority: '0.9', changefreq: 'hourly' },
|
||||
{ path: '/tournaments', priority: '0.85', changefreq: 'daily' },
|
||||
{ path: '/blog', priority: '0.7', changefreq: 'weekly' },
|
||||
{ path: '/uptime', priority: '0.5', changefreq: 'daily' },
|
||||
{ path: '/privacy', priority: '0.3', changefreq: 'monthly' },
|
||||
...blogPosts().map((post) => ({
|
||||
path: post.path,
|
||||
priority: '0.6',
|
||||
changefreq: 'monthly',
|
||||
lastmod: post.date || today,
|
||||
})),
|
||||
]
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((url) => ` <url>
|
||||
<loc>${escapeHtml(`${origin}${url.path}`)}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<lastmod>${escapeHtml(url.lastmod || today)}</lastmod>
|
||||
<changefreq>${url.changefreq}</changefreq>
|
||||
<priority>${url.priority}</priority>
|
||||
</url>`).join('\n')}
|
||||
|
||||
Reference in New Issue
Block a user