News-Detail: FAQ-Band + Produktkarten, tote Datapath-Links migriert

Die SEO-Artikel trugen (a) eine FAQ-Sektion als nackte <p>-Paare im
Bodytext und (b) ~150 Links auf die alte datapath.com-Struktur
(/datapath-products/..., alte /markets/...-Namen, /video-wall-solutions)
- alles 404 auf der neuen Seite.

- NewsJsonRenderer (Detail-Modus): extractFaqBand() schneidet die
  'Frequently asked questions'-Sektion aus dem Bodytext und liefert sie
  strukturiert als faq {heading, items[{question, answer(HTML)}]} fuers
  Frontend-Accordion (fail-safe: unter 2 sauberen Paaren bleibt alles
  unveraendert); collectMentionedProducts() liefert bis zu 6 Karten
  (title, text, image, link) fuer im Text verlinkte Produkt- und
  Kategorieseiten. Beide Felder additiv; Listen unveraendert.
- Migration fix_datapath_links_20261110: 148 News-Records auf die neuen
  Ziele umgeschrieben (Mapping alte Datapath-IA -> /product/<slug> bzw.
  /products/<slug>/Markets/Solutions, Ziele gegen DB verifiziert) und
  113 sys_redirects 301 angelegt (alte Pfade mit und ohne Slash - greift
  fuer externe Backlinks, sobald datapath.com hierher zeigt).

Live verifiziert: Beispielartikel liefert faq (8 Fragen, Antworten mit
funktionierenden Links), 4 Karten, Bodytext ohne FAQ-Absaetze und ohne
Datapath-Links; normale News unveraendert (faq null); Redirects 301 ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-10 12:52:14 +01:00
1 parent 5c640d02f7
commit dc9b5fce47
2 files changed
+343 -1

No files matched your search

@@ -361,6 +361,22 @@ final class NewsJsonRenderer
$pathSegment = (string)($news['path_segment'] ?? '');
$urls = $this->buildNewsUrls($news, $settings);
// Detail view only (contract 0083-BE): the SEO articles carry a
// "Frequently asked questions" section as bare <p> pairs plus inline
// product links. The Q&A pairs move into a structured `faq` band
// (accordion in the frontend), linked products/categories become
// `mentionedProducts` cards; the body keeps everything else.
$detailBody = null;
$faqBand = null;
$mentionedProducts = [];
if ($mode !== 'list') {
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
$faqSplit = $this->extractFaqBand($detailBody);
$detailBody = $faqSplit['html'];
$faqBand = $faqSplit['faq'];
$mentionedProducts = $this->collectMentionedProducts($detailBody);
}
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
@@ -380,7 +396,7 @@ final class NewsJsonRenderer
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'],
: (string)$detailBody,
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
@@ -392,6 +408,8 @@ final class NewsJsonRenderer
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
'related' => $this->resolveRelatedNews($uid),
'relatedLinks' => $this->resolveRelatedLinks($uid),
'faq' => $faqBand,
'mentionedProducts' => $mentionedProducts,
];
}
@@ -595,6 +613,142 @@ final class NewsJsonRenderer
return rtrim($cut, " .,;:") . '...';
}
/**
* Splits the trailing FAQ section out of an article body.
*
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
* with "Frequently asked" or "FAQ", followed by alternating question
* paragraphs (plain text ending in "?") and answer paragraphs (may
* contain links). Fail-safe: anything that does not yield at least two
* clean Q&A pairs leaves the HTML untouched.
*
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
*/
private function extractFaqBand(string $html): array
{
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
return ['html' => $html, 'faq' => null];
}
$heading = trim((string)$headingMatch[2][0]);
$sectionStart = (int)$headingMatch[0][1];
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
$sectionEnd = $cursor;
$items = [];
$question = null;
$answerParts = [];
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
$text = trim(strip_tags($paragraph[1]));
if ($text === '') {
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
continue;
}
if (str_ends_with($text, '?')) {
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
$question = $text;
$answerParts = [];
} elseif ($question !== null) {
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
} else {
break;
}
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
}
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
if (count($items) < 2) {
return ['html' => $html, 'faq' => null];
}
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
return [
'html' => trim($clean),
'faq' => ['heading' => $heading, 'items' => $items],
];
}
/**
* Cards for every product detail page (/product/<slug>) and product
* category page (/products/<slug>) linked inside an article body -
* order of first appearance, deduplicated, at most six.
*
* @return array<int,array<string,mixed>>
*/
private function collectMentionedProducts(string $html): array
{
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
return [];
}
$cards = [];
foreach ($matches as $match) {
$href = (string)$match[1];
if (isset($cards[$href]) || count($cards) >= 6) {
continue;
}
try {
if (!empty($match[2])) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
->from('tx_vitec_domain_model_product')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('hideonwebsite', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'product',
'title' => (string)($row['title'] ?? ''),
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
'link' => $href,
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
];
} else {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $qb->select('uid', 'title', 'description', 'abstract')
->from('pages')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'category',
'title' => (string)($row['title'] ?? ''),
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
'link' => $href,
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
];
}
} catch (\Throwable $e) {
continue;
}
}
return array_values($cards);
}
/**
* Where a news item actually points.
*