Press
This commit is contained in:
150
greenlns-landing/app/press/page.tsx
Normal file
150
greenlns-landing/app/press/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import styles from './press.module.css'
|
||||
|
||||
const releaseUrl =
|
||||
'https://news.prfree.org/@greenlenspro/greenlens-publishes-a-step-by-step-guide-to-troubleshooting-common-houseplant-problems-0ochpt6gr315'
|
||||
const routineReleaseUrl =
|
||||
'https://news.newswirebiz.com/@greenlenspro/greenlens-shares-a-5-step-plant-care-routine-for-when-houseplants-start-showing-stress-39kipufgt3xh'
|
||||
const openPrReleaseUrl =
|
||||
'https://www.openpr.de/news/1319466/Zimmerpflanzen-retten-statt-raten-6-Fragen-vor-jedem-Pflege-Schritt.html'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Press | GreenLens',
|
||||
description: 'Latest GreenLens news, press releases, and company announcements.',
|
||||
alternates: { canonical: '/press' },
|
||||
}
|
||||
|
||||
export default function PressPage() {
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.contour} aria-hidden="true" />
|
||||
<div className={styles.heroInner}>
|
||||
<header className={styles.nav}>
|
||||
<Link href="/" className={styles.brand} aria-label="GreenLens home">
|
||||
<span className={styles.brandMark} aria-hidden="true">
|
||||
<LeafIcon />
|
||||
</span>
|
||||
GreenLens
|
||||
</Link>
|
||||
<nav aria-label="Press navigation" className={styles.navLinks}>
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/support">Support</Link>
|
||||
<a href="https://apps.apple.com/de/app/plant-doctor-greenlens-pro/id6759843546?l=en-GB" target="_blank" rel="noreferrer">
|
||||
Get the app
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className={styles.heroCopy}>
|
||||
<p className={styles.kicker}>GreenLens press office</p>
|
||||
<h1>Newsroom</h1>
|
||||
<div className={styles.rule} />
|
||||
<p>Latest updates, product news, and practical plant-care resources from the GreenLens team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.releases} aria-labelledby="latest-release">
|
||||
<div className={styles.content}>
|
||||
<div className={styles.sectionHeading}>
|
||||
<span className={styles.sectionIcon} aria-hidden="true"><LeafIcon /></span>
|
||||
<h2 id="latest-release">Latest releases</h2>
|
||||
<span className={styles.headingLine} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className={styles.releaseList}>
|
||||
<ReleaseCard
|
||||
title="Zimmerpflanzen retten statt raten: 6 Fragen vor jedem Pflege-Schritt"
|
||||
lede="Der deutschsprachige Ratgeber zeigt, wie Pflanzenfans Symptome einordnen, vor einer Veränderung die wichtigsten Hinweise prüfen und mit ruhigen, nachvollziehbaren Schritten handeln können."
|
||||
points={[
|
||||
'Sichtbare Veränderungen genauer beschreiben statt sofort zu reagieren.',
|
||||
'Wasser, Licht und jüngste Änderungen am Standort prüfen.',
|
||||
'Hinweise auf Schädlinge oder Krankheiten früh erkennen.',
|
||||
'Mit kleinen Anpassungen arbeiten und die Entwicklung dokumentieren.',
|
||||
]}
|
||||
href={openPrReleaseUrl}
|
||||
date="July 30, 2026"
|
||||
/>
|
||||
<ReleaseCard
|
||||
title="GreenLens Shares a 5-Step Plant Care Routine for When Houseplants Start Showing Stress"
|
||||
lede="A practical five-step routine helps indoor plant owners assess common warning signs before changing watering, light, fertilizer, or treatments."
|
||||
points={[
|
||||
'Define the symptom before treating it.',
|
||||
'Review recent changes in the plant’s environment.',
|
||||
'Check the root zone, drainage, and available light.',
|
||||
'Make one low-risk adjustment and document the response.',
|
||||
]}
|
||||
href={routineReleaseUrl}
|
||||
/>
|
||||
<ReleaseCard
|
||||
title="GreenLens Publishes a Step-by-Step Guide to Troubleshooting Common Houseplant Problems"
|
||||
lede="The new guide gives indoor plant owners a practical way to assess yellow leaves, brown tips, wilting, pests, light, and watering before making a care change."
|
||||
points={[
|
||||
'Read symptom patterns before reacting to a single leaf.',
|
||||
'Check watering, drainage, light, and recent changes one at a time.',
|
||||
'Build a simple photo-and-notes history to spot recurring patterns.',
|
||||
'Know when a plant issue needs professional or urgent attention.',
|
||||
]}
|
||||
href={releaseUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseCard({
|
||||
title,
|
||||
lede,
|
||||
points,
|
||||
href,
|
||||
date = 'July 29, 2026',
|
||||
}: {
|
||||
title: string
|
||||
lede: string
|
||||
points: string[]
|
||||
href: string
|
||||
date?: string
|
||||
}) {
|
||||
return (
|
||||
<article className={styles.releaseCard}>
|
||||
<div className={styles.releaseMeta}>
|
||||
<time dateTime={date === 'July 30, 2026' ? '2026-07-30' : '2026-07-29'}>{date}</time>
|
||||
<span>Press release</span>
|
||||
</div>
|
||||
|
||||
<h3>{title}</h3>
|
||||
<p className={styles.lede}>{lede}</p>
|
||||
|
||||
<div className={styles.keyPoints}>
|
||||
<h4>What the guide covers</h4>
|
||||
<ul>
|
||||
{points.map((point) => <li key={point}><LeafBullet /> {point}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.releaseFooter}>
|
||||
<p>Source: <strong>GreenLens Press Office</strong></p>
|
||||
<a href={href} target="_blank" rel="noreferrer" className={styles.releaseLink}>
|
||||
Read full release <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LeafIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M19.5 3.5C12.1 3.6 6.4 7.2 5.1 13.1c-.8 3.7 1.4 6.6 4.8 6.6 6.3 0 9.9-7.7 9.6-16.2Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
|
||||
<path d="M4.4 20.5c3.8-4.6 7.7-7.8 12.4-10.1M10.2 14.5l.7 4.2M13.9 10.5l3.1 1" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LeafBullet() {
|
||||
return <span className={styles.bullet} aria-hidden="true"><LeafIcon /></span>
|
||||
}
|
||||
288
greenlns-landing/app/press/press.module.css
Normal file
288
greenlns-landing/app/press/press.module.css
Normal file
@@ -0,0 +1,288 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
color: #12332e;
|
||||
background: #f7f5ef;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 430px;
|
||||
color: #f7f5ef;
|
||||
background: #0e1b24;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 70% 48%, rgba(47, 165, 114, 0.09), transparent 30%);
|
||||
}
|
||||
|
||||
.contour {
|
||||
position: absolute;
|
||||
right: -5%;
|
||||
bottom: -45%;
|
||||
width: min(720px, 65vw);
|
||||
aspect-ratio: 1;
|
||||
opacity: 0.48;
|
||||
border: 1px solid rgba(111, 205, 151, 0.35);
|
||||
border-radius: 54% 46% 58% 42%;
|
||||
box-shadow:
|
||||
42px -32px 0 -1px rgba(111, 205, 151, 0.26),
|
||||
88px -70px 0 -1px rgba(111, 205, 151, 0.18),
|
||||
132px -114px 0 -1px rgba(111, 205, 151, 0.12);
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.heroInner,
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1180px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid rgba(247, 245, 239, 0.13);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-family: var(--body);
|
||||
font-size: 1.28rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.brandMark,
|
||||
.sectionIcon,
|
||||
.bullet {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #61c98c;
|
||||
}
|
||||
|
||||
.brandMark { width: 27px; height: 27px; }
|
||||
.brandMark svg { width: 100%; height: 100%; }
|
||||
|
||||
.navLinks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.navLinks a {
|
||||
color: rgba(247, 245, 239, 0.76);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.navLinks a:hover { color: #fff; }
|
||||
|
||||
.heroCopy {
|
||||
max-width: 630px;
|
||||
padding: 74px 0 88px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin-bottom: 17px;
|
||||
color: #78d8a1;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heroCopy h1 {
|
||||
color: #fff;
|
||||
font-family: var(--body);
|
||||
font-size: clamp(3.4rem, 7vw, 6.6rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.rule {
|
||||
width: 58px;
|
||||
height: 3px;
|
||||
margin: 28px 0 23px;
|
||||
border-radius: 4px;
|
||||
background: #61c98c;
|
||||
}
|
||||
|
||||
.heroCopy > p:last-child {
|
||||
max-width: 530px;
|
||||
color: rgba(247, 245, 239, 0.77);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.releases { padding: 72px 0 110px; }
|
||||
|
||||
.sectionHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid #8acdae;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sectionIcon svg { width: 22px; height: 22px; }
|
||||
|
||||
.sectionHeading h2 {
|
||||
color: #12332e;
|
||||
font-family: var(--body);
|
||||
font-size: clamp(1.45rem, 2vw, 1.9rem);
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.headingLine {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
margin-left: 18px;
|
||||
background: #9ad1b5;
|
||||
}
|
||||
|
||||
.releaseCard {
|
||||
padding: clamp(28px, 5vw, 58px);
|
||||
border: 1px solid #d0dbd3;
|
||||
border-radius: 16px;
|
||||
background: #fffefa;
|
||||
box-shadow: 0 18px 55px rgba(14, 51, 42, 0.06);
|
||||
}
|
||||
|
||||
.releaseList {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.releaseMeta,
|
||||
.releaseFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.releaseMeta time {
|
||||
color: #587069;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.releaseMeta span {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid #c5d6cd;
|
||||
border-radius: 7px;
|
||||
color: #276e51;
|
||||
font-size: 0.69rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.releaseCard h3 {
|
||||
max-width: 880px;
|
||||
margin: 27px 0 16px;
|
||||
color: #12332e;
|
||||
font-family: var(--body);
|
||||
font-size: clamp(1.75rem, 4vw, 3rem);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 820px;
|
||||
color: #536762;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.keyPoints {
|
||||
margin-top: 38px;
|
||||
padding: 25px 0 27px;
|
||||
border-top: 1px solid #d8e1da;
|
||||
border-bottom: 1px solid #d8e1da;
|
||||
}
|
||||
|
||||
.keyPoints h4 {
|
||||
margin-bottom: 17px;
|
||||
color: #2b7a58;
|
||||
font-family: var(--body);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.keyPoints ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 28px;
|
||||
}
|
||||
|
||||
.keyPoints li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
color: #4a5f59;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.bullet { width: 16px; min-width: 16px; height: 16px; margin-top: 3px; }
|
||||
.bullet svg { width: 100%; height: 100%; }
|
||||
|
||||
.releaseFooter { padding-top: 24px; }
|
||||
|
||||
.releaseFooter p {
|
||||
color: #697b75;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.releaseFooter strong { color: #347c5b; }
|
||||
|
||||
.releaseLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #236f50;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.releaseLink span { font-size: 1.2rem; transition: transform 160ms ease; }
|
||||
.releaseLink:hover span { transform: translateX(4px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero { min-height: 390px; }
|
||||
.heroInner, .content { width: min(100% - 32px, 1180px); }
|
||||
.nav { padding: 19px 0; }
|
||||
.navLinks { gap: 14px; }
|
||||
.navLinks a { font-size: 0.75rem; }
|
||||
.navLinks a:first-child { display: none; }
|
||||
.heroCopy { padding: 62px 0 68px; }
|
||||
.releases { padding: 48px 0 72px; }
|
||||
.headingLine { display: none; }
|
||||
.releaseMeta, .releaseFooter { align-items: flex-start; flex-direction: column; }
|
||||
.releaseMeta { gap: 12px; }
|
||||
.keyPoints ul { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -24,6 +24,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/press`,
|
||||
lastModified: new Date('2026-07-29'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/plant-identifier-app`,
|
||||
lastModified: new Date('2026-07-10'),
|
||||
|
||||
55
greenlns-landing/scripts/changed-urls-2026-07-27.json
Normal file
55
greenlns-landing/scripts/changed-urls-2026-07-27.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"host": "greenlenspro.com",
|
||||
"generatedAt": "2026-07-27",
|
||||
"reason": "Commits 50c0f33, 45a7704, 018b751. Title/Description/H1-Rewrites plus neue Content-Sections. Die 4 Problem-Pages ohne seoPages.ts-Diff sind enthalten, weil SeoCategoryPage.tsx, SeoSections.tsx, FAQ.tsx und CTA.tsx umgebaut wurden und damit jede SEO-Seite anders rendert. /de/* fehlt bewusst: die Route ist ein 301 auf die Root-Slugs.",
|
||||
"groups": {
|
||||
"home": [
|
||||
"https://greenlenspro.com/",
|
||||
"https://greenlenspro.com/en",
|
||||
"https://greenlenspro.com/es"
|
||||
],
|
||||
"moneyPagesEn": [
|
||||
"https://greenlenspro.com/plant-identifier-app",
|
||||
"https://greenlenspro.com/plant-disease-identifier",
|
||||
"https://greenlenspro.com/plant-care-app",
|
||||
"https://greenlenspro.com/plant-health-app",
|
||||
"https://greenlenspro.com/plant-scanner",
|
||||
"https://greenlenspro.com/houseplant-identifier",
|
||||
"https://greenlenspro.com/succulent-identifier",
|
||||
"https://greenlenspro.com/flower-scanner",
|
||||
"https://greenlenspro.com/identify-plant-photo",
|
||||
"https://greenlenspro.com/best-plant-identification-app"
|
||||
],
|
||||
"moneyPagesDe": [
|
||||
"https://greenlenspro.com/pflanzen-erkennen-app",
|
||||
"https://greenlenspro.com/pflanzen-erkennen-kostenlos",
|
||||
"https://greenlenspro.com/pflanzen-bestimmen",
|
||||
"https://greenlenspro.com/zimmerpflanzen-bestimmen",
|
||||
"https://greenlenspro.com/pflanzen-krankheiten-erkennen",
|
||||
"https://greenlenspro.com/pflanzen-pflege-app",
|
||||
"https://greenlenspro.com/giess-erinnerung-app",
|
||||
"https://greenlenspro.com/blumen-scanner"
|
||||
],
|
||||
"problemPagesDe": [
|
||||
"https://greenlenspro.com/braune-blattspitzen",
|
||||
"https://greenlenspro.com/gelbe-blaetter-zimmerpflanze",
|
||||
"https://greenlenspro.com/pflanze-haengt-nach-umtopfen",
|
||||
"https://greenlenspro.com/wurzelfaeule-erkennen",
|
||||
"https://greenlenspro.com/ueberwaessert-oder-zu-trocken",
|
||||
"https://greenlenspro.com/trauermuecken-blumenerde"
|
||||
],
|
||||
"comparison": [
|
||||
"https://greenlenspro.com/vs/picturethis",
|
||||
"https://greenlenspro.com/vs/plantum",
|
||||
"https://greenlenspro.com/vs/inaturalist",
|
||||
"https://greenlenspro.com/vs/google-lens"
|
||||
],
|
||||
"spanish": [
|
||||
"https://greenlenspro.com/es/identificador-de-plantas",
|
||||
"https://greenlenspro.com/es/app-para-cuidar-plantas",
|
||||
"https://greenlenspro.com/es/diagnosticar-enfermedades-plantas",
|
||||
"https://greenlenspro.com/es/escaner-de-plantas",
|
||||
"https://greenlenspro.com/es/comparar/google-lens"
|
||||
]
|
||||
}
|
||||
}
|
||||
329
greenlns-landing/scripts/submit-changed-urls.mjs
Normal file
329
greenlns-landing/scripts/submit-changed-urls.mjs
Normal file
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reicht eine kuratierte URL-Liste bei IndexNow (Bing, Yandex, Seznam, Naver)
|
||||
* und optional bei der Google Indexing API ein.
|
||||
*
|
||||
* Keine npm-Dependencies. Braucht Node 18+ (globales fetch).
|
||||
*
|
||||
* node scripts/submit-changed-urls.mjs --dry-run
|
||||
* node scripts/submit-changed-urls.mjs
|
||||
* node scripts/submit-changed-urls.mjs --google-only
|
||||
* node scripts/submit-changed-urls.mjs --urls scripts/changed-urls-2026-07-27.json
|
||||
*
|
||||
* Konfiguration (.env oder Umgebung):
|
||||
* INDEXNOW_KEY IndexNow-Key, muss als <key>.txt live erreichbar sein
|
||||
* GOOGLE_SERVICE_ACCOUNT Pfad zur Service-Account-JSON (Default: ./service_account.json)
|
||||
*
|
||||
* Hinweis zur Google Indexing API: offiziell unterstuetzt Google damit nur
|
||||
* JobPosting und BroadcastEvent. Fuer normale Seiten funktioniert es in der
|
||||
* Praxis oft, ist aber nicht zugesichert. Das Tageslimit liegt bei 200 URLs.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
// ---------------------------------------------------------------- CLI + env
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const hasFlag = (name) => argv.includes(`--${name}`);
|
||||
const getArg = (name, fallback) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
|
||||
const DRY_RUN = hasFlag('dry-run');
|
||||
const SKIP_PREFLIGHT = hasFlag('skip-preflight');
|
||||
const GOOGLE_ONLY = hasFlag('google-only');
|
||||
const INDEXNOW_ONLY = hasFlag('indexnow-only');
|
||||
const DO_INDEXNOW = !GOOGLE_ONLY;
|
||||
const DO_GOOGLE = !INDEXNOW_ONLY;
|
||||
|
||||
loadDotEnv(path.join(repoRoot, '.env'));
|
||||
loadDotEnv(path.join(repoRoot, '.env.local'));
|
||||
|
||||
function loadDotEnv(file) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = m[2].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- URL-Liste
|
||||
|
||||
const urlsFile = path.resolve(
|
||||
repoRoot,
|
||||
getArg('urls', 'scripts/changed-urls-2026-07-27.json')
|
||||
);
|
||||
|
||||
if (!fs.existsSync(urlsFile)) {
|
||||
fail(`URL-Datei nicht gefunden: ${urlsFile}`);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(urlsFile, 'utf8'));
|
||||
const groups = config.groups ?? {};
|
||||
const urls = [...new Set(Object.values(groups).flat())];
|
||||
|
||||
if (urls.length === 0) fail('Die URL-Liste ist leer.');
|
||||
|
||||
console.log(`\n Quelle ${path.relative(repoRoot, urlsFile)}`);
|
||||
console.log(` Host ${config.host}`);
|
||||
console.log(` URLs ${urls.length}`);
|
||||
for (const [name, list] of Object.entries(groups)) {
|
||||
console.log(` ${String(list.length).padStart(3)} ${name}`);
|
||||
}
|
||||
console.log(` Modus ${DRY_RUN ? 'DRY RUN (es wird nichts gesendet)' : 'LIVE'}`);
|
||||
console.log(
|
||||
` Kanaele ${[DO_INDEXNOW && 'IndexNow', DO_GOOGLE && 'Google Indexing API']
|
||||
.filter(Boolean)
|
||||
.join(' + ') || 'keine'}\n`
|
||||
);
|
||||
|
||||
// alle URLs muessen zum konfigurierten Host gehoeren
|
||||
const foreign = urls.filter((u) => new URL(u).host !== config.host);
|
||||
if (foreign.length) {
|
||||
fail(`Diese URLs passen nicht zu host="${config.host}":\n ${foreign.join('\n ')}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Preflight
|
||||
|
||||
if (!SKIP_PREFLIGHT) {
|
||||
console.log(' Preflight: pruefe, ob jede URL live 200 liefert ...');
|
||||
const bad = [];
|
||||
for (const url of urls) {
|
||||
const status = await statusOf(url);
|
||||
if (status !== 200) bad.push(`${status} ${url}`);
|
||||
}
|
||||
if (bad.length) {
|
||||
console.error('\n Diese URLs antworten nicht mit 200:');
|
||||
for (const b of bad) console.error(` ${b}`);
|
||||
fail(
|
||||
'Abbruch. Eine URL einzureichen, die 404 oder 500 liefert, schadet mehr als sie nutzt.\n' +
|
||||
' Deploy pruefen, dann erneut ausfuehren (oder --skip-preflight setzen).'
|
||||
);
|
||||
}
|
||||
console.log(` Preflight OK: alle ${urls.length} URLs liefern 200.\n`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- IndexNow
|
||||
|
||||
if (DO_INDEXNOW) {
|
||||
const key = process.env.INDEXNOW_KEY || detectKeyInPublicDir();
|
||||
if (!key) {
|
||||
fail(
|
||||
'Kein IndexNow-Key gefunden.\n' +
|
||||
' Entweder INDEXNOW_KEY in .env setzen, oder eine <key>.txt in public/\n' +
|
||||
' ablegen, die genau den Key als Inhalt hat (bing.com/indexnow).'
|
||||
);
|
||||
}
|
||||
if (!process.env.INDEXNOW_KEY) {
|
||||
console.log(` IndexNow-Key aus public/ erkannt: ${key}`);
|
||||
}
|
||||
|
||||
const keyLocation = `https://${config.host}/${key}.txt`;
|
||||
|
||||
if (!SKIP_PREFLIGHT) {
|
||||
const res = await fetch(keyLocation).catch(() => null);
|
||||
const body = res && res.ok ? (await res.text()).trim().replace(/^/, '') : null;
|
||||
if (!res || !res.ok) {
|
||||
fail(`IndexNow-Key-Datei nicht erreichbar: ${keyLocation}`);
|
||||
}
|
||||
if (body !== key) {
|
||||
fail(
|
||||
`IndexNow-Key-Datei enthaelt nicht den erwarteten Key.\n` +
|
||||
` ${keyLocation}\n erwartet: ${key}\n gefunden: ${JSON.stringify(body)}`
|
||||
);
|
||||
}
|
||||
console.log(` IndexNow-Key verifiziert: ${keyLocation}`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
host: config.host,
|
||||
key,
|
||||
keyLocation,
|
||||
urlList: urls,
|
||||
};
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [dry-run] POST https://api.indexnow.org/indexnow (${urls.length} URLs)\n`);
|
||||
} else {
|
||||
const res = await fetch('https://api.indexnow.org/indexnow', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
// 200 = angenommen, 202 = angenommen, Key wird noch geprueft
|
||||
if (res.status === 200 || res.status === 202) {
|
||||
console.log(` IndexNow OK (${res.status}): ${urls.length} URLs uebermittelt.\n`);
|
||||
} else {
|
||||
console.error(` IndexNow fehlgeschlagen (${res.status}): ${await res.text()}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Google Indexing API
|
||||
|
||||
if (DO_GOOGLE) {
|
||||
const saPath = path.resolve(
|
||||
repoRoot,
|
||||
process.env.GOOGLE_SERVICE_ACCOUNT || 'service_account.json'
|
||||
);
|
||||
|
||||
if (!fs.existsSync(saPath)) {
|
||||
console.warn(
|
||||
` Google Indexing API uebersprungen: ${path.relative(repoRoot, saPath)} nicht gefunden.\n` +
|
||||
` Pfad ueber GOOGLE_SERVICE_ACCOUNT setzen oder --indexnow-only nutzen.\n`
|
||||
);
|
||||
} else if (urls.length > 200) {
|
||||
fail(`Die Google Indexing API erlaubt 200 URLs pro Tag, die Liste hat ${urls.length}.`);
|
||||
} else {
|
||||
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
|
||||
console.log(` Google Indexing API als ${sa.client_email}`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [dry-run] ${urls.length}x urlNotifications:publish (URL_UPDATED)\n`);
|
||||
} else {
|
||||
const token = await getAccessToken(sa);
|
||||
let ok = 0;
|
||||
const failures = [];
|
||||
|
||||
for (const url of urls) {
|
||||
const res = await fetch(
|
||||
'https://indexing.googleapis.com/v3/urlNotifications:publish',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url, type: 'URL_UPDATED' }),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
ok++;
|
||||
console.log(` ok ${url}`);
|
||||
} else {
|
||||
const text = await res.text();
|
||||
failures.push(`${res.status} ${url}\n ${text.slice(0, 200)}`);
|
||||
console.log(` FEHL ${res.status} ${url}`);
|
||||
}
|
||||
await sleep(120); // bleibt unter dem Minutenlimit
|
||||
}
|
||||
|
||||
console.log(`\n Google: ${ok}/${urls.length} akzeptiert.`);
|
||||
if (failures.length) {
|
||||
console.error('\n Fehlgeschlagen:');
|
||||
for (const f of failures) console.error(` ${f}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Fertig.\n');
|
||||
if (!DRY_RUN) {
|
||||
console.log(' Denk dran: Google Search Console hat keine API fuer "Indexierung');
|
||||
console.log(' beantragen". Die wichtigsten Seiten dort weiterhin manuell anstossen.\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Helpers
|
||||
|
||||
/**
|
||||
* Sucht in public/ nach einer <key>.txt, deren Inhalt exakt dem Dateinamen
|
||||
* entspricht. Genau so verlangt IndexNow die Key-Datei.
|
||||
*/
|
||||
function detectKeyInPublicDir() {
|
||||
const publicDir = path.join(repoRoot, 'public');
|
||||
if (!fs.existsSync(publicDir)) return null;
|
||||
|
||||
const candidates = [];
|
||||
for (const file of fs.readdirSync(publicDir)) {
|
||||
if (!file.endsWith('.txt')) continue;
|
||||
const name = file.slice(0, -4);
|
||||
if (!/^[a-f0-9]{8,128}$/i.test(name)) continue;
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(path.join(publicDir, file), 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// BOM und Null-Bytes aus Windows-Editoren wegräumen
|
||||
const normalised = content.replace(/ | ||||