[object Object]

← back to Designerwallcoverings

TK-11073 Wave3: defensive image-keep filter + muralsource/rigo color-from-color_name

e0cf48e9fd53a287e57404e89c0ecbbcc761eed5 · 2026-09-01 12:04:54 -0700 · Steve Abrams

Add shared _own-image-filter.mjs (keepOwnImages) and wire it into the 4 blind-append onboarders
(knoll/maharam/muralsource/stout) so a future scraper convention-change can't reintroduce the
cross-pattern image-pollution bug — no-op when the vendor doesn't encode SKUs in filenames
(currently-clean vendors unaffected), strips only provably-foreign plates. Also apply the
color:<real color_name> + demote-AI-palette correction to muralsource and rigo tags(). Code-only;
no live product writes (their live catalogs are clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e0cf48e9fd53a287e57404e89c0ecbbcc761eed5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 12:04:54 2026 -0700

    TK-11073 Wave3: defensive image-keep filter + muralsource/rigo color-from-color_name
    
    Add shared _own-image-filter.mjs (keepOwnImages) and wire it into the 4 blind-append onboarders
    (knoll/maharam/muralsource/stout) so a future scraper convention-change can't reintroduce the
    cross-pattern image-pollution bug — no-op when the vendor doesn't encode SKUs in filenames
    (currently-clean vendors unaffected), strips only provably-foreign plates. Also apply the
    color:<real color_name> + demote-AI-palette correction to muralsource and rigo tags(). Code-only;
    no live product writes (their live catalogs are clean).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/_own-image-filter.mjs                  | 56 ++++++++++++++++++++++++++
 scripts/knoll-onboard/build-payloads.mjs       |  9 +++--
 scripts/maharam-onboard/build-payloads.mjs     |  9 +++--
 scripts/muralsource-onboard/build-payloads.mjs | 22 +++++++---
 scripts/rigo-onboard/build-payloads.mjs        |  5 ++-
 scripts/stout-onboard/build-payloads.mjs       |  8 ++--
 6 files changed, 90 insertions(+), 19 deletions(-)

diff --git a/scripts/_own-image-filter.mjs b/scripts/_own-image-filter.mjs
new file mode 100644
index 0000000..90c77f7
--- /dev/null
+++ b/scripts/_own-image-filter.mjs
@@ -0,0 +1,56 @@
+/**
+ * TK-11073 Wave 3 — shared defensive own-pattern/own-SKU image-keep filter.
+ *
+ * Generalizes the TK-11070 Sanderson images() fix (own-SAW-code filename match) so the latent
+ * blind-append image-pollution bug can NEVER go live on a future import for the onboarders that
+ * blindly append a scraper `all_images` array (knoll, maharam, muralsource, stout).
+ *
+ * The blind-append bug: a scraper fills all_images with a ROLLING WINDOW off a shared collection
+ * carousel, so a product's image array picks up ADJACENT-pattern plates. The reliable, offline,
+ * high-precision "belongs to this product" signal is the product's OWN mfr_sku (or its normalized
+ * article code) embedded in the gallery-image filename.
+ *
+ * Behavior (conservative, non-destructive by default):
+ *   - ALWAYS keep the hero (r.image_url) as position 1.
+ *   - For each extra gallery/all_images URL: KEEP it iff its filename embeds the product's own SKU
+ *     code (normalized), OR — when NONE of the extras carry ANY recognizable sku-like code — keep
+ *     them all (the scraper doesn't encode SKUs in filenames for this vendor, so the own-SKU signal
+ *     is unavailable and we must NOT strip blindly). Only when SOME extras DO carry a foreign SKU
+ *     code do we drop the ones that don't match this product's own code.
+ *   This makes the filter a genuine SAFEGUARD: it strips cross-pattern plates when it can prove them
+ *   foreign, and is a no-op when it can't — so it never regresses a currently-clean vendor.
+ */
+
+// normalize a sku-ish token for filename comparison: uppercase, strip non-alphanumerics.
+// e.g. "W1013-7" → "W10137"; "SAW0189_02" → "SAW018902".
+export function skuKey(s) {
+  return String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+}
+
+// does a filename embed a sku-like code? (a letter-run followed by digits, ≥4 alnum). Used to decide
+// whether the vendor encodes SKUs in filenames at all.
+const SKU_LIKE = /[A-Z]{1,4}\s*0*\d{2,5}([-_]\d{1,3})?/i;
+function fileOf(u) { return String(u || '').split('/').pop().split('?')[0]; }
+function hasSkuLike(u) { return SKU_LIKE.test(fileOf(u)); }
+
+/**
+ * @param {string} heroUrl  product's canonical hero image
+ * @param {string[]} extras all_images / gallery URLs (may include the hero, dupes, foreign plates)
+ * @param {string} mfrSku   this product's own manufacturer SKU
+ * @returns {string[]} kept image URLs (hero first, deduped)
+ */
+export function keepOwnImages(heroUrl, extras, mfrSku) {
+  const seen = new Set();
+  const out = [];
+  const push = u => { if (u && !seen.has(u)) { seen.add(u); out.push(u); } };
+  push(heroUrl);
+  const list = (Array.isArray(extras) ? extras : []).filter(Boolean);
+  const own = skuKey(mfrSku);
+  const anyForeignCode = own && list.some(u => hasSkuLike(u) && !skuKey(fileOf(u)).includes(own));
+  for (const u of list) {
+    if (!anyForeignCode) { push(u); continue; }            // no proof of foreign plates → keep all (no-op)
+    // some extras carry a foreign sku code → keep only own-coded ones (drop foreign plates)
+    if (own && skuKey(fileOf(u)).includes(own)) push(u);
+  }
+  return out;
+}
diff --git a/scripts/knoll-onboard/build-payloads.mjs b/scripts/knoll-onboard/build-payloads.mjs
index b0f341f..0a9bd1b 100644
--- a/scripts/knoll-onboard/build-payloads.mjs
+++ b/scripts/knoll-onboard/build-payloads.mjs
@@ -14,6 +14,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 import { specMetafields } from '../_spec-to-metafields.mjs';
+import { keepOwnImages } from '../_own-image-filter.mjs';   // TK-11073 Wave 3 defensive image filter
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -84,10 +85,10 @@ function metafields(r) {
   return mf;
 }
 function galleryUrls(r) {
-  const urls = new Set();
-  if (r.image_url) urls.add(r.image_url);
-  try { for (const u of JSON.parse(r.all_images || '[]')) if (u) urls.add(u); } catch {}
-  return [...urls];
+  // TK-11073 Wave 3: defensive own-SKU image-keep filter guards against the latent blind-append
+  // cross-pattern image-pollution bug (no-op when the vendor doesn't encode SKUs in filenames).
+  let extras = []; try { extras = JSON.parse(r.all_images || '[]'); } catch {}
+  return keepOwnImages(r.image_url, extras, r.mfr_sku);
 }
 
 function main() {
diff --git a/scripts/maharam-onboard/build-payloads.mjs b/scripts/maharam-onboard/build-payloads.mjs
index ebd8abf..30dc724 100644
--- a/scripts/maharam-onboard/build-payloads.mjs
+++ b/scripts/maharam-onboard/build-payloads.mjs
@@ -23,6 +23,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 import { specMetafields } from '../_spec-to-metafields.mjs';
+import { keepOwnImages } from '../_own-image-filter.mjs';   // TK-11073 Wave 3 defensive image filter
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -99,10 +100,10 @@ function metafields(r) {
   return mf;
 }
 function galleryUrls(r) {
-  const urls = new Set();
-  if (r.image_url) urls.add(r.image_url);
-  try { for (const u of JSON.parse(r.all_images || '[]')) if (u) urls.add(u); } catch {}
-  return [...urls];
+  // TK-11073 Wave 3: defensive own-SKU image-keep filter guards against the latent blind-append
+  // cross-pattern image-pollution bug (no-op when the vendor doesn't encode SKUs in filenames).
+  let extras = []; try { extras = JSON.parse(r.all_images || '[]'); } catch {}
+  return keepOwnImages(r.image_url, extras, r.mfr_sku);
 }
 
 function main() {
diff --git a/scripts/muralsource-onboard/build-payloads.mjs b/scripts/muralsource-onboard/build-payloads.mjs
index 30e15b5..8c52f00 100644
--- a/scripts/muralsource-onboard/build-payloads.mjs
+++ b/scripts/muralsource-onboard/build-payloads.mjs
@@ -24,6 +24,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 import { specMetafields } from '../_spec-to-metafields.mjs';
+import { keepOwnImages } from '../_own-image-filter.mjs';   // TK-11073 Wave 3 defensive image filter
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -67,9 +68,19 @@ function tags(r) {
   const add = v => { const s = titleCase(clean(String(v || ''))); if (s && !isBadWord(s) && !/^paper$/i.test(s) && !/wall\s*paper/i.test(s)) out.set(s.toLowerCase(), s); };
   ['Mural Source', 'Wallcovering', 'Mural', 'display_variant'].forEach(t => out.set(t.toLowerCase(), t));
   add(r.pattern_name); add(r.color_name);
-  for (const c of jarr(r.ai_colors)) add(c && c.name);
+  // TK-11073: color: facet from the REAL scraped colorway only; demote AI palette color-words so they
+  // don't emit as stray bare tags (palette hues stay in custom.color_*/color_details metafields).
+  const cw = clean(r.color_name);
+  if (cw && !isBadWord(cw)) out.set(('color:' + titleCase(cw)).toLowerCase(), 'color:' + titleCase(cw));
+  const palette = new Set(jarr(r.ai_colors).map(c => c && c.name).filter(Boolean).map(x => titleCase(clean(x)).toLowerCase()));
+  const realCw = cw ? titleCase(cw).toLowerCase() : null;
   for (const s of jarr(r.ai_styles)) add(s);
-  for (const t of jarr(r.ai_tags)) { const s = String(t || ''); if (s.length <= 24) add(s); }  // drop long material strings
+  for (const t of jarr(r.ai_tags)) {
+    const s = String(t || ''); if (s.length > 24) continue;         // drop long material strings
+    const disp = titleCase(clean(s));
+    if (palette.has(disp.toLowerCase()) && disp.toLowerCase() !== realCw) continue;  // drop AI palette color-word strays
+    add(s);
+  }
   return [...out.values()].join(', ');
 }
 function repeatStr(r) {
@@ -116,10 +127,9 @@ function metafields(r) {
   return mf;
 }
 function galleryUrls(r) {
-  const urls = new Set();
-  if (r.image_url) urls.add(r.image_url);
-  for (const u of jarr(r.all_images)) if (u) urls.add(u);
-  return [...urls];
+  // TK-11073 Wave 3: defensive own-SKU image-keep filter guards against the latent blind-append
+  // cross-pattern image-pollution bug (no-op when the vendor doesn't encode SKUs in filenames).
+  return keepOwnImages(r.image_url, jarr(r.all_images), r.mfr_sku);
 }
 
 function main() {
diff --git a/scripts/rigo-onboard/build-payloads.mjs b/scripts/rigo-onboard/build-payloads.mjs
index bb52a6d..72db866 100644
--- a/scripts/rigo-onboard/build-payloads.mjs
+++ b/scripts/rigo-onboard/build-payloads.mjs
@@ -61,7 +61,10 @@ function tags(r) {
   const w = (r.weight_type || '').match(/Type\s*(I{1,3})/i); if (w) t.add(`Type ${w[1].toUpperCase()}`);
   for (const s of (r.ai_styles || '').split(',')) if (s.trim()) t.add(s.trim());
   for (const p of (r.ai_patterns || '').split(',')) if (p.trim()) t.add(p.trim());
-  for (const c of (r.ai_colors || '').split(',').slice(0, 3)) if (c.trim()) t.add(c.trim());
+  // TK-11073: color: facet from the REAL scraped colorway (color_name), NOT the AI palette.
+  // The AI palette hues (ai_colors) are DEMOTED — no longer bare tags (they stay in metafields).
+  const cw = String(r.color_name || '').trim();
+  if (cw) { t.add('color:' + titleCase(cw)); t.add(titleCase(cw)); }
   return [...t].filter(Boolean).join(', ');
 }
 
diff --git a/scripts/stout-onboard/build-payloads.mjs b/scripts/stout-onboard/build-payloads.mjs
index 06e2353..032a6e1 100644
--- a/scripts/stout-onboard/build-payloads.mjs
+++ b/scripts/stout-onboard/build-payloads.mjs
@@ -27,6 +27,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 import { specMetafields } from '../_spec-to-metafields.mjs';
+import { keepOwnImages } from '../_own-image-filter.mjs';   // TK-11073 Wave 3 defensive image filter
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -133,10 +134,9 @@ function metafields(r) {
   return mf;
 }
 function galleryUrls(r) {
-  const urls = new Set();
-  if (r.image_url) urls.add(r.image_url);
-  for (const u of jarr(r.all_images)) if (u) urls.add(u);
-  return [...urls];
+  // TK-11073 Wave 3: defensive own-SKU image-keep filter guards against the latent blind-append
+  // cross-pattern image-pollution bug (no-op when the vendor doesn't encode SKUs in filenames).
+  return keepOwnImages(r.image_url, jarr(r.all_images), r.mfr_sku);
 }
 
 function main() {

← 07a4911 TK-11073 Wave2: Stout color: facet from real color_name, dro  ·  back to Designerwallcoverings  ·  TK-11073 Wave4: fleetwide CSS-keyword artifact tag cleanup ( 5eebaac →