← 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
A scripts/_own-image-filter.mjsM scripts/knoll-onboard/build-payloads.mjsM scripts/maharam-onboard/build-payloads.mjsM scripts/muralsource-onboard/build-payloads.mjsM scripts/rigo-onboard/build-payloads.mjsM scripts/stout-onboard/build-payloads.mjs
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 →