Import product photos from live sites; link downloads and SEO meta to products
- vitec:import-product-images: attach staged photos (fileadmin/user_upload/ products/<slug>/, 116 files harvested from vitec.com and datapath.co.uk carousels) to image-less products via FAL/DataHandler; fill-up only. fix_image_reference_fieldname.php repairs the first run's references (TCA field is productimage, not the JSON key images) - vitec:link-product-downloads: match the download records imported on 2026-08-06 to products by title (doc-type/brand/role suffix stripping, alias table for live-site naming deviations, series rules for g45xx/e38xx/ chassis docs); 55 unique-match links added, add-only, idempotent - SEO meta: seo_meta_vitec.json + apply_seo_meta.php fill empty seotitle/ seometa from the live vitec.com product pages (11 products, fill-up only)"
This commit is contained in:
1 parent
4a49196bcc
commit
c91063d6f3
5 files changed
+601
No files matched your search
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Link the imported download records (vitec:import-downloads, 2026-08-06) to
|
||||
* their products via `downloads` (tx_vitec_product_download_mm) by matching
|
||||
* the download title against the product titles.
|
||||
*
|
||||
* Deliberately conservative: a download is linked ONLY when the match is
|
||||
* unique. Matching per download title, in this order:
|
||||
*
|
||||
* 1. title minus a trailing document-type phrase ("... Datasheet",
|
||||
* "... Brochure", "... User Guide", ...) == product title
|
||||
* 2. additionally with a leading brand word (VITEC / Datapath) removed
|
||||
* 3. additionally with a trailing role word (Transmitter / Receiver /
|
||||
* Encoder / Decoder / Card) removed - "Aligo TX100 Transmitter
|
||||
* Datasheet" -> "TX100"
|
||||
*
|
||||
* All comparisons on a normalized form (lowercase, alphanumerics only).
|
||||
* Existing links are never touched, nothing is removed; a download that
|
||||
* matches nothing (or more than one product) is reported for manual review.
|
||||
* Idempotent: already-linked pairs are skipped.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:link-product-downloads --dry-run
|
||||
* vendor/bin/typo3 vitec:link-product-downloads
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:link-product-downloads',
|
||||
description: 'Link download records to products by title match (unique matches only, add-only)'
|
||||
)]
|
||||
final class LinkProductDownloadsCommand extends Command
|
||||
{
|
||||
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
|
||||
private const MM_TABLE = 'tx_vitec_product_download_mm';
|
||||
|
||||
/** Trailing document-type phrases, longest first. */
|
||||
private const DOC_SUFFIXES = [
|
||||
'quick start guide', 'quick reference guide', 'application note',
|
||||
'success story', 'user guide', 'white paper', 'case study',
|
||||
'whats new', "what's new", 'datasheet', 'data sheet', 'brochure',
|
||||
'firmware', 'software', 'manual', 'flyer', 'guide',
|
||||
];
|
||||
|
||||
/** Trailing role words a download title may carry beyond the product name. */
|
||||
private const ROLE_SUFFIXES = [
|
||||
'capture card', 'graphics card', 'encoder card', 'decoder card',
|
||||
'transmitter', 'receiver', 'transcoder', 'end-point', 'end point',
|
||||
'encoder', 'decoder', 'blade', 'card',
|
||||
];
|
||||
|
||||
/**
|
||||
* Known live-site names that differ from OUR (authoritative) dev names -
|
||||
* normalized document name => dev product title. Dev names never change
|
||||
* (decision 2026-09-04), so the bridge lives here.
|
||||
*/
|
||||
private const ALIASES = [
|
||||
'visionscsdi4' => 'VisionSC-SD14',
|
||||
'visioniosdi4' => 'VisionIO-SD14',
|
||||
'visionsdi2' => 'VisionSD12',
|
||||
'prismsff' => 'SFF PRISM',
|
||||
'sffprism' => 'SFF PRISM',
|
||||
'avediaep6' => 'Avedia End-Points',
|
||||
'eztvep6' => 'EZ TV End-Points',
|
||||
'xp2526' => 'X-Player End-Points',
|
||||
'xp2650' => 'X-Player End-Points',
|
||||
];
|
||||
|
||||
/**
|
||||
* Series documents: regex on the lowercased document name (doc-type
|
||||
* suffix already stripped) => dev product title. May match SEVERAL
|
||||
* records of that title (the Avedia Modular Chassis exists twice by
|
||||
* design - encoder branch and RF branch - and gets its datasheets on
|
||||
* both).
|
||||
*/
|
||||
private const SERIES_RULES = [
|
||||
'/^rf gateway g45\d+/' => '45-series RF gateways',
|
||||
'/^encoder e38\d+/' => '38-Series Encoders',
|
||||
'/^chassis c1(101|103|210)$/' => 'Avedia Modular Chassis',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::PRODUCT_TABLE);
|
||||
|
||||
$products = $connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
$productByNorm = [];
|
||||
foreach ($products as $product) {
|
||||
$productByNorm[$this->normalize((string)$product['title'])][] = (int)$product['uid'];
|
||||
}
|
||||
$titleByUid = array_column($products, 'title', 'uid');
|
||||
|
||||
$downloads = $connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
|
||||
$existing = [];
|
||||
foreach ($connection->fetchAllAssociative('SELECT uid_local, uid_foreign FROM ' . self::MM_TABLE) as $row) {
|
||||
$existing[(int)$row['uid_local']][] = (int)$row['uid_foreign'];
|
||||
}
|
||||
|
||||
$additions = []; // product uid => download uids to add
|
||||
$unmatched = [];
|
||||
$ambiguous = [];
|
||||
|
||||
foreach ($downloads as $download) {
|
||||
$downloadUid = (int)$download['uid'];
|
||||
$name = $this->stripDocSuffix((string)$download['title']);
|
||||
if ($name === null) {
|
||||
// No document-type suffix - not a per-product document
|
||||
// (company brochures etc.); leave those to manual linking.
|
||||
$unmatched[] = $download['title'] . ' (kein Dokumenttyp-Suffix)';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aliases and series rules may map to several records on purpose;
|
||||
// generic title matches must stay unique.
|
||||
$multiAllowed = false;
|
||||
$candidates = [];
|
||||
$aliasTitle = self::ALIASES[$this->normalize($name)] ?? null;
|
||||
if ($aliasTitle === null) {
|
||||
foreach (self::SERIES_RULES as $pattern => $title) {
|
||||
if (preg_match($pattern, mb_strtolower($name))) {
|
||||
$aliasTitle = $title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($aliasTitle !== null) {
|
||||
$candidates = $productByNorm[$this->normalize($aliasTitle)] ?? [];
|
||||
$multiAllowed = true;
|
||||
}
|
||||
if ($candidates === []) {
|
||||
$candidates = $this->matchProduct($name, $productByNorm);
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
$unmatched[] = $download['title'];
|
||||
continue;
|
||||
}
|
||||
if (count($candidates) > 1 && !$multiAllowed) {
|
||||
$ambiguous[] = $download['title'];
|
||||
continue;
|
||||
}
|
||||
foreach ($candidates as $productUid) {
|
||||
if (in_array($downloadUid, $existing[$productUid] ?? [], true)) {
|
||||
continue; // already linked
|
||||
}
|
||||
$additions[$productUid][] = $downloadUid;
|
||||
$output->writeln(sprintf('%s "%s" (dl %d) -> %d %s',
|
||||
$dryRun ? 'plan ' : 'link ', $download['title'], $downloadUid, $productUid, $titleByUid[$productUid]));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($unmatched as $title) {
|
||||
$output->writeln('<comment>kein Treffer: ' . $title . '</comment>');
|
||||
}
|
||||
foreach ($ambiguous as $title) {
|
||||
$output->writeln('<comment>MEHRDEUTIG: ' . $title . '</comment>');
|
||||
}
|
||||
|
||||
$pairCount = array_sum(array_map('count', $additions));
|
||||
$output->writeln(sprintf("\n%d neue Verknuepfung(en) auf %d Produkt(e); %d ohne Treffer, %d mehrdeutig.",
|
||||
$pairCount, count($additions), count($unmatched), count($ambiguous)));
|
||||
|
||||
if ($additions === []) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nichts geschrieben.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
foreach ($additions as $productUid => $downloadUids) {
|
||||
$merged = array_merge($existing[$productUid] ?? [], $downloadUids);
|
||||
$datamap[self::PRODUCT_TABLE][$productUid]['downloads'] = implode(',', array_unique($merged));
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Title without its trailing document-type phrase; null when none found. */
|
||||
private function stripDocSuffix(string $title): ?string
|
||||
{
|
||||
$norm = mb_strtolower(trim($title));
|
||||
foreach (self::DOC_SUFFIXES as $suffix) {
|
||||
if (str_ends_with($norm, $suffix)) {
|
||||
return trim(mb_substr(trim($title), 0, mb_strlen(trim($title)) - mb_strlen($suffix)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique product uids matching the document name, applying the passes
|
||||
* described in the class comment.
|
||||
*
|
||||
* @param array<string,int[]> $productByNorm
|
||||
* @return int[]
|
||||
*/
|
||||
private function matchProduct(string $name, array $productByNorm): array
|
||||
{
|
||||
$variants = [$name];
|
||||
// leading brand word
|
||||
$stripped = preg_replace('/^(vitec|datapath)\s+/i', '', $name);
|
||||
if ($stripped !== $name) {
|
||||
$variants[] = $stripped;
|
||||
}
|
||||
// trailing role word (on both variants)
|
||||
foreach ($variants as $variant) {
|
||||
foreach (self::ROLE_SUFFIXES as $role) {
|
||||
if (preg_match('/\s+' . preg_quote($role, '/') . '$/i', $variant)) {
|
||||
$variants[] = trim(preg_replace('/\s+' . preg_quote($role, '/') . '$/i', '', $variant));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($variants as $variant) {
|
||||
$norm = $this->normalize($variant);
|
||||
// aliases also apply to the stripped variants ("VisionSC SDI4
|
||||
// Capture Card" -> "VisionSC SDI4" -> alias -> VisionSC-SD14)
|
||||
$aliasTitle = self::ALIASES[$norm] ?? null;
|
||||
if ($aliasTitle !== null) {
|
||||
$norm = $this->normalize($aliasTitle);
|
||||
}
|
||||
$hit = $productByNorm[$norm] ?? [];
|
||||
if ($hit !== []) {
|
||||
return $hit;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* lowercase, alphanumerics only - "TX1/F" and "tx1 f" compare equal.
|
||||
* "+" survives as "plus", otherwise "MGW Diamond+ OG" and
|
||||
* "MGW Diamond OG" would collide.
|
||||
*/
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user