Skip to content

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 names
  • items — 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 / 100min_spend, parses Pay Now $X,XXX from termspay_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 — Asserts fetch_pricing_json is called with each event's actual venue_id, never the hardcoded VEN1121562.

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 returning table_pricing=None. Asserts pricing is preserved.
  • test_rescrape_does_not_wipe_artist_bio — Same pattern for artist_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).