From 8e0730aaeb65404ae5b37f665af45d6f412b9659 Mon Sep 17 00:00:00 2001 From: artem Date: Fri, 18 Sep 2026 23:23:56 +0300 Subject: [PATCH] ssr --- server/api.ts | 71 +++++++++++ server/index.ts | 286 +++++++++++++++++++++++++++++++++++++++++++++ server/template.ts | 61 ++++++++++ 3 files changed, 418 insertions(+) create mode 100644 server/api.ts create mode 100644 server/index.ts create mode 100644 server/template.ts diff --git a/server/api.ts b/server/api.ts new file mode 100644 index 0000000..8552315 --- /dev/null +++ b/server/api.ts @@ -0,0 +1,71 @@ +import axios from "axios"; + +const API_BASE = process.env.VITE_APP_API_URL || "https://cycle-rider.ru"; + +export const api = axios.create({ + baseURL: API_BASE, + timeout: 10000, +}); + +export interface WorkoutItem { + id: string; + name: string; + created_by: string; + created_at: string; + description: string; + cadence: number; + heart_rate: number; + max_cadence: number; + max_heart_rate: number; + temperature: number; + speed: number; + power: number; + max_speed: number; + max_power: number; + duraion_sec: number; + distantion: number; + attachment: { url: string } | null; + latitude: number; + longitude: number; + is_public: boolean; + workouted_at: string; + external_links?: { values: Array<{ type: string; value: string }> }; + photos?: Array<{ + id: string; + url: string; + size: number; + latitude: number | null; + longitude: number | null; + }>; +} + +export interface WorkoutListResponse { + results: WorkoutItem[]; +} + +export interface WorkoutDetailResponse { + workout: WorkoutItem; + results: Array<{ + timestamp: number; + longitude: number; + latitude: number; + elevation: number | null; + power: number | null; + heart_rate: number | null; + speed: number | null; + }>; +} + +export async function getPublicWorkouts(): Promise { + const res = await api.get("/api/v0/public/workouts"); + return res.data.results; +} + +export async function getPublicWorkout( + id: string, +): Promise { + const res = await api.get( + `/api/v0/public/workouts/${id}`, + ); + return res.data; +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..e07b1a4 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,286 @@ +import express, { Request, Response } from "express"; +import { getPublicWorkouts, getPublicWorkout } from "./api"; +import { renderTemplate, getAssetTags, SeoMeta } from "./template"; + +const app = express(); +const PORT = process.env.SSR_PORT || 3001; +const BASE_URL = process.env.SSR_BASE_URL || "https://cycle-rider.ru"; + +app.disable("x-powered-by"); + +// --- Helpers --- + +function getAssetTagsSafe(): string { + try { + return getAssetTags(); + } catch { + return ''; + } +} + +function escapeHtml(text: string): string { + return text.replace(//g, ">"); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + return `${dd}.${mm}.${yyyy}`; +} + +function formatDuration(sec: number): string { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + return `${h} ч. ${m} мин.`; +} + +function formatDistance(meters: number): string { + return (meters / 1000).toFixed(1); +} + +function formatSpeed(ms: number): string { + return Math.round(ms * 3.6).toString(); +} + +function getToken(req: Request): string | null { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) return null; + const match = cookieHeader.match(/(?:^|;\s*)token=([^;]*)/); + return match ? decodeURIComponent(match[1]) : null; +} + +// --- Routes --- + +// GET / — Landing page (SEO) +app.get("/", async (_req: Request, res: Response) => { + const token = getToken(_req); + if (token) { + res.redirect(302, "/explore"); + return; + } + + try { + let workoutsHtml: string; + try { + const workouts = await getPublicWorkouts(); + const top5 = workouts.slice(0, 5); + workoutsHtml = top5 + .map( + (w) => + `
  • ${escapeHtml(w.name)} — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км
  • `, + ) + .join("\n"); + } catch { + workoutsHtml = "
  • Загрузка…
  • "; + } + + const content = ` +

    Cycle Rider — анализ велотренировок

    +

    Платформа для анализа велотренировок: мощность, пульс, скорость, каденс. Загрузите FIT/GPX файлы и получите детальную аналитику.

    +

    Последние тренировки

    +
      +${workoutsHtml} +
    +

    Возможности

    +
      +
    • Нормализованная мощность (NP) и FTP
    • +
    • Пульсовые зоны и скорость
    • +
    • Каденс и дистанция
    • +
    • Профиль высоты
    • +
    `; + + const meta: SeoMeta = { + title: "Cycle Rider — Анализ велотренировок: мощность, пульс, скорость", + description: + "Анализируй велотренировки: мощность (FTP, NP), пульс, скорость, каденс, зоны мощности. Загрузка FIT/GPX. Cycle Rider.", + canonicalUrl: `${BASE_URL}/`, + jsonLd: { + "@context": "https://schema.org", + "@type": "WebApplication", + name: "Cycle Rider", + description: "Платформа для анализа велотренировок", + url: `${BASE_URL}/`, + applicationCategory: "HealthApplication", + operatingSystem: "Web", + offers: { + "@type": "Offer", + price: "0", + }, + }, + }; + + res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); + } catch { + res.status(500).send("Internal Server Error"); + } +}); + +// GET /explore — Public feed +app.get("/explore", async (_req: Request, res: Response) => { + try { + const workouts = await getPublicWorkouts(); + const list = workouts + .map( + (w) => + `
  • \n ${escapeHtml(w.name)}\n — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км, ${formatSpeed(w.speed)} км/ч, ${w.heart_rate} уд/мин\n
  • `, + ) + .join("\n"); + + const content = ` +

    Публичные тренировки

    +

    Лента велотренировок от сообщества.

    +
      +${list} +
    `; + + const meta: SeoMeta = { + title: "Публичные велотренировки — Cycle Rider", + description: + "Лента публичных велотренировок: скорость, пульс, мощность, расстояние. Cycle Rider.", + canonicalUrl: `${BASE_URL}/explore`, + jsonLd: { + "@context": "https://schema.org", + "@type": "WebPage", + name: "Публичные велотренировки", + url: `${BASE_URL}/explore`, + description: + "Лента публичных велотренировок: скорость, пульс, мощность, расстояние.", + }, + }; + + res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); + } catch { + res.status(500).send("Internal Server Error"); + } +}); + +// GET /public/workouts/:id — Workout detail +app.get("/public/workouts/:id", async (req: Request, res: Response) => { + try { + const detail = await getPublicWorkout(req.params.id); + const w = detail.workout; + + const content = ` +

    ${escapeHtml(w.name)}

    +

    ${escapeHtml(w.description)}

    +
      +
    • Дата: ${formatDate(w.workouted_at)}
    • +
    • Расстояние: ${formatDistance(w.distantion)} км
    • +
    • Средняя скорость: ${formatSpeed(w.speed)} км/ч
    • +
    • Макс. скорость: ${formatSpeed(w.max_speed)} км/ч
    • +
    • Средний пульс: ${w.heart_rate} уд/мин
    • +
    • Макс. пульс: ${w.max_heart_rate} уд/мин
    • +
    • Средняя мощность: ${w.power} Вт
    • +
    • Макс. мощность: ${w.max_power} Вт
    • +
    • Каденс: ${w.cadence} об/мин
    • +
    • Длительность: ${formatDuration(w.duraion_sec)}
    • +
    `; + + const desc = + w.description.slice(0, 150) + (w.description.length > 150 ? "…" : ""); + const metrics = `${formatDistance(w.distantion)} км, ${formatSpeed(w.speed)} км/ч, пульс ${w.heart_rate}, мощность ${w.power} Вт`; + + const meta: SeoMeta = { + title: `${w.name} — Cycle Rider`, + description: `${desc} ${metrics}`, + canonicalUrl: `${BASE_URL}/public/workouts/${w.id}`, + jsonLd: { + "@context": "https://schema.org", + "@type": "SportsActivity", + name: w.name, + description: desc, + url: `${BASE_URL}/public/workouts/${w.id}`, + startDate: w.workouted_at, + sport: "Cycling", + }, + }; + + res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); + } catch (err: unknown) { + // Check for 404 from API + if ( + err && + typeof err === "object" && + "response" in err && + (err as { response?: { status?: number } }).response?.status === 404 + ) { + res.status(404).send("Not found"); + return; + } + res.status(500).send("Internal Server Error"); + } +}); + +// GET /routes — Routes page (minimal) +app.get("/routes", async (_req: Request, res: Response) => { + try { + const content = ` +

    Конструктор маршрутов

    +

    Создайте свой велосипедный маршрут на карте. Добавляйте точки, рассчитывайте дистанцию и экспортируйте в GPX.

    `; + + const meta: SeoMeta = { + title: "Конструктор веломаршрутов — Cycle Rider", + description: + "Создайте велосипедный маршрут на карте, экспортируйте в GPX. Cycle Rider.", + canonicalUrl: `${BASE_URL}/routes`, + }; + + res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); + } catch { + res.status(500).send("Internal Server Error"); + } +}); + +// GET /sitemap.xml +app.get("/sitemap.xml", async (_req: Request, res: Response) => { + try { + const workouts = await getPublicWorkouts(); + + const staticUrls = [ + `${BASE_URL}/daily1.0`, + `${BASE_URL}/exploredaily0.9`, + `${BASE_URL}/routesweekly0.5`, + ]; + + const workoutUrls = workouts.map((w) => { + const date = new Date(w.workouted_at).toISOString().split("T")[0]; + return `${BASE_URL}/public/workouts/${w.id}${date}monthly0.7`; + }); + + const xml = `\n\n ${[ + ...staticUrls, + ...workoutUrls, + ].join("\n ")}\n`; + + res.set("Content-Type", "application/xml; charset=utf-8"); + res.send(xml); + } catch { + res.status(500).send("Internal Server Error"); + } +}); + +// GET /robots.txt +app.get("/robots.txt", (_req: Request, res: Response) => { + const text = `User-agent: * +Allow: / +Disallow: /auth/ +Disallow: /workouts/ +Disallow: /preferences/ +Disallow: /admin/ + +Sitemap: ${BASE_URL}/sitemap.xml +`; + res.set("Content-Type", "text/plain; charset=utf-8"); + res.send(text); +}); + +// Health check +app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok" }); +}); + +app.listen(PORT, () => { + console.log(`SSR server listening on :${PORT}`); +}); diff --git a/server/template.ts b/server/template.ts new file mode 100644 index 0000000..9ae7a43 --- /dev/null +++ b/server/template.ts @@ -0,0 +1,61 @@ +import fs from "fs"; +import path from "path"; + +export interface SeoMeta { + title: string; + description: string; + canonicalUrl: string; + ogImage?: string; + jsonLd?: Record; +} + +export interface TemplateOptions { + meta: SeoMeta; + content: string; // HTML content to inject inside
    + assetTags: string; // ` + : ""; + + return ` + + + + + + ${meta.title} + + + + + + + ${meta.ogImage ? `` : ""} + + + ${assetTags} + ${jsonLdScript} + + +
    + ${content} +
    + +`; +}