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:
parent
713024be37
commit
b74f881ac8
15 changed files with 521 additions and 23 deletions
123
app/Http/Controllers/Web/LlmsTxtController.php
Normal file
123
app/Http/Controllers/Web/LlmsTxtController.php
Normal 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)',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Web/NewsSitemapController.php
Normal file
78
app/Http/Controllers/Web/NewsSitemapController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue