News: redaktionell eingefuegtes FAQPage-JSON als echtes Schema ausgeben

17 SEO-Artikel tragen ein rohes FAQPage-JSON-Objekt in einem
abschliessenden Absatz ihres Bodytexts - das Frontend renderte es als
sichtbaren Text, Google sah es nie als strukturierte Daten.

Neue Klasse Seo\EmbeddedJsonLd trennt solche schema.org-Bloecke aus dem
RTE-HTML (fail-safe: was nicht sauber dekodiert, bleibt stehen):
- NewsJsonRenderer: Detail-bodytext und Listen-Exzerpt ohne die Bloecke
- PageJsonLdRenderer: die dekodierten Nodes wandern in den @graph der
  Seite (FAQPage landet damit als <script type=application/ld+json> im
  Head, den das Frontend bereits injiziert)
- VitecMetaHandler: Description-Fallback aus dem Bodytext ohne JSON-Muell

Verifiziert an 3 Artikeln: @graph mit NewsArticle + FAQPage (8-9 Fragen),
kein FAQPage-Rest im Textkoerper, kein sichtbarer JSON-Absatz im HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-10 11:36:48 +01:00
1 parent c9b00e6f10
commit 5c640d02f7
4 files changed
+71 -2

No files matched your search

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
/**
* Splits editor-pasted schema.org JSON blocks out of RTE HTML.
*
* The SEO-maintained news articles carry a raw FAQPage JSON object in a
* trailing <p>…</p> of their bodytext (found 2026-11-10: 17 records). Left
* in place, the frontend renders the JSON as visible text while Google
* never sees it as structured data. This helper removes such paragraphs
* from the HTML and returns the decoded nodes, ready to join the page's
* JSON-LD @graph (their own @context is dropped - the graph carries one).
*
* Fail-safe: a paragraph that merely looks like JSON but does not decode
* to a schema.org node stays in the HTML untouched.
*/
final class EmbeddedJsonLd
{
/**
* @return array{html: string, nodes: list<array<string,mixed>>}
*/
public static function extract(string $html): array
{
if ($html === '' || !str_contains($html, 'schema.org')) {
return ['html' => $html, 'nodes' => []];
}
$nodes = [];
$clean = preg_replace_callback(
'#<p[^>]*>\s*(\{.+?\})\s*</p>#s',
static function (array $match) use (&$nodes): string {
$candidate = $match[1];
if (!str_contains($candidate, 'schema.org')) {
return $match[0];
}
$decoded = json_decode($candidate, true);
if (!is_array($decoded)) {
// RTE processing may have entity-encoded the quotes.
$decoded = json_decode(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5), true);
}
if (!is_array($decoded) || empty($decoded['@type'])) {
return $match[0];
}
unset($decoded['@context']);
$nodes[] = $decoded;
return '';
},
$html
) ?? $html;
return ['html' => trim($clean), 'nodes' => $nodes];
}
}
@@ -85,7 +85,9 @@ class VitecMetaHandler extends MetaHandler
}
$description = $this->plainText((string)($news['teaser'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($news['bodytext'] ?? ''));
$description = $this->plainText(
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
);
}
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
}
@@ -375,9 +375,12 @@ final class NewsJsonRenderer
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
// card goes blank. The detail mode is unchanged.
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
// paragraph) are split out of the body - they reach Google via
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: RteResolver::html($news['bodytext'] ?? ''),
: \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'],
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
@@ -579,6 +582,7 @@ final class NewsJsonRenderer
*/
private function listExcerpt(string $html): string
{
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
if (mb_strlen($text) <= 300) {
return $text;
@@ -156,6 +156,13 @@ final class PageJsonLdRenderer
'author' => (string)($newsRow['author'] ?? ''),
'author_email' => (string)($newsRow['author_email'] ?? ''),
], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization);
// Editor-pasted schema blocks (e.g. FAQPage JSON in a
// trailing bodytext paragraph) join the @graph here;
// NewsJsonRenderer strips them out of the rendered body.
foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) {
$nodes[] = $embeddedNode;
}
}
}