Table Pricing Extraction¶
VIP table/bottle service pricing for LIV, XS, and TAO Group events.
Three Pricing Sources¶
Vinny supports three venue groups with different pricing architectures:
| Venue | Source | When extracted | Command |
|---|---|---|---|
| LIV Las Vegas / LIV Beach | urvenue AJAX API (HTTP GET) | After scraping, via vinny pricing |
vinny pricing |
| XS Nightclub | uv_tablesitems JS variable embedded in each event page |
During vinny scrape automatically |
vinny scrape |
| TAO Group (Hakkasan, Omnia, Marquee) | urvenue API via booketing.com proxy (HTTP GET) | After scraping, via vinny pricing |
vinny pricing |
All sources populate the same VegasEvent.table_pricing field and appear in vinny tables, vinny deals, and vinny heatmap.
LIV: urvenue API¶
The LIV website uses a WordPress plugin called urvenue to manage table reservations. When you visit the "map" page for an event, JavaScript makes an AJAX call to fetch section and pricing data. We skip the browser entirely and call that API directly.
API Endpoint¶
GET https://www.livnightclub.com/wp-admin/admin-ajax.php
?action=uvpx
&uvaction=uwspx_map
&date={YYYY-MM-DD}
&venuecode={VEN_CODE}
&ecozone=000
&returntempl=1
Parameters:
- venuecode — The venue identifier (e.g. VEN1121561 for LIV nightclub, VEN1214881 for LIV Beach)
- date — Event date in YYYY-MM-DD format
User-Agent header required
The urvenue API returns HTTP 403 without a User-Agent header. No authentication or cookies are needed — just a standard browser UA string.
No authentication or cookies required. A User-Agent header is needed to avoid 403 responses.
Known Venue Codes¶
| Code | Venue | Type |
|---|---|---|
VEN1121561 |
LIV Las Vegas | Nightclub |
VEN1121562 |
LIV Las Vegas (alternate layout) | Nightclub |
VEN1214881 |
LIV Beach | Dayclub |
The venue code comes from the event's embedded JSON (venuecode field), extracted during scraping by LIVExtractor. Each event stores it as venue_id on the VegasEvent model.
API Response Format¶
{
"sections": {
"SEC200001": {"name": "Stage", "id": "200001"},
"SEC200002": {"name": "Dance Floor", "id": "200002"}
},
"items": {
"ITEM_STG": {
"itemname": "Stage",
"listprice": 7000,
"paynow": 9713.33,
"capacity": "8",
"stock": "1",
"totalstock": "2"
}
},
"secitems": {
"SEC200001": ["ITEM_STG"],
"SEC200002": ["ITEM_DF"]
}
}
sections— Room sections with display namesitems— Inventory with pricing (listprice= minimum spend,paynow= deposit/pay-now price)secitems— Maps sections to their available items
Data Model¶
Each section becomes a TablePricingTier:
class TablePricingTier(BaseModel):
name: str # "Stage", "Dance Floor", "Beach Villa", etc.
min_spend: float # Minimum bottle spend
pay_now: float # Deposit/pay-now price
guests: int # Max guests at this table
available: bool # True if stock > 0
Sections vary by venue type:
Nightclub (VEN1121561): Stage, Dance Floor, Center Dance Floor, Center Upper Dance Floor, Upper Dance Floor, Premium Balcony, Balcony Royale, 2nd Row Balcony
Dayclub (VEN1214881): Beach Villa, Dance Floor, Beach Couch, Pool Couch, Lower Club, Center Club, Upper Club, Premium Upper Club, Premium Terrace East/West, Terrace Reserve, Terrace Table, Terrace Daybeds
XS Nightclub: Embedded JS Variable¶
XS Nightclub (Wynn Las Vegas) embeds table pricing directly in each event page as a JavaScript variable — no separate API call needed.
Data Format¶
The pricing is server-rendered in a <script> block:
var uv_tablesitems = {
"i8014202971": {
"name": "Dance Floor VIP",
"capacity": "15",
"baseprice": "700000",
"terms": "<table>...<td>Pay Now</td><td>$1,410</td>...</table>"
},
"i8014202972": { ... }
};
Key fields:
- name — Section name (HTML-escaped; decoded before use)
- capacity — Max guests (string integer)
- baseprice — Minimum spend in cents (700000 = $7,000)
- terms — HTML table containing the "Pay Now" deposit amount
Extraction¶
XSExtractor.extract_table_pricing(soup) in src/extractors/xs.py:
1. Scans all <script> tags for uv_tablesitems
2. Extracts the JSON object with a regex
3. Converts each item: baseprice / 100 → min_spend, parses Pay Now $X,XXX from terms → pay_now
4. Returns a TablePricing with all tiers (all marked available=True)
Since XS has no inventory/stock tracking in this variable, all tiers are always marked available. The pricing reflects the menu; actual availability must be checked via the booking flow.
VenueExtractor Protocol¶
Any extractor can implement embedded pricing by overriding extract_table_pricing():
class VenueExtractor:
def extract_table_pricing(self, soup: BeautifulSoup) -> TablePricing | None:
"""Return pricing from page HTML, or None if not available."""
return None # default — no embedded pricing
crawlee_main.py calls both extract() and extract_table_pricing() for every event page. When pricing is returned, it is attached to the event via model_copy(update={"table_pricing": pricing}) before the event is saved.
TAO Group: booketing.com Proxy (urvenue)¶
TAO Group venues (Hakkasan, Omnia, Marquee) use the same urvenue protocol as LIV, but routed through a booketing.com proxy instead of the LIV WordPress admin-ajax endpoint.
API Endpoint¶
GET https://booketing.com/uws/house/proxy
?action=uvpx
&uvaction=uwspx_map
&date={YYYY-MM-DD}
&venuecode={VEN_CODE}
&manageentid=61
&ecozone=000
&returntempl=1
The only differences from the LIV endpoint:
- Base URL: booketing.com/uws/house/proxy instead of livnightclub.com/wp-admin/admin-ajax.php
- Extra param: manageentid=61 (identifies TAO Group as the management entity)
Known Venue Codes¶
| Code | Venue | Type |
|---|---|---|
VEN1085 |
Hakkasan Nightclub | Nightclub |
VEN1089 |
Omnia Nightclub | Nightclub |
VEN1108 |
Marquee Nightclub | Nightclub |
Venue codes are extracted from the booketing.com reservation links embedded in each taogroup.com event page. The extractor maps venue name → VEN code in _VENUE_CODES (src/extractors/tao.py).
Routing Logic¶
_build_pricing_url() in src/pricing.py automatically routes TAO venues through the booketing proxy:
_TAO_VENUE_CODES = {"VEN1085", "VEN1089", "VEN1108"}
def _build_pricing_url(venue_id, event_date):
is_tao = venue_id in _TAO_VENUE_CODES
base = BOOKETING_PROXY_BASE if is_tao else API_BASE
params = {"action": "uvpx", "uvaction": "uwspx_map", ...}
if is_tao:
params["manageentid"] = "61"
return f"{base}?{urlencode(params)}"
The response format is identical to LIV — same sections, items, secitems structure. parse_pricing_data() handles both without any TAO-specific logic.
Pricing Routing¶
flowchart TD
A["VegasEvent\nwith venue_id"] --> B{"_build_pricing_url()"}
B --> C{"venue_id in\n_TAO_VENUE_CODES?"}
C -->|"VEN1085, VEN1089, VEN1108"| D["booketing.com/uws/house/proxy\n+ manageentid=61"]
C -->|"VEN1121561, VEN1214881, ..."| E["livnightclub.com/wp-admin/\nadmin-ajax.php"]
D --> F["Same urvenue response format"]
E --> F
F --> G["parse_pricing_data()\n→ TablePricing"]
G --> H["event.table_pricing\n(attached via model_copy)"]
style A fill:#7c3aed,color:#fff
style D fill:#d97706,color:#fff
style E fill:#059669,color:#fff
style H fill:#7c3aed,color:#fff
Architecture¶
src/pricing.py — enrichment (HTTP fetch + parse)
├── _build_pricing_url(venue_id, date) → URL string
├── fetch_pricing_json(venue_id, date) → dict | None (HTTP GET)
├── parse_pricing_data(api_data) → TablePricing | None (pure function)
└── enrich_events_with_pricing(events) → list[VegasEvent] (orchestrator)
src/pricing_views.py — data layer for CLI/API consumers
├── TableRow — flat, per-tier row with computed ppg / deposit_ratio
├── flatten_pricing() — VegasEvent list → list[TableRow]
├── filter_tables() — filter by date, venue, budget, ppg_max, guests
├── sort_tables() — sort by ppg, price, date, or venue
└── get_tonight() / get_this_weekend()
src/cli_tables.py — Rich terminal rendering + Cyclopts command handlers
├── render_tables() — color-coded Rich table
├── render_deals() — card-style best-deals list
├── render_heatmap() — venue x date grid
└── cmd_tables / cmd_deals / cmd_heatmap
The enrichment pipeline:
1. Filter events to those with venue_id starting with VEN and a valid event_date
2. Call fetch_pricing_json(venue_id, event_date) for each (concurrent, via thread pool)
3. Parse the JSON response into TablePricing with parse_pricing_data()
4. Attach to event.table_pricing
CLI Usage¶
Enrichment (scraping + pricing)¶
# Scrape with pricing enrichment
uv run vinny scrape --with-pricing
# Enrich an existing run
uv run vinny pricing --run latest
uv run vinny pricing --run 2026-02-27-192047 --concurrency 5
Browsing pricing data¶
Once pricing is in the master database (or a run), use these commands to explore it:
# Tonight's tables, sorted by $/guest (best value first)
vinny tables
# This weekend, filter by budget and party size
vinny tables --weekend --budget 5000 --guests 6
# Next N days with a $/guest cap
vinny tables --days 14 --ppg-max 300
# Include sold-out tables
vinny tables --all
# Sort by date instead of $/guest
vinny tables --sort date
# Filter by venue (partial, case-insensitive)
vinny tables --venue "beach"
# Top 10 best deals tonight (card layout)
vinny deals
# Top 5 deals this weekend
vinny deals --weekend --top 5
# Venue x date heatmap (next 2 weeks by default)
vinny heatmap
vinny heatmap --weeks 4
# All commands accept --run to read from a specific scrape run
vinny tables --run 2026-03-01-143022
Color tiers (applied to $/guest): - Green — ≤ $150/guest - Yellow — $151–$350/guest - Red — $350+/guest
Never hardcode VEN1121562
The map page at /las-vegas/map/?eid= hardcodes VEN1121562 in its JavaScript regardless of which event is loaded. Always call the API directly with venuecode={event.venue_id} — never scrape the map page. See the regression tests below.
The VEN1121562 Bug¶
The original implementation used Playwright to load the map page at /las-vegas/map/?eid={event_id}. That page's HTML hardcodes VEN1121562 in the JavaScript regardless of which event ID is in the URL. This meant:
- Every event returned the same room layout (the VEN1121562 layout)
- Nightclub events got wrong sections (Front Stage, Back Stage, Sky Box instead of Stage, Dance Floor, Center Dance Floor)
- Dayclub events got nightclub sections instead of Beach Villa, Pool Couch, etc.
- All events had identical pricing data
The fix: call the API directly with venuecode={event.venue_id} instead of loading the map page. This also eliminated the Playwright dependency for pricing, making it ~10x faster (plain HTTP GET vs headless browser).
Regression Tests¶
tests/test_pricing.py guards against this bug:
TestMatrodaPricingMatchesScreenshot— Fixtures match the exact values from the LIV website for Matroda (Feb 27, 2026). Asserts correct nightclub sections and rejects the old VEN1121562 sections.TestDayclubPricing— Verifies LIV Beach has dayclub-specific sections (Beach Villa, Pool Couch) that differ from the nightclub layout.test_different_venues_get_different_pricing— Enriches nightclub + dayclub events with venue-specific mocks, asserts they produce different pricing.test_passes_each_events_own_venue_id— Assertsfetch_pricing_jsonis called with each event's actualvenue_id, never the hardcodedVEN1121562.
Enrichment Preservation¶
Vinny uses a cumulative enrichment model: data added by one pipeline step is never erased by another.
The Problem¶
Multiple independent operations populate different fields on the same event:
| Operation | Populates |
|---|---|
vinny scrape (LIV) |
core event fields (performer, date, time, etc.) |
vinny scrape (XS) |
core fields + table_pricing (embedded) |
vinny scrape (TAO) |
core fields (pricing requires separate vinny pricing step) |
vinny pricing |
table_pricing (LIV + TAO via urvenue API) |
| artist enrichment | artist_bio, streaming_links, artist_image_url |
Without special handling, a plain re-scrape returns table_pricing=None for LIV events (which have no embedded pricing). If the master DB merge blindly overwrote with incoming data, previously-enriched pricing would be silently wiped.
The Fix: Non-Destructive Merge¶
MasterDatabase.add_or_update_event() in src/models/__init__.py uses this merge rule:
merged = {
**old_data,
**{k: v for k, v in new_data.items() if v is not None or old_data.get(k) is None},
}
In plain English: a new value only wins if it is non-None, or if there was no prior value. A None from the scraper never overwrites a real value from a previous enrichment step.
This applies to all enriched fields:
- table_pricing — never wiped by a re-scrape that returns None
- artist_bio — never wiped by a re-scrape
- streaming_links, artist_image_url — same rule
- Core scraped fields (event_time, performer, description, etc.) — still update normally when the scraper returns a new non-None value
Regression Tests¶
tests/test_models.py::TestMasterDatabase covers the merge behavior:
test_rescrape_does_not_wipe_table_pricing— Enriches an event with pricing, then simulates a re-scrape returningtable_pricing=None. Asserts pricing is preserved.test_rescrape_does_not_wipe_artist_bio— Same pattern forartist_bio.test_update_can_change_scraped_field— Verifies that scraped fields (e.g.event_time) still update when the scraper returns a new value.
CSV Output¶
Table pricing is flattened into per-section columns:
pricing_stage_min_spend, pricing_stage_pay_now, pricing_stage_guests, pricing_stage_available
pricing_dance_floor_min_spend, pricing_dance_floor_pay_now, ...
pricing_beach_villa_min_spend, pricing_beach_villa_pay_now, ...
Column names are derived from the section name (lowercased, spaces replaced with underscores).