diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index bab2d95e..107109fc 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -2816,8 +2816,7 @@ async function runTests() { ? radarMod : (radarMod as unknown as { default: typeof radarMod }).default ) as typeof radarMod; - const plausibilityMod = - await import('../../web/src/lib/offer-plausibility.js'); + const plausibilityMod = await import('@repo/shared-data/rules'); const { implausiblyLowOfferIds, implausiblyLowIndexes } = ( typeof plausibilityMod.implausiblyLowOfferIds === 'function' ? plausibilityMod diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index ce83d5ef..b7e7fcea 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -67,7 +67,7 @@ import ProductValueCalculator from '@/components/leaderboard/ProductValueCalcula import ProductOffersExplorer from '@/components/leaderboard/ProductOffersExplorer'; import ProductSpecsExportToolbar from '@/components/leaderboard/ProductSpecsExportToolbar'; import ClientMessages from '@/components/providers/ClientMessages'; -import { plausibleMinPrice } from '@/lib/offer-plausibility'; +import { plausibleMinPrice } from '@repo/shared-data/rules'; import { firstImageUrl, usableImageUrls, diff --git a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx index 05e81bf8..235f72f3 100644 --- a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx +++ b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx @@ -55,7 +55,7 @@ import { SAMPLE_AUDIT_URLS } from '@/lib/inspect-samples'; import { implausiblyLowOfferIds, plausibleOffers, -} from '@/lib/offer-plausibility'; +} from '@repo/shared-data/rules'; import { productImageUrl } from '@/lib/rate-domain-core'; const INSPECT_CONTROL_CHARS_RE = /\p{Cc}+/gu; diff --git a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx index eceb61e6..37ab5a6b 100644 --- a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx +++ b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx @@ -24,7 +24,7 @@ import { type RadarSignal, } from '@/lib/deal-radar'; import { escapeCsvCell } from '@/lib/csv-safe'; -import { implausiblyLowOfferIds } from '@/lib/offer-plausibility'; +import { implausiblyLowOfferIds } from '@repo/shared-data/rules'; import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; diff --git a/apps/web/src/lib/__tests__/offer-plausibility.test.ts b/apps/web/src/lib/__tests__/offer-plausibility.test.ts index 279c8a9f..598037c7 100644 --- a/apps/web/src/lib/__tests__/offer-plausibility.test.ts +++ b/apps/web/src/lib/__tests__/offer-plausibility.test.ts @@ -5,7 +5,7 @@ import { isImplausiblyLowPrice, plausibleMinPrice, plausibleOffers, -} from '../offer-plausibility'; +} from '@repo/shared-data/rules'; function main() { console.warn('๐Ÿงช Offer plausibility suite'); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index c3059dda..9a0a4d8b 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -2,7 +2,7 @@ import { createClient } from '@supabase/supabase-js'; import type { Database, MasterProduct } from '@repo/shared-data'; import { createLogger } from '@/lib/logger'; import { isPendingReview } from '@/lib/review-display'; -import { plausibleOffers } from '@/lib/offer-plausibility'; +import { plausibleOffers } from '@repo/shared-data/rules'; import { type ReviewFlagCategory, type ReviewItem, 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 5cfa3957..a312ac34 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 @@ -9699,7 +9699,7 @@ async function runCoreArchitectureSuite() { // 107. Issue #900: Offer-Plausibility, Product-Competitors & Copy/SEO/Sitemap Helpers Bounds { const plausMod = - require('../../../lib/offer-plausibility') as typeof import('../../../lib/offer-plausibility'); + require('@repo/shared-data/rules') as typeof import('@repo/shared-data/rules'); const compMod = require('../../../lib/product-competitors') as typeof import('../../../lib/product-competitors'); const verdictCopyMod = @@ -11462,7 +11462,7 @@ async function runCoreArchitectureSuite() { const { implausiblyLowOfferIds: implausiblyLowOfferIds983, plausibleOffers: plausibleOffers983, - } = await import('../../../lib/offer-plausibility'); + } = await import('@repo/shared-data/rules'); const { buildKeySpecLine: buildKeySpecLine983, rankRelatedCompetitors: rankRelatedCompetitors983, diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index 75ec37ed..ffce902d 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -1,6 +1,6 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { plausibleMinPrice, plausibleOffers } from '@/lib/offer-plausibility'; +import { plausibleMinPrice, plausibleOffers } from '@repo/shared-data/rules'; import { MAX_VALID_VND_PRICE, normalizeOfferOriginalPrice, diff --git a/apps/web/src/server/rate/deals.ts b/apps/web/src/server/rate/deals.ts index d0f2ba0c..7ea83728 100644 --- a/apps/web/src/server/rate/deals.ts +++ b/apps/web/src/server/rate/deals.ts @@ -18,7 +18,7 @@ import { getRadarSignal, type RadarSignal, } from '../../lib/deal-radar'; -import { plausibleOffers } from '../../lib/offer-plausibility'; +import { plausibleOffers } from '@repo/shared-data/rules'; import { RATE_CACHE_TAGS, categoryCacheTag, diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index c11d6f5a..5186a6ec 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -1,8 +1,9 @@ import 'server-only'; import { isImplausiblyLowPrice, + minimumListingPriceVnd, plausibleOffers, -} from '../../lib/offer-plausibility'; +} from '@repo/shared-data/rules'; import type { Database } from '@repo/shared-data'; import { MAX_VALID_VND_PRICE, @@ -269,11 +270,6 @@ const ACCESSORY_KEYWORDS_VI_EN = [ 'camera lens protector', ]; -const MINIMUM_DEVICE_PRICE_FLOOR_VND = 1_200_000; -const MINIMUM_EARBUDS_PRICE_FLOOR_VND = 400_000; -const EARBUDS_TITLE_REGEX = - /\b(?:airpods|earpods|buds|freebuds|soundpeats|wf-|wh-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)\b/i; - function isDomainOrSubdomain(host: string, domain: string): boolean { return host === domain || host.endsWith(`.${domain}`); } @@ -458,11 +454,7 @@ export function detectAccessoryOrJunk(params: { } } - const isEarbuds = - EARBUDS_TITLE_REGEX.test(titleLower) || EARBUDS_TITLE_REGEX.test(urlLower); - const minFloor = isEarbuds - ? MINIMUM_EARBUDS_PRICE_FLOOR_VND - : MINIMUM_DEVICE_PRICE_FLOOR_VND; + const minFloor = minimumListingPriceVnd([titleLower, urlLower]); if ( typeof safeParams.price === 'number' && diff --git a/packages/shared-data/package.json b/packages/shared-data/package.json index fa2df17b..bc2dfe0b 100644 --- a/packages/shared-data/package.json +++ b/packages/shared-data/package.json @@ -8,6 +8,11 @@ "import": "./dist/index.js", "require": "./dist/index.js" }, + "./rules": { + "types": "./dist/rules/index.d.ts", + "import": "./dist/rules/index.js", + "require": "./dist/rules/index.js" + }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js", diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 04b09b68..00885221 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -18,6 +18,8 @@ import { z, } from '../index.js'; import { getAdminClient, getRateClient, getDosafeClient } from '../server.js'; +import * as rules from '../rules/index.js'; +import { readdirSync, readFileSync } from 'node:fs'; function testVersionAndZeroStrapiExports(): void { assert.equal(SHARED_DATA_VERSION, '2.0.0'); @@ -659,26 +661,102 @@ function testSeedingRiskPenalty(): void { ); } +function testPriceRules(): void { + // One 1B VND ceiling for the schemas and the bait-price rule. + assert.equal(rules.MAX_VALID_VND_PRICE, sharedDataRoot.MAX_VALID_VND_PRICE); + assert.equal( + rules.MAX_PLAUSIBLE_VND_PRICE, + sharedDataRoot.MAX_VALID_VND_PRICE + ); + // The rules ship on their own subpath, not the root export. + assert.equal('plausibleOffers' in sharedDataRoot, false); + + assert.equal( + rules.minimumListingPriceVnd(['iphone 16 pro max 256gb']), + 1_200_000 + ); + assert.equal( + rules.minimumListingPriceVnd(['galaxy buds pro', 'https://shop.vn/x']), + 400_000 + ); + // Current behaviour, kept by the move: "buds3" has no word boundary after + // "buds", so it gets the device floor (follow-up in DOS/Rate#1089). + assert.equal(rules.minimumListingPriceVnd(['galaxy buds3 pro']), 1_200_000); + assert.equal( + rules.minimumListingPriceVnd(['sony', 'https://shop.vn/tai-nghe-sony']), + 400_000, + 'the URL alone can name earbuds' + ); + assert.equal( + rules.minimumListingPriceVnd(['budsy phone stand']), + 1_200_000, + 'whole words only' + ); + + // 500k is below 40% of the median of the other three (3.1M): flagged. + const offers = [ + { id: 'a', price_current: 500_000 }, + { id: 'b', price_current: 3_000_000 }, + { id: 'c', price_current: 3_100_000 }, + { id: 'd', price_current: 9_000_000 }, + ]; + assert.deepEqual([...rules.implausiblyLowOfferIds(offers)], ['a']); + assert.equal(rules.plausibleMinPrice(0, offers), 3_000_000); + + // One definition of the ceiling: the schemas re-export it, never redefine it. + assert.doesNotMatch( + readFileSync( + new URL('../schemas/specs.schema.js', import.meta.url), + 'utf8' + ), + /MAX_VALID_VND_PRICE\s*=/ + ); + + // Browser-safe: the compiled rules import only each other, so neither zod + // nor Supabase can reach a client bundle through them. + const dir = new URL('../rules/', import.meta.url); + for (const file of readdirSync(dir).filter((f) => f.endsWith('.js'))) { + const source = readFileSync(new URL(file, dir), 'utf8'); + // Static import/export statements (tsc emits them at column 0) and + // dynamic import() calls; comments are not matched. + const specifiers = [ + ...source.matchAll(/^(?:import|export)\b[^;]*?['"]([^'"]+)['"]/gm), + ...source.matchAll(/\bimport\(\s*['"]([^'"]+)['"]/g), + ].map((m) => m[1]); + for (const specifier of specifiers) { + assert.match( + specifier ?? '', + /^\.\/[\w-]+\.js$/, + `rules/${file} imports ${specifier}` + ); + } + } +} + function main(): void { console.log('๐Ÿงช Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); console.log( - ' โœ… [1/5] Version & Zero-Strapi root export isolation verified' + ' โœ… [1/7] Version & Zero-Strapi root export isolation verified' ); testRouteConstants(); - console.log(' โœ… [2/5] Route & REST API v1 constants verified'); + console.log(' โœ… [2/7] Route & REST API v1 constants verified'); testTenFieldGroupsSpecificationsSchema(); console.log( - ' โœ… [3/5] 10-Field-Groups ProductSpecificationsSchema verified' + ' โœ… [3/7] 10-Field-Groups ProductSpecificationsSchema verified' ); testMasterProductAndScrapedProductSchemas(); console.log( - ' โœ… [4/5] MasterProduct, ScrapedProduct & common schemas verified' + ' โœ… [4/7] MasterProduct, ScrapedProduct & common schemas verified' ); testSupabaseClientGuards(); - console.log(' โœ… [5/6] Supabase client fail-closed env guards verified'); + console.log(' โœ… [5/7] Supabase client fail-closed env guards verified'); testSeedingRiskPenalty(); - console.log(' โœ… [6/6] Shared seeding risk penalty verified'); + console.log(' โœ… [6/7] Shared seeding risk penalty verified'); + testPriceRules(); + console.log( + ' โœ… [7/7] Shared price rules (@repo/shared-data/rules) verified' + ); console.log('๐ŸŽ‰ All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts new file mode 100644 index 00000000..3523dc37 --- /dev/null +++ b/packages/shared-data/src/rules/index.ts @@ -0,0 +1,9 @@ +/** + * Business rules about prices, shared by rate.vn, the importer and the + * crawlers (DOS/Rate#1089). Plain functions and constants only: no zod and + * no Supabase client, so client components can import + * `@repo/shared-data/rules` without pulling either into the bundle. + */ +export { MAX_VALID_VND_PRICE } from './price-limits.js'; +export * from './offer-plausibility.js'; +export * from './listing-price-floor.js'; diff --git a/packages/shared-data/src/rules/listing-price-floor.ts b/packages/shared-data/src/rules/listing-price-floor.ts new file mode 100644 index 00000000..93778a56 --- /dev/null +++ b/packages/shared-data/src/rules/listing-price-floor.ts @@ -0,0 +1,23 @@ +/** + * Price floor for a main device listing. Below it, a listing that claims to be + * a phone, tablet, watch or laptop is almost always an accessory or a spare + * part. Earbuds sell for less, so they have their own floor. + */ + +/** Below this, a main device listing is treated as an accessory or part. */ +export const MINIMUM_DEVICE_PRICE_FLOOR_VND = 1_200_000; +/** Below this, an earbuds listing is treated as an accessory or part. */ +export const MINIMUM_EARBUDS_PRICE_FLOOR_VND = 400_000; +/** Titles or URLs of earbuds listings. */ +export const EARBUDS_TITLE_REGEX = + /\b(?:airpods|earpods|buds|freebuds|soundpeats|wf-|wh-|tws|tai[\s_-]*nghe|earbuds|in[\s_-]*ear)\b/i; + +/** + * The floor for a listing: the earbuds floor when any of the texts (title, + * URL) names earbuds, else the device floor. + */ +export function minimumListingPriceVnd(texts: readonly string[]): number { + return texts.some((text) => EARBUDS_TITLE_REGEX.test(text)) + ? MINIMUM_EARBUDS_PRICE_FLOOR_VND + : MINIMUM_DEVICE_PRICE_FLOOR_VND; +} diff --git a/apps/web/src/lib/offer-plausibility.ts b/packages/shared-data/src/rules/offer-plausibility.ts similarity index 92% rename from apps/web/src/lib/offer-plausibility.ts rename to packages/shared-data/src/rules/offer-plausibility.ts index 287447ed..3e953ba6 100644 --- a/apps/web/src/lib/offer-plausibility.ts +++ b/packages/shared-data/src/rules/offer-plausibility.ts @@ -3,14 +3,19 @@ * store asks for the same model is almost never a real deal: it is a * counterfeit, an accessory or a bait listing. Such offers must not set a * product's "from" price or the deal radar's market floor. + * + * The database applies the same rule to rate.master_products.min_price in + * rate.refresh_master_market_stats (DOS-Me migration + * 20261024060000_rate_master_min_price_plausible.sql): change both together. */ +import { MAX_VALID_VND_PRICE } from './price-limits.js'; /** An offer below this share of the other offers' median is implausible. */ export const IMPLAUSIBLY_LOW_RATIO = 0.4; /** Fewer offers than this are too few to call one an outlier. */ export const MIN_OFFERS_FOR_OUTLIER = 3; -/** Maximum plausible VND listing price (1B VND). */ -export const MAX_PLAUSIBLE_VND_PRICE = 1_000_000_000; +/** Maximum plausible VND listing price: the shared 1B VND ceiling. */ +export const MAX_PLAUSIBLE_VND_PRICE = MAX_VALID_VND_PRICE; function toSafePlausiblePrice(raw: unknown): number { if (typeof raw !== 'number' && typeof raw !== 'string') return NaN; diff --git a/packages/shared-data/src/rules/price-limits.ts b/packages/shared-data/src/rules/price-limits.ts new file mode 100644 index 00000000..caddbbc2 --- /dev/null +++ b/packages/shared-data/src/rules/price-limits.ts @@ -0,0 +1,2 @@ +/** Maximum realistic VND listing price (1 billion VND) to block unscaled fixed-point / concatenated price bugs. */ +export const MAX_VALID_VND_PRICE = 1_000_000_000; diff --git a/packages/shared-data/src/schemas/specs.schema.ts b/packages/shared-data/src/schemas/specs.schema.ts index f3833675..310b11f6 100644 --- a/packages/shared-data/src/schemas/specs.schema.ts +++ b/packages/shared-data/src/schemas/specs.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { MAX_VALID_VND_PRICE } from '../rules/price-limits.js'; import { HttpUrlSchema, StatusSchema } from './common.schema.js'; /** @@ -122,8 +123,8 @@ export const KeySpecsSchema = z.object({ ram: z.string().max(200).optional(), }); -/** Maximum realistic VND listing price (1 billion VND) to block unscaled fixed-point / concatenated price bugs. */ -export const MAX_VALID_VND_PRICE = 1_000_000_000; +// Defined with the price rules so `@repo/shared-data/rules` stays free of zod. +export { MAX_VALID_VND_PRICE }; export const MasterProductSchema = z.object({ id: z.string().uuid(),