From 07a1f4ac0f1d830223a05001ea3e505a64eff120 Mon Sep 17 00:00:00 2001 From: colt2822 Date: Fri, 17 Jul 2026 22:28:36 -0500 Subject: [PATCH] Add Deal Hawk resale and ARGUS procurement bots --- .env.example | 89 + .gitignore | 7 + README.md | 37 + config/argus-procurement-searches.json | 74 + config/facebook-marketplace-searches.json | 212 ++ config/pc-apis.json | 9 + config/pc-feeds.json | 79 + config/pc-retailers-active.json | 139 + config/pc-retailers.json | 437 +++ config/resale-car-retailers.json | 66 + config/resale-openbox-retailers.json | 50 + config/resale-tool-retailers.json | 58 + config/retailer-reliability.json | 69 + package.json | 12 + src/workers/argus-procurement-scorer.test.ts | 111 + src/workers/argus-procurement-scorer.ts | 585 ++++ src/workers/discord-price-bot.ts | 2513 +++++++++++++++++ src/workers/discord-price-feedback.ts | 158 ++ src/workers/discord-price-status.ts | 102 + src/workers/facebook-marketplace-discord.ts | 215 ++ .../facebook-marketplace-scorer.test.ts | 122 + src/workers/facebook-marketplace-scorer.ts | 728 +++++ 22 files changed, 5872 insertions(+) create mode 100644 config/argus-procurement-searches.json create mode 100644 config/facebook-marketplace-searches.json create mode 100644 config/pc-apis.json create mode 100644 config/pc-feeds.json create mode 100644 config/pc-retailers-active.json create mode 100644 config/pc-retailers.json create mode 100644 config/resale-car-retailers.json create mode 100644 config/resale-openbox-retailers.json create mode 100644 config/resale-tool-retailers.json create mode 100644 config/retailer-reliability.json create mode 100644 src/workers/argus-procurement-scorer.test.ts create mode 100644 src/workers/argus-procurement-scorer.ts create mode 100644 src/workers/discord-price-bot.ts create mode 100644 src/workers/discord-price-feedback.ts create mode 100644 src/workers/discord-price-status.ts create mode 100644 src/workers/facebook-marketplace-discord.ts create mode 100644 src/workers/facebook-marketplace-scorer.test.ts create mode 100644 src/workers/facebook-marketplace-scorer.ts diff --git a/.env.example b/.env.example index c982c2d..419c886 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,95 @@ REDIS_PORT=6379 # ============================================================================= # Discord Webhook: Server Settings → Integrations → Webhooks DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +# All price-error, resale, and Marketplace alerts route to DISCORD_WEBHOOK_URL (Nova Price Error). +PRICE_BOT_DISCORD_READY_MENTION= +PRICE_BOT_DISCORD_REVIEW_MENTION= +PRICE_BOT_DISCORD_HOT_MENTION= + +# Simple Discord price-error bot (no DB, Redis, or LLM calls) +# Run once: npm run worker:discord-price +# Loop mode: PRICE_BOT_LOOP=true npm run worker:discord-price +PRICE_BOT_LOOP=false +PRICE_BOT_INTERVAL_MS=900000 +PRICE_BOT_FETCH_TIMEOUT_MS=20000 +PRICE_BOT_PLAYWRIGHT_FALLBACK=true +PRICE_BOT_PLAYWRIGHT_TIMEOUT_MS=30000 +PRICE_BOT_TARGET_DELAY_MS=1000 +PRICE_BOT_MIN_CONFIDENCE=70 +PRICE_BOT_MIN_ALERT_DISCOUNT=30 +PRICE_BOT_MIN_WATCH_DISCOUNT=35 +PRICE_BOT_MIN_HOT_DISCOUNT=50 +PRICE_BOT_MIN_ERROR_DISCOUNT=80 +PRICE_BOT_MAX_ALERTS=5 +# Use pc-retailers-active.json for the productive lean list; pc-retailers.json keeps the broad research list. +PRICE_BOT_TARGETS_FILE=config/pc-retailers-active.json +PRICE_BOT_FEEDS_FILE=config/pc-feeds.json +PRICE_BOT_APIS_FILE=config/pc-apis.json +PRICE_BOT_OPENBOX_TARGETS_FILE=config/resale-openbox-retailers.json +PRICE_BOT_TOOL_TARGETS_FILE=config/resale-tool-retailers.json +PRICE_BOT_CAR_TARGETS_FILE=config/resale-car-retailers.json +PRICE_BOT_RETAILER_RELIABILITY_FILE=config/retailer-reliability.json +PRICE_BOT_USE_SQLITE=true +PRICE_BOT_SQLITE_FILE=.price-bot.sqlite +PRICE_BOT_HISTORY_LIMIT=30 +PRICE_BOT_CROSS_STORE_DROP_PERCENT=35 +PRICE_BOT_FAMILY_DEDUP_DROP_PERCENT=10 +PRICE_BOT_MAX_SANE_PRICE=8000 +PRICE_BOT_RESALE_ALERTS=true +PRICE_BOT_RESALE_MIN_PROFIT=60 +PRICE_BOT_RESALE_MIN_MARGIN_PERCENT=18 +PRICE_BOT_RESALE_MIN_DISCOUNT=35 +PRICE_BOT_RESALE_LOCAL_FEE_PERCENT=0 +PRICE_BOT_RESALE_FEE_PERCENT=13.25 +PRICE_BOT_RESALE_TAX_PERCENT=8.25 +PRICE_BOT_RESALE_SHIPPING_BUFFER=25 +PRICE_BOT_OPENBOX_RESALE_ALERTS=true +PRICE_BOT_OPENBOX_RESALE_MIN_PROFIT=90 +PRICE_BOT_OPENBOX_RESALE_MIN_MARGIN_PERCENT=28 +PRICE_BOT_OPENBOX_RESALE_MIN_DISCOUNT=35 +PRICE_BOT_TOOL_RESALE_ALERTS=true +PRICE_BOT_TOOL_RESALE_MIN_PROFIT=50 +PRICE_BOT_TOOL_RESALE_MIN_MARGIN_PERCENT=22 +PRICE_BOT_TOOL_RESALE_MIN_DISCOUNT=35 +PRICE_BOT_CAR_RESALE_ALERTS=true +PRICE_BOT_CAR_RESALE_MIN_PROFIT=75 +PRICE_BOT_CAR_RESALE_MIN_MARGIN_PERCENT=30 +PRICE_BOT_CAR_RESALE_MIN_DISCOUNT=40 +PRICE_BOT_BUY_SCORE_MIN=70 +PRICE_BOT_EBAY_COMPS_ENABLED=false +PRICE_BOT_EBAY_COMPS_REQUIRE=true +PRICE_BOT_EBAY_COMPS_MIN_COUNT=3 +PRICE_BOT_EBAY_COMPS_LIMIT=20 +PRICE_BOT_EBAY_COMPS_MAX_AGE_DAYS=90 +PRICE_BOT_READY_TO_BUY_SCORE=85 +PRICE_BOT_NEEDS_REVIEW_SCORE=70 +PRICE_BOT_MAX_READY_TO_BUY_PRICE=750 +PRICE_BOT_BUY_AUTOMATION_ENABLED=false +PRICE_BOT_BUY_AUTOMATION_MODE=off +PRICE_BOT_MAX_AUTO_BUY_PRICE=250 +PRICE_BOT_MAX_DAILY_AUTO_BUY_SPEND=500 +PRICE_BOT_AUTO_BUY_RETAILERS= +PRICE_BOT_TARGET_FAILURE_SKIP_COUNT=3 +PRICE_BOT_TARGET_SKIP_MINUTES=60 +PRICE_BOT_DRY_RUN=false +PRICE_BOT_TEST_SEND=false +PRICE_BOT_SELF_TEST=false +PRICE_BOT_STATE_FILE=.price-bot-state.json +PRICE_BOT_FACEBOOK_MARKETPLACE_TARGETS_FILE=config/facebook-marketplace-searches.json +PRICE_BOT_FACEBOOK_MARKETPLACE_RAW_LISTINGS_FILE= +PRICE_BOT_FACEBOOK_MARKETPLACE_MAX_ALERTS=8 +PRICE_BOT_FACEBOOK_MARKETPLACE_DRY_RUN=false +PRICE_BOT_FACEBOOK_MARKETPLACE_TEST_SEND=false + +# Optional source keys +BESTBUY_API_KEY= +EBAY_CLIENT_ID= +EBAY_CLIENT_SECRET= +EBAY_ACCESS_TOKEN= +EBAY_MARKETPLACE_ID=EBAY_US +EBAY_OAUTH_SCOPE=https://api.ebay.com/oauth/api_scope/buy.marketplace.insights +# Optional JSON array overriding the built-in Best Buy and Target targets. +# PRICE_BOT_TARGETS=[{"retailer":"example","url":"https://example.com/deals","category":"electronics","selectors":{"container":".product","title":".title","price":".price","originalPrice":".was","link":"a","image":"img"}}] # Twitter/X API (for bot posting) TWITTER_API_KEY=... diff --git a/.gitignore b/.gitignore index ecfe81b..9b9cd31 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,13 @@ yarn-error.log* # local env files .env*.local .env +.price-bot-state.json +.price-bot.sqlite +.price-bot.sqlite-* +.argus-*.json +.argus-*.txt +.marketplace-*.json +logs/ # vercel diff --git a/README.md b/README.md index 04a2ca0..7e562ef 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,43 @@ npm run worker # Or run specific workers npm run worker:validate # Anomaly validator npm run worker:notify # Notification sender +npm run worker:discord-price # Simple Discord price-error bot +``` + +### Simple Discord Price Bot + +For the lightweight version, skip Postgres, Redis, queues, and AI validation. Set `DISCORD_WEBHOOK_URL`, then run: + +```bash +npm run worker:discord-price +``` + +The bot scrapes the configured retailer pages, feeds, and optional APIs, uses deterministic price-drop detection, sends compact Discord webhook embeds, and stores price history/dedupe state in `.price-bot.sqlite` when available. Use `config/pc-retailers-active.json` for the productive lean list; `config/pc-retailers.json` keeps the broad research list. Feeds come from `config/pc-feeds.json`; optional API targets come from `config/pc-apis.json`. Static HTML is tried first, then Playwright renders pages that fail or return zero products. Repeatedly empty/failing targets are put on temporary cooldown. Use `PRICE_BOT_TARGETS_FILE` / `PRICE_BOT_FEEDS_FILE` / `PRICE_BOT_APIS_FILE` to point at other JSON files or `PRICE_BOT_TARGETS` to provide an inline selector-based list. + +Resale alerts are separate from strict price-error alerts. The resale lanes are `RESALE` for new/sealed PC electronics, `OPENBOX_RESALE` for acceptable open-box PC deals, `TOOL_RESALE` for tool kits/batteries/scanners, and `CAR_RESALE` for local-friendly car gear. The default scoring is conservative and local-first: local resale uses `PRICE_BOT_RESALE_LOCAL_FEE_PERCENT=0`, shipped fallback uses `PRICE_BOT_RESALE_FEE_PERCENT=13.25`, tax uses `PRICE_BOT_RESALE_TAX_PERCENT=8.25`, and shipping buffer only applies to shipped estimates. Each lane must clear profit, margin, and a lane-specific minimum discount before alerting. Open-box/clearance can qualify only for resale lanes; used, damaged, as-is, missing, seller-refurbished, and for-parts listings are rejected. + +Additional resale sources are loaded from `config/resale-openbox-retailers.json`, `config/resale-tool-retailers.json`, and `config/resale-car-retailers.json`. Override those with `PRICE_BOT_OPENBOX_TARGETS_FILE`, `PRICE_BOT_TOOL_TARGETS_FILE`, and `PRICE_BOT_CAR_TARGETS_FILE`. Retailer trust/penalty rules load from `config/retailer-reliability.json`. + +Buy score combines estimated profit, margin, discount, liquidity/path, condition risk, and optional eBay sold comps. Set `PRICE_BOT_EBAY_COMPS_ENABLED=true` with eBay Marketplace Insights credentials (`EBAY_CLIENT_ID`/`EBAY_CLIENT_SECRET` or `EBAY_ACCESS_TOKEN`) to validate candidates against recent sold listings. Marketplace Insights is a limited-release eBay Buy API; if access is missing or forbidden, the bot logs the issue and continues without comp gating. When comps are enabled and available, candidates without enough sold comps are rejected by default via `PRICE_BOT_EBAY_COMPS_REQUIRE=true`. + +Buy decisions are computed after buy score. Alerts can be `WATCH`, `NEEDS_REVIEW`, `READY_TO_BUY`, or `REJECTED`, and Discord shows comp confidence, buy-prep max price, preferred resale venue, decision notes, risk flags, local-only/store-pickup-only hints, and tool kit breakout value when detected. All price-error, resale, and Facebook Marketplace alerts route to `DISCORD_WEBHOOK_URL` so they land in Nova Price Error; lanes remain visible as alert fields, not Discord channels. Purchase automation is intentionally off by default: `PRICE_BOT_BUY_AUTOMATION_ENABLED=false` and `PRICE_BOT_BUY_AUTOMATION_MODE=off`. Future retailer-specific adapters can use `prepare`, `auto_cart`, or `auto_buy`, but only under the configured score, price, spend, and retailer allowlist gates. + +Facebook Marketplace Discord pushes use `npm run worker:facebook-marketplace:discord` with `PRICE_BOT_FACEBOOK_MARKETPLACE_RAW_LISTINGS_FILE` when the browser extractor writes raw visible cards. For a quick seeded send/dry-run of the current known candidates, use `PRICE_BOT_FACEBOOK_MARKETPLACE_TEST_SEND=true` and optionally `PRICE_BOT_FACEBOOK_MARKETPLACE_DRY_RUN=true`. + +Facebook Marketplace monitoring is browser-only because it depends on the user's logged-in Chrome tab. The read-only scan targets live in `config/facebook-marketplace-searches.json`, and listing text is scored by `src/workers/facebook-marketplace-scorer.ts`. It must only browse/search listings and report candidates; it must never message sellers, click contact buttons, save listings, or make offers. + +```bash +npm run worker:discord-price:status +npm run worker:discord-price:feedback -- list +npm run worker:discord-price:feedback -- mark good "solid margin" +npm run worker:discord-price:feedback -- summary +npm run worker:facebook-marketplace:self-test +``` + +```bash +PRICE_BOT_DRY_RUN=true npm run worker:discord-price +PRICE_BOT_TEST_SEND=true npm run worker:discord-price +PRICE_BOT_SELF_TEST=true npm run worker:discord-price ``` --- diff --git a/config/argus-procurement-searches.json b/config/argus-procurement-searches.json new file mode 100644 index 0000000..b601224 --- /dev/null +++ b/config/argus-procurement-searches.json @@ -0,0 +1,74 @@ +[ + { + "id": "camera-basic", + "priority": 1, + "slot": "camera", + "label": "First camera node", + "queries": ["tapo c500", "outdoor rtsp onvif camera", "wifi onvif outdoor camera", "tapo outdoor camera"], + "targetMin": 30, + "targetMax": 45 + }, + { + "id": "camera-upgraded", + "priority": 1, + "slot": "camera", + "label": "Upgraded camera node", + "queries": ["tapo c520ws", "tapo c520", "outdoor onvif ethernet camera", "rtsp onvif outdoor wifi camera"], + "targetMin": 45, + "targetMax": 70 + }, + { + "id": "microsd", + "priority": 1, + "slot": "microsd", + "label": "High-endurance microSD", + "queries": ["128gb high endurance microsd", "256gb high endurance microsd", "surveillance microsd card"], + "targetMin": 15, + "targetMax": 30 + }, + { + "id": "mounting-materials", + "priority": 1, + "slot": "mounting", + "label": "Mounting and weatherproof materials", + "queries": ["weatherproof junction box lot", "camera bracket u bolt lot", "outdoor electrical box cable glands", "satellite dish mount pole"], + "targetMin": 10, + "targetMax": 25 + }, + { + "id": "technic-electronics", + "priority": 2, + "slot": "technic-electronics", + "label": "Technic hub plus motors", + "queries": ["LEGO Technic hub motor lot", "Powered Up motor lot", "Technic Control+ incomplete set"], + "targetMin": 50, + "targetMax": 75 + }, + { + "id": "technic-bundle", + "priority": 2, + "slot": "technic-electronics", + "label": "Technic hub, motors, and useful parts", + "queries": ["broken app controlled LEGO Technic", "LEGO Technic hub motors gears", "LEGO robotics parts lot"], + "targetMin": 70, + "targetMax": 110 + }, + { + "id": "technic-bulk-parts", + "priority": 2, + "slot": "technic-parts", + "label": "Technic bulk gears axles beams", + "queries": ["bulk Technic gears axles beams", "LEGO Technic parts lot gears axles", "Technic liftarms pins connectors lot"], + "targetMin": 20, + "targetMax": 60 + }, + { + "id": "future-phase", + "priority": 3, + "slot": "future-phase", + "label": "Future ARGUS watchlist", + "queries": ["TP-Link EAP110 Outdoor", "TP-Link CPE210", "RTL-SDR", "Raspberry Pi", "Jetson", "thermal camera", "LiFePO4 battery"], + "targetMin": 1, + "targetMax": 999 + } +] diff --git a/config/facebook-marketplace-searches.json b/config/facebook-marketplace-searches.json new file mode 100644 index 0000000..1ff5146 --- /dev/null +++ b/config/facebook-marketplace-searches.json @@ -0,0 +1,212 @@ +[ + { + "query": "rtx 3080", + "lane": "pc", + "requireAny": ["rtx 3080", "3080"], + "rejectAny": ["water cooled", "hydro copper", "custom loop", "for parts", "not working"], + "riskAny": ["ships to you", "firm", "trade"], + "minPrice": 150, + "maxPrice": 330, + "estimatedResale": 430, + "minProfit": 75, + "minMarginPercent": 20, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "rtx 4070", + "lane": "pc", + "requireAny": ["rtx 4070", "4070 super"], + "rejectAny": ["4070 ti", "laptop gpu", "for parts", "not working"], + "riskAny": ["ships to you", "firm", "trade"], + "minPrice": 250, + "maxPrice": 430, + "estimatedResale": 560, + "minProfit": 90, + "minMarginPercent": 20, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "oled monitor", + "lane": "pc", + "requireAny": ["oled"], + "rejectAny": ["lcd", "led monitor", "parts", "cracked", "line on screen"], + "riskAny": ["ships to you", "no stand"], + "minPrice": 150, + "maxPrice": 450, + "estimatedResale": 650, + "minProfit": 125, + "minMarginPercent": 25, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "milwaukee m18 battery", + "lane": "tool", + "requireAll": ["milwaukee", "m18"], + "rejectAny": ["reconditioned", "recovered", "cells replaced", "dead", "not charging"], + "riskAny": ["used", "each", "ships to you"], + "minPrice": 35, + "maxPrice": 90, + "estimatedResale": 150, + "minProfit": 45, + "minMarginPercent": 45, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "uniden r7", + "lane": "car", + "requireAny": ["uniden r7", "uniden r8"], + "rejectAny": ["uniden r3", "uniden u60", "blend mount", "mount", "bracket", "for parts"], + "riskAny": ["ships to you", "verify fitment"], + "minPrice": 150, + "maxPrice": 300, + "estimatedResale": 420, + "minProfit": 85, + "minMarginPercent": 25, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "macbook m1", + "lane": "pc", + "requireAny": ["macbook air m1", "macbook pro m1", "macbook m1"], + "rejectAny": ["icloud locked", "activation locked", "parts", "cracked", "liquid damage", "2015", "2017", "2018", "intel"], + "riskAny": ["no charger", "firm", "ships to you"], + "minPrice": 250, + "maxPrice": 430, + "estimatedResale": 560, + "minProfit": 90, + "minMarginPercent": 22, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "ipad pro m1", + "lane": "pc", + "requireAny": ["ipad pro m1", "ipad pro 11", "ipad pro 12.9"], + "rejectAny": ["icloud locked", "activation locked", "cracked", "parts", "cellular locked", "screen only"], + "riskAny": ["no charger", "ships to you"], + "minPrice": 250, + "maxPrice": 450, + "estimatedResale": 620, + "minProfit": 100, + "minMarginPercent": 22, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "steam deck oled", + "lane": "pc", + "requireAny": ["steam deck oled"], + "rejectAny": ["lcd", "broken", "parts", "not working", "banned"], + "riskAny": ["ships to you", "no charger"], + "minPrice": 250, + "maxPrice": 430, + "estimatedResale": 560, + "minProfit": 85, + "minMarginPercent": 20, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "rog ally x", + "lane": "pc", + "requireAny": ["rog ally x", "asus rog ally x"], + "rejectAny": ["rog ally z1", "z1 extreme", "broken", "parts", "not working"], + "riskAny": ["ships to you", "no charger"], + "minPrice": 320, + "maxPrice": 520, + "estimatedResale": 690, + "minProfit": 100, + "minMarginPercent": 20, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "ps5 slim", + "lane": "pc", + "requireAny": ["ps5 slim", "playstation 5 slim"], + "rejectAny": ["digital code", "box only", "broken", "banned", "not working", "controller only"], + "riskAny": ["no controller", "ships to you"], + "minPrice": 180, + "maxPrice": 300, + "estimatedResale": 400, + "minProfit": 70, + "minMarginPercent": 22, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "xbox series x", + "lane": "pc", + "requireAny": ["xbox series x"], + "rejectAny": ["series s", "one x", "box only", "broken", "banned", "not working", "controller only"], + "riskAny": ["no controller", "ships to you"], + "minPrice": 150, + "maxPrice": 260, + "estimatedResale": 340, + "minProfit": 60, + "minMarginPercent": 22, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "synology nas", + "lane": "pc", + "requireAny": ["synology", "ds920", "ds923", "ds1522", "ds220", "ds224"], + "rejectAny": ["diskless j", "ds216j", "broken", "parts", "not working", "drive only"], + "riskAny": ["diskless", "ships to you"], + "minPrice": 120, + "maxPrice": 350, + "estimatedResale": 500, + "minProfit": 100, + "minMarginPercent": 28, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "elgato hd60 x", + "lane": "pc", + "requireAny": ["elgato hd60 x", "elgato hd60x", "4k60 pro", "4k x"], + "rejectAny": ["hd60 s", "cam link", "broken", "parts", "not working"], + "riskAny": ["ships to you", "no cable"], + "minPrice": 45, + "maxPrice": 95, + "estimatedResale": 150, + "minProfit": 45, + "minMarginPercent": 45, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "dji mini 3 pro", + "lane": "pc", + "requireAny": ["dji mini 3 pro", "dji mini 4 pro"], + "rejectAny": ["mini 2", "crashed", "broken", "parts", "not working", "battery only", "controller only"], + "riskAny": ["ships to you", "no controller", "no batteries"], + "minPrice": 300, + "maxPrice": 600, + "estimatedResale": 800, + "minProfit": 125, + "minMarginPercent": 22, + "readyScore": 75, + "reviewScore": 58 + }, + { + "query": "sony a7 iii", + "lane": "pc", + "requireAny": ["sony a7 iii", "sony a7iii", "sony a7 iv", "sony a7iv"], + "rejectAny": ["lens only", "broken", "parts", "not working", "for repair", "a7 ii"], + "riskAny": ["high shutter", "ships to you", "body only"], + "minPrice": 550, + "maxPrice": 950, + "estimatedResale": 1250, + "minProfit": 175, + "minMarginPercent": 20, + "readyScore": 75, + "reviewScore": 58 + } +] diff --git a/config/pc-apis.json b/config/pc-apis.json new file mode 100644 index 0000000..28858af --- /dev/null +++ b/config/pc-apis.json @@ -0,0 +1,9 @@ +[ + { + "retailer": "bestbuy-api", + "url": "https://api.bestbuy.com/v1/products((search=gaming%20laptop)|(search=gaming%20desktop)|(search=graphics%20card)|(search=ssd))", + "category": "bestbuy-products", + "kind": "api", + "adapter": "bestbuy" + } +] diff --git a/config/pc-feeds.json b/config/pc-feeds.json new file mode 100644 index 0000000..21eeeec --- /dev/null +++ b/config/pc-feeds.json @@ -0,0 +1,79 @@ +[ + { + "retailer": "reddit-buildapcsales", + "url": "https://www.reddit.com/r/buildapcsales/new/.rss", + "category": "pc-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-gpu", + "url": "https://slickdeals.net/newsearch.php?q=gpu&searcharea=deals&searchin=first&rss=1", + "category": "gpu-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-ssd", + "url": "https://slickdeals.net/newsearch.php?q=ssd&searcharea=deals&searchin=first&rss=1", + "category": "ssd-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-gaming-laptop", + "url": "https://slickdeals.net/newsearch.php?q=gaming%20laptop&searcharea=deals&searchin=first&rss=1", + "category": "gaming-laptop-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-monitor", + "url": "https://slickdeals.net/newsearch.php?q=monitor&searcharea=deals&searchin=first&rss=1", + "category": "monitor-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-oled-monitor", + "url": "https://slickdeals.net/newsearch.php?q=oled%20monitor%20240hz%20360hz&searcharea=deals&searchin=first&rss=1", + "category": "oled-monitor-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-gaming-pc", + "url": "https://slickdeals.net/newsearch.php?q=gaming%20pc&searcharea=deals&searchin=first&rss=1", + "category": "gaming-pc-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-macbook-ipad", + "url": "https://slickdeals.net/newsearch.php?q=macbook%20ipad%20apple&searcharea=deals&searchin=first&rss=1", + "category": "apple-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-mini-pc", + "url": "https://slickdeals.net/newsearch.php?q=mini%20pc%20nuc%20beelink%20minisforum&searcharea=deals&searchin=first&rss=1", + "category": "mini-pc-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "dealnews-computers", + "url": "https://www.dealnews.com/c39/Computers/?rss=1", + "category": "computer-deals-feed", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "tomshardware-feed", + "url": "https://www.tomshardware.com/feeds/all", + "category": "pc-hardware-news-feed", + "kind": "feed", + "adapter": "rss" + } +] diff --git a/config/pc-retailers-active.json b/config/pc-retailers-active.json new file mode 100644 index 0000000..5281ca5 --- /dev/null +++ b/config/pc-retailers-active.json @@ -0,0 +1,139 @@ +[ + { + "retailer": "newegg", + "url": "https://www.newegg.com/Desktop-Graphics-Cards/SubCategory/ID-48", + "category": "gpu", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Gaming-Desktops/SubCategory/ID-3742", + "category": "gaming-desktops", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Processors-Desktops/SubCategory/ID-343", + "category": "cpu", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Internal-SSDs/SubCategory/ID-636", + "category": "ssd", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Desktop-Memory/SubCategory/ID-147", + "category": "ram", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Power-Supplies/SubCategory/ID-58", + "category": "psu", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/LCD-LED-Monitors/SubCategory/ID-20", + "category": "monitors", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "walmart", + "url": "https://www.walmart.com/search?q=gaming+desktop", + "category": "gaming-desktops", + "selectors": { + "container": "[data-testid=\"item-stack\"]", + "title": "[data-automation-id=\"product-title\"]", + "price": "[data-automation-id=\"product-price\"]", + "originalPrice": "[data-testid=\"list-view\"]", + "link": "a[href*=\"/ip/\"]", + "image": "img" + } + }, + { + "retailer": "walmart", + "url": "https://www.walmart.com/search?q=gaming+laptop", + "category": "gaming-laptops", + "selectors": { + "container": "[data-testid=\"item-stack\"]", + "title": "[data-automation-id=\"product-title\"]", + "price": "[data-automation-id=\"product-price\"]", + "originalPrice": "[data-testid=\"list-view\"]", + "link": "a[href*=\"/ip/\"]", + "image": "img" + } + }, + { + "retailer": "dell", + "url": "https://www.dell.com/en-us/shop/deals/pc-and-electronics-deals", + "category": "computers", + "selectors": { + "container": "[data-testid=\"product-card\"], .ps-stack, .product-card", + "title": "h3, h2, .ps-title", + "price": ".ps-dell-price, [data-testid=\"price\"], .price", + "originalPrice": ".ps-market-price, .strike-through", + "link": "a", + "image": "img" + } + } +] diff --git a/config/pc-retailers.json b/config/pc-retailers.json new file mode 100644 index 0000000..57df817 --- /dev/null +++ b/config/pc-retailers.json @@ -0,0 +1,437 @@ +[ + { + "retailer": "amazon", + "url": "https://www.amazon.com/s?k=pc+components", + "category": "pc-parts", + "selectors": { + "container": "[data-component-type=\"s-search-result\"]", + "title": "h2 span", + "price": ".a-price .a-offscreen", + "originalPrice": ".a-text-price .a-offscreen", + "link": "h2 a", + "image": "img.s-image" + } + }, + { + "retailer": "amazon", + "url": "https://www.amazon.com/s?k=gaming+laptop", + "category": "gaming-laptops", + "selectors": { + "container": "[data-component-type=\"s-search-result\"]", + "title": "h2 span", + "price": ".a-price .a-offscreen", + "originalPrice": ".a-text-price .a-offscreen", + "link": "h2 a", + "image": "img.s-image" + } + }, + { + "retailer": "bestbuy", + "url": "https://www.bestbuy.com/site/electronics/computers-pcs/abcat0500000.c?id=abcat0500000", + "category": "computers", + "selectors": { + "container": ".sku-item", + "title": ".sku-header a", + "price": ".priceView-customer-price span", + "originalPrice": ".pricing-price__regular-price", + "link": ".sku-header a", + "image": ".product-image" + } + }, + { + "retailer": "bestbuy", + "url": "https://www.bestbuy.com/site/pc-gaming/gaming-desktops/pcmcat287600050002.c?id=pcmcat287600050002", + "category": "gaming-desktops", + "selectors": { + "container": ".sku-item", + "title": ".sku-header a", + "price": ".priceView-customer-price span", + "originalPrice": ".pricing-price__regular-price", + "link": ".sku-header a", + "image": ".product-image" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/todays-deals", + "category": "pc-deals", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Desktop-Graphics-Cards/SubCategory/ID-48", + "category": "gpu", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Gaming-Desktops/SubCategory/ID-3742", + "category": "gaming-desktops", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Processors-Desktops/SubCategory/ID-343", + "category": "cpu", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Internal-SSDs/SubCategory/ID-636", + "category": "ssd", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Desktop-Memory/SubCategory/ID-147", + "category": "ram", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/Power-Supplies/SubCategory/ID-58", + "category": "psu", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/LCD-LED-Monitors/SubCategory/ID-20", + "category": "monitors", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "newegg", + "url": "https://www.newegg.com/tools/combo-builder/1740", + "category": "pc-bundles", + "adapter": "newegg", + "selectors": { + "container": ".item-cell, .item-container", + "title": ".item-title", + "price": ".price-current", + "originalPrice": ".price-was", + "link": ".item-title", + "image": ".item-img img" + } + }, + { + "retailer": "microcenter", + "url": "https://www.microcenter.com/site/products/computer-parts.aspx", + "category": "pc-parts", + "selectors": { + "container": ".product_wrapper, .product", + "title": ".normal h2 a, .product_wrapper h2 a, a.ProductLink", + "price": ".price, .sale", + "originalPrice": ".original, .was", + "link": ".normal h2 a, .product_wrapper h2 a, a.ProductLink", + "image": "img" + } + }, + { + "retailer": "microcenter", + "url": "https://www.microcenter.com/category/4294967288/laptops-notebooks", + "category": "laptops", + "selectors": { + "container": ".product_wrapper, .product", + "title": ".normal h2 a, .product_wrapper h2 a, a.ProductLink", + "price": ".price, .sale", + "originalPrice": ".original, .was", + "link": ".normal h2 a, .product_wrapper h2 a, a.ProductLink", + "image": "img" + } + }, + { + "retailer": "walmart", + "url": "https://www.walmart.com/search?q=gaming+desktop", + "category": "gaming-desktops", + "selectors": { + "container": "[data-testid=\"item-stack\"]", + "title": "[data-automation-id=\"product-title\"]", + "price": "[data-automation-id=\"product-price\"]", + "originalPrice": "[data-testid=\"list-view\"]", + "link": "a[href*=\"/ip/\"]", + "image": "img" + } + }, + { + "retailer": "walmart", + "url": "https://www.walmart.com/search?q=gaming+laptop", + "category": "gaming-laptops", + "selectors": { + "container": "[data-testid=\"item-stack\"]", + "title": "[data-automation-id=\"product-title\"]", + "price": "[data-automation-id=\"product-price\"]", + "originalPrice": "[data-testid=\"list-view\"]", + "link": "a[href*=\"/ip/\"]", + "image": "img" + } + }, + { + "retailer": "target", + "url": "https://www.target.com/c/electronics/-/N-5xtg6", + "category": "electronics", + "selectors": { + "container": "[data-test=\"product-card\"]", + "title": "[data-test=\"product-title\"]", + "price": "[data-test=\"current-price\"] span", + "originalPrice": "[data-test=\"comparison-price\"]", + "link": "[data-test=\"product-title\"]", + "image": "[data-test=\"product-image\"] img" + } + }, + { + "retailer": "costco", + "url": "https://www.costco.com/computers.html", + "category": "computers", + "selectors": { + "container": ".product, .product-tile", + "title": ".description a, .product-title", + "price": ".price, .product-price", + "originalPrice": ".list-price, .was-price", + "link": ".description a, a", + "image": "img" + } + }, + { + "retailer": "bhphoto", + "url": "https://www.bhphotovideo.com/c/buy/Desktop-Computers/ci/28417", + "category": "desktops", + "selectors": { + "container": "[data-selenium=\"miniProductPageProduct\"], .item", + "title": "[data-selenium=\"miniProductPageProductName\"], .itemTitle", + "price": "[data-selenium=\"uppedDecimalPriceFirst\"], [data-selenium=\"price\"]", + "originalPrice": "[data-selenium=\"strikePrice\"]", + "link": "a", + "image": "img" + } + }, + { + "retailer": "bhphoto", + "url": "https://www.bhphotovideo.com/c/buy/Computer-Monitors/ci/6559", + "category": "monitors", + "selectors": { + "container": "[data-selenium=\"miniProductPageProduct\"], .item", + "title": "[data-selenium=\"miniProductPageProductName\"], .itemTitle", + "price": "[data-selenium=\"uppedDecimalPriceFirst\"], [data-selenium=\"price\"]", + "originalPrice": "[data-selenium=\"strikePrice\"]", + "link": "a", + "image": "img" + } + }, + { + "retailer": "adorama", + "url": "https://www.adorama.com/l/Computers", + "category": "computers", + "selectors": { + "container": ".item, .product", + "title": ".item-details a, .product-name", + "price": ".price, .your-price", + "originalPrice": ".regular-price, .was-price", + "link": ".item-details a, a", + "image": "img" + } + }, + { + "retailer": "staples", + "url": "https://www.staples.com/laptops/cat_CL167289", + "category": "laptops", + "selectors": { + "container": "[data-testid=\"product-card\"], .product-card", + "title": "[data-testid=\"product-title\"], .product-title", + "price": "[data-testid=\"price\"], .price", + "originalPrice": ".list-price, .was-price", + "link": "a", + "image": "img" + } + }, + { + "retailer": "officedepot", + "url": "https://www.officedepot.com/a/browse/laptop-computers/N=5+1462020/", + "category": "laptops", + "selectors": { + "container": ".product, .od-product-card", + "title": ".product-title, .description a", + "price": ".price, .sale-price", + "originalPrice": ".was-price, .list-price", + "link": "a", + "image": "img" + } + }, + { + "retailer": "dell", + "url": "https://www.dell.com/en-us/shop/deals/pc-and-electronics-deals", + "category": "computers", + "selectors": { + "container": "[data-testid=\"product-card\"], .ps-stack, .product-card", + "title": "h3, h2, .ps-title", + "price": ".ps-dell-price, [data-testid=\"price\"], .price", + "originalPrice": ".ps-market-price, .strike-through", + "link": "a", + "image": "img" + } + }, + { + "retailer": "hp", + "url": "https://www.hp.com/us-en/shop/vwa/laptops", + "category": "laptops", + "selectors": { + "container": ".product-tile, .product", + "title": ".product-title, h3", + "price": ".price, .sale-price", + "originalPrice": ".regular-price, .strike", + "link": "a", + "image": "img" + } + }, + { + "retailer": "lenovo", + "url": "https://www.lenovo.com/us/en/d/deals/laptops/", + "category": "laptops", + "selectors": { + "container": ".product_item, .product-card, [data-testid=\"product-card\"]", + "title": ".product_title, .product-title, h3", + "price": ".price, .saleprice, [data-testid=\"price\"]", + "originalPrice": ".webprice, .list-price, .strike-through", + "link": "a", + "image": "img" + } + }, + { + "retailer": "asus", + "url": "https://shop.asus.com/us/laptops.html", + "category": "laptops", + "selectors": { + "container": ".product-item", + "title": ".product-item-link", + "price": ".price", + "originalPrice": ".old-price", + "link": ".product-item-link", + "image": "img" + } + }, + { + "retailer": "acer", + "url": "https://store.acer.com/en-us/laptops", + "category": "laptops", + "selectors": { + "container": ".product-item", + "title": ".product-item-link", + "price": ".price", + "originalPrice": ".old-price", + "link": ".product-item-link", + "image": "img" + } + }, + { + "retailer": "corsair", + "url": "https://www.corsair.com/us/en/c/gaming-computers", + "category": "gaming-desktops", + "selectors": { + "container": ".product-tile, .product-card", + "title": ".product-title, h3", + "price": ".price, .product-price", + "originalPrice": ".compare-at-price, .was-price", + "link": "a", + "image": "img" + } + }, + { + "retailer": "nzxt", + "url": "https://nzxt.com/category/gaming-pcs/build", + "category": "gaming-desktops", + "selectors": { + "container": ".product-card, [data-testid=\"product-card\"]", + "title": "h3, .product-title", + "price": ".price, [data-testid=\"price\"]", + "originalPrice": ".compare-at-price, .was-price", + "link": "a", + "image": "img" + } + }, + { + "retailer": "ibuypower", + "url": "https://www.ibuypower.com/store/gaming-pcs", + "category": "gaming-desktops", + "selectors": { + "container": ".product-card, .product", + "title": ".product-title, h3", + "price": ".price, .sale-price", + "originalPrice": ".retail-price, .was-price", + "link": "a", + "image": "img" + } + }, + { + "retailer": "cyberpowerpc", + "url": "https://www.cyberpowerpc.com/category/gaming-pcs/", + "category": "gaming-desktops", + "selectors": { + "container": ".product, .product-card", + "title": ".product-title, h3", + "price": ".price, .sale-price", + "originalPrice": ".was-price, .regular-price", + "link": "a", + "image": "img" + } + } +] diff --git a/config/resale-car-retailers.json b/config/resale-car-retailers.json new file mode 100644 index 0000000..1bf552b --- /dev/null +++ b/config/resale-car-retailers.json @@ -0,0 +1,66 @@ +[ + { + "retailer": "slickdeals-obd-scanners", + "url": "https://slickdeals.net/newsearch.php?q=obd2%20diagnostic%20scanner%20automotive&searcharea=deals&searchin=first&rss=1", + "category": "car-diagnostic", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-dashcam-radar", + "url": "https://slickdeals.net/newsearch.php?q=dash%20cam%20radar%20detector&searcharea=deals&searchin=first&rss=1", + "category": "car-electronics", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-uniden-escort-radar", + "url": "https://slickdeals.net/newsearch.php?q=uniden%20escort%20radar%20detector%20rd8%20rl7&searcharea=deals&searchin=first&rss=1", + "category": "car-electronics", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-jump-pack", + "url": "https://slickdeals.net/newsearch.php?q=jump%20starter%20jump%20pack%20car&searcharea=deals&searchin=first&rss=1", + "category": "car-tools", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-detailing-polisher", + "url": "https://slickdeals.net/newsearch.php?q=detailing%20polisher%20car&searcharea=deals&searchin=first&rss=1", + "category": "car-detailing", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-racks-hitches", + "url": "https://slickdeals.net/newsearch.php?q=truck%20bed%20cover%20rack%20hitch&searcharea=deals&searchin=first&rss=1", + "category": "car-accessories", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-ceramic-coating", + "url": "https://slickdeals.net/newsearch.php?q=ceramic%20coating%20detailing%20car%20polisher&searcharea=deals&searchin=first&rss=1", + "category": "car-detailing", + "lane": "car", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-car-tuners", + "url": "https://slickdeals.net/newsearch.php?q=accessport%20tuner%20car%20performance%20scanner&searcharea=deals&searchin=first&rss=1", + "category": "car-electronics", + "lane": "car", + "kind": "feed", + "adapter": "rss" + } +] diff --git a/config/resale-openbox-retailers.json b/config/resale-openbox-retailers.json new file mode 100644 index 0000000..ae4587e --- /dev/null +++ b/config/resale-openbox-retailers.json @@ -0,0 +1,50 @@ +[ + { + "retailer": "slickdeals-bestbuy-openbox-monitor", + "url": "https://slickdeals.net/newsearch.php?q=best%20buy%20open%20box%20monitor&searcharea=deals&searchin=first&rss=1", + "category": "openbox-monitor", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-bestbuy-openbox-laptop", + "url": "https://slickdeals.net/newsearch.php?q=best%20buy%20open%20box%20gaming%20laptop&searcharea=deals&searchin=first&rss=1", + "category": "openbox-gaming-laptop", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-microcenter-openbox", + "url": "https://slickdeals.net/newsearch.php?q=micro%20center%20open%20box&searcharea=deals&searchin=first&rss=1", + "category": "openbox-pc", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-amazon-warehouse-pc", + "url": "https://slickdeals.net/newsearch.php?q=amazon%20warehouse%20monitor%20laptop%20ssd%20gpu&searcharea=deals&searchin=first&rss=1", + "category": "openbox-pc", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-newegg-openbox", + "url": "https://slickdeals.net/newsearch.php?q=newegg%20open%20box%20gpu%20monitor%20laptop&searcharea=deals&searchin=first&rss=1", + "category": "openbox-pc", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-bh-adorama-openbox", + "url": "https://slickdeals.net/newsearch.php?q=b%26h%20adorama%20open%20box%20monitor%20laptop&searcharea=deals&searchin=first&rss=1", + "category": "openbox-pc", + "lane": "openbox", + "kind": "feed", + "adapter": "rss" + } +] diff --git a/config/resale-tool-retailers.json b/config/resale-tool-retailers.json new file mode 100644 index 0000000..8654265 --- /dev/null +++ b/config/resale-tool-retailers.json @@ -0,0 +1,58 @@ +[ + { + "retailer": "slickdeals-milwaukee-tools", + "url": "https://slickdeals.net/newsearch.php?q=milwaukee%20tool%20battery%20charger%20combo&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-dewalt-tools", + "url": "https://slickdeals.net/newsearch.php?q=dewalt%20tool%20battery%20charger%20combo&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-makita-ryobi-ridgid", + "url": "https://slickdeals.net/newsearch.php?q=makita%20ryobi%20ridgid%20tool%20combo&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-tool-battery", + "url": "https://slickdeals.net/newsearch.php?q=tool%20battery%20charger%20kit&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-home-depot-tools", + "url": "https://slickdeals.net/newsearch.php?q=home%20depot%20tool%20combo%20kit&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-pressure-washer", + "url": "https://slickdeals.net/newsearch.php?q=pressure%20washer%20ego%20dewalt%20ryobi%20electric&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + }, + { + "retailer": "slickdeals-tool-storage", + "url": "https://slickdeals.net/newsearch.php?q=milwaukee%20packout%20tool%20storage%20dewalt%20tstak&searcharea=deals&searchin=first&rss=1", + "category": "tools", + "lane": "tool", + "kind": "feed", + "adapter": "rss" + } +] diff --git a/config/retailer-reliability.json b/config/retailer-reliability.json new file mode 100644 index 0000000..068a8c5 --- /dev/null +++ b/config/retailer-reliability.json @@ -0,0 +1,69 @@ +[ + { + "retailer": "newegg", + "scoreAdjustment": 4, + "tags": ["known pc retailer", "fast liquidity"] + }, + { + "retailer": "dell", + "scoreAdjustment": 4, + "tags": ["manufacturer direct"] + }, + { + "retailer": "bestbuy", + "scoreAdjustment": 5, + "tags": ["store pickup friendly", "clear open-box grading"] + }, + { + "retailer": "bestbuy-api", + "scoreAdjustment": 5, + "tags": ["api sourced", "store pickup friendly"] + }, + { + "retailer": "microcenter", + "scoreAdjustment": 5, + "tags": ["local pickup friendly", "clear open-box grading"] + }, + { + "retailer": "bhphoto", + "scoreAdjustment": 2, + "tags": ["specialty electronics retailer"] + }, + { + "retailer": "adorama", + "scoreAdjustment": 2, + "tags": ["specialty electronics retailer"] + }, + { + "retailer": "home-depot", + "scoreAdjustment": 3, + "tags": ["tool retail source"] + }, + { + "retailer": "lowes", + "scoreAdjustment": 3, + "tags": ["tool retail source"] + }, + { + "retailer": "amazon", + "scoreAdjustment": -2, + "tags": ["condition variance"], + "riskFlags": ["seller-risk"] + }, + { + "retailer": "walmart", + "scoreAdjustment": -3, + "tags": ["marketplace variance"], + "riskFlags": ["seller-risk"] + }, + { + "retailer": "slickdeals", + "scoreAdjustment": 0, + "tags": ["deal feed"] + }, + { + "retailer": "reddit", + "scoreAdjustment": 0, + "tags": ["community feed"] + } +] diff --git a/package.json b/package.json index 9cfdefc..1d798e8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,18 @@ "start": "next start", "lint": "next lint", "worker": "tsx src/worker.ts", + "worker:discord-price": "tsx src/workers/discord-price-bot.ts", + "worker:discord-price:dry-run": "powershell -NoProfile -Command \"$env:PRICE_BOT_DRY_RUN='true'; tsx src/workers/discord-price-bot.ts\"", + "worker:discord-price:test-send": "powershell -NoProfile -Command \"$env:PRICE_BOT_TEST_SEND='true'; tsx src/workers/discord-price-bot.ts\"", + "worker:discord-price:self-test": "powershell -NoProfile -Command \"$env:PRICE_BOT_SELF_TEST='true'; tsx src/workers/discord-price-bot.ts\"", + "worker:discord-price:status": "tsx src/workers/discord-price-status.ts", + "worker:discord-price:feedback": "tsx src/workers/discord-price-feedback.ts", + "worker:facebook-marketplace": "tsx src/workers/facebook-marketplace-scorer.ts", + "worker:facebook-marketplace:self-test": "powershell -NoProfile -Command \"$env:PRICE_BOT_FACEBOOK_MARKETPLACE_SELF_TEST='true'; tsx src/workers/facebook-marketplace-scorer.ts\"", + "worker:facebook-marketplace:discord": "tsx src/workers/facebook-marketplace-discord.ts", + "worker:facebook-marketplace:discord-test-send": "powershell -NoProfile -Command \"$env:PRICE_BOT_FACEBOOK_MARKETPLACE_TEST_SEND='true'; tsx src/workers/facebook-marketplace-discord.ts\"", + "worker:argus": "tsx src/workers/argus-procurement-scorer.ts", + "worker:argus:self-test": "powershell -NoProfile -Command \"$env:PRICE_BOT_ARGUS_SELF_TEST='true'; tsx src/workers/argus-procurement-scorer.ts\"", "worker:validate": "tsx src/workers/anomaly-validator.ts", "worker:notify": "tsx src/workers/notification-sender.ts", "db:seed": "tsx prisma/seed.ts", diff --git a/src/workers/argus-procurement-scorer.test.ts b/src/workers/argus-procurement-scorer.test.ts new file mode 100644 index 0000000..4eb922d --- /dev/null +++ b/src/workers/argus-procurement-scorer.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { buildArgusReport, scoreArgusListing, type ArgusTarget } from './argus-procurement-scorer'; + +const camera: ArgusTarget = { + id: 'camera-basic', + priority: 1, + slot: 'camera', + label: 'First camera node', + queries: ['tapo c500', 'outdoor rtsp onvif camera'], + targetMin: 30, + targetMax: 45, +}; + +const microsd: ArgusTarget = { + id: 'microsd', + priority: 1, + slot: 'microsd', + label: 'High-endurance microSD', + queries: ['256gb high endurance microsd'], + targetMin: 15, + targetMax: 30, +}; + +const technic: ArgusTarget = { + id: 'technic-electronics', + priority: 2, + slot: 'technic-electronics', + label: 'Technic hub plus motors', + queries: ['LEGO Technic hub motor lot'], + targetMin: 50, + targetMax: 75, +}; + +const future: ArgusTarget = { + id: 'future-phase', + priority: 3, + slot: 'future-phase', + label: 'Future ARGUS watchlist', + queries: ['Raspberry Pi'], + targetMin: 1, + targetMax: 999, +}; + +describe('ARGUS procurement scoring', () => { + it('promotes a Tapo C500-class camera only when the ARGUS requirements are supported', () => { + const listing = scoreArgusListing( + { marketplace: 'facebook', query: 'tapo c500', text: '$40 Tapo C500 outdoor WiFi camera with power adapter Tulsa, OK' }, + camera + ); + + expect(['BUY_CANDIDATE', 'NEGOTIATE']).toContain(listing.label); + expect(listing.modelNumber).toBe('Tapo C500'); + expect(listing.totalPrice).toBe(40); + }); + + it('rejects cloud-first or battery-only cameras without RTSP/ONVIF evidence', () => { + const listing = scoreArgusListing({ marketplace: 'facebook', query: 'outdoor rtsp onvif camera', text: '$35 Ring outdoor battery camera Tulsa, OK' }, camera); + + expect(listing.label).toBe('PASS'); + expect(listing.rejectionReasons.join(' ')).toMatch(/battery|cloud/i); + }); + + it('includes eBay shipping in total acquisition cost', () => { + const listing = scoreArgusListing( + { marketplace: 'ebay', query: '256gb high endurance microsd', text: 'Samsung Pro Endurance 256GB microSD card $21.99 +$4.25 shipping' }, + microsd + ); + + expect(listing.itemPrice).toBe(21.99); + expect(listing.shipping).toBe(4.25); + expect(listing.totalPrice).toBe(26.24); + expect(['BUY_CANDIDATE', 'NEGOTIATE']).toContain(listing.label); + }); + + it('passes generic or suspicious storage even if the capacity is high', () => { + const listing = scoreArgusListing({ marketplace: 'facebook', query: '256gb high endurance microsd', text: '$10 generic 512GB microSD untested Tulsa, OK' }, microsd); + + expect(listing.label).toBe('PASS'); + expect(listing.rejectionReasons.join(' ')).toMatch(/endurance|authenticity|condition/i); + }); + + it('requires both a Technic hub and motors for the electronics bundle', () => { + const listing = scoreArgusListing( + { marketplace: 'facebook', query: 'LEGO Technic hub motor lot', text: '$70 LEGO Technic 88012 hub with two motors and gears Broken Arrow, OK' }, + technic + ); + + expect(['BUY_CANDIDATE', 'NEGOTIATE']).toContain(listing.label); + expect(listing.modelNumber).toContain('Technic Hub'); + }); + + it('labels priority-three items as FUTURE_PHASE even when they look useful', () => { + const listing = scoreArgusListing({ marketplace: 'facebook', query: 'Raspberry Pi', text: '$45 Raspberry Pi 5 Tulsa, OK' }, future); + + expect(listing.label).toBe('FUTURE_PHASE'); + }); + + it('builds a ranked procurement report with missing required slots', () => { + const report = buildArgusReport( + [ + { marketplace: 'facebook', query: 'tapo c500', text: '$40 Tapo C500 outdoor WiFi camera with power adapter Tulsa, OK' }, + { marketplace: 'ebay', query: '256gb high endurance microsd', text: 'Samsung Pro Endurance 256GB microSD card $21.99 Free shipping' }, + ], + [camera, microsd, technic] + ); + + expect(report.bestImmediateCamera?.slot).toBe('camera'); + expect(report.bestMicroSd?.slot).toBe('microsd'); + expect(report.missingRequiredItems).toContain('Technic hub plus motors'); + }); +}); diff --git a/src/workers/argus-procurement-scorer.ts b/src/workers/argus-procurement-scorer.ts new file mode 100644 index 0000000..876de1d --- /dev/null +++ b/src/workers/argus-procurement-scorer.ts @@ -0,0 +1,585 @@ +import 'dotenv/config'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export type ArgusSlot = 'camera' | 'microsd' | 'mounting' | 'technic-electronics' | 'technic-parts' | 'future-phase'; +export type ArgusMarketplace = 'facebook' | 'ebay'; +export type ArgusLabel = 'BUY_CANDIDATE' | 'NEGOTIATE' | 'REVIEW' | 'PASS' | 'FUTURE_PHASE'; + +export type ArgusTarget = { + id: string; + priority: 1 | 2 | 3; + slot: ArgusSlot; + label: string; + queries: string[]; + targetMin: number; + targetMax: number; +}; + +export type ArgusRawListing = { + marketplace: ArgusMarketplace; + query?: string; + text: string; + href?: string; + img?: string; + seller?: string; + location?: string; + itemPrice?: number; + shipping?: number; + condition?: string; + observedAt?: string; +}; + +export type ArgusScoredListing = { + marketplace: ArgusMarketplace; + targetId: string; + slot: ArgusSlot; + priority: number; + title: string; + url?: string; + seller?: string; + location?: string; + itemPrice: number; + shipping: number; + totalPrice: number; + condition: string; + modelNumber?: string; + includedAccessories: string[]; + photoEvidence: string[]; + featureEvidence: string[]; + missingReplacementCost: number; + argusFitScore: number; + confidence: number; + label: ArgusLabel; + reasons: string[]; + rejectionReasons: string[]; + sellerQuestions: string[]; + observedAt: string; +}; + +export type ArgusReport = { + observedAt: string; + scannedCount: number; + candidates: ArgusScoredListing[]; + bestImmediateCamera?: ArgusScoredListing; + bestMicroSd?: ArgusScoredListing; + bestMountingMaterials?: ArgusScoredListing; + bestTechnicElectronics?: ArgusScoredListing; + bestTechnicBulkParts?: ArgusScoredListing; + recommendedImmediateTotal: number; + missingRequiredItems: string[]; + nearMatches: ArgusScoredListing[]; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_TARGETS_FILE = + process.env.PRICE_BOT_ARGUS_TARGETS_FILE || path.resolve(__dirname, '../../config/argus-procurement-searches.json'); +const RAW_LISTINGS_FILE = process.env.PRICE_BOT_ARGUS_RAW_LISTINGS_FILE; +const REPORT_FILE = process.env.PRICE_BOT_ARGUS_REPORT_FILE; +const SELF_TEST = process.env.PRICE_BOT_ARGUS_SELF_TEST === 'true'; + +function normalize(text: string) { + return text.toLowerCase().replace(/[^\w.$+-]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function has(text: string, pattern: RegExp) { + return pattern.test(normalize(text)); +} + +function money(value: number) { + return `$${value.toFixed(2)}`; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function parsePrice(raw: string) { + const match = raw.match(/\$\s*([0-9][0-9,]*(?:\.\d{2})?)/); + return match ? Number.parseFloat(match[1].replace(/,/g, '')) : 0; +} + +function parseTitle(raw: string) { + let title = raw.replace(/\s+/g, ' ').trim(); + const ebayTitle = title.match(/^(.*?)(?:\s+Opens in a new window or tab\b)/i); + if (ebayTitle) { + return ebayTitle[1].replace(/^(new listing|great price|sponsored)\s*/i, '').trim(); + } + const price = title.match(/\$\s*[0-9][0-9,]*(?:\.\d{2})?/); + if (price?.index !== undefined) title = title.slice(price.index + price[0].length).trim(); + title = title.replace(/^(just listed|sponsored|sold|pending|opens in a new window or tab)\s+/i, '').trim(); + const location = title.match(/\b([A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3},\s*(?:OK|MO|AR|KS|TX))\b\s*$/); + if (location?.index !== undefined) title = title.slice(0, location.index).trim(); + return title || raw.slice(0, 120); +} + +function parseLocation(raw: ArgusRawListing) { + if (raw.location) return raw.location; + const match = raw.text.match(/\b([A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3},\s*(?:OK|MO|AR|KS|TX))\b\s*$/); + return match?.[1]; +} + +function parseEbayShipping(raw: string) { + if (/\bfree (?:delivery|shipping)\b/i.test(raw)) return 0; + const match = raw.match(/\+\s*\$\s*([0-9][0-9,]*(?:\.\d{2})?)\s*(?:delivery|shipping)/i); + return match ? Number.parseFloat(match[1].replace(/,/g, '')) : 0; +} + +function conditionFromText(text: string, explicit?: string) { + if (explicit) return explicit; + if (has(text, /\b(new|brand new|sealed|unused)\b/)) return 'new'; + if (has(text, /\b(open box|like new)\b/)) return 'open-box'; + if (has(text, /\b(used|pre-owned)\b/)) return 'used'; + if (has(text, /\b(for parts|not working|water damaged|damaged|untested)\b/)) return 'bad'; + return 'unknown'; +} + +function inferCamera(text: string, totalPrice: number) { + const n = normalize(text); + const reasons: string[] = []; + const reject: string[] = []; + const questions: string[] = []; + const features: string[] = []; + const accessories: string[] = []; + const photoEvidence: string[] = []; + let score = 0; + let confidence = 25; + let modelNumber: string | undefined; + let missingReplacementCost = 0; + + if (/\btapo\s*c500\b/.test(n)) { + modelNumber = 'Tapo C500'; + score += 45; + confidence += 40; + features.push('known outdoor powered Wi-Fi camera class', 'microSD support expected', 'RTSP/ONVIF support expected'); + } else if (/\btapo\s*c520ws\b|\btapo\s*c520\b/.test(n)) { + modelNumber = 'Tapo C520WS'; + score += 48; + confidence += 42; + features.push('known upgraded outdoor Wi-Fi camera class', 'microSD support expected', 'RTSP/ONVIF support expected', 'Ethernet likely'); + } else if (/\b(onvif|rtsp)\b/.test(n) && /\b(outdoor|weatherproof|ip66|ip65)\b/.test(n)) { + score += 35; + confidence += 25; + features.push('listing states outdoor plus RTSP/ONVIF'); + } + + if (/\boutdoor|weatherproof|ip66|ip65\b/.test(n)) { + score += 10; + features.push('outdoor-rated evidence'); + } + if (/\brtsp\b/.test(n)) { + score += 12; + features.push('RTSP stated'); + } + if (/\bonvif\b/.test(n)) { + score += 12; + features.push('ONVIF stated'); + } + if (/\bmicrosd|micro sd|sd card|local recording\b/.test(n)) { + score += 10; + features.push('local microSD/local recording stated'); + } + if (/\bwifi|wi-fi|wireless\b/.test(n)) { + score += 6; + features.push('Wi-Fi stated'); + } + if (/\bethernet|rj45|poe\b/.test(n)) { + score += 5; + features.push('Ethernet/PoE stated'); + } + if (/\b(power adapter|adapter included|with power|plug|corded)\b/.test(n)) { + score += 8; + accessories.push('power adapter evidence'); + } else { + missingReplacementCost += 10; + questions.push('Confirm the proprietary power adapter is included or price a replacement.'); + } + + if (/\bbattery(?:-| )only|wire-free|solar only\b/.test(n)) reject.push('battery-only or wire-free camera'); + if (/\bring|blink|wyze|arlo|nest\b/.test(n) && !/\brtsp|onvif\b/.test(n)) reject.push('cloud-first camera without RTSP/ONVIF evidence'); + if (/\bsubscription|required subscription|cloud only|account locked|cannot reset|no reset\b/.test(n)) reject.push('cloud/account lock risk'); + if (/\bwater damaged|scratched lens|cloudy lens|for parts|not working|broken\b/.test(n)) reject.push('condition blocks camera deployment'); + if (!modelNumber && !/\brtsp\b/.test(n)) questions.push('Ask seller to confirm RTSP support.'); + if (!modelNumber && !/\bonvif\b/.test(n)) questions.push('Ask seller to confirm ONVIF support.'); + if (!/\bmicrosd|micro sd|sd card|local recording\b/.test(n) && !modelNumber) questions.push('Ask seller to confirm local microSD recording.'); + + if (totalPrice < 30 || totalPrice > 70) reject.push('outside requested camera target bands'); + if (modelNumber) photoEvidence.push(`model text: ${modelNumber}`); + + return { score, confidence, modelNumber, features, accessories, photoEvidence, missingReplacementCost, reject, reasons, questions }; +} + +function inferMicroSd(text: string, totalPrice: number) { + const n = normalize(text); + const reject: string[] = []; + const questions: string[] = []; + const features: string[] = []; + let score = 0; + let confidence = 25; + let modelNumber: string | undefined; + const capacity = n.match(/\b(128|256|512)\s*gb\b/); + + if (capacity) { + score += Number(capacity[1]) >= 256 ? 25 : 18; + confidence += 25; + modelNumber = `${capacity[1]}GB microSD`; + features.push(`${capacity[1]}GB capacity`); + } + if (/\b(high endurance|max endurance|pro endurance|surveillance|industrial)\b/.test(n)) { + score += 35; + confidence += 25; + features.push('endurance/surveillance line evidence'); + } + if (/\b(sandisk|western digital|wd purple|samsung|kingston|lexar)\b/.test(n)) { + score += 12; + confidence += 12; + features.push('recognizable storage brand'); + } + if (!capacity || Number(capacity[1]) < 128) reject.push('below 128GB minimum or capacity missing'); + if (!/\b(high endurance|max endurance|pro endurance|surveillance|industrial)\b/.test(n)) reject.push('not clearly high-endurance'); + if (/\bgeneric|unbranded|fake|replica|lot of assorted|untested\b/.test(n)) reject.push('counterfeit/authenticity risk'); + if (totalPrice < 15 || totalPrice > 30) reject.push('outside microSD target price'); + questions.push('Verify sealed packaging and seller authenticity evidence before buying storage.'); + + return { score, confidence, modelNumber, features, accessories: [], photoEvidence: [], missingReplacementCost: 0, reject, reasons: [], questions }; +} + +function inferMounting(text: string, totalPrice: number) { + const n = normalize(text); + const terms = [ + ['weatherproof box', /\b(weatherproof|outdoor|junction)\s+(?:electrical\s+)?box\b/], + ['camera brackets', /\bcamera bracket|mounting bracket\b/], + ['u-bolts', /\bu[- ]?bolt\b/], + ['cable glands', /\bcable gland\b/], + ['stainless hardware', /\bstainless|hardware|screws|bolts\b/], + ['dish/antenna mount', /\bsatellite dish mount|antenna pole|mast\b/], + ['small enclosure', /\benclosure|project box\b/], + ] as const; + const accessories = terms.filter(([, pattern]) => pattern.test(n)).map(([label]) => label); + const reject: string[] = []; + const questions: string[] = []; + let score = accessories.length * 14; + let confidence = accessories.length ? 45 + accessories.length * 8 : 20; + if (/\blot|bundle|box of|assorted\b/.test(n)) score += 12; + if (totalPrice < 10 || totalPrice > 25) reject.push('outside mounting-material target spend'); + if (accessories.length === 0) reject.push('no requested mounting/weatherproof material identified'); + questions.push('Confirm sizes fit camera mount and outdoor cable routing.'); + return { score, confidence, modelNumber: undefined, features: accessories, accessories, photoEvidence: [], missingReplacementCost: 0, reject, reasons: [], questions }; +} + +function inferTechnic(text: string, totalPrice: number, slot: ArgusSlot) { + const n = normalize(text); + const features: string[] = []; + const accessories: string[] = []; + const reject: string[] = []; + const questions: string[] = []; + let score = 0; + let confidence = 25; + let modelNumber: string | undefined; + const hasHub = /\b88012|technic hub|control\+ hub|powered up hub|smart hub\b/.test(n); + const motorMatches = n.match(/\b(?:motor|motors|88013|88014|88008|linear motor|l motor|xl motor)\b/g) ?? []; + const hasTwoMotors = motorMatches.length >= 2 || /\b(?:two|2)\s+(?:compatible\s+)?motors\b/.test(n); + const usefulParts = /\b(turntable|worm gear|bevel gear|gear|axle|frame|liftarm|pin|connector|beam)\b/.test(n); + + if (hasHub) { + score += 35; + confidence += 30; + modelNumber = 'LEGO Technic Hub 88012 / Control+ hub'; + features.push('hub evidence'); + } + if (hasTwoMotors) { + score += 28; + confidence += 20; + features.push('two motor evidence'); + } else if (motorMatches.length > 0) { + score += 12; + features.push('motor evidence'); + } + if (usefulParts) { + score += slot === 'technic-parts' ? 35 : 15; + confidence += 15; + accessories.push('useful Technic structural/mechanical pieces'); + } + if (/\bincomplete|missing body|no cosmetic|partial\b/.test(n)) features.push('incomplete cosmetic body acceptable'); + if (/\bbroken electronics|hub not working|motor not working|corrosion|battery leak\b/.test(n)) reject.push('electronics may not work'); + + if (slot === 'technic-electronics' && !hasHub) reject.push('no confirmed Technic/Powered Up hub'); + if (slot === 'technic-electronics' && !hasTwoMotors) reject.push('two compatible motors not confirmed'); + if (slot === 'technic-parts' && !usefulParts) reject.push('no useful gears/axles/beams identified'); + if (slot === 'technic-electronics' && (totalPrice < 50 || totalPrice > 125)) reject.push('outside Technic electronics target range'); + if (slot === 'technic-parts' && totalPrice > 60) reject.push('bulk parts lot too expensive for bench filler'); + questions.push('Ask seller to confirm hub powers on and motors run.'); + return { score, confidence, modelNumber, features, accessories, photoEvidence: [], missingReplacementCost: 0, reject, reasons: [], questions }; +} + +function inferFuture(text: string) { + const n = normalize(text); + const matches = ['eap110', 'cpe210', 'solar', 'lifepo4', 'charge controller', 'rtl-sdr', 'water quality', 'thermal camera', 'raspberry pi', 'jetson'].filter((term) => + n.includes(term) + ); + return { + score: matches.length ? 60 : 0, + confidence: matches.length ? 60 : 15, + modelNumber: matches[0], + features: matches, + accessories: [], + photoEvidence: [], + missingReplacementCost: 0, + reject: matches.length ? [] : ['not a future phase item'], + reasons: ['watchlist only; do not buy yet'], + questions: [], + }; +} + +function queryMatches(raw: ArgusRawListing, target: ArgusTarget) { + const text = normalize(`${raw.query ?? ''} ${raw.text}`); + if (raw.query) return target.queries.some((query) => normalize(query) === normalize(raw.query ?? '')); + if (target.slot === 'future-phase') return /(eap110|cpe210|solar|lifepo4|charge controller|rtl-sdr|water quality|thermal camera|raspberry pi|jetson)/.test(text); + return target.queries.some((query) => + normalize(query) + .split(' ') + .filter((part) => part.length > 2) + .some((part) => text.includes(part)) + ); +} + +export async function loadArgusTargets(file = DEFAULT_TARGETS_FILE): Promise { + const parsed = JSON.parse(await readFile(file, 'utf8')) as ArgusTarget[]; + if (!Array.isArray(parsed) || parsed.length === 0) throw new Error(`${file} must contain ARGUS targets`); + return parsed; +} + +export function scoreArgusListing(raw: ArgusRawListing, target: ArgusTarget): ArgusScoredListing { + const text = raw.text; + const title = parseTitle(text); + const itemPrice = raw.itemPrice ?? parsePrice(text); + const shipping = raw.shipping ?? (raw.marketplace === 'ebay' ? parseEbayShipping(text) : 0); + const totalPrice = itemPrice + shipping; + const condition = conditionFromText(text, raw.condition); + const observedAt = raw.observedAt ?? new Date().toISOString(); + + let inferred = + target.slot === 'camera' + ? inferCamera(text, totalPrice) + : target.slot === 'microsd' + ? inferMicroSd(text, totalPrice) + : target.slot === 'mounting' + ? inferMounting(text, totalPrice) + : target.slot === 'technic-electronics' || target.slot === 'technic-parts' + ? inferTechnic(text, totalPrice, target.slot) + : inferFuture(text); + + const rejectionReasons = [...inferred.reject]; + const reasons = [...inferred.reasons]; + let score = inferred.score; + let confidence = inferred.confidence; + + if (!queryMatches(raw, target)) rejectionReasons.push('query/target mismatch'); + if (/\b(account locked|cannot reset|for parts|water damaged|not working|untested)\b/i.test(text)) rejectionReasons.push('condition/access risk'); + if (totalPrice === 0) rejectionReasons.push('missing price'); + if (totalPrice >= target.targetMin && totalPrice <= target.targetMax) { + score += 12; + reasons.push(`inside target price ${money(target.targetMin)}-${money(target.targetMax)}`); + } else { + reasons.push(`outside target price ${money(target.targetMin)}-${money(target.targetMax)}`); + } + if (raw.marketplace === 'facebook') score += 4; + if (condition === 'new' || condition === 'open-box') confidence += 5; + if (condition === 'bad') rejectionReasons.push('bad condition'); + + score = Math.round(clamp(score, 0, 100)); + confidence = Math.round(clamp(confidence, 0, 100)); + + let label: ArgusLabel = 'REVIEW'; + if (target.priority === 3 || target.slot === 'future-phase') label = 'FUTURE_PHASE'; + else if (rejectionReasons.length > 0) label = score >= 55 && confidence >= 55 ? 'REVIEW' : 'PASS'; + else if (score >= 78 && confidence >= 70 && totalPrice <= target.targetMax) label = 'BUY_CANDIDATE'; + else if (score >= 60 && totalPrice <= target.targetMax + 15) label = 'NEGOTIATE'; + + return { + marketplace: raw.marketplace, + targetId: target.id, + slot: target.slot, + priority: target.priority, + title, + url: raw.href, + seller: raw.seller, + location: parseLocation(raw), + itemPrice, + shipping, + totalPrice, + condition, + modelNumber: inferred.modelNumber, + includedAccessories: inferred.accessories, + photoEvidence: inferred.photoEvidence, + featureEvidence: inferred.features, + missingReplacementCost: inferred.missingReplacementCost, + argusFitScore: score, + confidence, + label, + reasons, + rejectionReasons, + sellerQuestions: Array.from(new Set(inferred.questions)), + observedAt, + }; +} + +function dedupeKey(candidate: ArgusScoredListing) { + const url = candidate.url?.split('?')[0]; + if (url) return `${candidate.marketplace}:${url}`; + return [candidate.marketplace, normalize(candidate.title), candidate.totalPrice, candidate.modelNumber ?? '', candidate.location ?? ''].join('|'); +} + +export function scoreArgusListings(rawListings: ArgusRawListing[], targets: ArgusTarget[]) { + const scored = rawListings.flatMap((raw) => targets.filter((target) => queryMatches(raw, target)).map((target) => scoreArgusListing(raw, target))); + const seen = new Set(); + return scored + .filter((candidate) => { + const key = dedupeKey(candidate); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => labelRank(b.label) - labelRank(a.label) || b.argusFitScore - a.argusFitScore || a.totalPrice - b.totalPrice); +} + +function labelRank(label: ArgusLabel) { + return { PASS: 0, FUTURE_PHASE: 1, REVIEW: 2, NEGOTIATE: 3, BUY_CANDIDATE: 4 }[label]; +} + +function bestFor(candidates: ArgusScoredListing[], slot: ArgusSlot) { + return candidates.find((candidate) => candidate.slot === slot && candidate.label !== 'PASS' && candidate.label !== 'FUTURE_PHASE'); +} + +export function buildArgusReport(rawListings: ArgusRawListing[], targets: ArgusTarget[]): ArgusReport { + const candidates = scoreArgusListings(rawListings, targets); + const bestImmediateCamera = bestFor(candidates, 'camera'); + const bestMicroSd = bestFor(candidates, 'microsd'); + const bestMountingMaterials = bestFor(candidates, 'mounting'); + const bestTechnicElectronics = bestFor(candidates, 'technic-electronics'); + const bestTechnicBulkParts = bestFor(candidates, 'technic-parts'); + const recommendedImmediateTotal = [bestImmediateCamera, bestMicroSd, bestMountingMaterials] + .filter((candidate): candidate is ArgusScoredListing => Boolean(candidate)) + .reduce((sum, candidate) => sum + candidate.totalPrice + candidate.missingReplacementCost, 0); + const missingRequiredItems = [ + ['camera', bestImmediateCamera], + ['high-endurance microSD card', bestMicroSd], + ['mounting/weatherproof materials', bestMountingMaterials], + ['Technic hub plus motors', bestTechnicElectronics], + ['Technic bulk gears/axles/beams', bestTechnicBulkParts], + ] + .filter(([, candidate]) => !candidate) + .map(([name]) => String(name)); + return { + observedAt: new Date().toISOString(), + scannedCount: rawListings.length, + candidates, + bestImmediateCamera, + bestMicroSd, + bestMountingMaterials, + bestTechnicElectronics, + bestTechnicBulkParts, + recommendedImmediateTotal, + missingRequiredItems, + nearMatches: candidates.filter((candidate) => candidate.label === 'PASS' || candidate.label === 'REVIEW').slice(0, 20), + }; +} + +function summarizeCandidate(candidate?: ArgusScoredListing) { + if (!candidate) return 'None found'; + return `[${candidate.label}] ${candidate.title} (${candidate.marketplace}) total ${money(candidate.totalPrice)} score ${candidate.argusFitScore} conf ${candidate.confidence} url ${ + candidate.url ?? 'n/a' + }`; +} + +export function formatArgusReport(report: ArgusReport) { + const lines = [ + `ARGUS procurement report (${report.observedAt})`, + `Scanned ${report.scannedCount} raw listing(s); scored ${report.candidates.length} candidate match(es).`, + '', + `1. Best immediate camera option: ${summarizeCandidate(report.bestImmediateCamera)}`, + `2. Best microSD option: ${summarizeCandidate(report.bestMicroSd)}`, + `3. Best mounting-material lot: ${summarizeCandidate(report.bestMountingMaterials)}`, + `4. Best Technic electronics bundle: ${summarizeCandidate(report.bestTechnicElectronics)}`, + `5. Best Technic bulk-parts lot: ${summarizeCandidate(report.bestTechnicBulkParts)}`, + `6. Recommended immediate setup total: ${money(report.recommendedImmediateTotal)}`, + `7. Missing required items: ${report.missingRequiredItems.length ? report.missingRequiredItems.join(', ') : 'none'}`, + '', + 'Seller questions:', + ]; + const questions = Array.from(new Set(report.candidates.flatMap((candidate) => candidate.sellerQuestions))).slice(0, 12); + lines.push(...(questions.length ? questions.map((question) => `- ${question}`) : ['- None'])); + lines.push('', 'Top candidates:'); + for (const candidate of report.candidates.filter((item) => item.label !== 'PASS').slice(0, 20)) { + lines.push( + `- [${candidate.label}] ${candidate.slot}: ${candidate.title} | total ${money(candidate.totalPrice)} | score ${candidate.argusFitScore} | reasons ${ + candidate.reasons.join('; ') || 'n/a' + } | warnings ${candidate.rejectionReasons.join('; ') || 'none'} | ${candidate.url ?? 'no url'}` + ); + } + lines.push('', 'Rejected/near matches:'); + for (const candidate of report.nearMatches.slice(0, 12)) { + lines.push(`- [${candidate.label}] ${candidate.title} | ${candidate.rejectionReasons.join('; ') || 'weak fit'} | ${candidate.url ?? 'no url'}`); + } + return lines.join('\n'); +} + +async function loadRawListings(file?: string) { + if (!file) throw new Error('Set PRICE_BOT_ARGUS_RAW_LISTINGS_FILE to a JSON array of ARGUS raw listings'); + const parsed = JSON.parse(await readFile(file, 'utf8')) as ArgusRawListing[]; + if (!Array.isArray(parsed)) throw new Error(`${file} must contain a JSON array`); + return parsed; +} + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +async function runSelfTest() { + const targets = await loadArgusTargets(); + const camera = targets.find((target) => target.id === 'camera-basic'); + const microsd = targets.find((target) => target.id === 'microsd'); + const technic = targets.find((target) => target.id === 'technic-electronics'); + if (!camera || !microsd || !technic) throw new Error('Missing expected ARGUS targets'); + + const tapo = scoreArgusListing({ marketplace: 'facebook', query: 'tapo c500', text: '$40 Tapo C500 outdoor WiFi camera with power adapter Tulsa, OK' }, camera); + assert(tapo.label === 'BUY_CANDIDATE' || tapo.label === 'NEGOTIATE', `Expected Tapo C500 useful, got ${tapo.label}`); + + const ring = scoreArgusListing({ marketplace: 'facebook', query: 'outdoor rtsp onvif camera', text: '$35 Ring outdoor battery camera Tulsa, OK' }, camera); + assert(ring.label === 'PASS', `Expected cloud/battery camera pass, got ${ring.label}`); + + const card = scoreArgusListing({ marketplace: 'ebay', query: '256gb high endurance microsd', text: 'Samsung Pro Endurance 256GB microSD card $24.99 Free shipping' }, microsd); + assert(card.label === 'BUY_CANDIDATE' || card.label === 'NEGOTIATE', `Expected endurance microSD useful, got ${card.label}`); + + const fakeCard = scoreArgusListing({ marketplace: 'facebook', query: '128gb high endurance microsd', text: '$10 generic 512gb microsd untested Tulsa, OK' }, microsd); + assert(fakeCard.label === 'PASS', `Expected fake/generic storage pass, got ${fakeCard.label}`); + + const lego = scoreArgusListing( + { marketplace: 'facebook', query: 'LEGO Technic hub motor lot', text: '$70 LEGO Technic 88012 hub with two motors and gears Broken Arrow, OK' }, + technic + ); + assert(lego.label === 'BUY_CANDIDATE' || lego.label === 'NEGOTIATE', `Expected Technic hub/motors useful, got ${lego.label}`); + + console.log('argus procurement scorer self-test passed'); +} + +async function main() { + if (SELF_TEST) { + await runSelfTest(); + return; + } + const targets = await loadArgusTargets(); + const rawListings = await loadRawListings(RAW_LISTINGS_FILE); + const report = buildArgusReport(rawListings, targets); + const formatted = formatArgusReport(report); + if (REPORT_FILE) await writeFile(REPORT_FILE, `${formatted}\n`, 'utf8'); + console.log(formatted); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/workers/discord-price-bot.ts b/src/workers/discord-price-bot.ts new file mode 100644 index 0000000..f6edf1d --- /dev/null +++ b/src/workers/discord-price-bot.ts @@ -0,0 +1,2513 @@ +import 'dotenv/config'; +import * as cheerio from 'cheerio'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectAnomaly } from '@/lib/analysis/detection'; +import type { Product } from '@/types'; +import type { Browser } from 'playwright'; + +type TargetKind = 'html' | 'feed' | 'api'; +type AdapterName = 'generic' | 'newegg' | 'rss' | 'bestbuy'; + +type SelectorConfig = { + container: string; + title: string; + price: string; + originalPrice?: string; + link: string; + image?: string; +}; + +type TargetConfig = { + retailer: string; + url: string; + category?: string; + lane?: ResaleLane; + kind?: TargetKind; + adapter?: AdapterName; + selectors?: SelectorConfig; +}; + +type SeenProduct = { + title: string; + price: number; + originalPrice?: number; + alertedPrice?: number; + lastSeenAt: string; +}; + +type State = Record; + +type ProductMemory = SeenProduct & { + history: number[]; +}; + +type BotStore = { + getMemory(url: string): ProductMemory | undefined; + wasFamilyAlerted(familyKey: string, currentPrice: number): boolean; + shouldSkipTarget(key: string): boolean; + recordTargetResult(key: string, target: TargetConfig, productCount: number, error?: unknown): Promise; + markAlerted(product: Product): Promise; + recordAlertSnapshot(candidate: Candidate): Promise; + recordProducts(products: Product[]): Promise; + close(): Promise; +}; + +type AlertTier = 'WATCH' | 'HOT' | 'ERROR?' | 'RESALE' | 'OPENBOX_RESALE' | 'TOOL_RESALE' | 'CAR_RESALE'; +type ResaleLane = 'pc' | 'openbox' | 'tool' | 'car'; +type ResalePath = 'local' | 'shipped'; +type BuyDecision = 'WATCH' | 'READY_TO_BUY' | 'NEEDS_REVIEW' | 'REJECTED'; +type AutomationMode = 'off' | 'prepare' | 'auto_cart' | 'auto_buy'; +type CompConfidence = 'none' | 'weak' | 'fair' | 'strong'; + +type RetailerReliabilityRule = { + retailer: string; + scoreAdjustment: number; + tags: string[]; + riskFlags?: string[]; +}; + +type ConditionProfile = { + allowed: string[]; + rejected: string[]; +}; + +type ResaleEstimate = { + lane: ResaleLane; + path: ResalePath; + resaleValue: number; + resaleProfit: number; + resaleMargin: number; + resaleConfidence: number; + condition: ConditionProfile; + localOnly?: boolean; + storePickupOnly?: boolean; + kitBreakoutValue?: number; + riskTier?: string; + riskFlags: string[]; +}; + +type EbayCompSummary = { + source: 'ebay-sold'; + query: string; + count: number; + median: number; + low: number; + high: number; + newestSoldAt?: string; +}; + +type Candidate = Product & { + anomalyType: string; + confidence: number; + discountPercentage: number; + tier: AlertTier; + familyKey: string; + qualityReasons: string[]; + resaleValue?: number; + resaleProfit?: number; + resaleMargin?: number; + resalePath?: ResalePath; + resaleLane?: ResaleLane; + conditionSummary?: string; + compSource?: string; + compQuery?: string; + compCount?: number; + compMedian?: number; + compRange?: string; + compConfidence?: CompConfidence; + compNotes?: string[]; + buyPrepMaxPrice?: number; + buyPrepVenue?: string; + buyPrepNet?: number; + buyPrepAction?: string; + buyPrepChecklist?: string[]; + buyScore?: number; + buyScoreReasons?: string[]; + buyDecision?: BuyDecision; + automationMode?: AutomationMode; + decisionReasons?: string[]; + riskFlags?: string[]; + localOnly?: boolean; + storePickupOnly?: boolean; + kitBreakoutValue?: number; +}; + +const DEFAULT_TARGETS: TargetConfig[] = [ + { + retailer: 'bestbuy', + url: 'https://www.bestbuy.com/site/electronics/computers-pcs/abcat0500000.c?id=abcat0500000', + category: 'electronics', + selectors: { + container: '.sku-item', + title: '.sku-header a', + price: '.priceView-customer-price span', + originalPrice: '.pricing-price__regular-price', + link: '.sku-header a', + image: '.product-image', + }, + }, + { + retailer: 'target', + url: 'https://www.target.com/c/electronics/-/N-5xtg6', + category: 'electronics', + selectors: { + container: '[data-test="product-card"]', + title: '[data-test="product-title"]', + price: '[data-test="current-price"] span', + originalPrice: '[data-test="comparison-price"]', + link: '[data-test="product-title"]', + image: '[data-test="product-image"] img', + }, + }, +]; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_TARGETS_FILE = + process.env.PRICE_BOT_TARGETS_FILE || path.resolve(__dirname, '../../config/pc-retailers.json'); +const DEFAULT_FEEDS_FILE = + process.env.PRICE_BOT_FEEDS_FILE || path.resolve(__dirname, '../../config/pc-feeds.json'); +const DEFAULT_APIS_FILE = + process.env.PRICE_BOT_APIS_FILE || path.resolve(__dirname, '../../config/pc-apis.json'); +const DEFAULT_OPENBOX_FILE = + process.env.PRICE_BOT_OPENBOX_TARGETS_FILE || path.resolve(__dirname, '../../config/resale-openbox-retailers.json'); +const DEFAULT_TOOL_FILE = + process.env.PRICE_BOT_TOOL_TARGETS_FILE || path.resolve(__dirname, '../../config/resale-tool-retailers.json'); +const DEFAULT_CAR_FILE = + process.env.PRICE_BOT_CAR_TARGETS_FILE || path.resolve(__dirname, '../../config/resale-car-retailers.json'); +const DEFAULT_RELIABILITY_FILE = + process.env.PRICE_BOT_RETAILER_RELIABILITY_FILE || path.resolve(__dirname, '../../config/retailer-reliability.json'); +const STATE_FILE = process.env.PRICE_BOT_STATE_FILE || path.resolve(__dirname, '../../.price-bot-state.json'); +const SQLITE_FILE = process.env.PRICE_BOT_SQLITE_FILE || path.resolve(__dirname, '../../.price-bot.sqlite'); +const USE_SQLITE = process.env.PRICE_BOT_USE_SQLITE !== 'false'; +const LOOP = process.env.PRICE_BOT_LOOP === 'true'; +const INTERVAL_MS = Number.parseInt(process.env.PRICE_BOT_INTERVAL_MS || '900000', 10); +const FETCH_TIMEOUT_MS = Number.parseInt(process.env.PRICE_BOT_FETCH_TIMEOUT_MS || '20000', 10); +const PLAYWRIGHT_FALLBACK = process.env.PRICE_BOT_PLAYWRIGHT_FALLBACK !== 'false'; +const PLAYWRIGHT_TIMEOUT_MS = Number.parseInt(process.env.PRICE_BOT_PLAYWRIGHT_TIMEOUT_MS || '30000', 10); +const TARGET_DELAY_MS = Number.parseInt(process.env.PRICE_BOT_TARGET_DELAY_MS || '1000', 10); +const MAX_PER_RUN = Number.parseInt(process.env.PRICE_BOT_MAX_ALERTS || '5', 10); +const MIN_CONFIDENCE = Number.parseInt(process.env.PRICE_BOT_MIN_CONFIDENCE || '70', 10); +const MIN_ALERT_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_MIN_ALERT_DISCOUNT || '30', 10); +const MIN_WATCH_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_MIN_WATCH_DISCOUNT || '35', 10); +const MIN_HOT_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_MIN_HOT_DISCOUNT || '50', 10); +const MIN_ERROR_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_MIN_ERROR_DISCOUNT || '80', 10); +const MIN_CROSS_STORE_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_CROSS_STORE_DROP_PERCENT || '35', 10); +const HISTORY_LIMIT = Number.parseInt(process.env.PRICE_BOT_HISTORY_LIMIT || '30', 10); +const TARGET_FAILURE_SKIP_COUNT = Number.parseInt(process.env.PRICE_BOT_TARGET_FAILURE_SKIP_COUNT || '3', 10); +const TARGET_SKIP_MINUTES = Number.parseInt(process.env.PRICE_BOT_TARGET_SKIP_MINUTES || '60', 10); +const DRY_RUN = process.env.PRICE_BOT_DRY_RUN === 'true'; +const TEST_SEND = process.env.PRICE_BOT_TEST_SEND === 'true'; +const SELF_TEST = process.env.PRICE_BOT_SELF_TEST === 'true'; +const FAMILY_DEDUP_DROP_PERCENT = Number.parseInt(process.env.PRICE_BOT_FAMILY_DEDUP_DROP_PERCENT || '10', 10); +const DEFAULT_MAX_SANE_PRICE = Number.parseInt(process.env.PRICE_BOT_MAX_SANE_PRICE || '8000', 10); +const RESALE_ALERTS = process.env.PRICE_BOT_RESALE_ALERTS !== 'false'; +const RESALE_MIN_PROFIT = Number.parseInt(process.env.PRICE_BOT_RESALE_MIN_PROFIT || '60', 10); +const RESALE_MIN_MARGIN_PERCENT = Number.parseInt(process.env.PRICE_BOT_RESALE_MIN_MARGIN_PERCENT || '18', 10); +const RESALE_FEE_PERCENT = Number.parseFloat(process.env.PRICE_BOT_RESALE_FEE_PERCENT || '13.25'); +const RESALE_LOCAL_FEE_PERCENT = Number.parseFloat(process.env.PRICE_BOT_RESALE_LOCAL_FEE_PERCENT || '0'); +const RESALE_TAX_PERCENT = Number.parseFloat(process.env.PRICE_BOT_RESALE_TAX_PERCENT || '8.25'); +const RESALE_SHIPPING_BUFFER = Number.parseInt(process.env.PRICE_BOT_RESALE_SHIPPING_BUFFER || '25', 10); +const OPENBOX_RESALE_ALERTS = process.env.PRICE_BOT_OPENBOX_RESALE_ALERTS !== 'false'; +const TOOL_RESALE_ALERTS = process.env.PRICE_BOT_TOOL_RESALE_ALERTS !== 'false'; +const CAR_RESALE_ALERTS = process.env.PRICE_BOT_CAR_RESALE_ALERTS !== 'false'; +const OPENBOX_RESALE_MIN_PROFIT = Number.parseInt(process.env.PRICE_BOT_OPENBOX_RESALE_MIN_PROFIT || '90', 10); +const OPENBOX_RESALE_MIN_MARGIN_PERCENT = Number.parseInt(process.env.PRICE_BOT_OPENBOX_RESALE_MIN_MARGIN_PERCENT || '28', 10); +const TOOL_RESALE_MIN_PROFIT = Number.parseInt(process.env.PRICE_BOT_TOOL_RESALE_MIN_PROFIT || '50', 10); +const TOOL_RESALE_MIN_MARGIN_PERCENT = Number.parseInt(process.env.PRICE_BOT_TOOL_RESALE_MIN_MARGIN_PERCENT || '22', 10); +const CAR_RESALE_MIN_PROFIT = Number.parseInt(process.env.PRICE_BOT_CAR_RESALE_MIN_PROFIT || '75', 10); +const CAR_RESALE_MIN_MARGIN_PERCENT = Number.parseInt(process.env.PRICE_BOT_CAR_RESALE_MIN_MARGIN_PERCENT || '30', 10); +const RESALE_MIN_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_RESALE_MIN_DISCOUNT || '35', 10); +const OPENBOX_RESALE_MIN_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_OPENBOX_RESALE_MIN_DISCOUNT || '35', 10); +const TOOL_RESALE_MIN_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_TOOL_RESALE_MIN_DISCOUNT || '35', 10); +const CAR_RESALE_MIN_DISCOUNT = Number.parseInt(process.env.PRICE_BOT_CAR_RESALE_MIN_DISCOUNT || '40', 10); +const BUY_SCORE_MIN = Number.parseInt(process.env.PRICE_BOT_BUY_SCORE_MIN || '70', 10); +const EBAY_COMPS_ENABLED = process.env.PRICE_BOT_EBAY_COMPS_ENABLED === 'true'; +const EBAY_COMPS_REQUIRE = process.env.PRICE_BOT_EBAY_COMPS_REQUIRE !== 'false'; +const EBAY_COMPS_MIN_COUNT = Number.parseInt(process.env.PRICE_BOT_EBAY_COMPS_MIN_COUNT || '3', 10); +const EBAY_COMPS_LIMIT = Number.parseInt(process.env.PRICE_BOT_EBAY_COMPS_LIMIT || '20', 10); +const EBAY_COMPS_MAX_AGE_DAYS = Number.parseInt(process.env.PRICE_BOT_EBAY_COMPS_MAX_AGE_DAYS || '90', 10); +const EBAY_MARKETPLACE_ID = process.env.EBAY_MARKETPLACE_ID || 'EBAY_US'; +const EBAY_OAUTH_SCOPE = + process.env.EBAY_OAUTH_SCOPE || 'https://api.ebay.com/oauth/api_scope/buy.marketplace.insights'; +const READY_TO_BUY_SCORE = Number.parseInt(process.env.PRICE_BOT_READY_TO_BUY_SCORE || '85', 10); +const NEEDS_REVIEW_SCORE = Number.parseInt(process.env.PRICE_BOT_NEEDS_REVIEW_SCORE || '70', 10); +const MAX_READY_TO_BUY_PRICE = Number.parseInt(process.env.PRICE_BOT_MAX_READY_TO_BUY_PRICE || '750', 10); +const MAX_AUTO_BUY_PRICE = Number.parseInt(process.env.PRICE_BOT_MAX_AUTO_BUY_PRICE || '250', 10); +const MAX_DAILY_AUTO_BUY_SPEND = Number.parseInt(process.env.PRICE_BOT_MAX_DAILY_AUTO_BUY_SPEND || '500', 10); +const BUY_AUTOMATION_MODE = (process.env.PRICE_BOT_BUY_AUTOMATION_MODE || 'off') as AutomationMode; +const BUY_AUTOMATION_ENABLED = process.env.PRICE_BOT_BUY_AUTOMATION_ENABLED === 'true'; +const DISCORD_READY_MENTION = process.env.PRICE_BOT_DISCORD_READY_MENTION || ''; +const DISCORD_REVIEW_MENTION = process.env.PRICE_BOT_DISCORD_REVIEW_MENTION || ''; +const DISCORD_HOT_MENTION = process.env.PRICE_BOT_DISCORD_HOT_MENTION || ''; +const ALLOWED_AUTO_BUY_RETAILERS = (process.env.PRICE_BOT_AUTO_BUY_RETAILERS || '') + .split(',') + .map((retailer) => retailer.trim().toLowerCase()) + .filter(Boolean); +const USER_AGENT = + process.env.PRICE_BOT_USER_AGENT || + 'Mozilla/5.0 (compatible; PricehawkDiscordBot/0.1; +https://github.com/cld-maindev/pricehawk)'; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const DEFAULT_RELIABILITY_RULES: RetailerReliabilityRule[] = [ + { retailer: 'newegg', scoreAdjustment: 4, tags: ['known pc retailer'] }, + { retailer: 'dell', scoreAdjustment: 4, tags: ['manufacturer direct'] }, + { retailer: 'bestbuy', scoreAdjustment: 5, tags: ['store pickup friendly'] }, + { retailer: 'bestbuy-api', scoreAdjustment: 5, tags: ['api sourced'] }, + { retailer: 'microcenter', scoreAdjustment: 5, tags: ['local pickup friendly'] }, + { retailer: 'amazon', scoreAdjustment: -2, tags: ['condition variance'], riskFlags: ['seller-risk'] }, + { retailer: 'walmart', scoreAdjustment: -3, tags: ['marketplace variance'], riskFlags: ['seller-risk'] }, +]; +let retailerReliabilityRules = DEFAULT_RELIABILITY_RULES; + +function normalizeRetailer(retailer: string) { + return retailer.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +async function loadRetailerReliabilityRules() { + try { + const loaded = JSON.parse(await readFile(DEFAULT_RELIABILITY_FILE, 'utf8')) as RetailerReliabilityRule[]; + retailerReliabilityRules = loaded + .filter((rule) => rule.retailer && Number.isFinite(rule.scoreAdjustment)) + .map((rule) => ({ + retailer: rule.retailer, + scoreAdjustment: Math.max(-20, Math.min(20, Math.round(rule.scoreAdjustment))), + tags: Array.isArray(rule.tags) ? rule.tags.slice(0, 6) : [], + riskFlags: Array.isArray(rule.riskFlags) ? rule.riskFlags.slice(0, 6) : undefined, + })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + retailerReliabilityRules = DEFAULT_RELIABILITY_RULES; + } +} + +function retailerReliability(product: Product): RetailerReliabilityRule { + const normalized = normalizeRetailer(product.retailer); + return ( + retailerReliabilityRules.find((rule) => normalized.includes(normalizeRetailer(rule.retailer))) ?? { + retailer: product.retailer, + scoreAdjustment: 0, + tags: ['unrated retailer'], + } + ); +} + +function validateTargets(targets: TargetConfig[], source: string): TargetConfig[] { + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error(`${source} must be a non-empty JSON array`); + } + + for (const target of targets) { + if (!target.retailer || !target.url) { + throw new Error(`Each ${source} item needs retailer and url`); + } + + if ((target.kind ?? 'html') === 'html' && target.selectors) { + const { container, title, price, link } = target.selectors; + if (!container || !title || !price || !link) { + throw new Error(`Selector targets in ${source} need container, title, price, and link`); + } + } + } + + return targets; +} + +async function parseTargets(): Promise { + const raw = process.env.PRICE_BOT_TARGETS; + if (raw) return validateTargets(JSON.parse(raw) as TargetConfig[], 'PRICE_BOT_TARGETS'); + + const targets: TargetConfig[] = []; + + try { + const fileTargets = JSON.parse(await readFile(DEFAULT_TARGETS_FILE, 'utf8')) as TargetConfig[]; + targets.push(...validateTargets(fileTargets, DEFAULT_TARGETS_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + try { + const fileFeeds = JSON.parse(await readFile(DEFAULT_FEEDS_FILE, 'utf8')) as TargetConfig[]; + targets.push(...validateTargets(fileFeeds, DEFAULT_FEEDS_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + try { + const fileApis = JSON.parse(await readFile(DEFAULT_APIS_FILE, 'utf8')) as TargetConfig[]; + targets.push(...validateTargets(fileApis, DEFAULT_APIS_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + for (const source of [DEFAULT_OPENBOX_FILE, DEFAULT_TOOL_FILE, DEFAULT_CAR_FILE]) { + try { + const fileTargets = JSON.parse(await readFile(source, 'utf8')) as TargetConfig[]; + targets.push(...validateTargets(fileTargets, source)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + + return targets.length > 0 ? targets : DEFAULT_TARGETS; +} + +async function readJsonState(): Promise { + try { + return JSON.parse(await readFile(STATE_FILE, 'utf8')) as State; + } catch { + return {}; + } +} + +async function writeJsonState(state: State): Promise { + await mkdir(path.dirname(STATE_FILE), { recursive: true }); + await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} + +class JsonStore implements BotStore { + private health = new Map(); + private familyAlerts = new Map(); + + constructor(private state: State) {} + + getMemory(url: string): ProductMemory | undefined { + const seen = this.state[url]; + return seen ? { ...seen, history: [seen.price].filter(Number.isFinite) } : undefined; + } + + async markAlerted(product: Product): Promise { + const existing = this.state[product.url]; + this.state[product.url] = { + title: product.title, + price: product.price, + originalPrice: product.originalPrice ?? existing?.originalPrice, + alertedPrice: product.price, + lastSeenAt: new Date().toISOString(), + }; + } + + wasFamilyAlerted(familyKey: string, currentPrice: number): boolean { + const alertedPrice = this.familyAlerts.get(familyKey); + if (!alertedPrice) return false; + return currentPrice >= alertedPrice * (1 - FAMILY_DEDUP_DROP_PERCENT / 100); + } + + async recordAlertSnapshot(candidate: Candidate): Promise { + this.familyAlerts.set(candidate.familyKey, candidate.price); + } + + shouldSkipTarget(key: string): boolean { + const skipUntil = this.health.get(key)?.skipUntil; + return Boolean(skipUntil && skipUntil > Date.now()); + } + + async recordTargetResult(key: string, _target: TargetConfig, productCount: number, error?: unknown): Promise { + const current = this.health.get(key) ?? { failures: 0 }; + const failed = Boolean(error) || productCount === 0; + const failures = failed ? current.failures + 1 : 0; + this.health.set(key, { + failures, + skipUntil: failures >= TARGET_FAILURE_SKIP_COUNT ? Date.now() + TARGET_SKIP_MINUTES * 60 * 1000 : undefined, + }); + } + + async recordProducts(products: Product[]): Promise { + for (const product of products) { + const existing = this.state[product.url]; + this.state[product.url] = { + title: product.title, + price: product.price, + originalPrice: product.originalPrice ?? existing?.originalPrice, + alertedPrice: existing?.alertedPrice, + lastSeenAt: new Date().toISOString(), + }; + } + } + + async close(): Promise { + await writeJsonState(this.state); + } +} + +class SqliteStore implements BotStore { + private db: any; + + constructor(db: any) { + this.db = db; + this.db.exec(` + CREATE TABLE IF NOT EXISTS products ( + url TEXT PRIMARY KEY, + title TEXT NOT NULL, + retailer TEXT NOT NULL, + category TEXT, + image_url TEXT, + last_price REAL NOT NULL, + original_price REAL, + alerted_price REAL, + last_seen_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + price REAL NOT NULL, + seen_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_price_history_url_seen ON price_history(url, seen_at DESC); + CREATE TABLE IF NOT EXISTS target_health ( + target_key TEXT PRIMARY KEY, + retailer TEXT NOT NULL, + url TEXT NOT NULL, + successes INTEGER NOT NULL DEFAULT 0, + failures INTEGER NOT NULL DEFAULT 0, + last_product_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + last_checked_at TEXT, + skip_until TEXT + ); + CREATE TABLE IF NOT EXISTS alert_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + family_key TEXT NOT NULL, + title TEXT NOT NULL, + retailer TEXT NOT NULL, + category TEXT, + price REAL NOT NULL, + original_price REAL, + discount_percentage REAL NOT NULL, + confidence INTEGER NOT NULL, + tier TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + quality_reasons TEXT NOT NULL, + image_url TEXT, + sent_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_alert_snapshots_family_sent ON alert_snapshots(family_key, sent_at DESC); + `); + this.ensureAlertSnapshotColumns(); + } + + private ensureAlertSnapshotColumns(): void { + const columns = new Set( + (this.db.prepare('PRAGMA table_info(alert_snapshots)').all() as Array<{ name: string }>).map((column) => column.name) + ); + const migrations: Array<[string, string]> = [ + ['decision_label', 'TEXT'], + ['estimated_resale_value', 'REAL'], + ['estimated_profit', 'REAL'], + ['estimated_margin', 'REAL'], + ['buy_score', 'INTEGER'], + ['decision_reason', 'TEXT'], + ['evaluated_at', 'TEXT'], + ['comp_confidence', 'TEXT'], + ['comp_notes', 'TEXT'], + ]; + + for (const [name, type] of migrations) { + if (!columns.has(name)) { + this.db.exec(`ALTER TABLE alert_snapshots ADD COLUMN ${name} ${type}`); + } + } + } + + getMemory(url: string): ProductMemory | undefined { + const product = this.db.prepare('SELECT * FROM products WHERE url = ?').get(url); + if (!product) return undefined; + + const rows = this.db + .prepare('SELECT price FROM price_history WHERE url = ? ORDER BY seen_at DESC LIMIT ?') + .all(url, HISTORY_LIMIT) as Array<{ price: number }>; + + return { + title: product.title, + price: Number(product.last_price), + originalPrice: product.original_price === null ? undefined : Number(product.original_price), + alertedPrice: product.alerted_price === null ? undefined : Number(product.alerted_price), + lastSeenAt: product.last_seen_at, + history: rows.map((row) => Number(row.price)).filter(Number.isFinite), + }; + } + + async markAlerted(product: Product): Promise { + this.db.prepare('UPDATE products SET alerted_price = ? WHERE url = ?').run(product.price, product.url); + } + + wasFamilyAlerted(familyKey: string, currentPrice: number): boolean { + const row = this.db + .prepare('SELECT price FROM alert_snapshots WHERE family_key = ? ORDER BY sent_at DESC LIMIT 1') + .get(familyKey); + if (!row) return false; + return currentPrice >= Number(row.price) * (1 - FAMILY_DEDUP_DROP_PERCENT / 100); + } + + async recordAlertSnapshot(candidate: Candidate): Promise { + const now = new Date().toISOString(); + const decisionLabel = alertDecisionLabel(candidate); + this.db + .prepare( + `INSERT INTO alert_snapshots ( + url, family_key, title, retailer, category, price, original_price, discount_percentage, + confidence, tier, anomaly_type, quality_reasons, image_url, sent_at, + decision_label, estimated_resale_value, estimated_profit, estimated_margin, + buy_score, decision_reason, evaluated_at, comp_confidence, comp_notes + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + candidate.url, + candidate.familyKey, + candidate.title, + candidate.retailer, + candidate.category ?? null, + candidate.price, + candidate.originalPrice ?? null, + candidate.discountPercentage, + candidate.confidence, + candidate.tier, + candidate.anomalyType, + candidate.qualityReasons.join('; '), + candidate.imageUrl ?? null, + now, + decisionLabel, + candidate.resaleValue ?? null, + candidate.resaleProfit ?? null, + candidate.resaleMargin ?? null, + candidate.buyScore ?? null, + candidate.decisionReasons?.join('; ') ?? null, + now, + candidate.compConfidence ?? null, + candidate.compNotes?.join('; ') ?? null + ); + } + + shouldSkipTarget(key: string): boolean { + const row = this.db.prepare('SELECT skip_until FROM target_health WHERE target_key = ?').get(key); + if (!row?.skip_until) return false; + return new Date(row.skip_until).getTime() > Date.now(); + } + + async recordTargetResult(key: string, target: TargetConfig, productCount: number, error?: unknown): Promise { + const now = new Date().toISOString(); + const failed = Boolean(error) || productCount === 0; + const existing = this.db.prepare('SELECT failures FROM target_health WHERE target_key = ?').get(key); + const failures = failed ? Number(existing?.failures ?? 0) + 1 : 0; + const skipUntil = + failures >= TARGET_FAILURE_SKIP_COUNT ? new Date(Date.now() + TARGET_SKIP_MINUTES * 60 * 1000).toISOString() : null; + const errorMessage = error instanceof Error ? error.message : error ? String(error) : null; + + this.db + .prepare(` + INSERT INTO target_health ( + target_key, retailer, url, successes, failures, last_product_count, last_error, last_checked_at, skip_until + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(target_key) DO UPDATE SET + retailer = excluded.retailer, + url = excluded.url, + successes = target_health.successes + excluded.successes, + failures = excluded.failures, + last_product_count = excluded.last_product_count, + last_error = excluded.last_error, + last_checked_at = excluded.last_checked_at, + skip_until = excluded.skip_until + `) + .run(key, target.retailer, target.url, failed ? 0 : 1, failures, productCount, errorMessage, now, skipUntil); + } + + async recordProducts(products: Product[]): Promise { + const now = new Date().toISOString(); + const upsert = this.db.prepare(` + INSERT INTO products (url, title, retailer, category, image_url, last_price, original_price, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET + title = excluded.title, + retailer = excluded.retailer, + category = excluded.category, + image_url = COALESCE(excluded.image_url, products.image_url), + last_price = excluded.last_price, + original_price = COALESCE(excluded.original_price, products.original_price), + last_seen_at = excluded.last_seen_at + `); + const insertHistory = this.db.prepare('INSERT INTO price_history (url, price, seen_at) VALUES (?, ?, ?)'); + + this.db.exec('BEGIN'); + try { + for (const product of products) { + upsert.run( + product.url, + product.title, + product.retailer, + product.category ?? null, + product.imageUrl ?? null, + product.price, + product.originalPrice ?? null, + now + ); + insertHistory.run(product.url, product.price, now); + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + async close(): Promise { + this.db.close(); + } +} + +async function createStore(): Promise { + if (USE_SQLITE) { + try { + await mkdir(path.dirname(SQLITE_FILE), { recursive: true }); + const sqlite = await import('node:sqlite'); + console.log(`state: sqlite ${SQLITE_FILE}`); + return new SqliteStore(new sqlite.DatabaseSync(SQLITE_FILE)); + } catch (error) { + console.error('sqlite unavailable, falling back to json state', error); + } + } + + console.log(`state: json ${STATE_FILE}`); + return new JsonStore(await readJsonState()); +} + +function cleanText(value: string) { + return value.replace(/\s+/g, ' ').trim(); +} + +function parsePrice(value: string | undefined): number | undefined { + if (!value) return undefined; + const normalized = value.replace(/,/g, ''); + const match = normalized.match(/\$\s*(\d+(?:\.\d{1,2})?)/) ?? normalized.match(/\b(\d+(?:\.\d{1,2})?)\b/); + if (!match) return undefined; + const parsed = Number.parseFloat(match[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function decodeEntities(value: string) { + return value + .replace(//gs, '$1') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function stripHtml(value: string) { + return cleanText(decodeEntities(value).replace(/<[^>]*>/g, ' ')); +} + +function extractTag(item: string, tag: string): string | undefined { + const match = item.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i')); + return match ? stripHtml(match[1]) : undefined; +} + +function looksPcRelated(text: string) { + return /\b(gpu|graphics|rtx|radeon|geforce|cpu|ryzen|intel|core i[3579]|motherboard|ssd|nvme|ddr[45]?|ram|monitor|oled|laptop|notebook|macbook|ipad|apple|desktop|gaming pc|prebuilt|psu|power supply|case|cooler|keyboard|mouse|pc)\b/i.test(text); +} + +function targetLane(target: TargetConfig): ResaleLane | undefined { + if (target.lane) return target.lane; + const text = `${target.category ?? ''} ${target.retailer} ${target.url}`.toLowerCase(); + if (/\b(openbox|open-box|open box|warehouse)\b/.test(text)) return 'openbox'; + if (/\b(tool|tools|milwaukee|dewalt|makita|ryobi|ridgid|bosch)\b/.test(text)) return 'tool'; + if (/\b(car|auto|automotive|detailing|obd|scanner|dashcam|dash cam|radar)\b/.test(text)) return 'car'; + return undefined; +} + +function looksTargetRelevant(text: string, target: TargetConfig) { + const lane = targetLane(target); + if (lane === 'tool') { + return /\b(tool|tools|milwaukee|dewalt|makita|ryobi|ridgid|bosch|battery|charger|drill|impact|saw|grinder|pressure washer|combo kit|scanner|diagnostic)\b/i.test( + text + ); + } + if (lane === 'car') { + return /\b(car|auto|automotive|detailing|polisher|dash cam|dashcam|radar detector|uniden|escort|maxcam|jump starter|jump pack|obd|scanner|tuner|accessport|exhaust|coilover|wheel|tire|rack|hitch|bed cover)\b/i.test( + text + ); + } + if (lane === 'openbox') { + return looksPcRelated(text) || /\b(open box|open-box|warehouse|clearance|monitor|laptop|desktop|gpu|ssd|cpu)\b/i.test(text); + } + return looksPcRelated(text); +} + +function absoluteUrl(url: string, base: string) { + try { + return new URL(url, base).toString(); + } catch { + return base; + } +} + +function targetKey(target: TargetConfig) { + return `${target.retailer}:${target.url}`; +} + +function parseFeedProducts(xml: string, target: TargetConfig): Product[] { + const itemMatches = [...xml.matchAll(//gi)].map((match) => match[0]); + const entryMatches = [...xml.matchAll(//gi)].map((match) => match[0]); + const items = [...itemMatches, ...entryMatches]; + + return dedupeProducts( + items + .map((item): Product | undefined => { + const title = extractTag(item, 'title'); + const link = + extractTag(item, 'link') || + item.match(/]*href=["']([^"']+)["'][^>]*>/i)?.[1] || + extractTag(item, 'guid'); + const description = extractTag(item, 'description') || extractTag(item, 'summary') || ''; + const text = `${title ?? ''} ${description}`; + const price = parsePrice(text); + + if (!title || !link || !price || !looksTargetRelevant(text, target)) return undefined; + + return { + title, + price, + retailer: target.retailer, + category: target.category ?? 'pc-deals-feed', + url: absoluteUrl(link, target.url), + stockStatus: 'unknown', + scrapedAt: new Date(), + description, + }; + }) + .filter((product): product is Product => Boolean(product)) + ); +} + +async function fetchBestBuyProducts(target: TargetConfig): Promise { + const apiKey = process.env.BESTBUY_API_KEY; + if (!apiKey) { + console.log(`${target.retailer}: skipped Best Buy API target, BESTBUY_API_KEY not set`); + return []; + } + + const url = new URL(target.url); + url.searchParams.set('apiKey', apiKey); + url.searchParams.set('format', 'json'); + + const response = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`${target.retailer} returned ${response.status}`); + } + + const data = (await response.json()) as { + products?: Array<{ + name?: string; + salePrice?: number; + regularPrice?: number; + url?: string; + image?: string; + categoryPath?: Array<{ name?: string }>; + onlineAvailability?: boolean; + }>; + }; + + return dedupeProducts( + (data.products ?? []) + .map((item): Product | undefined => { + if (!item.name || !item.salePrice || !item.url) return undefined; + return { + title: item.name, + price: item.salePrice, + originalPrice: item.regularPrice, + retailer: target.retailer, + category: target.category ?? item.categoryPath?.at(-1)?.name, + url: item.url, + imageUrl: item.image, + stockStatus: item.onlineAvailability === false ? 'out_of_stock' : 'unknown', + scrapedAt: new Date(), + }; + }) + .filter((product): product is Product => Boolean(product)) + ); +} + +function asArray(value: T | T[] | undefined): T[] { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} + +function getJsonString(value: unknown): string | undefined { + if (typeof value === 'string') return cleanText(value); + if (Array.isArray(value)) return getJsonString(value[0]); + if (value && typeof value === 'object' && 'url' in value) { + return getJsonString((value as { url?: unknown }).url); + } + return undefined; +} + +function getJsonPrice(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : undefined; + if (typeof value === 'string') return parsePrice(value); + return undefined; +} + +function flattenJsonLd(node: unknown): Record[] { + if (Array.isArray(node)) return node.flatMap(flattenJsonLd); + if (!node || typeof node !== 'object') return []; + + const record = node as Record; + return [record, ...flattenJsonLd(record['@graph'])]; +} + +function isProductJsonLd(record: Record) { + const types = asArray(record['@type']).map((type) => String(type).toLowerCase()); + return types.includes('product'); +} + +function extractJsonLdProducts($: cheerio.CheerioAPI, target: TargetConfig): Product[] { + const products: Product[] = []; + + $('script[type="application/ld+json"]').each((_, element) => { + try { + const json = JSON.parse($(element).contents().text()); + for (const record of flattenJsonLd(json).filter(isProductJsonLd)) { + const offers = asArray(record.offers as Record | Record[] | undefined); + const offer = offers.find(Boolean); + const title = getJsonString(record.name); + const price = getJsonPrice(offer?.price); + const productUrl = getJsonString(record.url) || getJsonString(offer?.url); + + if (!title || !price || !productUrl) continue; + + products.push({ + title, + price, + retailer: target.retailer, + category: target.category, + url: absoluteUrl(productUrl, target.url), + imageUrl: getJsonString(record.image), + stockStatus: 'unknown', + scrapedAt: new Date(), + }); + } + } catch { + // Ignore invalid JSON-LD blocks. + } + }); + + return products; +} + +function dedupeProducts(products: Product[]): Product[] { + const seen = new Map(); + + for (const product of products) { + const key = product.url || `${product.retailer}:${product.title}:${product.price}`; + if (!seen.has(key)) seen.set(key, product); + } + + return Array.from(seen.values()); +} + +function parseNeweggProducts($: cheerio.CheerioAPI, target: TargetConfig): Product[] { + const products: Product[] = []; + + $('.item-cell, .item-container').each((_, element) => { + const card = $(element); + const linkEl = card.find('.item-title').first(); + const title = cleanText(linkEl.text()); + const priceText = cleanText(card.find('.price-current').first().text()); + const price = parsePrice(priceText); + const href = linkEl.attr('href'); + + if (!title || !price || !href) return; + + products.push({ + title, + price, + originalPrice: parsePrice(card.find('.price-was').first().text()), + retailer: target.retailer, + category: target.category, + url: absoluteUrl(href, target.url), + imageUrl: card.find('.item-img img').first().attr('src') || card.find('.item-img img').first().attr('data-src'), + stockStatus: 'unknown', + scrapedAt: new Date(), + }); + }); + + return products; +} + +function parseProductsFromHtml(html: string, target: TargetConfig): Product[] { + const $ = cheerio.load(html); + const products: Product[] = []; + + if (target.adapter === 'newegg' || target.retailer === 'newegg') { + products.push(...parseNeweggProducts($, target)); + } + + if (target.selectors) { + const { selectors } = target; + + $(selectors.container).each((_, element) => { + const card = $(element); + const title = cleanText(card.find(selectors.title).first().text()); + const price = parsePrice(card.find(selectors.price).first().text()); + const href = card.find(selectors.link).first().attr('href'); + + if (!title || !price || !href) return; + + const originalPrice = selectors.originalPrice ? parsePrice(card.find(selectors.originalPrice).first().text()) : undefined; + const imageUrl = selectors.image + ? card.find(selectors.image).first().attr('src') || card.find(selectors.image).first().attr('data-src') + : undefined; + + products.push({ + title, + price, + originalPrice, + retailer: target.retailer, + category: target.category, + url: absoluteUrl(href, target.url), + imageUrl: imageUrl ? absoluteUrl(imageUrl, target.url) : undefined, + stockStatus: 'unknown', + scrapedAt: new Date(), + }); + }); + } + + return dedupeProducts([...products, ...extractJsonLdProducts($, target)]); +} + +async function fetchStaticHtml(target: TargetConfig): Promise { + const response = await fetch(target.url, { + headers: { + 'User-Agent': USER_AGENT, + Accept: 'text/html,application/xhtml+xml', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`${target.retailer} returned ${response.status}`); + } + + return response.text(); +} + +function briefError(error: unknown): string { + if (error instanceof Error) { + return error.message.split('\n')[0] || error.name; + } + + return String(error); +} + +async function createBrowser(): Promise { + const { chromium } = await import('playwright'); + return chromium.launch({ + headless: true, + args: ['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--no-sandbox'], + }); +} + +async function fetchRenderedHtml(target: TargetConfig): Promise { + const activeBrowser = await createBrowser(); + const page = await activeBrowser.newPage({ + userAgent: USER_AGENT, + viewport: { width: 1366, height: 900 }, + }); + + try { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + }); + + await page.goto(target.url, { + waitUntil: 'domcontentloaded', + timeout: PLAYWRIGHT_TIMEOUT_MS, + }); + + if (target.selectors?.container) { + await page.waitForSelector(target.selectors.container, { + timeout: Math.min(PLAYWRIGHT_TIMEOUT_MS, 10000), + }).catch(() => undefined); + } + + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined); + return page.content(); + } finally { + await page.close().catch(() => undefined); + await activeBrowser.close().catch(() => undefined); + } +} + +async function fetchProducts(target: TargetConfig): Promise { + if ((target.kind ?? 'html') === 'api' && target.adapter === 'bestbuy') { + return fetchBestBuyProducts(target); + } + + if ((target.kind ?? 'html') === 'feed') { + const xml = await fetchStaticHtml(target); + return parseFeedProducts(xml, target); + } + + try { + const html = await fetchStaticHtml(target); + const products = parseProductsFromHtml(html, target); + if (products.length > 0 || !PLAYWRIGHT_FALLBACK) return products; + + console.log(`${target.retailer}: static scrape found 0, trying playwright`); + } catch (error) { + if (!PLAYWRIGHT_FALLBACK) throw error; + console.warn(`${target.retailer}: static scrape failed, trying playwright (${briefError(error)})`); + } + + try { + const renderedHtml = await fetchRenderedHtml(target); + return parseProductsFromHtml(renderedHtml, target); + } catch (error) { + console.warn(`${target.retailer}: playwright scrape failed (${briefError(error)})`); + return []; + } +} + +function median(values: number[]): number | undefined { + if (values.length === 0) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +function productSignature(product: Product) { + const terms = product.title + .toLowerCase() + .replace(/\b(\d+)\s*(gb|tb|hz|inch|in)\b/g, '$1$2') + .replace(/[^a-z0-9]+/g, ' ') + .split(' ') + .filter((term) => term.length > 2 && !['with', 'for', 'and', 'the', 'gaming', 'desktop', 'laptop'].includes(term)); + + return terms.slice(0, 8).join(' '); +} + +function productFamilyKey(product: Product) { + return productSignature(product) + .split(' ') + .filter((term) => !['sale', 'deal', 'combo', 'bundle', 'free', 'shipping'].includes(term)) + .slice(0, 6) + .join(' '); +} + +let ebayTokenCache: { token: string; expiresAt: number } | undefined; +let ebayCompsUnavailable = false; + +function ebayCompsConfigured() { + return Boolean(process.env.EBAY_ACCESS_TOKEN || (process.env.EBAY_CLIENT_ID && process.env.EBAY_CLIENT_SECRET)); +} + +async function getEbayAccessToken(): Promise { + if (process.env.EBAY_ACCESS_TOKEN) return process.env.EBAY_ACCESS_TOKEN; + if (!process.env.EBAY_CLIENT_ID || !process.env.EBAY_CLIENT_SECRET) return undefined; + if (ebayTokenCache && ebayTokenCache.expiresAt > Date.now() + 60_000) return ebayTokenCache.token; + + const credentials = Buffer.from(`${process.env.EBAY_CLIENT_ID}:${process.env.EBAY_CLIENT_SECRET}`).toString('base64'); + const body = new URLSearchParams({ + grant_type: 'client_credentials', + scope: EBAY_OAUTH_SCOPE, + }); + const response = await fetch('https://api.ebay.com/identity/v1/oauth2/token', { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`eBay OAuth returned ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!data.access_token) throw new Error('eBay OAuth response did not include access_token'); + ebayTokenCache = { + token: data.access_token, + expiresAt: Date.now() + Math.max(300, data.expires_in ?? 7200) * 1000, + }; + return ebayTokenCache.token; +} + +function buildEbayCompQuery(product: Product) { + const text = productSignature(product) + .split(' ') + .filter( + (term) => + ![ + 'windows', + 'home', + 'with', + 'combo', + 'bundle', + 'black', + 'white', + 'sale', + 'deal', + 'free', + 'shipping', + ].includes(term) + ) + .slice(0, 7) + .join(' '); + return text.slice(0, 100); +} + +function jsonPrice(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value === 'string') { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + if (value && typeof value === 'object' && 'value' in value) { + return jsonPrice((value as { value?: unknown }).value); + } + return undefined; +} + +function itemSoldAt(item: Record): string | undefined { + for (const key of ['lastSoldDate', 'itemEndDate', 'dateSold', 'soldDate']) { + const value = item[key]; + if (typeof value === 'string') return value; + } + return undefined; +} + +async function fetchEbaySoldComps(product: Product): Promise { + if (!EBAY_COMPS_ENABLED || ebayCompsUnavailable || !ebayCompsConfigured()) return undefined; + + const token = await getEbayAccessToken(); + if (!token) return undefined; + + const query = buildEbayCompQuery(product); + if (!query) return undefined; + + const url = new URL('https://api.ebay.com/buy/marketplace_insights/v1_beta/item_sales/search'); + url.searchParams.set('q', query); + url.searchParams.set('limit', String(Math.max(1, Math.min(EBAY_COMPS_LIMIT, 50)))); + url.searchParams.set('filter', `priceCurrency:USD,soldDate:[${new Date(Date.now() - EBAY_COMPS_MAX_AGE_DAYS * 86400_000).toISOString()}..]`); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + 'X-EBAY-C-MARKETPLACE-ID': EBAY_MARKETPLACE_ID, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403 || response.status === 404) { + ebayCompsUnavailable = true; + console.error(`eBay sold comps unavailable (${response.status}); continuing without comp gating`); + return undefined; + } + if (!response.ok) { + throw new Error(`eBay sold comps returned ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + + const data = (await response.json()) as { itemSales?: Array>; itemSummaries?: Array> }; + const items = data.itemSales ?? data.itemSummaries ?? []; + const prices = items + .map((item) => jsonPrice(item.price ?? item.itemPrice ?? item.soldPrice)) + .filter((price): price is number => typeof price === 'number' && Number.isFinite(price) && price > 0) + .sort((a, b) => a - b); + if (prices.length < EBAY_COMPS_MIN_COUNT) return undefined; + + const soldDates = items.map(itemSoldAt).filter((value): value is string => Boolean(value)).sort(); + return { + source: 'ebay-sold', + query, + count: prices.length, + median: median(prices) ?? prices[Math.floor(prices.length / 2)], + low: prices[0], + high: prices[prices.length - 1], + newestSoldAt: soldDates.at(-1), + }; +} + +function qualityIssues(product: Product): string[] { + const text = `${product.title} ${product.description ?? ''}`.toLowerCase(); + const title = product.title.toLowerCase(); + const issues: string[] = []; + const condition = conditionProfile(product); + + issues.push(...condition.allowed.map((issue) => `condition:${issue}`)); + issues.push(...condition.rejected.map((issue) => `reject:${issue}`)); + if (/\b(extra|get|save)\s+\$?\d+(?:\.\d{2})?\s+off\b/.test(title) || /\b(coupon|promo code|discount code)\b/.test(title)) { + issues.push('promo-only'); + } + if (/\b(after rebate|mail[- ]in rebate|mir\b|rebate card|gift card)\b/.test(text)) { + issues.push('rebate'); + } + if (/\b(coupon|promo code|with code|clip coupon)\b/.test(text)) { + issues.push('coupon'); + } + if (/\b(marketplace|sold by|third[- ]party|seller refurbished)\b/.test(text)) { + issues.push('marketplace'); + } + if (/\b(out of stock|backorder|backordered|sold out|unavailable)\b/.test(text)) { + issues.push('stock'); + } + if (!Number.isFinite(product.price) || product.price < 5) { + issues.push('bad-price'); + } + if (product.price > maxSanePrice(product)) { + issues.push('price-too-high'); + } + if (product.originalPrice && product.originalPrice > maxSanePrice(product) * 1.5) { + issues.push('bad-original-price'); + } + + return issues; +} + +function conditionProfile(product: Product): ConditionProfile { + const text = `${product.category ?? ''} ${product.title} ${product.description ?? ''}`.toLowerCase(); + const allowed: string[] = []; + const rejected: string[] = []; + + if (/\b(new open box|open[- ]box new)\b/.test(text)) allowed.push('new-open-box'); + else if (/\b(open box|open-box)\b/.test(text)) allowed.push('open-box'); + if (/\b(certified open box|certified open-box|geek squad certified)\b/.test(text)) allowed.push('certified-open-box'); + if (/\b(excellent condition|condition: excellent|open box excellent|excellent)\b/.test(text)) allowed.push('excellent'); + if (/\b(clearance|closeout|liquidation)\b/.test(text)) allowed.push('clearance'); + + if (/\b(for parts|parts only|parts\/repair|not working)\b/.test(text)) rejected.push('for-parts'); + if (/\b(as[- ]is|untested|damaged|broken|defective|missing|incomplete)\b/.test(text)) rejected.push('bad-condition'); + if (/\b(seller refurbished|refurbished by seller)\b/.test(text)) rejected.push('seller-refurbished'); + if (/\b(refurbished|renewed|recertified|pre-owned|preowned|used)\b/.test(text)) rejected.push('used-refurbished'); + + return { + allowed: Array.from(new Set(allowed)), + rejected: Array.from(new Set(rejected)), + }; +} + +function conditionSummary(profile: ConditionProfile) { + const allowed = profile.allowed.length > 0 ? profile.allowed.join(', ') : 'new/sealed assumed'; + const rejected = profile.rejected.length > 0 ? `; rejected: ${profile.rejected.join(', ')}` : ''; + return `${allowed}${rejected}`; +} + +function isHardFiltered(issues: string[], product: Product, lane?: ResaleLane) { + const condition = conditionProfile(product); + if (condition.rejected.length > 0) return true; + if (condition.allowed.length > 0 && lane !== 'openbox') return true; + + return issues.some((issue) => + ['marketplace', 'stock', 'bad-price', 'price-too-high', 'bad-original-price', 'promo-only'].includes(issue) + ); +} + +function maxSanePrice(product: Product) { + const text = `${product.category ?? ''} ${product.title}`.toLowerCase(); + + if (/\b(ssd|ram|memory|psu|power supply|cooler|keyboard|mouse)\b/.test(text)) return 1500; + if (/\b(monitor|display)\b/.test(text)) return 3000; + if (/\b(gpu|graphics|rtx|radeon|geforce)\b/.test(text)) return 4000; + if (/\b(cpu|processor|ryzen|intel)\b/.test(text)) return 2500; + if (/\b(laptop|notebook|desktop|gaming pc|prebuilt|computer)\b/.test(text)) return 8000; + + return DEFAULT_MAX_SANE_PRICE; +} + +function categoryMinimumDiscount(product: Product) { + const text = `${product.category ?? ''} ${product.title}`.toLowerCase(); + + if (/\b(laptop|notebook|macbook|ipad|desktop|gaming pc|prebuilt|computer|monitor|oled|display)\b/.test(text)) { + return Math.max(MIN_ALERT_DISCOUNT, MIN_WATCH_DISCOUNT); + } + if (/\b(gpu|graphics|rtx|radeon|geforce|cpu|processor|ryzen|intel|motherboard|ssd|ram|memory|psu)\b/.test(text)) { + return MIN_ALERT_DISCOUNT; + } + + return Math.max(MIN_ALERT_DISCOUNT, MIN_WATCH_DISCOUNT); +} + +function classifyResaleLane(product: Product): ResaleLane | undefined { + const text = `${product.category ?? ''} ${product.title}`.toLowerCase(); + const condition = conditionProfile(product); + + if ( + /\b(car|auto|automotive|detailing|polisher|dash cam|dashcam|radar detector|uniden|escort|maxcam|jump starter|jump pack|obd|scanner|tuner|accessport|exhaust|coilover|wheels|tires|rack|hitch|bed cover)\b/.test( + text + ) + ) { + return 'car'; + } + if ( + /\b(tool|tools|milwaukee|dewalt|makita|ryobi|ridgid|bosch|battery|charger|drill|impact|saw|grinder|pressure washer|scanner|diagnostic)\b/.test( + text + ) + ) { + return 'tool'; + } + if (condition.allowed.length > 0) return 'openbox'; + if ( + /\b(gpu|graphics|rtx|radeon|geforce|cpu|processor|ryzen|intel|ssd|nvme|ram|memory|monitor|oled|display|laptop|notebook|macbook|ipad|apple|desktop|gaming pc|prebuilt|console|playstation|xbox|switch|router|mesh|mini pc|nuc)\b/.test( + text + ) + ) { + return 'pc'; + } + + return undefined; +} + +function laneEnabled(lane: ResaleLane) { + if (!RESALE_ALERTS) return false; + if (lane === 'openbox') return OPENBOX_RESALE_ALERTS; + if (lane === 'tool') return TOOL_RESALE_ALERTS; + if (lane === 'car') return CAR_RESALE_ALERTS; + return true; +} + +function laneFloors(lane: ResaleLane) { + if (lane === 'openbox') { + return { + minProfit: OPENBOX_RESALE_MIN_PROFIT, + minMargin: OPENBOX_RESALE_MIN_MARGIN_PERCENT, + minDiscount: OPENBOX_RESALE_MIN_DISCOUNT, + }; + } + if (lane === 'tool') { + return { + minProfit: TOOL_RESALE_MIN_PROFIT, + minMargin: TOOL_RESALE_MIN_MARGIN_PERCENT, + minDiscount: TOOL_RESALE_MIN_DISCOUNT, + }; + } + if (lane === 'car') { + return { + minProfit: CAR_RESALE_MIN_PROFIT, + minMargin: CAR_RESALE_MIN_MARGIN_PERCENT, + minDiscount: CAR_RESALE_MIN_DISCOUNT, + }; + } + return { minProfit: RESALE_MIN_PROFIT, minMargin: RESALE_MIN_MARGIN_PERCENT, minDiscount: RESALE_MIN_DISCOUNT }; +} + +function isLocalOnlyProduct(product: Product, lane: ResaleLane) { + const text = `${product.category ?? ''} ${product.title}`.toLowerCase(); + if (lane === 'car' && /\b(bed cover|rack|hitch|wheel|wheels|tire|tires|exhaust|coilover)\b/.test(text)) return true; + return /\b(oled monitor|monitor|display|desktop|gaming pc|prebuilt|chair|rack|hitch|bed cover)\b/.test(text); +} + +function isStorePickupOnly(product: Product) { + const text = `${product.retailer} ${product.category ?? ''} ${product.title} ${product.description ?? ''}`.toLowerCase(); + return /\b(store pickup|pickup only|in store only|in-store only|open box|open-box|microcenter|micro center|bestbuy|best buy)\b/.test( + text + ); +} + +function toolKitBreakoutValue(product: Product, anchorValue: number) { + const text = `${product.category ?? ''} ${product.title}`.toLowerCase(); + if (!/\b(combo|kit|bundle)\b/.test(text)) return undefined; + if (!/\b(milwaukee|dewalt|makita|ryobi|ridgid|bosch|tool|drill|impact|battery|charger)\b/.test(text)) return undefined; + + let value = anchorValue; + const batteryMatches = text.match(/\b(\d+)\s*(?:x|pack)?\s*(?:ah)?\s*batter(?:y|ies)\b/); + const batteryCount = batteryMatches ? Number.parseInt(batteryMatches[1], 10) : /\bbatter(?:y|ies)\b/.test(text) ? 1 : 0; + if (batteryCount > 0) value += batteryCount * 45; + if (/\bcharger\b/.test(text)) value += 25; + if (/\b(drill|impact|driver|saw|grinder|oscillating|multi[- ]tool)\b/.test(text)) value += 45; + if (/\bcase|bag\b/.test(text)) value += 15; + + return value > anchorValue ? value : undefined; +} + +function riskFlagsFor(product: Product, lane: ResaleLane, condition: ConditionProfile, path: ResalePath) { + const text = `${product.category ?? ''} ${product.title} ${product.description ?? ''}`.toLowerCase(); + const flags: string[] = []; + + if (condition.allowed.length > 0) flags.push('condition-risk'); + if (path === 'shipped') flags.push('shipping-risk'); + if (isLocalOnlyProduct(product, lane)) flags.push('local-only'); + if (isStorePickupOnly(product)) flags.push('store-pickup-only'); + if (lane === 'car') flags.push('fitment/liquidity-risk'); + if (/\b(rebate|coupon|promo code|gift card)\b/.test(text)) flags.push('promo-risk'); + if (/\b(marketplace|sold by|third-party|third party)\b/.test(text)) flags.push('seller-risk'); + flags.push(...(retailerReliability(product).riskFlags ?? [])); + + return Array.from(new Set(flags)); +} + +function tierForLane(lane: ResaleLane): AlertTier { + if (lane === 'openbox') return 'OPENBOX_RESALE'; + if (lane === 'tool') return 'TOOL_RESALE'; + if (lane === 'car') return 'CAR_RESALE'; + return 'RESALE'; +} + +function scoreResalePath(product: Product, resaleValue: number, lane: ResaleLane, path: ResalePath) { + const feePercent = path === 'local' ? RESALE_LOCAL_FEE_PERCENT : RESALE_FEE_PERCENT; + const shippingBuffer = path === 'local' ? 0 : RESALE_SHIPPING_BUFFER; + const buyCost = product.price * (1 + RESALE_TAX_PERCENT / 100); + const feeCost = resaleValue * (feePercent / 100); + const profit = resaleValue - feeCost - shippingBuffer - buyCost; + const margin = product.price > 0 ? (profit / product.price) * 100 : 0; + const floors = laneFloors(lane); + + if (profit < floors.minProfit || margin < floors.minMargin) return undefined; + + return { + path, + resaleProfit: profit, + resaleMargin: margin, + }; +} + +function estimateFromResaleValue( + product: Product, + lane: ResaleLane, + resaleValue: number, + condition: ConditionProfile +): ResaleEstimate | undefined { + const floors = laneFloors(lane); + const discountPercentage = resaleValue > product.price ? ((resaleValue - product.price) / resaleValue) * 100 : 0; + if (discountPercentage < floors.minDiscount) return undefined; + + const kitBreakoutValue = lane === 'tool' ? toolKitBreakoutValue(product, resaleValue) : undefined; + const effectiveResaleValue = kitBreakoutValue && kitBreakoutValue > resaleValue ? kitBreakoutValue : resaleValue; + const localOnly = isLocalOnlyProduct(product, lane); + const storePickupOnly = isStorePickupOnly(product); + const paths = [ + scoreResalePath(product, effectiveResaleValue, lane, 'local'), + localOnly || storePickupOnly ? undefined : scoreResalePath(product, effectiveResaleValue, lane, 'shipped'), + ].filter((path): path is { path: ResalePath; resaleProfit: number; resaleMargin: number } => Boolean(path)); + paths.sort((a, b) => b.resaleProfit - a.resaleProfit); + const best = paths[0]; + if (!best) return undefined; + + const confidence = Math.min(95, Math.round(60 + best.resaleMargin / 2 + Math.min(best.resaleProfit, 200) / 20)); + + return { + lane, + path: best.path, + resaleValue: effectiveResaleValue, + resaleProfit: best.resaleProfit, + resaleMargin: best.resaleMargin, + resaleConfidence: confidence, + condition, + localOnly, + storePickupOnly, + kitBreakoutValue, + riskTier: 'normal', + riskFlags: riskFlagsFor(product, lane, condition, best.path), + }; +} + +function estimateResale(product: Product, originalPrice?: number, peerMedian?: number): ResaleEstimate | undefined { + const lane = classifyResaleLane(product); + if (!lane || !laneEnabled(lane)) return undefined; + + const condition = conditionProfile(product); + if (condition.rejected.length > 0) return undefined; + if (lane === 'openbox' && condition.allowed.length === 0) return undefined; + if (lane !== 'openbox' && condition.allowed.length > 0) return undefined; + + const anchors: number[] = []; + if (typeof originalPrice === 'number' && Number.isFinite(originalPrice) && originalPrice > product.price) { + anchors.push(originalPrice); + } + if (typeof peerMedian === 'number' && Number.isFinite(peerMedian) && peerMedian > product.price) { + anchors.push(peerMedian); + } + anchors.sort((a, b) => a - b); + if (anchors.length === 0) return undefined; + + return estimateFromResaleValue(product, lane, anchors[0], condition); +} + +async function refineResaleWithEbayComps(product: Product, estimate: ResaleEstimate): Promise<{ + estimate: ResaleEstimate; + comps?: EbayCompSummary; +} | undefined> { + if (!EBAY_COMPS_ENABLED || !ebayCompsConfigured() || ebayCompsUnavailable) return { estimate }; + + const comps = await fetchEbaySoldComps(product).catch((error) => { + console.error('eBay sold comps failed; continuing without comp gating', error); + return undefined; + }); + if (!comps) return EBAY_COMPS_REQUIRE && !ebayCompsUnavailable ? undefined : { estimate }; + + const refined = estimateFromResaleValue(product, estimate.lane, comps.median, estimate.condition); + if (!refined) return undefined; + return { estimate: refined, comps }; +} + +function buyScore(product: Product, estimate: ResaleEstimate, comps?: EbayCompSummary) { + const floors = laneFloors(estimate.lane); + const discountPercentage = ((estimate.resaleValue - product.price) / estimate.resaleValue) * 100; + const reasons: string[] = []; + let score = 35; + const reliability = retailerReliability(product); + + const profitRatio = estimate.resaleProfit / Math.max(floors.minProfit, 1); + const marginRatio = estimate.resaleMargin / Math.max(floors.minMargin, 1); + const discountRatio = discountPercentage / Math.max(floors.minDiscount, 1); + score += Math.min(25, Math.round(profitRatio * 12)); + score += Math.min(20, Math.round(marginRatio * 10)); + score += Math.min(10, Math.round(discountRatio * 6)); + + if (comps) { + score += Math.min(15, 6 + comps.count); + reasons.push(`${comps.count} eBay sold comps`); + } else { + score -= EBAY_COMPS_ENABLED && ebayCompsConfigured() ? 8 : 4; + reasons.push('estimate-only'); + } + + if (estimate.path === 'local') { + score += 5; + reasons.push('local-first'); + } else { + reasons.push('shipped fallback'); + } + if (estimate.localOnly) { + score += estimate.path === 'local' ? 3 : -8; + reasons.push('local-only'); + } + if (estimate.storePickupOnly) { + score += 2; + reasons.push('store-pickup-only'); + } + if (estimate.kitBreakoutValue) { + score += 8; + reasons.push(`kit breakout est $${estimate.kitBreakoutValue.toFixed(0)}`); + } + if (estimate.condition.allowed.length > 0) { + score -= estimate.lane === 'openbox' ? 4 : 10; + reasons.push(`condition:${estimate.condition.allowed.join('/')}`); + } + if (estimate.lane === 'car') { + score -= 4; + reasons.push('car liquidity risk'); + } + if (estimate.lane === 'tool') reasons.push('tool liquidity'); + if (estimate.resaleProfit >= floors.minProfit * 2) reasons.push('strong profit'); + if (estimate.resaleMargin >= floors.minMargin * 1.5) reasons.push('strong margin'); + score += reliability.scoreAdjustment; + reasons.push(...reliability.tags.map((tag) => `retailer:${tag}`)); + if (reliability.scoreAdjustment !== 0) reasons.push(`retailer score ${reliability.scoreAdjustment > 0 ? '+' : ''}${reliability.scoreAdjustment}`); + + return { + score: Math.max(0, Math.min(100, score)), + reasons, + }; +} + +function compConfidenceFor(product: Product, estimate: ResaleEstimate, comps?: EbayCompSummary): { + confidence: CompConfidence; + notes: string[]; +} { + const notes: string[] = []; + if (!comps) { + notes.push(EBAY_COMPS_ENABLED && ebayCompsConfigured() ? 'sold comps unavailable' : 'estimate-only'); + return { confidence: 'none', notes }; + } + + const spread = comps.median > 0 ? (comps.high - comps.low) / comps.median : 1; + const priceToMedian = comps.median > 0 ? product.price / comps.median : 1; + const profitToPrice = estimate.resaleProfit / Math.max(product.price, 1); + let points = 0; + + if (comps.count >= 10) points += 3; + else if (comps.count >= 6) points += 2; + else if (comps.count >= EBAY_COMPS_MIN_COUNT) points += 1; + + if (spread <= 0.35) points += 2; + else if (spread <= 0.6) points += 1; + else points -= 1; + + if (priceToMedian <= 0.65) points += 2; + else if (priceToMedian <= 0.8) points += 1; + + if (profitToPrice >= 0.35) points += 1; + if (estimate.path === 'local') points += 1; + if (estimate.riskFlags.some((flag) => ['seller-risk', 'promo-risk', 'fitment/liquidity-risk'].includes(flag))) points -= 1; + + notes.push(`${comps.count} sold comps`); + notes.push(`spread ${Math.round(spread * 100)}%`); + notes.push(`buy ${Math.round(priceToMedian * 100)}% of median`); + + if (points >= 7) return { confidence: 'strong', notes }; + if (points >= 4) return { confidence: 'fair', notes }; + return { confidence: 'weak', notes }; +} + +function applyCompConfidenceToScore(score: { score: number; reasons: string[] }, compConfidence: CompConfidence) { + const adjustments: Record = { + none: -6, + weak: -8, + fair: 0, + strong: 6, + }; + const nextScore = Math.max(0, Math.min(100, score.score + adjustments[compConfidence])); + return { + score: nextScore, + reasons: [...score.reasons, `comp confidence:${compConfidence}`], + }; +} + +function buyDecision(product: Product, estimate: ResaleEstimate, score: number): { + decision: BuyDecision; + automationMode: AutomationMode; + reasons: string[]; +} { + const reasons: string[] = []; + const retailer = product.retailer.toLowerCase(); + const trustedRetailer = ALLOWED_AUTO_BUY_RETAILERS.length === 0 || ALLOWED_AUTO_BUY_RETAILERS.includes(retailer); + const hardRisk = estimate.riskFlags.some((flag) => ['seller-risk', 'promo-risk'].includes(flag)); + + if (hardRisk) reasons.push('hard risk flag'); + if (!trustedRetailer) reasons.push('retailer not auto-buy allowlisted'); + if (product.price > MAX_READY_TO_BUY_PRICE) reasons.push(`above ready cap $${MAX_READY_TO_BUY_PRICE}`); + if (score < NEEDS_REVIEW_SCORE) return { decision: 'WATCH', automationMode: 'off', reasons: ['below review score', ...reasons] }; + if (hardRisk) return { decision: 'NEEDS_REVIEW', automationMode: 'off', reasons }; + if (score < READY_TO_BUY_SCORE || product.price > MAX_READY_TO_BUY_PRICE) { + return { decision: 'NEEDS_REVIEW', automationMode: 'off', reasons }; + } + + if ( + BUY_AUTOMATION_ENABLED && + BUY_AUTOMATION_MODE === 'auto_buy' && + trustedRetailer && + product.price <= MAX_AUTO_BUY_PRICE && + product.price <= MAX_DAILY_AUTO_BUY_SPEND && + estimate.condition.rejected.length === 0 && + estimate.riskFlags.every((flag) => !['seller-risk', 'promo-risk'].includes(flag)) + ) { + return { decision: 'READY_TO_BUY', automationMode: 'auto_buy', reasons: ['auto-buy eligible', ...reasons] }; + } + + if (BUY_AUTOMATION_ENABLED && BUY_AUTOMATION_MODE === 'auto_cart' && trustedRetailer) { + return { decision: 'READY_TO_BUY', automationMode: 'auto_cart', reasons: ['auto-cart eligible', ...reasons] }; + } + + if (BUY_AUTOMATION_ENABLED && BUY_AUTOMATION_MODE === 'prepare' && trustedRetailer) { + return { decision: 'READY_TO_BUY', automationMode: 'prepare', reasons: ['checkout prep eligible', ...reasons] }; + } + + return { decision: 'READY_TO_BUY', automationMode: 'off', reasons: ['manual approval required', ...reasons] }; +} + +function buyPrepCard(product: Product, estimate: ResaleEstimate, decision?: BuyDecision) { + const floors = laneFloors(estimate.lane); + const feePercent = estimate.path === 'local' ? RESALE_LOCAL_FEE_PERCENT : RESALE_FEE_PERCENT; + const shippingBuffer = estimate.path === 'local' ? 0 : RESALE_SHIPPING_BUFFER; + const maxBuy = (estimate.resaleValue * (1 - feePercent / 100) - shippingBuffer - floors.minProfit) / (1 + RESALE_TAX_PERCENT / 100); + const venue = + estimate.path === 'local' + ? estimate.lane === 'car' + ? 'Facebook Marketplace / local forums' + : 'Facebook Marketplace / local pickup' + : estimate.lane === 'tool' + ? 'eBay / local pickup fallback' + : 'eBay / Mercari'; + const checklist = [ + `do not pay over $${Math.max(0, maxBuy).toFixed(0)}`, + estimate.path === 'local' ? 'confirm local demand before buying' : 'confirm ship cost before buying', + estimate.condition.allowed.length > 0 ? 'verify condition/photos/return policy' : 'confirm sealed/new condition', + ]; + if (product.price > maxBuy) checklist.unshift('current price above prep max'); + if (estimate.riskFlags.length > 0) checklist.push(`review ${estimate.riskFlags.slice(0, 3).join(', ')}`); + + return { + maxPrice: Math.max(0, maxBuy), + venue, + net: estimate.resaleValue * (1 - feePercent / 100) - shippingBuffer, + action: decision === 'READY_TO_BUY' ? 'buy candidate after manual checks' : 'review before buy', + checklist, + }; +} + +function alertTier(discountPercentage: number, confidence: number, anomalyType: string, issues: string[]): AlertTier { + const softPenalty = issues.some((issue) => ['rebate', 'coupon'].includes(issue)) ? 10 : 0; + const adjustedConfidence = confidence - softPenalty; + + if ( + (anomalyType === 'decimal_error' && adjustedConfidence >= 90) || + (discountPercentage >= MIN_ERROR_DISCOUNT && adjustedConfidence >= 90) + ) { + return 'ERROR?'; + } + if (discountPercentage >= MIN_HOT_DISCOUNT && adjustedConfidence >= 80) { + return 'HOT'; + } + return 'WATCH'; +} + +function isResaleTier(tier: AlertTier) { + return ['RESALE', 'OPENBOX_RESALE', 'TOOL_RESALE', 'CAR_RESALE'].includes(tier); +} + +function alertDecisionLabel(candidate: Candidate): BuyDecision | null { + if (candidate.buyDecision) return candidate.buyDecision; + if (candidate.tier === 'WATCH') return 'WATCH'; + return null; +} + +function discordRoute(product: Candidate): { webhookUrl: string; mention: string; routeName: string } { + const defaultWebhook = process.env.DISCORD_WEBHOOK_URL; + if (!defaultWebhook) throw new Error('DISCORD_WEBHOOK_URL is required'); + + return { webhookUrl: defaultWebhook, mention: '', routeName: 'nova-price-error' }; +} + +function buildComparablePriceMap(products: Product[]) { + const grouped = new Map(); + + for (const product of products) { + const issues = qualityIssues(product); + if ( + issues.some((issue) => + ['marketplace', 'stock', 'bad-price', 'price-too-high', 'bad-original-price'].includes(issue) || issue.startsWith('reject:') + ) + ) { + continue; + } + + const signature = productSignature(product); + if (!signature) continue; + grouped.set(signature, [...(grouped.get(signature) ?? []), product.price]); + } + + return grouped; +} + +function comparableMedian(product: Product, grouped: Map): number | undefined { + const prices = grouped.get(productSignature(product))?.filter((price) => price !== product.price) ?? []; + return median(prices); +} + +async function pickCandidates(products: Product[], store: BotStore): Promise { + const candidates: Candidate[] = []; + const comparablePrices = buildComparablePriceMap(products); + + for (const product of products) { + const issues = qualityIssues(product); + const previous = store.getMemory(product.url); + const history = previous?.history ?? []; + const peerMedian = comparableMedian(product, comparablePrices); + const originalPrice = product.originalPrice ?? previous?.price ?? peerMedian; + const initialResale = estimateResale(product, originalPrice, peerMedian); + const resaleLane = initialResale?.lane; + if (isHardFiltered(issues, product, resaleLane)) continue; + + const result = detectAnomaly(product.price, originalPrice ?? null, history, { + category: product.category, + timestamp: new Date(), + }); + + const alreadyAlertedAtThisPrice = previous?.alertedPrice === product.price; + const peerDrop = peerMedian && peerMedian > product.price ? ((peerMedian - product.price) / peerMedian) * 100 : 0; + const isCrossStoreOutlier = peerDrop >= MIN_CROSS_STORE_DISCOUNT; + const isAnomaly = result.is_anomaly || isCrossStoreOutlier; + const anomalyType = result.anomaly_type ?? (isCrossStoreOutlier ? 'historical' : undefined); + const confidence = Math.max(result.confidence, isCrossStoreOutlier ? Math.min(95, 55 + peerDrop) : 0); + const discountPercentage = Math.round(Math.max(result.discount_percentage, peerDrop)); + const refinedResale = initialResale ? await refineResaleWithEbayComps(product, initialResale) : undefined; + const resale = refinedResale?.estimate; + const comps = refinedResale?.comps; + const compConfidence = resale ? compConfidenceFor(product, resale, comps) : undefined; + const score = resale ? applyCompConfidenceToScore(buyScore(product, resale, comps), compConfidence?.confidence ?? 'none') : undefined; + const decision = resale && score ? buyDecision(product, resale, score.score) : undefined; + const prep = resale ? buyPrepCard(product, resale, decision?.decision) : undefined; + const isResaleCandidate = Boolean(resale); + + if ((!isAnomaly || !anomalyType) && !isResaleCandidate) continue; + if (alreadyAlertedAtThisPrice) continue; + if (!isResaleCandidate && confidence < MIN_CONFIDENCE) continue; + if (score && score.score < BUY_SCORE_MIN) continue; + + const familyKey = productFamilyKey(product); + if (familyKey && store.wasFamilyAlerted(familyKey, product.price)) continue; + + const meetsDecimalErrorOverride = anomalyType === 'decimal_error' && confidence >= 90; + if (!isResaleCandidate && !meetsDecimalErrorOverride && discountPercentage < categoryMinimumDiscount(product)) continue; + + const tier = resale + ? tierForLane(resale.lane) + : alertTier(discountPercentage, Math.round(confidence), anomalyType ?? 'resale_margin', issues); + if (!isResaleCandidate && tier === 'WATCH' && discountPercentage < MIN_WATCH_DISCOUNT) continue; + + candidates.push({ + ...product, + originalPrice, + anomalyType: anomalyType ?? 'resale_margin', + confidence: resale?.resaleConfidence ?? Math.round(confidence), + discountPercentage, + tier, + familyKey, + qualityReasons: issues.length > 0 ? issues : ['clean'], + resaleValue: resale?.resaleValue, + resaleProfit: resale?.resaleProfit, + resaleMargin: resale?.resaleMargin, + resalePath: resale?.path, + resaleLane: resale?.lane, + conditionSummary: resale ? conditionSummary(resale.condition) : conditionSummary(conditionProfile(product)), + compSource: comps?.source, + compQuery: comps?.query, + compCount: comps?.count, + compMedian: comps?.median, + compRange: comps ? `$${comps.low.toFixed(2)}-$${comps.high.toFixed(2)}` : undefined, + compConfidence: compConfidence?.confidence, + compNotes: compConfidence?.notes, + buyPrepMaxPrice: prep?.maxPrice, + buyPrepVenue: prep?.venue, + buyPrepNet: prep?.net, + buyPrepAction: prep?.action, + buyPrepChecklist: prep?.checklist, + buyScore: score?.score, + buyScoreReasons: score?.reasons, + buyDecision: decision?.decision, + automationMode: decision?.automationMode, + decisionReasons: decision?.reasons, + riskFlags: resale?.riskFlags, + localOnly: resale?.localOnly, + storePickupOnly: resale?.storePickupOnly, + kitBreakoutValue: resale?.kitBreakoutValue, + }); + } + + return candidates + .sort( + (a, b) => + ['WATCH', 'RESALE', 'OPENBOX_RESALE', 'TOOL_RESALE', 'CAR_RESALE', 'HOT', 'ERROR?'].indexOf(b.tier) - + ['WATCH', 'RESALE', 'OPENBOX_RESALE', 'TOOL_RESALE', 'CAR_RESALE', 'HOT', 'ERROR?'].indexOf(a.tier) || + (b.buyScore ?? 0) - (a.buyScore ?? 0) || + (b.resaleProfit ?? 0) - (a.resaleProfit ?? 0) || + b.discountPercentage - a.discountPercentage || + b.confidence - a.confidence + ) + .slice(0, MAX_PER_RUN); +} + +async function sendDiscord(product: Candidate): Promise { + const route = discordRoute(product); + + const original = product.originalPrice ?? product.price; + const savings = Math.max(original - product.price, 0); + const signal = + isResaleTier(product.tier) && product.resaleProfit !== undefined && product.resaleMargin !== undefined + ? `est. profit $${product.resaleProfit.toFixed(2)} / ${Math.round(product.resaleMargin)}% margin` + : `${product.anomalyType} / ${product.confidence}%`; + const fields = [ + { name: 'Now', value: `$${product.price.toFixed(2)}`, inline: true }, + { name: 'Was', value: `$${original.toFixed(2)}`, inline: true }, + { name: 'Save', value: `$${savings.toFixed(2)}`, inline: true }, + ]; + if (isResaleTier(product.tier)) { + fields.push( + { name: 'Lane', value: product.resaleLane ?? 'pc', inline: true }, + { name: 'Resale path', value: product.resalePath ?? 'local', inline: true } + ); + } + if (isResaleTier(product.tier) && product.resaleValue !== undefined) { + fields.push({ name: 'Est resale', value: `$${product.resaleValue.toFixed(2)}`, inline: true }); + } + if (isResaleTier(product.tier) && product.resaleProfit !== undefined && product.resaleMargin !== undefined) { + fields.push( + { name: 'Est profit', value: `$${product.resaleProfit.toFixed(2)}`, inline: true }, + { name: 'Margin', value: `${Math.round(product.resaleMargin)}%`, inline: true }, + { name: 'Condition', value: product.conditionSummary ?? 'new/sealed assumed', inline: false } + ); + } + if (isResaleTier(product.tier) && product.buyScore !== undefined) { + fields.push({ name: 'Buy score', value: `${product.buyScore}/100`, inline: true }); + } + if (isResaleTier(product.tier) && product.buyDecision) { + fields.push( + { name: 'Decision', value: product.buyDecision, inline: true }, + { name: 'Automation', value: product.automationMode ?? 'off', inline: true }, + { name: 'Route', value: route.routeName, inline: true } + ); + } + if (isResaleTier(product.tier) && product.buyPrepMaxPrice !== undefined) { + fields.push( + { name: 'Max buy', value: `$${product.buyPrepMaxPrice.toFixed(2)}`, inline: true }, + { name: 'Net proceeds', value: `$${product.buyPrepNet?.toFixed(2) ?? 'n/a'}`, inline: true }, + { name: 'Resale venue', value: product.buyPrepVenue ?? 'local/eBay', inline: true }, + { name: 'Prep action', value: product.buyPrepAction ?? 'review before buy', inline: false } + ); + } + if (isResaleTier(product.tier) && product.buyPrepChecklist?.length) { + fields.push({ name: 'Buy checklist', value: product.buyPrepChecklist.join('\n').slice(0, 1024), inline: false }); + } + if (isResaleTier(product.tier) && product.kitBreakoutValue !== undefined) { + fields.push({ name: 'Kit breakout', value: `$${product.kitBreakoutValue.toFixed(2)}`, inline: true }); + } + if (isResaleTier(product.tier) && product.riskFlags?.length) { + fields.push({ name: 'Risk flags', value: product.riskFlags.join(', ').slice(0, 1024), inline: false }); + } + if (isResaleTier(product.tier) && product.decisionReasons?.length) { + fields.push({ name: 'Decision notes', value: product.decisionReasons.join(', ').slice(0, 1024), inline: false }); + } + if (isResaleTier(product.tier) && product.compSource) { + fields.push( + { name: 'Comps', value: `${product.compCount ?? 0} ${product.compSource} @ $${product.compMedian?.toFixed(2) ?? 'n/a'}`, inline: true }, + { name: 'Comp range', value: product.compRange ?? 'n/a', inline: true } + ); + } + if (isResaleTier(product.tier) && product.compConfidence) { + fields.push({ + name: 'Comp confidence', + value: [product.compConfidence, ...(product.compNotes ?? [])].join(' | ').slice(0, 1024), + inline: false, + }); + } + if (isResaleTier(product.tier) && product.buyScoreReasons?.length) { + fields.push({ name: 'Score reasons', value: product.buyScoreReasons.join(', ').slice(0, 1024), inline: false }); + } + fields.push( + { name: 'Signal', value: signal, inline: false }, + { name: 'Checks', value: product.qualityReasons.join(', '), inline: false } + ); + + const payload = { + content: + `${route.mention ? `${route.mention} ` : ''}${ + isResaleTier(product.tier) && product.resaleProfit !== undefined + ? `${product.tier} +$${product.resaleProfit.toFixed(0)} est at ${product.retailer}` + : `${product.tier} ${product.discountPercentage}% off at ${product.retailer}` + }`, + embeds: [ + { + title: `[${product.tier}] ${product.title}`.slice(0, 256), + url: product.url, + color: + product.tier === 'ERROR?' + ? 0xff3333 + : product.tier === 'HOT' + ? 0xff9900 + : isResaleTier(product.tier) + ? 0x2ecc71 + : 0x2f80ed, + fields, + thumbnail: product.imageUrl ? { url: product.imageUrl } : undefined, + timestamp: new Date().toISOString(), + }, + ], + }; + + const response = await fetch(route.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Discord webhook returned ${response.status}: ${await response.text()}`); + } +} + +async function runOnce(store: BotStore): Promise { + const targets = await parseTargets(); + const allProducts: Product[] = []; + + console.log(`loaded ${targets.length} target(s)`); + + for (const target of targets) { + const key = targetKey(target); + if (store.shouldSkipTarget(key)) { + console.log(`${target.retailer}: skipped temporary cooldown`); + continue; + } + + try { + const products = await fetchProducts(target); + console.log(`${target.retailer}: scraped ${products.length} products`); + allProducts.push(...products); + await store.recordTargetResult(key, target, products.length); + } catch (error) { + console.warn(`${target.retailer}: scrape failed (${briefError(error)})`); + await store.recordTargetResult(key, target, 0, error); + } + + if (TARGET_DELAY_MS > 0) await sleep(TARGET_DELAY_MS); + } + + const candidates = await pickCandidates(allProducts, store); + const alerted: Candidate[] = []; + for (const candidate of candidates) { + if (DRY_RUN) { + console.log( + `dry-run alert: [${candidate.tier}] ${candidate.title} at $${candidate.price.toFixed(2)} (${candidate.discountPercentage}% / ${candidate.confidence}%)` + ); + } else { + await sendDiscord(candidate); + alerted.push(candidate); + console.log(`alerted: ${candidate.title} at $${candidate.price.toFixed(2)}`); + } + if (TEST_SEND) break; + } + + await store.recordProducts(allProducts); + for (const candidate of alerted) { + await store.markAlerted(candidate); + await store.recordAlertSnapshot(candidate); + } + console.log( + `done: ${DRY_RUN ? candidates.length : alerted.length} ${DRY_RUN ? 'candidate(s)' : 'alert(s)'}, ${allProducts.length} product(s) seen` + ); +} + +function syntheticProduct(product: Partial & Pick): Product { + const slug = product.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return { + retailer: 'self-test', + url: `https://example.com/${slug}`, + stockStatus: 'unknown', + scrapedAt: new Date(), + ...product, + }; +} + +function expectTier(candidates: Candidate[], titlePart: string, tier: AlertTier) { + const candidate = candidates.find((item) => item.title.toLowerCase().includes(titlePart.toLowerCase())); + if (!candidate) throw new Error(`Expected candidate containing "${titlePart}"`); + if (candidate.tier !== tier) throw new Error(`Expected ${titlePart} to be ${tier}, got ${candidate.tier}`); +} + +function expectNoCandidate(candidates: Candidate[], titlePart: string) { + const candidate = candidates.find((item) => item.title.toLowerCase().includes(titlePart.toLowerCase())); + if (candidate) throw new Error(`Expected no candidate containing "${titlePart}", got ${candidate.tier}`); +} + +async function runSelfTest(): Promise { + const products = [ + syntheticProduct({ + title: 'Acer Predator 27 OLED Monitor Open Box Excellent', + category: 'openbox-monitor', + price: 300, + originalPrice: 599, + }), + syntheticProduct({ + title: 'Acer Predator 27 OLED Monitor Used Damaged For Parts', + category: 'openbox-monitor', + price: 100, + originalPrice: 599, + }), + syntheticProduct({ + title: 'Milwaukee M18 Fuel Drill Impact Driver Battery Charger Combo Kit', + category: 'tools', + price: 179, + originalPrice: 349, + }), + syntheticProduct({ + title: 'OBD2 Bidirectional Automotive Diagnostic Scanner Tablet', + category: 'car-diagnostic', + price: 199, + originalPrice: 449, + }), + syntheticProduct({ + title: 'ASUS RTX 5070 Graphics Card', + category: 'gpu', + price: 399, + originalPrice: 649, + }), + syntheticProduct({ + title: 'Extra $5 off on Apple MacBooks, iPads, and Much More at Woot!', + category: 'apple-deals-feed', + price: 5, + originalPrice: 499, + }), + ]; + + const store = new JsonStore({}); + const candidates = await pickCandidates(products, store); + expectTier(candidates, 'open box excellent', 'OPENBOX_RESALE'); + expectNoCandidate(candidates, 'for parts'); + expectTier(candidates, 'milwaukee', 'TOOL_RESALE'); + expectTier(candidates, 'diagnostic scanner', 'CAR_RESALE'); + expectTier(candidates, 'rtx 5070', 'RESALE'); + expectNoCandidate(candidates, 'extra $5 off'); + const tool = candidates.find((candidate) => candidate.title.toLowerCase().includes('milwaukee')); + if (!tool?.kitBreakoutValue) throw new Error('Expected Milwaukee kit to include breakout value'); + if (!tool.buyDecision) throw new Error('Expected Milwaukee kit to include buy decision'); + if (!tool.buyPrepMaxPrice || tool.buyPrepMaxPrice <= 0) throw new Error('Expected Milwaukee kit to include max buy prep price'); + if (!tool.buyPrepChecklist?.length) throw new Error('Expected Milwaukee kit to include buy prep checklist'); + const reliabilityEstimate: ResaleEstimate = { + lane: 'pc', + path: 'local', + resaleValue: 500, + resaleProfit: 120, + resaleMargin: 40, + resaleConfidence: 90, + condition: { allowed: [], rejected: [] }, + riskFlags: [], + }; + const trustedScore = buyScore({ ...products[4], retailer: 'newegg' }, reliabilityEstimate).score; + const riskyScore = buyScore({ ...products[4], retailer: 'walmart' }, reliabilityEstimate).score; + if (trustedScore <= riskyScore) throw new Error('Expected trusted retailer to score above marketplace-risk retailer'); + const previousDefaultWebhook = process.env.DISCORD_WEBHOOK_URL; + const previousReadyWebhook = process.env.DISCORD_READY_TO_BUY_WEBHOOK_URL; + const previousHotWebhook = process.env.DISCORD_HOT_WEBHOOK_URL; + try { + process.env.DISCORD_WEBHOOK_URL = 'https://discord.invalid/default'; + process.env.DISCORD_READY_TO_BUY_WEBHOOK_URL = 'https://discord.invalid/ready'; + process.env.DISCORD_HOT_WEBHOOK_URL = 'https://discord.invalid/hot'; + if (discordRoute({ ...tool, buyDecision: 'READY_TO_BUY' }).routeName !== 'nova-price-error') { + throw new Error('Expected READY_TO_BUY alert to use Nova Price Error Discord route'); + } + if (discordRoute({ ...tool, tier: 'HOT', buyDecision: undefined }).routeName !== 'nova-price-error') { + throw new Error('Expected HOT alert to use Nova Price Error Discord route'); + } + } finally { + if (previousDefaultWebhook === undefined) delete process.env.DISCORD_WEBHOOK_URL; + else process.env.DISCORD_WEBHOOK_URL = previousDefaultWebhook; + if (previousReadyWebhook === undefined) delete process.env.DISCORD_READY_TO_BUY_WEBHOOK_URL; + else process.env.DISCORD_READY_TO_BUY_WEBHOOK_URL = previousReadyWebhook; + if (previousHotWebhook === undefined) delete process.env.DISCORD_HOT_WEBHOOK_URL; + else process.env.DISCORD_HOT_WEBHOOK_URL = previousHotWebhook; + } + + const gpu = candidates.find((candidate) => candidate.title.includes('RTX 5070')); + if (!gpu) throw new Error('Expected RTX candidate for family dedupe test'); + await store.recordAlertSnapshot(gpu); + const deduped = await pickCandidates( + [ + syntheticProduct({ + title: 'ASUS RTX 5070 Graphics Card', + category: 'gpu', + price: 399, + originalPrice: 649, + }), + ], + store + ); + expectNoCandidate(deduped, 'rtx 5070'); + + await runSqlitePersistenceSelfTest(); + + console.log('self-test passed: resale lanes, condition filters, and family dedupe'); +} + +async function runSqlitePersistenceSelfTest(): Promise { + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(':memory:'); + try { + db.exec(` + CREATE TABLE alert_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + family_key TEXT NOT NULL, + title TEXT NOT NULL, + retailer TEXT NOT NULL, + category TEXT, + price REAL NOT NULL, + original_price REAL, + discount_percentage REAL NOT NULL, + confidence INTEGER NOT NULL, + tier TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + quality_reasons TEXT NOT NULL, + image_url TEXT, + sent_at TEXT NOT NULL + ); + INSERT INTO alert_snapshots ( + url, family_key, title, retailer, category, price, original_price, + discount_percentage, confidence, tier, anomaly_type, quality_reasons, image_url, sent_at + ) VALUES ( + 'https://example.com/existing', 'existing-family', 'Existing row', 'self-test', + 'gpu', 199, 399, 50, 90, 'RESALE', 'resale_margin', 'legacy', NULL, + '2026-01-01T00:00:00.000Z' + ); + `); + + const sqliteStore = new SqliteStore(db); + new SqliteStore(db); + const columns = new Set((db.prepare('PRAGMA table_info(alert_snapshots)').all() as Array<{ name: string }>).map((row) => row.name)); + for (const column of [ + 'decision_label', + 'estimated_resale_value', + 'estimated_profit', + 'estimated_margin', + 'buy_score', + 'decision_reason', + 'evaluated_at', + 'comp_confidence', + 'comp_notes', + ]) { + if (!columns.has(column)) throw new Error(`Missing migrated alert_snapshots column: ${column}`); + } + + await sqliteStore.recordAlertSnapshot({ + title: 'Persisted decision self-test', + price: 299, + originalPrice: 599, + retailer: 'self-test', + category: 'gpu', + url: 'https://example.com/persisted-decision', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'resale_margin', + confidence: 93, + discountPercentage: 50, + tier: 'RESALE', + familyKey: 'persisted-decision-family', + qualityReasons: ['self-test'], + resaleValue: 520, + resaleProfit: 125, + resaleMargin: 42, + buyScore: 88, + buyDecision: 'READY_TO_BUY', + automationMode: 'off', + decisionReasons: ['manual approval required'], + compConfidence: 'fair', + compNotes: ['6 sold comps', 'spread 28%'], + }); + await sqliteStore.recordAlertSnapshot({ + title: 'Missing optional fields self-test', + price: 99, + retailer: 'self-test', + category: 'accessory', + url: 'https://example.com/missing-optional-fields', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'percentage_drop', + confidence: 80, + discountPercentage: 40, + tier: 'WATCH', + familyKey: 'missing-optional-fields-family', + qualityReasons: ['self-test'], + }); + + const existing = db.prepare("SELECT COUNT(*) AS count FROM alert_snapshots WHERE url = 'https://example.com/existing'").get() as { + count: number; + }; + if (existing.count !== 1) throw new Error('Existing alert_snapshots row was not preserved during migration'); + + const persisted = db + .prepare( + `SELECT decision_label, estimated_resale_value, estimated_profit, estimated_margin, + confidence, buy_score, decision_reason, evaluated_at, comp_confidence, comp_notes + FROM alert_snapshots + WHERE url = 'https://example.com/persisted-decision'` + ) + .get() as { + decision_label: string; + estimated_resale_value: number; + estimated_profit: number; + estimated_margin: number; + confidence: number; + buy_score: number; + decision_reason: string; + evaluated_at: string; + comp_confidence: string; + comp_notes: string; + }; + if (persisted.decision_label !== 'READY_TO_BUY') throw new Error(`Expected READY_TO_BUY, got ${persisted.decision_label}`); + if (Number(persisted.estimated_resale_value) !== 520) throw new Error('Expected persisted resale value'); + if (Number(persisted.estimated_profit) !== 125) throw new Error('Expected persisted profit'); + if (Number(persisted.estimated_margin) !== 42) throw new Error('Expected persisted margin'); + if (Number(persisted.confidence) !== 93) throw new Error('Expected persisted confidence'); + if (Number(persisted.buy_score) !== 88) throw new Error('Expected persisted buy score'); + if (persisted.decision_reason !== 'manual approval required') throw new Error('Expected persisted decision reason'); + if (!persisted.evaluated_at) throw new Error('Expected evaluated_at timestamp'); + if (persisted.comp_confidence !== 'fair') throw new Error('Expected persisted comp confidence'); + if (persisted.comp_notes !== '6 sold comps; spread 28%') throw new Error('Expected persisted comp notes'); + + const missing = db + .prepare( + `SELECT decision_label, estimated_resale_value, estimated_profit, estimated_margin, + buy_score, decision_reason, comp_confidence, comp_notes + FROM alert_snapshots + WHERE url = 'https://example.com/missing-optional-fields'` + ) + .get() as Record; + if (missing.decision_label !== 'WATCH') throw new Error('Expected WATCH tier to persist as WATCH decision label'); + for (const key of ['estimated_resale_value', 'estimated_profit', 'estimated_margin', 'buy_score', 'decision_reason']) { + const value = missing[key]; + if (value !== null) throw new Error('Expected missing optional decision fields to persist as null'); + } + + if (BUY_AUTOMATION_ENABLED) throw new Error('Self-test must not enable buy automation'); + } finally { + db.close(); + } +} + +async function main() { + await loadRetailerReliabilityRules(); + + if (SELF_TEST) { + await runSelfTest(); + return; + } + + if (TEST_SEND) { + const testAlerts: Candidate[] = [ + { + title: 'PriceHawk synthetic RTX 5070 test alert', + price: 299.99, + originalPrice: 599.99, + retailer: 'test', + category: 'gpu', + url: 'https://example.com/pricehawk-test', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'resale_margin', + confidence: 99, + discountPercentage: 50, + tier: 'RESALE', + familyKey: 'pricehawk synthetic rtx 5070', + qualityReasons: ['test-send'], + resaleValue: 520, + resaleProfit: 125, + resaleMargin: 42, + resalePath: 'local', + resaleLane: 'pc', + conditionSummary: 'new/sealed assumed', + buyScore: 88, + buyScoreReasons: ['estimate-only', 'local-first', 'strong margin'], + buyDecision: 'READY_TO_BUY', + automationMode: 'off', + decisionReasons: ['manual approval required'], + riskFlags: [], + }, + { + title: 'PriceHawk synthetic open-box OLED monitor test alert', + price: 349.99, + originalPrice: 699.99, + retailer: 'test', + category: 'openbox-monitor', + url: 'https://example.com/pricehawk-openbox-test', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'resale_margin', + confidence: 94, + discountPercentage: 50, + tier: 'OPENBOX_RESALE', + familyKey: 'pricehawk synthetic openbox oled', + qualityReasons: ['test-send', 'condition:open-box'], + resaleValue: 640, + resaleProfit: 230, + resaleMargin: 66, + resalePath: 'local', + resaleLane: 'openbox', + conditionSummary: 'open-box, excellent', + compSource: 'ebay-sold', + compQuery: 'oled monitor 240hz', + compCount: 6, + compMedian: 640, + compRange: '$590.00-$699.00', + buyScore: 91, + buyScoreReasons: ['6 eBay sold comps', 'local-first', 'condition:open-box/excellent', 'strong profit'], + buyDecision: 'READY_TO_BUY', + automationMode: 'off', + decisionReasons: ['manual approval required'], + riskFlags: ['condition-risk', 'store-pickup-only'], + storePickupOnly: true, + }, + { + title: 'PriceHawk synthetic Milwaukee combo kit test alert', + price: 179.99, + originalPrice: 349.99, + retailer: 'test', + category: 'tools', + url: 'https://example.com/pricehawk-tool-test', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'resale_margin', + confidence: 91, + discountPercentage: 49, + tier: 'TOOL_RESALE', + familyKey: 'pricehawk synthetic tool', + qualityReasons: ['test-send'], + resaleValue: 320, + resaleProfit: 125, + resaleMargin: 69, + resalePath: 'local', + resaleLane: 'tool', + conditionSummary: 'new/sealed assumed', + buyScore: 86, + buyScoreReasons: ['estimate-only', 'local-first', 'tool liquidity', 'strong margin'], + buyDecision: 'READY_TO_BUY', + automationMode: 'off', + decisionReasons: ['manual approval required'], + riskFlags: [], + kitBreakoutValue: 320, + }, + { + title: 'PriceHawk synthetic OBD2 scanner test alert', + price: 199.99, + originalPrice: 449.99, + retailer: 'test', + category: 'car-diagnostic', + url: 'https://example.com/pricehawk-car-test', + stockStatus: 'unknown', + scrapedAt: new Date(), + anomalyType: 'resale_margin', + confidence: 92, + discountPercentage: 56, + tier: 'CAR_RESALE', + familyKey: 'pricehawk synthetic car', + qualityReasons: ['test-send'], + resaleValue: 410, + resaleProfit: 190, + resaleMargin: 95, + resalePath: 'local', + resaleLane: 'car', + conditionSummary: 'new/sealed assumed', + buyScore: 82, + buyScoreReasons: ['estimate-only', 'local-first', 'car liquidity risk', 'strong margin'], + buyDecision: 'NEEDS_REVIEW', + automationMode: 'off', + decisionReasons: ['car fitment review'], + riskFlags: ['fitment/liquidity-risk'], + localOnly: true, + }, + ]; + for (const alert of testAlerts) await sendDiscord(alert); + console.log(`test alerts sent: ${testAlerts.length}`); + return; + } + + const store = await createStore(); + try { + do { + await runOnce(store); + if (LOOP) await sleep(INTERVAL_MS); + } while (LOOP); + } finally { + await store.close(); + } +} + +main().catch((error) => { + console.error('Fatal discord price bot error:', error); + process.exit(1); +}); + +process.on('unhandledRejection', (error) => { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('Target page, context or browser has been closed')) { + console.warn('playwright page closed during scrape; continuing'); + return; + } + + console.error('Unhandled bot rejection:', error); +}); diff --git a/src/workers/discord-price-feedback.ts b/src/workers/discord-price-feedback.ts new file mode 100644 index 0000000..5e06480 --- /dev/null +++ b/src/workers/discord-price-feedback.ts @@ -0,0 +1,158 @@ +import 'dotenv/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type FeedbackLabel = 'good' | 'bad' | 'maybe' | 'bought' | 'passed'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SQLITE_FILE = process.env.PRICE_BOT_SQLITE_FILE || path.resolve(__dirname, '../../.price-bot.sqlite'); +const VALID_LABELS = new Set(['good', 'bad', 'maybe', 'bought', 'passed']); +const SELF_TEST = process.env.PRICE_BOT_FEEDBACK_SELF_TEST === 'true'; + +function usage(): never { + console.log(`Usage: + tsx src/workers/discord-price-feedback.ts list + tsx src/workers/discord-price-feedback.ts mark [note] + tsx src/workers/discord-price-feedback.ts summary`); + process.exit(1); +} + +function ensureFeedbackTable(db: any) { + db.exec(` + CREATE TABLE IF NOT EXISTS alert_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alert_id INTEGER NOT NULL, + label TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(alert_id) REFERENCES alert_snapshots(id) + ); + CREATE INDEX IF NOT EXISTS idx_alert_feedback_alert_created ON alert_feedback(alert_id, created_at DESC); + `); +} + +async function runSelfTest() { + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(':memory:'); + try { + db.exec(` + CREATE TABLE alert_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + family_key TEXT NOT NULL, + title TEXT NOT NULL, + retailer TEXT NOT NULL, + category TEXT, + price REAL NOT NULL, + original_price REAL, + discount_percentage REAL NOT NULL, + confidence INTEGER NOT NULL, + tier TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + quality_reasons TEXT NOT NULL, + image_url TEXT, + sent_at TEXT NOT NULL, + buy_score INTEGER, + decision_label TEXT + ); + INSERT INTO alert_snapshots ( + url, family_key, title, retailer, category, price, original_price, discount_percentage, + confidence, tier, anomaly_type, quality_reasons, image_url, sent_at, buy_score, decision_label + ) VALUES ( + 'https://example.com/a', 'fam', 'Feedback self-test alert', 'newegg', 'gpu', + 99, 199, 50, 90, 'RESALE', 'resale_margin', 'clean', NULL, + '2026-01-01T00:00:00.000Z', 88, 'READY_TO_BUY' + ); + `); + ensureFeedbackTable(db); + db.prepare('INSERT INTO alert_feedback (alert_id, label, note, created_at) VALUES (?, ?, ?, ?)').run( + 1, + 'good', + 'solid margin', + new Date().toISOString() + ); + const row = db + .prepare('SELECT label, note FROM alert_feedback WHERE alert_id = 1 ORDER BY created_at DESC LIMIT 1') + .get() as { label: string; note: string }; + if (row.label !== 'good') throw new Error('Expected feedback label to persist'); + if (row.note !== 'solid margin') throw new Error('Expected feedback note to persist'); + console.log('feedback self-test passed'); + } finally { + db.close(); + } +} + +async function main() { + if (SELF_TEST) { + await runSelfTest(); + return; + } + + const [command, idRaw, labelRaw, ...noteParts] = process.argv.slice(2); + if (!command) usage(); + + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(SQLITE_FILE); + + try { + ensureFeedbackTable(db); + + if (command === 'list') { + const rows = db + .prepare( + `SELECT a.id, a.tier, a.retailer, substr(a.title, 1, 72) AS title, a.price, + a.buy_score, a.decision_label, + (SELECT label FROM alert_feedback f WHERE f.alert_id = a.id ORDER BY f.created_at DESC LIMIT 1) AS feedback + FROM alert_snapshots a + ORDER BY a.sent_at DESC + LIMIT 20` + ) + .all() as Record[]; + console.table(rows); + return; + } + + if (command === 'summary') { + const rows = db + .prepare( + `SELECT label, COUNT(*) AS count + FROM alert_feedback + GROUP BY label + ORDER BY count DESC, label ASC` + ) + .all() as Record[]; + console.table(rows); + return; + } + + if (command === 'mark') { + const alertId = Number.parseInt(idRaw ?? '', 10); + const label = labelRaw as FeedbackLabel; + if (!Number.isInteger(alertId) || alertId <= 0 || !VALID_LABELS.has(label)) usage(); + + const alert = db.prepare('SELECT id, title FROM alert_snapshots WHERE id = ?').get(alertId) as + | { id: number; title: string } + | undefined; + if (!alert) throw new Error(`No alert_snapshots row found for id ${alertId}`); + + const note = noteParts.join(' ').trim() || null; + db.prepare('INSERT INTO alert_feedback (alert_id, label, note, created_at) VALUES (?, ?, ?, ?)').run( + alertId, + label, + note, + new Date().toISOString() + ); + console.log(`marked alert ${alertId} as ${label}: ${alert.title}`); + return; + } + + usage(); + } finally { + db.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/workers/discord-price-status.ts b/src/workers/discord-price-status.ts new file mode 100644 index 0000000..4161f3a --- /dev/null +++ b/src/workers/discord-price-status.ts @@ -0,0 +1,102 @@ +import 'dotenv/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SQLITE_FILE = process.env.PRICE_BOT_SQLITE_FILE || path.resolve(__dirname, '../../.price-bot.sqlite'); + +function printTable(title: string, rows: Record[]) { + console.log(`\n${title}`); + if (rows.length === 0) { + console.log(' none'); + return; + } + + console.table(rows); +} + +async function main() { + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(SQLITE_FILE, { readOnly: true }); + + try { + const tables = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>).map((row) => row.name) + ); + const counts = db + .prepare( + `SELECT + (SELECT COUNT(*) FROM products) AS products, + (SELECT COUNT(*) FROM price_history) AS price_points, + (SELECT COUNT(*) FROM target_health) AS targets, + (SELECT COUNT(*) FROM alert_snapshots) AS alerts` + ) + .get() as Record; + + console.log(`SQLite: ${SQLITE_FILE}`); + console.table([counts]); + + printTable( + 'Top Product Sources', + db + .prepare( + `SELECT retailer, COUNT(*) AS products + FROM products + GROUP BY retailer + ORDER BY products DESC + LIMIT 12` + ) + .all() as Record[] + ); + + printTable( + 'Target Health', + db + .prepare( + `SELECT retailer, last_product_count AS products, successes, failures, skip_until, last_error + FROM target_health + ORDER BY last_product_count DESC, failures ASC + LIMIT 20` + ) + .all() as Record[] + ); + + printTable( + 'Recent Products', + db + .prepare( + `SELECT retailer, substr(title, 1, 70) AS title, last_price AS price, last_seen_at + FROM products + ORDER BY last_seen_at DESC + LIMIT 10` + ) + .all() as Record[] + ); + + printTable( + 'Recent Alerts', + db + .prepare( + `SELECT a.id, a.tier, a.retailer, substr(a.title, 1, 70) AS title, a.price, + a.discount_percentage AS discount, a.buy_score, + ${ + tables.has('alert_feedback') + ? "(SELECT label FROM alert_feedback f WHERE f.alert_id = a.id ORDER BY f.created_at DESC LIMIT 1)" + : 'NULL' + } AS feedback, + a.sent_at + FROM alert_snapshots a + ORDER BY a.sent_at DESC + LIMIT 10` + ) + .all() as Record[] + ); + } finally { + db.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/workers/facebook-marketplace-discord.ts b/src/workers/facebook-marketplace-discord.ts new file mode 100644 index 0000000..22d8953 --- /dev/null +++ b/src/workers/facebook-marketplace-discord.ts @@ -0,0 +1,215 @@ +import 'dotenv/config'; +import { readFile } from 'node:fs/promises'; +import { + loadMarketplaceTargets, + scoreMarketplaceListings, + type MarketplaceRawListing, + type MarketplaceScoredListing, +} from './facebook-marketplace-scorer'; + +const RAW_LISTINGS_FILE = process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_RAW_LISTINGS_FILE; +const TEST_SEND = process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_TEST_SEND === 'true'; +const DRY_RUN = process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_DRY_RUN === 'true'; +const MAX_ALERTS = Number.parseInt(process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_MAX_ALERTS || '8', 10); +const SKIP_ALERTS = Number.parseInt(process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_SKIP_ALERTS || '0', 10); + +const RECENT_MARKETPLACE_LISTINGS: MarketplaceRawListing[] = [ + { + text: '$40 $50 a Milwaukee M12 & M18 Multi-Voltage Rapid Battery Charger Tulsa, OK', + href: 'https://www.facebook.com/marketplace/item/1923304895018389/', + }, + { + text: '$55 Milwaukee m18 1.5 battery and charger Tulsa, OK', + href: 'https://www.facebook.com/marketplace/item/1418110003517389/', + }, + { + text: '$60 Milwaukee M18 XC 5.0 Battery New! Broken Arrow, OK', + href: 'https://www.facebook.com/marketplace/item/3966731270289964/', + }, + { + text: '$300 $400 MacBook Air M1 2020 Tulsa, OK', + href: 'https://www.facebook.com/marketplace/item/26836407102719786/', + }, + { + text: '$65 battery 5.0 Milwaukee m18 one for 65 2 for 125 Tulsa, OK', + href: 'https://www.facebook.com/marketplace/item/1327834092790355/', + }, + { + text: '$35 $50 Milwaukee M18 18V Cordless 1/4” Hex Impact Driver with XC 5.0 Battery- used Claremore, OK', + href: 'https://www.facebook.com/marketplace/item/1731082291563165/', + }, + { + text: '$70 $80 Milwaukee M18 Brushless Drill with 6.0Ah Battery Glenpool, OK', + href: 'https://www.facebook.com/marketplace/item/2260130424753489/', + }, + { + text: '$60 Milwaukee M18 REDLITHIUM XC5.0 Battery 48-11-1850 $60 (each one) Tulsa, OK', + href: 'https://www.facebook.com/marketplace/item/1378633121066809/', + }, +]; + +async function loadRawListings() { + if (RAW_LISTINGS_FILE) { + const parsed = JSON.parse(await readFile(RAW_LISTINGS_FILE, 'utf8')) as MarketplaceRawListing[]; + if (!Array.isArray(parsed)) throw new Error(`${RAW_LISTINGS_FILE} must contain a JSON array`); + return parsed; + } + + if (TEST_SEND) return RECENT_MARKETPLACE_LISTINGS; + throw new Error('Set PRICE_BOT_FACEBOOK_MARKETPLACE_RAW_LISTINGS_FILE or PRICE_BOT_FACEBOOK_MARKETPLACE_TEST_SEND=true'); +} + +function discordWebhookUrl() { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) throw new Error('DISCORD_WEBHOOK_URL is required'); + return webhookUrl; +} + +function cleanFacebookUrl(url?: string) { + return url?.split('?')[0]; +} + +function alertColor(candidate: MarketplaceScoredListing) { + if (candidate.recommendation === 'READY_TO_BUY') return 0x2ecc71; + if (candidate.recommendation === 'NEGOTIATE') return 0xffc107; + if (candidate.recommendation === 'REVIEW') return 0x2f80ed; + if (candidate.decision === 'NEEDS_REVIEW') return 0xffc107; + return 0x2f80ed; +} + +function money(value: number) { + return `$${value.toFixed(2)}`; +} + +function pct(value: number) { + return `${Math.round(value)}%`; +} + +function fieldValue(value: string, limit = 1024) { + const normalized = value.replace(/\s+/g, ' ').trim(); + return normalized.length > limit ? `${normalized.slice(0, limit - 3)}...` : normalized || 'n/a'; +} + +function compsSummary(candidate: MarketplaceScoredListing) { + if (candidate.valuation.compsUsed.length === 0) return 'No exact sold comps stored'; + return candidate.valuation.compsUsed + .slice(0, 3) + .map((comp) => `${money(comp.soldPrice + (comp.shipping ?? 0))} - ${comp.title}`) + .join('\n'); +} + +async function sendMarketplaceDiscord(candidate: MarketplaceScoredListing) { + const href = cleanFacebookUrl(candidate.href); + const fields = [ + { name: 'Source', value: 'Facebook Marketplace', inline: true }, + { name: 'Lane', value: candidate.lane, inline: true }, + { name: 'Recommendation', value: candidate.recommendation, inline: true }, + { name: 'Identified', value: fieldValue(candidate.identity.identifiedProduct, 256), inline: false }, + { + name: 'Confidence', + value: `Identity ${pct(candidate.identity.identityConfidence)} | OEM ${pct(candidate.identity.authenticityConfidence)} | Condition ${pct( + candidate.identity.conditionConfidence + )}`, + inline: false, + }, + { + name: 'Ask / median / gross', + value: `${money(candidate.valuation.askingPrice)} / ${money(candidate.valuation.conservativeMedianSoldValue)} / ${money( + candidate.valuation.expectedGrossResale + )}`, + inline: false, + }, + { + name: 'Costs', + value: `Fees ${money(candidate.valuation.fees)} | Ship ${money(candidate.valuation.shipping)} | Test ${money( + candidate.valuation.testingRepairReserve + )} | Risk ${money(candidate.valuation.riskReserve)}`, + inline: false, + }, + { + name: 'Net / profit / ROI', + value: `${money(candidate.valuation.estimatedNetProceeds)} / ${money(candidate.valuation.estimatedProfit)} / ${pct( + candidate.valuation.roiPercent + )}`, + inline: false, + }, + { name: 'Max offer', value: money(candidate.valuation.maximumRecommendedOffer), inline: true }, + { name: 'Score', value: `${candidate.score}/100`, inline: true }, + { name: 'Reasoning', value: fieldValue(candidate.plainEnglishReasoning.join('; ')), inline: false }, + { name: 'Comps used', value: fieldValue(compsSummary(candidate)), inline: false }, + ]; + + if (candidate.location) fields.push({ name: 'Location', value: candidate.location, inline: true }); + if (candidate.warningFlags.length) fields.push({ name: 'Warnings', value: fieldValue(candidate.warningFlags.join(', ')), inline: false }); + if (candidate.riskFlags.length) fields.push({ name: 'Risk notes', value: fieldValue(candidate.riskFlags.join(', ')), inline: false }); + + const payload = { + content: `FB MARKETPLACE ${candidate.recommendation} ${money(candidate.valuation.estimatedProfit)} est | ${candidate.title}`, + embeds: [ + { + title: `[FB ${candidate.recommendation}] ${candidate.title}`.slice(0, 256), + url: href, + color: alertColor(candidate), + fields, + thumbnail: candidate.img ? { url: candidate.img } : undefined, + footer: { text: 'Nova Price Error | Marketplace read-only scan' }, + timestamp: new Date().toISOString(), + }, + ], + }; + + let response: Response | undefined; + for (let attempt = 0; attempt < 3; attempt += 1) { + response = await fetch(discordWebhookUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.status !== 429) break; + + const rateLimit = (await response.json().catch(() => undefined)) as { retry_after?: number } | undefined; + const retryAfterMs = Math.ceil((rateLimit?.retry_after ?? 1) * 1000) + 250; + await new Promise((resolve) => setTimeout(resolve, retryAfterMs)); + } + + if (!response || !response.ok) { + const status = response?.status ?? 'no response'; + const body = response ? await response.text() : 'request did not return a response'; + throw new Error(`Discord webhook returned ${status}: ${body}`); + } +} + +async function main() { + const targets = await loadMarketplaceTargets(); + const rawListings = await loadRawListings(); + const candidates = scoreMarketplaceListings(rawListings, targets) + .filter((candidate) => candidate.decision === 'READY_TO_BUY' || candidate.decision === 'NEEDS_REVIEW') + .slice(SKIP_ALERTS) + .slice(0, MAX_ALERTS); + + if (candidates.length === 0) { + console.log(`facebook marketplace discord: scanned ${rawListings.length} listing(s), no candidates`); + return; + } + + for (const candidate of candidates) { + if (DRY_RUN) { + console.log( + `dry-run discord: [${candidate.recommendation}/${candidate.decision}] ${candidate.title} ask ${money( + candidate.price + )} net ${money(candidate.valuation.estimatedNetProceeds)} profit ${money(candidate.valuation.estimatedProfit)} roi ${pct( + candidate.valuation.roiPercent + )} warnings=${candidate.warningFlags.join(',') || 'none'}` + ); + continue; + } + await sendMarketplaceDiscord(candidate); + console.log(`discord sent: [${candidate.recommendation}/${candidate.decision}] ${candidate.title}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/workers/facebook-marketplace-scorer.test.ts b/src/workers/facebook-marketplace-scorer.test.ts new file mode 100644 index 0000000..2189a17 --- /dev/null +++ b/src/workers/facebook-marketplace-scorer.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + scoreMarketplaceListing, + scoreMarketplaceListings, + type MarketplaceSearchTarget, + type MarketplaceSoldComp, +} from './facebook-marketplace-scorer'; + +const milwaukeeBatteryTarget: MarketplaceSearchTarget = { + query: 'milwaukee m18 battery', + lane: 'tool', + requireAll: ['milwaukee', 'm18'], + rejectAny: ['reconditioned', 'recovered', 'cells replaced', 'dead', 'not charging'], + riskAny: ['used', 'each', 'ships to you'], + minPrice: 35, + maxPrice: 90, + estimatedResale: 150, + minProfit: 45, + minMarginPercent: 45, + readyScore: 75, + reviewScore: 58, +}; + +function exactSoldComps(model = '48-11-1850'): MarketplaceSoldComp[] { + return [ + { title: `Milwaukee ${model} M18 XC 5.0Ah Battery Genuine`, soldPrice: 142, shipping: 8, url: 'https://ebay.example/sold-1' }, + { title: `Genuine Milwaukee ${model} REDLITHIUM XC5.0 M18 Battery`, soldPrice: 144, shipping: 7, url: 'https://ebay.example/sold-2' }, + { title: `Milwaukee M18 5.0Ah OEM Battery ${model}`, soldPrice: 138, shipping: 9, url: 'https://ebay.example/sold-3' }, + { title: `Milwaukee ${model} 5.0Ah battery used tested`, soldPrice: 140, shipping: 8, url: 'https://ebay.example/sold-4' }, + ]; +} + +describe('facebook marketplace Milwaukee battery scoring', () => { + it('allows READY_TO_BUY only for a strongly identified genuine OEM battery with exact sold comps', () => { + const listing = scoreMarketplaceListing( + { text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', soldComps: exactSoldComps() }, + milwaukeeBatteryTarget + ); + + expect(listing.recommendation).toBe('READY_TO_BUY'); + expect(listing.identity.modelNumber).toBe('48-11-1850'); + expect(listing.identity.platform).toBe('M18'); + expect(listing.identity.capacityAh).toBe(5); + expect(listing.valuation.compsUsed).toHaveLength(4); + expect(listing.valuation.estimatedProfit).toBeGreaterThanOrEqual(45); + expect(listing.valuation.roiPercent).toBeGreaterThanOrEqual(45); + }); + + it.each([ + ['$40 For Milwaukee M18 compatible replacement 5.0Ah battery Tulsa, OK', 'aftermarket compatible battery'], + ['$35 Milwaukee M18 battery shell case housing no cells Tulsa, OK', 'empty battery shell'], + ['$40 Milwaukee M12 M18 rapid charger Tulsa, OK', 'charger-only listing'], + ['$30 Milwaukee 48-11-1850 M18 battery untested as-is Tulsa, OK', 'untested battery'], + ])('does not promote %s (%s)', (text) => { + const listing = scoreMarketplaceListing({ text, soldComps: exactSoldComps() }, milwaukeeBatteryTarget); + + expect(listing.recommendation).not.toBe('READY_TO_BUY'); + }); + + it('requires review for a tool-and-battery bundle instead of valuing it as a clean battery', () => { + const listing = scoreMarketplaceListing( + { text: '$70 Milwaukee M18 Brushless Drill with 6.0Ah Battery Glenpool, OK', soldComps: exactSoldComps('48-11-1865') }, + milwaukeeBatteryTarget + ); + + expect(listing.recommendation).not.toBe('READY_TO_BUY'); + expect(listing.identity.batteryKind).toBe('tool_bundle'); + }); + + it('tracks quantity for a two-battery lot without guessing extra units', () => { + const listing = scoreMarketplaceListing( + { text: '$95 Two pack Milwaukee 48-11-1850 M18 XC 5.0Ah Batteries genuine Tulsa, OK', soldComps: exactSoldComps() }, + milwaukeeBatteryTarget + ); + + expect(listing.identity.quantity).toBe(2); + expect(listing.valuation.expectedGrossResale).toBeLessThanOrEqual(listing.valuation.conservativeMedianSoldValue * 2); + }); + + it('blocks READY_TO_BUY when visible photo text contradicts the title model', () => { + const listing = scoreMarketplaceListing( + { + text: '$35 Milwaukee 48-11-1850 M18 XC5.0 Battery Tulsa, OK', + photoText: 'photo label 48-11-1820 compact 2.0ah', + soldComps: exactSoldComps(), + }, + milwaukeeBatteryTarget + ); + + expect(listing.recommendation).not.toBe('READY_TO_BUY'); + expect(listing.warningFlags).toContain('visible-model-mismatch'); + }); + + it('deduplicates repeated Facebook listings by canonical URL', () => { + const duplicateA = { + text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', + href: 'https://facebook.example/item/1', + soldComps: exactSoldComps(), + }; + const duplicateB = { ...duplicateA, text: '$35 Milwaukee 48-11-1850 M18 XC5.0 Battery New Tulsa, OK' }; + + expect(scoreMarketplaceListings([duplicateA, duplicateB], [milwaukeeBatteryTarget])).toHaveLength(1); + }); + + it('uses exact sold comps and excludes mismatched active or bundled eBay listings', () => { + const listing = scoreMarketplaceListing( + { + text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', + soldComps: [ + { title: 'Milwaukee M18 drill kit with two batteries', soldPrice: 220, shipping: 20 }, + { title: 'For Milwaukee compatible battery active asking', soldPrice: 35, isActive: true }, + { title: 'Milwaukee charger only', soldPrice: 30, shipping: 8 }, + ], + }, + milwaukeeBatteryTarget + ); + + expect(listing.valuation.compsUsed).toHaveLength(0); + expect(listing.recommendation).not.toBe('READY_TO_BUY'); + expect(listing.warningFlags).toContain('insufficient-exact-sold-comps'); + }); +}); diff --git a/src/workers/facebook-marketplace-scorer.ts b/src/workers/facebook-marketplace-scorer.ts new file mode 100644 index 0000000..5a73afb --- /dev/null +++ b/src/workers/facebook-marketplace-scorer.ts @@ -0,0 +1,728 @@ +import 'dotenv/config'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type MarketplaceLane = 'pc' | 'tool' | 'car'; +type MarketplaceDecision = 'READY_TO_BUY' | 'NEEDS_REVIEW' | 'WATCH' | 'REJECTED'; +type MarketplaceRecommendation = 'READY_TO_BUY' | 'NEGOTIATE' | 'REVIEW' | 'PASS'; +type BatteryKind = + | 'genuine_oem_battery' + | 'aftermarket_compatible' + | 'empty_shell' + | 'adapter' + | 'charger' + | 'tool_bundle' + | 'mixed_lot' + | 'parts_or_untested' + | 'unknown'; + +export type MarketplaceSearchTarget = { + query: string; + lane: MarketplaceLane; + requireAny?: string[]; + requireAll?: string[]; + rejectAny?: string[]; + riskAny?: string[]; + minPrice: number; + maxPrice: number; + estimatedResale: number; + minProfit?: number; + minMarginPercent?: number; + readyScore?: number; + reviewScore?: number; +}; + +export type MarketplaceSoldComp = { + title: string; + soldPrice: number; + shipping?: number; + condition?: string; + url?: string; + soldAt?: string; + isActive?: boolean; +}; + +export type MarketplaceRawListing = { + text: string; + href?: string; + img?: string; + seller?: string; + description?: string; + photoText?: string; + soldComps?: MarketplaceSoldComp[]; +}; + +export type MarketplaceIdentity = { + productType: string; + identifiedProduct: string; + modelNumber?: string; + platform?: string; + capacityAh?: number; + quantity: number; + batteryKind?: BatteryKind; + identityConfidence: number; + authenticityConfidence: number; + conditionConfidence: number; + evidence: string[]; +}; + +export type MarketplaceValuation = { + askingPrice: number; + conservativeMedianSoldValue: number; + expectedGrossResale: number; + fees: number; + shipping: number; + testingRepairReserve: number; + riskReserve: number; + estimatedNetProceeds: number; + estimatedProfit: number; + roiPercent: number; + maximumRecommendedOffer: number; + compsUsed: MarketplaceSoldComp[]; +}; + +export type MarketplaceScoredListing = { + query: string; + lane: MarketplaceLane; + title: string; + price: number; + location?: string; + raw: string; + estimatedResale: number; + estimatedProfit: number; + marginPercent: number; + score: number; + decision: MarketplaceDecision; + recommendation: MarketplaceRecommendation; + identity: MarketplaceIdentity; + valuation: MarketplaceValuation; + plainEnglishReasoning: string[]; + riskFlags: string[]; + warningFlags: string[]; + rejectReason?: string; + href?: string; + img?: string; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_MARKETPLACE_CONFIG = + process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_TARGETS_FILE || + path.resolve(__dirname, '../../config/facebook-marketplace-searches.json'); +const SELF_TEST = process.env.PRICE_BOT_FACEBOOK_MARKETPLACE_SELF_TEST === 'true'; +const EBAY_FEE_PERCENT = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_EBAY_FEE_PERCENT || '13.25'); +const PAYMENT_FEE_PERCENT = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_PAYMENT_FEE_PERCENT || '0'); +const DEFAULT_SHIPPING = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_DEFAULT_SHIPPING || '12'); +const DEFAULT_PACKAGING = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_DEFAULT_PACKAGING || '2'); +const DEFAULT_RISK_RESERVE_PERCENT = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_RISK_RESERVE_PERCENT || '8'); +const BATTERY_TESTING_RESERVE = Number.parseFloat(process.env.PRICE_BOT_MARKETPLACE_BATTERY_TESTING_RESERVE || '8'); + +const MILWAUKEE_BATTERY_MODELS: Record = { + '48-11-1812': { platform: 'M18', capacityAh: 1.5 }, + '48-11-1815': { platform: 'M18', capacityAh: 1.5 }, + '48-11-1820': { platform: 'M18', capacityAh: 2 }, + '48-11-1828': { platform: 'M18', capacityAh: 3 }, + '48-11-1835': { platform: 'M18', capacityAh: 3 }, + '48-11-1840': { platform: 'M18', capacityAh: 4 }, + '48-11-1850': { platform: 'M18', capacityAh: 5 }, + '48-11-1865': { platform: 'M18', capacityAh: 6 }, + '48-11-1880': { platform: 'M18', capacityAh: 8 }, + '48-11-1812S': { platform: 'M18', capacityAh: 12 }, + '48-11-2420': { platform: 'M12', capacityAh: 2 }, + '48-11-2430': { platform: 'M12', capacityAh: 3 }, + '48-11-2440': { platform: 'M12', capacityAh: 4 }, + '48-11-2450': { platform: 'M12', capacityAh: 5 }, + '48-11-2460': { platform: 'M12', capacityAh: 6 }, +}; + +function normalize(text: string) { + return text.toLowerCase().replace(/[^\w.$-]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function includesTerm(text: string, term: string) { + const haystack = normalize(text); + const needle = normalize(term); + if (!needle) return false; + const pattern = needle + .split(/\s+/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\s+'); + return new RegExp(`\\b${pattern}\\b`, 'i').test(haystack); +} + +function hasAny(text: string, terms?: string[]) { + return !terms || terms.length === 0 || terms.some((term) => includesTerm(text, term)); +} + +function hasAll(text: string, terms?: string[]) { + return !terms || terms.length === 0 || terms.every((term) => includesTerm(text, term)); +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function median(values: number[]) { + if (values.length === 0) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +export async function loadMarketplaceTargets(file = DEFAULT_MARKETPLACE_CONFIG): Promise { + const targets = JSON.parse(await readFile(file, 'utf8')) as MarketplaceSearchTarget[]; + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error(`${file} must contain at least one Marketplace target`); + } + + for (const target of targets) { + if (!target.query || !target.lane || !Number.isFinite(target.minPrice) || !Number.isFinite(target.maxPrice)) { + throw new Error(`Invalid Marketplace target: ${JSON.stringify(target)}`); + } + if (!Number.isFinite(target.estimatedResale) || target.estimatedResale <= 0) { + throw new Error(`Marketplace target needs estimatedResale: ${target.query}`); + } + } + + return targets; +} + +export function parseMarketplaceListing(rawText: string) { + const raw = String(rawText || '').replace(/\s+/g, ' ').trim(); + const priceMatches = Array.from(raw.matchAll(/\$\s*([0-9][0-9,]*(?:\.\d{2})?)/g)); + const priceMatch = priceMatches[0]; + const price = priceMatch ? Number.parseFloat(priceMatch[1].replace(/,/g, '')) : undefined; + let title = raw; + + if (priceMatch && priceMatch.index !== undefined) title = raw.slice(priceMatch.index + priceMatch[0].length).trim(); + + title = title.replace(/^(just listed|pending|sold|ships to you|listed \d+\w* ago)\s+/i, '').trim(); + const locationMatch = title.match(/\b([A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3},\s*(?:OK|MO|AR|KS|TX))\b\s*$/); + const location = locationMatch?.[1]; + if (location && locationMatch.index !== undefined) title = title.slice(0, locationMatch.index).trim(); + if (priceMatches.length > 1) title = title.replace(/^\$\s*[0-9][0-9,]*(?:\.\d{2})?\s*/, '').trim(); + + return { price, title, location, raw }; +} + +function extractModel(text: string) { + const match = text.match(/\b48-11-[0-9]{4}[A-Z]?\b/i); + return match?.[0].toUpperCase(); +} + +function extractCapacityAh(text: string) { + const patterns = [ + /\b([0-9]{1,2}(?:\.[0-9])?)\s*(?:ah|amp\s*hour|amp-hour)\b/gi, + /\b(?:battery|batteries)\s+([0-9]{1,2}(?:\.[0-9])?)\b/gi, + /\b([0-9](?:\.[0-9]))\s*(?:battery|batteries)\b/gi, + /\bxc\s*([0-9](?:\.[0-9])?)\b/gi, + ]; + + for (const pattern of patterns) { + for (const match of Array.from(text.matchAll(pattern))) { + const index = match.index ?? 0; + const before = text.slice(Math.max(0, index - 6), index).toLowerCase(); + const value = Number.parseFloat(match[1]); + if (before.includes('$') || /\bfor\s*$/.test(before) || value > 15) continue; + return value; + } + } + + return undefined; +} + +function extractQuantity(text: string) { + const normalized = normalize(text); + if (/\b(one|1)\s+for\s+\$?\d+\s+(?:and\s+)?(?:two|2)\s+for\b/.test(normalized)) return 1; + const pair = normalized.match(/\b(?:pair|two|2)\s+(?:pack|batteries|battery)\b/) || normalized.match(/\b(?:qty|quantity)\s*[:#]?\s*2\b/); + if (pair) return 2; + const pack = normalized.match(/\b([2-9])\s*(?:pack|pk)\b/); + if (pack) return Number.parseInt(pack[1], 10); + return 1; +} + +function isBatterySearch(target: MarketplaceSearchTarget) { + return includesTerm(target.query, 'milwaukee m18 battery'); +} + +function classifyMilwaukeeBattery(rawListing: MarketplaceRawListing, title: string): MarketplaceIdentity { + const combined = `${title} ${rawListing.text} ${rawListing.description ?? ''} ${rawListing.photoText ?? ''}`; + const text = normalize(combined); + const photoText = normalize(rawListing.photoText ?? ''); + const evidence: string[] = []; + const modelNumber = extractModel(combined); + const modelFacts = modelNumber ? MILWAUKEE_BATTERY_MODELS[modelNumber] : undefined; + const platform = modelFacts?.platform ?? (includesTerm(text, 'm18') ? 'M18' : includesTerm(text, 'm12') ? 'M12' : undefined); + const capacityAh = modelFacts?.capacityAh ?? extractCapacityAh(combined); + const quantity = extractQuantity(combined); + const hasBatteryTerm = /\b(battery|batteries|redlithium|xc|high output|ho)\b/.test(text); + const hasBatteryPackEvidence = + /\b(redlithium|xc\s*[0-9](?:\.[0-9])?|high output|ho|[0-9]{1,2}(?:\.[0-9])?\s*ah|(?:battery|batteries)\s+[0-9]{1,2}(?:\.[0-9])?|[0-9](?:\.[0-9])\s*(?:battery|batteries)|48-11-[0-9]{4}[a-z]?)\b/.test( + text + ); + const hasTool = /\b(drill|driver|impact|saw|grinder|tool|kit|combo|brushless|fuel|hex)\b/.test(text); + const hasCharger = /\b(charger|charging|multi-voltage|rapid charge|rapid charger)\b/.test(text); + const badCondition = /\b(for parts|parts only|dead|untested|not tested|not charging|damaged|core only|as is|as-is|reconditioned|recovered|cells replaced)\b/.test(text); + const aftermarket = /\b(aftermarket|compatible|replacement|generic|knockoff|copy|not oem|for milwaukee|fits milwaukee)\b/.test(text); + const shell = /\b(shell|case|housing|empty|plastic only|no cells)\b/.test(text); + const adapter = /\b(adapter|adaptor|converter|mount|holder)\b/.test(text); + const stockPhoto = /\b(stock photo|stock image|picture for reference|photos? for reference|not actual)\b/.test(text); + const genuineEvidence = /\b(genuine|oem|authentic|redlithium|milwaukee)\b/.test(text) && !aftermarket; + const mixedLot = quantity > 1 || /\b(lot|bundle|set|misc|assorted|multiple)\b/.test(text); + + let batteryKind: BatteryKind = 'unknown'; + if (badCondition) batteryKind = 'parts_or_untested'; + else if (shell) batteryKind = 'empty_shell'; + else if (adapter) batteryKind = 'adapter'; + else if (hasCharger && !hasBatteryPackEvidence) batteryKind = 'charger'; + else if (aftermarket) batteryKind = 'aftermarket_compatible'; + else if (hasTool && hasBatteryTerm) batteryKind = 'tool_bundle'; + else if (mixedLot && hasBatteryTerm) batteryKind = 'mixed_lot'; + else if (platform && capacityAh && hasBatteryTerm && genuineEvidence) batteryKind = 'genuine_oem_battery'; + + if (modelNumber) evidence.push(`model:${modelNumber}`); + if (platform) evidence.push(`platform:${platform}`); + if (capacityAh) evidence.push(`capacity:${capacityAh}Ah`); + if (quantity > 1) evidence.push(`quantity:${quantity}`); + if (genuineEvidence) evidence.push('oem-wording'); + if (stockPhoto) evidence.push('stock-photo-warning'); + + let identityConfidence = 20; + if (modelNumber) identityConfidence += 45; + if (platform) identityConfidence += 15; + if (capacityAh) identityConfidence += 15; + if (hasBatteryTerm) identityConfidence += 10; + if (batteryKind !== 'genuine_oem_battery') identityConfidence -= 20; + if (hasTool || mixedLot || stockPhoto) identityConfidence -= 12; + + let authenticityConfidence = genuineEvidence ? 70 : 35; + if (modelNumber) authenticityConfidence += 10; + if (aftermarket || shell || adapter) authenticityConfidence = 5; + if (stockPhoto) authenticityConfidence -= 25; + + let conditionConfidence = /\b(new|sealed|unused)\b/.test(text) ? 80 : /\b(used)\b/.test(text) ? 45 : 55; + if (badCondition) conditionConfidence = 5; + if (stockPhoto) conditionConfidence -= 20; + + if (photoText) { + const photoModel = extractModel(photoText); + const photoCapacity = extractCapacityAh(photoText); + if (photoModel && modelNumber && photoModel !== modelNumber) { + evidence.push(`photo-title-model-mismatch:${photoModel}`); + identityConfidence -= 35; + } + if (photoCapacity && capacityAh && photoCapacity !== capacityAh) { + evidence.push(`photo-title-capacity-mismatch:${photoCapacity}Ah`); + identityConfidence -= 25; + } + } + + return { + productType: 'milwaukee-battery', + identifiedProduct: + batteryKind === 'genuine_oem_battery' + ? `Milwaukee ${platform ?? 'unknown'} ${capacityAh ?? '?'}Ah battery${quantity > 1 ? ` x${quantity}` : ''}` + : `Milwaukee battery listing classified as ${batteryKind.replace(/_/g, ' ')}`, + modelNumber, + platform, + capacityAh, + quantity, + batteryKind, + identityConfidence: Math.round(clamp(identityConfidence, 0, 100)), + authenticityConfidence: Math.round(clamp(authenticityConfidence, 0, 100)), + conditionConfidence: Math.round(clamp(conditionConfidence, 0, 100)), + evidence, + }; +} + +function genericIdentity(rawListing: MarketplaceRawListing, target: MarketplaceSearchTarget, title: string): MarketplaceIdentity { + const text = `${title} ${rawListing.description ?? ''} ${rawListing.photoText ?? ''}`; + return { + productType: target.lane, + identifiedProduct: title, + quantity: extractQuantity(text), + identityConfidence: hasAny(text, target.requireAny) && hasAll(text, target.requireAll) ? 75 : 40, + authenticityConfidence: 60, + conditionConfidence: /\b(new|sealed|unused|excellent)\b/i.test(text) ? 75 : /\b(used)\b/i.test(text) ? 50 : 60, + evidence: [], + }; +} + +function compMatchesIdentity(comp: MarketplaceSoldComp, identity: MarketplaceIdentity) { + if (comp.isActive) return false; + const title = normalize(comp.title); + if (identity.productType === 'milwaukee-battery') { + if (identity.batteryKind !== 'genuine_oem_battery') return false; + if (identity.modelNumber && !includesTerm(title, identity.modelNumber)) return false; + if (!identity.modelNumber) { + if (!identity.platform || !identity.capacityAh) return false; + if (!includesTerm(title, identity.platform)) return false; + if (!new RegExp(`\\b${identity.capacityAh}(?:\\.0)?\\s*(?:ah|amp)\\b`, 'i').test(title)) return false; + } + if (/\b(aftermarket|compatible|replacement|shell|case|adapter|charger|tool|drill|impact|lot|bundle|for parts|untested)\b/.test(title)) return false; + return true; + } + return true; +} + +function conservativeSoldMedian(rawListing: MarketplaceRawListing, identity: MarketplaceIdentity, fallback: number) { + const comps = (rawListing.soldComps ?? []).filter((comp) => compMatchesIdentity(comp, identity)); + if (comps.length < 3) return { medianSold: fallback, compsUsed: comps, compConfidence: 'insufficient' as const }; + const prices = comps.map((comp) => comp.soldPrice + (comp.shipping ?? 0)); + const center = median(prices) ?? fallback; + const filtered = prices.filter((price) => price >= center * 0.65 && price <= center * 1.35); + const finalPrices = filtered.length >= 3 ? filtered : prices; + return { + medianSold: Math.min(median(finalPrices) ?? fallback, fallback), + compsUsed: comps.filter((comp) => finalPrices.includes(comp.soldPrice + (comp.shipping ?? 0))).slice(0, 8), + compConfidence: finalPrices.length >= 5 ? ('fair' as const) : ('weak' as const), + }; +} + +function valuationFor(rawListing: MarketplaceRawListing, target: MarketplaceSearchTarget, price: number, identity: MarketplaceIdentity): MarketplaceValuation { + const { medianSold, compsUsed } = conservativeSoldMedian(rawListing, identity, target.estimatedResale); + const quantity = identity.productType === 'milwaukee-battery' && identity.batteryKind === 'genuine_oem_battery' ? identity.quantity : 1; + const gross = medianSold * quantity; + const feeRate = (EBAY_FEE_PERCENT + PAYMENT_FEE_PERCENT) / 100; + const fees = gross * feeRate; + const shipping = identity.productType === 'milwaukee-battery' ? DEFAULT_SHIPPING + DEFAULT_PACKAGING : DEFAULT_SHIPPING + DEFAULT_PACKAGING; + const testingRepairReserve = identity.productType === 'milwaukee-battery' ? BATTERY_TESTING_RESERVE * quantity : Math.max(5, gross * 0.03); + const reservePercent = + identity.authenticityConfidence < 60 || identity.conditionConfidence < 55 || identity.identityConfidence < 70 + ? DEFAULT_RISK_RESERVE_PERCENT + 7 + : DEFAULT_RISK_RESERVE_PERCENT; + const riskReserve = gross * (reservePercent / 100); + const net = Math.max(0, gross - fees - shipping - testingRepairReserve - riskReserve); + const profit = net - price; + const roi = price > 0 ? (profit / price) * 100 : 0; + const maximumRecommendedOffer = Math.max(0, net / 1.4); + return { + askingPrice: price, + conservativeMedianSoldValue: medianSold, + expectedGrossResale: gross, + fees, + shipping, + testingRepairReserve, + riskReserve, + estimatedNetProceeds: net, + estimatedProfit: profit, + roiPercent: roi, + maximumRecommendedOffer, + compsUsed, + }; +} + +function recommendationFor(identity: MarketplaceIdentity, valuation: MarketplaceValuation, target: MarketplaceSearchTarget, rawListing: MarketplaceRawListing) { + const warnings: string[] = []; + const reasons: string[] = []; + const minProfit = target.minProfit ?? 25; + const minMargin = target.minMarginPercent ?? 40; + + if (identity.productType === 'milwaukee-battery') { + if ( + identity.batteryKind === 'empty_shell' || + identity.batteryKind === 'adapter' || + identity.batteryKind === 'charger' || + identity.batteryKind === 'parts_or_untested' + ) { + warnings.push(`not-resale-battery:${identity.batteryKind}`); + reasons.push('listing is not a tested genuine battery resale candidate'); + return { recommendation: 'PASS' as const, decision: 'WATCH' as const, warnings, reasons }; + } + if (identity.batteryKind !== 'genuine_oem_battery') warnings.push(`not-oem-battery:${identity.batteryKind ?? 'unknown'}`); + if (!identity.modelNumber && (!identity.platform || !identity.capacityAh)) warnings.push('missing-exact-model-or-capacity'); + if (valuation.compsUsed.length < 3) warnings.push('insufficient-exact-sold-comps'); + if (identity.authenticityConfidence < 70) warnings.push('uncertain-oem-status'); + if (identity.conditionConfidence < 50) warnings.push('uncertain-condition'); + if (identity.identityConfidence < 75) warnings.push('ambiguous-identity'); + } else if (valuation.compsUsed.length < 3) { + warnings.push('insufficient-sold-comps'); + } + + if (rawListing.photoText && identity.evidence.some((item) => item.includes('mismatch'))) warnings.push('visible-model-mismatch'); + if (valuation.askingPrice >= valuation.estimatedNetProceeds) { + reasons.push('asking price is at or above conservative net resale proceeds'); + return { recommendation: 'PASS' as const, decision: 'WATCH' as const, warnings, reasons }; + } + if (valuation.estimatedProfit < 15) { + reasons.push('estimated profit is below $15 pass floor'); + return { recommendation: 'PASS' as const, decision: 'WATCH' as const, warnings, reasons }; + } + if (warnings.length > 0) { + reasons.push('identity, OEM status, condition, or comp support needs manual review'); + return { recommendation: valuation.estimatedProfit >= minProfit ? ('NEGOTIATE' as const) : ('REVIEW' as const), decision: 'NEEDS_REVIEW' as const, warnings, reasons }; + } + if (valuation.estimatedProfit >= 25 && valuation.roiPercent >= 40 && valuation.estimatedProfit >= minProfit && valuation.roiPercent >= minMargin) { + reasons.push('net profit and ROI clear READY_TO_BUY floors after fees, shipping, and reserves'); + return { recommendation: 'READY_TO_BUY' as const, decision: 'READY_TO_BUY' as const, warnings, reasons }; + } + reasons.push('profit exists, but not enough for automatic buy recommendation'); + return { recommendation: 'NEGOTIATE' as const, decision: 'NEEDS_REVIEW' as const, warnings, reasons }; +} + +function scoreFromEvaluation(identity: MarketplaceIdentity, valuation: MarketplaceValuation, recommendation: MarketplaceRecommendation) { + let score = 20; + score += Math.min(30, Math.max(0, valuation.estimatedProfit)); + score += Math.min(20, Math.max(0, valuation.roiPercent) / 3); + score += identity.identityConfidence * 0.15; + score += identity.authenticityConfidence * 0.1; + score += identity.conditionConfidence * 0.1; + if (recommendation === 'READY_TO_BUY') score += 10; + if (recommendation === 'PASS') score -= 30; + return Math.round(clamp(score, 0, 100)); +} + +function hardRejectReason(titleText: string, target: MarketplaceSearchTarget) { + if (target.rejectAny?.some((term) => includesTerm(titleText, term))) return 'reject term'; + if (/\b(scam|deposit|financing|monthly|wanted|iso|looking for|parts only|broken|not working|repair|password locked|icloud locked)\b/.test(titleText)) { + return 'hard risk term'; + } + return undefined; +} + +export function scoreMarketplaceListing(rawListing: MarketplaceRawListing, target: MarketplaceSearchTarget): MarketplaceScoredListing { + const parsed = parseMarketplaceListing(rawListing.text); + const combinedText = normalize(`${parsed.title} ${parsed.raw} ${rawListing.description ?? ''} ${rawListing.photoText ?? ''}`); + const titleText = normalize(parsed.title); + const riskFlags = new Set(); + const minProfit = target.minProfit ?? 25; + const minMargin = target.minMarginPercent ?? 40; + + function rejected(reason: string): MarketplaceScoredListing { + const identity = genericIdentity(rawListing, target, parsed.title); + const valuation = valuationFor(rawListing, target, parsed.price ?? 0, identity); + return { + query: target.query, + lane: target.lane, + title: parsed.title, + price: parsed.price ?? 0, + location: parsed.location, + raw: parsed.raw, + estimatedResale: valuation.expectedGrossResale, + estimatedProfit: valuation.estimatedProfit, + marginPercent: valuation.roiPercent, + score: 0, + decision: 'REJECTED', + recommendation: 'PASS', + identity, + valuation, + plainEnglishReasoning: [reason], + riskFlags: [reason], + warningFlags: [reason], + rejectReason: reason, + href: rawListing.href, + img: rawListing.img, + }; + } + + if (!parsed.price || !parsed.title) return rejected('missing price/title'); + if (!hasAny(combinedText, target.requireAny) || !hasAll(combinedText, target.requireAll)) return rejected('query mismatch'); + const hardReject = hardRejectReason(titleText, target); + if (hardReject) return rejected(hardReject); + if (parsed.price < target.minPrice) return rejected('placeholder price'); + if (parsed.price > target.maxPrice) return rejected('above max price'); + + for (const term of target.riskAny ?? []) if (includesTerm(combinedText, term)) riskFlags.add(term); + if (/\b(ships to you|shipping only)\b/.test(combinedText)) riskFlags.add('shipping'); + if (/\b(firm|trade|make offer|has to go|moving)\b/.test(combinedText)) riskFlags.add('negotiation/liquidity'); + if (/\b(stock photo|picture for reference|not actual)\b/.test(combinedText)) riskFlags.add('stock-photo-risk'); + + const identity = isBatterySearch(target) ? classifyMilwaukeeBattery(rawListing, parsed.title) : genericIdentity(rawListing, target, parsed.title); + const valuation = valuationFor(rawListing, target, parsed.price, identity); + const recommendation = recommendationFor(identity, valuation, target, rawListing); + const score = scoreFromEvaluation(identity, valuation, recommendation.recommendation); + + const decision = + recommendation.decision === 'READY_TO_BUY' && score >= (target.readyScore ?? 75) + ? 'READY_TO_BUY' + : recommendation.decision === 'NEEDS_REVIEW' && score >= (target.reviewScore ?? 58) + ? 'NEEDS_REVIEW' + : recommendation.recommendation === 'PASS' + ? 'WATCH' + : 'NEEDS_REVIEW'; + + return { + query: target.query, + lane: target.lane, + title: parsed.title, + price: parsed.price, + location: parsed.location, + raw: parsed.raw, + estimatedResale: valuation.expectedGrossResale, + estimatedProfit: valuation.estimatedProfit, + marginPercent: valuation.roiPercent, + score, + decision, + recommendation: recommendation.recommendation, + identity, + valuation, + plainEnglishReasoning: [ + `${identity.identifiedProduct} evaluated at $${valuation.estimatedNetProceeds.toFixed(2)} net after fees/shipping/reserves`, + ...recommendation.reasons, + ], + riskFlags: Array.from(new Set([...Array.from(riskFlags), ...recommendation.warnings])), + warningFlags: recommendation.warnings, + href: rawListing.href, + img: rawListing.img, + }; +} + +function dedupeKey(listing: MarketplaceScoredListing) { + const url = listing.href?.split('?')[0]; + if (url) return `url:${url}`; + const title = normalize(listing.title).replace(/\b(new|used|obo|firm)\b/g, '').trim(); + const image = listing.img ? listing.img.split('?')[0].slice(-80) : ''; + return [listing.location ?? '', title, listing.price, listing.identity.modelNumber ?? '', listing.identity.capacityAh ?? '', image].join('|'); +} + +export function scoreMarketplaceListings(rawListings: MarketplaceRawListing[], targets: MarketplaceSearchTarget[]) { + const scored = rawListings.flatMap((listing) => targets.map((target) => scoreMarketplaceListing(listing, target))); + const seen = new Set(); + return scored + .filter((listing) => listing.decision !== 'REJECTED') + .filter((listing) => { + const key = dedupeKey(listing); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort( + (a, b) => + decisionRank(b.decision) - decisionRank(a.decision) || + b.score - a.score || + b.valuation.estimatedProfit - a.valuation.estimatedProfit + ); +} + +function decisionRank(decision: MarketplaceDecision) { + return { REJECTED: 0, WATCH: 1, NEEDS_REVIEW: 2, READY_TO_BUY: 3 }[decision]; +} + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +function fixtureComps(model = '48-11-1850'): MarketplaceSoldComp[] { + return [ + { title: `Milwaukee ${model} M18 XC 5.0Ah Battery Genuine`, soldPrice: 142, shipping: 8, url: 'https://ebay.example/sold-1' }, + { title: `Genuine Milwaukee ${model} REDLITHIUM XC5.0 M18 Battery`, soldPrice: 144, shipping: 7, url: 'https://ebay.example/sold-2' }, + { title: `Milwaukee M18 5.0Ah OEM Battery ${model}`, soldPrice: 138, shipping: 9, url: 'https://ebay.example/sold-3' }, + { title: `Milwaukee ${model} 5.0Ah battery used tested`, soldPrice: 140, shipping: 8, url: 'https://ebay.example/sold-4' }, + { title: 'Milwaukee M18 drill kit with battery active listing', soldPrice: 200, isActive: true, url: 'https://ebay.example/active' }, + { title: 'For Milwaukee M18 compatible 5.0Ah battery replacement', soldPrice: 35, shipping: 6, url: 'https://ebay.example/mismatch' }, + ]; +} + +async function runSelfTest() { + const targets = await loadMarketplaceTargets(); + const byQuery = new Map(targets.map((target) => [target.query, target])); + + const rtx3080 = byQuery.get('rtx 3080'); + const oled = byQuery.get('oled monitor'); + const tool = byQuery.get('milwaukee m18 battery'); + const radar = byQuery.get('uniden r7'); + const macbook = byQuery.get('macbook m1'); + const steamDeck = byQuery.get('steam deck oled'); + const xbox = byQuery.get('xbox series x'); + const elgato = byQuery.get('elgato hd60 x'); + const dji = byQuery.get('dji mini 3 pro'); + const sony = byQuery.get('sony a7 iii'); + if (!rtx3080 || !oled || !tool || !radar || !macbook || !steamDeck || !xbox || !elgato || !dji || !sony) { + throw new Error('Missing expected Marketplace self-test targets'); + } + + assert(scoreMarketplaceListing({ text: '$275 $300 RTX 3080 Springfield, MO' }, rtx3080).decision !== 'REJECTED', 'Expected RTX 3080 candidate'); + assert(scoreMarketplaceListing({ text: '$275 EVGA GeForce RTX 3080 10GB Water Cooled GPU' }, rtx3080).decision === 'REJECTED', 'Expected custom loop RTX 3080 rejected'); + assert(scoreMarketplaceListing({ text: '$100 Acer Nitro 32 Gaming Monitor Jenks, OK' }, oled).decision === 'REJECTED', 'Expected non-OLED rejected'); + assert(scoreMarketplaceListing({ text: '$450 Samsung OLED QHD 360HZ MONITOR (27") Tulsa, OK' }, oled).decision !== 'REJECTED', 'Expected OLED candidate'); + assert(scoreMarketplaceListing({ text: '$250 Uniden r3 Wagoner, OK' }, radar).decision === 'REJECTED', 'Expected Uniden R3 rejected'); + assert(scoreMarketplaceListing({ text: '$350 MacBook Air M1 8GB 256GB Tulsa, OK' }, macbook).decision !== 'REJECTED', 'Expected M1 MacBook candidate'); + assert(scoreMarketplaceListing({ text: '$250 MacBook Pro Intel 2017 Tulsa, OK' }, macbook).decision === 'REJECTED', 'Expected Intel MacBook rejected'); + assert(scoreMarketplaceListing({ text: '$375 Steam Deck OLED 512GB Broken Arrow, OK' }, steamDeck).decision !== 'REJECTED', 'Expected Steam Deck OLED candidate'); + assert(scoreMarketplaceListing({ text: '$220 Xbox Series X with controller Tulsa, OK' }, xbox).decision !== 'REJECTED', 'Expected Xbox Series X candidate'); + assert(scoreMarketplaceListing({ text: '$70 Elgato HD60 X Capture Card Tulsa, OK' }, elgato).decision !== 'REJECTED', 'Expected Elgato HD60 X candidate'); + assert(scoreMarketplaceListing({ text: '$500 DJI Mini 3 Pro drone with controller Tulsa, OK' }, dji).decision !== 'REJECTED', 'Expected DJI Mini 3 Pro candidate'); + assert(scoreMarketplaceListing({ text: '$850 Sony A7 III body only Tulsa, OK' }, sony).decision !== 'REJECTED', 'Expected Sony A7 III candidate'); + + const genuine = scoreMarketplaceListing( + { text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', soldComps: fixtureComps() }, + tool + ); + assert(genuine.recommendation === 'READY_TO_BUY', `Expected genuine battery ready, got ${genuine.recommendation}`); + assert(genuine.identity.modelNumber === '48-11-1850', 'Expected exact Milwaukee model identification'); + assert(genuine.valuation.compsUsed.length >= 3, 'Expected exact sold comps to be stored'); + + const aftermarket = scoreMarketplaceListing({ text: '$40 For Milwaukee M18 compatible replacement 5.0Ah battery Tulsa, OK', soldComps: fixtureComps() }, tool); + assert(aftermarket.recommendation !== 'READY_TO_BUY', 'Expected aftermarket battery not ready'); + assert(aftermarket.riskFlags.some((flag) => flag.includes('aftermarket')), 'Expected aftermarket warning'); + + const shell = scoreMarketplaceListing({ text: '$20 Milwaukee M18 battery shell case housing no cells Tulsa, OK' }, tool); + assert(shell.recommendation === 'PASS', 'Expected empty battery shell pass'); + + const chargerOnly = scoreMarketplaceListing({ text: '$40 Milwaukee M12 M18 rapid charger Tulsa, OK' }, tool); + assert(chargerOnly.recommendation === 'PASS', 'Expected charger-only listing pass'); + + const bundle = scoreMarketplaceListing( + { text: '$70 Milwaukee M18 Brushless Drill with 6.0Ah Battery Glenpool, OK', soldComps: fixtureComps('48-11-1865') }, + tool + ); + assert(bundle.recommendation !== 'READY_TO_BUY', 'Expected tool-and-battery bundle to require review'); + + const twoPack = scoreMarketplaceListing( + { text: '$95 Two pack Milwaukee 48-11-1850 M18 XC 5.0Ah Batteries genuine Tulsa, OK', soldComps: fixtureComps() }, + tool + ); + assert(twoPack.identity.quantity === 2, 'Expected two-battery lot quantity'); + assert(twoPack.recommendation !== 'READY_TO_BUY' || twoPack.valuation.expectedGrossResale <= twoPack.valuation.conservativeMedianSoldValue * 2, 'Expected no quantity inflation beyond two exact units'); + + const untested = scoreMarketplaceListing({ text: '$30 Milwaukee 48-11-1850 M18 battery untested as-is Tulsa, OK' }, tool); + assert(untested.decision === 'REJECTED' || untested.recommendation === 'PASS', 'Expected untested battery rejected/pass'); + + const misleading = scoreMarketplaceListing( + { + text: '$45 Milwaukee 48-11-1850 M18 XC5.0 Battery Tulsa, OK', + photoText: 'photo label 48-11-1820 compact 2.0ah', + soldComps: fixtureComps(), + }, + tool + ); + assert(misleading.recommendation !== 'READY_TO_BUY', 'Expected visible model mismatch to block ready'); + assert(misleading.warningFlags.includes('visible-model-mismatch'), 'Expected visible-model-mismatch warning'); + + const duplicateA = { text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', href: 'https://facebook.example/item/1', soldComps: fixtureComps() }; + const duplicateB = { ...duplicateA, text: '$45 Milwaukee 48-11-1850 M18 XC5.0 Battery New Tulsa, OK' }; + assert(scoreMarketplaceListings([duplicateA, duplicateB], [tool]).length === 1, 'Expected duplicate Facebook listing dedupe'); + + const mismatchedComps = scoreMarketplaceListing( + { + text: '$35 Genuine Milwaukee 48-11-1850 M18 REDLITHIUM XC5.0 Battery New Tulsa, OK', + soldComps: [ + { title: 'Milwaukee M18 drill kit with two batteries', soldPrice: 220, shipping: 20 }, + { title: 'For Milwaukee compatible battery active asking', soldPrice: 35, isActive: true }, + { title: 'Milwaukee charger only', soldPrice: 30, shipping: 8 }, + ], + }, + tool + ); + assert(mismatchedComps.valuation.compsUsed.length === 0, 'Expected mismatched/active eBay comps excluded'); + assert(mismatchedComps.recommendation !== 'READY_TO_BUY', 'Expected insufficient exact sold comps to block ready'); + + console.log('facebook marketplace scorer self-test passed'); +} + +async function main() { + if (SELF_TEST) { + await runSelfTest(); + return; + } + + const targets = await loadMarketplaceTargets(); + console.log(`loaded ${targets.length} facebook marketplace target(s)`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +}