From d8b312934b3a72fb72f1c630b2da48d00a930cff Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:20:25 +0700 Subject: [PATCH 1/4] fix(importer): reject used, like-new and refurbished listings at ingest (#1430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rate.products stores no condition, so a non-new listing linked to a published master (exact model_key, variant or a master hint) fed its lower price into the new device's min_price and the deal radar. - ingestProduct throws NonNewListingError (code NON_NEW_LISTING) before anything is written when the normalizer detects a non-new condition, with or without a master hint (option A of #1430, no DDL). - Trade-in offers for the buyer's old device ("nhận thu máy cũ", "thu cũ lên đời", "đổi máy cũ lấy máy mới") no longer read as used, so new listings are not dropped for them. - The error is exported from @repo/crawlers (dist smoke). Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/dist-exports.smoke.mjs | 1 + .../shopee/__tests__/entity-matcher.test.ts | 16 +++++++++ .../__tests__/shopee-to-supabase.test.ts | 35 ++++++++++++++++++- .../importer/src/shopee/product-normalizer.ts | 32 ++++++++++++++--- .../importer/src/shopee/shopee-to-supabase.ts | 7 ++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs b/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs index a7a985ba2..ab05ac842 100644 --- a/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs +++ b/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs @@ -27,6 +27,7 @@ for (const name of [ 'computeRetryDelayMs', 'resolveItemModelMode', 'UnkeyableProductTitleError', + 'NonNewListingError', ]) { assert.equal( typeof crawlers[name], diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 7b38c2616..d9baaba82 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -261,6 +261,22 @@ async function runEntityMatcherSuite() { 'new', 'Trade-in promotion ("thu cũ đổi mới") must not misclassify a new phone as used' ); + for (const description of [ + 'Nhận thu máy cũ giá cao', + 'Hỗ trợ thu mua máy cũ, trả góp 0%', + 'Thu cũ lên đời, bảo hành 12 tháng', + 'Đổi máy cũ lấy máy mới tại cửa hàng', + ]) { + assert.equal( + detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', description), + 'new', + `A trade-in offer (${description}) does not make a new listing used (DOS/Rate#1430)` + ); + } + assert.equal( + detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', 'Máy cũ, pin 90%'), + 'used' + ); assert.equal( detectProductCondition('Điện thoại Apple iPhone 15 Pro Max 256GB Likenew 99% Zin Nguyên Bản'), 'like_new_99' diff --git a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts index c5e3aee96..a9681229b 100644 --- a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts +++ b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts @@ -17,7 +17,7 @@ import { normalizeScrapedProductPrices, platformReviewIdFor, } from '../shopee-to-supabase.js'; -import { UnkeyableProductTitleError } from '../product-normalizer.js'; +import { NonNewListingError, UnkeyableProductTitleError } from '../product-normalizer.js'; import type { RateSupabaseClient, ShopeeProduct, ShopeeReview } from '../types.js'; function createBaseProduct(overrides: Partial = {}): ShopeeProduct { @@ -532,6 +532,39 @@ async function runTests() { UnkeyableProductTitleError ); assert.equal(accessoryEvents.productUpserts.length, 0); + // A used, like-new or refurbished listing is rejected before anything is written, with + // or without a hint: its price would become the new device's min_price (DOS/Rate#1430). + for (const [title, hint, condition] of [ + ['Like new 99% iPhone 15 128GB', undefined, 'like_new_99'], + ['Điện thoại iPhone 14 Pro Max 128GB Máy cũ đã qua sử dụng', 'master-hint-verified', 'used'], + ['Apple iPhone 15 Pro 256GB Hàng Tân Trang Apple CPO', 'master-hint-verified', 'refurbished'], + ] as const) { + const { stub: nonNewStub, events: nonNewEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-verified' }, + }); + await assert.rejects( + new ShopeeIngestionPipeline({ supabaseClient: nonNewStub, apiKey: '' }).ingestProduct( + createBaseProduct({ title }), + undefined, + hint ? { masterIdHint: hint } : {} + ), + (err: unknown) => + err instanceof NonNewListingError && + err.code === 'NON_NEW_LISTING' && + err.condition === condition + ); + assert.equal(nonNewEvents.productUpserts.length, 0, `${title} writes no listing`); + } + // A new phone whose description offers a trade-in for the buyer's old one is ingested. + const { stub: tradeInStub, events: tradeInEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-verified' }, + }); + await new ShopeeIngestionPipeline({ supabaseClient: tradeInStub, apiKey: '' }).ingestProduct( + createBaseProduct({ description: 'Nhận thu máy cũ, hỗ trợ trả góp 0%' }), + undefined, + { masterIdHint: 'master-hint-verified' } + ); + assert.equal(tradeInEvents.productUpserts.length, 1); assert.equal(hintOkEvents.productUpserts[0]?.cluster_id, 'master-hint-verified'); assert.equal(hintOkEvents.productUpserts[0]?.category_id, 'cat-smartphones'); assert.equal( diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index d5392bda9..ae4703f3f 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -134,16 +134,40 @@ export class UnkeyableProductTitleError extends Error { } } +/** + * A used, like-new or refurbished listing. `rate.products` stores no condition, so + * linked to a master its lower price would become the new device's `min_price` and + * feed the deal radar. The listing is rejected before anything is written until + * condition is stored (DOS/Rate#1430). + */ +export class NonNewListingError extends Error { + readonly code = 'NON_NEW_LISTING'; + readonly condition: Exclude; + + constructor(title: unknown, condition: Exclude) { + super( + `Cannot ingest a non-new listing (condition=${condition}): ${JSON.stringify(String(title ?? '').slice(0, 200))}` + ); + this.name = 'NonNewListingError'; + this.condition = condition; + } +} + +/** A trade-in offer for the buyer's old device, which says nothing about the listing's own. */ +const TRADE_IN_PHRASE = + /thu\s*cũ\s*đổi\s*mới|(?:thu|đổi)\s*(?:mua\s*)?(?:máy\s*)?cũ\s*(?:lên\s*đời|lấy\s*(?:máy\s*)?mới)|(?:nhận|hỗ\s*trợ)\s*thu\s*(?:mua\s*)?máy\s*cũ/gi; + /** * Detect listing physical condition from product title and optional description (Audit C6). - * Strips promotional trade-in phrases ("thu cũ đổi mới") before matching so new retail listings - * offering trade-in programs are not misclassified as used devices. + * Strips promotional trade-in phrases ("thu cũ đổi mới", "thu máy cũ lên đời") before matching + * so new retail listings offering trade-in programs are not misclassified as used devices; a + * non-new listing is rejected at ingest (DOS/Rate#1430). */ export function detectProductCondition(title: string, description?: string): ProductCondition { const safeTitle = typeof title === 'string' ? title : ''; const safeDesc = typeof description === 'string' ? description : ''; - const cleanTitle = safeTitle.replace(/thu\s*cũ\s*đổi\s*mới/gi, ' '); - const cleanDesc = safeDesc.replace(/thu\s*cũ\s*đổi\s*mới/gi, ' '); + const cleanTitle = safeTitle.replace(TRADE_IN_PHRASE, ' '); + const cleanDesc = safeDesc.replace(TRADE_IN_PHRASE, ' '); const checkSource = (text: string): ProductCondition | null => { if ( diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index f2730ced5..a65aabf7d 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -18,6 +18,7 @@ import { } from './types.js'; import { isAccessoryTitle, + NonNewListingError, ProductNormalizer, UnkeyableProductTitleError, } from './product-normalizer.js'; @@ -538,6 +539,12 @@ export class ShopeeIngestionPipeline { if (candidate.keyable === false && (!options.masterIdHint || isAccessoryTitle(product.title))) { throw new UnkeyableProductTitleError(product.title); } + // A used, like-new or refurbished listing is rejected even with a hint: with no + // stored condition, its price would become the new device's min_price (DOS/Rate#1430). + const condition = candidate.condition ?? 'new'; + if (condition !== 'new') { + throw new NonNewListingError(product.title, condition); + } // 1. Upsert merchant const entityHash = hashEntity(`${platform}:${product.shop.shopId}`); From f4ae71d7f168a7b9aa6179055caf3ca44b073de7 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:23:15 +0700 Subject: [PATCH 2/4] fix(importer): only a non-new title rejects a listing; 2nd Gen is new (#1430 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The ingest check reads the title alone. Description boilerplate such as "không bán hàng cũ" or "99% khách hàng hài lòng" would drop new listings, so a description hit still only sets needs_review. - "2nd" counts as used only in "2nd hand": "AirPods Pro 2nd Gen" and "iPhone SE 2nd generation" are new devices. - The trade-in strip also covers "thu máy cũ đổi máy mới" and "thu mua máy cũ". Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/entity-matcher.test.ts | 9 +++++++ .../__tests__/shopee-to-supabase.test.ts | 27 ++++++++++++------- .../importer/src/shopee/product-normalizer.ts | 4 +-- .../importer/src/shopee/shopee-to-supabase.ts | 5 +++- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index d9baaba82..4f638475f 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -266,6 +266,8 @@ async function runEntityMatcherSuite() { 'Hỗ trợ thu mua máy cũ, trả góp 0%', 'Thu cũ lên đời, bảo hành 12 tháng', 'Đổi máy cũ lấy máy mới tại cửa hàng', + 'Thu máy cũ đổi máy mới, trợ giá', + 'Thu mua máy cũ giá cao', ]) { assert.equal( detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', description), @@ -277,6 +279,13 @@ async function runEntityMatcherSuite() { detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', 'Máy cũ, pin 90%'), 'used' ); + for (const title of [ + 'Tai nghe Apple AirPods Pro 2nd Gen USB-C Chính hãng VN/A', + 'Apple iPhone SE 2nd generation 64GB', + ]) { + assert.equal(detectProductCondition(title), 'new', `${title} is a new device`); + } + assert.equal(detectProductCondition('iPhone 13 128GB 2nd hand'), 'used'); assert.equal( detectProductCondition('Điện thoại Apple iPhone 15 Pro Max 256GB Likenew 99% Zin Nguyên Bản'), 'like_new_99' diff --git a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts index a9681229b..90fba560b 100644 --- a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts +++ b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts @@ -555,16 +555,23 @@ async function runTests() { ); assert.equal(nonNewEvents.productUpserts.length, 0, `${title} writes no listing`); } - // A new phone whose description offers a trade-in for the buyer's old one is ingested. - const { stub: tradeInStub, events: tradeInEvents } = createPipelineStubSupabase({ - hintRow: { id: 'master-hint-verified' }, - }); - await new ShopeeIngestionPipeline({ supabaseClient: tradeInStub, apiKey: '' }).ingestProduct( - createBaseProduct({ description: 'Nhận thu máy cũ, hỗ trợ trả góp 0%' }), - undefined, - { masterIdHint: 'master-hint-verified' } - ); - assert.equal(tradeInEvents.productUpserts.length, 1); + // Only the title rejects: description boilerplate that reads as non-new (a negation, + // "99% khách hàng") keeps a new listing (DOS/Rate#1430). + for (const description of [ + 'Nhận thu máy cũ, hỗ trợ trả góp 0%', + 'Cam kết hàng chính hãng, không bán hàng cũ, hàng tân trang', + '99% khách hàng hài lòng', + ]) { + const { stub: descStub, events: descEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-verified' }, + }); + await new ShopeeIngestionPipeline({ supabaseClient: descStub, apiKey: '' }).ingestProduct( + createBaseProduct({ description }), + undefined, + { masterIdHint: 'master-hint-verified' } + ); + assert.equal(descEvents.productUpserts.length, 1, `${description} keeps the listing`); + } assert.equal(hintOkEvents.productUpserts[0]?.cluster_id, 'master-hint-verified'); assert.equal(hintOkEvents.productUpserts[0]?.category_id, 'cat-smartphones'); assert.equal( diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index ae4703f3f..089d47602 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -155,7 +155,7 @@ export class NonNewListingError extends Error { /** A trade-in offer for the buyer's old device, which says nothing about the listing's own. */ const TRADE_IN_PHRASE = - /thu\s*cũ\s*đổi\s*mới|(?:thu|đổi)\s*(?:mua\s*)?(?:máy\s*)?cũ\s*(?:lên\s*đời|lấy\s*(?:máy\s*)?mới)|(?:nhận|hỗ\s*trợ)\s*thu\s*(?:mua\s*)?máy\s*cũ/gi; + /(?:thu|đổi)\s*(?:mua\s*)?(?:máy\s*)?cũ\s*(?:lên\s*đời|(?:lấy|đổi)\s*(?:máy\s*)?mới)|thu\s*(?:mua\s*)?máy\s*cũ/gi; /** * Detect listing physical condition from product title and optional description (Audit C6). @@ -185,7 +185,7 @@ export function detectProductCondition(title: string, description?: string): Pro return 'like_new_99'; } if ( - /(?:máy\s*cũ|hàng\s*cũ|điện\s*thoại\s*cũ|đã\s*qua\s*sử\s*dụng|\bsecond\s*hand\b|\b2nd\b|thanh\s*lý\s*máy\s*cũ|máy\s*trần\s*cũ)/i.test( + /(?:máy\s*cũ|hàng\s*cũ|điện\s*thoại\s*cũ|đã\s*qua\s*sử\s*dụng|\bsecond\s*hand\b|\b2nd\s*hand\b|thanh\s*lý\s*máy\s*cũ|máy\s*trần\s*cũ)/i.test( text ) ) { diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index a65aabf7d..3ff98f811 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -17,6 +17,7 @@ import { type ValidatedScrapedProduct, } from './types.js'; import { + detectProductCondition, isAccessoryTitle, NonNewListingError, ProductNormalizer, @@ -541,7 +542,9 @@ export class ShopeeIngestionPipeline { } // A used, like-new or refurbished listing is rejected even with a hint: with no // stored condition, its price would become the new device's min_price (DOS/Rate#1430). - const condition = candidate.condition ?? 'new'; + // Only the title decides: description boilerplate ("không bán hàng cũ", "99% khách + // hàng hài lòng") would drop new listings, so a description hit still only sets needs_review. + const condition = detectProductCondition(product.title); if (condition !== 'new') { throw new NonNewListingError(product.title, condition); } From bff1ac7beca1a0e83f7646d43ac5f3e236981cf0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:25:42 +0700 Subject: [PATCH 3/4] fix(importer): check the sanitized title; a bare 98% is not a condition (#1430 Codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The normalizer sets title_condition from the sanitized title, and the ingest check reads it, so a control character inside "Máy cũ" no longer slips a used listing past the check. - A bare "98%" or "99%" (such as "Chống ồn 98%") no longer reads as like-new; a percentage counts only after máy, ngoại hình, hình thức, keng or pin, besides the existing mới, đẹp and zin. Co-Authored-By: Claude Opus 5.5 --- apps/importer/src/shopee/__tests__/entity-matcher.test.ts | 8 ++++++++ .../src/shopee/__tests__/shopee-to-supabase.test.ts | 2 ++ apps/importer/src/shopee/product-normalizer.ts | 5 ++++- apps/importer/src/shopee/shopee-to-supabase.ts | 3 +-- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 4f638475f..d936736d0 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -286,6 +286,14 @@ async function runEntityMatcherSuite() { assert.equal(detectProductCondition(title), 'new', `${title} is a new device`); } assert.equal(detectProductCondition('iPhone 13 128GB 2nd hand'), 'used'); + // A bare percentage is a spec or an ad, not a condition; one about the device is. + assert.equal( + detectProductCondition( + 'Tai nghe Anker Soundcore Liberty 4 NC Mới 100% Chống ồn 98% Chính hãng' + ), + 'new' + ); + assert.equal(detectProductCondition('iPhone 13 Pro 128GB máy 98% pin 99%'), 'like_new_99'); assert.equal( detectProductCondition('Điện thoại Apple iPhone 15 Pro Max 256GB Likenew 99% Zin Nguyên Bản'), 'like_new_99' diff --git a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts index 90fba560b..68826178d 100644 --- a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts +++ b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts @@ -538,6 +538,8 @@ async function runTests() { ['Like new 99% iPhone 15 128GB', undefined, 'like_new_99'], ['Điện thoại iPhone 14 Pro Max 128GB Máy cũ đã qua sử dụng', 'master-hint-verified', 'used'], ['Apple iPhone 15 Pro 256GB Hàng Tân Trang Apple CPO', 'master-hint-verified', 'refurbished'], + // A control character inside the phrase is stripped before the check. + ['Apple iPhone 14 Pro Max 128GB Máy\u0000 cũ', 'master-hint-verified', 'used'], ] as const) { const { stub: nonNewStub, events: nonNewEvents } = createPipelineStubSupabase({ hintRow: { id: 'master-hint-verified' }, diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 089d47602..b9602b4c1 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -96,6 +96,8 @@ export interface NormalizedProductCandidate { storage_gb: number | null; // null = unparseable from the title; identity carries ':unknown' needs_review: boolean; // true when identity could not be confidently derived or listing is non-new (plan v7 §5.2, Audit C6) condition?: ProductCondition; + /** The condition the sanitized title alone states; the ingest check reads it (DOS/Rate#1430). */ + title_condition?: ProductCondition; origin?: ProductOrigin; ram_gb: number | null; screen_size_inch: number | null; @@ -178,7 +180,7 @@ export function detectProductCondition(title: string, description?: string): Pro return 'refurbished'; } if ( - /(?:\blike\s*new\b|\blikenew\b|mới\s*9[5-9]%|đẹp\s*9[5-9]%|zin\s*9[5-9]%|\b9[89]%|siêu\s*lướt|hàng\s*lướt|máy\s*lướt|hàng\s*trưng\s*bày|máy\s*trưng\s*bày|\bopen\s*box\b|đã\s*kích\s*hoạt\s*bảo\s*hành)/i.test( + /(?:\blike\s*new\b|\blikenew\b|mới\s*9[5-9]%|đẹp\s*9[5-9]%|zin\s*9[5-9]%|(?:máy|ngoại\s*hình|hình\s*thức|keng|pin)\s*9[5-9]%|siêu\s*lướt|hàng\s*lướt|máy\s*lướt|hàng\s*trưng\s*bày|máy\s*trưng\s*bày|\bopen\s*box\b|đã\s*kích\s*hoạt\s*bảo\s*hành)/i.test( text ) ) { @@ -3242,6 +3244,7 @@ export class ProductNormalizer { storage_gb: storageKnown ? storageGb : null, needs_review: !keyable || !storageConfident || condition !== 'new', condition, + title_condition: detectProductCondition(title), origin, ram_gb: ramGb, screen_size_inch: screenSize, diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index 3ff98f811..7f4533247 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -17,7 +17,6 @@ import { type ValidatedScrapedProduct, } from './types.js'; import { - detectProductCondition, isAccessoryTitle, NonNewListingError, ProductNormalizer, @@ -544,7 +543,7 @@ export class ShopeeIngestionPipeline { // stored condition, its price would become the new device's min_price (DOS/Rate#1430). // Only the title decides: description boilerplate ("không bán hàng cũ", "99% khách // hàng hài lòng") would drop new listings, so a description hit still only sets needs_review. - const condition = detectProductCondition(product.title); + const condition = candidate.title_condition ?? 'new'; if (condition !== 'new') { throw new NonNewListingError(product.title, condition); } From 472105cb05656dc05d7c869d20383740c094880a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:30:18 +0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(importer):=20"thu=20mua=20=C4=91i?= =?UTF-8?q?=E1=BB=87n=20tho=E1=BA=A1i=20c=C5=A9"=20is=20a=20trade-in=20off?= =?UTF-8?q?er=20too=20(#1430=20Codex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/importer/src/shopee/__tests__/entity-matcher.test.ts | 1 + apps/importer/src/shopee/product-normalizer.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index d936736d0..55785b152 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -268,6 +268,7 @@ async function runEntityMatcherSuite() { 'Đổi máy cũ lấy máy mới tại cửa hàng', 'Thu máy cũ đổi máy mới, trợ giá', 'Thu mua máy cũ giá cao', + 'Mới 100% - Thu mua điện thoại cũ giá cao', ]) { assert.equal( detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', description), diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index b9602b4c1..166d3b2ad 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -157,7 +157,7 @@ export class NonNewListingError extends Error { /** A trade-in offer for the buyer's old device, which says nothing about the listing's own. */ const TRADE_IN_PHRASE = - /(?:thu|đổi)\s*(?:mua\s*)?(?:máy\s*)?cũ\s*(?:lên\s*đời|(?:lấy|đổi)\s*(?:máy\s*)?mới)|thu\s*(?:mua\s*)?máy\s*cũ/gi; + /(?:thu|đổi)\s*(?:mua\s*)?(?:(?:máy|điện\s*thoại)\s*)?cũ\s*(?:lên\s*đời|(?:lấy|đổi)\s*(?:(?:máy|điện\s*thoại)\s*)?mới)|thu\s*(?:mua\s*)?(?:máy|điện\s*thoại)\s*cũ/gi; /** * Detect listing physical condition from product title and optional description (Audit C6).