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>
This commit is contained in:
parent
270d8293d1
commit
9e5728efb1
5 changed files with 34 additions and 12 deletions
|
|
@ -30,6 +30,7 @@ class LocationConfigRequest(BaseModel):
|
||||||
location_name: str
|
location_name: str
|
||||||
pages_to_scrape: int = 2
|
pages_to_scrape: int = 2
|
||||||
adults: int = 2
|
adults: int = 2
|
||||||
|
dest_id: Optional[str] = None # Booking.com numeric destination id (pins the search)
|
||||||
|
|
||||||
|
|
||||||
class HotelTierUpdate(BaseModel):
|
class HotelTierUpdate(BaseModel):
|
||||||
|
|
@ -310,10 +311,11 @@ async def set_location_config(
|
||||||
# Insert new config
|
# Insert new config
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, is_active)
|
INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, dest_id, is_active)
|
||||||
VALUES (:location, :pages, :adults, TRUE)
|
VALUES (:location, :pages, :adults, :dest_id, TRUE)
|
||||||
"""),
|
"""),
|
||||||
{'location': config.location_name, 'pages': config.pages_to_scrape, 'adults': config.adults}
|
{'location': config.location_name, 'pages': config.pages_to_scrape,
|
||||||
|
'adults': config.adults, 'dest_id': config.dest_id}
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,3 +279,7 @@ CREATE INDEX IF NOT EXISTS idx_direct_rates_scraped ON direct_rates(scraped_a
|
||||||
-- Link booking_com_hotels to direct_competitor_hotels (optional, for Market View direct column)
|
-- Link booking_com_hotels to direct_competitor_hotels (optional, for Market View direct column)
|
||||||
ALTER TABLE booking_com_hotels ADD COLUMN IF NOT EXISTS direct_hotel_id INTEGER
|
ALTER TABLE booking_com_hotels ADD COLUMN IF NOT EXISTS direct_hotel_id INTEGER
|
||||||
REFERENCES direct_competitor_hotels(id) ON DELETE SET NULL;
|
REFERENCES direct_competitor_hotels(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Pin the Booking.com destination: free-text ss= searches non-deterministically
|
||||||
|
-- resolve to the wrong place (Stow on the Wold once matched St. Wolfgang, AT).
|
||||||
|
ALTER TABLE booking_scrape_config ADD COLUMN IF NOT EXISTS dest_id VARCHAR(32);
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]:
|
||||||
"""Get the active scrape location configuration."""
|
"""Get the active scrape location configuration."""
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT id, location_name, location_search_url, pages_to_scrape, adults
|
SELECT id, location_name, location_search_url, pages_to_scrape, adults, dest_id
|
||||||
FROM booking_scrape_config
|
FROM booking_scrape_config
|
||||||
WHERE is_active = TRUE
|
WHERE is_active = TRUE
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
|
|
@ -99,6 +99,7 @@ def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]:
|
||||||
'location_search_url': result.location_search_url,
|
'location_search_url': result.location_search_url,
|
||||||
'pages_to_scrape': result.pages_to_scrape or 2,
|
'pages_to_scrape': result.pages_to_scrape or 2,
|
||||||
'adults': result.adults or 2,
|
'adults': result.adults or 2,
|
||||||
|
'dest_id': result.dest_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -330,7 +331,8 @@ async def scrape_date(
|
||||||
check_in=check_in,
|
check_in=check_in,
|
||||||
check_out=check_out,
|
check_out=check_out,
|
||||||
adults=config['adults'],
|
adults=config['adults'],
|
||||||
pages=config['pages_to_scrape']
|
pages=config['pages_to_scrape'],
|
||||||
|
dest_id=config.get('dest_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.blocked:
|
if result.blocked:
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ class ScraperBackend(ABC):
|
||||||
check_in: date,
|
check_in: date,
|
||||||
check_out: date,
|
check_out: date,
|
||||||
adults: int = 2,
|
adults: int = 2,
|
||||||
pages: int = 2
|
pages: int = 2,
|
||||||
|
dest_id: Optional[str] = None
|
||||||
) -> ScraperResult:
|
) -> ScraperResult:
|
||||||
"""
|
"""
|
||||||
Scrape booking.com location search results.
|
Scrape booking.com location search results.
|
||||||
|
|
@ -101,6 +102,9 @@ class ScraperBackend(ABC):
|
||||||
check_out: Check-out date (typically check_in + 1 for single night)
|
check_out: Check-out date (typically check_in + 1 for single night)
|
||||||
adults: Number of adults for search
|
adults: Number of adults for search
|
||||||
pages: Number of search result pages to scrape
|
pages: Number of search result pages to scrape
|
||||||
|
dest_id: Booking.com numeric destination id. Pins the search to one
|
||||||
|
destination — free-text ss= resolves non-deterministically
|
||||||
|
(Stow on the Wold intermittently matched St. Wolfgang, Austria)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ScraperResult with hotels and rates found
|
ScraperResult with hotels and rates found
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,8 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
check_in: date,
|
check_in: date,
|
||||||
check_out: date,
|
check_out: date,
|
||||||
adults: int,
|
adults: int,
|
||||||
offset: int = 0
|
offset: int = 0,
|
||||||
|
dest_id: Optional[str] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build booking.com search URL with parameters."""
|
"""Build booking.com search URL with parameters."""
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -287,6 +288,11 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
'no_rooms': 1,
|
'no_rooms': 1,
|
||||||
'group_children': 0,
|
'group_children': 0,
|
||||||
}
|
}
|
||||||
|
if dest_id:
|
||||||
|
# Pin the destination — without this, free-text ss= intermittently
|
||||||
|
# resolves to the wrong place entirely.
|
||||||
|
params['dest_id'] = dest_id
|
||||||
|
params['dest_type'] = 'city'
|
||||||
if offset > 0:
|
if offset > 0:
|
||||||
params['offset'] = offset
|
params['offset'] = offset
|
||||||
|
|
||||||
|
|
@ -502,7 +508,8 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
check_in: date,
|
check_in: date,
|
||||||
check_out: date,
|
check_out: date,
|
||||||
adults: int = 2,
|
adults: int = 2,
|
||||||
pages: int = 2
|
pages: int = 2,
|
||||||
|
dest_id: Optional[str] = None
|
||||||
) -> ScraperResult:
|
) -> ScraperResult:
|
||||||
"""
|
"""
|
||||||
Scrape booking.com location search results, rotating the proxy IP if
|
Scrape booking.com location search results, rotating the proxy IP if
|
||||||
|
|
@ -515,6 +522,7 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
check_out: Check-out date (check_in + 1 for single night rate)
|
check_out: Check-out date (check_in + 1 for single night rate)
|
||||||
adults: Number of adults
|
adults: Number of adults
|
||||||
pages: Number of result pages to scrape
|
pages: Number of result pages to scrape
|
||||||
|
dest_id: Booking.com numeric destination id (pins the search)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ScraperResult with hotels and rates found
|
ScraperResult with hotels and rates found
|
||||||
|
|
@ -523,7 +531,7 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
result = await self._scrape_once(location, check_in, check_out, adults, pages)
|
result = await self._scrape_once(location, check_in, check_out, adults, pages, dest_id)
|
||||||
|
|
||||||
# Soft-block signals: an explicit challenge, or a "successful" load
|
# Soft-block signals: an explicit challenge, or a "successful" load
|
||||||
# that yielded zero hotels (page 1 never rendered results).
|
# that yielded zero hotels (page 1 never rendered results).
|
||||||
|
|
@ -547,7 +555,8 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
check_in: date,
|
check_in: date,
|
||||||
check_out: date,
|
check_out: date,
|
||||||
adults: int,
|
adults: int,
|
||||||
pages: int
|
pages: int,
|
||||||
|
dest_id: Optional[str] = None
|
||||||
) -> ScraperResult:
|
) -> ScraperResult:
|
||||||
"""A single scrape attempt for one date on the current IP/session."""
|
"""A single scrape attempt for one date on the current IP/session."""
|
||||||
all_hotels = []
|
all_hotels = []
|
||||||
|
|
@ -574,7 +583,7 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
page_loaded = True
|
page_loaded = True
|
||||||
if page_num == 0:
|
if page_num == 0:
|
||||||
# Navigate to page 1 by URL
|
# Navigate to page 1 by URL
|
||||||
url = self._build_search_url(location, check_in, check_out, adults)
|
url = self._build_search_url(location, check_in, check_out, adults, dest_id=dest_id)
|
||||||
logger.info(f"Scraping page {page_num + 1}: {url}")
|
logger.info(f"Scraping page {page_num + 1}: {url}")
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
|
|
@ -588,7 +597,8 @@ class PlaywrightLocalBackend(ScraperBackend):
|
||||||
if not clicked:
|
if not clicked:
|
||||||
# Fall back to offset URL if the control isn't found
|
# Fall back to offset URL if the control isn't found
|
||||||
url = self._build_search_url(
|
url = self._build_search_url(
|
||||||
location, check_in, check_out, adults, offset=page_num * 25
|
location, check_in, check_out, adults,
|
||||||
|
offset=page_num * 25, dest_id=dest_id
|
||||||
)
|
)
|
||||||
logger.info(f"Next-button not found, offset fallback: {url}")
|
logger.info(f"Next-button not found, offset fallback: {url}")
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue