video sekcija: strani /video/<slug> z 20s napovednikom (predvajljiv na strani, VideoObject) in gumbom na YouTube; API /api/yt-videos; ingest skripta za FOLX YouTube kanal
This commit is contained in:
@@ -10,6 +10,7 @@ import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import AktuellesPage from "@/pages/aktuelles";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import VideoDetailPage from "@/pages/video-detail";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
@@ -43,6 +44,7 @@ function Router() {
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/aktuelles" component={AktuellesPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
<Route path="/video/:slug" component={VideoDetailPage} />
|
||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||
<Route path="/horoskop" component={HoroscopePage} />
|
||||
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
import { useEffect } from "react";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import Home from "@/pages/home";
|
||||
import ArticlePage from "@/pages/article";
|
||||
import CategoryPage from "@/pages/category";
|
||||
import AktuellesPage from "@/pages/aktuelles";
|
||||
import VideosPage from "@/pages/videos";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
import SearchPage from "@/pages/search";
|
||||
import EmpfangPage from "@/pages/empfang";
|
||||
import AboutPage from "@/pages/about";
|
||||
import ImpressumPage from "@/pages/impressum";
|
||||
import DatenschutzPage from "@/pages/datenschutz";
|
||||
import KontaktPage from "@/pages/kontakt";
|
||||
import AdminGalleryPage from "@/pages/admin-gallery";
|
||||
import AdminPushPage from "@/pages/admin-push";
|
||||
import CookieConsent from "@/components/cookie-consent";
|
||||
import MobileStickyAd from "@/components/mobile-sticky-ad";
|
||||
|
||||
function ScrollToTop() {
|
||||
const [location] = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<div>
|
||||
<ScrollToTop />
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/search" component={SearchPage} />
|
||||
<Route path="/article/:slug" component={ArticlePage} />
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/aktuelles" component={AktuellesPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
<Route path="/gallery" component={GalleryPageWrapper} />
|
||||
<Route path="/horoskop" component={HoroscopePage} />
|
||||
<Route path="/horoskop/:sign" component={HoroscopePage} />
|
||||
<Route path="/rezepte" component={RecipesPage} />
|
||||
<Route path="/empfang-folx-tv" component={EmpfangPage} />
|
||||
<Route path="/ueber-uns" component={AboutPage} />
|
||||
<Route path="/impressum" component={ImpressumPage} />
|
||||
<Route path="/datenschutz" component={DatenschutzPage} />
|
||||
<Route path="/kontakt" component={KontaktPage} />
|
||||
<Route path="/admin/gallery" component={AdminGalleryPage} />
|
||||
<Route path="/admin/push" component={AdminPushPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
<MobileStickyAd />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useRoute } from "wouter";
|
||||
import { ArrowLeft, Play, Clock, Youtube } from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
export interface YtVideo {
|
||||
ytId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
rubrik: string;
|
||||
publishedAt: string;
|
||||
durationS: number;
|
||||
teaser: string;
|
||||
description: string;
|
||||
chapters: { t: string; label: string }[];
|
||||
tags: string[];
|
||||
previewUrl: string;
|
||||
previewS: number;
|
||||
posterUrl: string;
|
||||
youtubeUrl: string;
|
||||
}
|
||||
|
||||
export function dauer(s: number) {
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function RelatedCard({ v }: { v: YtVideo }) {
|
||||
return (
|
||||
<Link href={`/video/${v.slug}`}>
|
||||
<article className="group cursor-pointer bg-card rounded-md border border-card-border h-full flex flex-col" data-testid={`card-related-${v.ytId}`}>
|
||||
<div className="relative overflow-hidden rounded-t-md">
|
||||
<img src={v.posterUrl} alt={v.shortTitle} loading="lazy"
|
||||
className="w-full aspect-video object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<span className="absolute bottom-2 right-2 rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-medium text-white">
|
||||
{dauer(v.durationS)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-card-foreground group-hover:text-primary transition-colors">
|
||||
{v.shortTitle}
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoDetailPage() {
|
||||
const [, params] = useRoute("/video/:slug");
|
||||
const slug = params?.slug || "";
|
||||
const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({
|
||||
queryKey: [`/api/yt-videos/${slug}`],
|
||||
enabled: !!slug,
|
||||
});
|
||||
const v = data?.item;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<PageSideAds />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<TopBannerAd />
|
||||
<Link href="/videos">
|
||||
<button className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-5 text-sm" data-testid="button-back-videos">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Alle Videos
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{isLoading || !v ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="w-full aspect-video rounded-lg" />
|
||||
<Skeleton className="h-7 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg overflow-hidden bg-black">
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={v.posterUrl}
|
||||
src={v.previewUrl}
|
||||
className="w-full aspect-video"
|
||||
data-testid="video-preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="rounded bg-primary/10 px-2 py-1 font-semibold text-primary">{v.rubrik}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3.5 h-3.5" />{dauer(v.durationS)}</span>
|
||||
<span>{format(new Date(v.publishedAt), "d. MMMM yyyy", { locale: de })}</span>
|
||||
<span className="text-muted-foreground/70">Vorschau {v.previewS} Sek.</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-foreground leading-tight" data-testid="text-video-title">
|
||||
{v.shortTitle}
|
||||
</h1>
|
||||
|
||||
{v.teaser && (
|
||||
<p className="mt-3 text-base text-card-foreground leading-relaxed" data-testid="text-video-teaser">
|
||||
{v.teaser}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={v.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="mt-5 inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-primary px-6 py-3 text-base font-semibold text-primary-foreground hover:opacity-90"
|
||||
data-testid="link-watch-youtube"
|
||||
>
|
||||
<Youtube className="w-5 h-5" />
|
||||
Ganzes Video auf YouTube ansehen
|
||||
</a>
|
||||
|
||||
{v.chapters?.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-bold text-foreground mb-3">Kapitel</h2>
|
||||
<ul className="space-y-1.5">
|
||||
{v.chapters.map((c) => (
|
||||
<li key={c.t} className="text-sm">
|
||||
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
|
||||
<span className="text-card-foreground">{c.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{v.description && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-bold text-foreground mb-2">Zum Video</h2>
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground leading-relaxed">
|
||||
{v.description.replace(/\n{3,}/g, "\n\n")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<MultiplexAd />
|
||||
|
||||
{data?.related?.length ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">Mehr aus {v.rubrik}</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{data.related.map((r) => <RelatedCard key={r.ytId} v={r} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -391,6 +391,37 @@ export async function registerRoutes(
|
||||
const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412";
|
||||
const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net";
|
||||
|
||||
// ---- FOLX YouTube video sekcija (napovednik na strani, cel video na YouTube) ----
|
||||
const ytIndexPath = path.join(uploadDir, "video", "index.json");
|
||||
function ytItems(): any[] {
|
||||
try {
|
||||
const raw = fs.readFileSync(ytIndexPath, "utf-8");
|
||||
const j = JSON.parse(raw);
|
||||
return Array.isArray(j.items) ? j.items : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
app.get("/api/yt-videos", (req, res) => {
|
||||
const rubrik = String(req.query.rubrik || "").trim();
|
||||
const page = Math.max(1, parseInt(String(req.query.page || "1")));
|
||||
const limit = Math.min(48, Math.max(1, parseInt(String(req.query.limit || "24"))));
|
||||
let items = ytItems();
|
||||
if (rubrik && rubrik !== "Alle") items = items.filter((i) => i.rubrik === rubrik);
|
||||
const total = items.length;
|
||||
const slice = items.slice((page - 1) * limit, page * limit);
|
||||
const rubriken = Array.from(new Set(ytItems().map((i) => i.rubrik)));
|
||||
res.set("Cache-Control", "public, max-age=120");
|
||||
res.json({ items: slice, total, page, totalPages: Math.max(1, Math.ceil(total / limit)), rubriken });
|
||||
});
|
||||
app.get("/api/yt-videos/:slug", (req, res) => {
|
||||
const it = ytItems().find((i) => i.slug === req.params.slug || i.ytId === req.params.slug);
|
||||
if (!it) return res.status(404).json({ message: "Video nicht gefunden" });
|
||||
const same = ytItems().filter((x) => x.rubrik === it.rubrik && x.slug !== it.slug).slice(0, 4);
|
||||
res.set("Cache-Control", "public, max-age=120");
|
||||
res.json({ item: it, related: same });
|
||||
});
|
||||
|
||||
app.get("/api/videos", async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+81
-1
@@ -80,6 +80,86 @@ export function serveStatic(app: Express) {
|
||||
const protocol = req.get("x-forwarded-proto") || "https";
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
// ---- /video/<slug>: napovednik je predvajljiv na strani, cel video na YouTube ----
|
||||
const ytVideoMatch = url.match(/^\/video\/([^?#\/]+)/);
|
||||
if (ytVideoMatch) {
|
||||
try {
|
||||
const slug = decodeURIComponent(ytVideoMatch[1]);
|
||||
const idxFile = path.resolve(process.cwd(), "client/public/uploads/video/index.json");
|
||||
const list: any[] = JSON.parse(await fs.promises.readFile(idxFile, "utf-8")).items || [];
|
||||
const v = list.find((i) => i.slug === slug || i.ytId === slug);
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
template = stripExistingMeta(template);
|
||||
if (!v) {
|
||||
template = template.replace(/<title>[^<]*<\/title>/, [
|
||||
'<meta name="robots" content="noindex, follow" />',
|
||||
'<title>Video nicht gefunden | Folx Music Television</title>',
|
||||
].join("\n "));
|
||||
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
const pageUrl = `${canonicalBase}/video/${v.slug}`;
|
||||
const poster = `${canonicalBase}${v.posterUrl}`;
|
||||
const desc = (v.teaser || v.shortTitle || v.title).slice(0, 300);
|
||||
const mins = Math.floor(v.previewS / 60), secs = v.previewS % 60;
|
||||
const videoLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": `${v.shortTitle} \u2013 Vorschau`,
|
||||
"description": desc,
|
||||
"thumbnailUrl": [poster],
|
||||
"uploadDate": v.publishedAt,
|
||||
"duration": `PT${mins}M${secs}S`,
|
||||
"contentUrl": `${canonicalBase}${v.previewUrl}`,
|
||||
"isFamilyFriendly": true,
|
||||
"inLanguage": "de",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Folx Music Television",
|
||||
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
|
||||
},
|
||||
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
|
||||
};
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||
`<meta property="og:type" content="video.other" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(pageUrl)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(poster)}" />`,
|
||||
`<meta property="og:image:width" content="1280" />`,
|
||||
`<meta property="og:image:height" content="720" />`,
|
||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||
`<meta property="og:locale" content="de_DE" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(v.shortTitle)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(poster)}" />`,
|
||||
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||
`<link rel="canonical" href="${escapeHtml(pageUrl)}" />`,
|
||||
`<title>${escapeHtml(v.shortTitle)} | ${escapeHtml(v.rubrik)} | Folx Music Television</title>`,
|
||||
`<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`,
|
||||
].join("\n ");
|
||||
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
||||
|
||||
const chapterHtml = (v.chapters || []).length
|
||||
? `<h2>Kapitel</h2><ul>${v.chapters.map((c: any) => `<li>${escapeHtml(c.t)} – ${escapeHtml(c.label)}</li>`).join("")}</ul>`
|
||||
: "";
|
||||
const ssr = [
|
||||
'<article class="ssr-content">',
|
||||
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
|
||||
`<video controls playsinline preload="metadata" poster="${escapeHtml(v.posterUrl)}" style="max-width:100%;height:auto;" src="${escapeHtml(v.previewUrl)}"></video>`,
|
||||
`<p><strong>${escapeHtml(desc)}</strong></p>`,
|
||||
chapterHtml,
|
||||
`<p><a href="${escapeHtml(v.youtubeUrl)}" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
|
||||
"</article>",
|
||||
].join("\n");
|
||||
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
const articleMatch = url.match(/^\/article\/([^?#]+)/);
|
||||
if (articleMatch) {
|
||||
try {
|
||||
@@ -318,7 +398,7 @@ export function serveStatic(app: Express) {
|
||||
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
|
||||
const KNOWN = [
|
||||
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
|
||||
/^\/videos\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
|
||||
/^\/videos\/?$/, /^\/video\/[^/]+\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
|
||||
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
|
||||
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import express, { type Express } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { storage } from "./storage";
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function ogImageUrl(coverImage: string, baseUrl: string): string {
|
||||
if (!coverImage) return "";
|
||||
let imgPath = coverImage;
|
||||
// Cloudinary: vsili FB razmerje 1200x630 z g_faces (da FB ne reze glav)
|
||||
if (imgPath.includes("res.cloudinary.com")) {
|
||||
imgPath = imgPath.replace(/([,&/?])h_\d+/i, "$1h_630").replace(/([,&/?])w_\d+/i, "$1w_1200");
|
||||
// ce ni g_faces, ga dodaj
|
||||
if (!/g_faces/.test(imgPath)) {
|
||||
imgPath = imgPath.replace(/\/(image\/(upload|fetch))\//, "/$1/g_faces,");
|
||||
}
|
||||
return imgPath;
|
||||
}
|
||||
if (imgPath.endsWith(".webp")) {
|
||||
imgPath = imgPath.replace(/\.webp$/, ".jpg");
|
||||
}
|
||||
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
|
||||
}
|
||||
|
||||
// Stari slugi clankov, ki so bili pozneje preimenovani. Brez tega vsak star
|
||||
// link (FB objave, komentarji, go.folx.tv kratki linki, Googlov indeks) pade
|
||||
// na trdi 404. Kljuc = star slug, vrednost = trenutni slug.
|
||||
const ARTICLE_SLUG_REDIRECTS: Record<string, string> = {
|
||||
"alexander-rier-verraet-das-ist-die-neue-frau-an-seiner-seite":
|
||||
"alexander-rier-von-mann-zu-mann-immer-wieder-sonntags",
|
||||
};
|
||||
|
||||
// Rubrike, ki jih pisemo enkrat in objavimo tudi na onemusic.news. Besedilo je
|
||||
// isto na obeh domenah, zato jih na folx.tv drzimo zunaj Googlovega indeksa —
|
||||
// v bazi in v /api/articles ostanejo, ker jih bere sidebar na TV kanalih.
|
||||
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
|
||||
|
||||
function setRobots(html: string, content: string): string {
|
||||
const tag = `<meta name="robots" content="${content}" />`;
|
||||
if (/<meta\s+name="robots"[^>]*>/i.test(html)) {
|
||||
return html.replace(/<meta\s+name="robots"[^>]*>/i, tag);
|
||||
}
|
||||
return html.replace("</head>", ` ${tag}\n </head>`);
|
||||
}
|
||||
|
||||
function stripExistingMeta(html: string): string {
|
||||
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
|
||||
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
|
||||
html = html.replace(/<meta\s+name="description"[^>]*>\s*/gi, "");
|
||||
html = html.replace(/<meta\s+name="keywords"[^>]*>\s*/gi, "");
|
||||
html = html.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
|
||||
return html;
|
||||
}
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath = path.resolve(__dirname, "public");
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath, {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
app.use("/{*path}", async (req, res) => {
|
||||
const url = req.originalUrl;
|
||||
const indexPath = path.resolve(distPath, "index.html");
|
||||
|
||||
const canonicalBase = "https://folx.tv";
|
||||
const host = req.get("host") || "folx.tv";
|
||||
const protocol = req.get("x-forwarded-proto") || "https";
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
const articleMatch = url.match(/^\/article\/([^?#]+)/);
|
||||
if (articleMatch) {
|
||||
try {
|
||||
const slug = decodeURIComponent(articleMatch[1]);
|
||||
const article = await storage.getArticleBySlug(slug);
|
||||
if (article && (article as any).targetUrl) {
|
||||
res.redirect(302, (article as any).targetUrl);
|
||||
return;
|
||||
}
|
||||
if (article) {
|
||||
const articleUrl = `${canonicalBase}/article/${article.slug}`;
|
||||
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
|
||||
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
|
||||
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
template = stripExistingMeta(template);
|
||||
|
||||
const pubDate = article.publishedAt
|
||||
? new Date(article.publishedAt as any).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
// dateModified: ce je clanek dopolnjen (enrich), preberi marker <!--folx-updated:ISO-->
|
||||
const modDate = (() => {
|
||||
const mm = /<!--folx-updated:([^>]+?)-->/.exec(article.content || "");
|
||||
if (mm) { try { return new Date(mm[1]).toISOString(); } catch (e) { /* ignore */ } }
|
||||
return pubDate;
|
||||
})();
|
||||
|
||||
// VideoObject: ce je v clanku vgrajen YouTube video
|
||||
const videoId = (() => {
|
||||
const mm = /youtube(?:-nocookie)?\.com\/embed\/([A-Za-z0-9_-]{6,})/.exec(article.content || "");
|
||||
return mm ? mm[1] : null;
|
||||
})();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "NewsArticle",
|
||||
"headline": article.title,
|
||||
"description": article.excerpt,
|
||||
"image": [finalImage],
|
||||
"datePublished": pubDate,
|
||||
"dateModified": modDate,
|
||||
"articleSection": article.category || "News",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": article.author || "Folx Music Television",
|
||||
"url": canonicalBase,
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Folx Music Television",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": `${canonicalBase}/og-image.jpg`,
|
||||
},
|
||||
},
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": articleUrl,
|
||||
},
|
||||
};
|
||||
const jsonLdTag = `<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>`;
|
||||
|
||||
const videoLd = videoId ? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": article.title,
|
||||
"description": article.excerpt,
|
||||
"thumbnailUrl": [`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`],
|
||||
"uploadDate": pubDate,
|
||||
"embedUrl": `https://www.youtube.com/embed/${videoId}`,
|
||||
"contentUrl": `https://www.youtube.com/watch?v=${videoId}`,
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Folx Music Television",
|
||||
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
|
||||
},
|
||||
} : null;
|
||||
const videoLdTag = videoLd
|
||||
? `<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`
|
||||
: "";
|
||||
|
||||
const ogTags = [
|
||||
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||
`<meta property="og:type" content="article" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
|
||||
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
|
||||
`<meta property="og:image:width" content="1200" />`,
|
||||
`<meta property="og:image:height" content="630" />`,
|
||||
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||
`<meta property="og:locale" content="de_DE" />`,
|
||||
`<meta property="fb:pages" content="281200958640258" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
|
||||
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
|
||||
`<meta name="keywords" content="Volksmusik, Schlager, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
||||
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
||||
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
|
||||
jsonLdTag,
|
||||
videoLdTag,
|
||||
].join("\n ");
|
||||
|
||||
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
|
||||
|
||||
// SSR: vgradi vsebino clanka v HTML, da jo Google/Discover vidita brez JS
|
||||
const ssrBlock = [
|
||||
'<article class="ssr-content">',
|
||||
`<h1>${escapeHtml(article.title)}</h1>`,
|
||||
imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(article.title)}" style="max-width:100%;height:auto;" />` : "",
|
||||
`<p><strong>${escapeHtml(article.excerpt)}</strong></p>`,
|
||||
article.content || "",
|
||||
"</article>",
|
||||
].join("\n");
|
||||
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssrBlock}</div>`);
|
||||
|
||||
if (ONE_RUBRIKEN.has(article.category || "")) {
|
||||
template = setRobots(template, "noindex, follow, max-image-preview:large");
|
||||
}
|
||||
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
|
||||
// Star slug preimenovanega clanka -> trajna preusmeritev na novega.
|
||||
const renamedTo = ARTICLE_SLUG_REDIRECTS[slug];
|
||||
if (renamedTo) {
|
||||
res.redirect(301, `/article/${renamedTo}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clanka s tem slugom ni. Brez tega bi SPA vrnil HTTP 200 z naslovnico
|
||||
// in canonical na "/" — Google to bere kot podvojeno naslovnico (soft 404),
|
||||
// izbrisani clanki pa ostanejo v indeksu in v Discovru.
|
||||
{
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
template = stripExistingMeta(template);
|
||||
template = template.replace(
|
||||
/<title>[^<]*<\/title>/,
|
||||
[
|
||||
'<meta name="robots" content="noindex, follow" />',
|
||||
'<meta name="description" content="Dieser Artikel ist nicht mehr verfuegbar." />',
|
||||
'<title>Artikel nicht gefunden | Folx Music Television</title>',
|
||||
].join("\n "),
|
||||
);
|
||||
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (url.match(/^\/gallery(\/|$|\?)/)) {
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
template = stripExistingMeta(template);
|
||||
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
|
||||
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
|
||||
const galUrl = `${canonicalBase}/gallery`;
|
||||
const galImage = `${canonicalBase}/images/og-image.jpg`;
|
||||
|
||||
const galTags = [
|
||||
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
|
||||
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
|
||||
`<meta property="og:image:width" content="1200" />`,
|
||||
`<meta property="og:image:height" content="630" />`,
|
||||
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||
`<meta property="og:locale" content="de_DE" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
|
||||
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
|
||||
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
|
||||
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
|
||||
`<title>${escapeHtml(galTitle)}</title>`,
|
||||
].join("\n ");
|
||||
|
||||
template = template.replace(/<title>[^<]*<\/title>/, galTags);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.match(/^\/horoskop(\/|$|\?)/)) {
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
template = stripExistingMeta(template);
|
||||
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
|
||||
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
|
||||
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
|
||||
|
||||
const title = capitalSign
|
||||
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
|
||||
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
|
||||
const desc = capitalSign
|
||||
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
|
||||
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
|
||||
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
|
||||
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
|
||||
|
||||
const horoTags = [
|
||||
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
|
||||
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
|
||||
`<meta property="og:image:width" content="1200" />`,
|
||||
`<meta property="og:image:height" content="675" />`,
|
||||
`<meta property="og:image:type" content="image/jpeg" />`,
|
||||
`<meta property="og:site_name" content="Folx Music Television" />`,
|
||||
`<meta property="og:locale" content="de_DE" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
|
||||
`<meta name="description" content="${escapeHtml(desc)}" />`,
|
||||
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
|
||||
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
|
||||
`<title>${escapeHtml(title)}</title>`,
|
||||
].join("\n ");
|
||||
|
||||
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
|
||||
// Neznana pot mora vrniti PRAVI 404, sicer Google dobi soft 404 (HTTP 200
|
||||
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
|
||||
const KNOWN = [
|
||||
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
|
||||
/^\/videos\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
|
||||
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
|
||||
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
|
||||
];
|
||||
const cleanPath = url.split("?")[0].split("#")[0];
|
||||
const known = KNOWN.some((re) => re.test(cleanPath));
|
||||
|
||||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||||
if (!known) {
|
||||
template = stripExistingMeta(template);
|
||||
template = template.replace(
|
||||
/<title>[^<]*<\/title>/,
|
||||
[
|
||||
'<title>Seite nicht gefunden | FOLX TV</title>',
|
||||
'<meta name="robots" content="noindex, follow" />',
|
||||
].join("\n ")
|
||||
);
|
||||
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||||
return;
|
||||
}
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user