web: Entitaeten im JSON-LD verknuepft, News-Sitemap und llms.txt (Roadmap 2.1, 2.2, 2.7)

- App\Support\StructuredData: Portal-Organization als Publisher mit @id
  und Logo (ImageObject), Newsroom-@id; NewsArticle.author auf der
  Meldung und Organization auf der Newsroom-Seite beschreiben jetzt
  dieselbe Entitaet. Startseiten nutzen denselben Publisher.
- /sitemap-news.xml je Spoke (NewsSitemapController): im Archiv
  sichtbare Meldungen der letzten 48 h, max. 1.000, news:publication
  mit Portalname und Sprache; 15 min gecacht, mit den anderen Sitemaps
  geleert; in robots.txt der Spokes. Hub liefert leere Liste.
- /llms.txt je Host (LlmsTxtController): sachliche Beschreibung,
  URL-Aufbau, aktive Kategorien, Seiten fuer Unternehmen, Rechtliches;
  Hub verweist auf beide Portale. Keine Aussagen ueber KI-Nennungen.
- Protokoll Teil 5 (Block 2) inkl. Betreiber-Schritten fuer das
  Publisher Center und Befund zu 2.5/E5; Roadmap 2.1/2.7 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:15:28 +00:00
parent 8cd76df0bb
commit b60a28dc9c
15 changed files with 521 additions and 23 deletions

