Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ for (const name of [
'computeRetryDelayMs',
'resolveItemModelMode',
'UnkeyableProductTitleError',
'NonNewListingError',
]) {
assert.equal(
typeof crawlers[name],
Expand Down
34 changes: 34 additions & 0 deletions apps/importer/src/shopee/__tests__/entity-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
44 changes: 43 additions & 1 deletion apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ShopeeProduct {
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 33 additions & 6 deletions apps/importer/src/shopee/product-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ProductCondition, 'new'>;

constructor(title: unknown, condition: Exclude<ProductCondition, 'new'>) {
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 IMPORTANT: Trade-in regex over-strips genuine used-device phrases

Failure Trace:
detectProductCondition('iPhone 13 128GB máy cũ giá tốt') → TRADE_IN_PHRASE matches 'máy cũ' (second alternative 'thu\s*(?:mua\s*)?(?:máy|điện\sthoại)\scũ' with optional 'thu'/'mua' absent), replacing it with a space. The remaining text no longer contains 'máy cũ', so the used-device regex fails and the function returns 'new'. The ingest check (shopee-to-supabase.ts:546-548) then reads title_condition='new' and proceeds to upsert the used listing, whose lower price becomes the new device's min_price — exactly the #1430 scenario the guard is meant to prevent. The old regex /thu\scũ\sđổi\s*mới/ required the full 'thu cũ đổi mới' phrase and would not have stripped 'máy cũ' alone.


/**
* 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 (
Expand All @@ -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
)
) {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/importer/src/shopee/shopee-to-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from './types.js';
import {
isAccessoryTitle,
NonNewListingError,
ProductNormalizer,
UnkeyableProductTitleError,
} from './product-normalizer.js';
Expand Down Expand Up @@ -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}`);
Expand Down
Loading