Skip to content

Commit b298f88

Browse files
committed
feat: add get_reviews tool to booking adapter
Adds an 8th tool `get_reviews` that extracts up to 75 guest reviews for any Booking.com property. Implementation uses the dedicated `reviewlist.html` paginated endpoint (pagename + cc1 + rows=25 + offset params) rather than the reviews modal, which cannot be reliably scrolled in headless. Pagination iterates offsets until the requested count is reached. Supports all 5 sort modes matching Booking.com's dropdown: most_relevant, newest_first (f_recent_desc), oldest_first (f_recent_asc), highest_scores (f_score_desc), lowest_scores (f_score_asc). Each returned PropertyReview has: reviewer, country, score, date, title, pros, cons, roomType, stayDuration, travellerType, rawText. AC results (Il Castelluccio, 1,600 reviews): - count:15 returns 15 reviews in <3s - count:50 returns 50 reviews across 2 paginated requests - sort:newest_first returns reviews ordered by March 2026 date - invalid URL returns isError:true - TypeScript build clean, no any types Made-with: Cursor
1 parent 09568db commit b298f88

3 files changed

Lines changed: 443 additions & 1 deletion

File tree

src/index.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineAdapter } from "@browserkit/core";
22
import { z } from "zod";
33
import type { Page } from "patchright";
44
import { SELECTORS } from "./selectors.js";
5-
import { extractTripsPage, extractBookingDetail, extractSearchResults, extractPropertyPage, extractSavedProperties } from "./scraper.js";
5+
import { extractTripsPage, extractBookingDetail, extractSearchResults, extractPropertyPage, extractSavedProperties, extractPropertyReviews, type ReviewSort } from "./scraper.js";
66

77
// ── Schemas ───────────────────────────────────────────────────────────────────
88

@@ -334,5 +334,64 @@ export default defineAdapter({
334334
},
335335
},
336336

337+
// ── get_reviews ───────────────────────────────────────────────────────────
338+
{
339+
name: "get_reviews",
340+
description: [
341+
"Get guest reviews for a Booking.com property.",
342+
"Opens the 'Read all reviews' modal on the property page and extracts up to count reviews.",
343+
"Each review includes: reviewer name, country, score, date, review title, pros, cons,",
344+
"room type, stay duration, traveller type, and rawText for LLM fallback.",
345+
"",
346+
"Sort options (matching Booking.com's modal dropdown):",
347+
" most_relevant (default), newest_first, oldest_first, highest_scores, lowest_scores",
348+
"",
349+
"Use get_saved_properties, search_hotels, or get_property to find a property_url first.",
350+
"",
351+
"Examples:",
352+
" get_reviews({ property_url: 'https://www.booking.com/hotel/it/il-castelluccio-countryresort.html', count: 20 })",
353+
" get_reviews({ property_url: '...', count: 50, sort: 'newest_first' })",
354+
].join("\n"),
355+
inputSchema: z.object({
356+
property_url: z.string().url()
357+
.describe("Full Booking.com hotel URL — from search_hotels, get_saved_properties, etc."),
358+
count: z.number().int().min(1).max(75).default(10)
359+
.describe("Max reviews to return (1–75)"),
360+
sort: z.enum(["most_relevant", "newest_first", "oldest_first", "highest_scores", "lowest_scores"])
361+
.default("most_relevant")
362+
.optional()
363+
.describe("Sort order: most_relevant (default), newest_first, oldest_first, highest_scores, lowest_scores"),
364+
}),
365+
annotations: { readOnlyHint: true as const, openWorldHint: true as const },
366+
async handler(page: Page, input: unknown) {
367+
const { property_url, count, sort } = z.object({
368+
property_url: z.string().url(),
369+
count: z.number().int().min(1).max(75).default(10),
370+
sort: z.enum(["most_relevant", "newest_first", "oldest_first", "highest_scores", "lowest_scores"])
371+
.default("most_relevant")
372+
.optional(),
373+
}).parse(input);
374+
375+
// Validate it's a Booking.com URL
376+
if (!property_url.includes("booking.com")) {
377+
return {
378+
content: [{ type: "text" as const, text: "Error: property_url must be a Booking.com hotel URL." }],
379+
isError: true,
380+
};
381+
}
382+
383+
const reviews = await extractPropertyReviews(
384+
page,
385+
property_url,
386+
count,
387+
(sort ?? "most_relevant") as ReviewSort
388+
);
389+
390+
return {
391+
content: [{ type: "text" as const, text: JSON.stringify(reviews, null, 2) }],
392+
};
393+
},
394+
},
395+
337396
],
338397
});

