web: RSS-Feeds je Branche und je Portal (Roadmap 3.1); dev: Sichtung des Ordners, Protokoll-Teile umnummeriert

- /{edition}/feed und /{edition}/category/{slug}/feed (FeedController,
  RSS 2.0 mit atom:link self, dc:creator, category; jüngste 50 der Ausgabe,
  Branche inkl. aktiver Unterkategorien, nur visibleInArchive).
- Cache über WebPageCache (Key-Gruppe feed), Hub leitet per 301 aufs Portal
  (canonical.portal_only_content_routes).
- Autodiscovery über @yield('feed_links') auf Start-, Branchen- und
  Archivseiten, sichtbarer Link in der Archiv-Leiste, llms.txt nennt die
  Feed-Muster, Block „RSS-Feeds" im Panel „Auslieferung"
  (VisibilityStats::feedLinks).
- Übersetzungen unter web.rss (web.feed ist die Startseiten-Liste).
- Tests: FeedTest (7), VisibilityStatsTest erweitert.
- dev/2026-09-01: Sichtung 18.09. (Teil 11), zweite Teil-Serie des
  Protokolls jetzt 7–10 mit nachgezogenen Verweisen, Anhang 1 abgeglichen,
  E6 abgehakt, 2.6 extern, 1c.5/E8 wartet auf Daten, 3.1 abgehakt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 13:57:09 +00:00
parent e2ef4dee31
commit 9d5e5a0b54
20 changed files with 547 additions and 28 deletions

View file

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StructuredData;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
/**
* RSS-Feeds je Branche und je Portal (Roadmap 3.1): `/{edition}/feed` und
* `/{edition}/category/{slug}/feed`. Die jüngsten Meldungen der Ausgabe
* (Portal × Sprache), bei einer Branche inklusive Unterkategorien — wie die
* Branchenseite selbst. Bei Doppelveröffentlichung erscheint je Portal die
* eigene Fassung; Leser und Feeds sehen beide (Anhang 2), nur die
* Suchmaschine bekommt die Erstquelle.
*
* Gecacht wie die Seitendaten (WebPageCache: 5 min frisch, danach stale und
* Neuaufbau nach der Antwort); Entfernungen leeren ihn mit den übrigen
* Seiten-Caches. Der Hub hat keine Meldungen — die Routen leiten dort auf
* das Portal (config/canonical.php, portal_only_content_routes).
*/
class FeedController extends Controller
{
public const MAX_ITEMS = 50;
public const TEASER_LENGTH = 300;
public function __invoke(Request $request, string $edition, ?string $slug = null): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$xml = WebPageCache::remember(
WebPageCache::key('feed', [$context->portal->value, $context->language, $slug ?? 'all']),
fn (): string => $this->render($context, $base, $slug),
);
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
private function render(WebPortalContext $context, string $base, ?string $slug): string
{
$language = $context->language;
$publisher = (string) StructuredData::publisher($base)['name'];
$brand = (array) ($context->domainConfig['brand'] ?? []);
$categoryIds = null;
$category = null;
if ($slug !== null) {
$category = Category::query()
->whereIn('portal', $context->portalValues)
->where('is_active', true)
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->first();
if (! $category) {
abort(404);
}
$categoryIds = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->pluck('id')
->prepend($category->id)
->unique()
->values()
->all();
}
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($language)
->visibleInArchive()
->when($categoryIds !== null, fn (Builder $query) => $query->whereIn('category_id', $categoryIds))
->with([
'company:id,name',
'category.translations' => fn ($query) => $query->where('locale', $language),
])
->orderByDesc('published_at')
->limit(self::MAX_ITEMS)
->get(['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']);
$items = $releases->map(fn (PressRelease $release): array => [
'title' => (string) $release->title,
'link' => $base.'/'.$language.'/press-release/'.$release->slug,
'published_at' => $release->published_at?->toRfc2822String() ?? '',
'description' => $release->teaserText(self::TEASER_LENGTH),
'category' => $release->localizedCategoryName($language),
'creator' => $release->company?->name,
])->all();
$translation = $category?->translations->first();
$categoryName = $translation?->name;
$channel = [
'title' => $categoryName !== null
? __('web.rss.category_title', ['category' => $categoryName, 'brand' => $publisher])
: __('web.rss.portal_title', ['brand' => $publisher]),
'link' => $categoryName !== null
? $base.'/'.$language.'/category/'.$slug
: $base.'/'.$language,
'self' => $categoryName !== null
? $base.'/'.$language.'/category/'.$slug.'/feed'
: $base.'/'.$language.'/feed',
'description' => filled($translation?->description)
? (string) $translation->description
: (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $publisher])),
'language' => $language,
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
];
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
}
}

View file

@ -106,6 +106,7 @@ class LlmsTxtController extends Controller
'- 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',
'- RSS-Feed aller Meldungen: '.$base.'/de/feed — je Kategorie: '.$base.'/de/category/{slug}/feed',
'',
'## Kategorien',
'',

View file

