$c->getName(), $product->getCategories()); foreach ($product->getAttributes() as $attribute) { if (in_array(strtolower($attribute->getName()), ['category', 'source_category', 'google_category'], true)) { $values[] = $attribute->getValue(); } } foreach ($values as $value) { if (preg_match('/^(?:dvd|dvd video|blu[ -]?ray(?: 4k(?: uhd)?)?|vhs|super ?8|movies|films?|film e dvd|movies & tv)$/iu', trim($value))) { return true; } } return false; } private static function hasMusicCategory(ProductDTO $product): bool { $values = array_map(static fn ($c): string => $c->getName(), $product->getCategories()); foreach ($product->getAttributes() as $a) { if (in_array(strtolower($a->getName()), ['category', 'source_category', 'google_category'], true)) { $values[] = $a->getValue(); } } foreach ($values as $value) { if (preg_match('/^(?:cd|cd audio|audio cd|vinile|vinyl|audio cassette|audiocassetta)$/iu', trim($value))) { return true; } } return false; } public function normalize(ProductDTO $p): ProductDTO { // An exact identity-only source must not acquire fields from title heuristics. if (\TFGraphics\SmartImport\Providers\Foundation\RuntimeProviderCatalog::hasCapability((string) $p->getProviderInfo()?->getName(), 'conservative_identity_only')) { return $p; } $a = []; foreach ($p->getAttributes() as $attribute) { $key = strtolower($attribute->getName()); if (!isset($a[$key])) { $a[$key] = $attribute->getValue(); } } $sourceAttributes = $a; $supplement = SupplementContentPolicy::editable($p) && (SupplementContentPolicy::applies($p) || ($a['supplement_merge_evidence'] ?? '') === 'same_barcode_compatible_records'); $title = $p->getName(); $description = $p->getDescription(); $short = $p->getShortDescription(); $seo = $p->getSeo(); if (strtolower((string) $p->getProviderInfo()?->getName()) === 'braveweb') { $title = trim(explode('|', $title)[0]); $title = trim((string) preg_replace('/\s+(?:kopen|acquista|buy)\??\s*$/iu', '', $title)); foreach (['description', 'short'] as $field) { $$field = trim((string) preg_replace('/\s*[-–|]\s*(?:buyer protection|fast shipping|spedizione gratuita).*$/iu', '', (string) $$field)); } if ($title !== $p->getName() || $description !== $p->getDescription()) { $a['source_title'] = $a['source_title'] ?? $p->getName(); $seo = new SeoDTO($title, $description ?: $title, $seo?->getKeywords()); } } $categories = $p->getCategories(); $categoryText = implode(' ', array_map(static fn ($c): string => $c->getName(), $categories)); $categoryText .= ' ' . ($a['category'] ?? '') . ' ' . ($a['google_category'] ?? ''); $evidence = $title . ' ' . $categoryText; $domain = $a['media_type'] ?? $a['resolved_media_type'] ?? 'generic'; if (!empty($a['aic']) || !empty($a['aic_package'])) { $domain = 'medicine'; } elseif (!empty($a['isbn']) || !empty($a['isbn_13'])) { $domain = 'book'; } $authoritativeDomain = in_array($a['classification_authority'] ?? '', ['operator_explicit_domain', 'operator_category_decision', 'identifier_semantics'], true) ? $domain : null; if ($supplement && $authoritativeDomain === null) { if ($categories === [] && empty($a['category'])) { $categories = [new CategoryDTO('Integratori alimentari')]; } if (in_array($domain, ['auto', 'generic', 'unknown'], true)) { $domain = 'food'; } } // A retailer taxonomy describes a shelf, not necessarily this product. // Preserve an already recognised film when its own title confirms the // physical video edition. Hardware in the title still vetoes it. $titleHardware = self::isHardware($title); $explicitVideoTitle = preg_match('/(?:\[(?:dvd|blu[ -]?ray(?: 4k(?: uhd)?)?)\]|\((?:[1-9]\d*\s+)?(?:dvd|blu[ -]?ray|4k(?: uhd)?)\)|\bdvd(?:\s*\[region\s*[0-9]+\])?\s*$|\bblu[ -]?ray(?:\s+(?:4k|uhd))*\s*$)/iu', $title) === 1; $videoEdition = !$titleHardware && ( ($domain === 'movie' && preg_match('/\b(?:dvd|blu[ -]?ray|vhs|videocassetta|super ?8)\b/iu', $title) === 1) || (in_array($domain, ['auto', 'generic', 'unknown'], true) && $explicitVideoTitle) ); if ($videoEdition && $authoritativeDomain === null) { $domain = 'movie'; } if ($videoEdition && $authoritativeDomain === null) { $rejected = []; $categories = array_values(array_filter($categories, static function ($category) use (&$rejected): bool { if (!self::isHardware($category->getName())) { return true; } $rejected[] = $category->getName(); return false; })); foreach (['category', 'google_category', 'source_category'] as $key) { if (isset($a[$key]) && self::isHardware($a[$key])) { $rejected[] = $a[$key]; unset($a[$key]); } } if ($rejected !== []) { $a['source_rejected_hardware_categories'] = implode(' | ', array_unique($rejected)); $a['category_conflict_reason'] = 'video_edition_with_hardware_taxonomy'; if ($seo !== null && self::isHardware((string) $seo->getKeywords())) { $seo = new SeoDTO($seo->getTitle(), $seo->getDescription(), null); } } } $hardware = $titleHardware || (!$videoEdition && self::isHardware($categoryText)); $stationery = preg_match('/\b(?:highlighter|evidenziatore|textmarker|stationery)\b/iu', $evidence) === 1 || (strtolower(trim($a['brand'] ?? '')) === 'stabilo' && preg_match('/\bboss\b/i', $title)); if (($hardware || $stationery) && $authoritativeDomain === null) { $domain = 'generic'; unset($a['sale_format'], $a['sale_format_source']); if ($stationery) { foreach (['nutriscore', 'ingredients', 'allergens', 'labels', 'energy_kj', 'energy_kcal', 'fat', 'saturated_fat', 'carbohydrates', 'sugars', 'protein', 'salt'] as $field) { unset($a[$field]); } $categories = [new CategoryDTO('Cancelleria')]; } } elseif (preg_match('/\b(?:videogames?|video games?|playstation|ps[345]|xbox|nintendo)\b/iu', $evidence)) { $domain = 'game'; } elseif ((in_array($domain, ['auto', 'generic', 'unknown'], true) && self::hasMusicCategory($p)) || preg_match('/\b(?:music cds?|music & sound recordings|audio cd|compact disc)\b/iu', $evidence)) { $domain = 'music'; if (preg_match('/\bcd\b/i', $title)) { $a['format'] = $a['format'] ?? 'CD'; } } elseif (preg_match('/\b(?:sapone|body soap|tělová mýdla|cosmetics|cosmetica)\b/iu', $evidence . ' ' . $description)) { $domain = 'beauty'; } elseif (in_array($domain, ['auto', 'generic', 'unknown'], true) && (preg_match('/(?:\((?:[1-9]\d*\s+)?(?:dvd|blu[ -]?ray|4k(?: uhd)?)\)|\bDVD)\s*$/i', $title) || self::hasVideoCategory($p))) { $domain = 'movie'; } if ($authoritativeDomain !== null) { $domain = $authoritativeDomain; } if ($domain !== ($a['media_type'] ?? 'generic')) { $a['resolved_media_type'] = $domain; $a['domain_evidence_source'] = 'product_title_and_category'; } $a['media_type'] = $domain; if ($domain === 'movie' && ! $hardware) { // The semantic result wins over broad retailer shelves such as // "Software" or a stale jewelry support restored by the UI. $rejectedCategories = []; $normalizedCategories = []; foreach ($categories as $category) { $name = trim($category->getName()); if (self::isIncompatibleVideoCategory($name)) { $rejectedCategories[] = $name; continue; } if (preg_match('/^(?:movies?|films?|dvd video|blu[ -]?ray)$/iu', $name) === 1) { $name = 'Film'; } if ($name !== '') { $normalizedCategories[strtolower($name)] = new CategoryDTO($name); } } $normalizedCategories['film'] = new CategoryDTO('Film'); $categories = array_values($normalizedCategories); foreach (['category', 'google_category', 'source_category'] as $categoryField) { if (! isset($a[$categoryField]) || ! self::isIncompatibleVideoCategory($a[$categoryField])) { continue; } $rejectedCategories[] = $a[$categoryField]; $a['source_' . $categoryField] = $a[$categoryField]; unset($a[$categoryField]); } if ($rejectedCategories !== []) { $a['source_rejected_video_categories'] = implode(' | ', array_values(array_unique($rejectedCategories))); $a['category_conflict_reason'] = 'canonical_movie_overrides_retailer_shelf'; } $a['macro_category'] = 'film_home_video'; foreach (['jewelry_kind', 'metal_purity', 'stone'] as $jewelryField) { unset($a[$jewelryField]); } $formatEvidence = implode(' ', array_filter([ $title, $a['physical_format'] ?? '', $a['format'] ?? '', ])); $saleFormat = self::videoSaleFormat($formatEvidence); if ($saleFormat !== null) { if (empty($a['sale_format'])) { $a['sale_format'] = $saleFormat; } // POST roundtrips do not confer authority through provenance // strings. Re-establish the source only when the physical // text independently confirms the preserved format. if ($a['sale_format'] === $saleFormat && empty($a['sale_format_source'])) { $a['sale_format_source'] = 'physical_identifier_evidence'; } } // Technical edition labels remain available as attributes, but // must never become plot, excerpt, SEO copy or subtitle. if (self::isTechnicalPhysicalText($description)) { $a['source_physical_description'] ??= (string) $description; $a['description_source_type'] = 'physical_edition_metadata'; $description = null; } if (self::isTechnicalPhysicalText($short)) { $a['source_physical_short_description'] = (string) $short; $short = null; } } if ($domain === 'music') { foreach (['catalog_number'=>'catno', 'genres'=>'genre', 'release_date'=>'year'] as $target=>$source) { if (empty($a[$target])) { $values = []; foreach ($p->getAttributes() as $field) { if ($field->getName() === $source && trim($field->getValue()) !== '') { $values[] = $field->getValue(); } } if ($values !== []) { $a[$target] = implode(', ', array_unique($values)); } } } if (empty($a['sale_format']) && preg_match('/\b45\s*(?:RPM|giri)\b/i', $a['format'] ?? '')) { $a['sale_format'] = 'vinyl_45'; } elseif (empty($a['sale_format']) && preg_match('/\b33(?:[.,]1?3?| 1\/3)?\s*(?:RPM|giri)\b/i', $a['format'] ?? '')) { $a['sale_format'] = 'vinyl_33'; } } if ($categories === [] && trim($a['category'] ?? '') !== '') { $categories = [new CategoryDTO($a['category'])]; } $categoryMap = [ 'Apparel & Accessories > Shoes' => 'Abbigliamento e accessori > Scarpe', 'Media > Music & Sound Recordings > Music CDs' => 'Musica > CD', 'Zdraví a krása > Osobní péče > Kosmetika > Koupele a péče o tělo > Tělová mýdla' => 'Bellezza e cura personale > Saponi per il corpo', ]; $categories = array_map(static fn ($c) => isset($categoryMap[$c->getName()]) ? new CategoryDTO($categoryMap[$c->getName()]) : $c, $categories); if (preg_match('/^org-/i', $a['manufacturer'] ?? '')) { unset($a['manufacturer']); } if (trim($a['package_quantity'] ?? '') === '0') { unset($a['package_quantity']); } if (strtolower($a['packaging'] ?? '') === 'bocal') { $a['packaging'] = 'Vasetto'; } if (isset($a['labels'])) { $labels = preg_split('/\s*[,;]\s*/u', $a['labels']) ?: []; $map = ['vegetarian' => 'Vegetariano', 'végétarien' => 'Vegetariano', 'no-gluten' => 'Senza glutine', 'gluten-free' => 'Senza glutine']; $a['labels'] = implode(', ', array_unique(array_map(static fn ($v) => $map[function_exists('mb_strtolower') ? mb_strtolower($v) : strtolower($v)] ?? $v, $labels))); } if ($supplement) { if (preg_match('/^(?:unknown|n\/a|n\/d|null|undefined)$/i', trim($a['nutriscore'] ?? ''))) { $a['source_nutriscore'] ??= $a['nutriscore']; unset($a['nutriscore']); } $minsan = trim($a['minsan'] ?? ''); if ($minsan === '' && preg_match('/\bMINSAN\s*[:#-]?\s*(\d{9})\b/i', (string) $description, $m)) { $minsan = $m[1]; } if (preg_match('/^\d{9}$/', $minsan)) { $a['minsan'] = $minsan; foreach (['model', 'mpn'] as $field) { if (trim($a[$field] ?? '') === $minsan) { $a['source_' . $field] ??= $a[$field]; unset($a[$field]); } } } $notes = []; if ($minsan === '' && preg_match('/^\d{9}$/', trim($a['mpn'] ?? $a['model'] ?? ''))) { $notes[] = 'Codice a nove cifre da verificare: la fonte non ne dichiara il tipo.'; if (isset($a['model'], $a['mpn']) && $a['model'] === $a['mpn']) { $a['source_model'] ??= $a['model']; unset($a['model']); } } if (SupplementContentPolicy::truncated($description)) { $notes[] = 'Descrizione incompleta nella fonte: verificare il testo integrale.'; } if (SupplementContentPolicy::medicineClaim($description) || SupplementContentPolicy::medicineClaim($short)) { $notes[] = 'Conflitto: una descrizione definisce medicinale un prodotto identificato come integratore.'; if (SupplementContentPolicy::medicineClaim($description)) { $a['source_conflicting_description'] = (string) $description; $description = null; } if (SupplementContentPolicy::medicineClaim($short)) { $a['source_conflicting_short_description'] = (string) $short; $short = null; } } if (isset($a['source_supplement_candidates']) && SupplementContentPolicy::medicineClaim($a['source_supplement_candidates'])) { $notes[] = 'Una fonte contiene una descrizione medicinale incompatibile, esclusa dalla selezione automatica.'; } foreach ($notes as $note) { if (!str_contains($a['content_review_notes'] ?? '', $note)) { $a['content_review_notes'] = trim(($a['content_review_notes'] ?? '') . ' ' . $note); } } } if (!$supplement && empty($a['model']) && !empty($a['mpn'])) { $a['model'] = $a['mpn']; } if (empty($a['model']) && preg_match('/\b[A-Z]{1,5}\d{1,4}-[A-Z0-9]{3,}\b/', $title, $m)) { $a['model'] = $m[0]; } if (preg_match('/\btoner\b/i', $title)) { if (empty($a['mpn']) && preg_match('/\((\d{6,12})\)/', $title, $m)) { $a['mpn'] = $m[1]; } if (empty($a['color']) && preg_match('/\b(cyan|magenta|yellow|black)\b/i', $title, $m)) { $a['color'] = ucfirst(strtolower($m[1])); } if (empty($a['specifications']) && preg_match('/\b([\d.]+)\s+(?:Seiten|pages|pagine)\b/i', $title, $m)) { $a['specifications'] = 'Resa dichiarata: ' . $m[1] . ' pagine'; } } if (empty($a['size']) && preg_match('/\b\d{2}(?:[.,]\d)?\s*EU(?:\s*\/\s*\d+(?:[.,]\d)?\s*[A-Z]?\s*US)?\b/i', $title, $m)) { $a['size'] = $m[0]; } if (!$supplement && empty($a['model']) && !empty($a['mpn'])) { $a['model'] = $a['mpn']; } if (empty($a['brand']) && !empty($a['model']) && preg_match('/^([A-Z][a-zA-Z]+)\s+' . preg_quote($a['model'], '/') . '\b/', $title, $m)) { $a['brand'] = $m[1]; } if (str_contains(strtolower($categoryText), 'shoes') && empty($a['color']) && preg_match('/,\s*(Coffee|Black|Brown|White)\s*,/i', $title, $m)) { $a['color'] = $m[1]; } if (strtolower((string) $p->getProviderInfo()?->getName()) === 'braveweb' && !empty($a['brand'])) { $brandPattern = preg_quote((string) $a['brand'], '/'); foreach (['description', 'short'] as $field) { $original = (string) $$field; $$field = preg_replace('/^(' . $brandPattern . ')\s+' . $brandPattern . '(?=\s)/iu', '$1', $original); if ($$field !== $original) { $a['source_' . $field] ??= $original; } } } if ($domain === 'jewelry') { $notes = []; if (preg_match('/^\s*collana\b/iu', $title)) { $a['jewelry_kind'] = 'necklace'; if ($categories === []) { $categories[] = new CategoryDTO('Collane'); } } if (preg_match('/\bacciaio\b/iu', $title) && preg_match('/^metallo$/iu', $a['material'] ?? '')) { $notes[] = 'Materiale da verificare: il titolo indica acciaio, il campo materiale indica metallo.'; } if (preg_match('/smalto\s+bianco/iu', (string) $description) && preg_match('/turchese/iu', $a['color'] ?? '')) { $notes[] = 'Colore da verificare: la descrizione indica smalto bianco, il campo colore indica turchese.'; } if (preg_match('/Certificazione:\s*([^,|]*\b\d+\s*(?:mesi|months)\b)/iu', $a['specifications'] ?? '', $m)) { $a['source_certification'] = $m[1]; $a['specifications'] = str_replace($m[0], 'Durata dichiarata da verificare: ' . $m[1], $a['specifications']); $notes[] = 'La durata in mesi non prova una certificazione: verificare se si tratta della garanzia.'; } if ($notes !== []) { $a['content_review_notes'] = implode(' ', $notes); } } $cleaner = new ProductDescriptionCleaner(); $cleanDescription = $cleaner->clean($description, $title); $cleanShort = $cleaner->clean($short, $title); if ($cleanDescription !== $description || $cleanShort !== $short) { $a['description_cleanup_status'] = 'cleaned'; } $description = $cleanDescription; $short = $cleanShort; $seo = new SeoDTO( $cleaner->clean($supplement ? $title : ($seo?->getTitle() ?? $title)) ?: $title, $cleaner->clean($supplement ? (SupplementContentPolicy::completeExcerpt($description) ?: SupplementContentPolicy::completeExcerpt($short) ?: $title) : ($description ?: ($short ?: $title))) ?: $title, $supplement ? implode(', ', array_filter(preg_split('/[,;|]+/u', (string) $seo?->getKeywords()) ?: [], static fn (string $v): bool => trim($v) !== '' && !preg_match('/^(?:unknown|n\/a|n\/d|null|undefined)$/i', trim($v)))) : $seo?->getKeywords() ); if ($supplement && $p->getImages() !== [] && !isset($a['selected_image_urls']) && !isset($a['featured_image']) && !isset($a['image_selection_confirmed'])) { // Initial acquisition only: match the cover to the featured-first // image order produced by the merger. Existing selections win. $a['cover_url'] = $p->getImages()[0]->getUrl(); } $changedSources = []; foreach ($sourceAttributes as $key => $value) { if ($key !== 'source_normalization_evidence' && (!isset($a[$key]) || $a[$key] !== $value)) { $changedSources[$key] = $value; } } if ($changedSources !== [] && !isset($a['source_normalization_evidence'])) { $a['source_normalization_evidence'] = (string) json_encode($changedSources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $attributes = []; foreach ($a as $key => $value) { $attributes[] = new AttributeDTO($key, (string) $value); } // Preserve multi-valued provider fields (artists, labels, categories). $seen = []; foreach ($p->getAttributes() as $attribute) { $key = strtolower($attribute->getName()); if (isset($seen[$key]) && isset($a[$key]) && !in_array($key, ['media_type', 'resolved_media_type', 'labels'], true)) { $attributes[] = $attribute; } $seen[$key] = true; } return (new ProductImageCanonicalizer())->normalize(new ProductDTO($p->getId(), $title, $description, $short, $p->getPrice(), $p->getInventory(), $categories, $p->getImages(), $attributes, $seo, $p->getProviderInfo(), $p->getSku(), $p->getBarcode())); } private static function isIncompatibleVideoCategory(string $category): bool { $category = trim($category); if ($category === '') { return false; } return self::isHardware($category) || preg_match('/^(?:software|jewelry|jewellery|gioielli|abbigliamento|clothing|apparel|electronics?|elettronica)$/iu', $category) === 1; } private static function videoSaleFormat(string $evidence): ?string { if (preg_match('/\b(?:4k\s*(?:uhd)?|uhd\s*4k|ultra\s*hd)\b/iu', $evidence) === 1) { return 'uhd_4k'; } if (preg_match('/\bblu[ -]?ray\b/iu', $evidence) === 1) { return 'blu_ray'; } if (preg_match('/\bdvd(?:-standard)?\b/iu', $evidence) === 1) { return 'dvd'; } if (preg_match('/\bvhs\b/iu', $evidence) === 1) { return 'vhs'; } if (preg_match('/\bsuper[ -]?8\b/iu', $evidence) === 1) { return 'super8'; } return null; } private static function isTechnicalPhysicalText(?string $value): bool { $value = trim((string) $value); if ($value === '') { return false; } $labels = preg_match_all('/\b(?:Label|Format|Region|Release Date|Video Format|No\.? of Discs?|UPC)\s*:/iu', $value); return $labels >= 2 || preg_match('/^ATTENTION:\s*REQUIRES\s+(?:NTSC|PAL)\s+REGION\b/iu', $value) === 1; } }