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) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:15:28 +00:00
parent 8cd76df0bb
commit b60a28dc9c
15 changed files with 521 additions and 23 deletions

View file

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* `/llms.txt` (Roadmap 2.7): eine kurze, sachliche Beschreibung des Hosts
* für KI-Systeme — was die Seite ist, wie die URLs aufgebaut sind, wo die
* strukturierten Daten und Sitemaps liegen. Keine Werbeaussagen, keine
* Versprechen über Nennungen in KI-Antworten (Formulierungsgrenze 2.7).
* Hub und Spokes bekommen je eine eigene Fassung; pro Host eine Stunde
* gecacht.
*/
class LlmsTxtController extends Controller
{
public function __invoke(Request $request): Response
{
$host = $request->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)',
'',
]);
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Middleware\SetEdition;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StructuredData;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Google-News-Sitemap je Spoke (Roadmap 2.2): nur Meldungen der letzten
* 48 Stunden, höchstens 1.000, mit Publikationsname und Sprache — so wie
* Google News sie verlangt. Der Hub (pressekonto) hat keine Meldungen und
* liefert eine leere Liste. Kurz gecacht (15 min), damit neue Meldungen
* schnell erscheinen; Entfernungen leeren den Cache über WebCaches.
*/
class NewsSitemapController extends Controller
{
public const CACHE_TTL_SECONDS = 900;
public const WINDOW_HOURS = 48;
public const MAX_URLS = 1000;
public function __invoke(Request $request): Response
{
$host = $request->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<array{loc: string, publication: string, language: string, published_at: string, title: string}>
*/
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;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Support;
/**
* Gemeinsame Entitäten für das JSON-LD der Web-Seiten (Roadmap 2.1):
* das Portal als Publisher mit stabiler `@id` und Logo, der Newsroom als
* Organization mit stabiler `@id`. NewsArticle.author auf der Meldung und
* die Organization auf der Newsroom-Seite zeigen damit auf dieselbe Entität,
* statt zwei namensgleiche Objekte zu sein.
*/
class StructuredData
{
public static function portalBase(?string $domainUrl): string
{
return rtrim($domainUrl ?? (string) config('app.url'), '/');
}
/**
* Portal-Organisation (Publisher der Meldungen) — Name aus der
* Domain-Konfiguration, Logo als ImageObject (600×200-SVG unter /images).
*
* @return array<string, mixed>
*/
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';
}
}

View file

@ -33,6 +33,7 @@ class WebCaches
if ($host) { if ($host) {
Cache::forget('web.sitemap.'.$host); Cache::forget('web.sitemap.'.$host);
Cache::forget('web.sitemap-news.'.$host);
} }
} }
} }

View file

@ -184,13 +184,13 @@ Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit | | Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---| |---|---|---|---|
| 2.1 | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms | M | 1b.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 | M | 2.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.3 | Branchen- und Kategorie-Landingpages aus dem Archivbestand | M | keine |
| 2.4 | Transparenzseiten: Preisvergleich, ohne Abo veröffentlichen, ohne Mindestlaufzeit | S | 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.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.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. **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 | | ~~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 | | ~~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 | | ~~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 | | E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |
--- ---

View file

@ -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 `archived` (bis 2022), nichts Öffentliches. Die 44 Besitzerkonten bleiben
unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391. unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391.
Test: `RetireLinkSpamCompaniesTest`. 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 `</script>`-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.

View file

@ -1,10 +1,12 @@
@extends('web.layouts.web-master') @extends('web.layouts.web-master')
@php @php
use App\Support\StructuredData;
$brand = config('domains.domains.businessportal24.brand', []); $brand = config('domains.domains.businessportal24.brand', []);
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). // 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'); $brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24');
$jsonLd = [ $jsonLd = [
@ -24,11 +26,7 @@
'query-input' => 'required name=search_term_string', 'query-input' => 'required name=search_term_string',
], ],
], ],
[ StructuredData::publisher($portalBase),
'@type' => 'Organization',
'name' => $brandFullName,
'url' => $portalBase.'/',
],
], ],
]; ];
@endphp @endphp

View file

