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:
parent
b60a28dc9c
commit
4616bb7aad
15 changed files with 793 additions and 13 deletions
83
app/Console/Commands/SeedCategoryDescriptions.php
Normal file
83
app/Console/Commands/SeedCategoryDescriptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
22
app/Http/Controllers/Web/CategoryArchiveController.php
Normal file
22
app/Http/Controllers/Web/CategoryArchiveController.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
150
app/Services/Web/CategoryArchivePageData.php
Normal file
150
app/Services/Web/CategoryArchivePageData.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue