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 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5.1 committed 2026-11-10 18:12:34 +01:00
1 parent 37c4c116d0
commit f7b1eacecb
1 file changed
+307
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
/**
* Duplicate clean-up after the Seobility crawl of 2026-11-10.
*
* A. Product copy "avedia-modular-chassis-c1101-c1103-c1210-1" (empty) -> 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[^>]*>(.*?)<\/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";