@ -2,9 +2,11 @@
namespace App\Services\PressRelease;
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Http\Middleware\SetEdition;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
@ -133,7 +135,7 @@ class VisibilityStats
/**
* Einzelwerte einer Meldung für das Panel „Auslieferung".
*
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null}
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string}>}
*/
public function releaseSummary(PressRelease $pressRelease): array
{
@ -167,9 +169,49 @@ class VisibilityStats
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
'crawlers' => $this->crawlerFetches($pressRelease),
'crawlers_since' => $this->crawlerHitsSince(),
'feeds' => $this->feedLinks($pressRelease, $links),
];
}
/**
* RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der
* Feed der Branche und der Portal-Feed. Beide führen die jüngsten
* Meldungen — eine Aussage über uns, nicht über Abonnenten.
*
* @param list<array{portal: Portal, label: string, url: string}> $links
* @return list<array{label: string, url: string}>
*/
private function feedLinks(PressRelease $pressRelease, array $links): array
{
if ($links === []) {
return [];
}
$edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : SetEdition::DEFAULT_EDITION;
$categorySlug = $pressRelease->category?->translations()->where('locale', $edition)->value('slug');
$categoryName = $pressRelease->localizedCategoryName($edition);
$feeds = [];
foreach ($links as $link) {
$base = (string) parse_url($link['url'], PHP_URL_SCHEME).'://'.(string) parse_url($link['url'], PHP_URL_HOST);
if ($categorySlug !== null) {
$feeds[] = [
'label' => $link['label'].' · '.$categoryName,
'url' => $base.'/'.$edition.'/category/'.$categorySlug.'/feed',
];
}
$feeds[] = [
'label' => $link['label'],
'url' => $base.'/'.$edition.'/feed',
];
}
return $feeds;
}
/**
* Abrufe durch Such- und KI-Dienste je Meldung (Roadmap 1c.6), zuletzt
* abgerufener Dienst zuerst. Belegt nur den Abruf durch den Crawler des

View file

@ -93,7 +93,7 @@ return [
| sonst `portal_only_host`.
*/
'portal_only_content_routes' => ['kategorie', 'kategorie.archiv', 'release.detail', 'newsroom', 'newsroom.claim'],
'portal_only_content_routes' => ['kategorie', 'kategorie.archiv', 'kategorie.feed', 'feed', 'release.detail', 'newsroom', 'newsroom.claim'],
'portal_only_host' => 'presseecho',

View file

@ -187,11 +187,11 @@ 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 — **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.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 7) | M | 2.1 |
| ~~2.3~~ | Branchen- und Kategorie-Landingpages aus dem Archivbestand — **17.09.2026:** Jahresarchiv je Branche `/category/{slug}/archiv/{jahr}` (34.600 Meldungen intern verlinkt, in der Sitemap), Jahresleiste auf der Branchenseite, Beschreibungen für alle 14 Kategorien (`categories:seed-descriptions`), Portalname im Titel | M | keine |
| ~~2.4~~ | Transparenzseiten — **17.09.2026 nach Briefing, drei Seiten:** `was-kostet-eine-pressemitteilung` (Modellvergleich ohne Namen, Prüfliste Folgekosten, eigene Zusagen, Rechenbeispiel mit gemischtem Ausgang), `pressemitteilung-veroeffentlichen-ohne-abo`, `pressemitteilung-loeschen-oder-aendern-lassen` (Meldeverfahren + Absatz für fremde Portale); keine fremden Zahlen | S | keine — **Nachtrag 17.09.2026 (Anhang 1):** vierte Seite „kostenlos veröffentlichen" |
| ~~2.5~~ | Konsolidierung der Ratgeberseiten unter `/de/presse/` — **geschlossen 17.09.2026:** Strecke existiert seit R3.2 nicht mehr, 301 auf `/faq` bleibt; Hilfe-Center trägt die Inhalte, jetzt auch in der Topleiste neben FAQ | 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 — **extern, in Arbeit (Betreiber, Stand 18.09.2026)**; Arbeitsregel, Ziele und Mustertext in Anhang 1, Abschnitt 5; nicht Teil der Umsetzung | M | 2.4 |
| ~~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.
@ -206,7 +206,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 3.1 | RSS-Feeds je Branche | S | 2.3 |
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal (Protokoll Teil 11) | S | 2.3 |
| 3.2 | Opt-in E-Mail-Digest je Branche, Double-Opt-in | M | 3.1, Bounce-Suppression live |
| 3.3 | Agenturmodell: mehrere Firmen unter einem Konto, aus den Sammeladressen aus 1a.2 abgeleitet | L | 1a.2 |
| 3.4 | Gezielte Ansprache kleiner PR-Agenturen und freier Texter | L | 3.3 |
@ -237,12 +237,12 @@ 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~~ | **Geschlossen 17.09.2026: bleibt bei FAQ, kein Slug-Mapping.** 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 |
| ~~E5~~ | **Geschlossen 17.09.2026: bleibt bei FAQ, kein Slug-Mapping.** 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 7) | 2.5 |
| ~~E6~~ | **Entschieden (umgesetzt 16.09.2026, abgehakt 18.09.2026): ja, dauerhaft.** Hilfeseite und Ablehnungsmail ohne kommerziellen Anteil; Partnerbereich nur als BALD am Seitenende. | 1d.1, 1d.6 |
| ~~E7~~ | **Entschieden 17.09.2026 (Anhang 1):** keine kostenfreie Einreichung, dauerhaft | 2.4, 2.6 |
| E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`), Entscheidung nach Verteilung aus 1c.4 (Anhang 1) | 1c.5 |
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 8) | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 8) | 2.4 |
| E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`); **Prüfung läuft seit 17.09.2026 täglich, Entscheidung, sobald die Verteilung belastbar ist** (Anhang 1) | 1c.5 |
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 10) | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 10) | 2.4 |
---

View file

