diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 22160276..b2d63313 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -284,8 +284,8 @@ async function runSuite() { ['iPad Air 5 64GB', 'apple:ipad-air-5:64gb'], ['Apple iPad Air (5th generation) Wi-Fi 64GB', 'apple:ipad-air-5:64gb'], ['Apple iPad Air 6 128GB', 'apple:ipad-air-m2:128gb'], - ['iPad Air 11 inch M2 128GB', 'apple:ipad-air-m2:128gb'], - ['iPad Air 13 M3 256GB', 'apple:ipad-air-m3:256gb'], + ['iPad Air 11 inch M2 128GB', 'apple:ipad-air-m2-11:128gb'], + ['iPad Air 13 M3 256GB', 'apple:ipad-air-m3-13:256gb'], ['iPad Air 128GB', 'apple:ipad-air:128gb'], ['iPad Air 5 M1 64GB', 'apple:ipad-air-m1:64gb'], ['iPad Air M1 64GB', 'apple:ipad-air-m1:64gb'], @@ -304,7 +304,7 @@ async function runSuite() { ['iPad 10 64GB so sánh với iPad A16', 'apple:ipad-10:64gb'], ['iPad 9 64GB chip A13 - A16 sắp về', 'apple:ipad-9:64gb'], // "iPad 11 Pro" is the 11-inch Pro, not the 11th generation. - ['iPad 11 Pro M4 256GB', 'apple:ipad-pro-m4:256gb'], + ['iPad 11 Pro M4 256GB', 'apple:ipad-pro-m4-11:256gb'], // Apple lines with no pattern of their own are not iPhones. ['Apple TV 4K (3rd generation) 128GB', 'apple:tv-4k-3:128gb'], ['Apple Vision Pro 256GB', 'apple:vision-pro:256gb'], @@ -346,6 +346,134 @@ async function runSuite() { for (const [title, expectedKey] of appleKeys819) { assert.equal(keyOf(title), expectedKey, title); } + // An Apple line sold in more than one size keys each size apart (DOS/Rate#1113). + // Labeled fixtures: title -> key. + const appleSizeKeys1113: Array<[string, string]> = [ + // The names of the published masters key to their stored keys. + ['Apple iPad Pro M5 11 inch 256GB', 'apple:ipad-pro-m5-11:256gb'], + ['Apple MacBook Pro M4 Pro 14 inch 512GB', 'apple:macbook-pro-m4-pro-14:512gb'], + ['Apple Watch Series 11 46mm GPS 64GB', 'apple:watch-series-11-46mm:64gb'], + // The other size is another product. + ['iPad Pro M4 13 inch 512GB', 'apple:ipad-pro-m4-13:512gb'], + ['iPad Pro 13" M5 1TB', 'apple:ipad-pro-m5-13:1024gb'], + ['iPad Pro 11 2024 M4 256GB', 'apple:ipad-pro-m4-11:256gb'], + ['iPad Pro 12.9 inch M2 128GB', 'apple:ipad-pro-m2-129:128gb'], + ['iPad Pro 12,9 M2 128GB', 'apple:ipad-pro-m2-129:128gb'], + ['MacBook Air 15 M3 16GB 512GB', 'apple:macbook-air-m3-15:512gb'], + ['MacBook Air M2 8GB/256GB 13.6 inch', 'apple:macbook-air-m2-13:256gb'], + ['MacBook Pro M4 Max 16 inch 48GB 1TB', keyOf('MacBook Pro M4 Max 16" 48GB 1TB')], + ['Apple Watch Series 10 42mm GPS 64GB', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 11 GPS + Cellular 42mm', 'apple:watch-series-11-42mm:64gb'], + // A RAM size, a RAM/ROM pair or a core count is not a screen size, and a size with + // "inch" wins over a bare number. + ['MacBook Air M3 16GB 256GB', 'apple:macbook-air-m3:256gb'], + ['MacBook Air M4 16/256', 'apple:macbook-air-m4:unknown'], + ['MacBook Pro M4 16-core GPU 14 inch 512GB', 'apple:macbook-pro-m4-14:512gb'], + ['iPad Pro M4 11-core 256GB', 'apple:ipad-pro-m4:256gb'], + ['MacBook Pro M4 Max 16 nhân CPU 40 nhân GPU 48GB 1TB', keyOf('MacBook Pro M4 Max 48GB 1TB')], + ['MacBook Pro M3 Pro 18GB 512GB - 14 lõi GPU', 'apple:macbook-pro-m3-pro:512gb'], + ['MacBook Air M3 16G 512G', keyOf('MacBook Air M3 512G')], + ['MacBook Air M3 RAM 16 SSD 512GB', keyOf('MacBook Air M3 RAM SSD 512GB')], + // An Air comes in 13 and 15 inch; an OS version or a part code is no size. + ['MacBook Air M2 8GB 256GB macOS 14 Sonoma', 'apple:macbook-air-m2:256gb'], + ['MacBook Air M2 Z15S 8GB 256GB', 'apple:macbook-air-m2:256gb'], + ['MacBook Air M3 256GB macOS 14', 'apple:macbook-air-m3:256gb'], + ['HN Macbook Air M4 16 256 macbook', keyOf('Macbook Air M4 256')], + [ + 'MacBook Pro M4 Pro CPU 14 nhân GPU 20 nhân RAM 24GB SSD 512GB', + 'apple:macbook-pro-m4-pro:512gb', + ], + ['iPad Pro M4 13.0 inch 256GB', 'apple:ipad-pro-m4-13:256gb'], + // A bundled accessory's size is not the product's; "GPS + Cellular" is the watch. + ['iPad Pro M4 256GB + bàn phím 11 inch', 'apple:ipad-pro-m4:256gb'], + ['iPad Pro 11 inch M4 + Magic Keyboard 256GB', 'apple:ipad-pro-m4-11:256gb'], + ['MacBook Pro M4 Pro 16″ 24GB 512GB', 'apple:macbook-pro-m4-pro-16:512gb'], + // A title without its size keeps the size-less key and goes to Tier 2. + ['Máy tính bảng Apple iPad Pro M4 256GB WiFi', 'apple:ipad-pro-m4:256gb'], + ['Apple Watch Series 11 64GB', 'apple:watch-series-11:64gb'], + // One-size lines never carry a size. + ['Apple Watch Ultra 3 49mm 64GB', 'apple:watch-ultra-3:64gb'], + ['Apple Watch Ultra 2 49mm 64GB', 'apple:watch-ultra-2:64gb'], + ['iPad Air 5 10.9 inch 64GB', 'apple:ipad-air-5:64gb'], + ['iPad mini 7 8.3 inch 128GB', 'apple:ipad-mini-7:128gb'], + ['MacBook Neo 13 inch 256GB', 'apple:macbook-neo:256gb'], + // A size after "cho" belongs to the product the accessory fits. + ['Ốp lưng iPad Pro M4 13 inch cho iPad Pro 11', 'apple:ipad-pro-m4-13:unknown'], + ]; + for (const [title, expectedKey] of appleSizeKeys1113) { + assert.equal(keyOf(title), expectedKey, title); + } + // DOS/Rate#1124: a size outside a band phrase is the case's; a band's size counts only + // when the title names no other. + const watchBandSizeKeys1124: Array<[string, string]> = [ + // Retailer titles: case size first, then the band. + [ + 'Apple Watch Series 11 - GPS, 46mm - Viền Nhôm Dây Cao Su S/M', + 'apple:watch-series-11-46mm:64gb', + ], + ['Apple Watch Series 11 GPS 46mm viền nhôm dây thể thao', 'apple:watch-series-11-46mm:64gb'], + ['Apple Watch Series 10 42mm (GPS) Viền Nhôm - Dây Vải', 'apple:watch-series-10-42mm:unknown'], + ['Apple Watch Series 11 42mm viền thép dây Link Bracelet', 'apple:watch-series-11-42mm:64gb'], + // Two sizes, one of them the band's, leave the size-less key (Tier 2). + ['Apple Watch Series 10 dây Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Loop 45mm cho mặt 46mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], + ['Apple Watch SE 3 GPS Strap 40mm for 44mm case', 'apple:watch-se-3:unknown'], + ['Apple Watch Series 10 Bracelet 42mm | 46mm', 'apple:watch-series-10-46mm:unknown'], + // A band is sold by the case it fits: a band's size alone is the case's. + [ + 'Apple Watch Series 10 GPS Dây Cao Su 46mm Chính Hãng VN/A', + 'apple:watch-series-10-46mm:unknown', + ], + ['Apple Watch Series 9 GPS Sport Loop 45mm', 'apple:watch-series-9-45mm:unknown'], + ['Apple Watch Series 11 GPS Viền Nhôm Dây Silicone 42mm', 'apple:watch-series-11-42mm:64gb'], + // The band phrase ends at ")", ",", "|", ";", an en dash or " - ": a case size after it + // wins over the band's. + ['Apple Watch Series 11 GPS (Viền nhôm, dây cao su) 46mm', 'apple:watch-series-11-46mm:64gb'], + ['Apple Watch Series 10 dây Sport Band 45mm, mặt 42mm', 'apple:watch-series-10-42mm:unknown'], + ['Apple Watch Series 10 Dây Vải 45mm - 46mm GPS', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Dây Vải 45mm; 46mm GPS', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Dây Vải 45mm \u2013 46mm GPS', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 (dây vải 45mm) 46mm', 'apple:watch-series-10-46mm:unknown'], + // A glued "Sportband" is a band; "không dây" is wireless, no band. + ['Apple Watch Series 10 Sportband 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 sạc không dây 46mm cho dây 42mm', 'apple:watch-series-10-46mm:unknown'], + [ + 'Apple Watch Series 10 sạc kho\u0302ng dây 46mm cho dây 42mm', + 'apple:watch-series-10-46mm:unknown', + ], + // A size in brackets after the band is the case's when it is the only one. + [ + 'Apple Watch SE 2024 Viền nhôm dây cao su [40mm/GPS] - Mới', + 'apple:watch-se-2024-40mm:unknown', + ], + // A decomposed or upper-case "dây" is the same word; "Bandit" is no band. + ['Apple Watch Series 10 da\u0302y vải 45mm, 42mm', 'apple:watch-series-10-42mm:unknown'], + ['APPLE WATCH SERIES 10 DÂY VẢI 45MM, 42MM', 'apple:watch-series-10-42mm:unknown'], + ['Apple Watch Series 10 Bandit 46mm cho mặt 42mm', 'apple:watch-series-10-46mm:unknown'], + ]; + for (const [title, expectedKey] of watchBandSizeKeys1124) { + assert.equal(keyOf(title), expectedKey, title); + } + // The registry has one entry per line: a sized title keeps the line's benchmark, but + // the entry's screen and battery describe only the size it was written for. + assert.equal( + normalizer.normalize(createDummyShopeeProduct('Apple Watch Series 11 GPS 42mm')) + .is_benchmark_enriched, + true + ); + const ipadPro13 = normalizer.normalize( + createDummyShopeeProduct('iPad Pro M4 13 inch WiFi 256GB') + ); + assert.equal(ipadPro13.screen_size_inch, 13); + assert.equal(ipadPro13.battery_mah, null); + const ipadPro11 = normalizer.normalize(createDummyShopeeProduct('iPad Pro M4 11 inch 256GB')); + assert.equal(ipadPro11.screen_size_inch, 11); + assert.equal(ipadPro11.battery_mah, 8160); + assert.equal( + normalizer.normalize(createDummyShopeeProduct('MacBook Air M3 15 inch 256GB')).screen_size_inch, + 15 + ); // iPad mini 6 and 7 are two models (DOS/Rate#819): "(A17 Pro)" is the 7th, "8.3 inch" is // its screen, and a size or a year after "mini" is no generation. assert.equal(keyOf('Apple iPad mini 6 Wi-Fi 64GB'), 'apple:ipad-mini-6:64gb'); @@ -403,15 +531,15 @@ async function runSuite() { assert.ok(!/\s{2}/.test(ultra2.name), ultra2.name); assert.equal(keyOf('Apple Watch Series'), 'apple:watch:unknown'); // A case size or storage after "SE" is not its generation: one SE, not "SE 40"; an - // ordinal one is ("SE 2nd Gen"). - assert.equal(keyOf('Apple Watch SE 40mm'), 'apple:watch-se:unknown'); - assert.equal(keyOf('Apple Watch SE 44mm GPS'), 'apple:watch-se:unknown'); - assert.equal(keyOf('Apple Watch SE 44 mm GPS'), 'apple:watch-se:unknown'); - assert.equal(keyOf('Apple Watch SE 2nd Gen GPS 44mm'), 'apple:watch-se-2:unknown'); - assert.equal(keyOf('Apple Watch SE 3rd Gen 44mm'), 'apple:watch-se-3:unknown'); - assert.equal(keyOf('Apple Watch SE 2022 40mm'), 'apple:watch-se-2022:unknown'); + // ordinal one is ("SE 2nd Gen"). The case size keys apart (DOS/Rate#1113). + assert.equal(keyOf('Apple Watch SE 40mm'), 'apple:watch-se-40mm:unknown'); + assert.equal(keyOf('Apple Watch SE 44mm GPS'), 'apple:watch-se-44mm:unknown'); + assert.equal(keyOf('Apple Watch SE 44 mm GPS'), 'apple:watch-se-44mm:unknown'); + assert.equal(keyOf('Apple Watch SE 2nd Gen GPS 44mm'), 'apple:watch-se-2-44mm:unknown'); + assert.equal(keyOf('Apple Watch SE 3rd Gen 44mm'), 'apple:watch-se-3-44mm:unknown'); + assert.equal(keyOf('Apple Watch SE 2022 40mm'), 'apple:watch-se-2022-40mm:unknown'); assert.equal(keyOf('Apple Watch SE 32GB'), 'apple:watch-se:32gb'); - assert.equal(keyOf('Apple Watch SE2 40mm'), 'apple:watch-se2:unknown'); + assert.equal(keyOf('Apple Watch SE2 40mm'), 'apple:watch-se2-40mm:unknown'); // With no line matched, the family the title names first, else iPhone. assert.equal(keyOf('MacBook M4'), 'apple:macbook:unknown'); assert.equal(keyOf('Apple "iPad" 256GB'), 'apple:ipad:256gb'); @@ -532,20 +660,20 @@ async function runSuite() { assert.equal(ipad.model_key, 'apple:ipad-pro-m4:256gb'); assert.equal(ipad.is_benchmark_enriched, true); assert.equal(ipad.specifications.hardware?.chipset, 'Apple M4 (3 nm)'); - // Retail titles put the chip after the screen size; it must still reach the key. + // Retail titles put the chip after the screen size; it must still reach the key, and + // the screen size keys the 11 and 13 inch models apart (DOS/Rate#1113). for (const title of [ 'Máy tính bảng Apple iPad Pro 11 inch M4 Wi-Fi 256GB', 'Apple iPad Pro 11 inch M4 WiFi 256GB - Chính hãng', ]) { - assert.equal( - normalizer.normalize(createDummyShopeeProduct(title)).model_key, - 'apple:ipad-pro-m4:256gb', - title - ); + const sized = normalizer.normalize(createDummyShopeeProduct(title)); + assert.equal(sized.model_key, 'apple:ipad-pro-m4-11:256gb', title); + // The registry has one entry per line, so a sized title is still enriched. + assert.equal(sized.is_benchmark_enriched, true, title); } assert.equal( normalizer.normalize(createDummyShopeeProduct('MacBook Air 13 inch M3 8GB 256GB')).model_key, - 'apple:macbook-air-m3:256gb' + 'apple:macbook-air-m3-13:256gb' ); assert.equal( normalizer.normalize(createDummyShopeeProduct('Tai nghe Apple AirPods Pro 2 USB-C MTJV3')) @@ -623,7 +751,7 @@ async function runSuite() { ['Apple iPhone 18 Pro Max', 'apple:iphone-18-pro-max:unknown'], ['Apple iPhone 18 Pro', 'apple:iphone-18-pro:unknown'], ['Lenovo ThinkPad X1 Carbon Gen 12 Ultra 7 155U', 'lenovo:thinkpad-x1-carbon-gen-12:unknown'], - ['Máy tính bảng iPad Pro M5 11 inch WiFi', 'apple:ipad-pro-m5:unknown'], + ['Máy tính bảng iPad Pro M5 11 inch WiFi', 'apple:ipad-pro-m5-11:unknown'], ['Máy tính bảng Xiaomi Pad 7 Pro', 'xiaomi:pad-7-pro:unknown'], ]; for (const [title, expectedKey] of storagelessRegistryTitles) { @@ -648,7 +776,7 @@ async function runSuite() { // title naming it (the single-storage watches above keep their keys). assert.equal( normalizer.normalize(createDummyShopeeProduct('Apple Watch Series 11 GPS 46mm')).model_key, - 'apple:watch-series-11:64gb' + 'apple:watch-series-11-46mm:64gb' ); const sonyBuds = normalizer.normalize( @@ -1385,14 +1513,14 @@ async function runSuite() { const ipadProM5Ram = normalizer.normalize( createDummyShopeeProduct('iPad Pro 11 M5 2025 WiFi 12GB') ); - assert.equal(ipadProM5Ram.model_key, 'apple:ipad-pro-m5:unknown'); + assert.equal(ipadProM5Ram.model_key, 'apple:ipad-pro-m5-11:unknown'); assert.equal(ipadProM5Ram.ram_gb, 12); assert.equal(ipadProM5Ram.storage_gb, null); const ipadAirRamOnly = normalizer.normalize( createDummyShopeeProduct('iPad Air 11 M3 2025 WiFi 8GB') ); - assert.equal(ipadAirRamOnly.model_key, 'apple:ipad-air-m3:unknown'); + assert.equal(ipadAirRamOnly.model_key, 'apple:ipad-air-m3-11:unknown'); assert.equal(ipadAirRamOnly.ram_gb, 8); assert.equal(ipadAirRamOnly.storage_gb, null); diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 872b757b..ed689baa 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1089,6 +1089,74 @@ function isProductCodeToken(token: string): boolean { ); } +// Screen sizes an Apple line is sold in. A bare number counts only on its own: not in a +// part code ("Z15S"), after "RAM" or an OS name ("macOS 14"), or before a storage, a +// RAM/ROM pair or a core count ("16GB", "16G", "16/512", "16-core", "16 nhân CPU"). +// Titles carry single spaces, so each gap is one optional space (CodeQL +// js/polynomial-redos). +const SIZE_NOT_PRECEDED_BY = String.raw`(? [ + new RegExp(String.raw`(? = { + ipad: sizePatterns(IPAD_SIZE), + 'macbook air': sizePatterns(MACBOOK_AIR_SIZE), + 'macbook pro': sizePatterns(MACBOOK_PRO_SIZE), +}; + +/** + * The size of an Apple line sold in more than one: "11", "12.9" or "13" for an iPad Pro + * or Air, "13" or "15" for a MacBook Air, "13" to "16" for a MacBook Pro, "42mm" or + * "46mm" for a Watch Series or SE. Read from the product's own part of the title, after + * its family word and before a "cho/for" accessory phrase or a "+" bundle; a size with + * "inch" wins over + * a bare number (DOS/Rate#1113). + */ +function readAppleSize(model: string, title: string): string { + const family = /^ipad (?:pro|air)\b/i.test(model) + ? 'ipad' + : /^macbook air\b/i.test(model) + ? 'macbook air' + : /^macbook pro\b/i.test(model) + ? 'macbook pro' + : /^watch (?:series|se\d?)\b/i.test(model) + ? 'watch' + : ''; + if (!family) return ''; + const at = title.search(new RegExp(`\\b${family.split(' ')[0]}\\b`, 'i')); + // A bundle's "+ bàn phím 11 inch" is another product; "GPS + Cellular" is the watch. + const own = title.slice(Math.max(0, at)).split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; + if (family === 'watch') { + // A size outside a band phrase ("dây", "band", "strap", "loop" or "bracelet" up to the + // next ")", ",", "|", ";", en dash or " - ") is the case's. A band is sold by the case + // it fits ("Dây Cao Su 46mm"), so a band's size counts only when the title names no + // other: "dây Sport Band 45mm cho mặt 42mm" does not tell the watch's (DOS/Rate#1124). + const caseSize = own + .split(/[),|;\u2013]| - /) + .map(part => part.split(WATCH_BAND_WORD)[0]) + .join(' ') + .match(WATCH_SIZE)?.[1]; + if (caseSize) return `${caseSize}mm`; + const bandSize = own.match(WATCH_SIZE)?.[1]; + const sizes = new Set( + [...title.slice(Math.max(0, at)).matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1]) + ); + return bandSize && sizes.size === 1 ? `${bandSize}mm` : ''; + } + const [marked, bare] = APPLE_SIZE_PATTERNS[family]!; + const size = (own.match(marked) ?? own.match(bare))?.[1]; + return size ? size.replace(',', '.') : ''; +} + function slugify(text: string): string { return ( String(text ?? '') @@ -1162,6 +1230,7 @@ export class ProductNormalizer { // 2. Extract Model let model = ''; + let appleSize = ''; // Spellings that split a tier word from its model number read like the joined ones // (DOS/Rate#1090). `title` has single spaces, so each gap is one optional space. // Each rewrite applies only where the name ends right after it: the title ends, or a @@ -1354,6 +1423,12 @@ export class ProductNormalizer { // Retailers name an Apple TV 4K or HomePod by its year as often as by its generation. model = `${model} ${APPLE_GENERATION_BY_YEAR[`${model} ${generation[1]}`] ?? generation[1]}`; } + // A line Apple sells in more than one size keys each size apart: the 11 and 13 inch + // iPad Pro, or the 42 and 46mm Watch Series, are two products at two prices. A title + // without its size keeps the size-less key, and one-size lines (Watch Ultra 49mm, + // iPad mini, MacBook Neo) never carry one (DOS/Rate#1113). + appleSize = readAppleSize(model, appleTitle); + if (appleSize) model = `${model} ${appleSize}`; } else if (brand === 'Sony' && /w([fh])-?1000xm(\d+)/i.test(modelTitle)) { // "WF1000XM5" without the hyphen is the same model (DOS/Rate#815). const m = modelTitle.match(/w([fh])-?1000xm(\d+)/i); @@ -1529,8 +1604,12 @@ export class ProductNormalizer { ); const baseKey = `${slugify(brand)}:${slugify(model)}`; - const benchmark = Object.hasOwn(CANONICAL_PHONE_REGISTRY, baseKey) - ? CANONICAL_PHONE_REGISTRY[baseKey] + // The registry holds one entry per line, not per size. + const registryKey = appleSize + ? `${slugify(brand)}:${slugify(model.slice(0, -appleSize.length))}` + : baseKey; + const benchmark = Object.hasOwn(CANONICAL_PHONE_REGISTRY, registryKey) + ? CANONICAL_PHONE_REGISTRY[registryKey] : undefined; const toValidStorageGb = (val: number): number | null => @@ -1652,9 +1731,20 @@ export class ProductNormalizer { categorySlug === 'earbuds' || categorySlug === 'smartwatches' ? null : (validClaimedRam ?? benchmark?.ram_gb ?? null); + // The registry describes one size of a line: its screen and battery do not describe + // the other size ("iPad Pro M4 13" is not the 11 inch entry) (DOS/Rate#1113). + const appleInches = appleSize && !appleSize.endsWith('mm') ? parseFloat(appleSize) : null; + const benchmarkSized = + benchmark && + (appleInches === null || + Math.floor(benchmark.screen_size_inch ?? NaN) === Math.floor(appleInches)) + ? benchmark + : undefined; const screenSize = - categorySlug === 'earbuds' ? null : (benchmark?.screen_size_inch ?? titleScreenSize); - const batteryMah = benchmark ? benchmark.battery_mah : null; + categorySlug === 'earbuds' + ? null + : (benchmarkSized?.screen_size_inch ?? titleScreenSize ?? appleInches); + const batteryMah = benchmarkSized ? benchmarkSized.battery_mah : null; const has5g = benchmark ? benchmark.has_5g : categorySlug === 'smartphones' || /\b5g\b/i.test(title); diff --git a/apps/web/e2e/phone-menu-focus-trap.spec.ts b/apps/web/e2e/phone-menu-focus-trap.spec.ts index 52bcf337..7907622f 100644 --- a/apps/web/e2e/phone-menu-focus-trap.spec.ts +++ b/apps/web/e2e/phone-menu-focus-trap.spec.ts @@ -189,3 +189,86 @@ test('Tab after a click on a blank spot stays in the phone menu', async ({ if (key === 'Tab') expect(state.isClose).toBe(true); } }); + +// Content added while the menu is open is inert too, and closing the menu +// clears only the inert it set: an element that other code made inert, +// before or while the menu was open, stays inert (DOS/Rate#1110). +test('the open phone menu covers late content and keeps foreign inert', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + // Inert before the menu opens: not ours to clear. + await page.evaluate(() => { + document.querySelector('footer')!.inert = true; + }); + const { menu } = await openMenu(page); + + await page.evaluate(() => { + const toast = document.createElement('button'); + toast.id = 'late-toast'; + toast.textContent = 'Late toast'; + document.body.append(toast); + const extra = document.createElement('a'); + extra.id = 'late-nav-link'; + extra.href = '/late'; + extra.textContent = 'Late nav link'; + document.getElementById('rate-nav-menu')!.parentElement!.append(extra); + // Other code makes an element inert that the menu already holds. + const held = [...document.querySelectorAll('[inert]')].find( + (el) => el.contains(document.querySelector('main')) + )!; + held.dataset.testHeld = ''; + held.inert = true; + }); + await expect + .poll(() => + page.evaluate(() => + ['late-toast', 'late-nav-link'].map((id) => + Boolean(document.getElementById(id)!.closest('[inert]')) + ) + ) + ) + .toEqual([true, true]); + expect(await accessibleNodes(page)).not.toContain('button:Late toast'); + + await page.keyboard.press('Escape'); + await expect(menu).not.toHaveClass(/nav-menus-wrapper-open/); + expect( + await page.evaluate(() => ({ + toast: document.getElementById('late-toast')!.inert, + link: document.getElementById('late-nav-link')!.inert, + held: document.querySelector('[data-test-held]')!.inert, + footer: document.querySelector('footer')!.inert, + })) + ).toEqual({ toast: false, link: false, held: true, footer: true }); +}); + +// At 320px (supported since DOS/Rate#809) the drawer leaves the toggle in +// view, so the live toggle is never a hidden button (DOS/Rate#1110). +test('the open phone menu leaves the toggle visible at 320px', async ({ + page, +}) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.goto('/'); + const { menu, toggle } = await openMenu(page); + const drawer = (await menu.boundingBox())!; + const button = (await toggle.boundingBox())!; + expect(drawer.x + drawer.width).toBeLessThanOrEqual(button.x); + expect( + await toggle.evaluate((el) => { + const box = el.getBoundingClientRect(); + return ( + document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2 + ) === el + ); + }) + ).toBe(true); + + // Escape hands focus back to the toggle, which is on screen. + await page.keyboard.press('Escape'); + await expect(toggle).toBeFocused(); + await expect(toggle).toBeInViewport(); +}); diff --git a/apps/web/src/app/style/scss/style.scss b/apps/web/src/app/style/scss/style.scss index bbdbc400..193a0f29 100644 --- a/apps/web/src/app/style/scss/style.scss +++ b/apps/web/src/app/style/scss/style.scss @@ -4821,8 +4821,12 @@ nav a:hover { } @media (max-width: 991.98px) { + // Narrower than the screen by enough to leave the menu toggle (30px, 12px + // from the right edge) in view: it stays live while the menu is open and + // takes focus back when it closes (DOS/Rate#1110). 100% of a fixed box + // is the viewport without a classic scrollbar, unlike 100vw. .navigation .nav-menus-wrapper { - width: 320px; + width: min(320px, calc(100% - 56px)); height: 100%; top: 0; left: -400px; diff --git a/apps/web/src/lib/focus-trap.ts b/apps/web/src/lib/focus-trap.ts index 1d1d8e97..69d63c36 100644 --- a/apps/web/src/lib/focus-trap.ts +++ b/apps/web/src/lib/focus-trap.ts @@ -63,28 +63,85 @@ export function tabFromSpot( return next ?? (backwards ? items[items.length - 1] : items[0]) ?? null; } +/** + * How many open `inertOutside` calls hold each element inert. Only elements + * in this map had their `inert` set by us; anything else that is inert + * belongs to other code and is never cleared here. + */ +const inertHolds = new Map(); + /** * Makes everything in the page `inert` except `keep` and their ancestors, so * the rest of the page leaves the focus order and the accessibility tree. - * Elements that were already inert are left alone. Returns the undo. + * Elements added to the page while it holds (a toast, a portal, new content + * after a soft navigation) become inert too. Elements that other code made + * inert are left alone, and so is an element whose `inert` other code + * changes while this holds. Returns the undo, which clears only the `inert` + * set here, once no other open call still holds the element. */ export function inertOutside(keep: readonly HTMLElement[]): () => void { - const changed: HTMLElement[] = []; - for (const kept of keep) { - for ( - let node: HTMLElement = kept; - node.parentElement && node !== document.body; - node = node.parentElement - ) { - for (const sibling of Array.from(node.parentElement.children)) { - if (!(sibling instanceof HTMLElement) || sibling.inert) continue; - if (keep.some((el) => sibling.contains(el))) continue; - sibling.inert = true; - changed.push(sibling); + // Every element this call has held, including ones it gave up because + // other code took over their `inert`: it does not take those again. + const mine = new Set(); + // Drops this call's hold; the last hold clears the `inert` set here. + function release(el: HTMLElement) { + const holds = inertHolds.get(el); + if (holds === undefined) return; + if (holds > 1) { + inertHolds.set(el, holds - 1); + } else { + inertHolds.delete(el); + el.inert = false; + } + } + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'childList') { + // A removed element is no longer held, so it is not kept or watched. + for (const node of Array.from(record.removedNodes)) { + if (!(node instanceof HTMLElement) || !mine.has(node)) continue; + release(node); + mine.delete(node); + } + continue; + } + // Our own writes are dropped below, so this change is someone else's. + if (record.target instanceof HTMLElement) + inertHolds.delete(record.target); + } + hold(); + }); + function hold() { + observer.disconnect(); + for (const kept of keep) { + for ( + let node: HTMLElement = kept; + node.parentElement && node !== document.body; + node = node.parentElement + ) { + observer.observe(node.parentElement, { childList: true }); + for (const sibling of Array.from(node.parentElement.children)) { + if (!(sibling instanceof HTMLElement) || mine.has(sibling)) continue; + if (keep.some((el) => sibling.contains(el))) continue; + const holds = inertHolds.get(sibling); + if (holds === undefined && sibling.inert) continue; + // Written only once: a second write would read, to the observer + // of the call that holds it already, as a change by other code. + if (holds === undefined) sibling.inert = true; + inertHolds.set(sibling, (holds ?? 0) + 1); + mine.add(sibling); + } } } + for (const el of mine) { + if (inertHolds.has(el)) + observer.observe(el, { attributeFilter: ['inert'] }); + } + observer.takeRecords(); } + hold(); return () => { - for (const el of changed) el.inert = false; + observer.disconnect(); + for (const el of mine) release(el); }; } diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index b47f66ef..f0439b5a 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -953,58 +953,120 @@ function isUnscoredTrustViewRow(row: RawTrustViewRow): boolean { } /** - * Follower counts for the unscored sellers among view rows; the view has - * none and the baseline needs it (#685). Missing data leaves the count out. + * The view's DOSafe score, or null when it has none. The view reads 0 for a + * seller with no threat_intel row and no synced merchants.risk_score (column + * default 0): that is a missing score (#1099). threat_intel.source is NOT + * NULL, so any string, even a blank one, marks a joined row (#1108). */ -async function followerCountsForUnscoredSellers( +function trustViewDosafeRisk(row: RawTrustViewRow): number | null { + const viewDosafeRisk = toOptionalRiskScore100(row.dosafe_risk_score); + return viewDosafeRisk === 0 && typeof row.threat_source !== 'string' + ? null + : viewDosafeRisk; +} + +/** What the view lacks for its sellers, read from rate.merchants. */ +interface TrustViewSellerExtras { + /** Unscored sellers only: the baseline needs it (#685). */ + followerCounts: Map; + /** merchants.metadata.dosafe_risk_score for sellers the view has none for. */ + metadataDosafeRisks: Map; +} + +/** + * Follower counts for the unscored sellers among view rows (#685) and the + * metadata DOSafe score for sellers with no view score (#1108). The view's + * metadata column is the product's, so both come from rate.merchants. + * Missing data leaves an entry out. + */ +async function sellerExtrasForTrustViewRows( rows: readonly RawTrustViewRow[] -): Promise> { - const ids = Array.from( - new Set( - rows - .filter((row) => row.merchant_id && isUnscoredTrustViewRow(row)) - .map((row) => row.merchant_id) - ) - ); - const counts = new Map(); - if (ids.length === 0) return counts; +): Promise { + const unscored = new Set(); + const noDosafe = new Set(); + for (const row of rows) { + if (!row.merchant_id) continue; + if (isUnscoredTrustViewRow(row)) unscored.add(row.merchant_id); + if (trustViewDosafeRisk(row) === null) noDosafe.add(row.merchant_id); + } + const extras: TrustViewSellerExtras = { + followerCounts: new Map(), + metadataDosafeRisks: new Map(), + }; + const ids = Array.from(new Set([...unscored, ...noDosafe])); + if (ids.length === 0) return extras; try { const supabase = getRateClient(); - for (let i = 0; i < ids.length; i += IN_FILTER_CHUNK_SIZE) { + const read = async (chunk: string[], select: string) => { + if (chunk.length === 0) return []; const { data, error } = await supabase .from('merchants') - .select('id, follower_count') - .in('id', ids.slice(i, i + IN_FILTER_CHUNK_SIZE)); - if (error) return counts; - for (const m of (data ?? []) as Array<{ - id: string; - follower_count: number | string | null; - }>) { - counts.set(m.id, Number(m.follower_count) || 0); + .select(select) + .in('id', chunk); + return error + ? null + : ((data ?? []) as unknown as Array<{ + id: string; + follower_count: number | string | null; + metadata_dosafe_risk_score?: unknown; + }>); + }; + let withMetadata = true; + for (let i = 0; i < ids.length; i += IN_FILTER_CHUNK_SIZE) { + const chunk = ids.slice(i, i + IN_FILTER_CHUNK_SIZE); + let rows = withMetadata + ? await read( + chunk, + 'id, follower_count, metadata_dosafe_risk_score:metadata->dosafe_risk_score' + ) + : null; + // A rejected metadata path must not cost unscored sellers their + // follower adjustment (#685): fall back to the columns #685 read. + if (!rows) { + withMetadata = false; + rows = await read( + chunk.filter((id) => unscored.has(id)), + 'id, follower_count' + ); + } + if (!rows) return extras; + for (const m of rows) { + if (unscored.has(m.id)) { + extras.followerCounts.set(m.id, Number(m.follower_count) || 0); + } + // Numbers only, as mapOfferRowToMerchantOffer reads metadata. + const metaRisk = + typeof m.metadata_dosafe_risk_score === 'number' + ? toOptionalRiskScore100(m.metadata_dosafe_risk_score) + : null; + if (noDosafe.has(m.id) && metaRisk !== null) { + extras.metadataDosafeRisks.set(m.id, metaRisk); + } } } } catch { - // The baseline without the follower adjustment is still a valid number. + // Without them the baseline and a missing DOSafe score still render. } - return counts; + return extras; } export function mapTrustViewRowToMerchantOffer( row: RawTrustViewRow, - followerCount?: number + followerCount?: number, + metadataDosafeRisk?: number | null ): MerchantOffer { const safeRow = row && typeof row === 'object' && !Array.isArray(row) ? row : ({} as RawTrustViewRow); const combinedRisk = toSafeRiskScore100(safeRow.threat_risk_score, 0); - // The view reads 0 for a seller with no threat_intel row and no synced - // merchants.risk_score (column default 0): that is a missing score (#1099). - const viewDosafeRisk = toOptionalRiskScore100(safeRow.dosafe_risk_score); + // merchants.metadata only fills a missing view score, as on the fallback + // path where metadata is read when no column score exists (#1108). const dosafeRisk = - viewDosafeRisk === 0 && !sanitizeOfferString(safeRow.threat_source) - ? null - : viewDosafeRisk; + trustViewDosafeRisk(safeRow) ?? + (typeof metadataDosafeRisk === 'number' + ? toOptionalRiskScore100(metadataDosafeRisk) + : null); const persistedReviewRisk = toSafeRiskScore100(safeRow.review_risk_score, 0); const isOfficial = isStrictOfferOfficialFlag(safeRow.merchant_is_official); const hasSeller = sanitizeOfferString(safeRow.merchant_id) !== ''; @@ -1239,9 +1301,13 @@ export async function getMasterProductOffers( ).catch(() => null); if (viewRows) { const rows = viewRows as unknown as RawTrustViewRow[]; - const followers = await followerCountsForUnscoredSellers(rows); + const extras = await sellerExtrasForTrustViewRows(rows); return rows.map((row) => - mapTrustViewRowToMerchantOffer(row, followers.get(row.merchant_id)) + mapTrustViewRowToMerchantOffer( + row, + extras.followerCounts.get(row.merchant_id), + extras.metadataDosafeRisks.get(row.merchant_id) + ) ); } @@ -1299,13 +1365,17 @@ export async function getMasterProductOffersBatch( if (viewData) { const rows = viewData as unknown as RawTrustViewRow[]; - const followers = await followerCountsForUnscoredSellers(rows); + const extras = await sellerExtrasForTrustViewRows(rows); for (const row of rows) { const cid = row.cluster_id ? String(row.cluster_id) : ''; if (!cid) continue; const list = offersByMaster.get(cid) ?? []; list.push( - mapTrustViewRowToMerchantOffer(row, followers.get(row.merchant_id)) + mapTrustViewRowToMerchantOffer( + row, + extras.followerCounts.get(row.merchant_id), + extras.metadataDosafeRisks.get(row.merchant_id) + ) ); offersByMaster.set(cid, list); } diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 989efc13..4261ecd1 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -13125,6 +13125,238 @@ async function runCoreArchitectureSuite() { ); } + // ------------------------------------------------------------------------- + // 126j. View offers select threat_source and read merchants.metadata (#1108) + // ------------------------------------------------------------------------- + { + const { + getMasterProductOffers, + getMasterProductOffersBatch, + mapTrustViewRowToMerchantOffer, + } = await import('@/lib/rate-api'); + + const viewMerchant = (extra: Record, metaRisk?: unknown) => + mapTrustViewRowToMerchantOffer( + { + id: 'o', + merchant_id: 'm', + platform: 'shopee', + title: 't', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + review_risk_score: 0, + review_risk_breakdown: {}, + ...extra, + } as never, + undefined, + metaRisk as never + ).merchant; + // Metadata fills a missing view score only. + assert.equal( + viewMerchant({ dosafe_risk_score: 0 }, 7).dosafe_risk_score, + 7 + ); + assert.equal(viewMerchant({ dosafe_risk_score: 0 }, 7).risk_score, 15); + assert.equal(viewMerchant({}, 60).risk_score, 60); + assert.equal( + viewMerchant({ dosafe_risk_score: 0, threat_source: 'dosafe' }, 7) + .dosafe_risk_score, + 0 + ); + assert.equal( + viewMerchant({ dosafe_risk_score: 35 }, 7).dosafe_risk_score, + 35 + ); + // threat_intel.source is NOT NULL: a blank one is still a joined row's 0; + // a null one is the LEFT JOIN miss. + assert.equal( + viewMerchant({ dosafe_risk_score: 0, threat_source: '' }) + .dosafe_risk_score, + 0 + ); + assert.equal( + viewMerchant({ dosafe_risk_score: 0, threat_source: null }) + .dosafe_risk_score, + null + ); + assert.equal( + viewMerchant({ dosafe_risk_score: 0 }, '7').dosafe_risk_score, + null + ); + assert.equal( + viewMerchant({ dosafe_risk_score: 0 }, 250).dosafe_risk_score, + 100 + ); + + const viewRow1108 = (id: string, merchantId: string, dosafe: number) => ({ + id, + cluster_id: 'master-1108', + merchant_id: merchantId, + platform: 'shopee', + title: 't', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + threat_risk_score: dosafe, + dosafe_risk_score: dosafe, + review_risk_score: 42, + review_risk_breakdown: { total_reviews: 3 }, + }); + const viewSelects1108: string[] = []; + const merchantIds1108: string[] = []; + const json1108 = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + ); + if (url.pathname.endsWith('/v_products_with_merchant_trust')) { + viewSelects1108.push(url.searchParams.get('select') ?? ''); + return json1108( + Number(url.searchParams.get('offset') ?? 0) > 0 + ? [] + : [ + viewRow1108('o1', 'm-meta', 0), + viewRow1108('o2', 'm-scored', 35), + ] + ); + } + if (url.pathname.endsWith('/merchants')) { + merchantIds1108.push(url.searchParams.get('id') ?? ''); + assert.match( + url.searchParams.get('select') ?? '', + /metadata->dosafe_risk_score/ + ); + return json1108([ + { + id: 'm-meta', + follower_count: 900, + metadata_dosafe_risk_score: 7, + }, + ]); + } + return json1108([]); + }) as typeof fetch; + + const offers1108 = await getMasterProductOffers('master-1108'); + const batch1108 = await getMasterProductOffersBatch(['master-1108']); + for (const offers of [offers1108, batch1108.get('master-1108') ?? []]) { + assert.deepEqual( + offers.map((o) => [o.id, o.merchant.dosafe_risk_score]), + [ + ['o1', 7], + ['o2', 35], + ] + ); + // A scored seller gets no follower count from the extra read (#685). + assert.equal(offers[0]?.merchant.follower_count, 0); + } + // Without threat_source every view 0 would read as missing (#1099). + assert.ok(viewSelects1108.length > 0); + for (const select of viewSelects1108) { + assert.ok( + select + .split(',') + .map((c) => c.trim()) + .includes('threat_source'), + `TRUST_VIEW_SELECT must include threat_source: ${select}` + ); + } + // Only the seller without a view score is read. + assert.deepEqual(merchantIds1108, ['in.(m-meta)', 'in.(m-meta)']); + + // An unscored seller without a view score gets both values; a rejected + // metadata path still leaves the follower adjustment (#685); a row with + // no seller is not read. + const unscoredRow1108 = { + ...viewRow1108('o3', 'm-new', 0), + review_risk_score: 0, + review_risk_breakdown: {}, + }; + const noSellerRow1108 = { ...viewRow1108('o4', '', 0), merchant_id: '' }; + const runOffers1108 = async (rejectMetadata: boolean) => { + const selects: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + ); + if (url.pathname.endsWith('/v_products_with_merchant_trust')) { + return json1108( + Number(url.searchParams.get('offset') ?? 0) > 0 + ? [] + : [unscoredRow1108, noSellerRow1108] + ); + } + if (url.pathname.endsWith('/merchants')) { + const select = url.searchParams.get('select') ?? ''; + selects.push(`${select} | ${url.searchParams.get('id')}`); + if (rejectMetadata && select.includes('metadata')) { + return new Response( + JSON.stringify({ message: 'simulated metadata path failure' }), + { status: 400, headers: { 'content-type': 'application/json' } } + ); + } + return json1108([ + { + id: 'm-new', + follower_count: 60000, + ...(select.includes('metadata') + ? { metadata_dosafe_risk_score: 12 } + : {}), + }, + ]); + } + return json1108([]); + }) as typeof fetch; + const offers = await getMasterProductOffers('master-1108'); + return { offers, selects }; + }; + const both1108 = await runOffers1108(false); + assert.deepEqual( + both1108.offers.map((o) => [ + o.id, + o.merchant.dosafe_risk_score, + o.merchant.review_risk_score, + o.merchant.follower_count, + ]), + [ + ['o3', 12, 10, 60000], + ['o4', null, 42, 0], + ] + ); + assert.equal(both1108.selects.length, 1); + assert.match(both1108.selects[0] ?? '', /\| in\.\(m-new\)$/); + const rejected1108 = await runOffers1108(true); + assert.deepEqual( + rejected1108.offers.map((o) => [ + o.id, + o.merchant.dosafe_risk_score, + o.merchant.review_risk_score, + ]), + [ + ['o3', null, 10], + ['o4', null, 42], + ] + ); + assert.deepEqual(rejected1108.selects.slice(1), [ + 'id,follower_count | in.(m-new)', + ]); + + console.log( + ' ✅ [126j/135] View offers select threat_source and read merchants.metadata (#1108) passed' + ); + } + // ------------------------------------------------------------------------- // 127. Rate Server Index & Extension Entity, Slug, and Category Bounds (#1029) // ------------------------------------------------------------------------- diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index d0e226b7..09c94fc6 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -694,6 +694,79 @@ function testPriceRules(): void { ]) { assert.equal(rules.minimumListingPriceVnd([title]), 400_000, title); } + // Glued tiers and a glued brand are earbuds; over-ear WH- headphones and + // other devices keep the device floor (DOS/Rate#1116). + for (const title of [ + 'Samsung Galaxy BudsPro3', + 'Samsung Galaxy Buds Pro 3', + 'Huawei FreeBudsPro4', + 'Huawei FreeBuds5i', + 'Huawei FreeBuds 5i', + 'SamsungGalaxyBuds3', + 'Galaxy Buds3 Pro', + 'Galaxy Buds2Pro', + 'Sony WF-1000XM5', + 'Apple AirPods Pro 2', + 'AirPodsPro', + 'samsung:galaxy-buds3-pro:unknown', + ]) { + assert.equal(rules.minimumListingPriceVnd([title]), 400_000, title); + } + for (const title of [ + 'Sony WH-1000XM5', + 'Sony WH-CH720N', + 'Samsung Galaxy S24', + 'Galaxy Watch7', + 'Samsung Galaxy Z Flip', + 'Buddha statue', + 'sony:wh-1000xm5:unknown', + ]) { + assert.equal(rules.minimumListingPriceVnd([title]), 1_200_000, title); + } + // Any brand glued to the name, two glued tiers and a digit after "Pro" are + // earbuds; an over-ear title stays a device even with "tai nghe" in it + // (DOS/Rate#1125). + for (const title of [ + 'PixelBudsPro2', + 'Google PixelBudsPro2', + 'HuaweiFreeBudsPro4', + 'HuaweiFreeBuds5i', + 'SonyWF1000XM5', + 'SonyWF-1000XM5', + 'AirPodsPro2', + 'Apple AirPodsPro2', + 'Buds3ProFE', + 'Samsung Galaxy Buds3ProFE', + 'XiaomiRedmiBuds5Pro', + 'SamsungBuds3', + 'OppoEncoBuds2', + 'pin 0.5Wh tai nghe', + ]) { + assert.equal(rules.minimumListingPriceVnd([title]), 400_000, title); + } + for (const title of [ + 'Tai nghe Sony WH-1000XM5', + 'Tai nghe chụp tai Sony WH1000XM4', + 'Tai nghe Bluetooth Sony WH-CH720N', + 'SonyWH-XB910N tai nghe', + 'Tai nghe Sony WH 1000XM5', + 'Sony_WH-1000XM5 tai nghe', + 'Tai nghe Sony WH-L600', + 'Tai nghe Sony WH-RF400', + 'https://repairpods.vn/iphone-16', + 'https://shop.vn/tai-nghe-sony-wh-1000xm5', + 'Which phone', + ]) { + assert.equal(rules.minimumListingPriceVnd([title]), 1_200_000, title); + } + assert.equal( + rules.minimumListingPriceVnd([ + 'Tai nghe Sony WH-1000XM5', + 'https://shop.vn/tai-nghe-sony', + ]), + 1_200_000, + 'an over-ear title wins over "tai-nghe" in the URL' + ); assert.equal( rules.minimumListingPriceVnd(['galaxy buds+']), 400_000, diff --git a/packages/shared-data/src/rules/listing-price-floor.ts b/packages/shared-data/src/rules/listing-price-floor.ts index 6590087a..c7669e83 100644 --- a/packages/shared-data/src/rules/listing-price-floor.ts +++ b/packages/shared-data/src/rules/listing-price-floor.ts @@ -10,18 +10,34 @@ export const MINIMUM_DEVICE_PRICE_FLOOR_VND = 1_200_000; export const MINIMUM_EARBUDS_PRICE_FLOOR_VND = 400_000; /** * Titles or URLs of earbuds listings. A model number or tier may follow the - * name with no space ("Buds3", "Buds2Pro", "FreeBuds5i", "AirPodsPro", - * "WF1000XM5") (DOS/Rate#1092). + * name with no space ("Buds3", "Buds2Pro", "BudsPro3", "Buds3ProFE", + * "FreeBudsPro4", "AirPodsPro2", "WF1000XM5") (DOS/Rate#1092), and a brand may + * be glued to the name ("SamsungGalaxyBuds3", "PixelBudsPro2", "RedmiBuds5", + * "AppleAirPodsPro2", "HuaweiFreeBudsPro4", "SonyWF1000XM5"). AirPods, + * FreeBuds and WF1000XM need a word start or their own brand just before them + * ("repairpods" is not AirPods). Over-ear headphones ("WH-1000XM5", + * "WH CH720N", "WH-L600") are not earbuds and keep the device floor, even when + * the text also says "tai nghe" (DOS/Rate#1116, DOS/Rate#1125). Any over-ear + * model in the text wins, so a title naming both a WF- and a WH- model gets + * the device floor. + * + * No ReDoS: the pattern is anchored, so the over-ear lookahead scans the text + * once. Every repeat ends where the next part starts with a letter, a word + * boundary or the end, so no two repeats compete for the same characters. */ export const EARBUDS_TITLE_REGEX = - /\b(?:airpods(?:\d+|pro|max)?|earpods|buds\d*(?:pro|fe|live|core|plus)?|freebuds\d*i?|soundpeats|wf-|wf1000xm\d+|wh-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)\b/i; + /^(?![\s\S]*(?:\b|_|sony)wh(?:[\s_-]?1000xm|[\s_-][a-z]{1,3}\d))[\s\S]*?(?:\b(?:buds\d*(?:(?:pro|fe|live|core|plus)\d*){0,2}|earpods|soundpeats|wf-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)|(?:\b|apple)airpods(?:\d+|(?:pro|max)\d*)?|(?:\b|huawei)freebuds\d*(?:(?:pro|se)\d*)?i?|(?:galaxy|pixel|redmi|samsung|enco|tune)buds\d*(?:(?:pro|fe|live|core|plus)\d*){0,2}|(?:\b|sony)wf-?1000xm\d+)\b/i; + +/** Keeps a word boundary between the joined title and URL. */ +const TEXT_SEPARATOR = ' '; /** - * The floor for a listing: the earbuds floor when any of the texts (title, - * URL) names earbuds, else the device floor. + * The floor for a listing: the earbuds floor when the texts (title, URL) name + * earbuds, else the device floor. They are read as one text, so an over-ear + * model in the title also wins over "tai-nghe" in the URL. */ export function minimumListingPriceVnd(texts: readonly string[]): number { - return texts.some((text) => EARBUDS_TITLE_REGEX.test(text)) + return EARBUDS_TITLE_REGEX.test(texts.join(TEXT_SEPARATOR)) ? MINIMUM_EARBUDS_PRICE_FLOOR_VND : MINIMUM_DEVICE_PRICE_FLOOR_VND; }