View file

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* `/llms.txt` (Roadmap 2.7): eine kurze, sachliche Beschreibung des Hosts
* für KI-Systeme — was die Seite ist, wie die URLs aufgebaut sind, wo die
* strukturierten Daten und Sitemaps liegen. Keine Werbeaussagen, keine
* Versprechen über Nennungen in KI-Antworten (Formulierungsgrenze 2.7).
* Hub und Spokes bekommen je eine eigene Fassung; pro Host eine Stunde
* gecacht.
*/
class LlmsTxtController extends Controller
{
public function __invoke(Request $request): Response
{
$host = $request->getHost();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$isHub = str_contains($host, 'pressekonto');
$text = Cache::remember('web.llms.'.$host, 3600, fn (): string => $isHub ? $this->hub($base) : $this->portal($base));
return response($text, 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
private function hub(string $base): string
{
$presseecho = rtrim((string) config('domains.domains.presseecho.url'), '/');
$businessportal = rtrim((string) config('domains.domains.businessportal24.url'), '/');
return implode("\n", [
'# pressekonto',
'',
'> Publisher-Bereich der Presseportale presseecho und businessportal24: Unternehmen schreiben hier Pressemitteilungen, lassen sie automatisiert prüfen und veröffentlichen sie auf beiden Portalen.',
'',
'Die Meldungen selbst erscheinen nicht auf dieser Domain, sondern auf den beiden Portalen. Jede Meldung trägt dort NewsArticle-Markup (schema.org), jeder Newsroom Organization-Markup; Urheber einer Meldung ist immer das genannte Unternehmen, nicht das Portal.',
'',
'## Informationen',
'',
'- [Hilfe]('.$base.'/de/hilfe): Ablauf von der Registrierung bis zur Veröffentlichung',
'- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung): Anlass, Aufbau, Sprache, Prüfablauf',
'- [FAQ]('.$base.'/de/faq)',
'- [Richtlinien]('.$base.'/de/richtlinien): Was veröffentlicht wird und was nicht',
'- [API]('.$base.'/de/api): Einlieferung per REST-API; OpenAPI-Spezifikation unter '.$base.'/docs/api/v1',
'',
'## Portale',
'',
'- [presseecho]('.$presseecho.'/llms.txt): Pressemitteilungen aus Wirtschaft und Industrie',
'- [businessportal24]('.$businessportal.'/llms.txt): Pressemitteilungen aus Deutschland, Österreich und der Schweiz',
'',
'## Rechtliches',
'',
'- [Impressum]('.$base.'/de/impressum)',
'- [Datenschutz]('.$base.'/de/datenschutz)',
'- [AGB]('.$base.'/de/agb)',
'- [Inhalte melden]('.$base.'/de/melden)',
'',
]);
}
private function portal(string $base): string
{
$context = WebPortalContext::fromRequest();
$brand = (array) ($context->domainConfig['brand'] ?? []);
$name = ($brand['name'] ?? '').($brand['accent'] ?? '');
$hub = rtrim((string) config('domains.domains.pressekonto.url'), '/');
$categories = Category::query()
->where('is_active', true)
->whereIn('portal', $context->portalValues)
->with('translations')
->get()
->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de'))
->filter()
->sortBy('name')
->map(fn ($translation) => '- ['.$translation->name.']('.$base.'/de/category/'.$translation->slug.')')
->values()
->all();
return implode("\n", [
'# '.$name,
'',
'> '.($brand['tagline_long'] ?? 'Presseportal für Pressemitteilungen.'),
'',
'Pressemitteilungen werden von Unternehmen eingereicht und vor der Veröffentlichung geprüft. Urheber einer Meldung ist das im Kopf genannte Unternehmen; das Portal ist Herausgeber. Meldungen tragen NewsArticle-Markup (schema.org) mit dem Newsroom des Unternehmens als author, Newsrooms tragen Organization-Markup.',
'',
'## Aufbau',
'',
'- Meldung: '.$base.'/de/press-release/{slug}',
'- Newsroom eines Unternehmens: '.$base.'/de/newsroom/{slug} — Übersicht unter '.$base.'/de/newsrooms',
'- Kategorie: '.$base.'/de/category/{slug}',
'- Sitemap: '.$base.'/sitemap.xml — News-Sitemap (Meldungen der letzten 48 Stunden): '.$base.'/sitemap-news.xml',
'',
'## Kategorien',
'',
...$categories,
'',
'## Für Unternehmen',
'',
'- [Veröffentlichen]('.$base.'/de/veroeffentlichen): Wie eine Meldung auf das Portal kommt',
'- [Preise]('.$base.'/de/preise)',
'- [Was eine gute Pressemitteilung ausmacht]('.$base.'/de/gute-pressemitteilung)',
'- [Hilfe]('.$base.'/de/hilfe)',
'- [API]('.$base.'/de/api): Einlieferung per REST-API über den Publisher-Bereich '.$hub,
'',
'## Rechtliches',
'',
'- [Impressum]('.$base.'/de/impressum)',
'- [Datenschutz]('.$base.'/de/datenschutz)',
'- [AGB]('.$base.'/de/agb)',
'- [Richtlinien]('.$base.'/de/richtlinien)',
'- [Inhalte melden]('.$base.'/de/melden)',
'',
]);
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Middleware\SetEdition;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StructuredData;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Google-News-Sitemap je Spoke (Roadmap 2.2): nur Meldungen der letzten
* 48 Stunden, höchstens 1.000, mit Publikationsname und Sprache — so wie
* Google News sie verlangt. Der Hub (pressekonto) hat keine Meldungen und
* liefert eine leere Liste. Kurz gecacht (15 min), damit neue Meldungen
* schnell erscheinen; Entfernungen leeren den Cache über WebCaches.
*/
class NewsSitemapController extends Controller
{
public const CACHE_TTL_SECONDS = 900;
public const WINDOW_HOURS = 48;
public const MAX_URLS = 1000;
public function __invoke(Request $request): Response
{
$host = $request->getHost();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$isHub = str_contains($host, 'pressekonto');
$xml = Cache::remember('web.sitemap-news.'.$host, self::CACHE_TTL_SECONDS, function () use ($base, $isHub): string {
$entries = $isHub ? [] : $this->entries($base);
return view('web.sitemap-news', ['entries' => $entries])->render();
});
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* @return list<array{loc: string, publication: string, language: string, published_at: string, title: string}>
*/
private function entries(string $base): array
{
$context = WebPortalContext::fromRequest();
$publication = (string) StructuredData::publisher($base)['name'];
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->visibleInArchive()
->forPortalValues($context->portalValues)
->where('published_at', '>=', now()->subHours(self::WINDOW_HOURS))
->orderByDesc('published_at')
->limit(self::MAX_URLS)
->get(['slug', 'title', 'language', 'published_at']);
$entries = [];
foreach ($releases as $release) {
$edition = in_array($release->language, SetEdition::EDITIONS, true) ? $release->language : SetEdition::DEFAULT_EDITION;
$entries[] = [
'loc' => $base.'/'.$edition.'/press-release/'.$release->slug,
'publication' => $publication,
'language' => $edition,
'published_at' => (string) $release->published_at?->toAtomString(),
'title' => (string) $release->title,
];
}
return $entries;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Support;
/**
* Gemeinsame Entitäten für das JSON-LD der Web-Seiten (Roadmap 2.1):
* das Portal als Publisher mit stabiler `@id` und Logo, der Newsroom als
* Organization mit stabiler `@id`. NewsArticle.author auf der Meldung und
* die Organization auf der Newsroom-Seite zeigen damit auf dieselbe Entität,
* statt zwei namensgleiche Objekte zu sein.
*/
class StructuredData
{
public static function portalBase(?string $domainUrl): string
{
return rtrim($domainUrl ?? (string) config('app.url'), '/');
}
/**
* Portal-Organisation (Publisher der Meldungen) — Name aus der
* Domain-Konfiguration, Logo als ImageObject (600×200-SVG unter /images).
*
* @return array<string, mixed>
*/
public static function publisher(string $portalBase): array
{
$isPresseecho = config('app.theme') === 'presseecho';
$brand = (array) config('domains.domains.'.($isPresseecho ? 'presseecho' : 'businessportal24').'.brand', []);
return [
'@type' => 'Organization',
'@id' => $portalBase.'/#organization',
'name' => ($brand['name'] ?? ($isPresseecho ? 'presse' : 'businessportal')).($brand['accent'] ?? ($isPresseecho ? 'echo' : '24')),
'url' => $portalBase.'/',
'logo' => [
'@type' => 'ImageObject',
'url' => $portalBase.'/images/'.($isPresseecho ? 'presseecho-logo.svg' : 'businessportal-logo.svg'),
'width' => 600,
'height' => 200,
],
];
}
public static function newsroomUrl(string $portalBase, string $slug): string
{
return $portalBase.'/'.app()->getLocale().'/newsroom/'.$slug;
}
public static function newsroomId(string $newsroomUrl): string
{
return $newsroomUrl.'#organization';
}
}

View file

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