793 lines
39 KiB
TypeScript
793 lines
39 KiB
TypeScript
import { publisherSchema, homeSchema, INFO_SEO, articleModifiedTime, articleGenre } from "../shared/seo";
|
||
import express, { type Express } from "express";
|
||
import fs from "fs";
|
||
import path from "path";
|
||
import { storage } from "./storage";
|
||
import sharp from "sharp";
|
||
|
||
// 15. 9. 2026 (Sebastjan): stran je v keywords vsakemu clanku pisala
|
||
// "Volksmusik, Schlager" — tudi Beckerju, Geissensom in Ivanovic. Cevovod ze ve,
|
||
// kaj kos je (znacka MUSIC NEWS / STAR NEWS), zato jo zapise v vsebino kot
|
||
// marker <!--folx-badge:STAR NEWS-->, enako kot <!--folx-updated:...-->.
|
||
// Brez markerja ostane vse kot prej (glasbeno), da starejsi clanki ne nazadujejo.
|
||
// Besedilne zaznave namenoma NI: preizkus na 30 clankih jo je zgresil pri
|
||
// Fernsehgartnu in Carpendaleju.
|
||
const folxGenre = (article: any) => articleGenre(String(article?.content || ""));
|
||
|
||
|
||
function escapeHtml(str: string): string {
|
||
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
// Dejanske dimenzije naslovnice. Prej sta bili sirina in visina v meta
|
||
// oznakah fiksno zapisani kot 1200x630, slike pa so v resnici 1200x900 —
|
||
// Facebook in Google sta dobila napacno vrednost. Ce dimenzij ne moremo
|
||
// prebrati, oznak raje ne zapisemo.
|
||
const ogDimCache = new Map<string, { w: number; h: number } | null>();
|
||
|
||
async function ogImageDims(imgUrl: string, publicDir: string) {
|
||
if (ogDimCache.has(imgUrl)) return ogDimCache.get(imgUrl) ?? null;
|
||
let dims: { w: number; h: number } | null = null;
|
||
try {
|
||
const m = /\/(uploads|images)\/([^/?#]+)$/.exec(imgUrl);
|
||
if (m) {
|
||
const cand = [
|
||
path.join(publicDir, m[1], m[2]),
|
||
path.join(publicDir, m[1], m[2].replace(/\.jpg$/, ".webp")),
|
||
];
|
||
for (const p of cand) {
|
||
if (!fs.existsSync(p)) continue;
|
||
const meta = await sharp(p).metadata();
|
||
if (meta.width && meta.height) { dims = { w: meta.width, h: meta.height }; break; }
|
||
}
|
||
}
|
||
} catch (e) {
|
||
dims = null;
|
||
}
|
||
ogDimCache.set(imgUrl, dims);
|
||
return dims;
|
||
}
|
||
|
||
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",
|
||
// 25. 8. 2026 izbrisan dvojnik: bot je isto zgodbo objavil dvakrat.
|
||
// Andreas Martin je bil vrnjen (bil je ze v Search Console), zato tu ni.
|
||
"isi-glueck-sagt-konzerte-ab-stimme-in-gefahr":
|
||
"isi-glueck-sagt-konzerte-ab-sorge-um-ihre-stimme",
|
||
};
|
||
|
||
// 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.
|
||
// Izdajatelj: doslej samo ime in logo. Brez registrirane druzbe, naslova in
|
||
// profilov Google znamke nima s cim povezati.
|
||
const PUBLISHER = publisherSchema;
|
||
|
||
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
|
||
|
||
// 30. 8. 2026: rubrike so izbrisane iz baze folx.tv in zivijo samo se na
|
||
// folx.news. Stare povezave s Facebooka in Googla morajo se vedno delati,
|
||
// zato ob neznanem slugu vprasamo folx.news, ali ga pozna, in preusmerimo.
|
||
let NEWS_SLUGS: Set<string> = new Set();
|
||
let NEWS_SLUGS_AT = 0;
|
||
async function newsHasSlug(slug: string): Promise<boolean> {
|
||
if (Date.now() - NEWS_SLUGS_AT > 600000) {
|
||
try {
|
||
const r = await fetch("https://folx.news/api/articles", {
|
||
headers: { "user-agent": "folx.tv-redirect" },
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
const j: any = await r.json();
|
||
if (Array.isArray(j) && j.length) {
|
||
NEWS_SLUGS = new Set(j.map((a: any) => String(a.slug)));
|
||
NEWS_SLUGS_AT = Date.now();
|
||
}
|
||
} catch (e) {
|
||
NEWS_SLUGS_AT = Date.now() - 540000; // ob napaki poskusi cez minuto
|
||
}
|
||
}
|
||
return NEWS_SLUGS.has(slug);
|
||
}
|
||
|
||
|
||
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, {
|
||
// brez tega express.static sam postrezhe index.html za "/" in nasa
|
||
// naslovnica nikoli ne pride do handlerja, ki ji doda strukturirane podatke
|
||
index: false,
|
||
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}`;
|
||
|
||
// ---- /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 longDesc = String(v.descriptionClean || v.description || v.teaser || v.shortTitle)
|
||
.replace(/https?:\/\/\S+/g, "").replace(/\s+/g, " ").trim().slice(0, 900);
|
||
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
|
||
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
|
||
// Poglavja: iz "1:24" naredimo sekunde, da jih lahko damo v Clip
|
||
// oznacbo (Google iz nje naredi "Wichtige Momente" pod videom) in da
|
||
// je vsako poglavje klikljiva povezava z ?t=<sekunde>.
|
||
const chapSecs = (t: string) => {
|
||
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
|
||
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
|
||
};
|
||
const chaps = (v.chapters || [])
|
||
.map((c: any) => ({ ...c, s: chapSecs(c.t) }))
|
||
.filter((c: any) => c.s < (v.durationS || 0))
|
||
.sort((a: any, b: any) => a.s - b.s);
|
||
const startAt = parseInt(String((req.query as any)?.t || ""), 10);
|
||
const embedSrc = Number.isFinite(startAt) && startAt > 0
|
||
? `${embedUrl}?start=${startAt}` : embedUrl;
|
||
const videoLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "VideoObject",
|
||
"name": v.shortTitle,
|
||
"description": longDesc || desc,
|
||
"thumbnailUrl": [poster],
|
||
"uploadDate": v.publishedAt,
|
||
"duration": `PT${mins}M${secs}S`,
|
||
"embedUrl": embedUrl,
|
||
"contentUrl": v.youtubeUrl,
|
||
"isFamilyFriendly": true,
|
||
"inLanguage": "de",
|
||
"publisher": PUBLISHER(canonicalBase),
|
||
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
|
||
...(chaps.length >= 3 ? {
|
||
hasPart: chaps.map((c: any, i: number) => ({
|
||
"@type": "Clip",
|
||
"name": c.label,
|
||
"startOffset": c.s,
|
||
"endOffset": i + 1 < chaps.length ? chaps[i + 1].s : (v.durationS || c.s + 30),
|
||
"url": `${pageUrl}?t=${c.s}`,
|
||
})),
|
||
potentialAction: {
|
||
"@type": "SeekToAction",
|
||
"target": `${pageUrl}?t={seek_to_second_number}`,
|
||
"startOffset-input": "required name=seek_to_second_number",
|
||
},
|
||
} : {}),
|
||
};
|
||
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 = chaps.length
|
||
? `<h2>Kapitel</h2><ul>${chaps.map((c: any) => `<li><a href="${escapeHtml(pageUrl)}?t=${c.s}">${escapeHtml(c.t)}</a> – ${escapeHtml(c.label)}</li>`).join("")}</ul>`
|
||
: "";
|
||
// Celoten opis gre v SSR, da ga Google prebere brez JavaScripta.
|
||
// Prej je bil v HTML samo naslov in teaser (~320 znakov), ostalo je
|
||
// izrisal sele React.
|
||
const descHtml = String(v.descriptionClean || v.description || "")
|
||
.split(/\n{2,}/)
|
||
.map((par: string) => par.trim())
|
||
.filter((par: string) => par.length > 2 && !/^https?:\/\//.test(par) && !/^[\u2013\u2014-]{4,}$/.test(par))
|
||
.map((par: string) => `<p>${escapeHtml(par).replace(/\n/g, "<br />")}</p>`)
|
||
.join("");
|
||
const ssr = [
|
||
'<article class="ssr-content">',
|
||
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
|
||
`<iframe width="640" height="360" src="${escapeHtml(embedSrc)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
|
||
`<p><strong>${escapeHtml(desc)}</strong></p>`,
|
||
chapterHtml,
|
||
`<p><a href="${escapeHtml(v.youtubeUrl)}" target="_blank" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
|
||
v.articleUrl ? `<p><a href="${escapeHtml(v.articleUrl)}">Ganzen Artikel lesen</a></p>` : "",
|
||
descHtml ? `<h2>Zum Video</h2>${descHtml}` : "",
|
||
"</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) {
|
||
}
|
||
}
|
||
|
||
// ---- Naslovnica: doslej brez strukturiranih podatkov. Organization in
|
||
// WebSite sta tista dva vozlisca, na katera se veze vse ostalo. ----
|
||
if (url === "/" || url.startsWith("/?")) {
|
||
try {
|
||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||
const homeLd = homeSchema(canonicalBase);
|
||
template = template.replace(
|
||
"</head>",
|
||
`<script id="home-jsonld" type="application/ld+json">${JSON.stringify(homeLd).replace(/</g, "\\u003c")}</script>\n</head>`);
|
||
|
||
// 31. 8. 2026: naslovnica je React aplikacija, zato je robot dobil samo
|
||
// lupino brez ene same povezave na clanek. Clanki se do Googla pridejo
|
||
// prek sitemapa, a odkrivanje je pocasnejse. Zato v #root vpisemo se
|
||
// strezniski seznam zadnjih 40 clankov; React ga ob zagonu zamenja s
|
||
// pravo mrezo, robot pa povezave dobi takoj in brez izrisa JavaScripta.
|
||
try {
|
||
const vsi = await storage.getArticles();
|
||
const zadnji = [...vsi]
|
||
.filter((a: any) => !a.targetUrl && !ONE_RUBRIKEN.has(a.category || ""))
|
||
.sort((a: any, b: any) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||
.slice(0, 40);
|
||
if (zadnji.length) {
|
||
const seznam = zadnji
|
||
.filter((a: any) => a.slug && a.title)
|
||
.map((a: any) =>
|
||
`<li><a href="/article/${escapeHtml(a.slug)}">${escapeHtml(a.title)}</a></li>`)
|
||
.join("");
|
||
const blok = `<div id="ssr-neueste"><h2>Neueste Artikel</h2><ul>${seznam}</ul>` +
|
||
`<p><a href="/category/News">Alle Nachrichten</a></p></div>`;
|
||
template = template.replace('<div id="root"></div>', () => `<div id="root">${blok}</div>`);
|
||
}
|
||
} catch (e) {
|
||
}
|
||
|
||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||
return;
|
||
} catch (e) {
|
||
}
|
||
}
|
||
|
||
// 29. 8. 2026: rubrike Aktuelles so preseljene na loceno stran folx.news.
|
||
// Clanki ostanejo v bazi in v /api/articles (bere jih folx.news in TV sidebar),
|
||
// na folx.tv pa se naslovi trajno preusmerijo.
|
||
const NEWS_HOST = "https://folx.news";
|
||
const NEWS_RUBRIK_SLUG: Record<string, string | undefined> = {
|
||
"Politik": "politik",
|
||
"Promis": "promis",
|
||
"Sport": "sport",
|
||
"Musik & Film": "musik-film",
|
||
"Wetter": "wetter",
|
||
"Finanzen": "finanzen",
|
||
"Aktuelles": "",
|
||
};
|
||
const pathOnly = url.split("?")[0].split("#")[0];
|
||
// 30. 8. 2026: horoskop, recepti in galerija so ukinjeni. Stare povezave
|
||
// ne smejo v 404, zato gredo trajno na naslovnico.
|
||
if (/^\/(horoskop|rezepte|gallery|galerie)(\/.*)?$/.test(pathOnly)) {
|
||
res.redirect(301, "/");
|
||
return;
|
||
}
|
||
if (/^\/aktuelles\/?$/.test(pathOnly)) {
|
||
res.redirect(301, `${NEWS_HOST}/`);
|
||
return;
|
||
}
|
||
const catRedirect = pathOnly.match(/^\/category\/([^/]+)\/?$/);
|
||
if (catRedirect) {
|
||
const catName = decodeURIComponent(catRedirect[1]);
|
||
const key = Array.from(ONE_RUBRIKEN).find((r) => r.toLowerCase() === catName.toLowerCase());
|
||
if (key) {
|
||
const rs = NEWS_RUBRIK_SLUG[key];
|
||
res.redirect(301, rs ? `${NEWS_HOST}/rubrik/${rs}` : `${NEWS_HOST}/`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- /category/<ime>: prej je vrnil HTML naslovnice s canonical "/" in
|
||
// brez vsebine, kar je Google bral kot soft 404 / podvojeno naslovnico ----
|
||
const catMatch = url.match(/^\/category\/([^?#\/]+)/);
|
||
if (catMatch) {
|
||
try {
|
||
const cat = decodeURIComponent(catMatch[1]);
|
||
const all = await storage.getArticles();
|
||
const list = all
|
||
.filter((a: any) => (a.category || "").toLowerCase() === cat.toLowerCase() && !a.targetUrl)
|
||
.slice(0, 40);
|
||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||
template = stripExistingMeta(template);
|
||
if (!list.length) {
|
||
template = template.replace(/<title>[^<]*<\/title>/, [
|
||
'<meta name="robots" content="noindex, follow" />',
|
||
`<title>${escapeHtml(cat)} | Folx Music Television</title>`,
|
||
].join("\n "));
|
||
res.status(404).set({ "Content-Type": "text/html" }).end(template);
|
||
return;
|
||
}
|
||
const catUrl = `${canonicalBase}/category/${encodeURIComponent(cat)}`;
|
||
const isNews = cat.toLowerCase() === "news";
|
||
const catHeading = isNews ? "Schlager & Volksmusik News" : cat;
|
||
const catTitle = isNews
|
||
? `${catHeading} | FOLX TV`
|
||
: `${cat} – Volksmusik & Schlager | Folx Music Television`;
|
||
const catDesc = isNews
|
||
? "Aktuelle Schlager & Volksmusik News: Nachrichten, Interviews und Neuigkeiten aus der Musikszene bei FOLX TV."
|
||
: `Alle Beiträge aus der Rubrik ${cat} bei FOLX TV: aktuelle Meldungen, Interviews und Videos aus Volksmusik und Schlager.`;
|
||
const firstImg = ogImageUrl(list[0]?.coverImage || "", canonicalBase) || `${canonicalBase}/og-image.jpg`;
|
||
const itemLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "CollectionPage",
|
||
"name": catTitle,
|
||
"description": catDesc,
|
||
"url": catUrl,
|
||
"mainEntity": {
|
||
"@type": "ItemList",
|
||
"itemListElement": list.slice(0, 20).map((a: any, i: number) => ({
|
||
"@type": "ListItem",
|
||
"position": i + 1,
|
||
"url": `${canonicalBase}/article/${a.slug}`,
|
||
"name": a.title,
|
||
})),
|
||
},
|
||
};
|
||
const tags = [
|
||
`<meta property="og:title" content="${escapeHtml(catTitle)}" />`,
|
||
`<meta property="og:description" content="${escapeHtml(catDesc)}" />`,
|
||
`<meta property="og:type" content="website" />`,
|
||
`<meta property="og:url" content="${escapeHtml(catUrl)}" />`,
|
||
`<meta property="og:image" content="${escapeHtml(firstImg)}" />`,
|
||
`<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(catTitle)}" />`,
|
||
`<meta name="twitter:image" content="${escapeHtml(firstImg)}" />`,
|
||
`<meta name="description" content="${escapeHtml(catDesc)}" />`,
|
||
`<link rel="canonical" href="${escapeHtml(catUrl)}" />`,
|
||
`<title>${escapeHtml(catTitle)}</title>`,
|
||
`<script type="application/ld+json">${JSON.stringify(itemLd).replace(/</g, "\\u003c")}</script>`,
|
||
].join("\n ");
|
||
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
||
// Rubrike, ki sestavljajo /aktuelles, niso del indeksiranega dela strani:
|
||
// clanki so ze noindex, zato tudi njihov seznam ne sme v indeks.
|
||
if (Array.from(ONE_RUBRIKEN).some((r) => r.toLowerCase() === cat.toLowerCase())) {
|
||
template = setRobots(template, "noindex, follow");
|
||
}
|
||
const ssr = [
|
||
'<section class="ssr-content">',
|
||
`<h1>${escapeHtml(cat)}</h1>`,
|
||
"<ul>",
|
||
...list.map((a: any) => {
|
||
const img = ogImageUrl(a.coverImage || "", canonicalBase);
|
||
return `<li><a href="/article/${escapeHtml(a.slug)}">` +
|
||
(img ? `<img src="${escapeHtml(img)}" alt="${escapeHtml(a.title)}" width="320" style="max-width:100%;height:auto;" /> ` : "") +
|
||
`<strong>${escapeHtml(a.title)}</strong></a><p>${escapeHtml(a.excerpt || "")}</p></li>`;
|
||
}),
|
||
"</ul>",
|
||
"</section>",
|
||
].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 {
|
||
const slug = decodeURIComponent(articleMatch[1]);
|
||
const article = await storage.getArticleBySlug(slug);
|
||
if (article && ONE_RUBRIKEN.has((article as any).category || "")) {
|
||
res.redirect(301, `${NEWS_HOST}/${article.slug}`);
|
||
return;
|
||
}
|
||
if (!article && (await newsHasSlug(slug))) {
|
||
res.redirect(301, `${NEWS_HOST}/${slug}`);
|
||
return;
|
||
}
|
||
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`;
|
||
const articleImgDims = await ogImageDims(finalImage, distPath);
|
||
if (!imageUrl) {
|
||
console.warn(`[og] clanek brez naslovnice, pada na logotip: ${article.slug}`);
|
||
}
|
||
|
||
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 = articleModifiedTime(article.content || "", 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",
|
||
"@id": `${articleUrl}#article`,
|
||
"inLanguage": "de-DE",
|
||
"headline": article.title,
|
||
"description": article.excerpt,
|
||
"image": [finalImage],
|
||
"datePublished": pubDate,
|
||
"dateModified": modDate,
|
||
"articleSection": folxGenre(article).section,
|
||
"keywords": folxGenre(article).words.join(", "),
|
||
"author": {
|
||
"@type": "Organization",
|
||
"name": article.author || "Folx Music Television",
|
||
"url": canonicalBase,
|
||
},
|
||
"publisher": PUBLISHER(canonicalBase),
|
||
"mainEntityOfPage": {
|
||
"@type": "WebPage",
|
||
"@id": articleUrl,
|
||
},
|
||
};
|
||
const jsonLdTag = `<script id="article-jsonld" 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": PUBLISHER(canonicalBase),
|
||
} : 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="article:published_time" content="${pubDate}" />`,
|
||
`<meta property="article:modified_time" content="${modDate}" />`,
|
||
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
|
||
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
|
||
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
|
||
...(articleImgDims
|
||
? [`<meta property="og:image:width" content="${articleImgDims.w}" />`,
|
||
`<meta property="og:image:height" content="${articleImgDims.h}" />`]
|
||
: []),
|
||
`<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="${escapeHtml(folxGenre(article).words.join(", "))}, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
|
||
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
|
||
`<title>${escapeHtml(article.title)} | FOLX TV</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) {
|
||
}
|
||
}
|
||
|
||
// /rezepte in /videos sta prej vracala canonical na naslovnico, zato ju Google
|
||
// ni mogel indeksirati kot samostojni strani (29. 8. 2026).
|
||
const SEKCIJE: Record<string, { pot: string; naslov: string; opis: string } | undefined> = {
|
||
"/rezepte": {
|
||
pot: "/rezepte",
|
||
naslov: "Rezepte \u2013 Rezept des Tages | Folx Music Television",
|
||
opis: "Jeden Tag ein neues Rezept: einfache Gerichte, Klassiker und Ideen f\u00fcr jeden Anlass \u2013 bei FOLX TV.",
|
||
},
|
||
"/videos": {
|
||
pot: "/videos",
|
||
naslov: "Videos \u2013 Schlager und Volksmusik | Folx Music Television",
|
||
opis: "Alle Videos von FOLX TV: Interviews, Auftritte und Musik News aus Schlager und Volksmusik.",
|
||
},
|
||
};
|
||
const sekPot = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||
const sek = SEKCIJE[sekPot];
|
||
if (sek) {
|
||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||
template = stripExistingMeta(template);
|
||
const sUrl = `${canonicalBase}${sek.pot}`;
|
||
const sImg = `${canonicalBase}/images/og-image.jpg`;
|
||
const sTags = [
|
||
`<meta property="og:title" content="${escapeHtml(sek.naslov)}" />`,
|
||
`<meta property="og:description" content="${escapeHtml(sek.opis)}" />`,
|
||
`<meta property="og:type" content="website" />`,
|
||
`<meta property="og:url" content="${escapeHtml(sUrl)}" />`,
|
||
`<meta property="og:image" content="${escapeHtml(sImg)}" />`,
|
||
`<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(sek.naslov)}" />`,
|
||
`<meta name="twitter:description" content="${escapeHtml(sek.opis)}" />`,
|
||
`<meta name="twitter:image" content="${escapeHtml(sImg)}" />`,
|
||
`<meta name="description" content="${escapeHtml(sek.opis)}" />`,
|
||
`<link rel="canonical" href="${escapeHtml(sUrl)}" />`,
|
||
`<title>${escapeHtml(sek.naslov)}</title>`,
|
||
].join("\n ");
|
||
template = template.replace(/<title>[^<]*<\/title>/, sTags);
|
||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ---- /aktuelles: zbirna stran rubrik, ki so vse noindex. Prej je vracala
|
||
// HTML naslovnice s canonical "/", kar je Google bral kot podvojeno
|
||
// naslovnico. Zdaj ima svoj canonical in noindex, follow. ----
|
||
if (/^\/aktuelles\/?$/.test(url.split("?")[0].split("#")[0])) {
|
||
let template = await fs.promises.readFile(indexPath, "utf-8");
|
||
template = stripExistingMeta(template);
|
||
const aktTitle = "Aktuelles | Folx Music Television";
|
||
template = template.replace(/<title>[^<]*<\/title>/, [
|
||
`<link rel="canonical" href="${canonicalBase}/aktuelles" />`,
|
||
`<title>${escapeHtml(aktTitle)}</title>`,
|
||
].join("\n "));
|
||
template = setRobots(template, "noindex, follow");
|
||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||
return;
|
||
}
|
||
|
||
const infoPath = pathOnly.replace(/\/+$/, "");
|
||
const info = INFO_SEO[infoPath];
|
||
if (info) {
|
||
let template = stripExistingMeta(await fs.promises.readFile(indexPath, "utf-8"));
|
||
const pageUrl = `${canonicalBase}${infoPath}`;
|
||
const title = `${info.title} | FOLX TV`;
|
||
const ld = { "@context": "https://schema.org", "@type": info.type,
|
||
"@id": `${pageUrl}#webpage`, url: pageUrl, name: title, description: info.description,
|
||
inLanguage: "de-DE", isPartOf: { "@id": `${canonicalBase}/#website` }, publisher: PUBLISHER(canonicalBase) };
|
||
template = template.replace(/<title>[^<]*<\/title>/, [
|
||
`<title>${escapeHtml(title)}</title>`,
|
||
`<meta name="description" content="${escapeHtml(info.description)}" />`,
|
||
`<link rel="canonical" href="${pageUrl}" />`,
|
||
`<meta property="og:title" content="${escapeHtml(title)}" />`,
|
||
`<meta property="og:description" content="${escapeHtml(info.description)}" />`,
|
||
`<meta property="og:type" content="website" />`,
|
||
`<meta property="og:url" content="${pageUrl}" />`,
|
||
`<meta property="og:image" content="${canonicalBase}/og-image.jpg" />`,
|
||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
|
||
`<meta name="twitter:description" content="${escapeHtml(info.description)}" />`,
|
||
`<meta name="twitter:image" content="${canonicalBase}/og-image.jpg" />`,
|
||
`<script id="info-jsonld" type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
|
||
].join("\n"));
|
||
res.status(200).type("html").send(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\/?$/, /^\/video\/[^/]+\/?$/, /^\/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);
|
||
});
|
||
}
|