Ingestion

From auction pages to rows in Postgres.

Every price record passes through validation, bottle normalization, in-run deduplication, and a database write — with Celery beat keeping sources on staggered schedules so one slow spider never blocks the rest.

Four-stage pipeline

1 · ValidateRequired fields, type checks, currency bounds
2 · NormalizeMatch or create bottle, detect category, aliases
3 · DedupeIn-memory keys per scrape run
4 · PersistPostgreSQL prices + bottle stats refresh

Implemented in src/scrapers/pipelines/ — separate item types for auction lots (AuctionLotItem) and retail listings (RetailPriceItem).

Active sources

Schedules defined in src/tasks/celery_app.py. Auction spiders run every 6–8 hours; retail every 12 hours.

SpiderTypeSourceSchedule
whisky_auctioneerAuctionwhiskyauctioneer.comEvery 6h
scotch_whisky_auctionsAuctionscotchwhiskyauctions.comEvery 6h
whisky_auction_ukAuctionwhisky.auctionEvery 8h
whiskyauction_comAuctionwhiskyauction.comEvery 8h
whisky_hunterAPIwhiskyhunter.netDaily
whisky_hammerAuctionwhiskyhammer.comEvery 12h
dekantaRetaildekanta.com (Shopify JSON)Every 12h
whisky_barrelRetailthewhiskybarrel.comEvery 12h
fine_dramsRetailfinedrams.comEvery 12h
cask_cartelRetailcaskcartel.comEvery 12h
bottle_blue_bookValuationbottlebluebook.comDaily

Several sources (Whiskybase, Wine-Searcher, Whiskystats, RW101) were built but disabled when site structures changed — the spider registry makes toggling them straightforward.

Reliability work

Stale-run guards

Celery tasks mark scrape runs in Postgres; zombie runs are cleaned up before new jobs start so workers never double-write.

Source ID index

Database-level dedup on source_id plus an in-memory cache during high-volume runs reduced duplicate inserts.

Memory-safe exports

Pandas OOM'd on 1.3M rows — switched to psql COPY via scripts/export_dataset.sh for publishing.

Crawl scope limits

Retail and valuation spiders cap page depth so long-tail catalog crawls don't starve the worker queue.

Seed data

Three Kaggle datasets bootstrapped the catalog before live scrapers took over:

  • Brand catalog (~4,800 brands) → bottles
  • Cask auction CSV (~562 records) → bottles + prices
  • Market aggregates (~1,719 monthly rows) → market_stats