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
3 changes: 1 addition & 2 deletions apps/extension/src/test-extension-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
JOY marked this conversation as resolved.
const { implausiblyLowOfferIds, implausiblyLowIndexes } = (
typeof plausibilityMod.implausiblyLowOfferIds === 'function'
? plausibilityMod
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/inspect/DeepInspectWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/__tests__/offer-plausibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
isImplausiblyLowPrice,
plausibleMinPrice,
plausibleOffers,
} from '../offer-plausibility';
} from '@repo/shared-data/rules';

function main() {
console.warn('πŸ§ͺ Offer plausibility suite');
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/rate-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/rate/api-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/rate/deals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 3 additions & 11 deletions apps/web/src/server/rate/extension.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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' &&
Expand Down
5 changes: 5 additions & 0 deletions packages/shared-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
90 changes: 84 additions & 6 deletions packages/shared-data/src/__tests__/shared-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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!');
}

Expand Down
9 changes: 9 additions & 0 deletions packages/shared-data/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -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';
23 changes: 23 additions & 0 deletions packages/shared-data/src/rules/listing-price-floor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared-data/src/rules/price-limits.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions packages/shared-data/src/schemas/specs.schema.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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(),
Expand Down
Loading