diff --git a/migrations/crawl2_fixes_20261110.php b/migrations/crawl2_fixes_20261110.php new file mode 100644 index 0000000..1b6a615 --- /dev/null +++ b/migrations/crawl2_fixes_20261110.php @@ -0,0 +1,296 @@ + /products/aetria (and the two redirects + * follow), X-View self-link cleared, chassis card -> surviving product. + * B. News shells still carrying the InfoComm import placeholder text: + * #6/#7/#5 are duplicates of real articles (#17/#18/#16) -> deleted + 301; + * #4 (EKOM) gets its text from the old /en/news page. #551 is a byte- + * identical copy of #550 under a title that does not match the text -> 301. + * C. Three typos. + * D. Dead vitec.com links in old articles: "https://www.vitec.com/ ", + * trailing-dot host, /industries, /green-initiative, two dead PDFs -> + * internal targets. + * E. SEO titles for the short top-level pages (/solutions "Solutions | VITEC" etc.). + * F. Meta descriptions for the nine category pages whose generated text was + * under 60 characters ("VITEC Avedia: Avedia Platform."). + * + * Dry run: php migrations/crawl2_fixes_20261110.php + * Write: php migrations/crawl2_fixes_20261110.php --apply (--only=A,B / --skip=F) + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +$apply = in_array('--apply', $argv, true); +$only = []; $skip = []; +foreach ($argv as $arg) { + if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); } + if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); } +} +$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, 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"; +$now = time(); +$purgePaths = []; +$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8'))); + +// --- helpers ----------------------------------------------------------------- + +$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1"); +$insertRedirect = $pdo->prepare( + "INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode) + VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)" +); +$addRedirect = static function (string $source, string $target) use ($pdo, $hasRedirect, $insertRedirect, $apply, &$purgePaths): void { + $purgePaths[$source] = true; + $hasRedirect->execute([$source]); + $existing = $hasRedirect->fetch(); + if ($existing) { + if ($existing['target'] === $target) { return; } + echo " redirect $source: Ziel {$existing['target']} -> $target\n"; + if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$existing['uid']]); } + return; + } + echo " redirect $source -> $target\n"; + if ($apply) { $insertRedirect->execute([$source, $target]); } +}; +$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void { + $st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?"); + $st->execute([$oldTarget]); + foreach ($st->fetchAll() as $r) { + echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n"; + if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]); } + } +}; +$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void { + echo " loeschen $table #$uid\n"; + if ($apply) { + $pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]); + $pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]); + } +}; +$newsRow = static function (int $uid) use ($pdo): ?array { + $st = $pdo->prepare("SELECT uid, title, path_segment, type, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?"); + $st->execute([$uid]); + $r = $st->fetch(); + return $r ?: null; +}; +$updateField = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void { + if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); } +}; +// Replace patterns in a text field of every matching row; prints each change. +$replaceIn = static function (string $table, string $field, array $pairs, string $label) use ($pdo, $apply, $now, &$purgePaths): int { + // pair = [needle, replacement, isRegex, likeNeedle (plain text for the SQL prefilter; required for regex pairs)] + $like = implode(' OR ', array_fill(0, count($pairs), "$field LIKE ?")); + $params = array_map(fn($p) => '%' . ($p[3] ?? $p[0]) . '%', $pairs); + $extra = $table === 'tx_news_domain_model_news' ? ', path_segment AS ref' : ($table === 'tt_content' ? ', pid AS ref' : ', uid AS ref'); + $st = $pdo->prepare("SELECT uid, $field AS v $extra FROM $table WHERE deleted = 0 AND ($like)"); + $st->execute($params); + $n = 0; + foreach ($st->fetchAll() as $r) { + $new = (string)$r['v']; + foreach ($pairs as [$needle, $replacement, $isRegex]) { + $new = $isRegex ? (string)preg_replace($needle, $replacement, $new) : str_replace($needle, $replacement, $new); + } + if ($new === (string)$r['v']) { continue; } + $n++; + echo " $label: $table #{$r['uid']} ({$r['ref']})\n"; + if ($table === 'tx_news_domain_model_news') { $purgePaths['/news/article/' . $r['ref']] = true; } + if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]); } + } + return $n; +}; + +// --- A. card CTAs onto hidden/deleted products ----------------------------------- + +if ($runs('A')) { + echo "\nA. Karten-Buttons auf versteckte/geloeschte Produkt-URLs\n"; + $ctas = [ + 11744 => ['/product/artiosign', '/products/aetria'], + 11745 => ['/product/artioview', '/products/aetria'], + 11649 => ['/product/artiosign', '/products/aetria'], + 11650 => ['/product/artioview', '/products/aetria'], + 12179 => ['/product/avedia-modular-chassis-c1101-c1103-c1210-1', '/product/avedia-modular-chassis-c1101-c1103-c1210'], + ]; + $ce = $pdo->prepare("SELECT uid, pid, header, vitec_primary_cta, vitec_primary_cta_label FROM tt_content WHERE deleted = 0 AND uid = ?"); + foreach ($ctas as $uid => [$old, $new]) { + $ce->execute([$uid]); $c = $ce->fetch(); + if (!$c) { continue; } + if (trim((string)$c['vitec_primary_cta']) !== $old) { echo " CE #$uid: CTA ist '{$c['vitec_primary_cta']}', erwartet '$old' - uebersprungen\n"; continue; } + echo " CE #$uid (Seite {$c['pid']}, \"{$c['header']}\"): $old -> $new\n"; + $updateField('tt_content', $uid, 'vitec_primary_cta', $new); + $pageSlug = $pdo->query("SELECT slug FROM pages WHERE uid = {$c['pid']}")->fetchColumn(); + if ($pageSlug) { $purgePaths[$pageSlug] = true; } + } + // X-View card links to the page it sits on: no button. + $ce->execute([12133]); $c = $ce->fetch(); + if ($c && trim((string)$c['vitec_primary_cta']) === '/product/x-view-integrated-displays') { + echo " CE #12133 (\"{$c['header']}\"): Selbst-Link entfernt (Button weg)\n"; + $updateField('tt_content', 12133, 'vitec_primary_cta', ''); + $updateField('tt_content', 12133, 'vitec_primary_cta_label', ''); + $purgePaths['/products/x-view-integrated-displays'] = true; + } + // The two product redirects follow the cards. + $addRedirect('/product/artiosign', '/products/aetria'); + $addRedirect('/product/artioview', '/products/aetria'); +} + +// --- B. placeholder news shells ----------------------------------------------------- + +if ($runs('B')) { + echo "\nB. Platzhalter-News (InfoComm-Importtext)\n"; + $placeholderMarker = 'VITEC will demonstrate its latest enterprise video technologies at InfoComm 2026'; + $isPlaceholder = static function (int $uid) use ($pdo, $placeholderMarker): bool { + $b = (string)$pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = $uid")->fetchColumn(); + return strlen($b) === 1308 && str_contains($b, $placeholderMarker); + }; + // duplicate shell => real article + foreach ([6 => 17, 7 => 18, 5 => 16] as $shell => $real) { + $s = $newsRow($shell); $r = $newsRow($real); + if (!$s || !$r) { echo " #$shell/#$real fehlt, uebersprungen\n"; continue; } + if (!$isPlaceholder($shell)) { echo " #$shell ist kein Platzhalter mehr, uebersprungen\n"; continue; } + echo " #$shell \"{$s['title']}\" (Platzhalter) -> #$real \"{$r['title']}\" ({$r['bl']} Zeichen)\n"; + $softDelete('tx_news_domain_model_news', $shell); + $addRedirect('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']); + $retarget('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']); + $purgePaths['/news/article/' . $r['path_segment']] = true; + } + // EKOM: text lives on the old page + $s = $newsRow(4); + if ($s && $isPlaceholder(4)) { + $page = $pdo->query("SELECT uid, slug FROM pages WHERE deleted = 0 AND slug LIKE '%/news/vitec-partners-with-ekom%' ORDER BY uid LIMIT 1")->fetch(); + $html = ''; + if ($page) { + foreach ($pdo->query("SELECT CType, bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$page['uid']} AND CType IN ('text','textmedia','html') AND bodytext <> '' ORDER BY sorting") as $c) { + $part = (string)$c['bodytext']; + if (str_contains($part, 'vitec.com/ ')) { $part = str_replace('https://www.vitec.com/ ', 'www.vitec.com', $part); } + $html .= $part . "\n"; + } + } + $len = mb_strlen($plain($html)); + if ($len >= 500) { + echo " #4 \"{$s['title']}\": Text von Seite #{$page['uid']} ({$page['slug']}) uebernommen, $len Zeichen\n"; + $updateField('tx_news_domain_model_news', 4, 'bodytext', trim($html)); + $purgePaths['/news/article/' . $s['path_segment']] = true; + } else { + echo " #4: keine alte Seite mit Text gefunden ($len Zeichen) - bleibt\n"; + } + } + // byte-identical Datapath pair: keep the one whose title matches the text + $a = $newsRow(551); $b = $newsRow(550); + if ($a && $b) { + $ba = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 551")->fetchColumn(); + $bb = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 550")->fetchColumn(); + if ($ba === $bb) { + echo " #551 \"{$a['title']}\" = Textkopie von #550 \"{$b['title']}\" -> weg\n"; + $softDelete('tx_news_domain_model_news', 551); + $addRedirect('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']); + $retarget('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']); + } + } +} + +// --- C. typos ------------------------------------------------------------------------ + +if ($runs('C')) { + echo "\nC. Tippfehler\n"; + $replaceIn('tx_news_domain_model_news', 'bodytext', [ + ['/\bsponser\b/', 'sponsor', true, 'sponser'], + ['/\bSponser\b/', 'Sponsor', true, 'Sponser'], + ['/\breconnaisance\b/', 'reconnaissance', true, 'reconnaisance'], + ['/\bReconnaisance\b/', 'Reconnaissance', true, 'Reconnaisance'], + ['/\bauxillary\b/', 'auxiliary', true, 'auxillary'], + ['/\bAuxillary\b/', 'Auxiliary', true, 'Auxillary'], + ], 'Tippfehler'); +} + +// --- D. dead vitec.com links ----------------------------------------------------------- + +if ($runs('D')) { + echo "\nD. Tote vitec.com-Links\n"; + $pairs = [ + ['https://www.vitec.com/ ', 'www.vitec.com', false], + ['~https?://www\.vitec\.com\.(?=[/"#\s<])/?~', '/', true, 'www.vitec.com.'], + ['https://www.vitec.com/green-initiative', '/insights/sustainability', false], + ['https://www.vitec.com/industries/military/government', '/markets/defense-and-security', false], + ['https://www.vitec.com/industries', '/markets', false], + ['~https?://www\.vitec\.com/fileadmin/downloads/Company_Downloads/Use_Cases/Northwestern_University_USE_CASE_WebVersion\.pdf~', '/success-stories/northwestern-university', true, 'Northwestern_University_USE_CASE'], + ['~https?://datapathdocuments\.co\.uk/wp-content/uploads/Datapath-Fx4_datasheet_EN\.pdf~', '/product/fx4', true, 'Datapath-Fx4_datasheet'], + ]; + $n = $replaceIn('tx_news_domain_model_news', 'bodytext', $pairs, 'Link'); + $n += $replaceIn('tt_content', 'bodytext', $pairs, 'Link'); + echo " $n Datensaetze\n"; +} + +// --- E. SEO titles for short top-level pages ------------------------------------------- + +if ($runs('E')) { + echo "\nE. Seitentitel der Hauptseiten\n"; + $titles = [ + '/solutions' => 'Video Solutions for Control Rooms, Streaming & Signage', + '/products' => 'Products: IPTV, Video Streaming, Video Walls & Capture', + '/support' => 'Support, Training & Product Resources', + '/datasheets' => 'Datasheets: Encoders, IPTV, Video Walls & Capture Cards', + '/contact' => 'Contact VITEC: Sales, Support & Offices Worldwide', + '/contact/locations' => 'VITEC Offices & Locations Worldwide', + '/success-stories' => 'Success Stories from Broadcast, Defense & Enterprise', + ]; + $st = $pdo->prepare("SELECT uid, title, seo_title FROM pages WHERE deleted = 0 AND slug = ?"); + foreach ($titles as $slug => $seo) { + $st->execute([$slug]); $p = $st->fetch(); + if (!$p) { continue; } + if ($p['seo_title'] === $seo) { continue; } + printf(" %-20s \"%s\" -> \"%s\"\n", $slug, $p['seo_title'], $seo); + $updateField('pages', (int)$p['uid'], 'seo_title', $seo); + $purgePaths[$slug] = true; + } +} + +// --- F. descriptions for the thinnest category pages ------------------------------------- + +if ($runs('F')) { + echo "\nF. Meta-Descriptions der kuerzesten Kategorieseiten\n"; + $desc = [ + '/products/ez-tv' => 'EZ TV Platform by VITEC: enterprise IPTV and digital signage for live TV, video distribution and targeted content across screens and devices.', + '/products/activesqx' => 'ActiveSQX2 by VITEC and Datapath: IP stream decoding card that brings network video sources onto video walls. Specifications, features and datasheets.', + '/products/avedia' => 'Avedia Platform by VITEC: one platform for IP video, digital signage and interactive experiences across enterprise networks.', + '/products/rf-gateway' => 'VITEC RF gateways for the Avedia Modular System: receive satellite, cable and terrestrial TV and distribute it as IPTV across the network.', + '/products/prism-transcoder' => 'PRISM and PRISM SFF by VITEC: broadcast-grade video transcoders for converting, scaling and re-streaming live video, including a compact small-form-factor model.', + '/products/channellink-ip-gateway' => 'ChannelLink IP Gateway by VITEC: brings IP video streams into IPTV and digital signage networks, with transcoding for reliable delivery to every screen.', + '/products/aligo' => 'Aligo by VITEC: AV over IP and KVM encoders, receivers and workstations (QTX100, TX100, RX100, TX100 SQX) for control rooms and multi-display environments.', + '/products/vms-plugins-and-integrations' => 'VMS plugins and integrations for VITEC and Datapath video walls: bring Genetec and Milestone video management systems onto operator displays.', + '/products/image-graphics-cards' => 'Datapath Image graphics cards from VITEC (Image 2K, Image 4K, IQS4): multi-output graphics for video walls and multi-display workstations.', + ]; + $st = $pdo->prepare("SELECT uid, description FROM pages WHERE deleted = 0 AND slug = ?"); + foreach ($desc as $slug => $d) { + $st->execute([$slug]); $p = $st->fetch(); + if (!$p || $p['description'] === $d) { continue; } + printf(" %-42s (%d -> %d Zeichen)\n alt: %s\n neu: %s\n", $slug, strlen((string)$p['description']), strlen($d), $p['description'], $d); + $updateField('pages', (int)$p['uid'], 'description', $d); + $purgePaths[$slug] = true; + } +} + +echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n"; +echo $apply ? "\nFERTIG.\n" : "\n(Trockenlauf, nichts geschrieben)\n"; diff --git a/packages/vitec/Classes/Seo/VitecMetaHandler.php b/packages/vitec/Classes/Seo/VitecMetaHandler.php index eaeca12..14da3ef 100644 --- a/packages/vitec/Classes/Seo/VitecMetaHandler.php +++ b/packages/vitec/Classes/Seo/VitecMetaHandler.php @@ -123,6 +123,11 @@ class VitecMetaHandler extends MetaHandler if ($recordTitle !== '') { $title = $recordTitle; } + // "Betfred HQ | VITEC" is too short to say what the page is. + $storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title)); + if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) { + $title = $storyCore . ' | Success Story'; + } $description = trim((string)($usecase['seo_description'] ?? '')); if ($description === '') { $description = trim((string)($usecase['og_description'] ?? '')); @@ -182,12 +187,19 @@ class VitecMetaHandler extends MetaHandler // --- compose meta list ------------------------------------------------ $meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : []; - $description = $this->crop($description, 300); + // ~155 characters is what search engines show (Seobility: 527 "zu lang"). + $description = $this->crop($description, 155); $absImage = $image !== '' ? $this->absolute($image, $base) : ''; if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) { $meta[] = ['name' => 'description', 'content' => $description]; } + // Editorial descriptions already in the list get the same length cap. + foreach ($meta as $index => $entry) { + if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) { + $meta[$index]['content'] = $this->crop(trim($entry['content']), 155); + } + } $finalDescription = $this->metaContent($meta, 'name', 'description'); if ($finalDescription === '') { $finalDescription = $description; @@ -267,22 +279,27 @@ class VitecMetaHandler extends MetaHandler return ''; } // Rootline keys are the depth (0 = root), but the array is krsort()ed - // (current page first). Sort by depth and take the entry before last. + // (current page first). Walk up from the parent, skipping structural + // levels (folders, shortcuts, spacers, technical names) until a page + // with a presentable name turns up; the site root never counts. $rootLine = $pageInfo->getRootLine(); ksort($rootLine); $byDepth = array_values($rootLine); - $parent = count($byDepth) >= 3 ? ($byDepth[count($byDepth) - 2] ?? null) : null; - if (!is_array($parent)) { - return ''; + for ($i = count($byDepth) - 2; $i >= 1; $i--) { + $candidate = $byDepth[$i]; + if (!is_array($candidate)) { + continue; + } + if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) { + continue; + } + $name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? '')); + if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) { + continue; + } + return $name; } - if (in_array((int)($parent['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) { - return ''; - } - $name = trim((string)($parent['nav_title'] ?? '')) ?: trim((string)($parent['title'] ?? '')); - if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) { - return ''; - } - return $name; + return ''; } /**