src/scraper.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,40 @@ export async function extractBookingDetail(
456456
};
457457
}
458458

459+
// ── Reviews types ─────────────────────────────────────────────────────────────
460+
461+
export type ReviewSort =
462+
| "most_relevant"
463+
| "newest_first"
464+
| "oldest_first"
465+
| "highest_scores"
466+
| "lowest_scores";
467+
468+
export interface PropertyReview {
469+
/** Reviewer display name */
470+
reviewer: string;
471+
/** Reviewer nationality, e.g. "France" */
472+
country: string;
473+
/** Review score, e.g. 9.2 */
474+
score: number | null;
475+
/** Review date as displayed, e.g. "Reviewed: March 29, 2026" */
476+
date: string;
477+
/** Bold review headline, e.g. "Exceptional" */
478+
title: string;
479+
/** Positive body text */
480+
pros: string;
481+
/** Negative body text (may be empty) */
482+
cons: string;
483+
/** Room type booked, e.g. "Family Suite" */
484+
roomType: string;
485+
/** Stay duration string, e.g. "2 nights · March 2026" */
486+
stayDuration: string;
487+
/** Traveller type, e.g. "Couple" */
488+
travellerType: string;
489+
/** Full card innerText — LLM fallback */
490+
rawText: string;
491+
}
492+
459493
// ── Phase 2: Search & Property types ─────────────────────────────────────────
460494

461495
export interface SearchResult {
@@ -1086,6 +1120,257 @@ export async function extractSavedProperties(page: Page, count: number): Promise
10861120
return extractFromCurrentPage(page, "");
10871121
}
10881122