@ -1,6 +1,7 @@
@extends('web.layouts.web-master') @extends('web.layouts.web-master')
@php @php
use App\Support\StructuredData;
use Illuminate\Support\Str; use Illuminate\Support\Str;
\Carbon\Carbon::setLocale(app()->getLocale()); \Carbon\Carbon::setLocale(app()->getLocale());
@ -34,10 +35,11 @@
// SEO: Der Newsroom ist die kanonische Firmenseite im Portal — // SEO: Der Newsroom ist die kanonische Firmenseite im Portal —
// Organization-Markup verknüpft PMs (author) mit diesem Profil. // Organization-Markup verknüpft PMs (author) mit diesem Profil.
// Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl). // 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 = [ $jsonLd = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'Organization', '@type' => 'Organization',
'@id' => StructuredData::newsroomId($newsroomUrl),
'name' => $company->name, 'name' => $company->name,
'url' => $newsroomUrl, 'url' => $newsroomUrl,
...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []), ...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []),

View file

@ -1,10 +1,12 @@
@extends('web.layouts.web-master') @extends('web.layouts.web-master')
@php @php
use App\Support\StructuredData;
$brand = config('domains.domains.presseecho.brand', []); $brand = config('domains.domains.presseecho.brand', []);
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). // 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'); $brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo');
$jsonLd = [ $jsonLd = [
@ -24,11 +26,7 @@
'query-input' => 'required name=search_term_string', 'query-input' => 'required name=search_term_string',
], ],
], ],
[ StructuredData::publisher($portalBase),
'@type' => 'Organization',
'name' => $brandFullName,
'url' => $portalBase.'/',
],
], ],
]; ];
@endphp @endphp

View file

