web: Jahresarchiv je Branche, Kategoriebeschreibungen, Portalname im Titel (Roadmap 2.3)

- /de/category/{slug}/archiv/{jahr} (CategoryArchivePageData,
  CategoryArchiveController, View kategorie-archiv): 30 Meldungen je
  Seite, neueste zuerst, Jahresleiste mit Zahlen; ohne Jahr das juengste
  Jahr mit Meldungen. Canonical mit ?page=N auf Folgeseiten, hreflang
  nur auf Seite 1, CollectionPage + BreadcrumbList. Gecacht bis Seite 3.
  Leere Jahre, unbekannte Kategorien, leere Folgeseiten: 404.
- Branchenseite: Jahresleiste unter dem Feed (archiveYears, eine
  gruppierte Query); Sitemap listet alle Jahresarchive.
- categories:seed-descriptions {--dry-run} {--force}: fuellt leere DE/EN
  Beschreibungen aus database/seeders/data/category-descriptions.php
  (14 Kategorien, sachlich, ohne Werbeaussagen), leert den Seiten-Cache.
- Branchenseite: Titel trug config('app.name') ("– pressekonto") statt
  des Portalnamens — jetzt presseecho/businessportal24.
- Protokoll Teil 5 (2.3 mit Befund: 0 von 14 Beschreibungen, Sonstiges =
  28 % des Bestands), Roadmap 2.3 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:25:29 +00:00
parent b60a28dc9c
commit 4616bb7aad
15 changed files with 793 additions and 13 deletions

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Support\WebPageCache;
use Illuminate\Console\Command;
/**
* Beschreibungen der Branchenseiten einspielen (Roadmap 2.3). Ohne eigene
* Beschreibung zeigen alle Branchenseiten denselben Standardsatz — für
* Suchmaschinen 14 gleich aussehende Seiten. Texte liegen in
* database/seeders/data/category-descriptions.php, Schlüssel ist der
* DE-Slug. Nur leere Felder werden gefüllt; --force überschreibt.
*/
class SeedCategoryDescriptions extends Command
{
protected $signature = 'categories:seed-descriptions
{--dry-run : Nur zeigen, was geschrieben würde}
{--force : Auch vorhandene Beschreibungen überschreiben}';
protected $description = 'Füllt leere Kategoriebeschreibungen (DE/EN) aus database/seeders/data/category-descriptions.php.';
public function handle(): int
{
/** @var array<string, array<string, string>> $texts */
$texts = require database_path('seeders/data/category-descriptions.php');
$rows = [];
$written = 0;
foreach ($texts as $slug => $byLocale) {
$category = Category::query()
->whereHas('translations', fn ($query) => $query->where('locale', 'de')->where('slug', $slug))
->with('translations')
->first();
if (! $category) {
$rows[] = [$slug, '—', 'Kategorie nicht gefunden'];
continue;
}
foreach ($byLocale as $locale => $text) {
/** @var CategoryTranslation|null $translation */
$translation = $category->translations->firstWhere('locale', $locale);
if (! $translation) {
$rows[] = [$slug, $locale, 'keine Übersetzung'];
continue;
}
if (filled($translation->description) && ! $this->option('force')) {
$rows[] = [$slug, $locale, 'bereits vorhanden, übersprungen'];
continue;
}
$rows[] = [$slug, $locale, mb_strimwidth($text, 0, 60, '…')];
if (! $this->option('dry-run')) {
$translation->forceFill(['description' => $text])->save();
$written++;
}
}
}
$this->table(['Kategorie', 'Sprache', 'Ergebnis'], $rows);
if ($this->option('dry-run')) {
$this->info('[Dry-Run] Nichts geschrieben.');
return self::SUCCESS;
}
WebPageCache::flush();
$this->info(sprintf('%d Beschreibungen geschrieben, Seiten-Cache geleert.', $written));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\CategoryArchivePageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class CategoryArchiveController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} (siehe CategoryController);
// der optionale Parameter muss hinter den injizierten Abhängigkeiten stehen.
public function __invoke(CategoryArchivePageData $pageData, string $edition, string $slug, ?string $year = null): View
{
return view('web.kategorie-archiv', $pageData->build(
WebPortalContext::fromRequest(),
$slug,
$year === null ? null : (int) $year,
));
}
}

View file

@ -7,6 +7,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Web\CategoryArchivePageData;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -76,18 +77,28 @@ class SitemapController extends Controller
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
// Kategorien über die lokalisierten Slugs (CategoryTranslation).
$categorySlugs = Category::query()
// Kategorien über die lokalisierten Slugs (CategoryTranslation),
// dazu je Kategorie die Jahresarchive (2.3).
$categories = Category::query()
->where('is_active', true)
->whereIn('portal', $context->portalValues)
->with('translations')
->get()
->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')?->slug)
->filter()
->unique();
->with(['translations', 'children' => fn ($query) => $query->where('is_active', true)])
->get();
foreach ($categories as $category) {
$slug = $category->translations->firstWhere('locale', 'de')?->slug;
if (! $slug) {
continue;
}
foreach ($categorySlugs as $slug) {
$urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null];
$categoryIds = $category->children->pluck('id')->prepend($category->id)->unique()->values()->all();
foreach (array_keys(CategoryArchivePageData::yearsWithCounts($categoryIds, $context->portalValues, 'de')) as $year) {
$urls[] = ['loc' => $base.'/de/category/'.$slug.'/archiv/'.$year, 'lastmod' => null];
}
}
// Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv

