Add CSV price guides and PriceCharting alongside eBay

An eBay production keyset needs account verification, which leaves pricing
blocked on someone else's review queue. These are two routes that are not.

CSV price guide, POST /api/prices/import: no account, works immediately.
Column names are matched by alias, so a PriceCharting bulk export
(product-name / console-name / loose-price) and a hand-kept
title,system,loose,cib,new sheet both parse, along with currency symbols,
thousands separators and blank cells. Rows match on title + system, so the
same game on two consoles is priced separately, and rows for games not in
the library are reported rather than silently added.

PriceCharting adapter: paid, but access is immediate with no review, and it
quotes the same three tiers this app stores, so no inference is needed.
Their API docs are not reachable without an account, so the parser follows
the widely-used convention — integer pennies under hyphenated keys — and is
tolerant enough that a naming difference degrades to "no price" instead of
throwing. One method to adjust if it differs.

Providers are now a registry rather than a single service. /api/prices/status
lists each one with what it is configured for, what its numbers actually
mean, and how to enable it; refresh takes an optional provider name and
falls back to the first configured one. With none configured it answers 503
pointing at the CSV route.

Checked against the real library: a five-row guide in PriceCharting's own
column names priced four games and reported the fifth as not owned, with
each effective value following that copy's condition. Demo figures were
cleared afterwards.

135 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:46:10 -04:00
co-authored by Claude Opus 5
parent ca70bcef34
commit 8e136f42f8
8 changed files with 889 additions and 66 deletions
+38 -29
View File
@@ -216,43 +216,52 @@ keys: `rating`, `value`, `price`, `purchased`.
### Market value
Prices come from **eBay's Browse API**, which needs a free developer account:
register at developer.ebay.com, create a production application keyset, and put
the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`.
Without them the pricing endpoints answer 503 with an explanation and nothing
else is affected.
Three routes, deliberately independent, because each is blocked differently.
| Route | Blocked by | Cost | Basis |
| --- | --- | --- | --- |
| **CSV price guide** | nothing | free | whatever you supply |
| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
| Manual entry | nothing | free | your own judgement |
```
GET /api/prices/status is a provider configured?
POST /api/prices/refresh {dryRun, limit} price some games
GET /api/prices/status which sources are usable
POST /api/prices/refresh {provider, dryRun} price from a live source
POST /api/prices/import (multipart CSV) apply a price list
```
**These are asking prices, not sold prices.** Browse returns active listings.
eBay's completed-sales data lives behind the Marketplace Insights API, which is
a limited release closed to new developers, and PriceCharting — the usual
alternative — requires a paid subscription. Asking prices skew high: sellers
list optimistically and unsold listings linger. Treat the numbers as an upper
bound. The provider name (`ebay-asking`) is stored with every value it writes,
so the source is always visible next to the figure.
**The CSV route needs no account and works today.** Column names are matched by
alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
are `$`, thousands separators and blank cells. Rows are matched on title +
system, so the same game on two consoles is priced separately; rows for games
you do not own are reported rather than added.
Deriving a price from listings takes more than an average:
Free sources that do **not** work for this: eBay's completed-sales data sits
behind the Marketplace Insights API, a limited release closed to new
developers; NEXARDA and CheapShark price current retail and digital
storefronts, not collectibles. Scraping PriceCharting violates their terms.
- **Listings are classified into loose / CIB / new** from the title, because a
feed of mixed conditions has no single price. Accessories are discarded
outright — a "box only" listing at $45 counted as a copy would halve the loose
estimate for a $130 cartridge. So are reproductions and multi-game lots.
- **The qualifier is required when discarding.** An early version matched a bare
"box", which threw away "complete in box" and "with box and manual" — most of
the CIB tier — while keeping the cheap box-only listings the filter existed to
remove. Caught by a test asserting on the tier, not on the count.
- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags
a mean past the point of usefulness; a median ignores them.
- **Sample counts travel with the estimate.** A tier from two listings deserves
less confidence than one from thirty.
Deriving a price from eBay listings takes more than an average:
- **Listings are classified into loose / CIB / new** from their titles, since a
mixed feed has no single price. Accessories are discarded — a "box only"
listing at $45 counted as a copy would halve the loose estimate for a $130
cartridge — as are reproductions and multi-game lots.
- **The discard qualifier is required.** An early version matched a bare "box",
which threw away "complete in box" and "with box and manual" — most of the CIB
tier — while keeping exactly the listings the filter existed to remove. Caught
by a test asserting on tiers rather than counts.
- **Median with an interquartile trim.** One optimist asking 50x moves a mean
and not a median.
- **Sample counts travel with the estimate**, because a tier drawn from two
listings deserves less confidence than one drawn from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches
that copy's condition. Changing a game's condition re-prices it from the stored
tiers without another lookup.
that copy's condition — so changing a condition re-prices it with no further
lookup. Every value carries the source that wrote it and the moment it was
captured.
### Database changes