store-scrapper-js-common 1.0.222 → 1.0.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/eval-filter.d.ts +1 -0
- package/dist/utils/eval-filter.js +43 -11
- package/dist/utils/eval-filter.js.map +1 -1
- package/dist/utils/eval-filter.spec.js +50 -0
- package/dist/utils/eval-filter.spec.js.map +1 -1
- package/docs/superpowers/plans/2026-06-19-word-matching-v2.md +382 -0
- package/docs/superpowers/specs/2026-06-19-word-matching-v2-design.md +68 -0
- package/package.json +1 -1
- package/src/utils/eval-filter.spec.ts +57 -1
- package/src/utils/eval-filter.ts +51 -9
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeText = normalizeText;
|
|
3
4
|
exports.evalFilter = evalFilter;
|
|
4
5
|
const price_utils_1 = require("./price-utils");
|
|
5
6
|
function cleanSet(arr, lower) {
|
|
@@ -16,19 +17,44 @@ function cleanSet(arr, lower) {
|
|
|
16
17
|
}
|
|
17
18
|
return out.length > 0 ? out : null;
|
|
18
19
|
}
|
|
20
|
+
function normalizeText(s) {
|
|
21
|
+
return s
|
|
22
|
+
.normalize('NFD')
|
|
23
|
+
.replace(/[̀-ͯ]/g, '')
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
function cleanNormalizedSet(arr) {
|
|
29
|
+
if (!Array.isArray(arr))
|
|
30
|
+
return null;
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const v of arr) {
|
|
33
|
+
if (typeof v !== 'string')
|
|
34
|
+
continue;
|
|
35
|
+
const t = normalizeText(v);
|
|
36
|
+
if (t.length === 0)
|
|
37
|
+
continue;
|
|
38
|
+
out.push(t);
|
|
39
|
+
}
|
|
40
|
+
return out.length > 0 ? out : null;
|
|
41
|
+
}
|
|
42
|
+
function tokenize(normalized) {
|
|
43
|
+
return normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
|
44
|
+
}
|
|
19
45
|
const finite = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
20
46
|
function evalFilter(product, filter) {
|
|
21
|
-
var _a, _b, _c, _d
|
|
47
|
+
var _a, _b, _c, _d;
|
|
22
48
|
const categories = cleanSet(filter.categories, true);
|
|
23
49
|
const brands = cleanSet(filter.brands, true);
|
|
24
50
|
const stores = cleanSet(filter.stores, false);
|
|
25
|
-
const sellers =
|
|
26
|
-
const words =
|
|
51
|
+
const sellers = cleanNormalizedSet(filter.sellers);
|
|
52
|
+
const words = cleanNormalizedSet(filter.words);
|
|
27
53
|
const minDiscountPct = finite(filter.minDiscountPct) && filter.minDiscountPct > 0 ? filter.minDiscountPct : null;
|
|
28
54
|
const minPrice = finite(filter.minPrice) ? filter.minPrice : null;
|
|
29
55
|
const maxPrice = finite(filter.maxPrice) ? filter.maxPrice : null;
|
|
30
|
-
const excludeWords =
|
|
31
|
-
const excludeSellers =
|
|
56
|
+
const excludeWords = cleanNormalizedSet(filter.excludeWords);
|
|
57
|
+
const excludeSellers = cleanNormalizedSet(filter.excludeSellers);
|
|
32
58
|
const excludeMarketplace = filter.excludeMarketplace === true;
|
|
33
59
|
if (!categories && !brands && !stores && !sellers && !words
|
|
34
60
|
&& minDiscountPct === null && minPrice === null && maxPrice === null) {
|
|
@@ -63,24 +89,30 @@ function evalFilter(product, filter) {
|
|
|
63
89
|
return false;
|
|
64
90
|
}
|
|
65
91
|
if (sellers) {
|
|
66
|
-
const s =
|
|
92
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
67
93
|
if (!s || !sellers.includes(s))
|
|
68
94
|
return false;
|
|
69
95
|
}
|
|
70
96
|
if (words) {
|
|
71
|
-
const name = (
|
|
97
|
+
const name = normalizeText((_c = product.name) !== null && _c !== void 0 ? _c : '');
|
|
72
98
|
if (!name || !words.some((w) => name.includes(w)))
|
|
73
99
|
return false;
|
|
74
100
|
}
|
|
75
101
|
if (excludeMarketplace && product.isMarketplace === true)
|
|
76
102
|
return false;
|
|
77
103
|
if (excludeWords) {
|
|
78
|
-
const name = (
|
|
79
|
-
if (name
|
|
80
|
-
|
|
104
|
+
const name = normalizeText((_d = product.name) !== null && _d !== void 0 ? _d : '');
|
|
105
|
+
if (name) {
|
|
106
|
+
const tokens = new Set(tokenize(name));
|
|
107
|
+
for (const e of excludeWords) {
|
|
108
|
+
const hit = /[^a-z0-9]/.test(e) ? name.includes(e) : tokens.has(e);
|
|
109
|
+
if (hit)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
81
113
|
}
|
|
82
114
|
if (excludeSellers) {
|
|
83
|
-
const s =
|
|
115
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
84
116
|
if (s && excludeSellers.includes(s))
|
|
85
117
|
return false;
|
|
86
118
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"eval-filter.js","sourceRoot":"/","sources":["utils/eval-filter.ts"],"names":[],"mappings":"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","sourcesContent":["import { Product } from '../entities/product';\nimport { getMinPrice } from './price-utils';\n\n/**\n * User-defined filter for an alert channel.\n * POSITIVE fields are AND-ed; an absent (or empty/blank) field is ignored.\n * EXCLUSION fields only narrow (they reject matches) and do NOT count toward the\n * safety floor. A filter with NO positive field returns false (anti-spam floor) —\n * so an exclusion-only filter (e.g. just `excludeMarketplace`) matches nothing,\n * never \"everything except X\".\n */\nexport type ChannelFilter = {\n // ── Positive (inclusion) — AND-ed; at least one is required to match anything ──\n /** Match if any of the product's AI-normalized `inferredCategories` is any of these (case-insensitive). */\n categories?: string[];\n /** Match if the product's brandName is any of these (case-insensitive). */\n brands?: string[];\n /** Match if the product's storeRef is any of these (exact). */\n stores?: string[];\n /** Match if the product's sellerName is any of these (case-insensitive exact). */\n sellers?: string[];\n /** Match if product.priceStats.currentPricePrevPriceDiffPct >= this (>0 to be active). */\n minDiscountPct?: number;\n /** Match if the product's current min price >= this value. */\n minPrice?: number;\n /** Match if the product's current min price <= this value. */\n maxPrice?: number;\n /** Match if any of these words appears (case-insensitive substring) in product.name. */\n words?: string[];\n\n // ── Exclusion (negative) — reject a match; do NOT satisfy the safety floor ──\n /** Skip if any of these words appears (case-insensitive substring) in product.name. */\n excludeWords?: string[];\n /** Skip if the product's sellerName is any of these (case-insensitive exact). */\n excludeSellers?: string[];\n /** Skip if the product is a marketplace listing (product.isMarketplace === true). */\n excludeMarketplace?: boolean;\n};\n\n/** Trimmed, non-empty string elements (optionally lowercased), or null if none. */\nfunction cleanSet(arr: string[] | undefined, lower: boolean): string[] | null {\n if (!Array.isArray(arr)) return null;\n const out: string[] = [];\n for (const v of arr) {\n if (typeof v !== 'string') continue;\n const t = v.trim();\n if (t.length === 0) continue;\n out.push(lower ? t.toLowerCase() : t);\n }\n return out.length > 0 ? out : null;\n}\n\nconst finite = (n: number | undefined): n is number => typeof n === 'number' && Number.isFinite(n);\n\n/**\n * Pure, allocation-light predicate: true when the product matches every effective\n * field of the filter. Defensive against untrusted JSON filters (null/empty arrays,\n * null elements, blank words, non-numeric bounds are all ignored, never throw).\n *\n * Returns false when the filter has no effective fields (safety floor).\n *\n * NB: price bounds use getMinPrice(product.price), which (by existing lib behavior)\n * does not treat a validated offerPrice of 0 (isFree) as the min — free products are\n * not reliably caught by maxPrice:0.\n */\nexport function evalFilter(product: Product, filter: ChannelFilter): boolean {\n const categories = cleanSet(filter.categories, true);\n const brands = cleanSet(filter.brands, true);\n const stores = cleanSet(filter.stores, false); // storeRef is an id — exact, no lowercase\n const sellers = cleanSet(filter.sellers, true);\n const words = cleanSet(filter.words, true);\n // minDiscountPct of 0 (or negative/NaN) is \"no floor\" => inactive\n const minDiscountPct = finite(filter.minDiscountPct) && filter.minDiscountPct > 0 ? filter.minDiscountPct : null;\n const minPrice = finite(filter.minPrice) ? filter.minPrice : null;\n const maxPrice = finite(filter.maxPrice) ? filter.maxPrice : null;\n\n // Exclusion fields — only narrow; they do NOT satisfy the safety floor below.\n const excludeWords = cleanSet(filter.excludeWords, true);\n const excludeSellers = cleanSet(filter.excludeSellers, true);\n const excludeMarketplace = filter.excludeMarketplace === true;\n\n // Safety floor (anti-spam): require at least one POSITIVE field. Exclusion-only\n // filters (excludeWords/excludeSellers/excludeMarketplace) match nothing — never\n // \"everything except X\".\n if (!categories && !brands && !stores && !sellers && !words\n && minDiscountPct === null && minPrice === null && maxPrice === null) {\n return false;\n }\n\n if (categories) {\n // Match against the AI-normalized `inferredCategories` (the mapped keys the\n // UI offers), NOT the raw per-store `product.category`. Any-of: the product\n // matches if any of its inferred categories is in the filter's set.\n const productCats = cleanSet(product.inferredCategories, true);\n if (!productCats || !productCats.some((pc) => categories.includes(pc))) return false;\n }\n\n if (brands) {\n const b = product.brandName?.toLowerCase();\n if (!b || !brands.includes(b)) return false;\n }\n\n if (stores) {\n if (!product.storeRef || !stores.includes(product.storeRef)) return false;\n }\n\n if (minDiscountPct !== null) {\n const pct = product.priceStats?.currentPricePrevPriceDiffPct;\n if (pct === undefined || pct === null || pct < minDiscountPct) return false;\n }\n\n if (minPrice !== null || maxPrice !== null) {\n const currentPrice = getMinPrice(product.price);\n if (currentPrice === null || currentPrice === undefined) return false;\n if (minPrice !== null && currentPrice < minPrice) return false;\n if (maxPrice !== null && currentPrice > maxPrice) return false;\n }\n\n if (sellers) {\n const s = product.sellerName?.toLowerCase();\n if (!s || !sellers.includes(s)) return false;\n }\n\n if (words) {\n const name = product.name?.toLowerCase();\n if (!name || !words.some((w) => name.includes(w))) return false;\n }\n\n // ── Exclusions (applied after the positive AND passes; any hit rejects) ──\n if (excludeMarketplace && product.isMarketplace === true) return false;\n\n if (excludeWords) {\n const name = product.name?.toLowerCase();\n if (name && excludeWords.some((w) => name.includes(w))) return false;\n }\n\n if (excludeSellers) {\n const s = product.sellerName?.toLowerCase();\n if (s && excludeSellers.includes(s)) return false;\n }\n\n return true;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"eval-filter.js","sourceRoot":"/","sources":["utils/eval-filter.ts"],"names":[],"mappings":"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","sourcesContent":["import { Product } from '../entities/product';\nimport { getMinPrice } from './price-utils';\n\n/**\n * User-defined filter for an alert channel.\n * POSITIVE fields are AND-ed; an absent (or empty/blank) field is ignored.\n * EXCLUSION fields only narrow (they reject matches) and do NOT count toward the\n * safety floor. A filter with NO positive field returns false (anti-spam floor) —\n * so an exclusion-only filter (e.g. just `excludeMarketplace`) matches nothing,\n * never \"everything except X\".\n */\nexport type ChannelFilter = {\n // ── Positive (inclusion) — AND-ed; at least one is required to match anything ──\n /** Match if any of the product's AI-normalized `inferredCategories` is any of these (case-insensitive). */\n categories?: string[];\n /** Match if the product's brandName is any of these (case-insensitive). */\n brands?: string[];\n /** Match if the product's storeRef is any of these (exact). */\n stores?: string[];\n /** Match if the product's sellerName is any of these (case-insensitive exact). */\n sellers?: string[];\n /** Match if product.priceStats.currentPricePrevPriceDiffPct >= this (>0 to be active). */\n minDiscountPct?: number;\n /** Match if the product's current min price >= this value. */\n minPrice?: number;\n /** Match if the product's current min price <= this value. */\n maxPrice?: number;\n /** Match if any of these words appears (case-insensitive substring) in product.name. */\n words?: string[];\n\n // ── Exclusion (negative) — reject a match; do NOT satisfy the safety floor ──\n /** Skip if any of these words appears (case-insensitive substring) in product.name. */\n excludeWords?: string[];\n /** Skip if the product's sellerName is any of these (case-insensitive exact). */\n excludeSellers?: string[];\n /** Skip if the product is a marketplace listing (product.isMarketplace === true). */\n excludeMarketplace?: boolean;\n};\n\n/** Trimmed, non-empty string elements (optionally lowercased), or null if none. */\nfunction cleanSet(arr: string[] | undefined, lower: boolean): string[] | null {\n if (!Array.isArray(arr)) return null;\n const out: string[] = [];\n for (const v of arr) {\n if (typeof v !== 'string') continue;\n const t = v.trim();\n if (t.length === 0) continue;\n out.push(lower ? t.toLowerCase() : t);\n }\n return out.length > 0 ? out : null;\n}\n\n/**\n * Normalize free text for matching: NFD-decompose + strip combining marks\n * (á→a, é→e, ñ→n, …), lowercase, collapse whitespace, trim. Deterministic.\n */\nexport function normalizeText(s: string): string {\n return s\n .normalize('NFD')\n .replace(/[̀-ͯ]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/** Like cleanSet but normalizeText each element (drops blanks); null if none. */\nfunction cleanNormalizedSet(arr: string[] | undefined): string[] | null {\n if (!Array.isArray(arr)) return null;\n const out: string[] = [];\n for (const v of arr) {\n if (typeof v !== 'string') continue;\n const t = normalizeText(v);\n if (t.length === 0) continue;\n out.push(t);\n }\n return out.length > 0 ? out : null;\n}\n\n/** Split normalized text into alphanumeric tokens (whole-word matching). */\nfunction tokenize(normalized: string): string[] {\n return normalized.split(/[^a-z0-9]+/).filter(Boolean);\n}\n\nconst finite = (n: number | undefined): n is number => typeof n === 'number' && Number.isFinite(n);\n\n/**\n * Pure, allocation-light predicate: true when the product matches every effective\n * field of the filter. Defensive against untrusted JSON filters (null/empty arrays,\n * null elements, blank words, non-numeric bounds are all ignored, never throw).\n *\n * Returns false when the filter has no effective fields (safety floor).\n *\n * NB: price bounds use getMinPrice(product.price), which (by existing lib behavior)\n * does not treat a validated offerPrice of 0 (isFree) as the min — free products are\n * not reliably caught by maxPrice:0.\n */\nexport function evalFilter(product: Product, filter: ChannelFilter): boolean {\n const categories = cleanSet(filter.categories, true);\n const brands = cleanSet(filter.brands, true);\n const stores = cleanSet(filter.stores, false); // storeRef is an id — exact, no lowercase\n const sellers = cleanNormalizedSet(filter.sellers);\n const words = cleanNormalizedSet(filter.words);\n // minDiscountPct of 0 (or negative/NaN) is \"no floor\" => inactive\n const minDiscountPct = finite(filter.minDiscountPct) && filter.minDiscountPct > 0 ? filter.minDiscountPct : null;\n const minPrice = finite(filter.minPrice) ? filter.minPrice : null;\n const maxPrice = finite(filter.maxPrice) ? filter.maxPrice : null;\n\n // Exclusion fields — only narrow; they do NOT satisfy the safety floor below.\n const excludeWords = cleanNormalizedSet(filter.excludeWords);\n const excludeSellers = cleanNormalizedSet(filter.excludeSellers);\n const excludeMarketplace = filter.excludeMarketplace === true;\n\n // Safety floor (anti-spam): require at least one POSITIVE field. Exclusion-only\n // filters (excludeWords/excludeSellers/excludeMarketplace) match nothing — never\n // \"everything except X\".\n if (!categories && !brands && !stores && !sellers && !words\n && minDiscountPct === null && minPrice === null && maxPrice === null) {\n return false;\n }\n\n if (categories) {\n // Match against the AI-normalized `inferredCategories` (the mapped keys the\n // UI offers), NOT the raw per-store `product.category`. Any-of: the product\n // matches if any of its inferred categories is in the filter's set.\n const productCats = cleanSet(product.inferredCategories, true);\n if (!productCats || !productCats.some((pc) => categories.includes(pc))) return false;\n }\n\n if (brands) {\n const b = product.brandName?.toLowerCase();\n if (!b || !brands.includes(b)) return false;\n }\n\n if (stores) {\n if (!product.storeRef || !stores.includes(product.storeRef)) return false;\n }\n\n if (minDiscountPct !== null) {\n const pct = product.priceStats?.currentPricePrevPriceDiffPct;\n if (pct === undefined || pct === null || pct < minDiscountPct) return false;\n }\n\n if (minPrice !== null || maxPrice !== null) {\n const currentPrice = getMinPrice(product.price);\n if (currentPrice === null || currentPrice === undefined) return false;\n if (minPrice !== null && currentPrice < minPrice) return false;\n if (maxPrice !== null && currentPrice > maxPrice) return false;\n }\n\n if (sellers) {\n const s = product.sellerName ? normalizeText(product.sellerName) : '';\n if (!s || !sellers.includes(s)) return false;\n }\n\n if (words) {\n const name = normalizeText(product.name ?? '');\n if (!name || !words.some((w) => name.includes(w))) return false;\n }\n\n // ── Exclusions (applied after the positive AND passes; any hit rejects) ──\n if (excludeMarketplace && product.isMarketplace === true) return false;\n\n if (excludeWords) {\n const name = normalizeText(product.name ?? '');\n if (name) {\n const tokens = new Set(tokenize(name));\n for (const e of excludeWords) {\n // Whole-word match for single alphanumeric tokens (so \"kit\" doesn't drop\n // \"KitchenAid\"). Any entry carrying a non-alphanumeric char — a space\n // (\"play station\") OR a hyphen/punctuation (\"play-station\") — can never be\n // a single token, so fall through to substring matching instead of a token\n // lookup that would always miss.\n const hit = /[^a-z0-9]/.test(e) ? name.includes(e) : tokens.has(e);\n if (hit) return false;\n }\n }\n }\n\n if (excludeSellers) {\n const s = product.sellerName ? normalizeText(product.sellerName) : '';\n if (s && excludeSellers.includes(s)) return false;\n }\n\n return true;\n}\n"]}
|
|
@@ -240,4 +240,54 @@ describe('evalFilter — robustness over untrusted JSON filters', () => {
|
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
|
+
describe('normalizeText', () => {
|
|
244
|
+
it('strips diacritics incl. ñ→n and lowercases', () => {
|
|
245
|
+
expect((0, eval_filter_1.normalizeText)('Niño')).toBe('nino');
|
|
246
|
+
expect((0, eval_filter_1.normalizeText)('Colchón')).toBe('colchon');
|
|
247
|
+
expect((0, eval_filter_1.normalizeText)('CAFÉ')).toBe('cafe');
|
|
248
|
+
});
|
|
249
|
+
it('collapses whitespace and trims', () => {
|
|
250
|
+
expect((0, eval_filter_1.normalizeText)(' play station ')).toBe('play station');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('words (normalized substring)', () => {
|
|
254
|
+
it('matches accent-insensitively both directions', () => {
|
|
255
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'Colchón King' }), { words: ['colchon'] })).toBe(true);
|
|
256
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'Colchon King' }), { words: ['colchón'] })).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
it('still matches substrings (lenient)', () => {
|
|
259
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct(), { words: ['galaxy'] })).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
it('misses when no word is a substring', () => {
|
|
262
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct(), { words: ['refrigerador'] })).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe('excludeWords (whole-word + phrase)', () => {
|
|
266
|
+
it('does NOT over-exclude on substring ("kit" keeps "KitchenAid")', () => {
|
|
267
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'KitchenAid Batidora' }), { words: ['batidora'], excludeWords: ['kit'] })).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
it('rejects a standalone whole word', () => {
|
|
270
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'Kit de limpieza' }), { words: ['limpieza'], excludeWords: ['kit'] })).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
it('is accent-insensitive', () => {
|
|
273
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'iPhone 13 Reacondicionado' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
it('multi-word entry matches as a phrase substring', () => {
|
|
276
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'Consola Play Station 5' }), { words: ['consola'], excludeWords: ['play station'] })).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
it('hyphenated entry matches as a substring (falls through to substring, not a token lookup)', () => {
|
|
279
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'Consola Play-Station 5' }), { words: ['consola'], excludeWords: ['play-station'] })).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
it('passes when no excluded word/phrase present', () => {
|
|
282
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ name: 'iPhone 13 Nuevo' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
describe('sellers/excludeSellers (normalized exact)', () => {
|
|
286
|
+
it('sellers matches accent-insensitively', () => {
|
|
287
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ sellerName: 'Niñas Store' }), { sellers: ['ninas store'] })).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
it('excludeSellers rejects accent-insensitively', () => {
|
|
290
|
+
expect((0, eval_filter_1.evalFilter)(makeProduct({ brandName: 'Samsung', sellerName: 'Niñas Store' }), { brands: ['Samsung'], excludeSellers: ['ninas store'] })).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
243
293
|
//# sourceMappingURL=eval-filter.spec.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"eval-filter.spec.js","sourceRoot":"/","sources":["utils/eval-filter.spec.ts"],"names":[],"mappings":"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","sourcesContent":["import { Product } from '../entities/product';\nimport { Price } from '../entities/price';\nimport { PricesStats } from '../entities/prices-stats';\nimport { evalFilter, ChannelFilter } from './eval-filter';\n\n// ---------------------------------------------------------------------------\n// Minimal Product fixture (only fields used by evalFilter)\n// ---------------------------------------------------------------------------\nfunction makeProduct(overrides: Partial<Product> = {}): Product {\n const price = {\n productRef: 'prod-1',\n offerPrice: 50000,\n normalPrice: 80000,\n } as Price;\n\n const priceStats = {\n minPriceSum: 0,\n maxPriceSum: 0,\n avgPriceSum: 0,\n pricesCount: 1,\n avgMinPrice: 0,\n avgAvgPrice: 0,\n avgMaxPrice: 0,\n currentMinPrice: 50000,\n currentMaxPrice: 80000,\n currentPriceAvgMinPriceDiffPct: 0,\n currentPriceBestPriceDiffPct: 0,\n currentPricePrevPriceDiffPct: 37.5, // 37.5 % off vs previous price\n currentMinPriceMaxPriceDiffPct: 37.5,\n } as PricesStats;\n\n const base: Partial<Product> = {\n name: 'Samsung Galaxy S24 Ultra 256GB',\n storeRef: '5f27437dabd4b000086cd698', // Falabella store ref\n brandName: 'Samsung',\n category: 'Celulares y Telefonía', // raw per-store category (NOT matched)\n inferredCategories: ['Smartphones'], // AI-normalized keys (matched by evalFilter)\n price,\n priceStats,\n };\n\n return Object.assign(new Product(), base, overrides);\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\ndescribe('evalFilter', () => {\n // --- empty filter guard ------------------------------------------------\n\n it('returns false when filter has no fields set', () => {\n expect(evalFilter(makeProduct(), {})).toBe(false);\n });\n\n // --- categories --------------------------------------------------------\n\n it('matches when an inferredCategory is in filter.categories (case-insensitive)', () => {\n const filter: ChannelFilter = { categories: ['smartphones'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when an inferredCategory is in filter.categories (mixed case)', () => {\n const filter: ChannelFilter = { categories: ['SMARTPHONES', 'Laptops'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches any-of across multiple inferredCategories', () => {\n const filter: ChannelFilter = { categories: ['electronics'] };\n expect(evalFilter(makeProduct({ inferredCategories: ['Smartphones', 'Electronics'] }), filter)).toBe(true);\n });\n\n it('matches against inferredCategories, NOT the raw product.category', () => {\n // raw category is 'Celulares y Telefonía'; matching it must NOT work.\n const filter: ChannelFilter = { categories: ['celulares y telefonía'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when none of the inferredCategories are in filter.categories', () => {\n const filter: ChannelFilter = { categories: ['Laptops', 'Tablets'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no inferredCategories and filter.categories is set', () => {\n const filter: ChannelFilter = { categories: ['Smartphones'] };\n expect(evalFilter(makeProduct({ inferredCategories: undefined }), filter)).toBe(false);\n });\n\n // --- brands ------------------------------------------------------------\n\n it('matches when product brandName is in filter.brands (case-insensitive)', () => {\n const filter: ChannelFilter = { brands: ['samsung'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product brandName is NOT in filter.brands', () => {\n const filter: ChannelFilter = { brands: ['Apple', 'LG'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n // --- stores ------------------------------------------------------------\n\n it('matches when product storeRef is in filter.stores', () => {\n const filter: ChannelFilter = { stores: ['5f27437dabd4b000086cd698'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product storeRef is NOT in filter.stores', () => {\n const filter: ChannelFilter = { stores: ['other-store-id'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n // --- minDiscountPct ----------------------------------------------------\n\n it('matches when product discount equals minDiscountPct (boundary >=)', () => {\n // currentPricePrevPriceDiffPct is 37.5 in fixture\n const filter: ChannelFilter = { minDiscountPct: 37.5 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when product discount is above minDiscountPct', () => {\n const filter: ChannelFilter = { minDiscountPct: 30 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product discount is below minDiscountPct', () => {\n const filter: ChannelFilter = { minDiscountPct: 50 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no priceStats and minDiscountPct is set', () => {\n const filter: ChannelFilter = { minDiscountPct: 10 };\n expect(evalFilter(makeProduct({ priceStats: undefined }), filter)).toBe(false);\n });\n\n // --- minPrice / maxPrice -----------------------------------------------\n\n it('matches when product min price equals minPrice (boundary >=)', () => {\n // getMinPrice(price) = min(50000, 80000) = 50000\n const filter: ChannelFilter = { minPrice: 50000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product min price is below minPrice', () => {\n const filter: ChannelFilter = { minPrice: 60000 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('matches when product min price equals maxPrice (boundary <=)', () => {\n const filter: ChannelFilter = { maxPrice: 50000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product min price is above maxPrice', () => {\n const filter: ChannelFilter = { maxPrice: 49999 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('matches when product price is within [minPrice, maxPrice] range', () => {\n const filter: ChannelFilter = { minPrice: 40000, maxPrice: 60000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product has no price and a price bound is set', () => {\n const filter: ChannelFilter = { minPrice: 1000 };\n expect(evalFilter(makeProduct({ price: undefined }), filter)).toBe(false);\n });\n\n // --- words -------------------------------------------------------------\n\n it('matches when any word appears (case-insensitive substring) in product name', () => {\n const filter: ChannelFilter = { words: ['galaxy'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when second of two words appears in product name', () => {\n const filter: ChannelFilter = { words: ['iphone', 'ultra'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when none of the words appear in product name', () => {\n const filter: ChannelFilter = { words: ['iphone', 'pixel'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no name and words filter is set', () => {\n const filter: ChannelFilter = { words: ['galaxy'] };\n expect(evalFilter(makeProduct({ name: undefined }), filter)).toBe(false);\n });\n\n // --- combined fields (AND logic) ---------------------------------------\n\n it('matches when all present fields pass', () => {\n const filter: ChannelFilter = {\n categories: ['smartphones'],\n brands: ['samsung'],\n stores: ['5f27437dabd4b000086cd698'],\n minDiscountPct: 30,\n minPrice: 40000,\n maxPrice: 60000,\n words: ['galaxy'],\n };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when one of multiple present fields fails', () => {\n const filter: ChannelFilter = {\n categories: ['smartphones'],\n brands: ['Apple'], // <-- this will fail\n minDiscountPct: 30,\n };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('ignores absent optional fields (product with only storeRef filter)', () => {\n const filter: ChannelFilter = { stores: ['5f27437dabd4b000086cd698'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n});\n\ndescribe('evalFilter — robustness over untrusted JSON filters', () => {\n const p = makeProduct(); // Samsung / Smartphones / store ref / \"Galaxy\" / 37.5% off\n\n it('ignores null/non-string array elements (no throw)', () => {\n expect(evalFilter(p, { brands: ['Samsung', null as never] })).toBe(true);\n expect(evalFilter(p, { categories: [null as never, 'Smartphones'] })).toBe(true);\n expect(evalFilter(p, { words: ['galaxy', null as never] })).toBe(true);\n });\n\n it('treats a null field as absent (no throw)', () => {\n expect(evalFilter(p, { stores: null as never, brands: ['Samsung'] })).toBe(true);\n expect(evalFilter(p, { categories: null as never })).toBe(false); // no effective field\n });\n\n it('treats an empty array as absent, not match-nothing', () => {\n expect(evalFilter(p, { categories: [] })).toBe(false); // only field, empty => floor\n expect(evalFilter(p, { categories: [], brands: ['Samsung'] })).toBe(true); // empty ignored, brand matches\n });\n\n it('ignores empty/whitespace word tokens (does not match everything)', () => {\n expect(evalFilter(p, { words: [''] })).toBe(false);\n expect(evalFilter(p, { words: [' '] })).toBe(false);\n expect(evalFilter(p, { words: [' galaxy '] })).toBe(true); // trimmed\n });\n\n it('minDiscountPct of 0 is no floor (inactive)', () => {\n expect(evalFilter(p, { minDiscountPct: 0 })).toBe(false); // only field => floor\n expect(evalFilter(p, { minDiscountPct: 0, brands: ['Samsung'] })).toBe(true);\n });\n\n it('does not throw on a product missing priceStats/price/name/category/brand', () => {\n const bare = makeProduct({\n priceStats: undefined as never, price: undefined as never, name: undefined as never,\n category: undefined as never, inferredCategories: undefined as never,\n brandName: undefined as never, storeRef: undefined as never,\n });\n expect(evalFilter(bare, { minDiscountPct: 30 })).toBe(false);\n expect(evalFilter(bare, { minPrice: 1 })).toBe(false);\n expect(evalFilter(bare, { words: ['x'] })).toBe(false);\n expect(() => evalFilter(bare, {\n brands: ['Samsung'], categories: ['x'], stores: ['y'], words: ['z'], minPrice: 1, maxPrice: 9, minDiscountPct: 5,\n })).not.toThrow();\n });\n\n // --- sellers (positive allow-list) -------------------------------------\n describe('sellers (positive)', () => {\n it('matches when sellerName is in filter.sellers (case-insensitive)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'Falabella' }), { sellers: ['falabella'] })).toBe(true);\n });\n it('misses when sellerName is NOT in filter.sellers', () => {\n expect(evalFilter(makeProduct({ sellerName: 'OtherSeller' }), { sellers: ['falabella'] })).toBe(false);\n });\n it('misses when product has no sellerName but filter.sellers is set', () => {\n expect(evalFilter(makeProduct({ sellerName: undefined }), { sellers: ['falabella'] })).toBe(false);\n });\n it('counts as a positive field for the safety floor', () => {\n // sellers-only filter is allowed (not exclusion-only)\n expect(evalFilter(makeProduct({ sellerName: 'Falabella' }), { sellers: ['falabella'] })).toBe(true);\n });\n });\n\n // --- excludeWords / excludeSellers / excludeMarketplace (negative) -----\n describe('exclusions', () => {\n it('excludeWords: rejects when name contains an excluded word (substring, ci)', () => {\n // positive word matches, but excludeWords kicks it out\n expect(evalFilter(makeProduct(), { words: ['galaxy'], excludeWords: ['ultra'] })).toBe(false);\n });\n it('excludeWords: passes when name contains none of the excluded words', () => {\n expect(evalFilter(makeProduct(), { words: ['galaxy'], excludeWords: ['refurbished'] })).toBe(true);\n });\n it('excludeMarketplace: rejects a marketplace product', () => {\n expect(evalFilter(makeProduct({ isMarketplace: true }), { brands: ['Samsung'], excludeMarketplace: true })).toBe(false);\n });\n it('excludeMarketplace: passes a non-marketplace product', () => {\n expect(evalFilter(makeProduct({ isMarketplace: false }), { brands: ['Samsung'], excludeMarketplace: true })).toBe(true);\n });\n it('excludeSellers: rejects when sellerName is in the deny-list (ci)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'BadSeller' }), { brands: ['Samsung'], excludeSellers: ['badseller'] })).toBe(false);\n });\n it('excludeSellers: passes when sellerName is not in the deny-list', () => {\n expect(evalFilter(makeProduct({ sellerName: 'GoodSeller' }), { brands: ['Samsung'], excludeSellers: ['badseller'] })).toBe(true);\n });\n\n // anti-spam floor: exclusion-only filters match NOTHING\n it('excludeMarketplace-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct({ isMarketplace: false }), { excludeMarketplace: true })).toBe(false);\n });\n it('excludeWords-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct(), { excludeWords: ['refurbished'] })).toBe(false);\n });\n it('excludeSellers-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'GoodSeller' }), { excludeSellers: ['badseller'] })).toBe(false);\n });\n });\n});\n"]}
|
|
1
|
+
{"version":3,"file":"eval-filter.spec.js","sourceRoot":"/","sources":["utils/eval-filter.spec.ts"],"names":[],"mappings":"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","sourcesContent":["import { Product } from '../entities/product';\nimport { Price } from '../entities/price';\nimport { PricesStats } from '../entities/prices-stats';\nimport { evalFilter, normalizeText, ChannelFilter } from './eval-filter';\n\n// ---------------------------------------------------------------------------\n// Minimal Product fixture (only fields used by evalFilter)\n// ---------------------------------------------------------------------------\nfunction makeProduct(overrides: Partial<Product> = {}): Product {\n const price = {\n productRef: 'prod-1',\n offerPrice: 50000,\n normalPrice: 80000,\n } as Price;\n\n const priceStats = {\n minPriceSum: 0,\n maxPriceSum: 0,\n avgPriceSum: 0,\n pricesCount: 1,\n avgMinPrice: 0,\n avgAvgPrice: 0,\n avgMaxPrice: 0,\n currentMinPrice: 50000,\n currentMaxPrice: 80000,\n currentPriceAvgMinPriceDiffPct: 0,\n currentPriceBestPriceDiffPct: 0,\n currentPricePrevPriceDiffPct: 37.5, // 37.5 % off vs previous price\n currentMinPriceMaxPriceDiffPct: 37.5,\n } as PricesStats;\n\n const base: Partial<Product> = {\n name: 'Samsung Galaxy S24 Ultra 256GB',\n storeRef: '5f27437dabd4b000086cd698', // Falabella store ref\n brandName: 'Samsung',\n category: 'Celulares y Telefonía', // raw per-store category (NOT matched)\n inferredCategories: ['Smartphones'], // AI-normalized keys (matched by evalFilter)\n price,\n priceStats,\n };\n\n return Object.assign(new Product(), base, overrides);\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\ndescribe('evalFilter', () => {\n // --- empty filter guard ------------------------------------------------\n\n it('returns false when filter has no fields set', () => {\n expect(evalFilter(makeProduct(), {})).toBe(false);\n });\n\n // --- categories --------------------------------------------------------\n\n it('matches when an inferredCategory is in filter.categories (case-insensitive)', () => {\n const filter: ChannelFilter = { categories: ['smartphones'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when an inferredCategory is in filter.categories (mixed case)', () => {\n const filter: ChannelFilter = { categories: ['SMARTPHONES', 'Laptops'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches any-of across multiple inferredCategories', () => {\n const filter: ChannelFilter = { categories: ['electronics'] };\n expect(evalFilter(makeProduct({ inferredCategories: ['Smartphones', 'Electronics'] }), filter)).toBe(true);\n });\n\n it('matches against inferredCategories, NOT the raw product.category', () => {\n // raw category is 'Celulares y Telefonía'; matching it must NOT work.\n const filter: ChannelFilter = { categories: ['celulares y telefonía'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when none of the inferredCategories are in filter.categories', () => {\n const filter: ChannelFilter = { categories: ['Laptops', 'Tablets'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no inferredCategories and filter.categories is set', () => {\n const filter: ChannelFilter = { categories: ['Smartphones'] };\n expect(evalFilter(makeProduct({ inferredCategories: undefined }), filter)).toBe(false);\n });\n\n // --- brands ------------------------------------------------------------\n\n it('matches when product brandName is in filter.brands (case-insensitive)', () => {\n const filter: ChannelFilter = { brands: ['samsung'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product brandName is NOT in filter.brands', () => {\n const filter: ChannelFilter = { brands: ['Apple', 'LG'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n // --- stores ------------------------------------------------------------\n\n it('matches when product storeRef is in filter.stores', () => {\n const filter: ChannelFilter = { stores: ['5f27437dabd4b000086cd698'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product storeRef is NOT in filter.stores', () => {\n const filter: ChannelFilter = { stores: ['other-store-id'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n // --- minDiscountPct ----------------------------------------------------\n\n it('matches when product discount equals minDiscountPct (boundary >=)', () => {\n // currentPricePrevPriceDiffPct is 37.5 in fixture\n const filter: ChannelFilter = { minDiscountPct: 37.5 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when product discount is above minDiscountPct', () => {\n const filter: ChannelFilter = { minDiscountPct: 30 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product discount is below minDiscountPct', () => {\n const filter: ChannelFilter = { minDiscountPct: 50 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no priceStats and minDiscountPct is set', () => {\n const filter: ChannelFilter = { minDiscountPct: 10 };\n expect(evalFilter(makeProduct({ priceStats: undefined }), filter)).toBe(false);\n });\n\n // --- minPrice / maxPrice -----------------------------------------------\n\n it('matches when product min price equals minPrice (boundary >=)', () => {\n // getMinPrice(price) = min(50000, 80000) = 50000\n const filter: ChannelFilter = { minPrice: 50000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product min price is below minPrice', () => {\n const filter: ChannelFilter = { minPrice: 60000 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('matches when product min price equals maxPrice (boundary <=)', () => {\n const filter: ChannelFilter = { maxPrice: 50000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product min price is above maxPrice', () => {\n const filter: ChannelFilter = { maxPrice: 49999 };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('matches when product price is within [minPrice, maxPrice] range', () => {\n const filter: ChannelFilter = { minPrice: 40000, maxPrice: 60000 };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when product has no price and a price bound is set', () => {\n const filter: ChannelFilter = { minPrice: 1000 };\n expect(evalFilter(makeProduct({ price: undefined }), filter)).toBe(false);\n });\n\n // --- words -------------------------------------------------------------\n\n it('matches when any word appears (case-insensitive substring) in product name', () => {\n const filter: ChannelFilter = { words: ['galaxy'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('matches when second of two words appears in product name', () => {\n const filter: ChannelFilter = { words: ['iphone', 'ultra'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when none of the words appear in product name', () => {\n const filter: ChannelFilter = { words: ['iphone', 'pixel'] };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('misses when product has no name and words filter is set', () => {\n const filter: ChannelFilter = { words: ['galaxy'] };\n expect(evalFilter(makeProduct({ name: undefined }), filter)).toBe(false);\n });\n\n // --- combined fields (AND logic) ---------------------------------------\n\n it('matches when all present fields pass', () => {\n const filter: ChannelFilter = {\n categories: ['smartphones'],\n brands: ['samsung'],\n stores: ['5f27437dabd4b000086cd698'],\n minDiscountPct: 30,\n minPrice: 40000,\n maxPrice: 60000,\n words: ['galaxy'],\n };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n\n it('misses when one of multiple present fields fails', () => {\n const filter: ChannelFilter = {\n categories: ['smartphones'],\n brands: ['Apple'], // <-- this will fail\n minDiscountPct: 30,\n };\n expect(evalFilter(makeProduct(), filter)).toBe(false);\n });\n\n it('ignores absent optional fields (product with only storeRef filter)', () => {\n const filter: ChannelFilter = { stores: ['5f27437dabd4b000086cd698'] };\n expect(evalFilter(makeProduct(), filter)).toBe(true);\n });\n});\n\ndescribe('evalFilter — robustness over untrusted JSON filters', () => {\n const p = makeProduct(); // Samsung / Smartphones / store ref / \"Galaxy\" / 37.5% off\n\n it('ignores null/non-string array elements (no throw)', () => {\n expect(evalFilter(p, { brands: ['Samsung', null as never] })).toBe(true);\n expect(evalFilter(p, { categories: [null as never, 'Smartphones'] })).toBe(true);\n expect(evalFilter(p, { words: ['galaxy', null as never] })).toBe(true);\n });\n\n it('treats a null field as absent (no throw)', () => {\n expect(evalFilter(p, { stores: null as never, brands: ['Samsung'] })).toBe(true);\n expect(evalFilter(p, { categories: null as never })).toBe(false); // no effective field\n });\n\n it('treats an empty array as absent, not match-nothing', () => {\n expect(evalFilter(p, { categories: [] })).toBe(false); // only field, empty => floor\n expect(evalFilter(p, { categories: [], brands: ['Samsung'] })).toBe(true); // empty ignored, brand matches\n });\n\n it('ignores empty/whitespace word tokens (does not match everything)', () => {\n expect(evalFilter(p, { words: [''] })).toBe(false);\n expect(evalFilter(p, { words: [' '] })).toBe(false);\n expect(evalFilter(p, { words: [' galaxy '] })).toBe(true); // trimmed\n });\n\n it('minDiscountPct of 0 is no floor (inactive)', () => {\n expect(evalFilter(p, { minDiscountPct: 0 })).toBe(false); // only field => floor\n expect(evalFilter(p, { minDiscountPct: 0, brands: ['Samsung'] })).toBe(true);\n });\n\n it('does not throw on a product missing priceStats/price/name/category/brand', () => {\n const bare = makeProduct({\n priceStats: undefined as never, price: undefined as never, name: undefined as never,\n category: undefined as never, inferredCategories: undefined as never,\n brandName: undefined as never, storeRef: undefined as never,\n });\n expect(evalFilter(bare, { minDiscountPct: 30 })).toBe(false);\n expect(evalFilter(bare, { minPrice: 1 })).toBe(false);\n expect(evalFilter(bare, { words: ['x'] })).toBe(false);\n expect(() => evalFilter(bare, {\n brands: ['Samsung'], categories: ['x'], stores: ['y'], words: ['z'], minPrice: 1, maxPrice: 9, minDiscountPct: 5,\n })).not.toThrow();\n });\n\n // --- sellers (positive allow-list) -------------------------------------\n describe('sellers (positive)', () => {\n it('matches when sellerName is in filter.sellers (case-insensitive)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'Falabella' }), { sellers: ['falabella'] })).toBe(true);\n });\n it('misses when sellerName is NOT in filter.sellers', () => {\n expect(evalFilter(makeProduct({ sellerName: 'OtherSeller' }), { sellers: ['falabella'] })).toBe(false);\n });\n it('misses when product has no sellerName but filter.sellers is set', () => {\n expect(evalFilter(makeProduct({ sellerName: undefined }), { sellers: ['falabella'] })).toBe(false);\n });\n it('counts as a positive field for the safety floor', () => {\n // sellers-only filter is allowed (not exclusion-only)\n expect(evalFilter(makeProduct({ sellerName: 'Falabella' }), { sellers: ['falabella'] })).toBe(true);\n });\n });\n\n // --- excludeWords / excludeSellers / excludeMarketplace (negative) -----\n describe('exclusions', () => {\n it('excludeWords: rejects when name contains an excluded word (substring, ci)', () => {\n // positive word matches, but excludeWords kicks it out\n expect(evalFilter(makeProduct(), { words: ['galaxy'], excludeWords: ['ultra'] })).toBe(false);\n });\n it('excludeWords: passes when name contains none of the excluded words', () => {\n expect(evalFilter(makeProduct(), { words: ['galaxy'], excludeWords: ['refurbished'] })).toBe(true);\n });\n it('excludeMarketplace: rejects a marketplace product', () => {\n expect(evalFilter(makeProduct({ isMarketplace: true }), { brands: ['Samsung'], excludeMarketplace: true })).toBe(false);\n });\n it('excludeMarketplace: passes a non-marketplace product', () => {\n expect(evalFilter(makeProduct({ isMarketplace: false }), { brands: ['Samsung'], excludeMarketplace: true })).toBe(true);\n });\n it('excludeSellers: rejects when sellerName is in the deny-list (ci)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'BadSeller' }), { brands: ['Samsung'], excludeSellers: ['badseller'] })).toBe(false);\n });\n it('excludeSellers: passes when sellerName is not in the deny-list', () => {\n expect(evalFilter(makeProduct({ sellerName: 'GoodSeller' }), { brands: ['Samsung'], excludeSellers: ['badseller'] })).toBe(true);\n });\n\n // anti-spam floor: exclusion-only filters match NOTHING\n it('excludeMarketplace-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct({ isMarketplace: false }), { excludeMarketplace: true })).toBe(false);\n });\n it('excludeWords-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct(), { excludeWords: ['refurbished'] })).toBe(false);\n });\n it('excludeSellers-only filter matches nothing (no positive field)', () => {\n expect(evalFilter(makeProduct({ sellerName: 'GoodSeller' }), { excludeSellers: ['badseller'] })).toBe(false);\n });\n });\n});\n\ndescribe('normalizeText', () => {\n it('strips diacritics incl. ñ→n and lowercases', () => {\n expect(normalizeText('Niño')).toBe('nino');\n expect(normalizeText('Colchón')).toBe('colchon');\n expect(normalizeText('CAFÉ')).toBe('cafe');\n });\n it('collapses whitespace and trims', () => {\n expect(normalizeText(' play station ')).toBe('play station');\n });\n});\n\ndescribe('words (normalized substring)', () => {\n it('matches accent-insensitively both directions', () => {\n expect(evalFilter(makeProduct({ name: 'Colchón King' }), { words: ['colchon'] })).toBe(true);\n expect(evalFilter(makeProduct({ name: 'Colchon King' }), { words: ['colchón'] })).toBe(true);\n });\n it('still matches substrings (lenient)', () => {\n expect(evalFilter(makeProduct(), { words: ['galaxy'] })).toBe(true);\n });\n it('misses when no word is a substring', () => {\n expect(evalFilter(makeProduct(), { words: ['refrigerador'] })).toBe(false);\n });\n});\n\ndescribe('excludeWords (whole-word + phrase)', () => {\n it('does NOT over-exclude on substring (\"kit\" keeps \"KitchenAid\")', () => {\n expect(evalFilter(makeProduct({ name: 'KitchenAid Batidora' }), { words: ['batidora'], excludeWords: ['kit'] })).toBe(true);\n });\n it('rejects a standalone whole word', () => {\n expect(evalFilter(makeProduct({ name: 'Kit de limpieza' }), { words: ['limpieza'], excludeWords: ['kit'] })).toBe(false);\n });\n it('is accent-insensitive', () => {\n expect(evalFilter(makeProduct({ name: 'iPhone 13 Reacondicionado' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(false);\n });\n it('multi-word entry matches as a phrase substring', () => {\n expect(evalFilter(makeProduct({ name: 'Consola Play Station 5' }), { words: ['consola'], excludeWords: ['play station'] })).toBe(false);\n });\n it('hyphenated entry matches as a substring (falls through to substring, not a token lookup)', () => {\n // \"play-station\" keeps its hyphen after normalize; tokenize splits on it, so a\n // token lookup would always miss. It must fall through to substring matching.\n expect(evalFilter(makeProduct({ name: 'Consola Play-Station 5' }), { words: ['consola'], excludeWords: ['play-station'] })).toBe(false);\n });\n it('passes when no excluded word/phrase present', () => {\n expect(evalFilter(makeProduct({ name: 'iPhone 13 Nuevo' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(true);\n });\n});\n\ndescribe('sellers/excludeSellers (normalized exact)', () => {\n it('sellers matches accent-insensitively', () => {\n expect(evalFilter(makeProduct({ sellerName: 'Niñas Store' }), { sellers: ['ninas store'] })).toBe(true);\n });\n it('excludeSellers rejects accent-insensitively', () => {\n expect(evalFilter(makeProduct({ brandName: 'Samsung', sellerName: 'Niñas Store' }), { brands: ['Samsung'], excludeSellers: ['ninas store'] })).toBe(false);\n });\n});\n"]}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# Word-matching v2 Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make user-alert word matching diacritic-insensitive and make `excludeWords` precise (whole-word), without losing leniency on positive `words`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Pure changes in `store-scraper-common`'s `evalFilter` — a `normalizeText` helper applied to free-text fields, `words` stays substring (now normalized), `excludeWords` becomes whole-word (with a phrase→substring fallback), `sellers`/`excludeSellers` normalized. Matcher consumes via a version bump only (no matcher code or channel-index change). GUI hint is a separate, non-blocking handoff.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Jest, npm (publish to npmjs.org; package `store-scrapper-js-common`).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-06-19-word-matching-v2-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File structure
|
|
16
|
+
|
|
17
|
+
- **Modify** `src/utils/eval-filter.ts` — add `normalizeText`, `cleanNormalizedSet`, `tokenize`; rewire `words`/`excludeWords`/`sellers`/`excludeSellers`.
|
|
18
|
+
- **Modify** `src/utils/eval-filter.spec.ts` — tests for the above.
|
|
19
|
+
- **Modify** (later repo) `store-scraper-alerts/src/user-match/user-match.service.spec.ts` — match()-level regression tests.
|
|
20
|
+
|
|
21
|
+
`categories`/`brands` (already-normalized keys) and `stores` (id) are intentionally **not** touched.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Task 1: `normalizeText` helper
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Modify: `src/utils/eval-filter.ts` (add helper near `cleanSet`, ~line 27)
|
|
29
|
+
- Test: `src/utils/eval-filter.spec.ts`
|
|
30
|
+
|
|
31
|
+
- [ ] **Step 1: Write failing tests** — append inside the top-level `describe('evalFilter', …)` block (or a new `describe`) in `src/utils/eval-filter.spec.ts`. First export `normalizeText` so it's testable — add to the import at the top of the spec:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { evalFilter, normalizeText } from './eval-filter';
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
describe('normalizeText', () => {
|
|
39
|
+
it('strips diacritics incl. ñ→n and lowercases', () => {
|
|
40
|
+
expect(normalizeText('Niño')).toBe('nino');
|
|
41
|
+
expect(normalizeText('Colchón')).toBe('colchon');
|
|
42
|
+
expect(normalizeText('CAFÉ')).toBe('cafe');
|
|
43
|
+
expect(normalizeText('Año')).toBe('ano');
|
|
44
|
+
});
|
|
45
|
+
it('collapses whitespace and trims', () => {
|
|
46
|
+
expect(normalizeText(' play station ')).toBe('play station');
|
|
47
|
+
});
|
|
48
|
+
it('is idempotent', () => {
|
|
49
|
+
expect(normalizeText(normalizeText('Niño Café'))).toBe('nino cafe');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- [ ] **Step 2: Run, expect fail**
|
|
55
|
+
|
|
56
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t normalizeText`
|
|
57
|
+
Expected: FAIL — `normalizeText is not a function` (not exported / undefined).
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 3: Implement** — in `src/utils/eval-filter.ts`, add after the `cleanSet` function (after ~line 37):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
/**
|
|
63
|
+
* Normalize free text for matching: NFD-decompose + strip combining marks
|
|
64
|
+
* (á→a, é→e, ñ→n, …), lowercase, collapse whitespace, trim. Deterministic.
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeText(s: string): string {
|
|
67
|
+
return s
|
|
68
|
+
.normalize('NFD')
|
|
69
|
+
.replace(/[̀-ͯ]/g, '')
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/\s+/g, ' ')
|
|
72
|
+
.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Like cleanSet but normalizeText each element (drops blanks); null if none. */
|
|
76
|
+
function cleanNormalizedSet(arr: string[] | undefined): string[] | null {
|
|
77
|
+
if (!Array.isArray(arr)) return null;
|
|
78
|
+
const out: string[] = [];
|
|
79
|
+
for (const v of arr) {
|
|
80
|
+
if (typeof v !== 'string') continue;
|
|
81
|
+
const t = normalizeText(v);
|
|
82
|
+
if (t.length === 0) continue;
|
|
83
|
+
out.push(t);
|
|
84
|
+
}
|
|
85
|
+
return out.length > 0 ? out : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Split normalized text into alphanumeric tokens (whole-word matching). */
|
|
89
|
+
function tokenize(normalized: string): string[] {
|
|
90
|
+
return normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- [ ] **Step 4: Run, expect pass**
|
|
95
|
+
|
|
96
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t normalizeText`
|
|
97
|
+
Expected: PASS (3 tests).
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 5: Commit**
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git add src/utils/eval-filter.ts src/utils/eval-filter.spec.ts
|
|
103
|
+
git commit -m "feat(eval-filter): add normalizeText + cleanNormalizedSet + tokenize helpers"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Task 2: `words` — normalized substring (additive)
|
|
109
|
+
|
|
110
|
+
**Files:**
|
|
111
|
+
- Modify: `src/utils/eval-filter.ts` (the `words` declaration ~line 56 and the `words` branch ~line 97)
|
|
112
|
+
- Test: `src/utils/eval-filter.spec.ts`
|
|
113
|
+
|
|
114
|
+
- [ ] **Step 1: Write failing tests** — append in the spec (the `makeProduct` base name is `'Samsung Galaxy S24 Ultra 256GB'`):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
describe('words (normalized substring)', () => {
|
|
118
|
+
it('matches accent-insensitively (filter without accent, name with)', () => {
|
|
119
|
+
expect(evalFilter(makeProduct({ name: 'Colchón King 2 plazas' }), { words: ['colchon'] })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('matches accent-insensitively (filter with accent, name without)', () => {
|
|
122
|
+
expect(evalFilter(makeProduct({ name: 'Colchon King' }), { words: ['colchón'] })).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
it('still matches substrings (lenient) — "galaxy" in the base name', () => {
|
|
125
|
+
expect(evalFilter(makeProduct(), { words: ['galaxy'] })).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
it('misses when no word is a substring', () => {
|
|
128
|
+
expect(evalFilter(makeProduct(), { words: ['refrigerador'] })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
(`makeProduct` accepts `name` via overrides — confirm/extend its Pick to include `'name'`; it already does.)
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 2: Run, expect fail**
|
|
136
|
+
|
|
137
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t "words (normalized substring)"`
|
|
138
|
+
Expected: FAIL on the accent cases (current code lowercases but does not strip accents).
|
|
139
|
+
|
|
140
|
+
- [ ] **Step 3: Implement** — in `src/utils/eval-filter.ts`:
|
|
141
|
+
|
|
142
|
+
Change the `words` declaration (was `const words = cleanSet(filter.words, true);`):
|
|
143
|
+
```ts
|
|
144
|
+
const words = cleanNormalizedSet(filter.words);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Change the `words` branch (was using `product.name?.toLowerCase()`):
|
|
148
|
+
```ts
|
|
149
|
+
if (words) {
|
|
150
|
+
const name = normalizeText(product.name ?? '');
|
|
151
|
+
if (!name || !words.some((w) => name.includes(w))) return false;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- [ ] **Step 4: Run, expect pass**
|
|
156
|
+
|
|
157
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t "words (normalized substring)"`
|
|
158
|
+
Expected: PASS (4 tests).
|
|
159
|
+
|
|
160
|
+
- [ ] **Step 5: Commit**
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
git add src/utils/eval-filter.ts src/utils/eval-filter.spec.ts
|
|
164
|
+
git commit -m "feat(eval-filter): words match is diacritic-insensitive (normalized substring)"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Task 3: `excludeWords` — whole-word + phrase fallback
|
|
170
|
+
|
|
171
|
+
**Files:**
|
|
172
|
+
- Modify: `src/utils/eval-filter.ts` (the `excludeWords` declaration and branch)
|
|
173
|
+
- Test: `src/utils/eval-filter.spec.ts`
|
|
174
|
+
|
|
175
|
+
- [ ] **Step 1: Write failing tests**:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
describe('excludeWords (whole-word + phrase)', () => {
|
|
179
|
+
it('does NOT over-exclude on substring ("kit" keeps "KitchenAid…")', () => {
|
|
180
|
+
expect(evalFilter(makeProduct({ name: 'KitchenAid Batidora' }), { words: ['batidora'], excludeWords: ['kit'] })).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
it('rejects a standalone whole word ("kit")', () => {
|
|
183
|
+
expect(evalFilter(makeProduct({ name: 'Kit de limpieza' }), { words: ['limpieza'], excludeWords: ['kit'] })).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
it('is accent-insensitive (entry "reacondicionado" rejects "Reacondicionado")', () => {
|
|
186
|
+
expect(evalFilter(makeProduct({ name: 'iPhone 13 Reacondicionado' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
it('multi-word entry matches as a phrase substring', () => {
|
|
189
|
+
expect(evalFilter(makeProduct({ name: 'Sony PlayStation 5 Slim' }), { words: ['sony'], excludeWords: ['play station'] })).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
it('passes when no excluded whole-word/phrase is present', () => {
|
|
192
|
+
expect(evalFilter(makeProduct({ name: 'iPhone 13 Nuevo' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Note the phrase test: name "Sony PlayStation 5 Slim" → normalized "sony playstation 5 slim". Entry "play station" has a space → phrase substring; but normalized name has "playstation" (no space), so `name.includes('play station')` is **false** → would NOT reject. **Fix the test** to use a name where the phrase appears with a space, OR a single-token entry. Use this instead:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
it('multi-word entry matches as a phrase substring', () => {
|
|
201
|
+
expect(evalFilter(makeProduct({ name: 'Consola Play Station 5' }), { words: ['consola'], excludeWords: ['play station'] })).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 2: Run, expect fail**
|
|
206
|
+
|
|
207
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t "excludeWords (whole-word"`
|
|
208
|
+
Expected: FAIL — current substring impl over-excludes ("kit" in "KitchenAid" returns false, so the first test fails).
|
|
209
|
+
|
|
210
|
+
- [ ] **Step 3: Implement** — in `src/utils/eval-filter.ts`:
|
|
211
|
+
|
|
212
|
+
Change the `excludeWords` declaration (was `const excludeWords = cleanSet(filter.excludeWords, true);`):
|
|
213
|
+
```ts
|
|
214
|
+
const excludeWords = cleanNormalizedSet(filter.excludeWords);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Replace the `excludeWords` branch (was substring `excludeWords.some((w) => name.includes(w))`):
|
|
218
|
+
```ts
|
|
219
|
+
if (excludeWords) {
|
|
220
|
+
const name = normalizeText(product.name ?? '');
|
|
221
|
+
if (name) {
|
|
222
|
+
const tokens = new Set(tokenize(name));
|
|
223
|
+
for (const e of excludeWords) {
|
|
224
|
+
// single token → whole-word; multi-word entry (has a space) → phrase substring
|
|
225
|
+
const hit = e.includes(' ') ? name.includes(e) : tokens.has(e);
|
|
226
|
+
if (hit) return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- [ ] **Step 4: Run, expect pass**
|
|
233
|
+
|
|
234
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t "excludeWords (whole-word"`
|
|
235
|
+
Expected: PASS (5 tests).
|
|
236
|
+
|
|
237
|
+
- [ ] **Step 5: Commit**
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
git add src/utils/eval-filter.ts src/utils/eval-filter.spec.ts
|
|
241
|
+
git commit -m "feat(eval-filter): excludeWords whole-word match (phrase fallback) — no silent over-exclusion"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Task 4: `sellers` / `excludeSellers` — normalized exact
|
|
247
|
+
|
|
248
|
+
**Files:**
|
|
249
|
+
- Modify: `src/utils/eval-filter.ts` (the `sellers`/`excludeSellers` declarations + branches)
|
|
250
|
+
- Test: `src/utils/eval-filter.spec.ts`
|
|
251
|
+
|
|
252
|
+
- [ ] **Step 1: Write failing tests**:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
describe('sellers/excludeSellers (normalized exact)', () => {
|
|
256
|
+
it('sellers matches accent-insensitively', () => {
|
|
257
|
+
expect(evalFilter(makeProduct({ sellerName: 'Niñas Store' }), { sellers: ['ninas store'] })).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
it('excludeSellers rejects accent-insensitively', () => {
|
|
260
|
+
expect(evalFilter(makeProduct({ brandName: 'Samsung', sellerName: 'Niñas Store' }), { brands: ['Samsung'], excludeSellers: ['ninas store'] })).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
(Extend `makeProduct`'s override Pick to include `'sellerName'` if not already present in this repo's spec helper — the base `makeProduct` is in `eval-filter.spec.ts`; add `sellerName?: string` support by allowing it in overrides.)
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 2: Run, expect fail**
|
|
268
|
+
|
|
269
|
+
Run: `npx jest src/utils/eval-filter.spec.ts -t "sellers/excludeSellers"`
|
|
270
|
+
Expected: FAIL — current code lowercases but does not strip the ñ, so `'niñas store' !== 'ninas store'`.
|
|
271
|
+
|
|
272
|
+
- [ ] **Step 3: Implement** — in `src/utils/eval-filter.ts`:
|
|
273
|
+
|
|
274
|
+
Declarations (were `cleanSet(..., true)`):
|
|
275
|
+
```ts
|
|
276
|
+
const sellers = cleanNormalizedSet(filter.sellers);
|
|
277
|
+
const excludeSellers = cleanNormalizedSet(filter.excludeSellers);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`sellers` branch:
|
|
281
|
+
```ts
|
|
282
|
+
if (sellers) {
|
|
283
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
284
|
+
if (!s || !sellers.includes(s)) return false;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`excludeSellers` branch:
|
|
289
|
+
```ts
|
|
290
|
+
if (excludeSellers) {
|
|
291
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
292
|
+
if (s && excludeSellers.includes(s)) return false;
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- [ ] **Step 4: Run full lib suite**
|
|
297
|
+
|
|
298
|
+
Run: `npx jest src/utils/eval-filter.spec.ts`
|
|
299
|
+
Expected: PASS — all (existing + new) green. Confirms the safety floor and other fields are unaffected.
|
|
300
|
+
|
|
301
|
+
- [ ] **Step 5: Commit**
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
git add src/utils/eval-filter.ts src/utils/eval-filter.spec.ts
|
|
305
|
+
git commit -m "feat(eval-filter): sellers/excludeSellers diacritic-insensitive"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Task 5: Build, version bump, publish the lib
|
|
311
|
+
|
|
312
|
+
**Files:** none (build/publish)
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 1: Build** — `npm run build` — Expected: exit 0, `dist/` regenerated, no tsc errors.
|
|
315
|
+
- [ ] **Step 2: Version bump** — `npm version patch` — Expected: prints the new version (e.g. `v1.0.223`), creates a commit + tag. (Requires clean tree — Tasks 1-4 committed.)
|
|
316
|
+
- [ ] **Step 3: Publish** — `npm publish` — Expected: `+ store-scrapper-js-common@<new>`. **If it 404s, npm auth is expired — STOP and ask the user to `npm login` / publish; the package is owned by `vampuero`.**
|
|
317
|
+
- [ ] **Step 4: Push** — `git push origin master --follow-tags`.
|
|
318
|
+
- [ ] **Step 5: Verify** — `npm view store-scrapper-js-common version` — Expected: the new version.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Task 6: Matcher — bump lib + match()-level regression tests
|
|
323
|
+
|
|
324
|
+
**Files:**
|
|
325
|
+
- Modify: `store-scraper-alerts/package.json` + `package-lock.json` (dep bump)
|
|
326
|
+
- Test: `store-scraper-alerts/src/user-match/user-match.service.spec.ts`
|
|
327
|
+
|
|
328
|
+
- [ ] **Step 1: Bump + install** (in `~/WebstormProjects/store-scraper-alerts`, branch `development`):
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
npm install store-scrapper-js-common@^<new-version>
|
|
332
|
+
node -e "console.log(require('store-scrapper-js-common/package.json').version)" # confirms <new-version>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 2: Write match()-level tests** — append in the `describe('sellers + exclusions through match()', …)` block of `user-match.service.spec.ts` (the helper `makeProduct` already supports `name`/`sellerName`):
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
it('accent-insensitive include via match() (filter "nino" matches name "Niño")', () => {
|
|
339
|
+
const ch = makeChannel({ words: ['nino'] }, 1, '-100030');
|
|
340
|
+
const service = new UserMatchService(makeStore([ch]) as any);
|
|
341
|
+
expect(service.match(makeProduct({ name: 'Pijama Niño Talla 4' }))).toHaveLength(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('whole-word exclude via match() ("kit" does not drop "KitchenAid")', () => {
|
|
345
|
+
const ch = makeChannel({ words: ['batidora'], excludeWords: ['kit'] }, 1, '-100031');
|
|
346
|
+
const service = new UserMatchService(makeStore([ch]) as any);
|
|
347
|
+
expect(service.match(makeProduct({ name: 'KitchenAid Batidora' }))).toHaveLength(1);
|
|
348
|
+
expect(service.match(makeProduct({ name: 'Kit batidora repuesto' }))).toHaveLength(0);
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- [ ] **Step 3: Run user-match suite**
|
|
353
|
+
|
|
354
|
+
Run: `npx jest src/user-match/` (in store-scraper-alerts)
|
|
355
|
+
Expected: PASS — all (existing + 2 new) green.
|
|
356
|
+
|
|
357
|
+
- [ ] **Step 4: Typecheck** — `npx tsc --noEmit -p tsconfig.json` — Expected: exit 0.
|
|
358
|
+
|
|
359
|
+
- [ ] **Step 5: Commit + push**
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
git add package.json package-lock.json src/user-match/user-match.service.spec.ts
|
|
363
|
+
git commit -m "chore(deps): bump store-scrapper-js-common to <new> (word-matching v2) + match() tests"
|
|
364
|
+
git push origin development
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Task 7: Deploy matcher dev → prod
|
|
370
|
+
|
|
371
|
+
**Files:** none (deploy)
|
|
372
|
+
|
|
373
|
+
- [ ] **Step 1: Wait for `:dev` build** — `gh run list --branch development --limit 1` → wait for success.
|
|
374
|
+
- [ ] **Step 2: Deploy dev matcher** — `kubectl rollout restart deployment/scraper-user-match-deployment` (pop-os/default ctx) → `kubectl rollout status …`. Verify: `kubectl exec <pod> -- node -e "console.log(require('store-scrapper-js-common/package.json').version)"` prints `<new>`, restarts=0.
|
|
375
|
+
- [ ] **Step 3: Merge to master** — open PR `development→master`, merge → wait for `:latest` build success.
|
|
376
|
+
- [ ] **Step 4: Deploy prod matcher** — `ssh root@hetzner-00 'kubectl rollout restart deployment/scraper-user-match-deployment && kubectl rollout status … --timeout=150s'`. Verify lib `<new>` in the prod pod, restarts=0, `Booting in MATCHER mode` + `Fetched N user channels` in logs.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Out of scope (separate, non-blocking handoff)
|
|
381
|
+
|
|
382
|
+
- **GUI hint** (store-scraper-web): add a one-line note near the words/exclude inputs — *"Ignora tildes; las exclusiones coinciden por palabra completa."* No logic change (min-specificity unchanged). Hand to the GUI session; does not block this plan.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Word-matching v2 (evalFilter) — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-19
|
|
4
|
+
**Scope:** `store-scraper-common` `evalFilter` (`src/utils/eval-filter.ts`) — the `words` and `excludeWords` matching. Consumed by the user-owned-alert-channels matcher (`store-scraper-alerts`) and surfaced in the web GUI.
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Today `words` and `excludeWords` are case-insensitive **raw substring** matches against `product.name`. Two real, silent failure modes:
|
|
9
|
+
|
|
10
|
+
1. **No diacritic normalization (es-CL).** `words:["nino"]` misses "niño", `["colchon"]` misses "colchón", `["cafe"]` misses "café". Users type without accents and silently miss deals.
|
|
11
|
+
2. **Substring ≠ word.** `words:["apple"]` matches "pineapple"; worse, **`excludeWords:["kit"]` silently drops "KitchenAid"** — the user never sees what was over-excluded. For a negative filter this is the dangerous direction (silent loss).
|
|
12
|
+
|
|
13
|
+
## Goals / non-goals
|
|
14
|
+
|
|
15
|
+
- **Goal:** accent-insensitive matching; precise `excludeWords` (no silent over-exclusion); keep `words` forgiving (a positive include must not start missing things).
|
|
16
|
+
- **Non-goal:** stemming/plurals, multi-field matching (name+brand), phrase-AND groups, fuzzy/typo tolerance. (Listed in the v2 menu; out of scope here — YAGNI.)
|
|
17
|
+
|
|
18
|
+
## Design
|
|
19
|
+
|
|
20
|
+
### 1. `normalizeText(s: string): string`
|
|
21
|
+
A new pure helper in `eval-filter.ts`, applied to both the filter terms and the product text before comparison:
|
|
22
|
+
1. `String.prototype.normalize('NFD')` then strip combining marks (`/[̀-ͯ]/g`) → `á→a`, `é→e`, `ü→u`, **`ñ→n`**, `ó→o`, etc.
|
|
23
|
+
2. `.toLowerCase()`.
|
|
24
|
+
3. Collapse runs of whitespace to a single space and `.trim()`.
|
|
25
|
+
|
|
26
|
+
`ñ→n` is intentional and matches user intent ("nino"→"niño", "ano"→"año"). Deterministic, no config.
|
|
27
|
+
|
|
28
|
+
### 2. `words` (positive, any-of) — lenient normalized SUBSTRING
|
|
29
|
+
Match if `normalizeText(product.name)` **contains** `normalizeText(word)` as a substring, for ANY word. Behavioral delta vs today is **only** the normalization → it is **purely additive** (catches accent variants; never removes an existing match). A positive include stays forgiving so it doesn't miss no-space variants like "iphone15pro" for `"iphone"`.
|
|
30
|
+
|
|
31
|
+
### 3. `excludeWords` (negative) — precise normalized WHOLE-WORD (with phrase fallback)
|
|
32
|
+
- Tokenize `normalizeText(product.name)` on non-alphanumeric boundaries: `split(/[^a-z0-9]+/).filter(Boolean)` → e.g. "iPhone 15 (Nuevo)" → `["iphone","15","nuevo"]`.
|
|
33
|
+
- For each `excludeWords` entry `e` (normalized):
|
|
34
|
+
- **Single-token entry** (no internal whitespace after normalize): reject iff `e` is exactly one of the product tokens. → `"kit"` rejects a standalone "kit" but NOT "kitchenaid".
|
|
35
|
+
- **Multi-token entry** (contains a space, e.g. "play station", "iphone 15"): reject iff `normalizeText(name)` **contains the entry as a substring** (phrases keep working; whole-word tokenization can't express an ordered phrase cleanly).
|
|
36
|
+
- Any entry hit ⇒ the product is rejected (after the positive AND has passed).
|
|
37
|
+
|
|
38
|
+
### 4. `sellers` / `excludeSellers`
|
|
39
|
+
Unchanged in *semantics* (case-insensitive exact on `sellerName`), but they will also run through `normalizeText` for accent-insensitivity (a seller "Niñas Store" matches `"ninas store"`). Low-risk additive consistency; same normalize helper. `categories`/`brands`/`stores` are **out of scope** (categories/brands are already-normalized keys/values; storeRef is an id) — leave as-is.
|
|
40
|
+
|
|
41
|
+
### 5. Placement & blast radius
|
|
42
|
+
- **All in `store-scraper-common` `evalFilter`** (`normalizeText` + `tokenize` helpers + the `words`/`excludeWords`/`sellers`/`excludeSellers` branches).
|
|
43
|
+
- **Matcher:** picks it up via a **lib version bump only** — no matcher code change, no channel-index change (`words`/`excludeWords` are not indexed dimensions; they run in `evalFilter` after candidate selection). Same ship path as the v1 filter-field additions.
|
|
44
|
+
- **GUI:** one-line hint near the words/exclude inputs — "Ignora tildes; las exclusiones coinciden por palabra completa." No logic change (min-specificity unaffected — `words` is still positive, `excludeWords` still excluded from the floor).
|
|
45
|
+
- **user-config / persister:** no change (filter stored as JSON).
|
|
46
|
+
|
|
47
|
+
### 6. Backward compatibility
|
|
48
|
+
- Normalization (words, sellers) is **additive** — only adds accent-variant matches.
|
|
49
|
+
- The `excludeWords` whole-word change **alters behavior** for any existing channel relying on substring exclusion (e.g. a channel that excluded "kit" to drop "kitchen…"). **Decision: accept it** — it is strictly a precision improvement and the user base is tiny. No opt-in flag (YAGNI). Worth a heads-up to existing channel owners if any used substring exclusion.
|
|
50
|
+
|
|
51
|
+
### 7. Safety floor
|
|
52
|
+
Unchanged. `words` remains a positive field (counts toward the floor); `excludeWords` remains exclusion-only (does not). No change to `isFilterSpecificEnough` (web) is required.
|
|
53
|
+
|
|
54
|
+
## Testing
|
|
55
|
+
|
|
56
|
+
**Lib unit tests (`eval-filter.spec.ts`):**
|
|
57
|
+
- `normalizeText`: accents/ñ→n, mixed case, collapsed whitespace, idempotent.
|
|
58
|
+
- `words`: accent-insensitive match (`"nino"` matches name "Niño…"); still substring-lenient ("iphone" matches "iPhone15Pro"); accent variant that previously failed now passes (regression guard for the additive change).
|
|
59
|
+
- `excludeWords`: whole-word precision — `"kit"` does NOT reject "KitchenAid" but DOES reject a standalone "Kit"; phrase entry ("play station") rejects "PlayStation… " via substring; accent-insensitive ("nino" rejects "Niño…").
|
|
60
|
+
- `sellers`/`excludeSellers`: accent-insensitive exact.
|
|
61
|
+
- Safety floor unchanged (exclusion-only filter still matches nothing).
|
|
62
|
+
|
|
63
|
+
**Matcher test (`user-match.service.spec.ts`):** one match()-level case each for accent-insensitive include and whole-word exclude, proving the path through the index→evalFilter is intact.
|
|
64
|
+
|
|
65
|
+
## Rollout
|
|
66
|
+
1. Implement + test in `store-scraper-common`; `npm version patch`; publish.
|
|
67
|
+
2. Bump matcher (`store-scraper-alerts`) to the new version; run its suite; deploy dev → prod (lib baked into the image; no code change).
|
|
68
|
+
3. GUI session adds the hint text (independent, non-blocking).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Product } from '../entities/product';
|
|
2
2
|
import { Price } from '../entities/price';
|
|
3
3
|
import { PricesStats } from '../entities/prices-stats';
|
|
4
|
-
import { evalFilter, ChannelFilter } from './eval-filter';
|
|
4
|
+
import { evalFilter, normalizeText, ChannelFilter } from './eval-filter';
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Minimal Product fixture (only fields used by evalFilter)
|
|
@@ -313,3 +313,59 @@ describe('evalFilter — robustness over untrusted JSON filters', () => {
|
|
|
313
313
|
});
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
describe('normalizeText', () => {
|
|
318
|
+
it('strips diacritics incl. ñ→n and lowercases', () => {
|
|
319
|
+
expect(normalizeText('Niño')).toBe('nino');
|
|
320
|
+
expect(normalizeText('Colchón')).toBe('colchon');
|
|
321
|
+
expect(normalizeText('CAFÉ')).toBe('cafe');
|
|
322
|
+
});
|
|
323
|
+
it('collapses whitespace and trims', () => {
|
|
324
|
+
expect(normalizeText(' play station ')).toBe('play station');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('words (normalized substring)', () => {
|
|
329
|
+
it('matches accent-insensitively both directions', () => {
|
|
330
|
+
expect(evalFilter(makeProduct({ name: 'Colchón King' }), { words: ['colchon'] })).toBe(true);
|
|
331
|
+
expect(evalFilter(makeProduct({ name: 'Colchon King' }), { words: ['colchón'] })).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
it('still matches substrings (lenient)', () => {
|
|
334
|
+
expect(evalFilter(makeProduct(), { words: ['galaxy'] })).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
it('misses when no word is a substring', () => {
|
|
337
|
+
expect(evalFilter(makeProduct(), { words: ['refrigerador'] })).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('excludeWords (whole-word + phrase)', () => {
|
|
342
|
+
it('does NOT over-exclude on substring ("kit" keeps "KitchenAid")', () => {
|
|
343
|
+
expect(evalFilter(makeProduct({ name: 'KitchenAid Batidora' }), { words: ['batidora'], excludeWords: ['kit'] })).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
it('rejects a standalone whole word', () => {
|
|
346
|
+
expect(evalFilter(makeProduct({ name: 'Kit de limpieza' }), { words: ['limpieza'], excludeWords: ['kit'] })).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
it('is accent-insensitive', () => {
|
|
349
|
+
expect(evalFilter(makeProduct({ name: 'iPhone 13 Reacondicionado' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
it('multi-word entry matches as a phrase substring', () => {
|
|
352
|
+
expect(evalFilter(makeProduct({ name: 'Consola Play Station 5' }), { words: ['consola'], excludeWords: ['play station'] })).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
it('hyphenated entry matches as a substring (falls through to substring, not a token lookup)', () => {
|
|
355
|
+
// "play-station" keeps its hyphen after normalize; tokenize splits on it, so a
|
|
356
|
+
// token lookup would always miss. It must fall through to substring matching.
|
|
357
|
+
expect(evalFilter(makeProduct({ name: 'Consola Play-Station 5' }), { words: ['consola'], excludeWords: ['play-station'] })).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
it('passes when no excluded word/phrase present', () => {
|
|
360
|
+
expect(evalFilter(makeProduct({ name: 'iPhone 13 Nuevo' }), { words: ['iphone'], excludeWords: ['reacondicionado'] })).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('sellers/excludeSellers (normalized exact)', () => {
|
|
365
|
+
it('sellers matches accent-insensitively', () => {
|
|
366
|
+
expect(evalFilter(makeProduct({ sellerName: 'Niñas Store' }), { sellers: ['ninas store'] })).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
it('excludeSellers rejects accent-insensitively', () => {
|
|
369
|
+
expect(evalFilter(makeProduct({ brandName: 'Samsung', sellerName: 'Niñas Store' }), { brands: ['Samsung'], excludeSellers: ['ninas store'] })).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
package/src/utils/eval-filter.ts
CHANGED
|
@@ -50,6 +50,37 @@ function cleanSet(arr: string[] | undefined, lower: boolean): string[] | null {
|
|
|
50
50
|
return out.length > 0 ? out : null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Normalize free text for matching: NFD-decompose + strip combining marks
|
|
55
|
+
* (á→a, é→e, ñ→n, …), lowercase, collapse whitespace, trim. Deterministic.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeText(s: string): string {
|
|
58
|
+
return s
|
|
59
|
+
.normalize('NFD')
|
|
60
|
+
.replace(/[̀-ͯ]/g, '')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Like cleanSet but normalizeText each element (drops blanks); null if none. */
|
|
67
|
+
function cleanNormalizedSet(arr: string[] | undefined): string[] | null {
|
|
68
|
+
if (!Array.isArray(arr)) return null;
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
for (const v of arr) {
|
|
71
|
+
if (typeof v !== 'string') continue;
|
|
72
|
+
const t = normalizeText(v);
|
|
73
|
+
if (t.length === 0) continue;
|
|
74
|
+
out.push(t);
|
|
75
|
+
}
|
|
76
|
+
return out.length > 0 ? out : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Split normalized text into alphanumeric tokens (whole-word matching). */
|
|
80
|
+
function tokenize(normalized: string): string[] {
|
|
81
|
+
return normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
const finite = (n: number | undefined): n is number => typeof n === 'number' && Number.isFinite(n);
|
|
54
85
|
|
|
55
86
|
/**
|
|
@@ -67,16 +98,16 @@ export function evalFilter(product: Product, filter: ChannelFilter): boolean {
|
|
|
67
98
|
const categories = cleanSet(filter.categories, true);
|
|
68
99
|
const brands = cleanSet(filter.brands, true);
|
|
69
100
|
const stores = cleanSet(filter.stores, false); // storeRef is an id — exact, no lowercase
|
|
70
|
-
const sellers =
|
|
71
|
-
const words =
|
|
101
|
+
const sellers = cleanNormalizedSet(filter.sellers);
|
|
102
|
+
const words = cleanNormalizedSet(filter.words);
|
|
72
103
|
// minDiscountPct of 0 (or negative/NaN) is "no floor" => inactive
|
|
73
104
|
const minDiscountPct = finite(filter.minDiscountPct) && filter.minDiscountPct > 0 ? filter.minDiscountPct : null;
|
|
74
105
|
const minPrice = finite(filter.minPrice) ? filter.minPrice : null;
|
|
75
106
|
const maxPrice = finite(filter.maxPrice) ? filter.maxPrice : null;
|
|
76
107
|
|
|
77
108
|
// Exclusion fields — only narrow; they do NOT satisfy the safety floor below.
|
|
78
|
-
const excludeWords =
|
|
79
|
-
const excludeSellers =
|
|
109
|
+
const excludeWords = cleanNormalizedSet(filter.excludeWords);
|
|
110
|
+
const excludeSellers = cleanNormalizedSet(filter.excludeSellers);
|
|
80
111
|
const excludeMarketplace = filter.excludeMarketplace === true;
|
|
81
112
|
|
|
82
113
|
// Safety floor (anti-spam): require at least one POSITIVE field. Exclusion-only
|
|
@@ -117,12 +148,12 @@ export function evalFilter(product: Product, filter: ChannelFilter): boolean {
|
|
|
117
148
|
}
|
|
118
149
|
|
|
119
150
|
if (sellers) {
|
|
120
|
-
const s = product.sellerName
|
|
151
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
121
152
|
if (!s || !sellers.includes(s)) return false;
|
|
122
153
|
}
|
|
123
154
|
|
|
124
155
|
if (words) {
|
|
125
|
-
const name = product.name
|
|
156
|
+
const name = normalizeText(product.name ?? '');
|
|
126
157
|
if (!name || !words.some((w) => name.includes(w))) return false;
|
|
127
158
|
}
|
|
128
159
|
|
|
@@ -130,12 +161,23 @@ export function evalFilter(product: Product, filter: ChannelFilter): boolean {
|
|
|
130
161
|
if (excludeMarketplace && product.isMarketplace === true) return false;
|
|
131
162
|
|
|
132
163
|
if (excludeWords) {
|
|
133
|
-
const name = product.name
|
|
134
|
-
if (name
|
|
164
|
+
const name = normalizeText(product.name ?? '');
|
|
165
|
+
if (name) {
|
|
166
|
+
const tokens = new Set(tokenize(name));
|
|
167
|
+
for (const e of excludeWords) {
|
|
168
|
+
// Whole-word match for single alphanumeric tokens (so "kit" doesn't drop
|
|
169
|
+
// "KitchenAid"). Any entry carrying a non-alphanumeric char — a space
|
|
170
|
+
// ("play station") OR a hyphen/punctuation ("play-station") — can never be
|
|
171
|
+
// a single token, so fall through to substring matching instead of a token
|
|
172
|
+
// lookup that would always miss.
|
|
173
|
+
const hit = /[^a-z0-9]/.test(e) ? name.includes(e) : tokens.has(e);
|
|
174
|
+
if (hit) return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
135
177
|
}
|
|
136
178
|
|
|
137
179
|
if (excludeSellers) {
|
|
138
|
-
const s = product.sellerName
|
|
180
|
+
const s = product.sellerName ? normalizeText(product.sellerName) : '';
|
|
139
181
|
if (s && excludeSellers.includes(s)) return false;
|
|
140
182
|
}
|
|
141
183
|
|