File: //proc/self/root/tmp/generate-seo-pages.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { featuredSeoLinks, seoHub, seoLandingCatalog } from '../src/modules/seo/catalog.js';
const publicDir = path.resolve('/home/bewertemich.de/web/public');
const manifestPath = path.join(publicDir, 'landing-media', 'seed-manifest.json');
const seoManifestPath = path.join(publicDir, 'seo-manifest.json');
const siteBase = 'https://bewertemich.de';
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\"/g, '"');
const escapeAttr = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\"/g, '"')
.replace(/'/g, ''');
const seoTrackAttributes = (eventName, target = '', position = '') =>
`data-seo-track-event="${escapeAttr(eventName)}" data-seo-track-target="${escapeAttr(target)}" data-seo-track-position="${escapeAttr(position)}"`;
const slugifyEntry = (slug) => slug.replace(/-/g, '_');
const renderFaqSchema = (page) => ({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: (page.faqs || []).map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: {
'@type': 'Answer',
text: item.a
}
}))
});
const renderPageSchema = (page, canonical) => ({
'@context': 'https://schema.org',
'@type': 'Article',
headline: page.heading,
description: page.metaDescription,
inLanguage: 'de-DE',
about: page.tags || [],
mainEntityOfPage: canonical,
isAccessibleForFree: true
});
const readManifest = async () => {
const raw = await fs.readFile(manifestPath, 'utf8');
return JSON.parse(raw);
};
const buildMaps = (manifest) => ({
peopleBySlug: new Map((manifest.people || []).map((item) => [item.slug, item])),
comparisonBySlug: new Map((manifest.comparisons || []).map((item) => [item.slug, item]))
});
const buildRelatedMap = () => {
const map = new Map();
for (const item of seoLandingCatalog) map.set(item.slug, item);
map.set(seoHub.slug, seoHub);
return map;
};
const renderSampleCards = (page, peopleBySlug) => {
const people = (page.sampleSlugs || [])
.map((slug) => peopleBySlug.get(slug))
.filter(Boolean)
.slice(0, 3);
return people.map((person) => `
<article class="card photo-card">
<a href="${person.url}">
<img class="card-media" src="${person.imageUrl}" alt="${escapeHtml(person.title || person.displayName)}" loading="lazy" />
</a>
<div class="photo-card-body">
<span class="eyebrow">${escapeHtml(person.displayName)} · ${escapeHtml(person.city)}</span>
<h3>${escapeHtml(person.title || person.displayName)}</h3>
<p>Fiktives Seed-Foto aus Deutschland, geeignet fuer realistische iPhone-Style-Tests.</p>
<div class="metric-row">
<span class="metric">Foto #${person.photoId}</span>
<span class="metric">${person.variantPhotoId ? 'A/B moeglich' : 'Einzelbild'}</span>
</div>
</div>
</article>
`).join('');
};
const renderRelatedLinks = (page, relatedMap) => {
const items = (page.related || [])
.map((slug) => relatedMap.get(slug))
.filter(Boolean)
.slice(0, 3);
return items.map((item) => `
<a class="related-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'related-links')}>
<strong>${escapeHtml(item.title || item.heading)}</strong>
<span>${escapeHtml(item.subtitle || item.intro || '')}</span>
</a>
`).join('');
};
const trackingPixel = (slug, surface) => `
<script>
(function () {
const pageSlug = ${JSON.stringify(slug)};
const surface = ${JSON.stringify(surface)};
const toJson = (value) => {
try {
return JSON.stringify(value);
} catch {
return '{}';
}
};
const postEvent = (eventName, meta) => {
const payload = {
event: eventName,
source: 'seo_static',
surface,
entryPoint: pageSlug,
path: window.location.pathname,
referrer: window.document.referrer || '',
meta: meta || {}
};
const body = toJson(payload);
const endpoint = '/api/public/events';
if (navigator && typeof navigator.sendBeacon === 'function') {
const blob = typeof Blob === 'function' ? new Blob([body], { type: 'application/json;charset=utf-8' }) : null;
if (blob && navigator.sendBeacon(endpoint, blob)) return;
}
fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
credentials: 'same-origin'
}).catch(() => {});
};
postEvent('seo_landing_view', { position: 'page-load' });
for (const element of document.querySelectorAll('[data-seo-track-event]')) {
element.addEventListener('click', () => {
const eventName = element.getAttribute('data-seo-track-event') || 'seo_upload_click';
postEvent(eventName, {
target: element.getAttribute('data-seo-track-target') || '',
position: element.getAttribute('data-seo-track-position') || ''
});
});
}
})();
</script>`;
const renderPage = (page, maps, relatedMap) => {
const canonical = `${siteBase}/${page.slug}/`;
const comparison = maps.comparisonBySlug.get(page.compareSlug);
const comparisonImage = comparison ? `${siteBase}/s/compare/${comparison.id}/og.jpg` : `${siteBase}${page.heroImage}`;
const comparisonUrl = comparison ? `/compare/${comparison.id}` : `/upload?entry=seo_static_${slugifyEntry(page.slug)}`;
const comparisonLabel = comparison ? (comparison.slug || 'compare') : 'upload';
const sampleCards = renderSampleCards(page, maps.peopleBySlug);
const relatedLinks = renderRelatedLinks(page, relatedMap);
const schema = [renderPageSchema(page, canonical), renderFaqSchema(page)];
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtml(page.title)} | bewertemich.de</title>
<meta name="description" content="${escapeHtml(page.metaDescription)}" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(page.title)}" />
<meta property="og:description" content="${escapeHtml(page.metaDescription)}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${siteBase}${page.heroImage}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(page.title)}" />
<meta name="twitter:description" content="${escapeHtml(page.metaDescription)}" />
<meta name="twitter:image" content="${siteBase}${page.heroImage}" />
<link rel="stylesheet" href="/landing.css" />
<script type="application/ld+json">${JSON.stringify(schema)}</script>
${trackingPixel(page.slug, 'seo_landing')}
</head>
<body>
<main class="shell">
<section class="hero hero-split">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">${escapeHtml(page.tags?.[0] || 'Profilbild')}</span>
<span class="pill">Invite first</span>
<span class="pill">DE Longtail</span>
</div>
<h1>${escapeHtml(page.heading)}</h1>
<p>${escapeHtml(page.subtitle)}</p>
<p>${escapeHtml(page.intro)}</p>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'hero')}>${escapeHtml(page.ctaLabel)}</a>
<a class="btn btn-secondary" href="/top10">Oeffentliche Rankings ansehen</a>
</div>
</div>
<div class="hero-visual">
<img class="hero-art" src="${page.heroImage}" alt="${escapeHtml(page.heroAlt)}" />
<div class="hero-note">
<strong>${escapeHtml(page.proofTitle)}</strong>
<p>${escapeHtml(page.proofText)}</p>
</div>
</div>
</section>
<section class="proof-strip">
<article class="proof-card">
<strong>Belastbare Rankings</strong>
<p>Oeffentliche Toplisten starten erst ab 5 Stimmen und 4 unabhaengigen Votern.</p>
</article>
<article class="proof-card">
<strong>Invite first</strong>
<p>WhatsApp und Telegram liefern meist die schnellsten ersten Stimmen fuer ein klares Signal.</p>
</article>
<article class="proof-card">
<strong>Vergleich statt Chaos</strong>
<p>Wenn zwei Bilder eng sind, entscheidet der A/B-Modus schneller als jede Textdiskussion.</p>
</article>
</section>
<section class="grid cols-3">
${page.bullets.map((item) => `
<article class="card">
<span class="eyebrow">${escapeHtml(item.kicker)}</span>
<h2>${escapeHtml(item.title)}</h2>
<p>${escapeHtml(item.text)}</p>
</article>
`).join('')}
</section>
<section class="section-header">
<h2>Beispielbilder fuer ${escapeHtml(page.title)}</h2>
<p>Die Beispielbilder stammen aus dem Seed fuer bewertemich.de und zeigen realistische Smartphone-Aufnahmen statt austauschbarer Stockfotos.</p>
</section>
<section class="grid cols-3">
${sampleCards}
</section>
<section class="media-panel">
<img src="${comparisonImage}" alt="Share- oder Vergleichskarte fuer ${escapeHtml(page.title)}" loading="lazy" />
<div class="card">
<span class="eyebrow">Vergleichsmodus</span>
<h2>Wenn zwei Bilder nahe beieinander liegen, gewinnt der direkte Test</h2>
<p>Genau hier kippt die Entscheidung oft. Statt beide Fotos irgendwie okay zu finden, laesst du sie gegeneinander antreten und schaust auf den klaren Sieger.</p>
<ol class="list">
${page.steps.map((item) => `<li><strong>${escapeHtml(item.title)}:</strong> ${escapeHtml(item.text)}</li>`).join('')}
</ol>
<div class="actions">
<a class="btn btn-primary" href="${comparisonUrl}" ${seoTrackAttributes('seo_related_click', comparisonLabel, 'media-comparison')}>Beispiel ansehen</a>
<a class="btn btn-secondary dark" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'media-cta')}>Eigenes Foto testen</a>
</div>
</div>
</section>
<section class="card">
<div class="section-header compact">
<h2>FAQ zu ${escapeHtml(page.title)}</h2>
</div>
<div class="faq-list">
${(page.faqs || []).map((item) => `
<article class="faq-item">
<h3>${escapeHtml(item.q)}</h3>
<p>${escapeHtml(item.a)}</p>
</article>
`).join('')}
</div>
</section>
<section class="card">
<div class="section-header compact">
<h2>Verwandte Themen</h2>
<p>Interne DE-Longtails fuer weitere Suchintentionen rund um Profilbild, Foto-Test und Vergleich.</p>
</div>
<div class="related-grid">
${relatedLinks}
</div>
</section>
<section class="cta">
<h2>${escapeHtml(page.heading)} direkt ausprobieren</h2>
<p>Vom Suchintent direkt in den Flow: hochladen, Invite-Link teilen, Siegerbild uebernehmen.</p>
<div class="footer-actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'footer')}>${escapeHtml(page.ctaLabel)}</a>
<a class="btn btn-secondary" href="/profilbild-ratgeber/">Zum Ratgeber-Hub</a>
</div>
</section>
<div class="footer">bewertemich.de · ${escapeHtml(page.title)} · Deutschland</div>
</main>
</body>
</html>`;
};
const renderHubPage = (relatedMap) => {
const canonical = `${siteBase}/${seoHub.slug}/`;
const cards = seoLandingCatalog.map((item) => `
<a class="hub-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'hub-grid')}>
<span class="eyebrow">${escapeHtml(item.tags?.[0] || 'Profilbild')}</span>
<strong>${escapeHtml(item.title)}</strong>
<span>${escapeHtml(item.subtitle)}</span>
</a>
`).join('');
const schema = [{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: seoHub.heading,
description: seoHub.intro,
url: canonical
}];
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtml(seoHub.title)} | bewertemich.de</title>
<meta name="description" content="${escapeHtml(seoHub.intro)}" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(seoHub.title)}" />
<meta property="og:description" content="${escapeHtml(seoHub.intro)}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${siteBase}${seoHub.heroImage}" />
<link rel="stylesheet" href="/landing.css" />
<script type="application/ld+json">${JSON.stringify(schema)}</script>
${trackingPixel(seoHub.slug, 'seo_hub')}
</head>
<body>
<main class="shell">
<section class="hero hero-split">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">Hub</span>
<span class="pill">DE-SEO</span>
<span class="pill">Profilbild</span>
</div>
<h1>${escapeHtml(seoHub.heading)}</h1>
<p>${escapeHtml(seoHub.subtitle)}</p>
<p>${escapeHtml(seoHub.intro)}</p>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(seoHub.slug)}" ${seoTrackAttributes('seo_upload_click', seoHub.slug, 'hub-hero')}>Jetzt Foto testen</a>
<a class="btn btn-secondary" href="/top10">Toplisten ansehen</a>
</div>
</div>
<div class="hero-visual">
<img class="hero-art" src="${seoHub.heroImage}" alt="${escapeHtml(seoHub.heroAlt)}" />
<div class="hero-note">
<strong>Skalierbarer DE-Longtail-Hub</strong>
<p>Alle relevanten Themen rund um Profilbilder, Dating-Fotos, Business-Portraits und Vergleiche laufen hier zusammen.</p>
</div>
</div>
</section>
<section class="section-header">
<h2>Alle Ratgeberseiten</h2>
<p>Von der Suchanfrage direkt in passende Tests und Vergleiche. Die vier Kernseiten bleiben prominent, der Rest deckt spezifische Longtails ab.</p>
</section>
<section class="hub-grid">
${cards}
</section>
<section class="card">
<div class="section-header compact">
<h2>Direkteinstiege</h2>
</div>
<div class="related-grid">
${featuredSeoLinks.map((item) => `
<a class="related-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'featured')}>
<strong>${escapeHtml(item.title)}</strong>
<span>Zu diesem Thema direkt in die optimierte Landingpage springen.</span>
</a>
`).join('')}
</div>
</section>
<section class="cta">
<h2>Lieber direkt testen?</h2>
<p>Dann ueberspringst du die Theorie und startest sofort mit deinem Profilbild oder Vergleich.</p>
<div class="footer-actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(seoHub.slug)}" ${seoTrackAttributes('seo_upload_click', seoHub.slug, 'hub-footer')}>Upload starten</a>
<a class="btn btn-secondary" href="/top10">Toplisten ansehen</a>
</div>
</section>
<div class="footer">bewertemich.de · ${escapeHtml(seoHub.title)} · Deutschland</div>
</main>
</body>
</html>`;
};
const renderSitemap = () => {
const routes = [
'/',
'/upload',
'/top10',
`/${seoHub.slug}/`,
...seoLandingCatalog.map((item) => `/${item.slug}/`)
];
const today = new Date().toISOString().slice(0, 10);
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes.map((route) => ` <url>
<loc>${siteBase}${route}</loc>
<lastmod>${today}</lastmod>
</url>`).join('\n')}
</urlset>`;
};
const writePage = async (slug, html) => {
const dir = path.join(publicDir, slug);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.html'), html, 'utf8');
};
const buildSeoManifest = () => ({
generatedAt: new Date().toISOString(),
siteBase,
hub: { slug: seoHub.slug, title: seoHub.title, intro: seoHub.intro },
featuredLinks: featuredSeoLinks,
landingPages: seoLandingCatalog.map((page) => ({
slug: page.slug,
title: page.title,
tags: page.tags || [],
faqCount: (page.faqs || []).length
}))
});
const main = async () => {
const manifest = await readManifest();
const maps = buildMaps(manifest);
const relatedMap = buildRelatedMap();
for (const page of seoLandingCatalog) {
await writePage(page.slug, renderPage(page, maps, relatedMap));
}
await writePage(seoHub.slug, renderHubPage(relatedMap));
await fs.writeFile(path.join(publicDir, 'sitemap.xml'), renderSitemap(), 'utf8');
await fs.writeFile(seoManifestPath, JSON.stringify(buildSeoManifest(), null, 2), 'utf8');
};
await main();