View file

@ -0,0 +1,150 @@
<?php
namespace App\Services\Web;
use App\Models\Category;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
/**
* Jahresarchiv einer Branche (Roadmap 2.3): `/category/{slug}/archiv/{jahr}`.
* Die Branchenseite zeigt nur die jüngsten ~15 Meldungen; das Archiv macht
* den Bestand (34.000 Meldungen seit 2008) als stabile, verlinkte Seiten
* erreichbar. Jahr statt fortlaufender Seitenzahl, weil eine Jahresseite
* ihren Inhalt behält, während `?page=2` bei jeder neuen Meldung verrutscht.
*/
class CategoryArchivePageData
{
public const PerPage = 30;
/** Nur die ersten Seiten je Jahr werden gecacht (siehe NewsroomsPageData). */
private const MaxCachedPage = 3;
/**
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug, ?int $year): array
{
$page = Paginator::resolveCurrentPage();
$build = fn (): array => $this->buildFresh($context, $slug, $year, $page);
if ($page > self::MaxCachedPage) {
return $build();
}
return WebPageCache::remember(
WebPageCache::key('category-archive', [$context->portal->value, $context->language, $slug, (string) $year, 'page', $page]),
$build
);
}
/**
* Jahre mit Meldungen einer Branche (inkl. Unterkategorien), absteigend
* — auch für die Jahresleiste auf der Branchenseite.
*
* @param list<int> $categoryIds
* @param list<string> $portalValues
* @return array<int, int> Jahr => Anzahl
*/
public static function yearsWithCounts(array $categoryIds, array $portalValues, string $language): array
{
$yearExpression = DB::connection()->getDriverName() === 'sqlite'
? "CAST(strftime('%Y', published_at) AS INTEGER)"
: 'YEAR(published_at)';
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->selectRaw($yearExpression.' as year, COUNT(*) as releases_count')
->groupBy('year')
->orderByDesc('year')
->pluck('releases_count', 'year')
->map(fn ($count): int => (int) $count)
->all();
}
/**
* @return array<string, mixed>
*/
private function buildFresh(WebPortalContext $context, string $slug, ?int $year, int $page): array
{
$portalValues = $context->portalValues;
$language = $context->language;
$category = Category::query()
->whereIn('portal', $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),
'parent.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();
$years = self::yearsWithCounts($categoryIds, $portalValues, $language);
if ($years === []) {
abort(404);
}
// Ohne Jahr: das jüngste Jahr mit Meldungen (nicht das Kalenderjahr —
// eine Branche ohne Meldung im laufenden Jahr hätte sonst eine leere Seite).
$year ??= (int) array_key_first($years);
if (! isset($years[$year])) {
abort(404);
}
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->whereBetween('published_at', [
now()->setDate($year, 1, 1)->startOfDay(),
now()->setDate($year, 12, 31)->endOfDay(),
])
->with([
'company:id,name,slug',
'category.translations' => fn ($query) => $query->where('locale', $language),
])
->orderByDesc('published_at')
->paginate(self::PerPage, ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'], 'page', $page);
if ($page > 1 && $releases->isEmpty()) {
abort(404);
}
return [
'category' => $category,
'categoryAlternateSlugs' => $category->translations()->whereIn('locale', ['de', 'en'])->pluck('slug', 'locale')->all(),
'year' => $year,
'years' => $years,
'releases' => $releases,
'totalReleases' => array_sum($years),
];
}
}

View file

@ -293,6 +293,8 @@ class CategoryPageData
'topNewsrooms' => $topNewsrooms,
'subCategories' => $subCategories,
'relatedCategories' => $relatedCategories,
// Jahresleiste ins Archiv (2.3) — eine gruppierte Query.
'archiveYears' => CategoryArchivePageData::yearsWithCounts($categoryIds, $portalValues, $language),
];
}
}

View file

