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>
This commit is contained in:
jtricerolph 2026-07-10 00:37:35 +00:00
parent dd84ca230a
commit 83b46bee8f

View file

@ -36,29 +36,44 @@ logger = logging.getLogger(__name__)
# JS that extracts all rate plan rows from the room availability table. # JS that extracts all rate plan rows from the room availability table.
# Runs inside the page after the room table has loaded. # Runs inside the page after the room table has loaded.
#
# Iterates ALL tbody rows (matching the original scrapy spider approach) rather than
# walking siblings from room-type anchors. This handles hotels where the first rate
# row of a room type lacks js-rt-block-row, or where the room header uses a <div>
# instead of <a> for the room_type_id_ element — both caused under-counting.
_EXTRACT_RATES_JS = """ _EXTRACT_RATES_JS = """
() => { () => {
const results = []; const results = [];
const roomTypeNames = {};
document.querySelectorAll('[id^="room_type_id_"]').forEach(roomEl => { const rows = document.querySelectorAll(
const roomId = roomEl.getAttribute('data-room-id') || roomEl.id.replace('room_type_id_', ''); '#available_rooms tbody tr:not([data-is-room-upgrade])'
const roomName = ( );
roomEl.querySelector('.hprt-roomtype-icon-link')?.innerText ||
roomEl.querySelector('span')?.innerText || ''
).trim();
// Availability count from scarcity indicator ("We have 2 left") for (const tr of rows) {
const availText = roomEl.closest('tr')
?.querySelector('.only_x_left, .thisRoomAvailabilityNew span')
?.innerText?.trim() || '';
const availMatch = availText.match(/\\d+/);
const availCount = availMatch ? parseInt(availMatch[0]) : null;
// Walk sibling <tr> rows that belong to this room type
let tr = roomEl.closest('tr');
while (tr) {
if (tr.classList.contains('js-rt-block-row')) {
const blockId = tr.getAttribute('data-block-id') || ''; const blockId = tr.getAttribute('data-block-id') || '';
if (!blockId) continue;
const blockParts = blockId.split('_');
if (blockParts.length < 3) continue;
const roomId = blockParts[0];
const maxPersons = parseInt(blockParts[2]);
// Room name: the first row for each room type carries the room_type_id_ element.
// Subsequent rows for the same room type don't — reuse stored name (same as scrapy).
const roomNameEl = tr.querySelector('[id^="room_type_id_"]');
if (roomNameEl) {
const name = (
roomNameEl.querySelector('.hprt-roomtype-icon-link')?.innerText ||
roomNameEl.querySelector('span')?.innerText ||
roomNameEl.innerText ||
''
).trim();
if (name) roomTypeNames[roomId] = name;
}
const roomName = roomTypeNames[roomId] || '';
const priceRaw = tr.getAttribute('data-hotel-rounded-price') || ''; const priceRaw = tr.getAttribute('data-hotel-rounded-price') || '';
let fltrs = {}; let fltrs = {};
@ -71,21 +86,17 @@ _EXTRACT_RATES_JS = """
const breakfastIncluded = condCell.toLowerCase().includes('breakfast'); const breakfastIncluded = condCell.toLowerCase().includes('breakfast');
const nonRefundable = (fltrs.non_refundable === 1); const nonRefundable = (fltrs.non_refundable === 1);
// Free cancellation date: "Free cancellation before DD Month YYYY"
const cancelMatch = condCell.match(/free cancellation before ([\\w\\s]+)/i); const cancelMatch = condCell.match(/free cancellation before ([\\w\\s]+)/i);
const freeCancelText = cancelMatch ? cancelMatch[1].trim() : null; const freeCancelText = cancelMatch ? cancelMatch[1].trim() : null;
// Persons: extract from block_id format {room_id}_{rate_plan_id}_{persons}_{meal}_0 // Scarcity text ("We have 2 left") as fallback for availability
// More reliable than cell text parsing which can pick up the price instead. const availText = tr.querySelector('.only_x_left, .thisRoomAvailabilityNew span')
const blockParts = blockId.split('_'); ?.innerText?.trim() || '';
const maxPersons = blockParts.length >= 3 ? parseInt(blockParts[2]) : null; const availMatch = availText.match(/\\d+/);
let qtyAvailable = availMatch ? parseInt(availMatch[0]) : null;
// Quantity dropdown: last <td> has a <select> with options 0..N // Quantity dropdown in last <td>: options 0..N where N = available qty (capped at 10)
// where N = actual available qty (capped at 10 by Booking.com). const qtyCell = cells.length >= 2 ? cells[cells.length - 1] : null;
// This is more reliable than the "X left" scarcity text which only
// appears when availability is low (typically 5).
let qtyAvailable = availCount; // fallback to scarcity text
const qtyCell = cells.length >= 4 ? cells[cells.length - 1] : null;
if (qtyCell) { if (qtyCell) {
const sel = qtyCell.querySelector('select'); const sel = qtyCell.querySelector('select');
if (sel) { if (sel) {
@ -94,8 +105,6 @@ _EXTRACT_RATES_JS = """
.filter(v => !isNaN(v) && v > 0); .filter(v => !isNaN(v) && v > 0);
if (vals.length > 0) qtyAvailable = Math.max(...vals); if (vals.length > 0) qtyAvailable = Math.max(...vals);
} else { } else {
// Fallback: parse max number from cell text
// "Select rooms\\n0\\n1 (£208)\\n2 (£416)" [1,2] max 2
const nums = (qtyCell.innerText || '').match(/^(\\d+)/gm); const nums = (qtyCell.innerText || '').match(/^(\\d+)/gm);
if (nums && nums.length > 0) { if (nums && nums.length > 0) {
const parsed = nums.map(n => parseInt(n)).filter(v => v > 0); const parsed = nums.map(n => parseInt(n)).filter(v => v > 0);
@ -113,17 +122,10 @@ _EXTRACT_RATES_JS = """
breakfast_included: breakfastIncluded, breakfast_included: breakfastIncluded,
non_refundable: nonRefundable, non_refundable: nonRefundable,
free_cancel_text: freeCancelText, free_cancel_text: freeCancelText,
max_persons: maxPersons, max_persons: !isNaN(maxPersons) ? maxPersons : null,
}); });
} }
tr = tr.nextElementSibling;
if (!tr) break;
// Stop at the next room type's header row
if (tr.querySelector('[id^="room_type_id_"]')) break;
}
});
return results; return results;
} }
""" """