@ -1,6 +1,7 @@
@extends('web.layouts.web-master') @extends('web.layouts.web-master')
@php @php
use App\Support\StructuredData;
use Illuminate\Support\Str; use Illuminate\Support\Str;
// Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches // Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches
@ -93,7 +94,8 @@
$jsonLd = null; $jsonLd = null;
if (! $isPreview) { 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 // dateModified darf laut Google nicht vor datePublished liegen
// (updated_at kann bei terminierten PMs älter sein als published_at). // (updated_at kann bei terminierten PMs älter sein als published_at).
@ -125,12 +127,15 @@
'wordCount' => $wordCount ?: null, 'wordCount' => $wordCount ?: null,
'inLanguage' => app()->getLocale(), 'inLanguage' => app()->getLocale(),
'isAccessibleForFree' => true, 'isAccessibleForFree' => true,
// author trägt die @id der Newsroom-Organization (2.1): Meldung
// und Newsroom-Seite beschreiben dieselbe Entität.
'author' => $companyName ? array_filter([ 'author' => $companyName ? array_filter([
'@type' => 'Organization', '@type' => 'Organization',
'@id' => $authorNewsroomUrl ? StructuredData::newsroomId($authorNewsroomUrl) : null,
'name' => $companyName, 'name' => $companyName,
'url' => $company?->slug ? $portalBase.'/'.app()->getLocale().'/newsroom/'.$company->slug : null, 'url' => $authorNewsroomUrl,
]) : null, ]) : null,
'publisher' => ['@type' => 'Organization', 'name' => $brandLabel, 'url' => $portalBase], 'publisher' => StructuredData::publisher($portalBase),
], fn ($value) => $value !== null), ], fn ($value) => $value !== null),
[ [
'@type' => 'BreadcrumbList', '@type' => 'BreadcrumbList',

View file

@ -0,0 +1,16 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
@foreach ($entries as $entry)
<url>
<loc>{{ $entry['loc'] }}</loc>
<news:news>
<news:publication>
<news:name>{{ $entry['publication'] }}</news:name>
<news:language>{{ $entry['language'] }}</news:language>
</news:publication>
<news:publication_date>{{ $entry['published_at'] }}</news:publication_date>
<news:title>{{ $entry['title'] }}</news:title>
</news:news>
</url>
@endforeach
</urlset>

View file

@ -6,9 +6,11 @@ use App\Http\Controllers\Web\CategoryController;
use App\Http\Controllers\Web\ContactController; use App\Http\Controllers\Web\ContactController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\LegacyMediaRedirectController; use App\Http\Controllers\Web\LegacyMediaRedirectController;
use App\Http\Controllers\Web\LlmsTxtController;
use App\Http\Controllers\Web\NewsroomClaimController; use App\Http\Controllers\Web\NewsroomClaimController;
use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomDetailController;
use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\NewsroomsController;
use App\Http\Controllers\Web\NewsSitemapController;
use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\PreiseController;
use App\Http\Controllers\Web\PressReleaseAttachmentMediaController; use App\Http\Controllers\Web\PressReleaseAttachmentMediaController;
use App\Http\Controllers\Web\PressReleaseImageMediaController; 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). // verschattet (Webserver liefert existierende Dateien direkt aus).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); 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 () { Route::get('/robots.txt', function () {
$base = rtrim(request()->getSchemeAndHttpHost(), '/'); $base = rtrim(request()->getSchemeAndHttpHost(), '/');
$isHub = str_contains(request()->getHost(), 'pressekonto');
$lines = [ $lines = [
'User-agent: *', 'User-agent: *',
@ -320,6 +325,8 @@ Route::get('/robots.txt', function () {
'Disallow: /en/suche', 'Disallow: /en/suche',
'', '',
'Sitemap: '.$base.'/sitemap.xml', 'Sitemap: '.$base.'/sitemap.xml',
// News-Sitemap nur auf den Spokes — der Hub hat keine Meldungen.
...($isHub ? [] : ['Sitemap: '.$base.'/sitemap-news.xml']),
'', '',
]; ];

View file

@ -0,0 +1,45 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use Tests\TestCase;
/**
* `/llms.txt` je Host (Roadmap 2.7): sachliche Beschreibung, URL-Aufbau,
* Kategorien — ohne Werbeaussagen.
*/
test('a spoke serves llms.txt with its structure, categories and legal pages', function () {
/** @var TestCase $this */
$category = Category::factory()->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);
});

View file

@ -0,0 +1,74 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Support\WebCaches;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* Google-News-Sitemap je Spoke (Roadmap 2.2): nur Meldungen der letzten
* 48 Stunden, mit Publikationsname und Sprache.
*/
function newsSitemapRelease(Portal $portal, string $slug, array $attributes = []): PressRelease
{
return PressRelease::factory()
->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('<loc>https://businessportal24.test/de/press-release/frisch</loc>')
->toContain('<news:name>businessportal24</news:name>')
->toContain('<news:language>de</news:language>')
->toContain('<news:title>Frische Meldung &amp; mehr</news:title>')
->toContain('<loc>https://businessportal24.test/de/press-release/beide-portale</loc>');
$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('<urlset');
$this->assertStringNotContainsString('<url>', $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();
});

View file

@ -38,8 +38,10 @@ test('the release detail page emits NewsArticle and BreadcrumbList JSON-LD', fun
->assertSee('"@type":"NewsArticle"', false) ->assertSee('"@type":"NewsArticle"', false)
->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false) ->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false)
->assertSee('"datePublished"', false) ->assertSee('"datePublished"', false)
->assertSee('"publisher":{"@type":"Organization","name":"businessportal24"', false) ->assertSee('"publisher":{"@type":"Organization","@id":"https://businessportal24.test/#organization","name":"businessportal24"', false)
->assertSee('"author":{"@type":"Organization","name":"Muster GmbH"', 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('"@type":"BreadcrumbList"', false)
->assertSee('"articleSection":"Technologie"', false); ->assertSee('"articleSection":"Technologie"', false);
}); });
@ -77,7 +79,9 @@ test('a portal homepage emits WebSite JSON-LD with a SearchAction', function ()
->assertSuccessful() ->assertSuccessful()
->assertSee('"@type":"WebSite"', false) ->assertSee('"@type":"WebSite"', false)
->assertSee('"@type":"SearchAction"', 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 () { 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) $this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertSuccessful() ->assertSuccessful()
->assertSee('"@type":"Organization"', false) ->assertSee('"@type":"Organization"', false)
->assertSee('"@id":"https://businessportal24.test/de/newsroom/'.$company->slug.'#organization"', false)
->assertSee('"name":"Newsroom AG"', false) ->assertSee('"name":"Newsroom AG"', false)
->assertSee('"sameAs":["https://newsroom-ag.example"]', false); ->assertSee('"sameAs":["https://newsroom-ag.example"]', false);
}); });