@ -0,0 +1,66 @@
<?php
/**
* Beschreibungen der Branchenseiten (Roadmap 2.3), je DE-Slug der Kategorie.
* Eingespielt mit `php artisan categories:seed-descriptions`; danach in
* Admin ▸ Kategorien änderbar. Sachlich, ohne Werbeaussagen — der Text steht
* als Einleitung über der Meldungsliste und ist die Meta-Description.
*/
return [
'handel' => [
'de' => 'Pressemitteilungen aus dem Handel: Einzelhandel und Großhandel, E-Commerce und Versandhandel, Filialnetze, Sortimente, Handelsmarken und Logistik. Unternehmen veröffentlichen hier Neueröffnungen, Produkteinführungen, Kooperationen und Geschäftszahlen; Verbände und Dienstleister des Handels ihre Branchenmeldungen.',
'en' => 'Press releases from retail and wholesale: e-commerce and mail order, store networks, product ranges, private labels and logistics. Companies publish store openings, product launches, partnerships and results here; trade associations and service providers their industry news.',
],
'it' => [
'de' => 'Pressemitteilungen aus IT und Software: Anwendungen und Plattformen, Cloud, IT-Sicherheit, Netzwerke, Hardware, künstliche Intelligenz und Digitalisierung von Unternehmen. Softwarehäuser, Systemhäuser, Dienstleister und Hersteller veröffentlichen hier Produktmeldungen, Versionen, Partnerschaften und Kundenprojekte.',
'en' => 'Press releases from IT and software: applications and platforms, cloud, IT security, networks, hardware, artificial intelligence and digitalisation. Software vendors, system integrators, service providers and manufacturers publish product news, releases, partnerships and customer projects here.',
],
'finanzen' => [
'de' => 'Pressemitteilungen aus der Finanzbranche: Banken und Sparkassen, Versicherungen, Vermögensverwaltung, Fonds, Kredite und Zahlungsverkehr, Finanzberatung und Fintech. Unternehmen veröffentlichen hier Produkte, Konditionen, Studien, Personalien und Geschäftsergebnisse.',
'en' => 'Press releases from the financial sector: banks, insurers, asset management, funds, lending and payments, financial advice and fintech. Companies publish products, terms, studies, appointments and results here.',
],
'reisen' => [
'de' => 'Pressemitteilungen aus Tourismus und Reisen: Reiseveranstalter und Reisebüros, Hotels und Ferienunterkünfte, Fluggesellschaften, Bahn und Busreisen, Kreuzfahrten, Destinationen und Freizeitangebote. Hier erscheinen neue Angebote, Saisonstarts, Auszeichnungen und Zahlen aus der Branche.',
'en' => 'Press releases from tourism and travel: tour operators and travel agencies, hotels and holiday accommodation, airlines, rail and coach travel, cruises, destinations and leisure. New offers, season openings, awards and industry figures appear here.',
],
'kultur' => [
'de' => 'Pressemitteilungen aus Kultur, Medien und Unterhaltung: Verlage und Buchveröffentlichungen, Musik, Film, Theater, Ausstellungen, Festivals, Veranstaltungen und Bildungsangebote. Veranstalter, Verlage, Künstler und Institutionen kündigen hier Termine, Neuerscheinungen und Programme an.',
'en' => 'Press releases from culture, media and entertainment: publishers and book releases, music, film, theatre, exhibitions, festivals, events and education. Organisers, publishers, artists and institutions announce dates, new releases and programmes here.',
],
'politik' => [
'de' => 'Pressemitteilungen aus Politik, Verbänden und Gesellschaft: Stellungnahmen zu Gesetzen und Verordnungen, Verbandsarbeit, Kampagnen, Umfragen, Initiativen und Veranstaltungen. Verbände, Vereine, Stiftungen und Organisationen veröffentlichen hier ihre Positionen und Termine.',
'en' => 'Press releases from politics, associations and society: statements on legislation, association work, campaigns, surveys, initiatives and events. Associations, clubs, foundations and organisations publish their positions and dates here.',
],
'medizin' => [
'de' => 'Pressemitteilungen aus Medizin und Gesundheit: Kliniken und Praxen, Medizintechnik, Pharma, Pflege, Therapieangebote, Prävention und Gesundheitsdienstleistungen. Hier erscheinen neue Behandlungsangebote, Produkte, Studien, Zertifizierungen und Veranstaltungen.',
'en' => 'Press releases from medicine and healthcare: hospitals and practices, medical technology, pharma, care, therapies, prevention and health services. New treatments, products, studies, certifications and events appear here.',
],
'sport' => [
'de' => 'Pressemitteilungen aus Sport und Fitness: Vereine und Verbände, Sportveranstaltungen, Fitnessstudios, Sportartikel und Ausrüstung, Sponsoring und Trainingsangebote. Veranstalter, Hersteller und Anbieter veröffentlichen hier Termine, Ergebnisse, Produkte und Kooperationen.',
'en' => 'Press releases from sport and fitness: clubs and federations, sporting events, gyms, sporting goods and equipment, sponsorship and training. Organisers, manufacturers and providers publish dates, results, products and partnerships here.',
],
'motor-verkehr' => [
'de' => 'Pressemitteilungen aus Automobil, Mobilität und Verkehr: Fahrzeughersteller und Händler, Zulieferer, Werkstätten, Elektromobilität, Nutzfahrzeuge, Zweiräder, Logistik und Verkehrsdienstleistungen. Hier erscheinen Modellvorstellungen, Angebote, Technik, Zahlen und Branchentermine.',
'en' => 'Press releases from automotive, mobility and transport: vehicle manufacturers and dealers, suppliers, workshops, electric mobility, commercial vehicles, two-wheelers, logistics and transport services. Model launches, offers, technology, figures and industry events appear here.',
],
'haus' => [
'de' => 'Pressemitteilungen rund um Haus, Bauen und Wohnen: Bauunternehmen und Handwerk, Immobilien, Einrichtung, Garten, Energie und Haustechnik, Sanierung und Baustoffe. Hersteller, Handwerksbetriebe, Makler und Dienstleister veröffentlichen hier Produkte, Projekte, Ratgeber und Termine.',
'en' => 'Press releases about home, building and living: construction and trades, real estate, interiors, garden, energy and building technology, renovation and building materials. Manufacturers, tradespeople, agents and service providers publish products, projects, guides and dates here.',
],
'mode' => [
'de' => 'Pressemitteilungen aus Mode, Beauty und Lifestyle: Bekleidung und Schuhe, Accessoires, Schmuck, Kosmetik, Designer und Labels, Kollektionen und Modehandel. Hier erscheinen Kollektionsvorstellungen, Kooperationen, Ladeneröffnungen und Trends aus der Branche.',
'en' => 'Press releases from fashion, beauty and lifestyle: apparel and footwear, accessories, jewellery, cosmetics, designers and labels, collections and fashion retail. Collection launches, collaborations, store openings and trends appear here.',
],
'sonstiges' => [
'de' => 'Pressemitteilungen, die keiner der anderen Branchen zugeordnet sind: Dienstleistungen aller Art, Handwerk, Bildung und Weiterbildung, Beratung, regionale Meldungen und Themen, für die es keine eigene Rubrik gibt.',
'en' => 'Press releases that do not fit any of the other industries: services of all kinds, trades, education and training, consulting, regional news and topics without a category of their own.',
],
'wissenschaft-forschung' => [
'de' => 'Pressemitteilungen aus Wissenschaft und Forschung: Hochschulen und Forschungseinrichtungen, Studien und Studienergebnisse, Technologietransfer, Forschungsprojekte, Förderungen, Tagungen und Publikationen. Institute, Universitäten und forschende Unternehmen veröffentlichen hier ihre Ergebnisse und Termine.',
'en' => 'Press releases from science and research: universities and research institutions, studies and findings, technology transfer, research projects, funding, conferences and publications. Institutes, universities and research-driven companies publish their results and dates here.',
],
'recht' => [
'de' => 'Pressemitteilungen aus Recht und Steuern: Kanzleien, Steuerberatung, Rechtsdienstleister, Urteile und Gesetzesänderungen mit Praxisbezug, Verbraucherrecht, Arbeitsrecht, Unternehmensrecht und Compliance. Hier erscheinen Einordnungen aktueller Entscheidungen, Ratgeber und Kanzleimeldungen.',
'en' => 'Press releases from law and tax: law firms, tax advisors, legal service providers, rulings and legislative changes with practical relevance, consumer law, employment law, corporate law and compliance. Assessments of current decisions, guides and firm news appear here.',
],
];