@ -9,7 +9,7 @@
| Änderung | Betrifft | Art |
|---|---|---|
| Crawler-Abruf-Protokoll als eigenes Arbeitspaket | 1c.0 | neu |
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.N9A59YXJai6 | ersetzt |
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.6 | ersetzt |
| Erstquelle gegen Kopie als Argument für die Direktveröffentlichung | 1d | neu |
| Transparenzseiten ausformuliert, kein Wettbewerbervergleich | 2.4 | ersetzt |
| Zuschriften an Portalübersichten ausformuliert, zwei konkrete Ziele | 2.6 | ersetzt |
@ -28,10 +28,10 @@
|---|---|---|---|
| ~~1c.0~~ | Crawler-Abruf-Protokoll — **gebaut 17.09.2026**: `press_release_crawler_hits`, 15 Dienste in `config/crawlers.php`, keine IP, Bereinigung mit dem Ereignis-Log; `CRAWLER_HITS_RECORDED_SINCE` beim Deploy setzen | S | keine |
| ~~1c.1~~ | Absenderansicht Stufe 1 — **umgebaut 17.09.2026**: Panel heißt „Auslieferung", Google-Suchlink entfernt, Sitemap-Datum bleibt; keine Feeds vorhanden, daher kein Feed-Eintrag | M | 0.1 |
| 1c.2 | Zugang zur Ansicht über Magic Link, auch ohne Konto | S | 1b.2 |
| 1c.3 | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten | M | 1a.3, 1c.1 |
| ~~1c.2~~ | Zugang zur Ansicht über Magic Link, auch ohne Konto — **16.09.2026** (Hauptdokument) | S | 1b.2 |
| ~~1c.3~~ | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten — **16.09.2026** (Hauptdokument) | M | 1a.3, 1c.1 |
| ~~1c.4~~ | Indexierungsstatus — **gebaut 17.09.2026**: `press-releases:check-index-status` (täglich, Kontingent nach Alter gestaffelt), `press_release_index_checks` als Momentaufnahmen, Admin ▸ Reports ▸ Indexierung getrennt nach direkt/API; **live seit 17.09.2026** (Service-Konto eingerichtet, täglicher Lauf 05:10, Abfrage auf `en-US`) | M | GSC-Property je Portal verifiziert |
| 1c.5 | Entscheidung über die Kundenansicht des Indexierungsstatus, auf Basis der Verteilung aus 1c.4 — Schalter `GSC_CUSTOMER_VISIBLE` ist vorbereitet; Altbestand ohne Status-Log zählt in der Verteilung als „direkt" | S | 1c.4 |
| 1c.5 | Entscheidung über die Kundenansicht des Indexierungsstatus, auf Basis der Verteilung aus 1c.4 — Schalter `GSC_CUSTOMER_VISIBLE` ist vorbereitet; Altbestand ohne Status-Log zählt in der Verteilung als „direkt". **Wartet auf Daten (Stand 18.09.2026): der tägliche Lauf sammelt, Bericht unter Admin ▸ Reports ▸ Indexierung** | S | 1c.4 |
| ~~1c.6~~ | Abrufe durch KI-Dienste — **gebaut 17.09.2026**: Block im Panel je Dienst mit letztem Abruf und Anzahl, Satz „Ein Abruf ist kein Zitat" | S | 1c.0, 1c.1 |
**Stufe 1 verspricht nichts, was von Dritten abhängt.** Überschrift "Auslieferung", nicht "Sichtbarkeit bei Google". Keine Schaltfläche, die eine `site:`-Suche öffnet — sie liefert dem Kunden ein unvollständiges Ergebnis ohne Einordnung und ist schlechter als keine Angabe.
@ -62,8 +62,8 @@
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~2.4~~ | Transparenzseiten — vier Seiten, die vierte (`/pressemitteilung-kostenlos-veroeffentlichen`) **gebaut 17.09.2026**; Liste kostenloser Portale in `config/transparency.php` vorbereitet, leer bis geprüfte Einträge vorliegen | S | keine |
| 2.6 | Zuschriften an Betreiber von Portalübersichten — übernimmt der Betreiber, nicht Teil der Umsetzung | S | 2.4 |
| 2.7 | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` | M | 2.1 |
| 2.6 | Zuschriften an Betreiber von Portalübersichten — **extern, in Arbeit (Betreiber, Stand 18.09.2026)**; nicht Teil der Umsetzung | S | 2.4 |
| ~~2.7~~ | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` — **17.09.2026** (`/llms.txt` je Host, Entitäten über 2.1; Hauptdokument) | M | 2.1 |
**2.7 rückt vor.** Das Feld ist nicht mehr leer: seit Ende August 2026 besetzt mit Leitquelle ein kuratiertes Portal genau diese Positionierung, betrieben von einer Berliner PR-Agentur, 89 Euro je Meldung. Der Bestand dort ist zum Stichtag klein und die Aufbereitung Handarbeit; die These wird damit bestätigt, nicht der Markt besetzt. Der Vorteil liegt bei Archiv, Zulauf und Automatisierung — nicht beim Vokabular. Begriffe wie "KI-lesbar" oder "Referenzversion" sind belegt und werden nicht übernommen.

View file

