cleanup_stale_batches and force_reset_scraper were leaving hotels_found
and rates_scraped at 0 because update_scrape_batch never ran — the
container was killed before it could. Now we subquery booking_com_rates
by scrape_batch_id to show what was actually saved before the interruption.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DISTINCT ON tiebreaker was undefined when scraper writes multiple room
types in one batch (same scraped_at); adding rate_gross ASC ensures we
always pick the cheapest (lead-in / best available) rate, matching like
for like against the Newbook BAR tariff
- Past-date active alerts were never touched (start = today meant they
fell outside the query window); now resolved at the top of each run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SQLAlchemy's text() parser treated the ':' in '::date' (PostgreSQL cast syntax)
as a second unbound parameter after :start/:end, producing malformed SQL and
crashing the scheduled Booking.com scrape silently with no history entry written.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Original scrapy approach: extract <li> condition lines from each rate plan row
and text-search for known keywords. No match = null (unknown), not assumed false.
Adds breakfast_text, cancel_text, payment_text columns to booking_com_rates.
Booleans now nullable (null = not mentioned, true/false = explicit signal).
JS extraction searches all <li> items in the row (falls back to newline-split
innerText if none). Breakfast: 'breakfast' keyword. Cancel: 'free cancellation',
'non-refundable', 'total cost to cancel', 'fully chargeable'. Payment: 'no
prepayment', 'pay at the property', 'pay online'. data-fltrs used as fallback.
API snapshot endpoint now returns breakfast/cancel/payment text strings. Old
boolean-only rows degrade gracefully to derived labels.
Modal plan rows replace the fixed meal|cancel|price column layout with a single
stacked conditions cell: 0-3 lines depending on what the page actually shows.
Breakfast green when 'included', cancel green when 'Free cancellation…'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Breakfast was keyed off cells[2] specifically; hotel page templates vary and
the conditions column can appear at a different index, causing breakfast_included
to always be false for own/competitor rates. Now scans full row innerText and
also checks data-fltrs.mealplan/breakfast_included as a secondary signal.
Free cancellation extraction similarly updated to search all cells rather than
assuming a fixed column.
Also ships the update-available banner (polls /health every 2 min, prompts
reload when the version hash changes after a deploy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three read-only endpoints under /reporting/:
- /hotels hotel dimension table (tier, stars, review score)
- /rates full rates fact — all sources, room types, scrape history in one flat table
- /occupancy Newbook occupancy per date × room category
Rates UNION covers Booking.com scrapes, direct competitor engines (with
configured room/rate labels), and own hotel Newbook headline rates.
Authenticated via X-API-Key header; key stored in system_config.reporting_api_key.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Medium and low priority tiers were generating full static date ranges
(150 and 185 dates) every day. With a queue limit of 200, high (31) +
medium (150) consumed the entire budget, leaving only ~19 slots for low
priority — causing the observed ~6 month cap.
Medium now selects the 60 oldest-scraped (or never-scraped) dates from
the days 31-180 window; low selects the 30 oldest from days 181-365.
Per-run budget drops from ~365 to ~121 dates, and coverage naturally
cycles through the full year: medium every ~2-3 days, low every ~6-7 days.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The management monitor checks http://{host}:{port}/health (no slug prefix).
Adds a location = /health alias alongside the existing /rates/health
so both the slug URL (browser/NPM) and the bare path (monitor) work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both Plotly charts in OccHistoryModal now share the same xaxis range,
computed as the union of valid_from timestamps across both datasets so
rate movements and occupancy pick-up align on the same time axis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove shape:'hv' step interpolation from rate and occupancy traces —
defaults to diagonal lines matching the market view history graphs.
Also fixes CSS var() used as a Plotly colour (invalid, replaced with hex).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Apply 45% opacity to all column cells where stay_date < today —
date header, category fill, occupancy, and all tariff cells —
matching the same visual treatment as the Direct Rates table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PWA exact-match location blocks had no root directive so try_files $uri
resolved against nginx's default /etc/nginx/html instead of
/usr/share/nginx/html. Moving root to server level fixes all three
PWA blocks (registerSW.js, sw.js, manifest.webmanifest).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Schema: migrate newbook_occupancy_report_data from single-row upsert to
snapshot model (drop unique constraint, add valid_from / last_verified_at)
matching the pattern used by newbook_current_rates.
Backend: sync_occupancy now inserts a new row only when occupied/available/
maintenance figures change, otherwise bumps last_verified_at. New endpoint
GET /bookability/occupancy-history/{category_id}/{date} returns the timeline.
Rate matrix query updated to DISTINCT ON for the multi-row table.
Frontend: clicking any cell in the Bookability matrix opens a modal with
two stacked Plotly charts — rate history per tariff (step lines, green/red
markers for available/unavailable) and occupancy pick-up over time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Apply 45% opacity to rows where stay_date < today so past dates are
visually distinguished while preserving the red/yellow/green availability
colour semantics. Expansion rows carry the same opacity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Eviivo: no-dates page fetch returns all room types with data-item-name and
data-rate-plan-name; single request populates the full name catalogue
- QBook: item_name already in /api/pull response; extracted on a dummy date
call; no distinct rate plan names exposed so rate_labels left empty
- Mews: getCalendarData (bookingEngineId field) returns resourceCategories[].name
and rates[].name as {en-US: ...} dicts; both room and rate labels populated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A new toolbar row on the Rate Matrix lets users pick a reference datetime
(defaults to 24h ago, with 24h/3d/7d presets) and see ▲/▼ triangles
next to each competitor's BAR where the rate has moved ≥50p since then.
Two backend endpoints:
- GET /competitors/rate-changes?since= — static datetime comparison
- GET /competitors/rate-changes-vs-own — dynamic: diffs against the
timestamp our own Newbook rate last changed per date (highlights
competitor moves made in response to our own pricing decisions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Some DataImpulse residential IPs drop Chromium's TLS 1.3 GREASE/key_share
extensions mid-handshake (EOF error), while TLS 1.2 negotiation succeeds.
Booking.com supports TLS 1.2 so this should not affect scrape results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DataImpulse has two broken gateway nodes (.89, .97) and one working (.105).
Production DNS round-robin consistently landed on broken nodes. Fix is to
pin the proxy host to 67.213.121.105 in system_config. Revert to Chromium.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Booking.com detects headless Chromium via TLS ClientHello fingerprint (JA3)
and immediately flags the residential proxy IP on first contact. Firefox has
a different TLS fingerprint and GREASE extensions that aren't yet flagged.
Image blocking via Firefox preference (permissions.default.image=2) replaces
the Chrome --blink-settings flag. Dockerfile updated to install both browsers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
context.route() (request interception) breaks URL-embedded proxy auth in
Chromium — DataImpulse proactive CONNECT auth stops working when interception
is active. Replace route handler with --blink-settings=imagesEnabled=false
Chrome flag to block images without touching the interception layer.
Restore DataImpulse URL-embedded credentials (unchanged from before IPRoyal work).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
URL-embedded proxy credentials are dropped by Chromium when request
interception (context.route) is active. Both providers now use separate
username/password fields; DataImpulse incurs a 14s 407 round-trip on
context rotation but this only happens every 40 requests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DataImpulse username contains semicolons (;sessid.ID) that weren't being
URL-encoded in httpx_proxy_url, causing silent parse failures. Also use
httpx.Proxy object instead of raw string (consistent with httpx_proxy()),
and capture repr(e) so empty-message exceptions show their type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Separate verified_at (last checked) from valid_from (last rate change)
in the matrix response. Column headers show when rates were last checked;
hovering shows both "Checked: X" and "Changed: Y" so users can distinguish
a manual refresh that verified unchanged rates from one that found new prices.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cf.bstatic.com (images + CSS CDN) was consuming 1.5 GB per scrape run.
Added route interception to abort image, stylesheet, font, and media
resources, plus pure tracking domains. The DOM extraction only reads
HTML attributes — no visual resources are needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch date_last_updated from valid_from (rate change time) to
last_verified_at (last check time). All dates verified in the same daily
run now show a consistent timestamp rather than varying by when rates
last changed. Also commit per-date instead of batching 10 days — prevents
a single API error from rolling back up to 9 preceding committed dates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
URL-embedded credentials are silently dropped by Chromium for IPRoyal
(proxy auth never sent → every page.goto times out). Separate username/
password fields work correctly — IPRoyal responds to the 407 challenge
quickly so there is no latency penalty unlike DataImpulse (~14s).
DataImpulse keeps URL-embedded credentials to avoid that round-trip.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Auto-detect provider from hostname. DataImpulse appends session/country to
the username (LOGIN__cr.gb;sessid.ID); IPRoyal appends them to the password
(PASS_country-gb_session-ID_lifetime-30m). Both providers use the same
host/port/username/password/country config fields in Settings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DataImpulse endpoints are intermittently broken — some return EOF after
CONNECT immediately, others work fine. DNS round-robins between them so
the same request can fail 3× then succeed on the 4th. Timeouts were
returning blocked=False and silently failing with no retry. Now treated
as retryable blocks (with context rotation) so a fresh endpoint is tried.
Max retries 2→4 (5 total attempts) to give enough chances to land a
working DataImpulse endpoint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Header-based auth made no difference; the real issue is the hotel network
firewall blocking HTTPS CONNECT tunnels on port 823. Reverted to URL-embedded
credentials (original approach). Fix requires DataImpulse to enable port 443.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Embedding credentials in the proxy URL (http://user:pass@host:port) was
breaking HTTPS CONNECT tunnels on the hotel network. Switching to separate
username/password fields (Playwright) and httpx.Proxy(auth=...) sends a
Proxy-Authorization header instead, which passes through correctly.
With DataImpulse IP whitelisting the 407 round-trip is skipped anyway so
there is no latency penalty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
asyncio.gather iterated undefined `shards` — should be `date_shards`
(variable renamed when switching from hotel-first to date-first sharding).
Caused every hotel-page scrape to immediately fail with NameError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Workers now own a slice of dates and scrape all hotels per date before
advancing. A block or interruption leaves complete dates rather than
some hotels fully done and others not started.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old Stocks block_id has 0 in position 2 (not the persons count) so all
its rates were stored as max_persons=0 and deprioritised in the matrix.
Now reads max_persons from the visible 'Max persons: N' span (matching
the original scrapy spider), with aria-label/title fallback. block_id
segment 2 is unreliable across different hotel layouts.
Also treat max_persons=0 as unknown in the matrix ORDER BY priority.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add UA rotation (8 real Chrome UAs), random viewport, en-GB locale,
Europe/London timezone, stealth browser args to suppress automation
signals, and a per-context init script masking navigator.webdriver,
plugins, and chrome runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous approach walked siblings from [id^="room_type_id_"] anchors
and only processed rows with js-rt-block-row class, causing the first rate
row of some room types to be silently skipped when that class was absent.
Now iterates all #available_rooms tbody tr rows with data-block-id (same
strategy as the original scrapy spider), using the room_type_id_ element
presence to identify room names only on the first row, with fallback to the
stored name for subsequent rows of the same room type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Include tier=own in get_active_hotels() so the own hotel Booking.com
listing is scraped alongside competitors. Change matrix max_persons
filter from hard WHERE to ORDER BY priority so hotels with only
1-person rates (e.g. Old Stocks) still show up rather than being
silently excluded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_proxy_enabled() was checking cfg.get('server') but load_config() returns
{host, port, username, ...} — no 'server' key. Server URL is built by
proxy_util.playwright_proxy(). This meant proxy was silently disabled
and _proxy_kwargs() would also KeyError if called.
- _proxy_enabled() now delegates to proxy_util.is_enabled() (checks host+username)
- _proxy_kwargs() now calls proxy_util.playwright_proxy() with credentials
embedded in the URL (avoids 14s DataImpulse 407 round-trip)
- _get_context() generates a sticky session_id per context so each worker
gets a distinct residential IP lane
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The proxy-off → serial guard was correct for search-results (all workers
hit the same URL), but hotel-page workers each scrape a different hotel's
property page — parallel requests look like multi-tab browsing, not a
hammered aggregation endpoint. Added require_proxy param to
_effective_concurrency; hotel-page path passes require_proxy=False.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Scraping a past date via the hotel-page backend wouldn't return anything
useful (Booking.com shows no availability for dates gone by). The ↻
button is now only rendered for today and future dates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Scraper: restrict hotel-page scraper to 'competitor' tier only (was own+competitor); own hotel rates come from Newbook API, not Booking.com
- Backend: POST /competitors/discover — runs search-results scrape for one date to find market hotels regardless of current backend setting
- Backend: POST /competitors/hotels — add hotel manually from Booking.com URL + name + tier; upserts on slug conflict
- Frontend (Settings tab): Discover Market Hotels button added below manual date-range scrape
- Frontend (Hotels tab): Add Hotel form at top — paste URL (auto-derives name from slug), choose tier, submit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Market-tier hotels were auto-discovered from search results and don't
need room-level rate tracking — scraping all 25+ of them per date was
unnecessary. Only 'own' and 'competitor' hotels are now scraped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sold out + last price: show SOLD label above strikethrough price; remove price-index badge (rate no longer bookable, delta misleading)
- Sold out, no history: unchanged — shows 'Sold'
- Past days: price-index badge now grey/faded (#94a3b8 on #e2e8f0) instead of coloured — historical context only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>