View file

@ -186,7 +186,7 @@ Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist
|---|---|---|---|
| ~~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.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: 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 |

View file

@ -1616,6 +1616,50 @@ zwischen Meldung und Newsroom) ist der zweite Teil von 2.7.
Tests: `LlmsTxtTest` (2).
### 2.3 — Branchen-Landingpages aus dem Archivbestand (17.09.2026)
**Befund (Dev-DB = Live-Kopie):** 34.597 sichtbare Meldungen seit 2008
(presseecho 12.587, businessportal24 22.010), 14 Hauptkategorien ohne
Unterkategorien. Die Branchenseiten (`/de/category/{slug}`) existieren
seit Juli mit Lead/Top/Feed, Statistik, Newsrooms — zeigen aber nur die
jüngsten ~15 Meldungen. Der Bestand hing nur an der Sitemap, nicht an
internen Links. Dazu: **0 von 14 Kategorien haben eine Beschreibung**,
alle Branchenseiten zeigten denselben Standardsatz (14 gleich aussehende
Seiten); und der Seitentitel hieß auf beiden Spokes „– pressekonto"
(`config('app.name')` statt Portalname) — behoben.
„Sonstiges" trägt 9.607 Meldungen (28 %), ist also keine Branche, sondern
der Sammelposten; das ist ein Einlieferungs-Thema, kein Seitenthema.
**Umsetzung:**
1. **Jahresarchiv je Branche** — `/de/category/{slug}/archiv/{jahr}`
(`CategoryArchivePageData`, `CategoryArchiveController`, View
`kategorie-archiv`): 30 Meldungen je Seite, neueste zuerst, Datum mit
Jahr, Firma, Rubrik; Jahresleiste mit Zahlen; ohne Jahr das jüngste
Jahr mit Meldungen. Jahr statt fortlaufender Seitennummer, weil eine
Jahresseite ihren Inhalt behält, während `?page=2` bei jeder neuen
Meldung verrutscht. Canonical mit `?page=N` auf Folgeseiten (der
Layout-Default hätte auf Seite 1 kanonisiert), hreflang nur auf Seite 1,
CollectionPage + BreadcrumbList als JSON-LD. Gecacht bis Seite 3 je
Jahr (Schutz gegen Cache-Aufblähung wie bei den Newsrooms). Leere
Jahre, unbekannte Kategorien und leere Folgeseiten sind 404.
2. **Jahresleiste auf der Branchenseite** unter dem Feed
(`archiveYears`, eine gruppierte Query), und alle Jahresarchive in der
Sitemap (14 × ~18 Jahre ≈ 250 URLs).
3. **Beschreibungen:** `categories:seed-descriptions {--dry-run}
{--force}` füllt leere DE/EN-Beschreibungen aus
`database/seeders/data/category-descriptions.php` (je 2–3 Sätze:
Themenumfang und wer dort veröffentlicht, ohne Werbeaussagen). Nur
leere Felder, danach in Admin ▸ Kategorien änderbar. Dry-Run auf der
Dev-DB: alle 14 Kategorien gefunden.
Tests: `CategoryArchiveTest` (5), `SeedCategoryDescriptionsTest` (2);
`tests/Feature/Web` komplett grün (305).
**Live:** `php artisan categories:seed-descriptions --dry-run`, dann ohne
Flag. Bewusst nicht gebaut: Landingpages unterhalb der Kategorien
(„Branchen" im engeren Sinn) — dafür gibt es keine Unterkategorien und
keine Daten; die Meldungen tragen nur die 14 Hauptkategorien.
### Befund zu 2.5 / E5
Die alte Ratgeber-Strecke `/de/presse/*.html` leitet seit dem Rückbau
@ -1627,6 +1671,8 @@ 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.
**Deploy (2.1/2.2/2.3/2.7):** kein `migrate`, `view:cache`/`route:cache`
wie üblich, dann `categories:seed-descriptions`. Danach
`/sitemap-news.xml`, `/llms.txt`, `/de/category/handel/archiv/2019` und
eine Meldung im Rich-Results-Test (search.google.com/test/rich-results)
prüfen.

View file

@ -321,6 +321,23 @@ return [
'cta_pricing' => 'Tarife & Branchen-Pakete ansehen',
],
'category_archive' => [
'eyebrow' => 'Archiv',
'breadcrumb' => 'Archiv :year',
'title' => 'Pressemitteilungen :category :year',
'meta_title' => 'Pressemitteilungen :category :year – Archiv – :brand',
'meta_description' => 'Alle :count Pressemitteilungen aus dem Bereich :category, die :year auf :brand veröffentlicht wurden – chronologisch, mit Unternehmen und Datum.',
'intro' => ':count Pressemitteilungen aus dem Bereich :category aus dem Jahr :year. Das Archiv dieser Branche umfasst :total Meldungen seit :first.',
'nav_label' => 'Archiv nach Jahr',
'section_title' => 'Archiv :category',
'section_total' => ':count Meldungen insgesamt',
'back_to_category' => 'Aktuelle Meldungen :category',
'list_title' => 'Meldungen :year',
'page' => 'Seite :page',
'page_of' => 'Seite :page von :last',
'sorted' => 'Neueste zuerst',
],
'detail' => [
'breadcrumbs' => 'Brotkrumen',
'press_release' => 'Pressemitteilung',

View file

@ -316,6 +316,23 @@ return [
'cta_pricing' => 'View plans & industry packages',
],
'category_archive' => [
'eyebrow' => 'Archive',
'breadcrumb' => 'Archive :year',
'title' => ':category press releases :year',
'meta_title' => ':category press releases :year – archive – :brand',
'meta_description' => 'All :count press releases in :category published on :brand in :year – chronological, with company and date.',
'intro' => ':count press releases in :category from :year. The archive of this industry holds :total releases since :first.',
'nav_label' => 'Archive by year',
'section_title' => ':category archive',
'section_total' => ':count releases in total',
'back_to_category' => 'Latest :category releases',
'list_title' => 'Releases :year',
'page' => 'Page :page',
'page_of' => 'Page :page of :last',
'sorted' => 'Newest first',
],
'detail' => [
'breadcrumbs' => 'Breadcrumbs',
'press_release' => 'Press release',

View file

@ -0,0 +1,186 @@
@extends('web.layouts.web-master')
@php
use App\Support\StructuredData;
use Illuminate\Support\Str;
\Carbon\Carbon::setLocale(app()->getLocale());
$catTranslation = $category->translations->firstWhere('locale', app()->getLocale()) ?? $category->translations->first();
$catName = $catTranslation?->name ?? __('web.category.fallback_name');
$catSlug = $catTranslation?->slug;
$parentTranslation = $category->parent?->translations->firstWhere('locale', app()->getLocale()) ?? $category->parent?->translations->first();
$parentName = $parentTranslation?->name;
$fmt = fn ($value) => number_format((int) $value, 0, ',', '.');
$currentPage = $releases->currentPage();
$lastPage = $releases->lastPage();
$portalBase = StructuredData::portalBase($domainUrl ?? null);
$brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24';
$categoryUrl = $portalBase.'/'.app()->getLocale().'/category/'.$catSlug;
$archiveUrl = $categoryUrl.'/archiv/'.$year;
// Canonical mit Seitenzahl: der Layout-Default (Pfad ohne Query) würde
// jede Folgeseite auf Seite 1 kanonisieren.
$canonicalUrl = $currentPage > 1 ? $archiveUrl.'?page='.$currentPage : $archiveUrl;
$pageSuffix = $currentPage > 1 ? ' – '.__('web.category_archive.page', ['page' => $currentPage]) : '';
$metaTitle = __('web.category_archive.meta_title', ['category' => $catName, 'year' => $year, 'brand' => $brandLabel]).$pageSuffix;
$metaDescription = __('web.category_archive.meta_description', ['category' => $catName, 'year' => $year, 'count' => $fmt($years[$year] ?? 0), 'brand' => $brandLabel]);
$alternateSlugs = $categoryAlternateSlugs ?? [];
$catSlugDe = $alternateSlugs['de'] ?? null;
$catSlugEn = $alternateSlugs['en'] ?? null;
$breadcrumbs = collect([
['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()],
$parentName && $parentTranslation?->slug ? ['name' => $parentName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$parentTranslation->slug] : null,
['name' => $catName, 'item' => $categoryUrl],
['name' => __('web.category_archive.breadcrumb', ['year' => $year]), 'item' => $archiveUrl],
])->filter()->values();
$jsonLd = [
'@context' => 'https://schema.org',
'@graph' => [
[
'@type' => 'CollectionPage',
'name' => __('web.category_archive.title', ['category' => $catName, 'year' => $year]),
'url' => $canonicalUrl,
'inLanguage' => app()->getLocale(),
'isPartOf' => ['@type' => 'WebSite', 'url' => $portalBase.'/'],
],
[
'@type' => 'BreadcrumbList',
'itemListElement' => $breadcrumbs->map(fn (array $crumb, int $index) => [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $crumb['name'],
'item' => $crumb['item'],
])->all(),
],
],
];
@endphp
@section('title', $metaTitle)
@section('meta_description', Str::limit($metaDescription, 155))
@section('canonical', $canonicalUrl)
@if ($catSlugDe && $catSlugEn && $currentPage === 1)
@section('hreflang')
<link rel="alternate" hreflang="de" href="{{ $portalBase }}/de/category/{{ $catSlugDe }}/archiv/{{ $year }}">
<link rel="alternate" hreflang="en" href="{{ $portalBase }}/en/category/{{ $catSlugEn }}/archiv/{{ $year }}">
<link rel="alternate" hreflang="x-default" href="{{ $portalBase }}/de/category/{{ $catSlugDe }}/archiv/{{ $year }}">
@endsection
@endif
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection
@section('content')
<x-web.site-header />
<main class="bg-bg text-ink">
{{-- Breadcrumb --}}
<div class="border-b border-bg-rule">
<div class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-2 text-[11.5px] text-ink-3 flex-wrap">
<a href="{{ route('web.home') }}" class="hover:text-ink transition-colors">{{ __('web.nav.home') }}</a>
<span aria-hidden="true">›</span>
@if ($parentName && $parentTranslation?->slug)
<a href="{{ route('kategorie', ['slug' => $parentTranslation->slug]) }}" class="hover:text-ink transition-colors">{{ $parentName }}</a>
<span aria-hidden="true">›</span>
@endif
<a href="{{ route('kategorie', ['slug' => $catSlug]) }}" class="hover:text-ink transition-colors">{{ $catName }}</a>
<span aria-hidden="true">›</span>
<span class="text-ink">{{ __('web.category_archive.breadcrumb', ['year' => $year]) }}</span>
</div>
</div>
<section class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 pt-8 lg:pt-9">
<div class="flex items-center justify-between gap-4 mb-3">
<div class="eyebrow">{{ __('web.category_archive.eyebrow') }}</div>
<a href="{{ route('kategorie', ['slug' => $catSlug]) }}" class="inline-flex items-center gap-1.5 text-[12px] font-semibold text-ink-3 hover:text-ink transition-colors whitespace-nowrap">
← {{ __('web.category_archive.back_to_category', ['category' => $catName]) }}
</a>
</div>
<h1 class="font-serif text-[32px] sm:text-[40px] font-semibold m-0 leading-[1.1] tracking-[-0.5px] text-ink">
{{ __('web.category_archive.title', ['category' => $catName, 'year' => $year]) }}
</h1>
<p class="text-[14px] leading-[1.6] text-ink-2 mt-3 mb-0 max-w-[720px]">
{{ __('web.category_archive.intro', ['count' => $fmt($years[$year] ?? 0), 'category' => $catName, 'year' => $year, 'total' => $fmt($totalReleases), 'first' => array_key_last($years)]) }}
</p>
{{-- Jahresleiste --}}
<nav class="mt-6" aria-label="{{ __('web.category_archive.nav_label') }}">
<ul class="m-0 p-0 list-none flex flex-wrap gap-1.5">
@foreach ($years as $archiveYear => $archiveCount)
<li>
@if ($archiveYear === $year)
<span class="inline-flex items-baseline gap-1.5 px-2.5 py-1.5 border border-brand bg-brand text-white text-[12.5px] font-mono" aria-current="page">
{{ $archiveYear }} <span class="text-[10.5px] text-white/80">{{ $fmt($archiveCount) }}</span>
</span>
@else
<a href="{{ route('kategorie.archiv', ['slug' => $catSlug, 'year' => $archiveYear]) }}" class="inline-flex items-baseline gap-1.5 px-2.5 py-1.5 border border-bg-rule-strong bg-white text-[12.5px] font-mono text-ink hover:bg-bg-elev transition-colors">
{{ $archiveYear }} <span class="text-[10.5px] text-ink-3">{{ $fmt($archiveCount) }}</span>
</a>
@endif
</li>
@endforeach
</ul>
</nav>
</section>
<section class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 mt-10 mb-16">
<header class="flex items-baseline justify-between mb-4 flex-wrap gap-3">
<h2 class="font-serif text-[22px] font-semibold m-0 tracking-[-0.2px] text-ink">
{{ __('web.category_archive.list_title', ['year' => $year]) }}
@if ($lastPage > 1)
<span class="text-[13px] text-ink-3 font-normal ml-1.5">· {{ __('web.category_archive.page_of', ['page' => $currentPage, 'last' => $lastPage]) }}</span>
@endif
</h2>
<span class="text-[11.5px] text-ink-3">{{ __('web.category_archive.sorted') }}</span>
</header>
<hr class="rule-strong">
<ol class="m-0 p-0 list-none">
@foreach ($releases as $release)
@php
$rowCategory = $release->localizedCategoryName();
$isRecommended = $release->content_score !== null && $release->content_score >= 80;
@endphp
<li>
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}" class="grid items-start gap-x-4 py-3.5 border-b border-bg-rule hover:bg-bg-elev transition-colors group pl-1.5 pr-2 sm:pl-2 grid-cols-[minmax(0,1fr)] md:grid-cols-[6.5rem_minmax(0,1fr)]">
<time datetime="{{ $release->published_at?->toIso8601String() }}" class="font-mono text-[11.5px] leading-snug text-ink-3 pt-0.5 tabular-nums">
{{ $release->published_at?->translatedFormat('d.m.Y') }}
</time>
<div class="min-w-0">
<div class="font-serif text-[16px] sm:text-[17px] leading-[1.3] text-ink group-hover:text-brand transition-colors">
{{ $release->title }}
</div>
<div class="mt-1 text-[11.5px] text-ink-3 flex flex-wrap items-center gap-x-2">
@if ($release->company?->name)
<span class="truncate max-w-[24rem]">{{ $release->company->name }}</span>
@endif
@if ($rowCategory && $rowCategory !== $catName)
<span aria-hidden="true">·</span>
<span>{{ $rowCategory }}</span>
@endif
@if ($isRecommended)
<span aria-hidden="true">·</span>
<span class="text-brand font-semibold">{{ __('web.detail.recommended') }}</span>
@endif
</div>
</div>
</a>
</li>
@endforeach
</ol>
<x-web.pagination :paginator="$releases" />
</section>
</main>
<x-web.site-footer />
@endsection

View file

@ -61,7 +61,7 @@
];
@endphp
@section('title', __('web.category.meta_title', ['category' => $catName, 'brand' => config('app.name')]))
@section('title', __('web.category.meta_title', ['category' => $catName, 'brand' => $brandLabel]))
@section('meta_description', Str::limit(strip_tags($catDescription), 155))
@if ($catSlugDe && $catSlugEn)
@ -264,6 +264,25 @@
<span class="text-[11.5px] text-ink-3">{{ __('web.category.week_stat', ['count' => $fmt($categoryStats['week'] ?? 0)]) }}</span>
<a href="{{ route('suche') }}" class="px-3.5 py-2.5 text-[13px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">{{ __('web.category.search_archive') }} →</a>
</div>
{{-- Jahresarchiv (2.3): der Bestand als stabile, verlinkte Seiten. --}}
@if (! empty($archiveYears ?? []) && $catSlug)
<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>
</div>
<ul class="m-0 p-0 list-none flex flex-wrap gap-1.5">
@foreach ($archiveYears as $archiveYear => $archiveCount)
<li>
<a href="{{ route('kategorie.archiv', ['slug' => $catSlug, 'year' => $archiveYear]) }}" class="inline-flex items-baseline gap-1.5 px-2.5 py-1.5 border border-bg-rule-strong bg-white text-[12.5px] font-mono text-ink hover:bg-bg-elev transition-colors">
{{ $archiveYear }} <span class="text-[10.5px] text-ink-3">{{ $fmt($archiveCount) }}</span>
</a>
</li>
@endforeach
</ul>
</nav>
@endif
</div>
<aside class="flex flex-col gap-9">

View file

@ -2,6 +2,7 @@
use App\Enums\Portal;
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\HomeController;
@ -220,6 +221,11 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
// Kategorie / Branchenseite (z.B. /de/category/energie-klima)
Route::get('category/{slug}', CategoryController::class)->name('kategorie');
// Jahresarchiv einer Branche (Roadmap 2.3): /de/category/handel/archiv/2019
Route::get('category/{slug}/archiv/{year?}', CategoryArchiveController::class)
->where('year', '[0-9]{4}')
->name('kategorie.archiv');
// Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution)
Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail');

View file

@ -0,0 +1,111 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Web\CategoryArchivePageData;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* Jahresarchiv einer Branche (Roadmap 2.3): /de/category/{slug}/archiv/{jahr}.
*/
function archiveCategory(): Category
{
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$category->translations()->create(['locale' => 'en', 'name' => 'Commerce', 'slug' => 'commerce']);
return $category;
}
function archiveRelease(Category $category, string $slug, string $publishedAt, array $attributes = []): PressRelease
{
return PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for(Company::factory()->presseecho()->create(['name' => 'Archiv GmbH']))
->create(['slug' => $slug, 'published_at' => $publishedAt, ...$attributes]);
}
test('the archive lists the releases of one year, newest first, with a year navigation', function () {
/** @var TestCase $this */
$category = archiveCategory();
archiveRelease($category, 'alt-2019', '2019-03-01 10:00:00', ['title' => 'Meldung aus 2019']);
archiveRelease($category, 'alt-2019-spaeter', '2019-11-01 10:00:00', ['title' => 'Spätere Meldung 2019']);
archiveRelease($category, 'alt-2021', '2021-05-01 10:00:00', ['title' => 'Meldung aus 2021']);
$response = $this->get('https://presseecho.test/de/category/handel/archiv/2019')->assertSuccessful();
$response
->assertSee('Pressemitteilungen Handel 2019')
->assertSee('Spätere Meldung 2019')
->assertSee('Meldung aus 2019')
->assertSee('Archiv GmbH')
->assertSee('/de/category/handel/archiv/2021', false)
->assertSee('<link rel="canonical" href="https://presseecho.test/de/category/handel/archiv/2019">', false)
->assertSee('"@type":"CollectionPage"', false)
->assertSee('hreflang="en" href="https://presseecho.test/en/category/commerce/archiv/2019"', false);
// Nur die Liste prüfen — der Live-Ticker im Kopf zeigt die jüngste Meldung (2021).
$list = Str::between((string) $response->getContent(), '<ol class="m-0 p-0 list-none">', '</ol>');
expect($list)->toContain('Spätere Meldung 2019');
$this->assertStringNotContainsString('Meldung aus 2021', $list);
expect(strpos($list, 'Spätere Meldung 2019'))->toBeLessThan((int) strpos($list, 'Meldung aus 2019'));
});
test('without a year the archive opens the latest year with releases', function () {
/** @var TestCase $this */
$category = archiveCategory();
archiveRelease($category, 'alt-2019', '2019-03-01 10:00:00');
archiveRelease($category, 'alt-2021', '2021-05-01 10:00:00', ['title' => 'Meldung aus 2021']);
$this->get('https://presseecho.test/de/category/handel/archiv')
->assertSuccessful()
->assertSee('Pressemitteilungen Handel 2021')
->assertSee('Meldung aus 2021');
});
test('years without releases, unknown categories and empty pages are 404', function () {
/** @var TestCase $this */
$category = archiveCategory();
archiveRelease($category, 'alt-2019', '2019-03-01 10:00:00');
$this->get('https://presseecho.test/de/category/handel/archiv/2018')->assertNotFound();
$this->get('https://presseecho.test/de/category/gibt-es-nicht/archiv/2019')->assertNotFound();
$this->get('https://presseecho.test/de/category/handel/archiv/2019?page=2')->assertNotFound();
});
test('follow-up pages carry the page in canonical and title', function () {
/** @var TestCase $this */
$category = archiveCategory();
foreach (range(1, CategoryArchivePageData::PerPage + 1) as $i) {
archiveRelease($category, 'meldung-'.$i, sprintf('2020-01-%02d 10:00:00', min($i, 28)), ['title' => 'Meldung Nr. '.$i]);
}
$this->get('https://presseecho.test/de/category/handel/archiv/2020?page=2')
->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/category/handel/archiv/2020?page=2">', false)
->assertSee('Seite 2')
->assertDontSee('hreflang=', false);
});
test('the category page links its archive years and the sitemap lists them', function () {
/** @var TestCase $this */
$category = archiveCategory();
archiveRelease($category, 'alt-2019', '2019-03-01 10:00:00');
archiveRelease($category, 'neu', now()->subDay()->toDateTimeString());
$this->get('https://presseecho.test/de/category/handel')
->assertSuccessful()
->assertSee('Archiv Handel')
->assertSee('/de/category/handel/archiv/2019', false)
->assertSee('/de/category/handel/archiv/'.now()->year, false);
$this->get('https://presseecho.test/sitemap.xml')
->assertSuccessful()
->assertSee('<loc>https://presseecho.test/de/category/handel/archiv/2019</loc>', false);
});

View file

@ -0,0 +1,44 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use Tests\TestCase;
/**
* `categories:seed-descriptions` (Roadmap 2.3): füllt nur leere Felder,
* --force überschreibt, Dry-Run schreibt nichts.
*/
test('the command fills empty descriptions from the data file and keeps existing ones', function () {
/** @var TestCase $this */
$handel = Category::factory()->create(['portal' => Portal::Both, 'is_active' => true]);
$handel->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel', 'description' => null]);
$handel->translations()->create(['locale' => 'en', 'name' => 'Commerce', 'slug' => 'commerce', 'description' => 'Own text']);
$this->artisan('categories:seed-descriptions', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] Nichts geschrieben.')
->assertSuccessful();
expect($handel->translations()->where('locale', 'de')->value('description'))->toBeNull();
$this->artisan('categories:seed-descriptions')->assertSuccessful();
expect($handel->translations()->where('locale', 'de')->value('description'))->toStartWith('Pressemitteilungen aus dem Handel')
->and($handel->translations()->where('locale', 'en')->value('description'))->toBe('Own text');
$this->artisan('categories:seed-descriptions', ['--force' => true])->assertSuccessful();
expect($handel->translations()->where('locale', 'en')->value('description'))->toStartWith('Press releases from retail');
});
test('every data file entry has a german and an english text within the admin limit', function () {
/** @var array<string, array<string, string>> $texts */
$texts = require database_path('seeders/data/category-descriptions.php');
expect($texts)->toHaveCount(14);
foreach ($texts as $slug => $byLocale) {
expect($byLocale)->toHaveKeys(['de', 'en']);
expect(mb_strlen($byLocale['de']))->toBeLessThanOrEqual(1000);
expect(mb_strlen($byLocale['en']))->toBeLessThanOrEqual(1000);
}
});