@ -40,7 +40,7 @@ Schneller als über die API geht es zunächst über eine Bestandsabfrage nach id
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1c.7~~ | Doppelveröffentlichung erheben — **gebaut 17.09.2026**: `content_hash`, `press-releases:hash-content`, `press-releases:duplicates-report`. Befund: 9.092 Paare = 53 % des Archivs (Protokoll Teil 7) | S | keine |
| ~~1c.7~~ | Doppelveröffentlichung erheben — **gebaut 17.09.2026**: `content_hash`, `press-releases:hash-content`, `press-releases:duplicates-report`. Befund: 9.092 Paare = 53 % des Archivs (Protokoll Teil 9) | S | keine |
| ~~1c.8~~ | Kanonische Zuordnung — **gebaut 17.09.2026, ausgeschaltet bis E9**: `CanonicalAssigner`, `canonical_press_release_id`, Zuordnung bei Veröffentlichung + `press-releases:assign-canonicals`; rel=canonical, mainEntityOfPage, Sitemaps nur Erstquelle. Schalter `CANONICAL_ASSIGNMENT_ENABLED` | M | 1c.7 |
| ~~1c.9~~ | Quellenauskunft — **gebaut 17.09.2026, hinter `GSC_CUSTOMER_VISIBLE`**: `user_canonical` gespeichert, `sourceVerdict()` (eigene / andere eigene Fassung / fremdes Portal), Text im Block „Google-Index" | S | 1c.4, 1c.8 |
@ -99,7 +99,7 @@ Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der
Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nicht gebraucht — das Tageskontingent geht an die beiden Portale. Zwei Dinge sind dort trotzdem zu prüfen:
- ~~Stehen Konto- und Login-Bereiche auf `noindex`?~~ **17.09.2026: ja, jetzt per Meta-Tag in allen Portal-Layouts.** Die Indexierungsübersicht der neuen Property zeigt, was Google von dort erfasst hat.
- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar (Canonical zeigt aufs Portal, keine Sitemap-Einträge) — Empfehlung 301 auf businessportal24, ~34.000 Adressen, wartet auf Freigabe.
- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. ~~Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar~~ — **erledigt 18.09.2026 nach Freigabe (Commit 24c1bbf):** 301 aufs Portal des Eintrags, bei gleichem Slug auf die Erstquelle, unbekannter Slug bleibt 404, nur GET (Protokoll Teil 10). Feeds (3.1) leiten genauso.
---
@ -108,4 +108,4 @@ Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nich
| Nr. | Frage | Betrifft |
|---|---|---|
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung wie vorgeschlagen (businessportal24: finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; presseecho: alle übrigen inkl. sonstiges). Zuordnung ist aktiv, `CANONICAL_ASSIGNMENT_ENABLED` steht standardmäßig auf true | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 8) | 2.4 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 10) | 2.4 |

View file

