diff --git a/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs b/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs index a7a985ba..ab05ac84 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 7b38c261..55785b15 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -261,6 +261,40 @@ 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', + '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), + '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' + ); + 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'); + // 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 c5e3aee9..68826178 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,48 @@ 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'], + // 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' }, + }); + 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`); + } + // 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 d5392bda..166d3b2a 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; @@ -134,16 +136,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|đổ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). - * 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 ( @@ -154,14 +180,14 @@ 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 ) ) { 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 ) ) { @@ -3218,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 f2730ced..7f453324 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,14 @@ 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). + // 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 = candidate.title_condition ?? 'new'; + if (condition !== 'new') { + throw new NonNewListingError(product.title, condition); + } // 1. Upsert merchant const entityHash = hashEntity(`${platform}:${product.shop.shopId}`);