@@ -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
461495export 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 ( / \/ h o t e l \/ ( [ a - z ] { 2 } ) \/ ( [ ^ / ? # ] + ?) (?: \. h t m l ) ? (?: \? | $ ) / ) ;
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 (?: S u i t e | R o o m | A p a r t m e n t | S t u d i o | V i l l a | B u n g a l o w | D o u b l e | T w i n | S i n g l e | T r i p l e | F a m i l y | D e l u x e | S t a n d a r d | S u p e r i o r | C l a s s i c | L u x u r y | J u n i o r | P e n t h o u s e | E x e c u t i v e | P r e m i e r e ) \n ) / i) ;
1255+
1256+ if ( blocks . length <= 1 ) {
1257+ // Second fallback: split on "Reviewed: " lines as anchors
1258+ const byReviewed = fullText . split ( / (? = R e v i e w e d : \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 ( / R e v i e w e d : \s * ( .+ ) / i) ;
1263+ const date = dateMatch ?. [ 1 ] ?. trim ( ) ?? "" ;
1264+ const title = lines [ 1 ] ?? "" ;
1265+ const score = lines [ 2 ] ?. match ( / \b ( 1 0 (?: \. 0 ) ? | [ 0 - 9 ] \. [ 0 - 9 ] ) \b / ) ?. [ 1 ] ?? "" ;
1266+ const prosLine = lines . find ( ( l : string ) => / ^ L i k e d \s * [ · • ] / i. test ( l ) ) ;
1267+ const consLine = lines . find ( ( l : string ) => / ^ D i s l i k e d \s * [ · • ] / i. test ( l ) ) ;
1268+ const pros = prosLine ? prosLine . replace ( / ^ L i k e d \s * [ · • \s ] * / i, "" ) . trim ( ) : "" ;
1269+ const cons = consLine ? consLine . replace ( / ^ D i s l i k e d \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 ( 1 0 (?: \. 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 ( E x c e p t i o n a l | W o n d e r f u l | G o o d | S u p e r b | F a b u l o u s | P l e a s a n t | P o o r | O k a y ) \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 ( / L i k e d \s * [ · • ] [ ^ \n ] * / i) ?. [ 0 ] ;
1306+ const consLine = rawText . match ( / D i s l i k e d \s * [ · • ] [ ^ \n ] * / i) ?. [ 0 ] ;
1307+ pros = prosLine ? prosLine . replace ( / ^ L i k e d \s * [ · • \s ] * / i, "" ) . trim ( ) : "" ;
1308+ cons = consLine ? consLine . replace ( / ^ D i s l i k e d \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 ( / r e v i e w e d [: \s] + ( [ ^ \n ] + ) / i) ;
1325+ const date = dateMatch ?. [ 1 ] ?. trim ( ) ?? "" ;
1326+
1327+ const metaMatch = rawText . match ( / ( \d + \s + n i g h t s ? \s * [ · • · ] \s * \w + \d { 4 } ) / i) ;
1328+ const stayDuration = metaMatch ?. [ 1 ] ?. trim ( ) ?? "" ;
1329+
1330+ const roomMatch = rawText . match ( / \b s u i t e \b | \b r o o m \b | \b a p a r t m e n t \b | \b s t u d i o \b | \b v i l l a \b | \b b u n g a l o w \b | \b d o u b l e \b | \b t w i n \b | \b s i n g l e \b | \b f a m i l y \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 ( c o u p l e | s o l o t r a v e l l e r | f a m i l y | g r o u p | b u s i n e s s t r a v e l l e r ) \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 */
10901375async function extractFromCurrentPage ( page : Page , wishlistNameOverride : string ) : Promise < SavedProperty [ ] > {
10911376 return page . evaluate ( ( nameOverride : string ) => {
0 commit comments