From b60a28dc9c1b6cf437493d604096c6464a0888d3 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 17 Sep 2026 10:15:28 +0000 Subject: [PATCH] web: Entitaeten im JSON-LD verknuepft, News-Sitemap und llms.txt (Roadmap 2.1, 2.2, 2.7) - App\Support\StructuredData: Portal-Organization als Publisher mit @id und Logo (ImageObject), Newsroom-@id; NewsArticle.author auf der Meldung und Organization auf der Newsroom-Seite beschreiben jetzt dieselbe Entitaet. Startseiten nutzen denselben Publisher. - /sitemap-news.xml je Spoke (NewsSitemapController): im Archiv sichtbare Meldungen der letzten 48 h, max. 1.000, news:publication mit Portalname und Sprache; 15 min gecacht, mit den anderen Sitemaps geleert; in robots.txt der Spokes. Hub liefert leere Liste. - /llms.txt je Host (LlmsTxtController): sachliche Beschreibung, URL-Aufbau, aktive Kategorien, Seiten fuer Unternehmen, Rechtliches; Hub verweist auf beide Portale. Keine Aussagen ueber KI-Nennungen. - Protokoll Teil 5 (Block 2) inkl. Betreiber-Schritten fuer das Publisher Center und Befund zu 2.5/E5; Roadmap 2.1/2.7 abgehakt. Co-Authored-By: Claude Opus 5 (1M context) --- .../Controllers/Web/LlmsTxtController.php | 123 ++++++++++++++++++ .../Controllers/Web/NewsSitemapController.php | 78 +++++++++++ app/Support/StructuredData.php | 53 ++++++++ app/Support/WebCaches.php | 1 + dev/2026-09-01/roadmap-2026.md | 8 +- dev/2026-09-01/umsetzung-protokoll.md | 93 +++++++++++++ .../views/web/businessportal24.blade.php | 10 +- resources/views/web/newsroom.blade.php | 4 +- resources/views/web/presseecho.blade.php | 10 +- resources/views/web/release-detail.blade.php | 11 +- resources/views/web/sitemap-news.blade.php | 16 +++ routes/web.php | 7 + tests/Feature/Web/LlmsTxtTest.php | 45 +++++++ tests/Feature/Web/NewsSitemapTest.php | 74 +++++++++++ tests/Feature/Web/StructuredDataTest.php | 11 +- 15 files changed, 521 insertions(+), 23 deletions(-) create mode 100644 app/Http/Controllers/Web/LlmsTxtController.php create mode 100644 app/Http/Controllers/Web/NewsSitemapController.php create mode 100644 app/Support/StructuredData.php create mode 100644 resources/views/web/sitemap-news.blade.php create mode 100644 tests/Feature/Web/LlmsTxtTest.php create mode 100644 tests/Feature/Web/NewsSitemapTest.php diff --git a/app/Http/Controllers/Web/LlmsTxtController.php b/app/Http/Controllers/Web/LlmsTxtController.php new file mode 100644 index 00000000..814e726b --- /dev/null +++ b/app/Http/Controllers/Web/LlmsTxtController.php @@ -0,0 +1,123 @@ +getHost(); + $base = rtrim($request->getSchemeAndHttpHost(), '/'); + $isHub = str_contains($host, 'pressekonto'); + + $text = Cache::remember('web.llms.'.$host, 3600, fn (): string => $isHub ? $this->hub($base) : $this->portal($base)); + + return response($text, 200, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + private function hub(string $base): string + { + $presseecho = rtrim((string) config('domains.domains.presseecho.url'), '/'); + $businessportal = rtrim((string) config('domains.domains.businessportal24.url'), '/'); + + return implode("\n", [ + '# pressekonto', + '', + '> Publisher-Bereich der Presseportale presseecho und businessportal24: Unternehmen schreiben hier Pressemitteilungen, lassen sie automatisiert prüfen und veröffentlichen sie auf beiden Portalen.', + '', + 'Die Meldungen selbst erscheinen nicht auf dieser Domain, sondern auf den beiden Portalen. Jede Meldung trägt dort NewsArticle-Markup (schema.org), jeder Newsroom Organization-Markup; Urheber einer Meldung ist immer das genannte Unternehmen, nicht das Portal.', + '', + '## Informationen', + '', + '- [Hilfe]('.$base.'/de/hilfe): Ablauf von der Registrierung bis zur Veröffentlichung', + '- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung): Anlass, Aufbau, Sprache, Prüfablauf', + '- [FAQ]('.$base.'/de/faq)', + '- [Richtlinien]('.$base.'/de/richtlinien): Was veröffentlicht wird und was nicht', + '- [API]('.$base.'/de/api): Einlieferung per REST-API; OpenAPI-Spezifikation unter '.$base.'/docs/api/v1', + '', + '## Portale', + '', + '- [presseecho]('.$presseecho.'/llms.txt): Pressemitteilungen aus Wirtschaft und Industrie', + '- [businessportal24]('.$businessportal.'/llms.txt): Pressemitteilungen aus Deutschland, Österreich und der Schweiz', + '', + '## Rechtliches', + '', + '- [Impressum]('.$base.'/de/impressum)', + '- [Datenschutz]('.$base.'/de/datenschutz)', + '- [AGB]('.$base.'/de/agb)', + '- [Inhalte melden]('.$base.'/de/melden)', + '', + ]); + } + + private function portal(string $base): string + { + $context = WebPortalContext::fromRequest(); + $brand = (array) ($context->domainConfig['brand'] ?? []); + $name = ($brand['name'] ?? '').($brand['accent'] ?? ''); + $hub = rtrim((string) config('domains.domains.pressekonto.url'), '/'); + + $categories = Category::query() + ->where('is_active', true) + ->whereIn('portal', $context->portalValues) + ->with('translations') + ->get() + ->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')) + ->filter() + ->sortBy('name') + ->map(fn ($translation) => '- ['.$translation->name.']('.$base.'/de/category/'.$translation->slug.')') + ->values() + ->all(); + + return implode("\n", [ + '# '.$name, + '', + '> '.($brand['tagline_long'] ?? 'Presseportal für Pressemitteilungen.'), + '', + 'Pressemitteilungen werden von Unternehmen eingereicht und vor der Veröffentlichung geprüft. Urheber einer Meldung ist das im Kopf genannte Unternehmen; das Portal ist Herausgeber. Meldungen tragen NewsArticle-Markup (schema.org) mit dem Newsroom des Unternehmens als author, Newsrooms tragen Organization-Markup.', + '', + '## Aufbau', + '', + '- Meldung: '.$base.'/de/press-release/{slug}', + '- Newsroom eines Unternehmens: '.$base.'/de/newsroom/{slug} — Übersicht unter '.$base.'/de/newsrooms', + '- Kategorie: '.$base.'/de/category/{slug}', + '- Sitemap: '.$base.'/sitemap.xml — News-Sitemap (Meldungen der letzten 48 Stunden): '.$base.'/sitemap-news.xml', + '', + '## Kategorien', + '', + ...$categories, + '', + '## Für Unternehmen', + '', + '- [Veröffentlichen]('.$base.'/de/veroeffentlichen): Wie eine Meldung auf das Portal kommt', + '- [Preise]('.$base.'/de/preise)', + '- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung)', + '- [Hilfe]('.$base.'/de/hilfe)', + '- [API]('.$base.'/de/api): Einlieferung per REST-API über den Publisher-Bereich '.$hub, + '', + '## Rechtliches', + '', + '- [Impressum]('.$base.'/de/impressum)', + '- [Datenschutz]('.$base.'/de/datenschutz)', + '- [AGB]('.$base.'/de/agb)', + '- [Richtlinien]('.$base.'/de/richtlinien)', + '- [Inhalte melden]('.$base.'/de/melden)', + '', + ]); + } +} diff --git a/app/Http/Controllers/Web/NewsSitemapController.php b/app/Http/Controllers/Web/NewsSitemapController.php new file mode 100644 index 00000000..7a296406 --- /dev/null +++ b/app/Http/Controllers/Web/NewsSitemapController.php @@ -0,0 +1,78 @@ +getHost(); + $base = rtrim($request->getSchemeAndHttpHost(), '/'); + $isHub = str_contains($host, 'pressekonto'); + + $xml = Cache::remember('web.sitemap-news.'.$host, self::CACHE_TTL_SECONDS, function () use ($base, $isHub): string { + $entries = $isHub ? [] : $this->entries($base); + + return view('web.sitemap-news', ['entries' => $entries])->render(); + }); + + return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']); + } + + /** + * @return list + */ + private function entries(string $base): array + { + $context = WebPortalContext::fromRequest(); + $publication = (string) StructuredData::publisher($base)['name']; + + $releases = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->visibleInArchive() + ->forPortalValues($context->portalValues) + ->where('published_at', '>=', now()->subHours(self::WINDOW_HOURS)) + ->orderByDesc('published_at') + ->limit(self::MAX_URLS) + ->get(['slug', 'title', 'language', 'published_at']); + + $entries = []; + + foreach ($releases as $release) { + $edition = in_array($release->language, SetEdition::EDITIONS, true) ? $release->language : SetEdition::DEFAULT_EDITION; + + $entries[] = [ + 'loc' => $base.'/'.$edition.'/press-release/'.$release->slug, + 'publication' => $publication, + 'language' => $edition, + 'published_at' => (string) $release->published_at?->toAtomString(), + 'title' => (string) $release->title, + ]; + } + + return $entries; + } +} diff --git a/app/Support/StructuredData.php b/app/Support/StructuredData.php new file mode 100644 index 00000000..49dca48f --- /dev/null +++ b/app/Support/StructuredData.php @@ -0,0 +1,53 @@ + + */ + public static function publisher(string $portalBase): array + { + $isPresseecho = config('app.theme') === 'presseecho'; + $brand = (array) config('domains.domains.'.($isPresseecho ? 'presseecho' : 'businessportal24').'.brand', []); + + return [ + '@type' => 'Organization', + '@id' => $portalBase.'/#organization', + 'name' => ($brand['name'] ?? ($isPresseecho ? 'presse' : 'businessportal')).($brand['accent'] ?? ($isPresseecho ? 'echo' : '24')), + 'url' => $portalBase.'/', + 'logo' => [ + '@type' => 'ImageObject', + 'url' => $portalBase.'/images/'.($isPresseecho ? 'presseecho-logo.svg' : 'businessportal-logo.svg'), + 'width' => 600, + 'height' => 200, + ], + ]; + } + + public static function newsroomUrl(string $portalBase, string $slug): string + { + return $portalBase.'/'.app()->getLocale().'/newsroom/'.$slug; + } + + public static function newsroomId(string $newsroomUrl): string + { + return $newsroomUrl.'#organization'; + } +} diff --git a/app/Support/WebCaches.php b/app/Support/WebCaches.php index df4060cb..d65a38a6 100644 --- a/app/Support/WebCaches.php +++ b/app/Support/WebCaches.php @@ -33,6 +33,7 @@ class WebCaches if ($host) { Cache::forget('web.sitemap.'.$host); + Cache::forget('web.sitemap-news.'.$host); } } } diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index eeab8320..903d1b46 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -184,13 +184,13 @@ Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist | Nr. | Arbeitspaket | Aufwand | Abhängigkeit | |---|---|---|---| -| 2.1 | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms | M | 1b.1 | -| 2.2 | Aufnahme in Google News und Publisher Center prüfen und beantragen | M | 2.1 | +| ~~2.1~~ | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms — **Grundgerüst seit Juli (379688f), 17.09.2026 ergänzt:** gemeinsame `@id` zwischen `NewsArticle.author` und Newsroom-Organization, Publisher mit `@id` und Logo (`App\Support\StructuredData`) | M | 1b.1 | +| 2.2 | Aufnahme in Google News und Publisher Center prüfen und beantragen — **technischer Teil 17.09.2026:** News-Sitemap `/sitemap-news.xml` je Spoke (48 h, ≤ 1.000), in `robots.txt`; Publisher-Center-Anmeldung liegt beim Betreiber (Protokoll Teil 5) | M | 2.1 | | 2.3 | Branchen- und Kategorie-Landingpages aus dem Archivbestand | M | keine | | 2.4 | Transparenzseiten: Preisvergleich, ohne Abo veröffentlichen, ohne Mindestlaufzeit | S | keine | | 2.5 | Konsolidierung der bestehenden Ratgeberseiten unter `/de/presse/` gegen 1d.1 und 2.4 prüfen | S | 1d.1 | | 2.6 | Outreach an Betreiber von Presseportal-Listen und Vergleichsartikeln | M | 2.4 | -| 2.7 | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` | M | 2.1 | +| ~~2.7~~ | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` — **17.09.2026:** `/llms.txt` je Host (Hub verweist auf beide Portale), Entitätszuordnung über 2.1 | M | 2.1 | **Formulierungsgrenze bei 2.7:** Die technischen Voraussetzungen zu beschreiben ist haltbar. Eine Nennung in KI-Antworten zu versprechen ist es nicht. Vor Veröffentlichung auf einer Preisseite sauber formulieren. @@ -235,7 +235,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. | ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 | | ~~E3~~ | **Entschieden 16.09.2026: keine Credits.** Prüfung ist Teil der Freigabe, nicht ein Werkzeug; N automatische Läufe je Meldung (einstellbar, 3), danach Mensch, Automatik für die Meldung aus. Gelb geht weiter automatisch online, `yellow_to_manual_queue` bleibt als Schalter (Vorgabe aus). | 1d.4 | | ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 | -| E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 | +| E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt — **Befund 17.09.2026:** `/de/presse/*` leitet seit R3.2 auf `/faq`; offen ist nur, ob Schreib-Slugs stattdessen auf `/gute-pressemitteilung` zeigen sollen (Protokoll Teil 5) | 2.5 | | E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 | --- diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 6eabdc02..3f99ccfd 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1537,3 +1537,96 @@ Die 1.302 davon mit Meldungen haben nur `rejected` (bis 2019) und `archived` (bis 2022), nichts Öffentliches. Die 44 Besitzerkonten bleiben unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391. Test: `RetireLinkSpamCompaniesTest`. + +--- + +## Teil 5 — Block 2: Organische Sichtbarkeit (ab 17.09.2026) + +Block 1 ist komplett und live (17.09.2026). Block 2 beginnt mit dem +technischen Teil (2.1, 2.2, 2.7); die Inhalts- und Marketing-Pakete (2.3, +2.4, 2.6) folgen, 2.5 hängt an E5. + +### 2.1 — NewsArticle und Organization (17.09.2026) + +**Stand vorher:** Das Grundgerüst gab es seit Juli (Commit 379688f): +NewsArticle + BreadcrumbList auf jeder öffentlichen Meldung, Organization +auf dem Newsroom, WebSite + Organization auf den Portal-Startseiten, +BreadcrumbList auf Kategorien; Ausgabe über `x-web.structured-data` +(JSON_HEX_TAG gegen ``-Ausbruch). Die Vorschau bleibt ohne Markup. + +**Was fehlte:** Die Entitäten hingen nicht zusammen. `NewsArticle.author` +und die Organization der Newsroom-Seite waren zwei namensgleiche Objekte +ohne gemeinsame `@id`; der Publisher hatte kein Logo (Google empfiehlt es +für Article-Rich-Results) und ebenfalls keine `@id`. + +**Umsetzung:** `App\Support\StructuredData` bündelt die Entitäten: +- `publisher($portalBase)` — Organization des Portals mit + `@id = {portal}/#organization`, Name aus `config/domains.php`, Logo als + ImageObject (`/images/presseecho-logo.svg` bzw. + `businessportal-logo.svg`, 600×200). Verwendet auf Meldung (publisher) + und Startseite. +- `newsroomUrl()` / `newsroomId()` — `@id = {newsroom-url}#organization`. + Die Newsroom-Seite trägt sie, `NewsArticle.author` verweist darauf. +- Bewusst nicht ins Organization-Markup: Adresse, E-Mail, Telefon. Die + Newsroom-Seite zeigt sie nicht, und Markup ohne sichtbares Gegenstück + ist gegen Googles Richtlinien. + +Tests: `StructuredDataTest` erweitert (Publisher-`@id`, Logo, +Author-`@id`, Newsroom-`@id`). + +### 2.2 — Google News, technischer Teil (17.09.2026) + +**Umsetzung:** News-Sitemap je Spoke: `/sitemap-news.xml` +(`NewsSitemapController`) — nur im Archiv sichtbare Meldungen der letzten +48 Stunden, höchstens 1.000, `news:publication` mit Portalname und +Sprache (`language` der Meldung, Fallback `de`), `news:publication_date`, +`news:title`. 15 Minuten gecacht je Host, wird mit den anderen Sitemaps +über `WebCaches::forgetSitemaps()` geleert. `robots.txt` der Spokes nennt +sie; der Hub liefert eine leere Liste und nennt sie nicht. + +**Was jetzt beim Betreiber liegt (nicht im Code):** +1. Search Console: beide Spokes als Property bestätigt haben. +2. Publisher Center (publishercenter.google.com): je Portal eine + Publikation anlegen — Name exakt wie in `news:name` („presseecho", + „businessportal24"), Sprache Deutsch, Standort DE, Website-URL des + Portals; Logo (quadratisch und rechteckig) hochladen. +3. In der Publikation den Bereich „Google News" einrichten: Abschnitte + (z. B. die Kategorien) als Feed oder Web-Bereich anlegen; die + News-Sitemap wird nicht dort, sondern in der Search Console + eingereicht (Sitemaps → `/sitemap-news.xml`). +4. Erwartung ehrlich halten: Google News nimmt Presseportale nicht + verlässlich auf („Pressemitteilungen" sind laut Inhaltsrichtlinien + zulässig, aber kein bevorzugter Inhalt). Die Sitemap ist die + Voraussetzung, keine Zusage. Ergebnis nach 4–6 Wochen in der Search + Console unter „Leistung → Google News" ablesen. + +Tests: `NewsSitemapTest` (3). + +### 2.7 — Auffindbarkeit für KI-Systeme (17.09.2026) + +**Umsetzung:** `/llms.txt` je Host (`LlmsTxtController`, 1 h gecacht): +Titel, Kurzbeschreibung (Tagline aus `config/domains.php`), sachlicher +Absatz zu Urheberschaft (Unternehmen = Urheber, Portal = Herausgeber) und +zu den strukturierten Daten, URL-Aufbau (Meldung, Newsroom, Kategorie, +Sitemaps), aktive Kategorien mit Links, Seiten für Unternehmen, +Rechtliches. Der Hub verweist auf beide Portale und die OpenAPI-Datei. +Formulierungsgrenze eingehalten: keine Aussage darüber, ob oder wie +KI-Systeme die Inhalte verwenden. Die Entitätszuordnung aus 2.1 (`@id` +zwischen Meldung und Newsroom) ist der zweite Teil von 2.7. + +Tests: `LlmsTxtTest` (2). + +### Befund zu 2.5 / E5 + +Die alte Ratgeber-Strecke `/de/presse/*.html` leitet seit dem Rückbau +(R3.2) per 301 auf `/de/faq`. Es gibt also keine Ratgeberseiten mehr, die +zu konsolidieren wären — die Frage E5 reduziert sich darauf, ob die +Alt-URLs zum Schreiben von Pressemitteilungen künftig auf die Hilfeseite +`/de/gute-pressemitteilung` (1d.1) statt auf die FAQ zeigen sollen. +Empfehlung: ja für Slugs, die erkennbar das Schreiben betreffen, sonst +FAQ. Braucht die Liste der Alt-Slugs aus dem Webserver-Log oder der +Search Console; ohne die bleibt es bei FAQ. + +**Deploy:** kein `migrate`, nur `view:cache`/`route:cache` wie üblich. +Danach `/sitemap-news.xml`, `/llms.txt` und eine Meldung im +Rich-Results-Test (search.google.com/test/rich-results) prüfen. diff --git a/resources/views/web/businessportal24.blade.php b/resources/views/web/businessportal24.blade.php index 14e8313c..aef6fa8f 100644 --- a/resources/views/web/businessportal24.blade.php +++ b/resources/views/web/businessportal24.blade.php @@ -1,10 +1,12 @@ @extends('web.layouts.web-master') @php + use App\Support\StructuredData; + $brand = config('domains.domains.businessportal24.brand', []); // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). - $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $portalBase = StructuredData::portalBase($domainUrl ?? null); $brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24'); $jsonLd = [ @@ -24,11 +26,7 @@ 'query-input' => 'required name=search_term_string', ], ], - [ - '@type' => 'Organization', - 'name' => $brandFullName, - 'url' => $portalBase.'/', - ], + StructuredData::publisher($portalBase), ], ]; @endphp diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php index 356e904e..2069625b 100644 --- a/resources/views/web/newsroom.blade.php +++ b/resources/views/web/newsroom.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') @php + use App\Support\StructuredData; use Illuminate\Support\Str; \Carbon\Carbon::setLocale(app()->getLocale()); @@ -34,10 +35,11 @@ // SEO: Der Newsroom ist die kanonische Firmenseite im Portal — // Organization-Markup verknüpft PMs (author) mit diesem Profil. // Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl). - $newsroomUrl = rtrim($domainUrl ?? config('app.url'), '/').'/'.app()->getLocale().'/newsroom/'.$company->slug; + $newsroomUrl = StructuredData::newsroomUrl(StructuredData::portalBase($domainUrl ?? null), $company->slug); $jsonLd = [ '@context' => 'https://schema.org', '@type' => 'Organization', + '@id' => StructuredData::newsroomId($newsroomUrl), 'name' => $company->name, 'url' => $newsroomUrl, ...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []), diff --git a/resources/views/web/presseecho.blade.php b/resources/views/web/presseecho.blade.php index 144ee8c8..84985d80 100644 --- a/resources/views/web/presseecho.blade.php +++ b/resources/views/web/presseecho.blade.php @@ -1,10 +1,12 @@ @extends('web.layouts.web-master') @php + use App\Support\StructuredData; + $brand = config('domains.domains.presseecho.brand', []); // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). - $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $portalBase = StructuredData::portalBase($domainUrl ?? null); $brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo'); $jsonLd = [ @@ -24,11 +26,7 @@ 'query-input' => 'required name=search_term_string', ], ], - [ - '@type' => 'Organization', - 'name' => $brandFullName, - 'url' => $portalBase.'/', - ], + StructuredData::publisher($portalBase), ], ]; @endphp diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index b55b3fad..b4856058 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') @php + use App\Support\StructuredData; use Illuminate\Support\Str; // Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches @@ -93,7 +94,8 @@ $jsonLd = null; if (! $isPreview) { - $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $portalBase = StructuredData::portalBase($domainUrl ?? null); + $authorNewsroomUrl = $company?->slug ? StructuredData::newsroomUrl($portalBase, $company->slug) : null; // dateModified darf laut Google nicht vor datePublished liegen // (updated_at kann bei terminierten PMs älter sein als published_at). @@ -125,12 +127,15 @@ 'wordCount' => $wordCount ?: null, 'inLanguage' => app()->getLocale(), 'isAccessibleForFree' => true, + // author trägt die @id der Newsroom-Organization (2.1): Meldung + // und Newsroom-Seite beschreiben dieselbe Entität. 'author' => $companyName ? array_filter([ '@type' => 'Organization', + '@id' => $authorNewsroomUrl ? StructuredData::newsroomId($authorNewsroomUrl) : null, 'name' => $companyName, - 'url' => $company?->slug ? $portalBase.'/'.app()->getLocale().'/newsroom/'.$company->slug : null, + 'url' => $authorNewsroomUrl, ]) : null, - 'publisher' => ['@type' => 'Organization', 'name' => $brandLabel, 'url' => $portalBase], + 'publisher' => StructuredData::publisher($portalBase), ], fn ($value) => $value !== null), [ '@type' => 'BreadcrumbList', diff --git a/resources/views/web/sitemap-news.blade.php b/resources/views/web/sitemap-news.blade.php new file mode 100644 index 00000000..99d0b96c --- /dev/null +++ b/resources/views/web/sitemap-news.blade.php @@ -0,0 +1,16 @@ +{!! '' !!} + +@foreach ($entries as $entry) + + {{ $entry['loc'] }} + + + {{ $entry['publication'] }} + {{ $entry['language'] }} + + {{ $entry['published_at'] }} + {{ $entry['title'] }} + + +@endforeach + diff --git a/routes/web.php b/routes/web.php index dbdb0211..ee78d816 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,9 +6,11 @@ use App\Http\Controllers\Web\CategoryController; use App\Http\Controllers\Web\ContactController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\LegacyMediaRedirectController; +use App\Http\Controllers\Web\LlmsTxtController; use App\Http\Controllers\Web\NewsroomClaimController; use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; +use App\Http\Controllers\Web\NewsSitemapController; use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\PressReleaseAttachmentMediaController; use App\Http\Controllers\Web\PressReleaseImageMediaController; @@ -307,9 +309,12 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func // verschattet (Webserver liefert existierende Dateien direkt aus). // --------------------------------------------------------------------------- Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); +Route::get('/sitemap-news.xml', NewsSitemapController::class)->name('sitemap.news'); +Route::get('/llms.txt', LlmsTxtController::class)->name('llms'); Route::get('/robots.txt', function () { $base = rtrim(request()->getSchemeAndHttpHost(), '/'); + $isHub = str_contains(request()->getHost(), 'pressekonto'); $lines = [ 'User-agent: *', @@ -320,6 +325,8 @@ Route::get('/robots.txt', function () { 'Disallow: /en/suche', '', 'Sitemap: '.$base.'/sitemap.xml', + // News-Sitemap nur auf den Spokes — der Hub hat keine Meldungen. + ...($isHub ? [] : ['Sitemap: '.$base.'/sitemap-news.xml']), '', ]; diff --git a/tests/Feature/Web/LlmsTxtTest.php b/tests/Feature/Web/LlmsTxtTest.php new file mode 100644 index 00000000..03404153 --- /dev/null +++ b/tests/Feature/Web/LlmsTxtTest.php @@ -0,0 +1,45 @@ +create(['portal' => Portal::Presseecho, 'is_active' => true]); + $category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie']); + $inactive = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => false]); + $inactive->translations()->create(['locale' => 'de', 'name' => 'Veraltet', 'slug' => 'veraltet']); + + $response = $this->get('https://presseecho.test/llms.txt') + ->assertSuccessful() + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + + $text = (string) $response->getContent(); + expect($text) + ->toStartWith("# presseecho\n\n> ") + ->toContain('https://presseecho.test/de/press-release/{slug}') + ->toContain('https://presseecho.test/sitemap-news.xml') + ->toContain('- [Energie](https://presseecho.test/de/category/energie)') + ->toContain('- [Impressum](https://presseecho.test/de/impressum)') + ->toContain('NewsArticle'); + + $this->assertStringNotContainsString('veraltet', $text); +}); + +test('the hub serves llms.txt that points to both portals', function () { + /** @var TestCase $this */ + $text = (string) $this->get('https://pressekonto.test/llms.txt')->assertSuccessful()->getContent(); + + expect($text) + ->toStartWith("# pressekonto\n\n> ") + ->toContain('https://presseecho.test/llms.txt') + ->toContain('https://businessportal24.test/llms.txt') + ->toContain('https://pressekonto.test/docs/api/v1'); + + $this->assertStringNotContainsString('press-release/{slug}', $text); +}); diff --git a/tests/Feature/Web/NewsSitemapTest.php b/tests/Feature/Web/NewsSitemapTest.php new file mode 100644 index 00000000..54bc533f --- /dev/null +++ b/tests/Feature/Web/NewsSitemapTest.php @@ -0,0 +1,74 @@ +published() + ->forPortal($portal) + ->for(Category::factory()->create(['portal' => $portal])) + ->for(Company::factory()->businessportal24()->create()) + ->create(['slug' => $slug, ...$attributes]); +} + +test('the news sitemap lists only releases of the last 48 hours of the own portal', function () { + /** @var TestCase $this */ + newsSitemapRelease(Portal::Businessportal24, 'frisch', ['title' => 'Frische Meldung & mehr', 'published_at' => now()->subHours(3)]); + newsSitemapRelease(Portal::Both, 'beide-portale', ['published_at' => now()->subHours(20)]); + newsSitemapRelease(Portal::Businessportal24, 'zu-alt', ['published_at' => now()->subDays(3)]); + newsSitemapRelease(Portal::Presseecho, 'fremdes-portal', ['published_at' => now()->subHour()]); + newsSitemapRelease(Portal::Businessportal24, 'terminiert', ['published_at' => now()->addHour()]); + + $response = $this->get('https://businessportal24.test/sitemap-news.xml')->assertSuccessful(); + + expect($response->headers->get('Content-Type'))->toContain('application/xml'); + + $xml = (string) $response->getContent(); + expect($xml) + ->toContain('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"') + ->toContain('https://businessportal24.test/de/press-release/frisch') + ->toContain('businessportal24') + ->toContain('de') + ->toContain('Frische Meldung & mehr') + ->toContain('https://businessportal24.test/de/press-release/beide-portale'); + + $this->assertStringNotContainsString('zu-alt', $xml); + $this->assertStringNotContainsString('fremdes-portal', $xml); + $this->assertStringNotContainsString('terminiert', $xml); +}); + +test('the hub serves an empty news sitemap and does not advertise it in robots.txt', function () { + /** @var TestCase $this */ + newsSitemapRelease(Portal::Presseecho, 'nicht-im-hub', ['published_at' => now()->subHour()]); + + $xml = (string) $this->get('https://pressekonto.test/sitemap-news.xml')->assertSuccessful()->getContent(); + expect($xml)->toContain('assertStringNotContainsString('', $xml); + + $robots = (string) $this->get('https://pressekonto.test/robots.txt')->getContent(); + $this->assertStringNotContainsString('sitemap-news.xml', $robots); + + $this->get('https://presseecho.test/robots.txt') + ->assertSee('Sitemap: https://presseecho.test/sitemap-news.xml', false); +}); + +test('the news sitemap is cached per host and cleared with the other sitemaps', function () { + /** @var TestCase $this */ + $this->get('https://presseecho.test/sitemap-news.xml')->assertSuccessful(); + expect(Cache::has('web.sitemap-news.presseecho.test'))->toBeTrue(); + + WebCaches::forgetSitemaps(); + + expect(Cache::has('web.sitemap-news.presseecho.test'))->toBeFalse(); +}); diff --git a/tests/Feature/Web/StructuredDataTest.php b/tests/Feature/Web/StructuredDataTest.php index 4c24daf3..af871fe5 100644 --- a/tests/Feature/Web/StructuredDataTest.php +++ b/tests/Feature/Web/StructuredDataTest.php @@ -38,8 +38,10 @@ test('the release detail page emits NewsArticle and BreadcrumbList JSON-LD', fun ->assertSee('"@type":"NewsArticle"', false) ->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false) ->assertSee('"datePublished"', false) - ->assertSee('"publisher":{"@type":"Organization","name":"businessportal24"', false) - ->assertSee('"author":{"@type":"Organization","name":"Muster GmbH"', false) + ->assertSee('"publisher":{"@type":"Organization","@id":"https://businessportal24.test/#organization","name":"businessportal24"', false) + ->assertSee('"logo":{"@type":"ImageObject","url":"https://businessportal24.test/images/businessportal-logo.svg"', false) + ->assertSee('"author":{"@type":"Organization","@id":"https://businessportal24.test/de/newsroom/', false) + ->assertSee('#organization","name":"Muster GmbH"', false) ->assertSee('"@type":"BreadcrumbList"', false) ->assertSee('"articleSection":"Technologie"', false); }); @@ -77,7 +79,9 @@ test('a portal homepage emits WebSite JSON-LD with a SearchAction', function () ->assertSuccessful() ->assertSee('"@type":"WebSite"', false) ->assertSee('"@type":"SearchAction"', false) - ->assertSee('https://presseecho.test/de/suche?q={search_term_string}', false); + ->assertSee('https://presseecho.test/de/suche?q={search_term_string}', false) + ->assertSee('"@id":"https://presseecho.test/#organization"', false) + ->assertSee('"url":"https://presseecho.test/images/presseecho-logo.svg"', false); }); test('the newsroom page emits Organization JSON-LD for the company', function () { @@ -100,6 +104,7 @@ test('the newsroom page emits Organization JSON-LD for the company', function () $this->get('https://businessportal24.test/de/newsroom/'.$company->slug) ->assertSuccessful() ->assertSee('"@type":"Organization"', false) + ->assertSee('"@id":"https://businessportal24.test/de/newsroom/'.$company->slug.'#organization"', false) ->assertSee('"name":"Newsroom AG"', false) ->assertSee('"sameAs":["https://newsroom-ag.example"]', false); });