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
8cd76df0bb
commit
b60a28dc9c
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Support/StructuredData.php
Normal file
53
app/Support/StructuredData.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ class WebCaches
|
||||||
|
|
||||||
if ($host) {
|
if ($host) {
|
||||||
Cache::forget('web.sitemap.'.$host);
|
Cache::forget('web.sitemap.'.$host);
|
||||||
|
Cache::forget('web.sitemap-news.'.$host);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,13 +184,13 @@ Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist
|
||||||
|
|
||||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 2.1 | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms | M | 1b.1 |
|
| ~~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 | 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 5) | M | 2.1 |
|
||||||
| 2.3 | Branchen- und Kategorie-Landingpages aus dem Archivbestand | M | keine |
|
| 2.3 | Branchen- und Kategorie-Landingpages aus dem Archivbestand | M | keine |
|
||||||
| 2.4 | Transparenzseiten: Preisvergleich, ohne Abo veröffentlichen, ohne Mindestlaufzeit | S | 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.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 |
|
| 2.6 | Outreach an Betreiber von Presseportal-Listen und Vergleichsartikeln | M | 2.4 |
|
||||||
| 2.7 | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` | M | 2.1 |
|
| ~~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.
|
**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.
|
||||||
|
|
||||||
|
|
@ -235,7 +235,7 @@ 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 |
|
| ~~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 |
|
| ~~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 |
|
| ~~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 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 |
|
| E5 | 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 |
|
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1537,3 +1537,96 @@ Die 1.302 davon mit Meldungen haben nur `rejected` (bis 2019) und
|
||||||
`archived` (bis 2022), nichts Öffentliches. Die 44 Besitzerkonten bleiben
|
`archived` (bis 2022), nichts Öffentliches. Die 44 Besitzerkonten bleiben
|
||||||
unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391.
|
unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391.
|
||||||
Test: `RetireLinkSpamCompaniesTest`.
|
Test: `RetireLinkSpamCompaniesTest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil 5 — 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,
|
||||||
|
2.4, 2.6) folgen, 2.5 hängt an E5.
|
||||||
|
|
||||||
|
### 2.1 — NewsArticle und Organization (17.09.2026)
|
||||||
|
|
||||||
|
**Stand vorher:** Das Grundgerüst gab es seit Juli (Commit 379688f):
|
||||||
|
NewsArticle + BreadcrumbList auf jeder öffentlichen Meldung, Organization
|
||||||
|
auf dem Newsroom, WebSite + Organization auf den Portal-Startseiten,
|
||||||
|
BreadcrumbList auf Kategorien; Ausgabe über `x-web.structured-data`
|
||||||
|
(JSON_HEX_TAG gegen `</script>`-Ausbruch). Die Vorschau bleibt ohne Markup.
|
||||||
|
|
||||||
|
**Was fehlte:** Die Entitäten hingen nicht zusammen. `NewsArticle.author`
|
||||||
|
und die Organization der Newsroom-Seite waren zwei namensgleiche Objekte
|
||||||
|
ohne gemeinsame `@id`; der Publisher hatte kein Logo (Google empfiehlt es
|
||||||
|
für Article-Rich-Results) und ebenfalls keine `@id`.
|
||||||
|
|
||||||
|
**Umsetzung:** `App\Support\StructuredData` bündelt die Entitäten:
|
||||||
|
- `publisher($portalBase)` — Organization des Portals mit
|
||||||
|
`@id = {portal}/#organization`, Name aus `config/domains.php`, Logo als
|
||||||
|
ImageObject (`/images/presseecho-logo.svg` bzw.
|
||||||
|
`businessportal-logo.svg`, 600×200). Verwendet auf Meldung (publisher)
|
||||||
|
und Startseite.
|
||||||
|
- `newsroomUrl()` / `newsroomId()` — `@id = {newsroom-url}#organization`.
|
||||||
|
Die Newsroom-Seite trägt sie, `NewsArticle.author` verweist darauf.
|
||||||
|
- Bewusst nicht ins Organization-Markup: Adresse, E-Mail, Telefon. Die
|
||||||
|
Newsroom-Seite zeigt sie nicht, und Markup ohne sichtbares Gegenstück
|
||||||
|
ist gegen Googles Richtlinien.
|
||||||
|
|
||||||
|
Tests: `StructuredDataTest` erweitert (Publisher-`@id`, Logo,
|
||||||
|
Author-`@id`, Newsroom-`@id`).
|
||||||
|
|
||||||
|
### 2.2 — Google News, technischer Teil (17.09.2026)
|
||||||
|
|
||||||
|
**Umsetzung:** News-Sitemap je Spoke: `/sitemap-news.xml`
|
||||||
|
(`NewsSitemapController`) — nur im Archiv sichtbare Meldungen der letzten
|
||||||
|
48 Stunden, höchstens 1.000, `news:publication` mit Portalname und
|
||||||
|
Sprache (`language` der Meldung, Fallback `de`), `news:publication_date`,
|
||||||
|
`news:title`. 15 Minuten gecacht je Host, wird mit den anderen Sitemaps
|
||||||
|
über `WebCaches::forgetSitemaps()` geleert. `robots.txt` der Spokes nennt
|
||||||
|
sie; der Hub liefert eine leere Liste und nennt sie nicht.
|
||||||
|
|
||||||
|
**Was jetzt beim Betreiber liegt (nicht im Code):**
|
||||||
|
1. Search Console: beide Spokes als Property bestätigt haben.
|
||||||
|
2. Publisher Center (publishercenter.google.com): je Portal eine
|
||||||
|
Publikation anlegen — Name exakt wie in `news:name` („presseecho",
|
||||||
|
„businessportal24"), Sprache Deutsch, Standort DE, Website-URL des
|
||||||
|
Portals; Logo (quadratisch und rechteckig) hochladen.
|
||||||
|
3. In der Publikation den Bereich „Google News" einrichten: Abschnitte
|
||||||
|
(z. B. die Kategorien) als Feed oder Web-Bereich anlegen; die
|
||||||
|
News-Sitemap wird nicht dort, sondern in der Search Console
|
||||||
|
eingereicht (Sitemaps → `/sitemap-news.xml`).
|
||||||
|
4. Erwartung ehrlich halten: Google News nimmt Presseportale nicht
|
||||||
|
verlässlich auf („Pressemitteilungen" sind laut Inhaltsrichtlinien
|
||||||
|
zulässig, aber kein bevorzugter Inhalt). Die Sitemap ist die
|
||||||
|
Voraussetzung, keine Zusage. Ergebnis nach 4–6 Wochen in der Search
|
||||||
|
Console unter „Leistung → Google News" ablesen.
|
||||||
|
|
||||||
|
Tests: `NewsSitemapTest` (3).
|
||||||
|
|
||||||
|
### 2.7 — Auffindbarkeit für KI-Systeme (17.09.2026)
|
||||||
|
|
||||||
|
**Umsetzung:** `/llms.txt` je Host (`LlmsTxtController`, 1 h gecacht):
|
||||||
|
Titel, Kurzbeschreibung (Tagline aus `config/domains.php`), sachlicher
|
||||||
|
Absatz zu Urheberschaft (Unternehmen = Urheber, Portal = Herausgeber) und
|
||||||
|
zu den strukturierten Daten, URL-Aufbau (Meldung, Newsroom, Kategorie,
|
||||||
|
Sitemaps), aktive Kategorien mit Links, Seiten für Unternehmen,
|
||||||
|
Rechtliches. Der Hub verweist auf beide Portale und die OpenAPI-Datei.
|
||||||
|
Formulierungsgrenze eingehalten: keine Aussage darüber, ob oder wie
|
||||||
|
KI-Systeme die Inhalte verwenden. Die Entitätszuordnung aus 2.1 (`@id`
|
||||||
|
zwischen Meldung und Newsroom) ist der zweite Teil von 2.7.
|
||||||
|
|
||||||
|
Tests: `LlmsTxtTest` (2).
|
||||||
|
|
||||||
|
### Befund zu 2.5 / E5
|
||||||
|
|
||||||
|
Die alte Ratgeber-Strecke `/de/presse/*.html` leitet seit dem Rückbau
|
||||||
|
(R3.2) per 301 auf `/de/faq`. Es gibt also keine Ratgeberseiten mehr, die
|
||||||
|
zu konsolidieren wären — die Frage E5 reduziert sich darauf, ob die
|
||||||
|
Alt-URLs zum Schreiben von Pressemitteilungen künftig auf die Hilfeseite
|
||||||
|
`/de/gute-pressemitteilung` (1d.1) statt auf die FAQ zeigen sollen.
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
@extends('web.layouts.web-master')
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
use App\Support\StructuredData;
|
||||||
|
|
||||||
$brand = config('domains.domains.businessportal24.brand', []);
|
$brand = config('domains.domains.businessportal24.brand', []);
|
||||||
|
|
||||||
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
|
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
|
||||||
$portalBase = rtrim($domainUrl ?? config('app.url'), '/');
|
$portalBase = StructuredData::portalBase($domainUrl ?? null);
|
||||||
$brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24');
|
$brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24');
|
||||||
|
|
||||||
$jsonLd = [
|
$jsonLd = [
|
||||||
|
|
@ -24,11 +26,7 @@
|
||||||
'query-input' => 'required name=search_term_string',
|
'query-input' => 'required name=search_term_string',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
StructuredData::publisher($portalBase),
|
||||||
'@type' => 'Organization',
|
|
||||||
'name' => $brandFullName,
|
|
||||||
'url' => $portalBase.'/',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
@extends('web.layouts.web-master')
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
use App\Support\StructuredData;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
\Carbon\Carbon::setLocale(app()->getLocale());
|
\Carbon\Carbon::setLocale(app()->getLocale());
|
||||||
|
|
@ -34,10 +35,11 @@
|
||||||
// SEO: Der Newsroom ist die kanonische Firmenseite im Portal —
|
// SEO: Der Newsroom ist die kanonische Firmenseite im Portal —
|
||||||
// Organization-Markup verknüpft PMs (author) mit diesem Profil.
|
// Organization-Markup verknüpft PMs (author) mit diesem Profil.
|
||||||
// Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl).
|
// Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl).
|
||||||
$newsroomUrl = rtrim($domainUrl ?? config('app.url'), '/').'/'.app()->getLocale().'/newsroom/'.$company->slug;
|
$newsroomUrl = StructuredData::newsroomUrl(StructuredData::portalBase($domainUrl ?? null), $company->slug);
|
||||||
$jsonLd = [
|
$jsonLd = [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'Organization',
|
'@type' => 'Organization',
|
||||||
|
'@id' => StructuredData::newsroomId($newsroomUrl),
|
||||||
'name' => $company->name,
|
'name' => $company->name,
|
||||||
'url' => $newsroomUrl,
|
'url' => $newsroomUrl,
|
||||||
...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []),
|
...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
@extends('web.layouts.web-master')
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
use App\Support\StructuredData;
|
||||||
|
|
||||||
$brand = config('domains.domains.presseecho.brand', []);
|
$brand = config('domains.domains.presseecho.brand', []);
|
||||||
|
|
||||||
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
|
// SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
|
||||||
$portalBase = rtrim($domainUrl ?? config('app.url'), '/');
|
$portalBase = StructuredData::portalBase($domainUrl ?? null);
|
||||||
$brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo');
|
$brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo');
|
||||||
|
|
||||||
$jsonLd = [
|
$jsonLd = [
|
||||||
|
|
@ -24,11 +26,7 @@
|
||||||
'query-input' => 'required name=search_term_string',
|
'query-input' => 'required name=search_term_string',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
StructuredData::publisher($portalBase),
|
||||||
'@type' => 'Organization',
|
|
||||||
'name' => $brandFullName,
|
|
||||||
'url' => $portalBase.'/',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
@extends('web.layouts.web-master')
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
use App\Support\StructuredData;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
// Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches
|
// Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches
|
||||||
|
|
@ -93,7 +94,8 @@
|
||||||
$jsonLd = null;
|
$jsonLd = null;
|
||||||
|
|
||||||
if (! $isPreview) {
|
if (! $isPreview) {
|
||||||
$portalBase = rtrim($domainUrl ?? config('app.url'), '/');
|
$portalBase = StructuredData::portalBase($domainUrl ?? null);
|
||||||
|
$authorNewsroomUrl = $company?->slug ? StructuredData::newsroomUrl($portalBase, $company->slug) : null;
|
||||||
|
|
||||||
// dateModified darf laut Google nicht vor datePublished liegen
|
// dateModified darf laut Google nicht vor datePublished liegen
|
||||||
// (updated_at kann bei terminierten PMs älter sein als published_at).
|
// (updated_at kann bei terminierten PMs älter sein als published_at).
|
||||||
|
|
@ -125,12 +127,15 @@
|
||||||
'wordCount' => $wordCount ?: null,
|
'wordCount' => $wordCount ?: null,
|
||||||
'inLanguage' => app()->getLocale(),
|
'inLanguage' => app()->getLocale(),
|
||||||
'isAccessibleForFree' => true,
|
'isAccessibleForFree' => true,
|
||||||
|
// author trägt die @id der Newsroom-Organization (2.1): Meldung
|
||||||
|
// und Newsroom-Seite beschreiben dieselbe Entität.
|
||||||
'author' => $companyName ? array_filter([
|
'author' => $companyName ? array_filter([
|
||||||
'@type' => 'Organization',
|
'@type' => 'Organization',
|
||||||
|
'@id' => $authorNewsroomUrl ? StructuredData::newsroomId($authorNewsroomUrl) : null,
|
||||||
'name' => $companyName,
|
'name' => $companyName,
|
||||||
'url' => $company?->slug ? $portalBase.'/'.app()->getLocale().'/newsroom/'.$company->slug : null,
|
'url' => $authorNewsroomUrl,
|
||||||
]) : null,
|
]) : null,
|
||||||
'publisher' => ['@type' => 'Organization', 'name' => $brandLabel, 'url' => $portalBase],
|
'publisher' => StructuredData::publisher($portalBase),
|
||||||
], fn ($value) => $value !== null),
|
], fn ($value) => $value !== null),
|
||||||
[
|
[
|
||||||
'@type' => 'BreadcrumbList',
|
'@type' => 'BreadcrumbList',
|
||||||
|
|
|
||||||
16
resources/views/web/sitemap-news.blade.php
Normal file
16
resources/views/web/sitemap-news.blade.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
|
||||||
|
@foreach ($entries as $entry)
|
||||||
|
<url>
|
||||||
|
<loc>{{ $entry['loc'] }}</loc>
|
||||||
|
<news:news>
|
||||||
|
<news:publication>
|
||||||
|
<news:name>{{ $entry['publication'] }}</news:name>
|
||||||
|
<news:language>{{ $entry['language'] }}</news:language>
|
||||||
|
</news:publication>
|
||||||
|
<news:publication_date>{{ $entry['published_at'] }}</news:publication_date>
|
||||||
|
<news:title>{{ $entry['title'] }}</news:title>
|
||||||
|
</news:news>
|
||||||
|
</url>
|
||||||
|
@endforeach
|
||||||
|
</urlset>
|
||||||
|
|
@ -6,9 +6,11 @@ use App\Http\Controllers\Web\CategoryController;
|
||||||
use App\Http\Controllers\Web\ContactController;
|
use App\Http\Controllers\Web\ContactController;
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use App\Http\Controllers\Web\LegacyMediaRedirectController;
|
use App\Http\Controllers\Web\LegacyMediaRedirectController;
|
||||||
|
use App\Http\Controllers\Web\LlmsTxtController;
|
||||||
use App\Http\Controllers\Web\NewsroomClaimController;
|
use App\Http\Controllers\Web\NewsroomClaimController;
|
||||||
use App\Http\Controllers\Web\NewsroomDetailController;
|
use App\Http\Controllers\Web\NewsroomDetailController;
|
||||||
use App\Http\Controllers\Web\NewsroomsController;
|
use App\Http\Controllers\Web\NewsroomsController;
|
||||||
|
use App\Http\Controllers\Web\NewsSitemapController;
|
||||||
use App\Http\Controllers\Web\PreiseController;
|
use App\Http\Controllers\Web\PreiseController;
|
||||||
use App\Http\Controllers\Web\PressReleaseAttachmentMediaController;
|
use App\Http\Controllers\Web\PressReleaseAttachmentMediaController;
|
||||||
use App\Http\Controllers\Web\PressReleaseImageMediaController;
|
use App\Http\Controllers\Web\PressReleaseImageMediaController;
|
||||||
|
|
@ -307,9 +309,12 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
|
||||||
// verschattet (Webserver liefert existierende Dateien direkt aus).
|
// verschattet (Webserver liefert existierende Dateien direkt aus).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
||||||
|
Route::get('/sitemap-news.xml', NewsSitemapController::class)->name('sitemap.news');
|
||||||
|
Route::get('/llms.txt', LlmsTxtController::class)->name('llms');
|
||||||
|
|
||||||
Route::get('/robots.txt', function () {
|
Route::get('/robots.txt', function () {
|
||||||
$base = rtrim(request()->getSchemeAndHttpHost(), '/');
|
$base = rtrim(request()->getSchemeAndHttpHost(), '/');
|
||||||
|
$isHub = str_contains(request()->getHost(), 'pressekonto');
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'User-agent: *',
|
'User-agent: *',
|
||||||
|
|
@ -320,6 +325,8 @@ Route::get('/robots.txt', function () {
|
||||||
'Disallow: /en/suche',
|
'Disallow: /en/suche',
|
||||||
'',
|
'',
|
||||||
'Sitemap: '.$base.'/sitemap.xml',
|
'Sitemap: '.$base.'/sitemap.xml',
|
||||||
|
// News-Sitemap nur auf den Spokes — der Hub hat keine Meldungen.
|
||||||
|
...($isHub ? [] : ['Sitemap: '.$base.'/sitemap-news.xml']),
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
45
tests/Feature/Web/LlmsTxtTest.php
Normal file
45
tests/Feature/Web/LlmsTxtTest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\Category;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/llms.txt` je Host (Roadmap 2.7): sachliche Beschreibung, URL-Aufbau,
|
||||||
|
* Kategorien — ohne Werbeaussagen.
|
||||||
|
*/
|
||||||
|
test('a spoke serves llms.txt with its structure, categories and legal pages', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
|
||||||
|
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie']);
|
||||||
|
$inactive = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => false]);
|
||||||
|
$inactive->translations()->create(['locale' => 'de', 'name' => 'Veraltet', 'slug' => 'veraltet']);
|
||||||
|
|
||||||
|
$response = $this->get('https://presseecho.test/llms.txt')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
|
||||||
|
|
||||||
|
$text = (string) $response->getContent();
|
||||||
|
expect($text)
|
||||||
|
->toStartWith("# presseecho\n\n> ")
|
||||||
|
->toContain('https://presseecho.test/de/press-release/{slug}')
|
||||||
|
->toContain('https://presseecho.test/sitemap-news.xml')
|
||||||
|
->toContain('- [Energie](https://presseecho.test/de/category/energie)')
|
||||||
|
->toContain('- [Impressum](https://presseecho.test/de/impressum)')
|
||||||
|
->toContain('NewsArticle');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('veraltet', $text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hub serves llms.txt that points to both portals', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$text = (string) $this->get('https://pressekonto.test/llms.txt')->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
expect($text)
|
||||||
|
->toStartWith("# pressekonto\n\n> ")
|
||||||
|
->toContain('https://presseecho.test/llms.txt')
|
||||||
|
->toContain('https://businessportal24.test/llms.txt')
|
||||||
|
->toContain('https://pressekonto.test/docs/api/v1');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('press-release/{slug}', $text);
|
||||||
|
});
|
||||||
74
tests/Feature/Web/NewsSitemapTest.php
Normal file
74
tests/Feature/Web/NewsSitemapTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Support\WebCaches;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-News-Sitemap je Spoke (Roadmap 2.2): nur Meldungen der letzten
|
||||||
|
* 48 Stunden, mit Publikationsname und Sprache.
|
||||||
|
*/
|
||||||
|
function newsSitemapRelease(Portal $portal, string $slug, array $attributes = []): PressRelease
|
||||||
|
{
|
||||||
|
return PressRelease::factory()
|
||||||
|
->published()
|
||||||
|
->forPortal($portal)
|
||||||
|
->for(Category::factory()->create(['portal' => $portal]))
|
||||||
|
->for(Company::factory()->businessportal24()->create())
|
||||||
|
->create(['slug' => $slug, ...$attributes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the news sitemap lists only releases of the last 48 hours of the own portal', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
newsSitemapRelease(Portal::Businessportal24, 'frisch', ['title' => 'Frische Meldung & mehr', 'published_at' => now()->subHours(3)]);
|
||||||
|
newsSitemapRelease(Portal::Both, 'beide-portale', ['published_at' => now()->subHours(20)]);
|
||||||
|
newsSitemapRelease(Portal::Businessportal24, 'zu-alt', ['published_at' => now()->subDays(3)]);
|
||||||
|
newsSitemapRelease(Portal::Presseecho, 'fremdes-portal', ['published_at' => now()->subHour()]);
|
||||||
|
newsSitemapRelease(Portal::Businessportal24, 'terminiert', ['published_at' => now()->addHour()]);
|
||||||
|
|
||||||
|
$response = $this->get('https://businessportal24.test/sitemap-news.xml')->assertSuccessful();
|
||||||
|
|
||||||
|
expect($response->headers->get('Content-Type'))->toContain('application/xml');
|
||||||
|
|
||||||
|
$xml = (string) $response->getContent();
|
||||||
|
expect($xml)
|
||||||
|
->toContain('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')
|
||||||
|
->toContain('<loc>https://businessportal24.test/de/press-release/frisch</loc>')
|
||||||
|
->toContain('<news:name>businessportal24</news:name>')
|
||||||
|
->toContain('<news:language>de</news:language>')
|
||||||
|
->toContain('<news:title>Frische Meldung & mehr</news:title>')
|
||||||
|
->toContain('<loc>https://businessportal24.test/de/press-release/beide-portale</loc>');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('zu-alt', $xml);
|
||||||
|
$this->assertStringNotContainsString('fremdes-portal', $xml);
|
||||||
|
$this->assertStringNotContainsString('terminiert', $xml);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hub serves an empty news sitemap and does not advertise it in robots.txt', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
newsSitemapRelease(Portal::Presseecho, 'nicht-im-hub', ['published_at' => now()->subHour()]);
|
||||||
|
|
||||||
|
$xml = (string) $this->get('https://pressekonto.test/sitemap-news.xml')->assertSuccessful()->getContent();
|
||||||
|
expect($xml)->toContain('<urlset');
|
||||||
|
$this->assertStringNotContainsString('<url>', $xml);
|
||||||
|
|
||||||
|
$robots = (string) $this->get('https://pressekonto.test/robots.txt')->getContent();
|
||||||
|
$this->assertStringNotContainsString('sitemap-news.xml', $robots);
|
||||||
|
|
||||||
|
$this->get('https://presseecho.test/robots.txt')
|
||||||
|
->assertSee('Sitemap: https://presseecho.test/sitemap-news.xml', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the news sitemap is cached per host and cleared with the other sitemaps', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->get('https://presseecho.test/sitemap-news.xml')->assertSuccessful();
|
||||||
|
expect(Cache::has('web.sitemap-news.presseecho.test'))->toBeTrue();
|
||||||
|
|
||||||
|
WebCaches::forgetSitemaps();
|
||||||
|
|
||||||
|
expect(Cache::has('web.sitemap-news.presseecho.test'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
@ -38,8 +38,10 @@ test('the release detail page emits NewsArticle and BreadcrumbList JSON-LD', fun
|
||||||
->assertSee('"@type":"NewsArticle"', false)
|
->assertSee('"@type":"NewsArticle"', false)
|
||||||
->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false)
|
->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false)
|
||||||
->assertSee('"datePublished"', false)
|
->assertSee('"datePublished"', false)
|
||||||
->assertSee('"publisher":{"@type":"Organization","name":"businessportal24"', false)
|
->assertSee('"publisher":{"@type":"Organization","@id":"https://businessportal24.test/#organization","name":"businessportal24"', false)
|
||||||
->assertSee('"author":{"@type":"Organization","name":"Muster GmbH"', false)
|
->assertSee('"logo":{"@type":"ImageObject","url":"https://businessportal24.test/images/businessportal-logo.svg"', false)
|
||||||
|
->assertSee('"author":{"@type":"Organization","@id":"https://businessportal24.test/de/newsroom/', false)
|
||||||
|
->assertSee('#organization","name":"Muster GmbH"', false)
|
||||||
->assertSee('"@type":"BreadcrumbList"', false)
|
->assertSee('"@type":"BreadcrumbList"', false)
|
||||||
->assertSee('"articleSection":"Technologie"', false);
|
->assertSee('"articleSection":"Technologie"', false);
|
||||||
});
|
});
|
||||||
|
|
@ -77,7 +79,9 @@ test('a portal homepage emits WebSite JSON-LD with a SearchAction', function ()
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('"@type":"WebSite"', false)
|
->assertSee('"@type":"WebSite"', false)
|
||||||
->assertSee('"@type":"SearchAction"', false)
|
->assertSee('"@type":"SearchAction"', false)
|
||||||
->assertSee('https://presseecho.test/de/suche?q={search_term_string}', false);
|
->assertSee('https://presseecho.test/de/suche?q={search_term_string}', false)
|
||||||
|
->assertSee('"@id":"https://presseecho.test/#organization"', false)
|
||||||
|
->assertSee('"url":"https://presseecho.test/images/presseecho-logo.svg"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the newsroom page emits Organization JSON-LD for the company', function () {
|
test('the newsroom page emits Organization JSON-LD for the company', function () {
|
||||||
|
|
@ -100,6 +104,7 @@ test('the newsroom page emits Organization JSON-LD for the company', function ()
|
||||||
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
|
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('"@type":"Organization"', false)
|
->assertSee('"@type":"Organization"', false)
|
||||||
|
->assertSee('"@id":"https://businessportal24.test/de/newsroom/'.$company->slug.'#organization"', false)
|
||||||
->assertSee('"name":"Newsroom AG"', false)
|
->assertSee('"name":"Newsroom AG"', false)
|
||||||
->assertSee('"sameAs":["https://newsroom-ag.example"]', false);
|
->assertSee('"sameAs":["https://newsroom-ag.example"]', false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue