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 '';
}
/**