From 3fc025baf7dd361a4869a03cc888d4665b176fbd Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:43:07 +0000 Subject: [PATCH 1/7] booking.com: return the house rules in get_property --- domains/booking.com/README.md | 2 +- domains/booking.com/get_property.js | 39 +++++++++++++++++++--- registry.json | 6 ++-- tests/booking-stays.test.mjs | 51 ++++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/domains/booking.com/README.md b/domains/booking.com/README.md index 5d9695b2..6abcc477 100644 --- a/domains/booking.com/README.md +++ b/domains/booking.com/README.md @@ -5,6 +5,6 @@ | continue_checkout | Presses "Next: Final details" on the checkout's details step after checking the total, required fields and that the selection holds the party; never books or pays. | | fill_guest_details | Types the main guest's name, email, phone and address into the checkout's details step; presses nothing. | | get_checkout_step | Reads Booking.com's checkout page, including a Partner Offer's partner checkout: the step, property, dates, selected rooms, any warning that they cannot hold the party and the total including taxes and fees. | -| get_property | Reads one property's dated room rates that hold the party's adults, with IDs, adult and child places, whether they fit every child, cancellation, prepayment, meals and excluded fees, plus its address, reviews and check-in and check-out times. | +| get_property | Reads one property's dated room rates that hold the party's adults, with IDs, adult and child places, whether they fit every child, cancellation, prepayment, meals and excluded fees, plus its address, reviews, check-in and check-out times and house rules (pets, payment methods and cash, children and beds). | | search_stays | Searches stays for a destination, dates and party in the site's current currency and returns the first results page with prices and reviews. | | select_room | Selects one room of an exact rate, including a Partner Offer, on the open property page for the expected party and presses "I'll reserve", which opens the details step without booking. | diff --git a/domains/booking.com/get_property.js b/domains/booking.com/get_property.js index 8d2f0d8f..010c736e 100644 --- a/domains/booking.com/get_property.js +++ b/domains/booking.com/get_property.js @@ -29,7 +29,7 @@ } }; const propertySchema = { - type: "object", additionalProperties: false, required: ["property_id", "name", "property_url", "page_url", "address", "review", "review_categories", "popular_facilities", "check_in_time", "check_out_time"], + type: "object", additionalProperties: false, required: ["property_id", "name", "property_url", "page_url", "address", "review", "review_categories", "popular_facilities", "check_in_time", "check_out_time", "house_rules"], properties: { property_id: { type: ["string", "null"], description: "Booking.com's numeric property id." }, name: { type: "string" }, @@ -44,7 +44,15 @@ review_categories: { type: "array", items: { type: "object", additionalProperties: false, required: ["category", "score"], properties: { category: { type: "string" }, score: { type: "number" } } } }, popular_facilities: { type: "array", items: { type: "string" } }, check_in_time: { type: ["string", "null"], description: "The house-rules text, for example 'From 2:00 PM'." }, - check_out_time: { type: ["string", "null"] } + check_out_time: { type: ["string", "null"] }, + house_rules: { + type: "array", maxItems: 20, + items: { + type: "object", additionalProperties: false, required: ["title", "lines"], + properties: { title: { type: "string" }, lines: { type: "array", maxItems: 30, items: { type: "string" } } } + }, + description: "Every house-rules section in page order, with its title as the page shows it (for example 'Pets', 'Accepted payment methods' or 'Cards accepted at this property', 'Children & Beds', 'Age restriction', 'Smoking', 'Parties', 'Quiet hours') and its text lines: card logos by name, and each crib or extra-bed row as one line ('4 - 12 years: Extra bed upon request, € 12.50 per child, per night'). A section the property did not fill in is absent, not 'allowed'. Empty when the page has no house rules." + } } }; return [{ @@ -52,7 +60,7 @@ match: { url_patterns: ["https://www.booking.com/*"] }, tool: { name: "get_property", - description: "Read one Booking.com property page for given dates and party: name, address, review score, popular facilities, check-in and check-out times, and every room with each rate the site lists for the requested party (displayed price for the whole stay, cancellation, prepayment and meal terms, taxes and fees excluded from the price). Get property_url from search_stays. Requires explicit check_in, check_out, adults and currency, for one room. currency must be the currency the site is already showing (its header currency picker): the tool never changes the site's currency and fails with currency_mismatch, naming what the site shows, when they differ. The output echoes the effective dates, guests and currency the page applied and a price_basis, and a page that did not apply them as asked is an error. Rates the page limits to fewer adults than the party are left out, because their prices do not cover the whole party (rates_left_out counts them); when every rate is like that the status is no_rate_for_party. With children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit, before max_rates applies. A rate marked as a members-only price needs signing in: price.total then includes a member discount (member_discount) that a signed-out visitor is not given. Read-only: it never selects a room or rate, saves to a list, reserves or opens checkout, and changes no browser or account setting; Booking.com records the page view. Listing a rate is not a hold or a guarantee, and prices can differ by session (member discounts when signed in). Run it on any https://www.booking.com page in an English-language session and do not navigate the tab while it runs. Dates the property cannot offer give status no_availability, and so can dates beyond the site's bookable window; for such dates the page may also apply the party the session last searched, which is reported as search_not_applied.", + description: "Read one Booking.com property page for given dates and party: name, address, review score, popular facilities, check-in and check-out times, the house rules (pets, accepted payment methods and cash, children and extra beds, age restriction, smoking, parties, quiet hours, as the property lists them), and every room with each rate the site lists for the requested party (displayed price for the whole stay, cancellation, prepayment and meal terms, taxes and fees excluded from the price). Get property_url from search_stays. Requires explicit check_in, check_out, adults and currency, for one room. currency must be the currency the site is already showing (its header currency picker): the tool never changes the site's currency and fails with currency_mismatch, naming what the site shows, when they differ. The output echoes the effective dates, guests and currency the page applied and a price_basis, and a page that did not apply them as asked is an error. Rates the page limits to fewer adults than the party are left out, because their prices do not cover the whole party (rates_left_out counts them); when every rate is like that the status is no_rate_for_party. With children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit, before max_rates applies. A rate marked as a members-only price needs signing in: price.total then includes a member discount (member_discount) that a signed-out visitor is not given. Read-only: it never selects a room or rate, saves to a list, reserves or opens checkout, and changes no browser or account setting; Booking.com records the page view. Listing a rate is not a hold or a guarantee, and prices can differ by session (member discounts when signed in). Run it on any https://www.booking.com page in an English-language session and do not navigate the tab while it runs. Dates the property cannot offer give status no_availability, and so can dates beyond the site's bookable window; for such dates the page may also apply the party the session last searched, which is reported as search_not_applied.", inputSchema: { type: "object", additionalProperties: false, required: ["property_url", "check_in", "check_out", "adults", "currency"], @@ -283,6 +291,29 @@ const facilities = pickTid(doc, "property-most-popular-facilities-wrapper"); const rules = rawText(pickTid(doc, "HouseRules-wrapper")); const time = label => (new RegExp(label + "\\s*((?:From|Until|Between)\\s+\\d{1,2}(?::\\d{2})?\\s*(?:[AP]M)?(?:\\s*(?:-|to|\\u2013)\\s*\\d{1,2}(?::\\d{2})?\\s*(?:[AP]M)?)?)", "i").exec(rules) || [])[1] ?? null; + // Each house-rules row is a title cell and a content cell. Content lines are the innermost blocks with text; + // card logos are images named by alt text, and a crib or extra-bed row (an age range, then the offer and its + // charge) is kept together as one line. + const ruleLines = (el, out) => { + if (out.length >= 30 || el.getAttribute("aria-hidden") === "true") return out; + if (el.tagName === "IMG") { const alt = (el.getAttribute("alt") || "").replace(/\s+/g, " ").trim().slice(0, 300); if (alt) out.push(alt); return out; } + const kids = [...el.children]; + if (kids.length === 2 && /^\d+\s*(?:-\s*\d+|\+)\s*years?$/i.test(text(kids[0]))) { + out.push((text(kids[0]) + ": " + ruleLines(kids[1], []).join(", ")).slice(0, 300)); + return out; + } + if (!kids.length || [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) { + const line = text(el); + if (line) out.push(line); + return out; + } + for (const kid of kids) ruleLines(kid, out); + return out; + }; + const rulesContent = pickTid(doc, "HouseRules-wrapper")?.querySelector("[data-testid=\"property-section--content\"]"); + const ruleRows = (rulesContent?.querySelector("hr")?.parentNode || rulesContent)?.children || []; + const houseRules = [...ruleRows].filter(row => row.tagName === "DIV" && row.children.length >= 2 && text(row.children[0])) + .slice(0, 20).map(row => ({ title: text(row.children[0]), lines: ruleLines(row.children[1], []) })); const property = { property_id: cfg(html, /\bb_hotel_id\s*:\s*'(\d{1,20})'/), name: name.slice(0, 300), property_url: query.property_url, page_url: query.property_url + "?" + params.toString(), @@ -290,7 +321,7 @@ review: scoreMatch ? { score: Number(scoreMatch[1]), label: scoreMatch[2].trim(), count: Number(scoreMatch[3].replace(/,/g, "")) } : null, review_categories: [...doc.querySelectorAll("[data-testid=\"review-subscore\"]")].slice(0, 20).map(el => /^(.*?),\s*([\d.]+),\s*Average rating/.exec(text(el))).filter(Boolean).map(m => ({ category: m[1], score: Number(m[2]) })), popular_facilities: facilities ? [...facilities.querySelectorAll("li")].map(text).filter(Boolean).slice(0, 20) : [], - check_in_time: time("Check-in"), check_out_time: time("Check-out") + check_in_time: time("Check-in"), check_out_time: time("Check-out"), house_rules: houseRules }; const rowEls = [...doc.querySelectorAll("#hprt-table tr.js-rt-block-row")]; diff --git a/registry.json b/registry.json index 91949481..6b9baf62 100644 --- a/registry.json +++ b/registry.json @@ -1132,9 +1132,9 @@ "https://www.booking.com/*" ], "url_patterns_reason": "Narrowed to www.booking.com: the stays search and property pages are served there, and the read tools fetch those pages same-origin; a fetch from www.booking.com to flights. or secure.booking.com fails, so the tools cannot work on other booking.com hosts.", - "status": "qa_passed", - "last_verified": "2026-10-17", - "notes": "Dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", + "status": "draft", + "last_verified": null, + "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line). The author ran it live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; this is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", "provenance": { "qa_agent": "agt_88d67e429f599403d4fb" } diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index 83f071f2..a0c922cf 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -126,9 +126,25 @@ const partnerRow = ({ head = ROOM_A, blockId = "bbasic_0", total = "$298" } = {} .replace('`, ``) .replace("", '
Partner Offer

Same room, lower price. Facilitated by a trusted partner company.

'); +const HOUSE_RULES = `

House rules

Check-in
From 2:00 PM
Guests are required to show a photo ID.

Check-out
Until 11:00 AM
`; +// One house-rules row as observed: an icon and title cell, then a content cell. +const ruleRow = (title, content) => `
${title}
${content}
`; +const FULL_HOUSE_RULES = `

House rules

Harbor View Studios takes special requests – add in the next step!
` + + ruleRow("Check-in", `
From 3:00 PM to 11:30 PM
Guests are required to show a photo ID and credit card at check-in.
`) + + ruleRow("Check-out", `
Until 12:00 PM
`) + + ruleRow("Cancellation/ prepayment", `
`) + + ruleRow("Children & Beds", `

Child policies

Children of all ages are welcome.

Children 13 and above will be charged as adults at this property.

Crib and extra bed policies

` + + `
0 - 3 years
Crib upon request
Free
` + + `
13+ years
Extra bed upon request
€ 25 per person, per night
` + + `

All cribs and extra beds are subject to availability.

`) + + ruleRow("Age restriction", `
The minimum age for check-in is 18
`) + + ruleRow("Pets", `
Pets are not allowed.
`) + + ruleRow("Cards accepted at this property", `
VisaMastercardDebit card Cash is not accepted
`) + + ruleRow("Quiet hours", `
Guests need be quiet between 10:00 PM and 7:00 AM.
`) + + `
`; const ROOM_B = { id: "1002", name: "Executive One-Bedroom Apartment", beds: "Bedroom 1: 1 queen bed", left: null }; -function propertyPage({ rows = null, signedIn = false, currency = "USD", occupancy = "2 adults · 0 children · 1 room", room1 = "A,A", cfg = config({ currency, hotelId: "1000001" }), ld = PROPERTY_LD, noAvailability = false, extraBody = "" } = {}) { +function propertyPage({ rows = null, signedIn = false, currency = "USD", occupancy = "2 adults · 0 children · 1 room", room1 = "A,A", cfg = config({ currency, hotelId: "1000001" }), ld = PROPERTY_LD, noAvailability = false, extraBody = "", houseRules = HOUSE_RULES } = {}) { const table = rows === null ? [rateRow({ head: ROOM_A }), rateRow({ blockId: "1001_2002_2_2_0", total: "$338", original: "$375", perNight: "$169", before: "$337.56", cancellation: "Free cancellation before October 27, 2026", meal: "Wonderful breakfast included" }), rateRow({ head: ROOM_B, blockId: "1002_2001_2_2_0", guests: null, total: "$348", original: null, perNight: "$174", before: "$348.10", excluded: null, meal: null, cancellation: "Free cancellation" })] : rows; @@ -139,8 +155,7 @@ function propertyPage({ rows = null, signedIn = false, currency = "USD", occupan `
8.4
Rated: Very Good 8.4 – based on 1,234 reviews Expand ratings and reviews section
Very Good
1,234 reviews
` + `
Staff, 9.1, Average rating out of 10 Staff 9.1
Value for money, 7.9, Average rating out of 10 Value for money 7.9
` + `

Most popular amenities

  • Free Wifi
  • Air conditioning
` + - `

House rules

Check-in
From 2:00 PM
Guests are required to show a photo ID.

Check-out
Until 11:00 AM
` + - `${availability}${extraBody}`; + houseRules + `${availability}${extraBody}`; const ldScript = ld ? `` : ""; return shell({ body: body + ldScript, currency, signedIn, occupancy, cfg }); } @@ -603,7 +618,8 @@ test("get_property reads rooms, rates, cancellation, meal, taxes and the page's page_url: "https://www.booking.com/hotel/xx/harbor-view-studios.html?checkin=2026-11-10&checkout=2026-11-12&group_adults=2&no_rooms=1&group_children=0", address: "1 Example Quay, Old Quarter, 1000-000 Example Harbor, Examplia", review: { score: 8.4, label: "Very Good", count: 1234 }, review_categories: [{ category: "Staff", score: 9.1 }, { category: "Value for money", score: 7.9 }], popular_facilities: ["Free Wifi", "Air conditioning"], - check_in_time: "From 2:00 PM", check_out_time: "Until 11:00 AM" + check_in_time: "From 2:00 PM", check_out_time: "Until 11:00 AM", + house_rules: [{ title: "Check-in", lines: ["From 2:00 PM", "Guests are required to show a photo ID."] }, { title: "Check-out", lines: ["Until 11:00 AM"] }] }); assert.equal(output.rates_total, 3); assert.equal(output.rates_returned, 3); @@ -632,6 +648,33 @@ test("get_property reads rooms, rates, cancellation, meal, taxes and the page's assert.doesNotMatch(JSON.stringify(output), /fake-tracking|label=|secure\.booking\.com|book\.html/, "no tracking or booking-form data may be returned"); }); +test("get_property returns every house-rules section as the page lists it", async () => { + const { output } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: FULL_HOUSE_RULES }) })] }); + assert.equal(output.status, "ok"); + assert.equal(output.property.check_in_time, "From 3:00 PM to 11:30 PM"); + assert.equal(output.property.check_out_time, "Until 12:00 PM"); + assert.deepEqual(output.property.house_rules, [ + { title: "Check-in", lines: ["From 3:00 PM to 11:30 PM", "Guests are required to show a photo ID and credit card at check-in."] }, + { title: "Check-out", lines: ["Until 12:00 PM"] }, + { title: "Cancellation/ prepayment", lines: ["Cancellation and prepayment policies vary according to accommodation type. Check what conditions apply to each option when making your selection."] }, + { title: "Children & Beds", lines: ["Child policies", "Children of all ages are welcome.", "Children 13 and above will be charged as adults at this property.", "Crib and extra bed policies", "0 - 3 years: Crib upon request, Free", "13+ years: Extra bed upon request, € 25 per person, per night", "All cribs and extra beds are subject to availability."] }, + { title: "Age restriction", lines: ["The minimum age for check-in is 18"] }, + { title: "Pets", lines: ["Pets are not allowed."] }, + { title: "Cards accepted at this property", lines: ["Visa", "Mastercard", "Debit card", "Cash is not accepted"] }, + { title: "Quiet hours", lines: ["Guests need be quiet between 10:00 PM and 7:00 AM."] } + ]); +}); + +test("get_property returns house rules with no_availability and none when the page has no section", async () => { + const unavailable = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ noAvailability: true, houseRules: FULL_HOUSE_RULES }) })] }); + assert.equal(unavailable.output.status, "no_availability"); + assert.deepEqual(unavailable.output.property.house_rules.find(r => r.title === "Pets"), { title: "Pets", lines: ["Pets are not allowed."] }); + const missing = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: "" }) })] }); + assert.equal(missing.output.status, "ok"); + assert.deepEqual(missing.output.property.house_rules, []); + assert.equal(missing.output.property.check_in_time, null); +}); + test("get_property keeps an unreadable price as text and warns instead of guessing", async () => { const rows = [rateRow({ head: ROOM_A, total: "1'234 USD", original: null })]; const { output } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ rows }) })] }); From 5726a37485f61cb4161e4cd67463762bbe4b50a2 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:57:00 +0000 Subject: [PATCH 2/7] booking.com: report an unreadable house-rules section as null in get_property --- domains/booking.com/get_property.js | 24 ++++++++++++++---------- registry.json | 2 +- tests/booking-stays.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/domains/booking.com/get_property.js b/domains/booking.com/get_property.js index 010c736e..78002265 100644 --- a/domains/booking.com/get_property.js +++ b/domains/booking.com/get_property.js @@ -46,12 +46,12 @@ check_in_time: { type: ["string", "null"], description: "The house-rules text, for example 'From 2:00 PM'." }, check_out_time: { type: ["string", "null"] }, house_rules: { - type: "array", maxItems: 20, + type: ["array", "null"], maxItems: 20, items: { type: "object", additionalProperties: false, required: ["title", "lines"], properties: { title: { type: "string" }, lines: { type: "array", maxItems: 30, items: { type: "string" } } } }, - description: "Every house-rules section in page order, with its title as the page shows it (for example 'Pets', 'Accepted payment methods' or 'Cards accepted at this property', 'Children & Beds', 'Age restriction', 'Smoking', 'Parties', 'Quiet hours') and its text lines: card logos by name, and each crib or extra-bed row as one line ('4 - 12 years: Extra bed upon request, € 12.50 per child, per night'). A section the property did not fill in is absent, not 'allowed'. Empty when the page has no house rules." + description: "Every house-rules section in page order, with its title as the page shows it (for example 'Pets', 'Accepted payment methods' or 'Cards accepted at this property', 'Children & Beds', 'Age restriction', 'Smoking', 'Parties', 'Quiet hours') and its text lines: card logos by name, and each crib or extra-bed row as one line ('4 - 12 years: Extra bed upon request, € 12.50 per child, per night'). A section the property did not fill in is absent, not 'allowed'. Empty when the page has no house rules; null when it has a house-rules section the tool could not read, which says nothing about any rule." } } }; @@ -294,25 +294,29 @@ // Each house-rules row is a title cell and a content cell. Content lines are the innermost blocks with text; // card logos are images named by alt text, and a crib or extra-bed row (an age range, then the offer and its // charge) is kept together as one line. + const ruleLine = s => s.length > 600 ? s.slice(0, 599) + "…" : s; const ruleLines = (el, out) => { if (out.length >= 30 || el.getAttribute("aria-hidden") === "true") return out; if (el.tagName === "IMG") { const alt = (el.getAttribute("alt") || "").replace(/\s+/g, " ").trim().slice(0, 300); if (alt) out.push(alt); return out; } const kids = [...el.children]; - if (kids.length === 2 && /^\d+\s*(?:-\s*\d+|\+)\s*years?$/i.test(text(kids[0]))) { - out.push((text(kids[0]) + ": " + ruleLines(kids[1], []).join(", ")).slice(0, 300)); + if (kids.length === 2 && /^\d+\s*(?:[-\u2013]\s*\d+|\+)\s*years?$/i.test(text(kids[0]))) { + out.push(ruleLine(text(kids[0]) + ": " + ruleLines(kids[1], []).join(", "))); return out; } if (!kids.length || [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) { - const line = text(el); + const line = ruleLine(rawText(el)); if (line) out.push(line); return out; } for (const kid of kids) ruleLines(kid, out); return out; }; - const rulesContent = pickTid(doc, "HouseRules-wrapper")?.querySelector("[data-testid=\"property-section--content\"]"); - const ruleRows = (rulesContent?.querySelector("hr")?.parentNode || rulesContent)?.children || []; - const houseRules = [...ruleRows].filter(row => row.tagName === "DIV" && row.children.length >= 2 && text(row.children[0])) + // Rows are the siblings of the shallowest
, so a separator inside a row's content is not taken for one. + const rulesWrapper = pickTid(doc, "HouseRules-wrapper"); + const depth = el => { let d = 0; for (; el; el = el.parentNode) d++; return d; }; + const ruleSeparator = [...(rulesWrapper?.querySelector("[data-testid=\"property-section--content\"]")?.querySelectorAll("hr") || [])] + .reduce((top, hr) => !top || depth(hr) < depth(top) ? hr : top, null); + const houseRules = [...(ruleSeparator?.parentNode.children || [])].filter(row => row.tagName === "DIV" && row.children.length >= 2 && text(row.children[0])) .slice(0, 20).map(row => ({ title: text(row.children[0]), lines: ruleLines(row.children[1], []) })); const property = { property_id: cfg(html, /\bb_hotel_id\s*:\s*'(\d{1,20})'/), @@ -321,7 +325,7 @@ review: scoreMatch ? { score: Number(scoreMatch[1]), label: scoreMatch[2].trim(), count: Number(scoreMatch[3].replace(/,/g, "")) } : null, review_categories: [...doc.querySelectorAll("[data-testid=\"review-subscore\"]")].slice(0, 20).map(el => /^(.*?),\s*([\d.]+),\s*Average rating/.exec(text(el))).filter(Boolean).map(m => ({ category: m[1], score: Number(m[2]) })), popular_facilities: facilities ? [...facilities.querySelectorAll("li")].map(text).filter(Boolean).slice(0, 20) : [], - check_in_time: time("Check-in"), check_out_time: time("Check-out"), house_rules: houseRules + check_in_time: time("Check-in"), check_out_time: time("Check-out"), house_rules: rulesWrapper ? (houseRules.length ? houseRules : null) : [] }; const rowEls = [...doc.querySelectorAll("#hprt-table tr.js-rt-block-row")]; @@ -334,7 +338,7 @@ const warnings = []; if (signedIn) warnings.push("The browser is signed in to Booking.com, so prices may include member (Genius) discounts."); if (ages.length && !agesConfirmed) warnings.push("The page did not list the child ages, so the ages sent could not be confirmed."); - if (ages.length) warnings.push("Child charges and age limits are not checked; review the property's child policies."); + if (ages.length) warnings.push("Child charges and age limits are not checked against the rates; see the property's child policies in property.house_rules."); // A rate's capacity is in its occupancy cell ("Max. people: 2", "Max adults: 2 Max children: 1", "Only for 1 // guest") or, on another layout, in a "Guests: 2 adults, 1 child" line under its conditions. A child may take // an adult place, as Booking.com's checkout counts guests. diff --git a/registry.json b/registry.json index 6b9baf62..f3e2f37e 100644 --- a/registry.json +++ b/registry.json @@ -1134,7 +1134,7 @@ "url_patterns_reason": "Narrowed to www.booking.com: the stays search and property pages are served there, and the read tools fetch those pages same-origin; a fetch from www.booking.com to flights. or secure.booking.com fails, so the tools cannot work on other booking.com hosts.", "status": "draft", "last_verified": null, - "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line). The author ran it live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; this is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", + "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line), [] when the page has no house-rules section and null when it has one the tool cannot split into rows. The author ran an earlier revision live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; this is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", "provenance": { "qa_agent": "agt_88d67e429f599403d4fb" } diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index a0c922cf..360c03f5 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -675,6 +675,27 @@ test("get_property returns house rules with no_availability and none when the pa assert.equal(missing.output.property.check_in_time, null); }); +test("get_property splits house rules on the section's own separators and reports a section it cannot read as null", async () => { + const section = rows => `
${rows}
`; + const nested = section(ruleRow("Check-in", `
From 2:00 PM

Guests are required to show a photo ID.
`) + + ruleRow("Children & Beds", `
0 – 3 years
Crib upon requestFree
`) + + ruleRow("Damage policy", `
${"word ".repeat(150)}
`)); + const { output } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: nested }) })] }); + assert.equal(output.status, "ok"); + assert.deepEqual(output.property.house_rules.slice(0, 2), [ + { title: "Check-in", lines: ["From 2:00 PM", "Guests are required to show a photo ID."] }, + { title: "Children & Beds", lines: ["0 – 3 years: Crib upon request, Free"] } + ]); + const [damage] = output.property.house_rules[2].lines; + assert.equal(damage.length, 600); + assert.ok(damage.endsWith("…")); + + const unreadable = section(`
Pets
Pets are not allowed.
`); + const { output: unread } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: unreadable }) })] }); + assert.equal(unread.status, "ok"); + assert.equal(unread.property.house_rules, null); +}); + test("get_property keeps an unreadable price as text and warns instead of guessing", async () => { const rows = [rateRow({ head: ROOM_A, total: "1'234 USD", original: null })]; const { output } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ rows }) })] }); From ec0ef98432b80782fefbb811d98011b3e27b271b Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:22:10 +0000 Subject: [PATCH 3/7] booking.com: bound get_property's house rules, keep multi-offer crib rows together --- domains/booking.com/get_property.js | 36 +++++++++++++++++++---------- registry.json | 2 +- tests/booking-stays.test.mjs | 36 ++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/domains/booking.com/get_property.js b/domains/booking.com/get_property.js index 78002265..23333f47 100644 --- a/domains/booking.com/get_property.js +++ b/domains/booking.com/get_property.js @@ -29,7 +29,7 @@ } }; const propertySchema = { - type: "object", additionalProperties: false, required: ["property_id", "name", "property_url", "page_url", "address", "review", "review_categories", "popular_facilities", "check_in_time", "check_out_time", "house_rules"], + type: "object", additionalProperties: false, required: ["property_id", "name", "property_url", "page_url", "address", "review", "review_categories", "popular_facilities", "check_in_time", "check_out_time", "house_rules", "house_rules_truncated"], properties: { property_id: { type: ["string", "null"], description: "Booking.com's numeric property id." }, name: { type: "string" }, @@ -51,8 +51,9 @@ type: "object", additionalProperties: false, required: ["title", "lines"], properties: { title: { type: "string" }, lines: { type: "array", maxItems: 30, items: { type: "string" } } } }, - description: "Every house-rules section in page order, with its title as the page shows it (for example 'Pets', 'Accepted payment methods' or 'Cards accepted at this property', 'Children & Beds', 'Age restriction', 'Smoking', 'Parties', 'Quiet hours') and its text lines: card logos by name, and each crib or extra-bed row as one line ('4 - 12 years: Extra bed upon request, € 12.50 per child, per night'). A section the property did not fill in is absent, not 'allowed'. Empty when the page has no house rules; null when it has a house-rules section the tool could not read, which says nothing about any rule." - } + description: "Every house-rules section in page order, with its title as the page shows it (for example 'Pets', 'Accepted payment methods' or 'Cards accepted at this property', 'Children & Beds', 'Age restriction', 'Smoking', 'Parties', 'Quiet hours') and its text lines: card logos by name, and each crib or extra-bed row as one line ('4 - 12 years: Extra bed upon request, € 12.50 per child, per night', with '; ' between offers for the same ages). A section the property did not fill in is absent, not 'allowed'. Empty when the page has no house rules; null when it has a house-rules section the tool could not read, which says nothing about any rule." + }, + house_rules_truncated: { type: "boolean", description: "True when house_rules leaves out sections or lines the page lists (more than 20 sections, more than 30 lines in a section, or about 20 KB of text); read the rest on the property page." } } }; return [{ @@ -292,15 +293,15 @@ const rules = rawText(pickTid(doc, "HouseRules-wrapper")); const time = label => (new RegExp(label + "\\s*((?:From|Until|Between)\\s+\\d{1,2}(?::\\d{2})?\\s*(?:[AP]M)?(?:\\s*(?:-|to|\\u2013)\\s*\\d{1,2}(?::\\d{2})?\\s*(?:[AP]M)?)?)", "i").exec(rules) || [])[1] ?? null; // Each house-rules row is a title cell and a content cell. Content lines are the innermost blocks with text; - // card logos are images named by alt text, and a crib or extra-bed row (an age range, then the offer and its - // charge) is kept together as one line. + // card logos are images named by alt text, and a crib or extra-bed row (an age or age range, then the offer and its + // charge, one cell per offer) is kept together as one line. const ruleLine = s => s.length > 600 ? s.slice(0, 599) + "…" : s; const ruleLines = (el, out) => { - if (out.length >= 30 || el.getAttribute("aria-hidden") === "true") return out; + if (out.length > 30 || el.getAttribute("aria-hidden") === "true") return out; if (el.tagName === "IMG") { const alt = (el.getAttribute("alt") || "").replace(/\s+/g, " ").trim().slice(0, 300); if (alt) out.push(alt); return out; } const kids = [...el.children]; - if (kids.length === 2 && /^\d+\s*(?:[-\u2013]\s*\d+|\+)\s*years?$/i.test(text(kids[0]))) { - out.push(ruleLine(text(kids[0]) + ": " + ruleLines(kids[1], []).join(", "))); + if (kids.length >= 2 && /^\d+\s*(?:[-\u2013]\s*\d+|\+)?\s*years?(?:\s+old)?$/i.test(text(kids[0]))) { + out.push(ruleLine(text(kids[0]) + ": " + kids.slice(1).map(offer => ruleLines(offer, []).join(", ")).join("; "))); return out; } if (!kids.length || [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) { @@ -311,13 +312,24 @@ for (const kid of kids) ruleLines(kid, out); return out; }; - // Rows are the siblings of the shallowest
, so a separator inside a row's content is not taken for one. + // Rows are the siblings of the shallowest
, so a separator inside a row's content is not taken for one. Every + // property lists check-in and check-out, so rows without either are not the section's rows. const rulesWrapper = pickTid(doc, "HouseRules-wrapper"); const depth = el => { let d = 0; for (; el; el = el.parentNode) d++; return d; }; const ruleSeparator = [...(rulesWrapper?.querySelector("[data-testid=\"property-section--content\"]")?.querySelectorAll("hr") || [])] .reduce((top, hr) => !top || depth(hr) < depth(top) ? hr : top, null); - const houseRules = [...(ruleSeparator?.parentNode.children || [])].filter(row => row.tagName === "DIV" && row.children.length >= 2 && text(row.children[0])) - .slice(0, 20).map(row => ({ title: text(row.children[0]), lines: ruleLines(row.children[1], []) })); + const ruleRows = [...(ruleSeparator?.parentNode.children || [])].filter(row => row.tagName === "DIV" && row.children.length >= 2 && text(row.children[0])); + const houseRules = []; + let ruleBytes = 0, rulesTruncated = false; + for (const row of ruleRows) { + const lines = ruleLines(row.children[1], []); + const rule = { title: text(row.children[0]), lines: lines.slice(0, 30) }; + ruleBytes += new TextEncoder().encode(JSON.stringify(rule)).length; + if (houseRules.length === 20 || ruleBytes > 20000) { rulesTruncated = true; break; } + if (lines.length > 30) rulesTruncated = true; + houseRules.push(rule); + } + const rulesRead = houseRules.length > 0 && ruleRows.some(row => /^Check-(?:in|out)$/i.test(text(row.children[0]))); const property = { property_id: cfg(html, /\bb_hotel_id\s*:\s*'(\d{1,20})'/), name: name.slice(0, 300), property_url: query.property_url, page_url: query.property_url + "?" + params.toString(), @@ -325,7 +337,7 @@ review: scoreMatch ? { score: Number(scoreMatch[1]), label: scoreMatch[2].trim(), count: Number(scoreMatch[3].replace(/,/g, "")) } : null, review_categories: [...doc.querySelectorAll("[data-testid=\"review-subscore\"]")].slice(0, 20).map(el => /^(.*?),\s*([\d.]+),\s*Average rating/.exec(text(el))).filter(Boolean).map(m => ({ category: m[1], score: Number(m[2]) })), popular_facilities: facilities ? [...facilities.querySelectorAll("li")].map(text).filter(Boolean).slice(0, 20) : [], - check_in_time: time("Check-in"), check_out_time: time("Check-out"), house_rules: rulesWrapper ? (houseRules.length ? houseRules : null) : [] + check_in_time: time("Check-in"), check_out_time: time("Check-out"), house_rules: rulesWrapper ? (rulesRead ? houseRules : null) : [], house_rules_truncated: rulesRead && rulesTruncated }; const rowEls = [...doc.querySelectorAll("#hprt-table tr.js-rt-block-row")]; diff --git a/registry.json b/registry.json index f3e2f37e..b389e30e 100644 --- a/registry.json +++ b/registry.json @@ -1134,7 +1134,7 @@ "url_patterns_reason": "Narrowed to www.booking.com: the stays search and property pages are served there, and the read tools fetch those pages same-origin; a fetch from www.booking.com to flights. or secure.booking.com fails, so the tools cannot work on other booking.com hosts.", "status": "draft", "last_verified": null, - "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line), [] when the page has no house-rules section and null when it has one the tool cannot split into rows. The author ran an earlier revision live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; this is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", + "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line, with several offers for one age range kept with their own charges), [] when the page has no house-rules section and null when it has one the tool cannot split into rows that include check-in or check-out. house_rules_truncated reports sections or lines left out by the 20-section, 30-line and about 20 KB bounds. The author ran an earlier revision live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; the final parser was then run against house-rules sections captured from 38 live Lisbon and Orlando property pages (single-age and multi-offer crib rows included), none truncated. This is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", "provenance": { "qa_agent": "agt_88d67e429f599403d4fb" } diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index 360c03f5..24c7f66b 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -619,7 +619,8 @@ test("get_property reads rooms, rates, cancellation, meal, taxes and the page's address: "1 Example Quay, Old Quarter, 1000-000 Example Harbor, Examplia", review: { score: 8.4, label: "Very Good", count: 1234 }, review_categories: [{ category: "Staff", score: 9.1 }, { category: "Value for money", score: 7.9 }], popular_facilities: ["Free Wifi", "Air conditioning"], check_in_time: "From 2:00 PM", check_out_time: "Until 11:00 AM", - house_rules: [{ title: "Check-in", lines: ["From 2:00 PM", "Guests are required to show a photo ID."] }, { title: "Check-out", lines: ["Until 11:00 AM"] }] + house_rules: [{ title: "Check-in", lines: ["From 2:00 PM", "Guests are required to show a photo ID."] }, { title: "Check-out", lines: ["Until 11:00 AM"] }], + house_rules_truncated: false }); assert.equal(output.rates_total, 3); assert.equal(output.rates_returned, 3); @@ -678,13 +679,14 @@ test("get_property returns house rules with no_availability and none when the pa test("get_property splits house rules on the section's own separators and reports a section it cannot read as null", async () => { const section = rows => `
${rows}
`; const nested = section(ruleRow("Check-in", `
From 2:00 PM

Guests are required to show a photo ID.
`) + - ruleRow("Children & Beds", `
0 – 3 years
Crib upon requestFree
`) + + ruleRow("Children & Beds", `
0 – 3 years
Crib upon requestFree
2 years old
Crib upon requestFree
` + + `
0 - 2 years
Extra bed upon request$35 per stay
Crib upon requestFree
`) + ruleRow("Damage policy", `
${"word ".repeat(150)}
`)); const { output } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: nested }) })] }); assert.equal(output.status, "ok"); assert.deepEqual(output.property.house_rules.slice(0, 2), [ { title: "Check-in", lines: ["From 2:00 PM", "Guests are required to show a photo ID."] }, - { title: "Children & Beds", lines: ["0 – 3 years: Crib upon request, Free"] } + { title: "Children & Beds", lines: ["0 – 3 years: Crib upon request, Free", "2 years old: Crib upon request, Free", "0 - 2 years: Extra bed upon request, $35 per stay; Crib upon request, Free"] } ]); const [damage] = output.property.house_rules[2].lines; assert.equal(damage.length, 600); @@ -694,6 +696,34 @@ test("get_property splits house rules on the section's own separators and report const { output: unread } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: unreadable }) })] }); assert.equal(unread.status, "ok"); assert.equal(unread.property.house_rules, null); + + const row = (title, content) => `
${title}
${content}
`; + const innerOnly = section(row("Check-in", "
From 2:00 PM
") + row("Pets", "
Dogs
Allowed

Cats
Not allowed
")); + const { output: inner } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: innerOnly }) })] }); + assert.equal(inner.property.house_rules, null, "rows split on a separator inside a row's content are not the section's rows"); + assert.equal(inner.property.house_rules_truncated, false); +}); + +test("get_property bounds the house rules and says when it leaves some out", async () => { + const section = rows => `
${rows}
`; + const checkIn = ruleRow("Check-in", "
From 2:00 PM
"); + const many = section(checkIn + Array.from({ length: 24 }, (_, i) => ruleRow("Rule " + i, "
Text
")).join("")); + const { output: sections } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: many }) })] }); + assert.equal(sections.property.house_rules.length, 20); + assert.equal(sections.property.house_rules_truncated, true); + + const longSection = section(checkIn + ruleRow("Rules", Array.from({ length: 35 }, (_, i) => `
Line ${i}
`).join(""))); + const { output: lines } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: longSection }) })] }); + assert.equal(lines.property.house_rules[1].lines.length, 30); + assert.equal(lines.property.house_rules_truncated, true); + + const long = "界".repeat(700); + const huge = section(checkIn + Array.from({ length: 19 }, (_, i) => ruleRow("Rule " + i, Array(30).fill(`
${long}
`).join(""))).join("")); + const { output: unavailable } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ noAvailability: true, houseRules: huge }) })] }); + assert.equal(unavailable.status, "no_availability"); + assert.equal(unavailable.property.house_rules_truncated, true); + assert.equal(unavailable.property.house_rules[0].title, "Check-in"); + assert.ok(new TextEncoder().encode(JSON.stringify(unavailable)).length < 30000); }); test("get_property keeps an unreadable price as text and warns instead of guessing", async () => { From 99fd41d6e101e724dad6ad4141efacd811864eee Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:36:11 +0000 Subject: [PATCH 4/7] booking.com: point the child-policy warning at house_rules only when get_property read some --- domains/booking.com/get_property.js | 2 +- tests/booking-stays.test.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/domains/booking.com/get_property.js b/domains/booking.com/get_property.js index 23333f47..16120180 100644 --- a/domains/booking.com/get_property.js +++ b/domains/booking.com/get_property.js @@ -350,7 +350,7 @@ const warnings = []; if (signedIn) warnings.push("The browser is signed in to Booking.com, so prices may include member (Genius) discounts."); if (ages.length && !agesConfirmed) warnings.push("The page did not list the child ages, so the ages sent could not be confirmed."); - if (ages.length) warnings.push("Child charges and age limits are not checked against the rates; see the property's child policies in property.house_rules."); + if (ages.length) warnings.push("Child charges and age limits are not checked against the rates; " + (property.house_rules?.length ? "see the property's child policies in property.house_rules." : "review the property's child policies on the property page.")); // A rate's capacity is in its occupancy cell ("Max. people: 2", "Max adults: 2 Max children: 1", "Only for 1 // guest") or, on another layout, in a "Guests: 2 adults, 1 child" line under its conditions. A child may take // an adult place, as Booking.com's checkout counts guests. diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index 24c7f66b..7b589d6e 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -674,6 +674,9 @@ test("get_property returns house rules with no_availability and none when the pa assert.equal(missing.output.status, "ok"); assert.deepEqual(missing.output.property.house_rules, []); assert.equal(missing.output.property.check_in_time, null); + const childPolicies = async houseRules => (await run("get_property", { ...PROPERTY, children_ages: [7] }, { routes: [propertyRoute({ body: propertyPage({ occupancy: "2 adults · 1 child · 1 room", room1: "A,A,7", houseRules }) })] })).output.warnings.find(w => /child policies/.test(w)); + assert.match(await childPolicies(FULL_HOUSE_RULES), /in property\.house_rules\.$/); + assert.match(await childPolicies(""), /on the property page\.$/, "no house rules to point at"); }); test("get_property splits house rules on the section's own separators and reports a section it cannot read as null", async () => { From 36c6695c45ab7960017f453daf58a51d34781242 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:36:26 +0000 Subject: [PATCH 5/7] booking.com: test that get_property stops house rules at the 20 KB bound --- tests/booking-stays.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index 7b589d6e..b7d8ab48 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -727,6 +727,12 @@ test("get_property bounds the house rules and says when it leaves some out", asy assert.equal(unavailable.property.house_rules_truncated, true); assert.equal(unavailable.property.house_rules[0].title, "Check-in"); assert.ok(new TextEncoder().encode(JSON.stringify(unavailable)).length < 30000); + + const medium = section(checkIn + Array.from({ length: 8 }, (_, i) => ruleRow("Rule " + i, Array(8).fill(`
${"a".repeat(600)}
`).join(""))).join("")); + const { output: bounded } = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: medium }) })] }); + assert.deepEqual(bounded.property.house_rules.map(r => r.title), ["Check-in", "Rule 0", "Rule 1", "Rule 2", "Rule 3"]); + assert.ok(new TextEncoder().encode(JSON.stringify(bounded.property.house_rules)).length <= 20000); + assert.equal(bounded.property.house_rules_truncated, true); }); test("get_property keeps an unreadable price as text and warns instead of guessing", async () => { From 325e0ae148b27adf0af8f35027fa3394a794537b Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:03:19 +0000 Subject: [PATCH 6/7] booking.com: drop the get_property no-availability house-rules case the 20 KB bound test covers --- tests/booking-stays.test.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/booking-stays.test.mjs b/tests/booking-stays.test.mjs index b7d8ab48..a48deb1f 100644 --- a/tests/booking-stays.test.mjs +++ b/tests/booking-stays.test.mjs @@ -666,10 +666,7 @@ test("get_property returns every house-rules section as the page lists it", asyn ]); }); -test("get_property returns house rules with no_availability and none when the page has no section", async () => { - const unavailable = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ noAvailability: true, houseRules: FULL_HOUSE_RULES }) })] }); - assert.equal(unavailable.output.status, "no_availability"); - assert.deepEqual(unavailable.output.property.house_rules.find(r => r.title === "Pets"), { title: "Pets", lines: ["Pets are not allowed."] }); +test("get_property returns no house rules when the page has no section", async () => { const missing = await run("get_property", PROPERTY, { routes: [propertyRoute({ body: propertyPage({ houseRules: "" }) })] }); assert.equal(missing.output.status, "ok"); assert.deepEqual(missing.output.property.house_rules, []); From 360f3b7a6eff5fd55c2183f88f8851f4360ae1d9 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:31:30 +0000 Subject: [PATCH 7/7] booking.com: record independent QA of get_property's house rules --- bench/tasks/booking.com/booking-house-rules.json | 2 +- registry.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bench/tasks/booking.com/booking-house-rules.json b/bench/tasks/booking.com/booking-house-rules.json index bc5083d5..ac1ed653 100644 --- a/bench/tasks/booking.com/booking-house-rules.json +++ b/bench/tasks/booking.com/booking-house-rules.json @@ -1,7 +1,7 @@ { "id": "booking-house-rules", "domain": "booking.com", - "stage": "gap", + "stage": "inspect", "prompt": "On booking.com, find whether {{hotel}} in {{city}} allows pets and whether it accepts cash, as its house rules say. Reply with JSON: {\"pets_allowed\": boolean, \"cash_accepted\": boolean}.", "fixture": { "hotel": "Palacio Ramalhete", diff --git a/registry.json b/registry.json index b389e30e..24d0d6a6 100644 --- a/registry.json +++ b/registry.json @@ -1132,11 +1132,11 @@ "https://www.booking.com/*" ], "url_patterns_reason": "Narrowed to www.booking.com: the stays search and property pages are served there, and the read tools fetch those pages same-origin; a fetch from www.booking.com to flights. or secure.booking.com fails, so the tools cannot work on other booking.com hosts.", - "status": "draft", - "last_verified": null, - "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line, with several offers for one age range kept with their own charges), [] when the page has no house-rules section and null when it has one the tool cannot split into rows that include check-in or check-out. house_rules_truncated reports sections or lines left out by the 20-section, 30-line and about 20 KB bounds. The author ran an earlier revision live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; the final parser was then run against house-rules sections captured from 38 live Lisbon and Orlando property pages (single-age and multi-offer crib rows included), none truncated. This is not independent QA, so the status is draft until an independent pass on this source. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", + "status": "verified", + "last_verified": "2026-10-29", + "notes": "Adds house_rules: every house-rules section in page order with its title and lines (card logos by alt text, each crib or extra-bed row as one line, with several offers for one age range kept with their own charges), [] when the page has no house-rules section and null when it has one the tool cannot split into rows that include check-in or check-out. house_rules_truncated reports sections or lines left out by the 20-section, 30-line and about 20 KB bounds. The author ran an earlier revision live on 2026-10-28 on six Lisbon properties (pets allowed and not, cash accepted and not, cards-only and payment-method lists, crib and extra-bed rows, parties, quiet hours, no-age-restriction) and compared each section with the page; the final parser was then run against house-rules sections captured from 38 live Lisbon and Orlando property pages (single-age and multi-offer crib rows included), none truncated. Independent QA on 2026-10-29 of the final source (325e0ae), signed out in plain Kernel browsers started on the home page: search_stays then get_property at Palacio Ramalhete (2 adults, 28 days out) returned ok with all nine sections, including 'Pets are not allowed.', card logos as American Express, Visa and Mastercard next to 'Cash is not accepted', and the 0 - 2 years crib row as one line, untruncated. Bench booking-house-rules (5 pairs each, low thinking, tool from this source): Haiku 5.5 treatment vs control correct 5/5 vs 5/5 (1.2x faster), GPT-6 Luna 4/5 vs 2/5; 100% adoption, and every treatment trial that got get_property output answered correctly (9 of 9). The null and truncated paths are covered by fixtures only, and signed-in sessions were not tested. The rest is unchanged from the independently QA'd earlier source: dated property facts and room rates with effective party and currency, each rate's adult and child places (max_adults, max_children) read from either of the two capacity layouts the site serves (an occupancy cell, or a 'Guests:' line under the rate's conditions), excluded fees, cancellation, prepayment, meals, rate IDs and comparison ranks, plus page_url, the dated property page select_room acts on. Partner Offers keep their row id (bbasic_0, _bbasic_0) and are marked partner_offer, with a warning that their price, terms and availability can change at checkout. Rates with fewer adult places than the party are left out (party_too_large); with children, a rate with places for the adults but not for every child is kept, marked fits_party false and ranked after the rates that fit (before max_rates), because Booking.com accepted such a rate for a baby (no checkout alert) but alerted 'accommodates fewer than 3 guests' for a 5-year-old; a rate listing no capacity is kept with a warning. Optional lowest-price ordering before the 40-rate cap; scans at most 400 rows. Independent QA on 2026-10-16 ran it live from the home and search pages on more than ten properties and on about a dozen sessions at Palacio Ramalhete with and without Partner Offers: partner flags and counts matched the page, room_id agreed with select_room, plus no-availability dates, currency mismatch, invalid input and past check_in. One room; English. Independent QA on 2026-10-17 (three rounds, final source 5d7822e) ran it live at a Lisbon property on both capacity layouts, compared row by row with the page: 2 adults with children aged 0, 1, 2, 5 and 12+5 (fits_party true, false and adult-short rates left out), 2 adults, a property listing no capacity (kept, warned), max_rates truncation with fitting rates ranked first under both sorts, and outputSchema conformance. Ages 3 and 4, Partner Offers in a child search and signed-in sessions were not tested.", "provenance": { - "qa_agent": "agt_88d67e429f599403d4fb" + "qa_agent": "agt_f009472a350a7a79a637" } }, {