From f7b1eacecb788756e55864f8d244149189e23507 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 10 Nov 2026 18:12:34 +0100 Subject: [PATCH] Dubletten-Bereinigung nach Seobility: Produkt-Kopie, 10 News, Slugs, /about, Story-Texte Migration dedupe_20261110 (angewendet): leere Chassis-Kopie geloescht; bei InfoComm/LG/PRISM/ISE trugen die -1-Kopien den Text, die Basis war Platzhalter oder leer - Kopien uebernehmen den sauberen Slug; identische Kopien (Datapath-Uebernahme, Remote-Work-Blog) weg; Records ohne Inhalt (Global Events, DSEI-2023-Zeiger) weg; WallControl-Award und die beiden Jahresbotschaften mit sprechenden Slugs; /about (nur Standortliste) versteckt und auf /insights/who-we-are umgeleitet, Links umgehaengt; drei Story-Teaser/-Descriptions, die von fremden Stories kopiert waren, neu gesetzt. Alle alten Adressen leiten per 301, bestehende Redirects zeigen direkt auf die neuen Ziele (keine Ketten). Co-Authored-By: Claude Fable 5.1 --- migrations/dedupe_20261110.php | 307 +++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 migrations/dedupe_20261110.php diff --git a/migrations/dedupe_20261110.php b/migrations/dedupe_20261110.php new file mode 100644 index 0000000..9ec4c66 --- /dev/null +++ b/migrations/dedupe_20261110.php @@ -0,0 +1,307 @@ + deleted, 301 to the original. + * B. News duplicates. Where the "-1/-2" copy carries the real text and the + * base record is an empty shell or an import placeholder, the shell is + * deleted and the copy takes over the clean slug (301 from the "-1" URL). + * Records without any content anywhere (#9/#229 global events, #42 DSEI + * 2023 pointer) are deleted and their URLs sent to the news overview. + * C. Records that are NOT duplicates but carry a "-1/-2" slug from the + * import: slug rebuilt from the title, 301 from the old URL. + * D. /about (page 7): only an office list, superseded by /insights/who-we-are + * and /about/locations -> hidden, 301, two links re-pointed. + * E. Stories with copied teaser / SEO description (#11 UAE University, + * #30 Kansas City Royals, #35 Minnesota United): texts rebuilt from the + * story's own first paragraph. + * + * Dry run: php migrations/dedupe_20261110.php + * Write: php migrations/dedupe_20261110.php --apply + * Sections: --only=A,B or --skip=D,E + * + * Deletions are TYPO3 soft deletes (deleted = 1, recoverable via the recycler). + */ + +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 = []; + +// --- 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)" +); +$updateRedirectBySource = $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?"); +$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $updateRedirectBySource, $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) { $updateRedirectBySource->execute([$target, (int)$existing['uid']]); } + return; + } + echo " redirect $source -> $target\n"; + if ($apply) { $insertRedirect->execute([$source, $target]); } +}; + +// Redirects whose TARGET is the URL that goes away get the new target (no 301 chains). +$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, hidden, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?"); + $st->execute([$uid]); + $r = $st->fetch(); + return $r ?: null; +}; +$setSegment = static function (int $uid, string $segment) use ($pdo, $apply, $now): void { + echo " news #$uid slug -> $segment\n"; + if ($apply) { + $pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$segment, $now, $uid]); + } +}; +$slugify = static function (string $t): string { + $t = html_entity_decode($t, ENT_QUOTES, 'UTF-8'); + $t = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT', $t) ?: $t), '-')); + return $t; +}; +$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8'))); +$cut = static function (string $s, int $max): string { + if (mb_strlen($s) <= $max) { return $s; } + $s = mb_substr($s, 0, $max); + $s = preg_replace('/\s+\S*$/u', '', $s) ?? $s; + return rtrim($s, ' ,;:-') . '…'; +}; + +$newsOverview = '/insights/news'; + +// --- A. product copy ----------------------------------------------------------- + +if ($runs('A')) { + echo "\nA. Produkt-Kopie\n"; + $st = $pdo->query("SELECT uid, slug, LENGTH(description) dl FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug IN ('avedia-modular-chassis-c1101-c1103-c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1')"); + $p = []; + foreach ($st as $r) { $p[$r['slug']] = $r; } + $copy = $p['avedia-modular-chassis-c1101-c1103-c1210-1'] ?? null; + $base = $p['avedia-modular-chassis-c1101-c1103-c1210'] ?? null; + if ($copy && $base && (int)$copy['dl'] === 0) { + echo " Produkt #{$copy['uid']} (leer) -> #{$base['uid']}\n"; + $softDelete('tx_vitec_domain_model_product', (int)$copy['uid']); + $addRedirect('/product/' . $copy['slug'], '/product/' . $base['slug']); + $purgePaths['/product/' . $base['slug']] = true; + } else { + echo " nichts zu tun\n"; + } +} + +// --- B. news duplicates ---------------------------------------------------------- + +if ($runs('B')) { + echo "\nB. News-Dubletten\n"; + + // [keep uid, clean segment, [uids to delete], [old segments to redirect]] + $takeovers = [ + [12, 'vitec-to-showcase-expanding-enterprise-iptv-and-digital-signage-innovations-at-infocomm-2026', [1]], + [13, 'vitec-and-lg-electronics-to-deliver-large-scale-iptv-and-digital-signage-over-next-generation-private-5g-networks', [2]], + [14, 'vitec-to-launch-prism-sff-its-most-compact-transcoder-at-eurosatory-2026', [3]], + [258, 'ise-2026-a-milestone-for-vitec-and-datapath', [10, 230]], + [21, 'vitec-acquires-datapath-ltd-to-expand-video-capabilities', [263]], + [231, 'the-impact-of-remote-work-on-security', [344]], + ]; + foreach ($takeovers as [$keepUid, $clean, $deleteUids]) { + $keep = $newsRow($keepUid); + if ($keep === null) { echo " #$keepUid fehlt, uebersprungen\n"; continue; } + echo " behalten #$keepUid ({$keep['bl']} Zeichen) als /news/article/$clean | {$keep['title']}\n"; + foreach ($deleteUids as $d) { + $row = $newsRow($d); + if ($row === null) { echo " #$d schon weg\n"; continue; } + echo " weg: #$d /news/article/{$row['path_segment']} (type {$row['type']}, {$row['bl']} Zeichen)\n"; + $softDelete('tx_news_domain_model_news', $d); + if ($row['path_segment'] !== $clean) { + $addRedirect('/news/article/' . $row['path_segment'], '/news/article/' . $clean); + $retarget('/news/article/' . $row['path_segment'], '/news/article/' . $clean); + } + } + if ($keep['path_segment'] !== $clean) { + $old = $keep['path_segment']; + $setSegment($keepUid, $clean); + $addRedirect('/news/article/' . $old, '/news/article/' . $clean); + $retarget('/news/article/' . $old, '/news/article/' . $clean); + } + $purgePaths['/news/article/' . $clean] = true; + } + + // Records with no content anywhere. + echo " ohne Inhalt (Record + alte Seite leer):\n"; + foreach ([9, 229] as $d) { + $row = $newsRow($d); + if ($row === null) { continue; } + echo " weg: #$d /news/article/{$row['path_segment']} | {$row['title']}\n"; + $softDelete('tx_news_domain_model_news', $d); + $addRedirect('/news/article/' . $row['path_segment'], $newsOverview); + $retarget('/news/article/' . $row['path_segment'], $newsOverview); + } + $row = $newsRow(42); + if ($row !== null) { + $alt = $pdo->query("SELECT path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND uid <> 42 AND type = 0 AND path_segment LIKE '%dsei-2023%' LIMIT 1")->fetchColumn(); + $target = $alt ? '/news/article/' . $alt : $newsOverview; + echo " weg: #42 /news/article/{$row['path_segment']} (Zeiger auf leere Seite 321) -> $target\n"; + $softDelete('tx_news_domain_model_news', 42); + $addRedirect('/news/article/' . $row['path_segment'], $target); + $retarget('/news/article/' . $row['path_segment'], $target); + } +} + +// --- C. wrong "-N" slugs on distinct articles -------------------------------------- + +if ($runs('C')) { + echo "\nC. Eigene Artikel mit Import-Suffix im Slug\n"; + foreach ([ + 435 => 'wallcontrol-10-scoops-installation-product-awards-crown', + 334 => '2023-end-of-year-message-from-ceo-bjorn-krylander', + 376 => '2022-end-of-year-message-from-ceo-bjorn-krylander', + ] as $uid => $segment) { + $row = $newsRow($uid); + if ($row === null || $row['path_segment'] === $segment) { continue; } + echo " #$uid | {$row['title']}\n"; + $old = $row['path_segment']; + $setSegment($uid, $segment); + $addRedirect('/news/article/' . $old, '/news/article/' . $segment); + $retarget('/news/article/' . $old, '/news/article/' . $segment); + $purgePaths['/news/article/' . $segment] = true; + } +} + +// --- D. /about ------------------------------------------------------------------------ + +if ($runs('D')) { + echo "\nD. /about (Seite 7) -> /insights/who-we-are\n"; + $page = $pdo->query("SELECT uid, hidden, slug FROM pages WHERE uid = 7 AND deleted = 0")->fetch(); + if ($page && (int)$page['hidden'] === 0) { + echo " Seite 7 verstecken\n"; + if ($apply) { $pdo->exec("UPDATE pages SET hidden = 1, tstamp = $now WHERE uid = 7"); } + } + $addRedirect('/about', '/insights/who-we-are'); + $addRedirect('/about/', '/insights/who-we-are'); + foreach ($pdo->query("SELECT uid, pid, bodytext, vitec_button_link, header_link FROM tt_content WHERE deleted = 0 AND uid IN (117, 10291)") as $c) { + $changes = []; + foreach (['bodytext', 'vitec_button_link', 'header_link'] as $f) { + $v = (string)($c[$f] ?? ''); + $n = (string)preg_replace('/t3:\/\/page\?uid=7(?![0-9])/', 't3://page?uid=550', $v); + $n = str_replace(['"/about"', '"/about/"'], '"/insights/who-we-are"', $n); + if (in_array($v, ['/about', '/about/', '7'], true)) { $n = $v === '7' ? '550' : 't3://page?uid=550'; } + if ($n !== $v) { $changes[$f] = $n; } + elseif ($f !== 'bodytext' && $v !== '') { echo " CE #{$c['uid']} $f = $v\n"; } + } + if ($changes) { + echo " CE #{$c['uid']} (Seite {$c['pid']}): " . implode(', ', array_keys($changes)) . " -> who-we-are\n"; + if ($apply) { + $set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($changes))); + $pdo->prepare("UPDATE tt_content SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($changes), (int)$c['uid']]); + } + } else { + echo " CE #{$c['uid']}: kein /about-Link gefunden (manuell pruefen)\n"; + } + } + $purgePaths['/'] = true; +} + +// --- E. story teasers / descriptions copied from the wrong story ----------------------- + +if ($runs('E')) { + echo "\nE. Story-Teaser/-Beschreibungen aus dem eigenen Text\n"; + $firstPara = static function (int $usecase) use ($pdo, $plain): string { + $st = $pdo->prepare("SELECT bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_vitec_usecase_content = ? AND bodytext <> '' ORDER BY sorting"); + $st->execute([$usecase]); + $fallback = ''; + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $html) { + $html = (string)preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', (string)$html); + $parts = preg_match_all('/]*>(.*?)<\/p>/is', $html, $m) ? $m[1] : [$html]; + foreach ($parts as $p) { + $t = $plain($p); + if (mb_strlen($t) < 80 || str_contains($t, 'document.') || str_contains($t, 'function (')) { continue; } + // Prefer a real sentence over a title-case sub-headline. + if (mb_strlen($t) >= 120 && preg_match('/[.!?]$/u', $t) === 1) { return $t; } + if ($fallback === '') { $fallback = $t; } + } + } + return $fallback; + }; + // Texts derived from each story's own (correct) teaser or headline; the + // automatic first-paragraph pick was tried and read badly. + $texts = [ + 11 => ['seo_description' => 'VITEC IPTV and digital signage power streaming and next-generation learning at United Arab Emirates University. Read the success story.'], + 30 => ['teaser' => 'Reliable, high-quality, low-latency streams and digital signage tools: the Kansas City Royals improve fan engagement with VITEC’s IPTV platform.'], + 35 => [ + 'teaser' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.', + 'seo_description' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.', + ], + ]; + foreach ($texts as $uid => $values) { + $st = $pdo->prepare("SELECT uid, title, teaser, seo_description FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?"); + $st->execute([$uid]); + $row = $st->fetch(); + if (!$row) { continue; } + echo " #$uid {$row['title']} (erster Absatz der Story: " . $cut($firstPara($uid), 80) . ")\n"; + foreach ($values as $f => $v) { + echo " $f alt: " . $cut($plain($row[$f]), 90) . "\n $f neu: $v\n"; + } + if ($apply) { + $set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($values))); + $pdo->prepare("UPDATE tx_vitec_domain_model_usecase SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($values), $uid]); + } + } +} + +echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n"; +echo $apply ? "\nFERTIG. Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 referenceindex:update\n" : "\n(Trockenlauf, nichts geschrieben)\n";