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:
1 parent
5c640d02f7
commit
dc9b5fce47
2 files changed
+343
-1
No files matched your search
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fix dead old-site links inside news bodytexts (SEO follow-up 2026-11-10).
|
||||
*
|
||||
* The imported Datapath articles and the 17 new SEO articles carry hrefs
|
||||
* from the OLD datapath.com information architecture: /datapath-products/…,
|
||||
* old /markets/… market names and /video-wall-solutions/…. None of these
|
||||
* paths exist on the relaunch site (404 on every page view).
|
||||
*
|
||||
* 1. Rewrites every known old href inside tx_news_domain_model_news
|
||||
* bodytext (and teaser, defensively) onto the matching new product
|
||||
* detail page (/product/<slug>), product category page
|
||||
* (/products/<slug>), market or solution page.
|
||||
* 2. Queues a 301 sys_redirect for every old path (with and without
|
||||
* trailing slash) so external backlinks land correctly once the
|
||||
* datapath.com domains point at this site.
|
||||
*
|
||||
* Dry run: php migrations/fix_datapath_links_20261110.php
|
||||
* Write: php migrations/fix_datapath_links_20261110.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) { exit("no project root\n"); }
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '', $db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||
|
||||
/* Old path (no trailing slash) => new target path. */
|
||||
$mapping = [
|
||||
// Aetria
|
||||
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
|
||||
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
|
||||
'/datapath-products/aetria-control-room-solutions/aetria-network-manager' => '/product/aetria-network-manager',
|
||||
'/datapath-products/aetria-control-room-solutions/aetria-touch' => '/product/aetria-touch',
|
||||
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
|
||||
'/datapath-products/aetria-control-room-solutions/operator-workstations' => '/products/operator-workstations',
|
||||
// AVoIP / KVM
|
||||
'/datapath-products/arqa-avoip-with-kvm' => '/product/arqa',
|
||||
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
|
||||
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
|
||||
'/datapath-products/video-distribution-kvm/aligo-qtx100-quad-transmitter' => '/product/qtx100',
|
||||
'/datapath-products/video-distribution-kvm/aligo-rx100-receiver' => '/product/rx100',
|
||||
'/datapath-products/video-distribution-kvm/aligo-tx100-transmitter' => '/product/tx100',
|
||||
// Capture & graphics
|
||||
'/datapath-products/graphics-products' => '/products/capture-and-graphics',
|
||||
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
|
||||
'/datapath-products/graphics-products/image4k' => '/product/image-4k',
|
||||
'/datapath-products/sqx-ip-capture' => '/products/activesqx',
|
||||
'/datapath-products/vision-range' => '/products/vision-video-capture-cards',
|
||||
'/datapath-products/video-capture-cards/capture-card-features' => '/products/vision-video-capture-cards',
|
||||
'/datapath-products/video-capture-cards/vision-range/visionrgb-e1s' => '/product/visionrgb-e1s',
|
||||
'/datapath-products/video-capture-cards/vision-range/visionsd41s' => '/product/visionsd4-1s',
|
||||
'/datapath-products/video-capture-cards/vision-range/visionsdi2' => '/products/vision-video-capture-cards',
|
||||
'/datapath-products/video-capture-cards/visionav-range' => '/product/visionav',
|
||||
'/datapath-products/video-capture-cards/visionav-range/visionhd4' => '/product/visionhd4',
|
||||
'/datapath-products/video-capture-cards/visionsc-range' => '/products/vision-video-capture-cards',
|
||||
'/datapath-products/video-capture-cards/visionsc-range/visionsc-dp2' => '/product/visionsc-dp2',
|
||||
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
|
||||
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
|
||||
// Multi-display / Fx4 / Hx4
|
||||
'/datapath-products/multi-display-controllers/datapath-fx4' => '/product/fx4',
|
||||
'/datapath-products/multi-display-controllers/datapath-fx4-sdi' => '/product/fx4-sdi',
|
||||
'/datapath-products/multi-display-controllers/datapath-hx4' => '/product/hx4',
|
||||
'/datapath-products/multi-display-controllers/multi-display-overview' => '/products/x-series-multi-display-processors',
|
||||
'/datapath-products/multi-display-controllers/video-wall-designer-software' => '/products/video-wall-management-software',
|
||||
// Video walls / VSN / WallControl
|
||||
'/datapath-products/video-wall-control/industrial-wall-controllers' => '/products/vsn-series-video-wall-controllers',
|
||||
'/datapath-products/video-wall-control/video-wall-controllers' => '/products/vsn-series-video-wall-controllers',
|
||||
'/datapath-products/video-wall-control/vsn-11-series' => '/products/vsn-series-video-wall-controllers',
|
||||
'/datapath-products/video-wall-control/vsn-9-series' => '/products/vsn-series-video-wall-controllers',
|
||||
'/datapath-products/video-wall-controllers' => '/products/video-wall-controllers-and-management',
|
||||
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
|
||||
'/datapath-products/video-wall-controllers/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
|
||||
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
|
||||
'/datapath-products/video-wall-management-software' => '/products/video-wall-management-software',
|
||||
'/datapath-products/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
|
||||
// Accessories (no equivalent records - portfolio page)
|
||||
'/datapath-products/components-accessories/components-accessories-overview/displayport-accessories' => '/products',
|
||||
'/datapath-products/components-accessories/pcie-backplanes/express11-g3' => '/products',
|
||||
// Old market / solution names
|
||||
'/markets/airport-and-aviation' => '/markets/transport/airports',
|
||||
'/markets/corporate' => '/markets/corporation',
|
||||
'/markets/emergency-operation-center' => '/markets/public-safety/emergency-operation-centres',
|
||||
'/markets/events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
|
||||
'/markets/military-control-room' => '/markets/defense-and-security',
|
||||
'/markets/network-operation-center' => '/solutions/control-room-command-centre-solutions/control-room-platforms-soc-noc-command-centres',
|
||||
'/markets/retail-and-hospitality' => '/markets/hospitality',
|
||||
'/markets/security-and-surveillance' => '/solutions/security-surveillance-solutions',
|
||||
'/markets/traffic-management-control-centers' => '/markets/government/traffic-and-smart-mobility',
|
||||
'/markets/utilities' => '/markets/critical-national-infrastructure/energy-and-utilities',
|
||||
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
|
||||
'/video-wall-solutions/retail-events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
|
||||
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sanity: every target must exist (page slug, or product slug). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND slug = ? LIMIT 1");
|
||||
$missing = 0;
|
||||
foreach (array_unique(array_values($mapping)) as $target) {
|
||||
if (str_starts_with($target, '/product/')) {
|
||||
$productBySlug->execute([substr($target, strlen('/product/'))]);
|
||||
if ((int)$productBySlug->fetchColumn() > 0) { continue; }
|
||||
} else {
|
||||
$pageBySlug->execute([$target]);
|
||||
if ((int)$pageBySlug->fetchColumn() > 0) { continue; }
|
||||
}
|
||||
echo " !! Ziel fehlt: $target\n";
|
||||
$missing++;
|
||||
}
|
||||
if ($missing > 0) {
|
||||
echo "$missing Ziele nicht gefunden - Mapping pruefen.\n";
|
||||
if ($apply) { exit("Abbruch.\n"); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 1. Rewrite hrefs in news bodytext/teaser */
|
||||
/* ------------------------------------------------------------------ */
|
||||
echo "1. Links in News-Texten ersetzen:\n";
|
||||
$findNews = $pdo->prepare("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)");
|
||||
$fixNews = $pdo->prepare(
|
||||
"UPDATE tx_news_domain_model_news
|
||||
SET bodytext = REPLACE(bodytext, ?, ?), teaser = REPLACE(teaser, ?, ?), tstamp = UNIX_TIMESTAMP()
|
||||
WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)"
|
||||
);
|
||||
$totalHits = 0;
|
||||
foreach ($mapping as $old => $new) {
|
||||
// Both href variants: with and without trailing slash; plus the one
|
||||
// broken absolute form (http://datapath-products/...).
|
||||
$variants = ['href="' . $old . '/"', 'href="' . $old . '"', 'href="http:/' . $old . '/"'];
|
||||
foreach ($variants as $needle) {
|
||||
$like = '%' . $needle . '%';
|
||||
$findNews->execute([$like, $like]);
|
||||
$count = (int)$findNews->fetchColumn();
|
||||
if ($count === 0) { continue; }
|
||||
$totalHits += $count;
|
||||
echo " $needle -> href=\"$new\" ($count Record(s))\n";
|
||||
if ($apply) {
|
||||
$fixNews->execute([$needle, 'href="' . $new . '"', $needle, 'href="' . $new . '"', $like, $like]);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo " Summe Record-Treffer: $totalHits\n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 2. 301 redirects for the old paths */
|
||||
/* ------------------------------------------------------------------ */
|
||||
echo "2. Redirects anlegen:\n";
|
||||
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
|
||||
$insertRedirect = $pdo->prepare(
|
||||
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||
);
|
||||
$newRedirects = 0;
|
||||
foreach ($mapping as $old => $new) {
|
||||
foreach ([$old, $old . '/'] as $source) {
|
||||
$hasRedirect->execute([$source]);
|
||||
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
|
||||
$newRedirects++;
|
||||
if ($apply) {
|
||||
$insertRedirect->execute([$source, $new]);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo " $newRedirects neue Redirects (mit + ohne Slash; bestehende unangetastet)\n";
|
||||
|
||||
echo $apply ? "FERTIG. Cache flushen.\n" : "DRY RUN beendet.\n";
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user