Commit graph

118 commits

Author SHA1 Message Date
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
e472ba65c4 Improve sold-out and past-day cell display in rate matrix
- 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>
2026-07-09 23:56:40 +00:00
d97bfdab90 Fix Plotly namelength type cast 2026-07-09 23:50:07 +00:00
dbcc15661d Fix max_persons extraction and legend truncation in rate modal
- Scraper: extract max_persons from block_id (3rd segment) instead of cell text; cell[0] was picking up the price value (e.g. 189/166) causing the max_persons=2 filter to exclude all scraped rows and show "No rates available" in snapshot
- MarketView: add namelength: -1 to Plotly legend to prevent label truncation; increase bottom margin from 50→65 and legend y from -0.25→-0.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:49:18 +00:00
121976b3db Fix Plotly £ format warning and add scraper backend selector to Settings
- MarketView: split tickformat '£,.0f' into tickprefix+'£' + tickformat ',.0f' (Plotly d3 format doesn't accept £ prefix inline)
- Settings System tab: add editable dropdown for booking_scraper_backend (hotel page vs search results) with description of the tradeoff; backend was previously read-only in the table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:45:33 +00:00
f2e11bf585 Use qty dropdown for availability count, not just scarcity text
The 'We have X left' scarcity indicator only appears when availability is
low (typically ≤5). The quantity <select> dropdown in the booking form
always shows 0..N where N = actual available qty (capped at 10).

Extract max option value from the <select> in the last table cell per rate
plan row, falling back to regex parsing of the cell text if the select
isn't rendered, then to the scarcity text as a last resort.

Stored in both rooms_left and available_qty on RateData.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:20:15 +00:00
5462773dd7 Add rate snapshot to modal and room-type history chart improvements
Modal now shows two sections:
- Current availability: all room types with rooms-left count, then each rate
  plan (meal plan × cancel policy × price) from the latest scrape batch
- Rate history chart: one line per room type, cheapest 2-adult rate per
  scrape run (max_persons filter added to exclude 1-adult variants)

New endpoint: GET /competitors/hotels/{id}/rate-snapshot/{date}
Returns all rate plan variants grouped by room type from the latest batch.
Handles legacy single-row data (pre hotel-page scraper) gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:17:00 +00:00
bb37fcf501 Add hotel-page scraper backend with full room/rate plan extraction
Switches booking.com scraping from search-results page (Cloudflare-targeted)
to individual hotel property pages (not CF-protected). Each page load returns
all room types, all rate plan variants (room-only/B&B × refundable/non-ref ×
1-2 adults), and availability counts.

Key changes:
- New PlaywrightHotelPageBackend: proxy reuse until block, rotate on CF/WAF
- booking_scraper.py: _run_hotel_page_scrape(), scrape_hotel_date(),
  _scrape_hotels_concurrent() — sharded by hotel so one proxy session covers
  all dates for one hotel (looks human)
- schema.sql: ADD COLUMN rate_plan_id, max_persons on booking_com_rates
- competitors API: filter max_persons=2, order by rate_gross ASC as tiebreaker
  so DISTINCT ON returns cheapest 2-adult rate from latest batch

Enable via Settings → Scraper Backend → playwright_hotel_page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:08:03 +00:00
b10b2f5990 Fix page.content() blocking ~2 minutes after goto timeout on Cloudflare hits
When page.goto() times out while Cloudflare's JS challenge redirect is in
progress, page.content() silently blocks until that navigation completes
before raising — adding ~1m46s of hidden delay per blocked-page attempt.

Fix: skip page.content() entirely when page_loaded=False (goto already
timed out), setting content="" so block detection treats it as no signal.

Also break out of the page loop early when page 1 failed to load with 0
hotels — no point spending another 60s on the offset-URL page 2 when the
browser is in a Cloudflare challenge state.

Per-blocked-attempt time: ~4 min → ~1 min (30s goto + 30s selectors).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 21:34:37 +00:00
ea66025140 Reduce scraper timeouts and raise default concurrency to 6
Timeouts were set for the old proxy 407 round-trip world (14.5s latency
per challenge). Now that credentials are embedded in the proxy URL, pages
load in ~7s — the 90s goto timeout was forcing 5+ minutes of wasted wait
on Cloudflare-blocked dates before rotating sessions.

  homepage warmup goto: 90s → 20s
  page 1 search goto:   90s → 30s  (4× normal load time headroom)
  property-card waits:  30s → 15s  (cards appear in <3s on clean pages)

Default concurrency 3 → 6: each worker uses its own residential proxy IP
so parallelism is safe. ~0.4 GB per Chromium; 6 workers fits in 4 GB LXC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 21:17:19 +00:00
b47258d47b Fix scrape submission race condition allowing duplicate background tasks
Two rapid POST /scrape requests could both pass the lock check before
either background task acquired SCRAPE_LOCK, queuing two sequential
scrapes for the same date range. The second would hit Cloudflare after
the first already succeeded, causing repeated retries.

_SCRAPE_PENDING is set on submission and cleared when the task starts,
closing the gap between the 409 check and lock acquisition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:59:17 +00:00
13618e4471 Fix expired session showing EmbeddedFallback in portal iframe
When the JWT expired, AuthGate redirected window.location to /auth/login
which loaded inside the portal iframe. The portal detected window.self !==
window.top and showed the EmbeddedFallback ("This app isn't available yet")
instead of the login page.

Redirect window.top instead so the portal itself navigates to /login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:19:36 +00:00
134cc5cc3f Fix proxy 407 round-trip causing 14s latency per page.goto()
DataImpulse takes ~14s to issue a 407 challenge from this network.
Passing credentials as separate Playwright proxy fields caused Chromium
to wait for that challenge before sending auth on every CONNECT request,
making scrapes consistently time out at 90s.

Embedding credentials directly in the proxy server URL makes Chromium
send Proxy-Authorization on the first CONNECT, bypassing the round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:14:09 +00:00
92dce29e7b Fix page.content() crash and ensure rotation triggers on any empty result
page.content() throws when page is still navigating after a goto timeout;
catch it and continue with empty content. Also broaden the soft-block check
to trigger IP rotation whenever no hotels are found (not just on explicit
blocks), so proxy rotation fires even when the error path is hit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 18:16:06 +00:00
2ed53bea39 Add direct connection fallback after all proxy attempts fail
When all 3 proxy rotation attempts are soft-blocked (bad IP pool), fall
back to the server's own IP for a final attempt. Keeps proxy as primary
for IP diversity / rate-limit protection while ensuring a clean fallback
when the assigned residential pool is Cloudflare-challenged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 18:01:46 +00:00
7b4bb72f15 Add Referer header to search URL navigation
A direct hit to searchresults.en-gb.html with no Referer causes Cloudflare
to hold the response body open (never sending HTML), so domcontentloaded
never fires. Setting referer=HOMEPAGE makes the request look like the user
searched from the homepage, which resolves the stall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 17:50:45 +00:00
3c4bc633fe Revert to headed Chrome + Xvfb; add shm_size 256m to fix Xvfb in Docker
Headless mode gets blocked by Cloudflare on the search endpoint. Headed
mode with Xvfb works on dev (Unraid). The missing piece on Proxmox LXC was
insufficient /dev/shm (Docker default 64 MB); setting shm_size: 256m gives
Xvfb enough shared memory to start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 17:36:27 +00:00
e53c482141 Increase Playwright page.goto timeout from 30s to 90s
DataImpulse residential proxy takes ~29s to route through Booking.com's
Cloudflare edge — the previous 30s timeout was too short and the page
never had a chance to deliver its body. Also raised wait_for_selector
from 15s to 30s so property cards have time to render post-load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 17:23:41 +00:00
617ff932bb Switch Playwright to headless — Xvfb fails on Proxmox LXC kernels
Xvfb exits immediately on the production LXC (no framebuffer device
support), so headed mode was never working. Switch to headless=True
and remove Xvfb from the Dockerfile entirely.

Stealth coverage is unchanged: playwright-stealth patches webdriver,
plugins and chrome.runtime; the residential proxy + sticky session
provide the GB residential fingerprint; homepage warmup carries real
session cookies into the search. --enable-unsafe-swiftshader and
--disable-blink-features=AutomationControlled are kept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 17:01:54 +00:00
e781b1e8b9 Scraper: force-reset endpoint + watchdog to prevent stuck lock
Root cause: threading.Lock held indefinitely when Playwright browser
hangs inside run_in_executor (finally never fires from the async side).

Fixes:
- _acquire_scrape_lock/_release_scrape_lock track monotonic timestamp
- POST /competitors/scrape/reset force-releases the lock and marks any
  running batch as interrupted (queue rows stay intact for retry)
- GET /competitors/status now includes lock_held_seconds
- APScheduler watchdog job every 30 min auto-releases if held >3h
- Settings → Scraper Proxy tab shows live lock status (green/amber)
  with a Force Reset button requiring confirmation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:56:35 +00:00
d737940a00 Matrix: past/sold-out cells show last rate strikethrough, icons at bottom
Sold-out cells: amber (was red), show last known available rate with
strikethrough. Past dates: grey, same strikethrough treatment, no eye
icon (Booking.com won't serve past availability), history chart still
accessible. Date column headers also hide the eye link for past dates.

Backend adds a second query returning last_available_rate (most recent
available + non-null rate_gross) per hotel+date for the full range.

Icons are now 12px and stacked below the rate text in a flex-column
cell layout. Price index badge sits between rate and icons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 15:54:38 +00:00
1ffda18872 Market View: per-hotel rate history chart and Booking.com eye link
Replace full-cell link with two small icon buttons in each matrix cell:
- Eye icon links to the hotel's Booking.com page for that check-in date
- LineChart icon opens a history modal (Plotly multi-line chart)

The history chart plots best available rate over scrape runs, with one
line per room type so that changes in which room is cheapest show as
separate traces rather than a single jumpy line.

Backend: GET /competitors/hotels/{id}/rate-history/{stay_date} groups
by scrape batch, takes MIN(rate_gross) per room type per run, returns
series [{room_type, points: [{t, rate}]}].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 15:41:32 +00:00
2abc2b0297 Show own hotel's direct rate (Newbook best available) in the matrix direct sub-row
New GET /competitors/own-direct-rates: cheapest bookable non-dinner
Newbook tariff per date (same selection rules as the parity check).
'Show direct rates' now renders a 'Direct (Newbook)' sub-row under the
own-hotel row alongside the competitors' scraped direct rows; cell
tooltip names the tariff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:25:46 +00:00
ad0e82465c Parity: best-available comparison as default, term-matching optional
parity_match_mode config ('best_available' default | 'match_terms').
Best available = cheapest bookable non-dinner direct tariff vs the BC
lead-in (already BC's best available) — simplest like-for-like. Term
matching kept as an option for days where BC's cheapest basis differs
from direct's. Settings gains a comparison-basis select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:15 +00:00
3b501f95c5 Parity: like-for-like tariff matching instead of headline vs lead-in
The check compared Newbook's FIRST tariff (typically flexi B&B) against
Booking.com's lead-in card (often prepaid) — apples vs oranges, e.g.
21 Jul flagged B&B FLEX £289 vs a prepaid BC rate whose true comparable
was B&B PPAY £279.

- classify Newbook tariffs from names/descriptions (PPAY/prepay/advance/
  ADV/NRF/saver = prepaid; DBB/dinner/half board = dinner-inclusive)
- use the scraped BC rate's flags (free_cancellation/no_prepayment/
  breakfast_included) to pick the cheapest COMPARABLE tariff per date,
  excluding tariffs not bookable for that date (success=false, min-stay
  >1, unmet advance-purchase windows)
- tiered fallback (matched -> any non-dinner -> any -> legacy headline),
  recorded per alert in room_category as 'TARIFF vs BC basis'
- shared gather_comparisons() now drives both the daily job and
  GET /competitors/parity (issues gain newbook_tariff, booking_basis,
  match_quality, expected_rate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:57:17 +00:00
9545fd651d Parity markup and tolerance configurable as % or flat £
- config: parity_markup_value/_unit + parity_tolerance_value/_unit
  (pct|gbp), legacy *_pct keys still read as fallback
- expected rate = newbook + £X or newbook × (1 + X%); breach test uses
  the tolerance in its own unit
- Settings parity tab: unit selects + live worked example line
- Parity Alerts tab: unit-aware description, badge now shows £ deviation
  alongside %

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:41:55 +00:00
493e87b2dc Rate parity: expected-markup config, alert writer job, alerts UI + direct-link UI
Parity was read-only — the alerts table had no producer, so the Market
View badge could never fire. Now:
- jobs/check_rate_parity.py: daily 06:45 job comparing own Booking.com
  lead-in rate vs cheapest Newbook rate per date, measured against an
  EXPECTED markup (we deliberately price Booking.com higher to cover
  commission): alert when deviation from newbook*(1+markup%) exceeds the
  tolerance. Creates/updates active alerts, auto-resolves dates back in
  line, leaves acknowledged dates alone.
- config keys: parity_check_enabled, parity_expected_markup_pct,
  parity_tolerance_pct (system_config)
- POST /competitors/parity/check manual trigger; GET /parity now uses the
  same markup/tolerance and cheapest-across-categories Newbook rate
- Settings -> Rate Parity tab: markup %, tolerance %, enable toggle,
  run-now with result summary
- Market View -> Parity Alerts tab: status-filtered list w/ acknowledge
- Market View -> Hotels: direct-link dropdown per competitor (new PUT
  /competitors/hotels/{id}/direct-link) — closes the never-written
  direct_hotel_id gap so the matrix direct-rates sub-row can populate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:30:41 +00:00
029e3b379f Add benchmark & tier-offset configuration UI to Direct Rates Manage tab
- new GET /direct/hotels/{id}/config returns full hotel config plus
  distinct room/rate IDs from scraped data (and any known via labels)
- Configure panel: benchmark room/rate selects, tier base room, per-room
  £ offsets (base locked to 0), friendly room/rate names, room display
  order (up/down) — saved via the existing PUT endpoint
- enables apples-to-apples estimated benchmark when only e.g. a suite
  is left: bench = room_price - room_offset + bench_offset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:09:10 +00:00
46020576f8 Stats: fall back to all rate plans when no benchmark rate configured
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 06:53:44 +00:00
20d939de00 Restore guestline-monitor features: room stock/occupancy, rate history, room stats
- dates endpoint now returns total_avail, benchmark rate (tier-resolved),
  min-stay nights, max_rooms (summed per-room stock) and friendly labels
- rooms endpoint groups by room with stock, occupancy, best/bench rate and
  nested rate plans; injects known-but-absent rooms; honours room_order
- new /history/{stay_date} endpoint: per room/rate series, benchmark mode,
  or overall availability + cheapest rate per scrape run
- new /stats endpoint: per-room stock + windowed avg rate / current occ /
  est. final occ (look-back window so past dates give completed bookings)
- DirectRates page: occupancy pills, avail x/stock, Room Stats tab,
  history chart modal (plotly), expandable rate plans with history links
- fix discovery trigger crashing (run_until_complete inside running loop)
- fix migration script reading wrong column name (engine_profile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 06:50:37 +00:00
74ae94671b Wire up direct-rates column; remove dormant pause-on-block flow
Direct-rates sub-row in Market View was dead: the frontend filters
competitors on direct_hotel_id but /matrix and /hotels never returned it.
Add direct_hotel_id to both queries (+ HotelResponse), and only render the
"Direct" sub-row when a hotel actually has a non-null direct rate (was
rendering all-dashes on an empty {} object).

Remove the pause-on-block flow entirely — dormant since rotate-on-block
replaced it (nothing set booking_scraper_paused=true after set_scraper_paused
was dropped): is_scraper_paused, /config/unpause, the /scrape paused guard,
ScraperStatusResponse.paused/pause_until, and the frontend Paused badge +
Unpause button. Trim now-unused datetime import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 06:01:56 +00:00
1dc8c0a945 Extract shared proxy module; make it available to direct scraper; remove dead code
Proxy config, DataImpulse sticky-session username syntax, and Playwright/
httpx proxy builders now live in one place (services/proxy.py) instead of
being duplicated across the Booking.com backend and the /config/proxy test
endpoint. Both scrapers consume it.

- services/proxy.py: load_config/normalize (DB-authoritative, env fallback),
  new_session_id, username, playwright_proxy, httpx_proxy_url
- PlaywrightLocalBackend delegates proxy building to the module
- get_scraper_backend factory uses proxy.load_config (one resolution path)
- test_proxy_config endpoint uses the shared URL builder; httpx proxies= ->
  proxy= (forward-compatible, 0.28-safe)
- Direct booking-engine scraper (httpx) can now route through the same proxy,
  gated by the direct_scraper_use_proxy flag (default off, plumbing ready)

Dead code removed: set_scraper_paused (never called — rotate-on-block
replaced pause-on-block), get_competitor_matrix / get_hotels_list /
update_hotel_tier (endpoints have their own SQL), unused PROXY_KEYS tuple.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:55:24 +00:00
6b7f00b40a Parallel scrape workers (proxy-gated, configurable)
Fan a scrape run out across N workers, each with its own DB session and
its own scraper backend — and since every backend picks a random sticky
session id, each worker scrapes from a distinct residential IP. Dates are
interleaved across workers so each covers a spread of the range. Cuts a
150-date batch from ~30-45 min to ~12-15 min at 3 workers.

- booking_scraper_concurrency config key (default 3); Settings → Scraper
  Proxy has a "Parallel workers" field
- Forced to 1 when the proxy is off (N workers would share one IP and
  hammer it) or when there's a single date
- Both manual and queue paths routed through _scrape_dates_concurrent;
  workers mark their own queue items
- Per-worker rotate-on-block replaces the old global pause-on-block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:13:23 +00:00
579a189cb8 Use pasted Booking.com search URL to pin scrape destination
The location_search_url column existed but was dead — the scraper always
rebuilt the URL from the free-text location name. Now the Location
Configuration form takes a "Booking.com search URL" field: paste the
address-bar URL from a real search and the scraper lifts ss/dest_id/
dest_type from it (the most reliable destination pin). Falls back to
dest_id, then plain name. Server also extracts dest_id from the URL for
the column and derives a display name from ss when none is typed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:00:28 +00:00
9e5728efb1 Pin Booking.com search to a dest_id
Free-text ss= destination resolution is non-deterministic: tonight's
30-day run resolved "Stow on the Wold" to St. Wolfgang, Austria for 8 of
30 dates, saving Salzkammergut hotel rates into the matrix. dest_id +
dest_type=city in the search URL pins the destination.

- booking_scrape_config gains a dest_id column (idempotent ALTER)
- scrape_location_search/_build_search_url thread dest_id through
- /config/location accepts dest_id

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:49:57 +00:00
270d8293d1 Manage scraper proxy from the Settings page
- New "Scraper Proxy" tab: enable toggle, host/port/username/password/
  country, Save, and a Test Connection button that reports the live exit
  IP + country through the proxy
- Backend proxy config now lives in system_config (DB authoritative when
  booking_proxy_enabled is set; BOOKING_PROXY_* env vars are the fallback)
- Dedicated /config/proxy GET/POST/test endpoints; password is write-only
  (never returned, blank keeps the stored value) and masked in /config/system
- Surface proxy status keys in the read-only System tab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:36:19 +00:00
11b995739c Add residential proxy support to Booking.com scraper
- Route through DataImpulse residential proxy when BOOKING_PROXY_* env
  vars are set (empty = direct connection, unchanged behaviour)
- Sticky one IP per session via DataImpulse sessid; rotate_session()
  burns the IP for a fresh one
- Rotate-on-block: retry a date on a new IP when the page is challenged
  or page 1 renders zero hotels (up to 3 IPs; single attempt sans proxy)
- Persistent, pre-warmed context across dates so cache stays hot
  (~5 MB first page, ~0.3 MB per date after) instead of per-date cold loads
- Block images/media/fonts and third-party ad/consent/analytics hosts to
  cut bandwidth; never touch the anti-bot challenge (awswaf)
- Faster inter-page pacing now that a burned IP is cheap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:30:15 +00:00
1743590743 Fix backend startup: launch Xvfb directly, not via xvfb-run
xvfb-run as container PID 1 hangs before exec'ing uvicorn — its Xvfb
readiness handshake (SIGUSR1) is never delivered to PID 1, so the backend
sat 'Up (unhealthy)' with no logs and the frontend never started.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:35:03 +00:00
349c795708 Scraper stealth: headful+xvfb, playwright-stealth, coherent UA, warm-up, click-paginate
Tier 1+2 anti-detection to stop Booking.com throttling page 2:
- Run Chromium headful under xvfb (Dockerfile) — headless leaks SwiftShader
  WebGL, empty plugins, missing chrome.runtime
- playwright-stealth patches navigator.webdriver/plugins/WebGL vendor
- Single coherent Chrome-121 identity: UA + matching sec-ch-ua client hints +
  platform (dropped the Firefox/Safari UA strings — a mismatched UA on a
  Chromium engine is a stronger tell than no rotation)
- Homepage warm-up so search requests carry real session cookies + cookie
  consent dismiss
- Paginate by clicking next (offset= deep-link was the page-2 tell), offset
  URL kept as fallback
- Viewport jitter, mouse movement, longer scroll, selector retry on lazy load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:26:01 +00:00
b712196262 Scraper: process-wide lock, one scrape at a time
Concurrent manual scrapes were interleaving (two Chromium sessions on one
LXC) causing the page timeouts behind partial results. SCRAPE_LOCK guards
run_manual_scrape and process_queue; the trigger endpoint returns 409 when
busy, and the frontend keeps the job queued and retries after 30s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:14:55 +00:00
bf9425ee7e Market View: honest staleness per cell after partial scrapes
- matrix response gains last_scraped per date (max scraped_at across all
  hotels), so column headers show the newest scrape touching the date even
  when the visible hotels were on a failed page
- cells >1h older than the column's latest scrape render italic with *
- every cell tooltip now includes the datestamp the price was scraped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:46:59 +00:00
218b2f45f6 Scraper: don't flag not_listed from partial scrapes
A page-load timeout was logged and skipped, so a scrape could 'succeed'
with only page 1 of results — and the not_listed flagging then marked
every page-2 hotel absent, suppressing their last known rates.

- ScraperResult now tracks pages_requested/pages_ok
- scrape_date skips not_listed flagging when pages failed or when the
  scrape saw <60% of the date's 7-day coverage baseline; scraped rates
  are still saved, unseen hotels keep last known rate + scrape time

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:29:06 +00:00
de8c4ec257 Market View: make Scrape 7d/30d buttons gold with icon, right-aligned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:19:51 +00:00