Files
orascheandClaude Fable 5.1 f7b1eacecb 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>
2026-11-10 18:12:34 +01:00

308 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";