Commit graph

110 commits

Author SHA1 Message Date
312a14b80f Add live progress tracking to scrape history
Backend: atomic += increments to hotels_found, rates_scraped,
dates_completed, dates_failed after each date in both worker types
(search-results and hotel-page). Safe for concurrent workers because
PostgreSQL evaluates x = x + delta atomically per statement.
If the container is killed mid-run, the counts already reflect what
was done rather than zeroing out.

Frontend: new Progress column showing X/Y (NN%) of dates completed;
highlighted in gold while the batch is running. scrape-history query
auto-refetches every 5s whenever a running entry is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 09:09:46 +00:00
1acc817999 Back-fill hotels/rates counts when marking interrupted scrape batches
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>
2026-07-15 09:05:20 +00:00
9fa4c15081 Fix parity check: always compare lead-in rate + auto-resolve past alerts
- 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>
2026-07-15 08:59:14 +00:00
4b5b8b30ed Fix SQL syntax error in generate_series date cast (SQLAlchemy text() parsing)
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>
2026-07-15 08:42:15 +00:00
38b0c36923 Use text-search approach for rate conditions (breakfast/cancel/payment)
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>
2026-07-14 18:10:00 +00:00
69ecf1012e Fix breakfast detection on hotel-page scraper + add update banner
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>
2026-07-14 17:49:27 +00:00
e481ad5258 Add Power BI reporting endpoints — flat tabular data via API key auth
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>
2026-07-14 17:27:50 +00:00
d5ce38859d Fix booking scrape date selection — oldest-first tiered distribution
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>
2026-07-14 09:39:35 +00:00
9a0dc58b11 Add bare /health location to nginx for management monitor compatibility
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>
2026-07-13 17:07:35 +00:00
59984fc0ac Configurable inactivity timeout — disabled automatically in PWA/standalone mode 2026-07-13 12:29:02 +00:00
071ea9bfc3 Add shared device inactivity timeout to AuthGate 2026-07-13 11:53:06 +00:00
516f774223 Sync x-axis range between rate and occupancy charts in history popup
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>
2026-07-12 13:52:29 +00:00
60fdda8129 Use linear interpolation in bookability history charts
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>
2026-07-12 13:42:36 +00:00
808fa6f8b2 Fade past dates in Bookability matrix
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>
2026-07-12 13:36:29 +00:00
c63f8c5c4d Fix registerSW.js 404 — add root to server block
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>
2026-07-12 13:23:38 +00:00
2743b0e877 Add occupancy pick-up history + bookability history popup
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>
2026-07-12 12:38:02 +00:00
aef7d755f1 Fade past dates in Direct Rates hotel detail view
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>
2026-07-12 12:24:56 +00:00
960606cf6e Replace letter icons with Lucide PWA icons 2026-07-11 20:30:12 +00:00
4eea566799 Make rates installable as a PWA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 11:27:00 +00:00
b90f6bcbd6 Default changed-since to enabled (24h) on page load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 09:12:58 +00:00
68e8a6883b Add fetch_category_names to Eviivo, QBook and Mews profiles
- 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>
2026-07-10 13:00:19 +00:00
c2e1a9703a Add Eviivo, QBook and Mews direct scrape profiles
Three new engine profiles for competitor direct-rate monitoring:
- eviivo: HTML scrape via httpx (no JSON API); presence = available, no room count
- qbook: QBook/Hotels UK JSON API; availableroom count, skip roomprice=0 (unconfigured rate)
- mews: Mews Booking Engine v1 API (unauthenticated); getAvailability + getPricing; proper net/gross split; timezone-aware per property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 12:11:37 +00:00
d9f9a9a5f0 Fix mid-session 401 redirecting iframe to wrong URL, causing portal EmbeddedFallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:43:57 +00:00
e355833f1c Add "Changed Since" rate movement indicators to Market View matrix
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>
2026-07-10 11:19:34 +00:00
8ec066caa8 Revert TLS 1.2 flag — DataImpulse incompatible with Playwright on both envs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:07:45 +00:00
46eada16a3 Try forcing TLS 1.2 max to fix DataImpulse residential IP handshake failures
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>
2026-07-10 10:48:56 +00:00
ef7c4790ca Revert Firefox: root cause was broken DataImpulse nodes not fingerprinting
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>
2026-07-10 09:54:50 +00:00
12037dc3f7 Switch Playwright to Firefox to evade Booking.com Chromium JA3 fingerprint detection
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>
2026-07-10 09:42:05 +00:00
d28c738475 Block images via Chrome flag, not route interception; restore DataImpulse URL auth
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>
2026-07-10 09:14:36 +00:00
d223a449c3 Fix DataImpulse Playwright auth: use separate fields not URL-embedded
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>
2026-07-10 09:11:31 +00:00
f6d5fb27e1 Fix proxy test: URL-encode httpx credentials, use Proxy object, improve error detail
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>
2026-07-10 08:57:25 +00:00
ed0a027448 Add 'last changed' tooltip to bookability date headers
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>
2026-07-10 08:44:29 +00:00
418e0d2fcc Block images/CSS/fonts in Playwright to cut ~75% bandwidth
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>
2026-07-10 08:42:08 +00:00
d72f14ae49 Fix bookability column timestamps showing stale 'last changed' dates
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>
2026-07-10 08:41:58 +00:00
218589afbe Fix IPRoyal Playwright auth: use separate fields not URL-embedded
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>
2026-07-10 02:55:13 +00:00
2b55b0d6a5 Add IPRoyal proxy support alongside DataImpulse
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>
2026-07-10 02:37:53 +00:00
b44e688b3e Retry page load timeouts: treat as blocks, bump max retries to 4
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>
2026-07-10 02:19:16 +00:00
28c4dd9f54 Revert proxy auth change — URL-embedded credentials are correct
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>
2026-07-10 02:16:04 +00:00
940ffbbe39 Switch proxy auth from URL-embedded to Proxy-Authorization header
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>
2026-07-10 02:13:06 +00:00
3b931d4915 Fix NameError in date-first sharding: shards → date_shards
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>
2026-07-10 01:42:27 +00:00
effa982a8e Switch scrape sharding from hotel-first to date-first
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>
2026-07-10 01:34:46 +00:00
8ccc0e272d Fix max_persons extraction: read from span text not block_id segment 2
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>
2026-07-10 01:22:51 +00:00
52455328e7 Randomise browser fingerprint per context to evade bot detection
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>
2026-07-10 01:08:49 +00:00
83b46bee8f Fix JS extractor under-counting rooms: iterate all tbody rows like scrapy
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>
2026-07-10 00:37:35 +00:00
dd84ca230a Fix own hotel excluded from scrape and 1-person rates hidden in matrix
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>
2026-07-10 00:31:08 +00:00
ca76bc2f90 Fix proxy never being used in hotel-page scraper
_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>
2026-07-10 00:21:42 +00:00
1b985e5a16 Allow parallel workers for hotel-page scraper without proxy
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>
2026-07-10 00:18:56 +00:00
0721769e03 Hide per-date rescrape button for past columns
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>
2026-07-10 00:13:38 +00:00
c49985bec7 Add hotel discovery scrape, manual hotel add, and competitor-only filtering
- 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>
2026-07-10 00:08:58 +00:00
30583c59a4 Restrict hotel-page scraper to own + competitor tiers only
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>
2026-07-10 00:03:17 +00:00