@ -1540,7 +1540,7 @@ Test: `RetireLinkSpamCompaniesTest`.
---
## Teil 5 — Block 2: Organische Sichtbarkeit (ab 17.09.2026)
# Teil 7 — 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,
@ -1739,11 +1739,11 @@ neue Tabellen-Styles), `view:cache`/`route:cache` wie üblich, dann
eine Meldung im Rich-Results-Test (search.google.com/test/rich-results)
prüfen.
## Teil 6 — Anhang 1 zur Roadmap (17.09.2026)
# Teil 8 — Anhang 1 zur Roadmap (17.09.2026)
Grundlage: `roadmap-anhang1-2026.md`. 2.6 (Zuschriften an Betreiber)
bewusst ausgelassen — übernimmt der Betreiber an anderer Stelle. 2.7 war
mit Teil 5 bereits gebaut; die Formulierungsgrenze (Abruf belegbar,
mit Teil 7 bereits gebaut; die Formulierungsgrenze (Abruf belegbar,
Nennung nicht) gilt in allen neuen Texten.
### 1c.0 Crawler-Abruf-Protokoll
@ -1891,7 +1891,7 @@ Inhalte trägt das Hilfe-Center. Dafür steht **Hilfe** jetzt in der
Topleiste der Portale neben FAQ (Desktop) und im Burger-Menü unter den
Service-Links (Mobile); der Hub hatte den Link bereits.
## Teil 7 — Anhang 2: Doppelveröffentlichung und Quellenauskunft (17.09.2026)
# Teil 9 — Anhang 2: Doppelveröffentlichung und Quellenauskunft (17.09.2026)
Grundlage: `roadmap-anhang2-2026.md`. Commits siehe unten. **Der
Mechanismus ist gebaut und getestet, aber ausgeschaltet**
@ -1999,7 +1999,7 @@ Hintergrund), dann `press-releases:duplicates-report`. Erst nach E9:
`CANONICAL_ASSIGNMENT_ENABLED=true`, `press-releases:assign-canonicals
--dry-run`, dann ohne Flag, `view:cache`, Sitemaps prüfen.
## Teil 8 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026)
# Teil 10 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026)
### E9 — kanonische Zuordnung aktiv
@ -2122,3 +2122,80 @@ Pfade werden jetzt ab `base_path()` aufgelöst.
Dev-Verteilung presseecho (Bestand): direkt 8.845 Erstquellen / 2.126
Kopien, API 1.308 / 308.
# Teil 11 — Abschluss Block 0–2, Block 3.1 RSS-Feeds (18.09.2026)
## Sichtung des Ordners (18.09.2026)
Alle Dokumente in `dev/2026-09-01/` gegen Code und Git-Stand abgeglichen.
Befunde und was daraus wurde:
| Befund | Erledigt |
|---|---|
| `GSC_PROPERTY_BUSINESSPORTAL24` hatte als Vorgabe `sc-domain:businessportal24.de`, die Property ist `.com` | Vorgabe in `config/search_console.php` und `.env.example` korrigiert (live ist der Wert explizit gesetzt) |
| Anhang 1: Tippfehler in der Übersicht, 1c.2/1c.3/2.7 nicht abgehakt | angeglichen |
| Protokoll: „Teil 5" und „Teil 6" doppelt vergeben | zweite Serie ist jetzt Teil 7–10, Verweise in Roadmap und Anhang 2 nachgezogen |
| Log-Aufteilung je Thema lag uncommittet auf dem Branch | Commit 2ec6169 (billing, ki, monitoring, market als Tagesdateien; Suite loggt nur nach `testing-*.log`) |
| `docs/weiteres`: Decision-Update Duplicate-Content gelöscht, Verlinkung & Backlinks wieder da | Duplicate-Content ist durch Anhang 2 überholt (entfernt, README-Index nachgezogen); Verlinkung bleibt als Referenz für die Link-Policy |
| E6 stand offen, ist aber in 1d.1/1d.2 umgesetzt | als entschieden geführt |
| 2.6 Zuschriften | extern, in Arbeit beim Betreiber — nicht Teil der Umsetzung |
| 1c.5/E8 | Indexierungsprüfung läuft täglich; Entscheidung, sobald die Verteilung belastbar ist |
**Bleibt offen, bewusst nicht aus dem Code heraus erledigt:**
- 0.7: Absatz zur Ereignistabelle (Zweck, Umfang, 395 Tage, Rechtsgrundlage)
in der Datenschutzerklärung — Rechtstext, Betreiber.
- 0.1: Kohortenwerte „28–55 Tage" vom Live-System (Fenster 01.06.–01.09.)
einmal erheben und ablegen: `php artisan press-releases:view-distribution --from=2026-06-01 --to=2026-09-01 --json > dev/2026-09-01/basiswert-0.1.json`.
- Abbruchbedingungen der Blöcke 1–3 tragen weiter Platzhalter für Zeitraum
und Schwellwert; Block 1 läuft seit 17.09. ohne festen Vergleichspunkt.
- 2.2 Publisher Center (Betreiber), Ergebnis nach 4–6 Wochen in der Search
Console.
## 3.1 — RSS-Feeds je Branche
**Stand vorher:** keine Feeds (siehe 1c.1: „keine Feeds vorhanden, daher
kein Feed-Eintrag").
**Umsetzung** (`FeedController`, `web.feed`-View):
- `/{edition}/feed` — alle Meldungen der Ausgabe (Portal × Sprache);
`/{edition}/category/{slug}/feed` — Branche inklusive aktiver
Unterkategorien, wie die Branchenseite. Jeweils die jüngsten 50, neueste
zuerst, nur `visibleInArchive` (kein Termin in der Zukunft, kein 410).
- RSS 2.0 mit `atom:link rel="self"`, `dc:creator` = Firmenname,
`category` = lokalisierter Branchenname, `guid` = Permalink, Teaser 300
Zeichen ohne Markup. Kanaltitel „Pressemitteilungen {Branche} – {Portal}",
Beschreibung aus der Kategoriebeschreibung (2.3), sonst Tagline.
- Bei Doppelveröffentlichung führt jedes Portal seine eigene Fassung — Leser
und Feeds sehen beide (Anhang 2, Abschnitt 1); nur die Suchmaschine
bekommt die Erstquelle. Kein Canonical-Filter im Feed.
- Cache wie die Seitendaten (`WebPageCache`, 5 min frisch, bis 1 h stale
mit Neuaufbau nach der Antwort); `WebCaches::forgetPublicPages()` leert
ihn mit. Eine Korrektur (nur `forgetSitemaps()`) erscheint im Feed nach
spätestens fünf Minuten — hingenommen.
- Hub: `feed` und `kategorie.feed` in `canonical.portal_only_content_routes`
→ 301 auf presseecho, wie Kategorie und Archiv.
- Autodiscovery: `@yield('feed_links')` im Web-Layout; Startseiten nennen
den Portal-Feed, Branchenseite Branchen- und Portal-Feed, Jahresarchiv
den Branchen-Feed — Adressen aus der Portal-Basis, nicht aus `url()`
(das liefert wegen `forceRootUrl` den Hub). Sichtbarer Link „RSS-Feed"
in der Archiv-Leiste der Branchenseite; `llms.txt` nennt beide Muster.
- Absenderansicht: Block „RSS-Feeds" im Panel „Auslieferung" mit Branchen-
und Portal-Feed je Portal der Meldung, plus dem Satz, dass Abonnenten uns
nicht bekannt sind (`VisibilityStats::feedLinks()`).
- Übersetzungen unter `web.rss` — `web.feed` war schon die Meldungsliste der
Startseite.
Nicht gebaut: Feeds je Newsroom (kein Arbeitspaket), Bild-Enclosures
(zusätzliche Queries je Item, kein Bedarf benannt), Feed-Einträge in der
Sitemap (Feeds gehören nicht hinein).
Tests: `FeedTest` (7 — Branche mit Kindern und Fremdportal, Portal-Feed,
404, englische Ausgabe, Autodiscovery + llms.txt, Hub-301, Cache),
`VisibilityStatsTest` (+Feed-Links im Summary und im Panel). Pint, Larastan
ohne Befund.
**Deploy:** `view:cache`/`route:cache` neu; kein `migrate`, kein
`cache:clear` (neue Cache-Keys).

View file

@ -322,6 +322,16 @@ return [
'cta_pricing' => 'Tarife & Branchen-Pakete ansehen',
],
'rss' => [
'portal_title' => 'Pressemitteilungen – :brand',
'category_title' => 'Pressemitteilungen :category – :brand',
'default_description' => 'Aktuelle Pressemitteilungen auf :brand.',
'portal_link_title' => 'RSS-Feed: alle Pressemitteilungen – :brand',
'category_link_title' => 'RSS-Feed: Pressemitteilungen :category – :brand',
'link' => 'RSS-Feed',
'link_aria' => 'RSS-Feed der Branche :category',
],
'category_archive' => [
'eyebrow' => 'Archiv',
'breadcrumb' => 'Archiv :year',

View file

@ -317,6 +317,16 @@ return [
'cta_pricing' => 'View plans & industry packages',
],
'rss' => [
'portal_title' => 'Press releases – :brand',
'category_title' => ':category press releases – :brand',
'default_description' => 'Latest press releases on :brand.',
'portal_link_title' => 'RSS feed: all press releases – :brand',
'category_link_title' => 'RSS feed: :category press releases – :brand',
'link' => 'RSS feed',
'link_aria' => 'RSS feed for :category',
],
'category_archive' => [
'eyebrow' => 'Archive',
'breadcrumb' => 'Archive :year',

View file

@ -1598,6 +1598,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
</div>
{{-- RSS-Feeds (Roadmap 3.1): Branchen- und Portal-Feed je Portal. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('RSS-Feeds') }}</div>
@if (($visibility['feeds'] ?? []) !== [])
<ul class="mt-1 m-0 p-0 list-none space-y-0.5 text-[12.5px]">
@foreach ($visibility['feeds'] as $feed)
<li><a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a></li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
@else
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}</p>
@endif
</div>
{{-- Abrufe durch Such- und KI-Dienste (Roadmap 1c.6). Ein
Abruf ist kein Zitat — die Anzeige belegt nur, dass der
Crawler des Dienstes die Seite geholt hat. --}}

View file

@ -49,6 +49,10 @@
<link rel="alternate" hreflang="x-default" href="{{ url('/de') }}">
@endsection
@section('feed_links')
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.portal_link_title', ['brand' => ($brand['name'] ?? 'businessportal24').($brand['accent'] ?? '')]) }}" href="{{ rtrim($domainUrl ?? config('app.url'), '/') }}/{{ app()->getLocale() }}/feed">
@endsection
@section('content')
<x-web.site-header />

View file

@ -0,0 +1,27 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>{{ $channel['title'] }}</title>
<link>{{ $channel['link'] }}</link>
<atom:link href="{{ $channel['self'] }}" rel="self" type="application/rss+xml" />
<description>{{ $channel['description'] }}</description>
<language>{{ $channel['language'] }}</language>
<lastBuildDate>{{ $channel['last_build'] }}</lastBuildDate>
<ttl>15</ttl>
@foreach ($items as $item)
<item>
<title>{{ $item['title'] }}</title>
<link>{{ $item['link'] }}</link>
<guid isPermaLink="true">{{ $item['link'] }}</guid>
<pubDate>{{ $item['published_at'] }}</pubDate>
<description>{{ $item['description'] }}</description>
@if ($item['category'])
<category>{{ $item['category'] }}</category>
@endif
@if ($item['creator'])
<dc:creator>{{ $item['creator'] }}</dc:creator>
@endif
</item>
@endforeach
</channel>
</rss>

View file

@ -75,6 +75,12 @@
@endsection
@endif
@section('feed_links')
@if ($catSlug)
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.category_link_title', ['category' => $catName, 'brand' => $brandLabel]) }}" href="{{ $portalBase }}/{{ app()->getLocale() }}/category/{{ $catSlug }}/feed">
@endif
@endsection
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection

View file

@ -72,6 +72,13 @@
@endsection
@endif
@section('feed_links')
@if ($catSlug)
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.category_link_title', ['category' => $catName, 'brand' => $brandLabel]) }}" href="{{ $portalBase }}/{{ app()->getLocale() }}/category/{{ $catSlug }}/feed">
@endif
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.portal_link_title', ['brand' => $brandLabel]) }}" href="{{ $portalBase }}/{{ app()->getLocale() }}/feed">
@endsection
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection
@ -270,7 +277,10 @@
<nav class="mt-9 pt-6 border-t border-bg-rule" aria-label="{{ __('web.category_archive.nav_label') }}">
<div class="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<h3 class="font-serif text-[18px] font-semibold m-0 text-ink">{{ __('web.category_archive.section_title', ['category' => $catName]) }}</h3>
<span class="text-[11.5px] text-ink-3">{{ __('web.category_archive.section_total', ['count' => $fmt(array_sum($archiveYears))]) }}</span>
<span class="text-[11.5px] text-ink-3">
{{ __('web.category_archive.section_total', ['count' => $fmt(array_sum($archiveYears))]) }}
· <a href="{{ $portalBase }}/{{ app()->getLocale() }}/category/{{ $catSlug }}/feed" type="application/rss+xml" class="text-ink-2 underline underline-offset-2 hover:text-ink" aria-label="{{ __('web.rss.link_aria', ['category' => $catName]) }}">{{ __('web.rss.link') }}</a>
</span>
</div>
<ul class="m-0 p-0 list-none flex flex-wrap gap-1.5">
@foreach ($archiveYears as $archiveYear => $archiveCount)

View file

@ -48,6 +48,9 @@
@yield('hreflang')
@endif
{{-- RSS-Autodiscovery (Roadmap 3.1) – Portal-Feed und, auf Branchenseiten, der Feed der Branche --}}
@yield('feed_links')
{{-- OpenGraph / Social --}}
<meta property="og:type" content="{{ $ogType }}">
<meta property="og:site_name" content="{{ $siteName }}">

View file

@ -49,6 +49,10 @@
<link rel="alternate" hreflang="x-default" href="{{ url('/de') }}">
@endsection
@section('feed_links')
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.portal_link_title', ['brand' => ($brand['name'] ?? 'presseecho').($brand['accent'] ?? '')]) }}" href="{{ rtrim($domainUrl ?? config('app.url'), '/') }}/{{ app()->getLocale() }}/feed">
@endsection
@section('content')
<x-web.site-header />

View file

@ -5,6 +5,7 @@ use App\Http\Controllers\PressReleasePreviewController;
use App\Http\Controllers\Web\CategoryArchiveController;
use App\Http\Controllers\Web\CategoryController;
use App\Http\Controllers\Web\ContactController;
use App\Http\Controllers\Web\FeedController;
use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\LegacyMediaRedirectController;
use App\Http\Controllers\Web\LlmsTxtController;
@ -228,6 +229,11 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
->where('year', '[0-9]{4}')
->name('kategorie.archiv');
// RSS-Feeds (Roadmap 3.1): alle Meldungen der Ausgabe und je Branche
// (inkl. Unterkategorien). Der Hub leitet auf das Portal weiter.
Route::get('feed', FeedController::class)->name('feed');
Route::get('category/{slug}/feed', FeedController::class)->name('kategorie.feed');
// Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution)
Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail');

View file

@ -3,6 +3,7 @@
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseCrawlerHit;
@ -108,7 +109,9 @@ test('the daily series is gapless, marks days before measurement as unmeasured a
test('the release summary reports sitemap presence only for public releases and lists crawler fetches', function () {
/** @var TestCase $this */
[, $company] = claimant();
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'category_id' => $category->id, 'published_at' => now()->subDay(), 'hits' => 9]);
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'status' => 'draft']);
PressReleaseEvent::query()->create(['press_release_id' => $public->id, 'type' => PressReleaseEventType::Share, 'channel' => 'linkedin']);
@ -126,12 +129,18 @@ test('the release summary reports sitemap presence only for public releases and
->and($summary)->not->toHaveKey('search_url')
->and(collect($summary['crawlers'])->pluck('fetches', 'service')->sortKeys()->all())->toBe(['google' => 2, 'openai' => 1])
->and(collect($summary['crawlers'])->firstWhere('service', 'google')['label'])->toBe('Google Suche')
->and($summary['first_view_at'])->not->toBeNull();
->and($summary['first_view_at'])->not->toBeNull()
// RSS-Feeds (3.1): Branchen-Feed und Portal-Feed des eigenen Portals.
->and(collect($summary['feeds'])->pluck('url')->all())->toBe([
'https://businessportal24.test/de/category/handel/feed',
'https://businessportal24.test/de/feed',
]);
$draftSummary = $stats->releaseSummary($draft);
expect($draftSummary['in_sitemap_since'])->toBeNull()
->and($draftSummary['crawlers'])->toBe([]);
->and($draftSummary['crawlers'])->toBe([])
->and($draftSummary['feeds'])->toBe([]);
});
test('the release page shows the delivery panel with daily bars, sitemap date and crawler fetches, but no search link', function () {
@ -154,6 +163,8 @@ test('the release page shows the delivery panel with daily bars, sitemap date an
->assertSeeText('Anthropic / Claude')
->assertSeeText('1 Abruf')
->assertSeeText('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat')
->assertSeeText('RSS-Feeds')
->assertSee('https://businessportal24.test/de/feed', false)
// Indexierungsstatus bleibt zu (E8), solange die Kundenansicht nicht freigegeben ist.
->assertDontSeeText('Google-Index')
->assertSee('<svg', false)

View file

@ -0,0 +1,168 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Support\WebCaches;
use App\Support\WebPageCache;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* RSS-Feeds je Branche und je Portal (Roadmap 3.1): /{edition}/feed und
* /{edition}/category/{slug}/feed.
*/
function feedCategory(Portal $portal = Portal::Presseecho, string $slug = 'handel', string $name = 'Handel', ?Category $parent = null): Category
{
$category = Category::factory()->create(['portal' => $portal, 'is_active' => true, 'parent_id' => $parent?->id]);
$category->translations()->create(['locale' => 'de', 'name' => $name, 'slug' => $slug, 'description' => 'Meldungen aus dem Bereich '.$name.'.']);
$category->translations()->create(['locale' => 'en', 'name' => $name.' EN', 'slug' => $slug.'-en']);
return $category;
}
function feedRelease(Category $category, string $slug, array $attributes = [], Portal $portal = Portal::Presseecho): PressRelease
{
return PressRelease::factory()
->published()
->forPortal($portal)
->for($category)
->for(Company::factory()->presseecho()->create(['name' => 'Feed GmbH & Co.']))
->create(['slug' => $slug, 'published_at' => now()->subHour(), 'language' => 'de', ...$attributes]);
}
test('the category feed lists the newest releases of the category and its children on the own portal', function () {
/** @var TestCase $this */
$category = feedCategory();
$child = feedCategory(Portal::Presseecho, 'einzelhandel', 'Einzelhandel', $category);
$other = feedCategory(Portal::Presseecho, 'energie', 'Energie');
feedRelease($category, 'neu', ['title' => 'Neue Meldung & mehr', 'text' => '<p>Erster Absatz der Meldung.</p>', 'published_at' => now()->subHour()]);
feedRelease($child, 'kind', ['title' => 'Aus der Unterkategorie', 'published_at' => now()->subHours(2)]);
feedRelease($other, 'andere-branche');
feedRelease($category, 'fremdes-portal', [], Portal::Businessportal24);
feedRelease($category, 'terminiert', ['published_at' => now()->addHour()]);
feedRelease($category, 'englisch', ['language' => 'en']);
feedRelease($category, 'beide-portale', ['published_at' => now()->subHours(3)], Portal::Both);
$response = $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
expect($response->headers->get('Content-Type'))->toBe('application/rss+xml; charset=UTF-8');
$xml = (string) $response->getContent();
expect($xml)
->toStartWith('<?xml version="1.0" encoding="UTF-8"?>')
->toContain('<title>Pressemitteilungen Handel – presseecho</title>')
->toContain('<link>https://presseecho.test/de/category/handel</link>')
->toContain('<atom:link href="https://presseecho.test/de/category/handel/feed" rel="self" type="application/rss+xml" />')
->toContain('<description>Meldungen aus dem Bereich Handel.</description>')
->toContain('<language>de</language>')
->toContain('<title>Neue Meldung &amp; mehr</title>')
->toContain('<guid isPermaLink="true">https://presseecho.test/de/press-release/neu</guid>')
->toContain('<description>Erster Absatz der Meldung.</description>')
->toContain('<category>Handel</category>')
->toContain('<dc:creator>Feed GmbH &amp; Co.</dc:creator>')
->toContain('<link>https://presseecho.test/de/press-release/kind</link>')
->toContain('<link>https://presseecho.test/de/press-release/beide-portale</link>');
// Neueste zuerst.
expect(strpos($xml, 'press-release/neu'))->toBeLessThan((int) strpos($xml, 'press-release/kind'));
foreach (['andere-branche', 'fremdes-portal', 'terminiert', 'press-release/englisch'] as $absent) {
$this->assertStringNotContainsString($absent, $xml);
}
// Wohlgeformt: die Ausgabe muss ein XML-Parser lesen können.
$document = simplexml_load_string($xml);
expect($document)->not->toBeFalse()
->and(count($document->channel->item))->toBe(3);
});
test('the portal feed lists releases of all categories, newest first, capped', function () {
/** @var TestCase $this */
$handel = feedCategory();
$energie = feedCategory(Portal::Presseecho, 'energie', 'Energie');
feedRelease($handel, 'handel-neu', ['published_at' => now()->subHour()]);
feedRelease($energie, 'energie-alt', ['published_at' => now()->subDay()]);
feedRelease($handel, 'fremd', [], Portal::Businessportal24);
$xml = (string) $this->get('https://presseecho.test/de/feed')->assertSuccessful()->getContent();
expect($xml)
->toContain('<title>Pressemitteilungen – presseecho</title>')
->toContain('<link>https://presseecho.test/de</link>')
->toContain('<atom:link href="https://presseecho.test/de/feed" rel="self" type="application/rss+xml" />')
->toContain('press-release/handel-neu')
->toContain('press-release/energie-alt');
expect(strpos($xml, 'handel-neu'))->toBeLessThan((int) strpos($xml, 'energie-alt'));
$this->assertStringNotContainsString('press-release/fremd', $xml);
});
test('an unknown or inactive category has no feed', function () {
/** @var TestCase $this */
$inactive = feedCategory(Portal::Presseecho, 'veraltet', 'Veraltet');
$inactive->update(['is_active' => false]);
$this->get('https://presseecho.test/de/category/gibt-es-nicht/feed')->assertNotFound();
$this->get('https://presseecho.test/de/category/veraltet/feed')->assertNotFound();
});
test('the english edition serves the feed under the english slug with english releases only', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'deutsch');
feedRelease($category, 'english', ['language' => 'en', 'title' => 'English release']);
$xml = (string) $this->get('https://presseecho.test/en/category/handel-en/feed')->assertSuccessful()->getContent();
expect($xml)
->toContain('<title>Handel EN press releases – presseecho</title>')
->toContain('<language>en</language>')
->toContain('https://presseecho.test/en/press-release/english');
$this->assertStringNotContainsString('press-release/deutsch', $xml);
});
test('category and home pages announce the feeds for autodiscovery and link them', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'irgendwas');
$this->get('https://presseecho.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: Pressemitteilungen Handel – presseecho" href="https://presseecho.test/de/category/handel/feed">', false)
->assertSee('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: alle Pressemitteilungen – presseecho" href="https://presseecho.test/de/feed">', false)
->assertSee('href="https://presseecho.test/de/category/handel/feed" type="application/rss+xml"', false);
$this->get('https://presseecho.test/de')->assertSuccessful()
->assertSee('type="application/rss+xml" title="RSS-Feed: alle Pressemitteilungen – presseecho" href="https://presseecho.test/de/feed"', false);
$this->get('https://presseecho.test/llms.txt')->assertSuccessful()
->assertSee('https://presseecho.test/de/feed', false)
->assertSee('https://presseecho.test/de/category/{slug}/feed', false);
});
test('the hub redirects feed addresses to the portal', function () {
/** @var TestCase $this */
feedCategory(Portal::Both);
$this->get('https://pressekonto.test/de/feed')
->assertRedirect('https://presseecho.test/de/feed')
->assertStatus(301);
$this->get('https://pressekonto.test/de/category/handel/feed')
->assertRedirect('https://presseecho.test/de/category/handel/feed')
->assertStatus(301);
});
test('the feed is cached with the page data and cleared with the public pages', function () {
/** @var TestCase $this */
$category = feedCategory();
feedRelease($category, 'erste');
$this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
$key = WebPageCache::key('feed', ['presseecho', 'de', 'handel']);
expect(Cache::has($key))->toBeTrue();
WebCaches::forgetPublicPages();
expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'handel'])))->toBeFalse();
});