1123+
// ── Reviews extraction ────────────────────────────────────────────────────────
1124+
1125+
/** Candidate selectors for individual review cards inside the reviewlist page */
1126+
const REVIEW_CARD_SELECTORS = [
1127+
'[data-testid="review-card"]',
1128+
'.review_list_new_item_block',
1129+
'[data-review-id]',
1130+
'.c-review',
1131+
] as const;
1132+
1133+
/**
1134+
* Attempt to extract reviews from a Booking.com API response JSON blob.
1135+
* Supports the REST review gateway and GraphQL shapes.
1136+
*/
1137+
function extractReviewsFromApiResponse(body: Record<string, unknown>): Array<{
1138+
rawText: string; score: string; title: string; pros: string; cons: string;
1139+
reviewer: string; country: string; date: string; roomType: string; stayDuration: string; travellerType: string;
1140+
}> {
1141+
type ApiReview = Record<string, unknown>;
1142+
const results: ReturnType<typeof extractReviewsFromApiResponse> = [];
1143+
1144+
// Shape 1: { reviews: [...] } or { data: { reviews: [...] } }
1145+
const reviews: unknown[] =
1146+
(body["reviews"] as unknown[]) ||
1147+
((body["data"] as Record<string, unknown>)?.["reviews"] as unknown[]) ||
1148+
((body["result"] as Record<string, unknown>)?.["reviews"] as unknown[]) ||
1149+
[];
1150+
1151+
for (const rev of reviews) {
1152+
const r = rev as ApiReview;
1153+
const text = JSON.stringify(r);
1154+
results.push({
1155+
rawText: text.slice(0, 500),
1156+
score: String(r["average_score"] ?? r["score"] ?? r["rating"] ?? ""),
1157+
title: String(r["title"] ?? r["headline"] ?? ""),
1158+
pros: String(r["pros"] ?? r["positive"] ?? r["liked"] ?? ""),
1159+
cons: String(r["cons"] ?? r["negative"] ?? r["disliked"] ?? ""),
1160+
reviewer: String(r["author"] ?? r["reviewer_name"] ?? r["name"] ?? ""),
1161+
country: String(r["author_country"] ?? r["country"] ?? r["nationality"] ?? ""),
1162+
date: String(r["review_date"] ?? r["date"] ?? ""),
1163+
roomType: String(r["room_name"] ?? r["room_type"] ?? ""),
1164+
stayDuration: String(r["stay_duration"] ?? r["nights"] ?? ""),
1165+
travellerType: String(r["traveller_type"] ?? r["group_type"] ?? ""),
1166+
});
1167+
}
1168+
return results;
1169+
}
1170+
1171+
/**
1172+
* Extract guest reviews for a Booking.com property using the dedicated
1173+
* `reviewlist.html` paginated page.
1174+
*
1175+
* This is far simpler and more reliable than trying to control the reviews
1176+
* modal on the property page. The reviewlist URL is:
1177+
* www.booking.com/reviewlist.html?pagename=X&cc1=Y&type=total&rows=25&offset=N
1178+
*
1179+
* Sort params discovered from the reviewlist page UI:
1180+
* most_relevant → (no sort param)
1181+
* newest_first → sort=f_recent_desc
1182+
* oldest_first → sort=f_recent_asc
1183+
* highest_scores→ sort=f_score_desc
1184+
* lowest_scores → sort=f_score_asc
1185+
*
1186+
* Returns up to `count` reviews (max 75). Falls back gracefully on error.
1187+
*/
1188+
export async function extractPropertyReviews(
1189+
page: Page,
1190+
propertyUrl: string,
1191+
count: number,
1192+
sort: ReviewSort
1193+
): Promise<PropertyReview[]> {
1194+
// Extract pageName and countryCode from the property URL
1195+
// e.g. https://www.booking.com/hotel/it/il-castelluccio-countryresort.html
1196+
const urlMatch = propertyUrl.match(/\/hotel\/([a-z]{2})\/([^/?#]+?)(?:\.html)?(?:\?|$)/);
1197+
if (!urlMatch) {
1198+
return []; // not a recognisable Booking.com hotel URL
1199+
}
1200+
const [, cc1, pageName] = urlMatch;
1201+
1202+
const sortParam: Record<ReviewSort, string> = {
1203+
most_relevant: "",
1204+
newest_first: "f_recent_desc",
1205+
oldest_first: "f_recent_asc",
1206+
highest_scores: "f_score_desc",
1207+
lowest_scores: "f_score_asc",
1208+
};
1209+
1210+
const allCards: PropertyReview[] = [];
1211+
const seenKeys = new Set<string>();
1212+
const rowsPerPage = 25;
1213+
let offset = 0;
1214+
1215+
while (allCards.length < count) {
1216+
const params = new URLSearchParams({
1217+
pagename: pageName ?? "",
1218+
cc1: cc1 ?? "",
1219+
type: "total",
1220+
rows: String(rowsPerPage),
1221+
offset: String(offset),
1222+
lang: "en-us",
1223+
});
1224+
const sortVal = sortParam[sort];
1225+
if (sortVal) params.set("sort", sortVal);
1226+
1227+
const reviewListUrl = `https://www.booking.com/reviewlist.html?${params.toString()}`;
1228+
await page.goto(reviewListUrl, { waitUntil: "domcontentloaded", timeout: 25_000 });
1229+
await page.waitForTimeout(1_500);
1230+
1231+
// Extract review blocks from the page
1232+
const batch = await page.evaluate(
1233+
({ cardSels }: { cardSels: readonly string[] }) => {
1234+
type RawReview = {
1235+
rawText: string; score: string; title: string; pros: string; cons: string;
1236+
reviewer: string; country: string; date: string;
1237+
roomType: string; stayDuration: string; travellerType: string;
1238+
};
1239+
1240+
// Try data-testid card selectors first
1241+
let cards: HTMLElement[] = [];
1242+
for (const sel of cardSels) {
1243+
const found = Array.from(document.querySelectorAll(sel)) as HTMLElement[];
1244+
if (found.length > 0) { cards = found; break; }
1245+
}
1246+
1247+
// Fallback: extract from full page innerText by splitting on "Reviewed:" markers
1248+
if (cards.length === 0) {
1249+
const main = document.querySelector("main, #bodyconstraint-inner, .review_list") as HTMLElement | null;
1250+
const fullText = (main ?? document.body).innerText.trim();
1251+
1252+
// Split on Reviewed: anchors — each review starts with a reviewer name and ends
1253+
// just before the next reviewer's block
1254+
const blocks = fullText.split(/\n(?=\S.*?\n(?:Suite|Room|Apartment|Studio|Villa|Bungalow|Double|Twin|Single|Triple|Family|Deluxe|Standard|Superior|Classic|Luxury|Junior|Penthouse|Executive|Premiere)\n)/i);
1255+
1256+
if (blocks.length <= 1) {
1257+
// Second fallback: split on "Reviewed: " lines as anchors
1258+
const byReviewed = fullText.split(/(?=Reviewed:\s)/i);
1259+
if (byReviewed.length > 1) {
1260+
return byReviewed.slice(1).map((block: string): RawReview => {
1261+
const lines = block.split("\n").map((l: string) => l.trim()).filter((l: string) => l.length > 0);
1262+
const dateMatch = lines[0]?.match(/Reviewed:\s*(.+)/i);
1263+
const date = dateMatch?.[1]?.trim() ?? "";
1264+
const title = lines[1] ?? "";
1265+
const score = lines[2]?.match(/\b(10(?:\.0)?|[0-9]\.[0-9])\b/)?.[1] ?? "";
1266+
const prosLine = lines.find((l: string) => /^Liked\s*[·]/i.test(l));
1267+
const consLine = lines.find((l: string) => /^Disliked\s*[·]/i.test(l));
1268+
const pros = prosLine ? prosLine.replace(/^Liked\s*[·\s]*/i, "").trim() : "";
1269+
const cons = consLine ? consLine.replace(/^Disliked\s*[·\s]*/i, "").trim() : "";
1270+
return {
1271+
rawText: block.slice(0, 600),
1272+
score, title, pros, cons,
1273+
reviewer: "", country: "", date,
1274+
roomType: "", stayDuration: "", travellerType: "",
1275+
};
1276+
});
1277+
}
1278+
}
1279+
1280+
return [];
1281+
}
1282+
1283+
// Process found card elements
1284+
const results: RawReview[] = [];
1285+
for (const card of cards) {
1286+
const rawText = card.innerText?.trim() ?? "";
1287+
if (!rawText || rawText.length < 20) continue;
1288+
1289+
const scoreMatch = rawText.match(/\b(10(?:\.0)?|[0-9]\.[0-9])\b/);
1290+
const score = scoreMatch?.[1] ?? "";
1291+
1292+
const boldEl = card.querySelector("strong, b, h3, h4, [data-testid='review-title']") as HTMLElement | null;
1293+
let title = boldEl?.innerText?.trim() ?? "";
1294+
if (!title) {
1295+
const tm = rawText.match(/\b(Exceptional|Wonderful|Good|Superb|Fabulous|Pleasant|Poor|Okay)\b/);
1296+
title = tm?.[1] ?? "";
1297+
}
1298+
1299+
const posEl = card.querySelector('[data-testid="review-positive-text"], [data-testid="review-body-pos"]') as HTMLElement | null;
1300+
const negEl = card.querySelector('[data-testid="review-negative-text"], [data-testid="review-body-neg"]') as HTMLElement | null;
1301+
let pros = posEl?.innerText?.trim() ?? "";
1302+
let cons = negEl?.innerText?.trim() ?? "";
1303+
1304+
if (!pros && !cons) {
1305+
const prosLine = rawText.match(/Liked\s*[·][^\n]*/i)?.[0];
1306+
const consLine = rawText.match(/Disliked\s*[·][^\n]*/i)?.[0];
1307+
pros = prosLine ? prosLine.replace(/^Liked\s*[·\s]*/i, "").trim() : "";
1308+
cons = consLine ? consLine.replace(/^Disliked\s*[·\s]*/i, "").trim() : "";
1309+
if (!pros) {
1310+
pros = rawText.split("\n").filter((l: string) => l.trim().length > 20).slice(0, 3).join(" ").slice(0, 400);
1311+
}
1312+
}
1313+
1314+
const avatarEl = card.querySelector('[data-testid="review-author"], [class*="bui-avatar-block__title"]') as HTMLElement | null;
1315+
const countryEl = card.querySelector('[data-testid="review-country"], [class*="bui-avatar-block__subtitle"]') as HTMLElement | null;
1316+
let reviewer = avatarEl?.innerText?.trim() ?? "";
1317+
let country = countryEl?.innerText?.trim().replace(/[\u{1F000}-\u{1FFFF}]/gu, "").trim() ?? "";
1318+
if (!reviewer) {
1319+
const nameLines = rawText.split("\n").map((l: string) => l.trim()).filter((l: string) => l.length > 1 && l.length < 40);
1320+
reviewer = nameLines[0] ?? "";
1321+
if (!country) country = nameLines[1] ?? "";
1322+
}
1323+
1324+
const dateMatch = rawText.match(/reviewed[:\s]+([^\n]+)/i);
1325+
const date = dateMatch?.[1]?.trim() ?? "";
1326+
1327+
const metaMatch = rawText.match(/(\d+\s+nights?\s*[··]\s*\w+ \d{4})/i);
1328+
const stayDuration = metaMatch?.[1]?.trim() ?? "";
1329+
1330+
const roomMatch = rawText.match(/\bsuite\b|\broom\b|\bapartment\b|\bstudio\b|\bvilla\b|\bbungalow\b|\bdouble\b|\btwin\b|\bsingle\b|\bfamily\b/i);
1331+
const roomType = roomMatch
1332+
? rawText.split("\n").find((l: string) => new RegExp(roomMatch[0], "i").test(l))?.trim() ?? ""
1333+
: "";
1334+
1335+
const travellerMatch = rawText.match(/\b(couple|solo traveller|family|group|business traveller)\b/i);
1336+
const travellerType = travellerMatch?.[1]?.trim() ?? "";
1337+
1338+
results.push({ rawText, score, title, pros, cons, reviewer, country, date, roomType, stayDuration, travellerType });
1339+
}
1340+
return results;
1341+
},
1342+
{ cardSels: REVIEW_CARD_SELECTORS }
1343+
);
1344+
1345+
if (batch.length === 0) break; // no more reviews or page error
1346+
1347+
for (const raw of batch) {
1348+
const key = raw.rawText.slice(0, 80);
1349+
if (seenKeys.has(key)) continue;
1350+
seenKeys.add(key);
1351+
allCards.push({
1352+
reviewer: raw.reviewer,
1353+
country: raw.country,
1354+
score: raw.score ? parseFloat(raw.score) : null,
1355+
date: raw.date,
1356+
title: raw.title,
1357+
pros: raw.pros,
1358+
cons: raw.cons,
1359+
roomType: raw.roomType,
1360+
stayDuration: raw.stayDuration,
1361+
travellerType: raw.travellerType,
1362+
rawText: raw.rawText,
1363+
});
1364+
if (allCards.length >= count) break;
1365+
}
1366+
1367+
if (allCards.length >= count) break;
1368+
offset += rowsPerPage;
1369+
}
1370+
1371+
return allCards;
1372+
}
1373+
10891374
/** Extract properties from the currently loaded mywishlist page */
10901375
async function extractFromCurrentPage(page: Page, wishlistNameOverride: string): Promise<SavedProperty[]> {
10911376
return page.evaluate((nameOverride: string) => {

0 commit comments

Comments
 (0)