From 8141de5b4b4dc55d70ada23e7042f38ceb3ccaa8 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 22 Apr 2026 17:56:26 -0400 Subject: [PATCH] feat: add shared unstable listing classifier --- packages/core/src/index.ts | 1 + packages/core/src/types/common.ts | 9 ++ packages/core/src/utils/unstable.ts | 46 ++++++++++ .../core/test/unstable-listing-mode.test.ts | 84 +++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 packages/core/src/utils/unstable.ts create mode 100644 packages/core/test/unstable-listing-mode.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ea2c1fb..9c918fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,3 +41,4 @@ export * from "./utils/cookies"; export * from "./utils/delay"; export * from "./utils/format"; export * from "./utils/http"; +export * from "./utils/unstable"; diff --git a/packages/core/src/types/common.ts b/packages/core/src/types/common.ts index 692e1cc..a5a2ea4 100644 --- a/packages/core/src/types/common.ts +++ b/packages/core/src/types/common.ts @@ -18,3 +18,12 @@ export interface ListingDetails { address?: string | null; creationDate?: string; } + +export interface UnstableListingBuckets { + results: T[]; + unstableResults: T[]; +} + +export interface UnstableListingModeOptions { + hideUnstableResults?: boolean; +} diff --git a/packages/core/src/utils/unstable.ts b/packages/core/src/utils/unstable.ts new file mode 100644 index 0000000..bffb606 --- /dev/null +++ b/packages/core/src/utils/unstable.ts @@ -0,0 +1,46 @@ +import type { ListingDetails, UnstableListingBuckets } from "../types/common"; + +function getMedian(values: number[]): number { + const middleIndex = Math.floor(values.length / 2); + + if (values.length % 2 === 0) { + return (values[middleIndex - 1] + values[middleIndex]) / 2; + } + + return values[middleIndex]; +} + +export function classifyUnstableListings( + listings: T[], +): UnstableListingBuckets { + const validPrices = listings + .map((listing) => listing.listingPrice.cents) + .filter((price) => Number.isFinite(price) && price > 0) + .sort((left, right) => left - right); + + if (validPrices.length < 2) { + return { + results: [...listings], + unstableResults: [], + }; + } + + const threshold = getMedian(validPrices) * 0.8; + const buckets: UnstableListingBuckets = { + results: [], + unstableResults: [], + }; + + for (const listing of listings) { + const price = listing.listingPrice.cents; + + if (Number.isFinite(price) && price > 0 && price < threshold) { + buckets.unstableResults.push(listing); + continue; + } + + buckets.results.push(listing); + } + + return buckets; +} diff --git a/packages/core/test/unstable-listing-mode.test.ts b/packages/core/test/unstable-listing-mode.test.ts new file mode 100644 index 0000000..9e13de0 --- /dev/null +++ b/packages/core/test/unstable-listing-mode.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import type { ListingDetails } from "../src/types/common"; +import { classifyUnstableListings } from "../src/utils/unstable"; + +interface TestListing extends ListingDetails { + id: string; +} + +function makeListing(id: string, cents: number): TestListing { + return { + id, + url: `https://example.com/${id}`, + title: id, + listingPrice: { + amountFormatted: `$${(cents / 100).toFixed(2)}`, + cents, + currency: "CAD", + }, + listingType: "test", + listingStatus: "active", + }; +} + +describe("classifyUnstableListings", () => { + test("moves listings below 80% of median into unstableResults", () => { + const listings = [ + makeListing("stable-1", 100_00), + makeListing("stable-2", 110_00), + makeListing("unstable", 70_00), + ]; + + const buckets = classifyUnstableListings(listings); + + expect(buckets.results.map((listing) => listing.id)).toEqual(["stable-1", "stable-2"]); + expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["unstable"]); + }); + + test("uses the midpoint median for even-sized priced inputs", () => { + const listings = [ + makeListing("low", 79_00), + makeListing("mid-low", 100_00), + makeListing("mid-high", 120_00), + makeListing("high", 140_00), + ]; + + const buckets = classifyUnstableListings(listings); + + expect(buckets.results.map((listing) => listing.id)).toEqual(["mid-low", "mid-high", "high"]); + expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["low"]); + }); + + test("keeps non-positive prices in results and excludes them from the median input", () => { + const listings = [ + makeListing("zero", 0), + makeListing("negative", -500), + makeListing("stable-1", 100_00), + makeListing("stable-2", 120_00), + makeListing("unstable", 70_00), + ]; + + const buckets = classifyUnstableListings(listings); + + expect(buckets.results.map((listing) => listing.id)).toEqual([ + "zero", + "negative", + "stable-1", + "stable-2", + ]); + expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["unstable"]); + }); + + test("returns all listings in results when fewer than two valid prices are present", () => { + const listings = [ + makeListing("zero", 0), + makeListing("negative", -100), + makeListing("only-valid", 150_00), + ]; + + const buckets = classifyUnstableListings(listings); + + expect(buckets.results.map((listing) => listing.id)).toEqual(["zero", "negative", "only-valid"]); + expect(buckets.unstableResults).toEqual([]); + }); +});