@@ -944,138 +944,207 @@ async function callGraphQL<T>(page: Page, query: string, variables?: Record<stri
944944 * Returns all hotels across all wishlists, up to `count`.
945945 */
946946export async function extractSavedProperties ( page : Page , count : number ) : Promise < SavedProperty [ ] > {
947- // Navigate to the wishlist page — confirmed URL and works when logged in
947+ // Intercept the GraphQL responses that the page naturally makes when loading mywishlist.html.
948+ // The page calls wishlistsDetailForWishlistWidget + userWishlistById automatically.
949+ // We capture those responses instead of trying to replay the API call ourselves
950+ // (which fails due to CSRF token complexity).
951+
952+ const capturedWishlists : WishlistGraphQLResponse [ "data" ] [ "wishlistService" ] [ "userWishlist" ] = undefined as never ;
953+ const capturedListDetails = new Map < string , NonNullable < WishlistGraphQLResponse [ "data" ] [ "wishlistService" ] [ "userWishlistById" ] > > ( ) ;
954+ void capturedListDetails ; // unused — interceptedData.listDetails used instead
955+
956+ const interceptedData : {
957+ wishlists ?: Array < { listId : number ; nbHotels : number } > ;
958+ listDetails : Map < string , { hotels : Array < {
959+ displayName : string ; pageName : string ;
960+ location : { displayLocation : string ; countryCode : string } ;
961+ reviews : { totalScore : number ; reviewsCount : number } ;
962+ starRating ?: { value : number } | null ;
963+ availabilityData ?: { isSoldOut : boolean ; priceDisplayInfo ?: { displayPrice ?: { amountPerStay ?: { amountRounded : string ; currency : string } } } } ;
964+ } > ; name : string | null ; listId : number } > ;
965+ } = { listDetails : new Map ( ) } ;
966+
967+ // Set up response interceptor BEFORE navigation
968+ page . on ( "response" , async ( response ) => {
969+ const url = response . url ( ) ;
970+ if ( ! url . includes ( "/dml/graphql" ) ) return ;
971+ try {
972+ const body = await response . json ( ) as WishlistGraphQLResponse ;
973+ if ( ! body ?. data ?. wishlistService ) return ;
974+
975+ // Capture wishlists list
976+ const userWishlist = body . data . wishlistService . userWishlist ;
977+ if ( userWishlist ?. wishlists && userWishlist . wishlists . length > 0 ) {
978+ interceptedData . wishlists = userWishlist . wishlists ;
979+ }
980+
981+ // Capture individual wishlist details
982+ const wishlistById = body . data . wishlistService . userWishlistById ;
983+ if ( wishlistById ?. wishlist ) {
984+ const wl = wishlistById . wishlist ;
985+ const listId = String ( wl . listId ?? "" ) ;
986+ if ( listId && wl . hotels ) {
987+ interceptedData . listDetails . set ( listId , {
988+ listId : wl . listId ?? 0 ,
989+ name : wl . name ,
990+ hotels : ( wl . hotels ?? [ ] ) . map ( ( h ) => {
991+ const d = h . details ;
992+ return {
993+ displayName : d . displayName ,
994+ pageName : d . pageName ,
995+ location : d . location ,
996+ reviews : d . reviews ,
997+ starRating : d . starRating ,
998+ availabilityData : d . availabilityData ,
999+ } ;
1000+ } ) ,
1001+ } ) ;
1002+ }
1003+ }
1004+ } catch { /* ignore parse errors */ }
1005+ } ) ;
1006+
1007+ // Navigate — this triggers the natural GraphQL calls
9481008 await page . goto ( "https://www.booking.com/mywishlist.html" , {
9491009 waitUntil : "domcontentloaded" ,
9501010 timeout : 20_000 ,
9511011 } ) ;
952- // Wait for React to render the wishlist content
953- await page . waitForTimeout ( 3_000 ) ;
1012+ await page . waitForTimeout ( 3_500 ) ; // wait for API calls to complete
9541013
955- // First try: GraphQL API for structured data
956- // Falls through to DOM extraction if GraphQL CSRF fails
957- try {
958- const listResp = await callGraphQL < WishlistGraphQLResponse > ( page , WISHLIST_LIST_QUERY ) ;
959- const wishlists = listResp ?. data ?. wishlistService ?. userWishlist ?. wishlists ?? [ ] ;
1014+ // If we captured GraphQL data, use it (best quality)
1015+ if ( interceptedData . listDetails . size > 0 ) {
1016+ const results : SavedProperty [ ] = [ ] ;
9601017
961- if ( wishlists . length > 0 ) {
962- const results : SavedProperty [ ] = [ ] ;
1018+ for ( const [ listId , wl ] of interceptedData . listDetails ) {
1019+ if ( results . length >= count ) break ;
1020+ const wishlistName = wl . name ?? `Wishlist ${ listId } ` ;
9631021
964- for ( const wl of wishlists ) {
1022+ for ( const d of wl . hotels ) {
1023+ if ( results . length >= count ) break ;
1024+ const priceInfo = d . availabilityData ?. priceDisplayInfo ?. displayPrice ?. amountPerStay ;
1025+
1026+ results . push ( {
1027+ name : d . displayName ,
1028+ location : `${ d . location ?. displayLocation ?? "" } , ${ ( d . location ?. countryCode ?? "" ) . toUpperCase ( ) } ` ,
1029+ rating : d . reviews ?. totalScore ?? null ,
1030+ reviewCount : d . reviews ?. reviewsCount ?? 0 ,
1031+ stars : d . starRating ?. value ?? null ,
1032+ price : priceInfo ?. amountRounded ?? "" ,
1033+ currency : priceInfo ?. currency ?? "" ,
1034+ isSoldOut : d . availabilityData ?. isSoldOut ?? false ,
1035+ propertyUrl : `https://www.booking.com/hotel/${ d . location ?. countryCode ?? "xx" } /${ d . pageName } .html` ,
1036+ pageName : d . pageName ,
1037+ wishlistId : listId ,
1038+ wishlistName,
1039+ } ) ;
1040+ }
1041+ }
1042+
1043+ // If we have the wishlists list but not all their details, navigate to remaining ones
1044+ if ( interceptedData . wishlists && interceptedData . wishlists . length > interceptedData . listDetails . size ) {
1045+ for ( const wl of interceptedData . wishlists ) {
9651046 if ( results . length >= count ) break ;
9661047 if ( wl . nbHotels === 0 ) continue ;
1048+ if ( interceptedData . listDetails . has ( String ( wl . listId ) ) ) continue ;
9671049
968- const detailResp = await callGraphQL < WishlistGraphQLResponse > (
969- page ,
970- WISHLIST_HOTELS_QUERY ,
971- { input : { listId : String ( wl . listId ) , verticals : [ "ACCOMMODATION" ] } }
972- ) ;
973-
974- const wishlistData = detailResp ?. data ?. wishlistService ?. userWishlistById ?. wishlist ;
975- if ( ! wishlistData ) continue ;
976-
977- const wishlistName = wishlistData . name ?? `Wishlist ${ wl . listId } ` ;
978-
979- for ( const hotel of ( wishlistData . hotels ?? [ ] ) ) {
980- if ( results . length >= count ) break ;
981- const d = hotel . details ;
982- if ( ! d ) continue ;
983-
984- const priceInfo = d . availabilityData ?. priceDisplayInfo ?. displayPrice ?. amountPerStay ;
985-
986- results . push ( {
987- name : d . displayName ,
988- location : `${ d . location ?. displayLocation ?? "" } , ${ ( d . location ?. countryCode ?? "" ) . toUpperCase ( ) } ` ,
989- rating : d . reviews ?. totalScore ?? null ,
990- reviewCount : d . reviews ?. reviewsCount ?? 0 ,
991- stars : d . starRating ?. value ?? null ,
992- price : priceInfo ?. amountRounded ?? "" ,
993- currency : priceInfo ?. currency ?? "" ,
994- isSoldOut : d . availabilityData ?. isSoldOut ?? false ,
995- propertyUrl : `https://www.booking.com/hotel/${ d . location ?. countryCode ?? "xx" } /${ d . pageName } .html` ,
996- pageName : d . pageName ,
997- wishlistId : String ( wl . listId ) ,
998- wishlistName,
999- } ) ;
1050+ // Navigate to this specific wishlist to trigger its GraphQL load
1051+ await page . goto ( `https://www.booking.com/mywishlist.html?wl_id=${ wl . listId } ` , {
1052+ waitUntil : "domcontentloaded" ,
1053+ timeout : 15_000 ,
1054+ } ) ;
1055+ await page . waitForTimeout ( 2_500 ) ;
1056+
1057+ const freshData = interceptedData . listDetails . get ( String ( wl . listId ) ) ;
1058+ if ( freshData ) {
1059+ const wishlistName = freshData . name ?? `Wishlist ${ wl . listId } ` ;
1060+ for ( const d of freshData . hotels ) {
1061+ if ( results . length >= count ) break ;
1062+ const priceInfo = d . availabilityData ?. priceDisplayInfo ?. displayPrice ?. amountPerStay ;
1063+ results . push ( {
1064+ name : d . displayName ,
1065+ location : `${ d . location ?. displayLocation ?? "" } , ${ ( d . location ?. countryCode ?? "" ) . toUpperCase ( ) } ` ,
1066+ rating : d . reviews ?. totalScore ?? null ,
1067+ reviewCount : d . reviews ?. reviewsCount ?? 0 ,
1068+ stars : d . starRating ?. value ?? null ,
1069+ price : priceInfo ?. amountRounded ?? "" ,
1070+ currency : priceInfo ?. currency ?? "" ,
1071+ isSoldOut : d . availabilityData ?. isSoldOut ?? false ,
1072+ propertyUrl : `https://www.booking.com/hotel/${ d . location ?. countryCode ?? "xx" } /${ d . pageName } .html` ,
1073+ pageName : d . pageName ,
1074+ wishlistId : String ( wl . listId ) ,
1075+ wishlistName,
1076+ } ) ;
1077+ }
10001078 }
10011079 }
1002-
1003- if ( results . length > 0 ) return results ;
10041080 }
1005- } catch {
1006- // GraphQL failed — fall through to DOM extraction
1081+
1082+ if ( results . length > 0 ) return results ;
10071083 }
10081084
1009- // Fallback: DOM extraction from the rendered wishlist page
1010- // The page renders property cards with names and links even without GraphQL
1011- return page . evaluate ( ( maxCount : number ) => {
1085+ // Final fallback: DOM extraction from current page
1086+ return extractFromCurrentPage ( page , "" ) ;
1087+ }
1088+
1089+ /** Extract properties from the currently loaded mywishlist page */
1090+ async function extractFromCurrentPage ( page : Page , wishlistNameOverride : string ) : Promise < SavedProperty [ ] > {
1091+ return page . evaluate ( ( nameOverride : string ) => {
10121092 const results : Array < {
10131093 name : string ; location : string ; rating : number | null ; reviewCount : number ;
10141094 stars : number | null ; price : string ; currency : string ; isSoldOut : boolean ;
10151095 propertyUrl : string ; pageName : string ; wishlistId : string ; wishlistName : string ;
10161096 } > = [ ] ;
10171097
1018- // Get current wishlist name from heading
1019- const heading = document . querySelector ( "h1, h2, [class*='title'], [class*='heading']" ) as HTMLElement | null ;
1020- const wishlistName = heading ?. innerText ?. trim ( ) ?? "My Wishlist" ;
1098+ // Get wishlist name from heading
1099+ const headings = Array . from ( document . querySelectorAll ( "h1, h2, h3" ) ) as HTMLElement [ ] ;
1100+ const heading = headings . find ( h => h . innerText && h . innerText . trim ( ) . length > 1 && h . innerText . trim ( ) . length < 80 ) ;
1101+ const wishlistName = nameOverride || heading ?. innerText ?. trim ( ) || "Saved" ;
1102+
1103+ // Get current wl_id from URL
1104+ const urlMatch = window . location . href . match ( / w l _ i d = ( \d + ) / ) ;
1105+ const wishlistId = urlMatch ?. [ 1 ] ?? "" ;
10211106
1022- // Find all property cards — look for hotel links
1107+ // Find all property card links
10231108 const anchors = Array . from ( document . querySelectorAll ( "a[href*='/hotel/']" ) ) as HTMLAnchorElement [ ] ;
10241109 const seen = new Set < string > ( ) ;
10251110
10261111 for ( const anchor of anchors ) {
1027- if ( results . length >= maxCount ) break ;
1028- const href = anchor . href ;
1112+ const href = anchor . href ?. split ( "?" ) [ 0 ] ?? "" ;
10291113 if ( ! href || seen . has ( href ) ) continue ;
10301114
1031- // Skip navigation links — we want card links (they have text/images)
10321115 const cardText = anchor . innerText ?. trim ( ) ;
10331116 if ( ! cardText || cardText . length < 3 ) continue ;
10341117
10351118 seen . add ( href ) ;
10361119
1037- // Walk up to find the card container
1120+ // Walk up to card container
10381121 let el : HTMLElement = anchor ;
10391122 for ( let i = 0 ; i < 6 && el . parentElement ; i ++ ) {
10401123 el = el . parentElement as HTMLElement ;
10411124 if ( el . offsetHeight > 100 ) break ;
10421125 }
10431126
10441127 const rawText = el . innerText ?. trim ( ) ?? "" ;
1045-
1046- // Extract page name from URL: /hotel/{country}/{pagename}.html
1047- const pageMatch = href . match ( / \/ h o t e l \/ ( [ a - z ] { 2 } ) \/ ( [ ^ . ? # ] + ) / ) ;
1128+ const pageMatch = href . match ( / \/ h o t e l \/ ( [ a - z ] { 2 } ) \/ ( [ ^ / ] + ) (?: \. h t m l ) ? $ / ) ;
10481129 const countryCode = pageMatch ?. [ 1 ] ?? "" ;
10491130 const pageName = pageMatch ?. [ 2 ] ?? "" ;
10501131
1051- // Rating
10521132 const ratingMatch = rawText . match ( / \b ( 8 \. \d | 9 \. \d | 1 0 (?: \. 0 ) ? ) \b / ) ;
10531133 const rating = ratingMatch ? parseFloat ( ratingMatch [ 1 ] ?? "0" ) : null ;
10541134
1055- // Reviews
10561135 const reviewMatch = rawText . match ( / ( \d [ \d , ] + ) \s + (?: r e v i e w | R a t i n g ) / i) ;
10571136 const reviewCount = reviewMatch ? parseInt ( ( reviewMatch [ 1 ] ?? "0" ) . replace ( / , / g, "" ) ) : 0 ;
10581137
1059- // Name: first meaningful line
1060- const name = rawText . split ( "\n" ) . find ( l => l . trim ( ) . length > 3 && l . trim ( ) . length < 100 ) ?. trim ( ) ?? "" ;
1138+ const name = rawText . split ( "\n" ) . find ( ( l : string ) => l . trim ( ) . length > 3 && l . trim ( ) . length < 100 ) ?. trim ( ) ?? "" ;
10611139
10621140 results . push ( {
1063- name,
1064- location : countryCode . toUpperCase ( ) ,
1065- rating,
1066- reviewCount,
1067- stars : null ,
1068- price : "" ,
1069- currency : "" ,
1070- isSoldOut : false ,
1071- propertyUrl : href . split ( "?" ) [ 0 ] ?? href ,
1072- pageName,
1073- wishlistId : "" ,
1074- wishlistName,
1141+ name, location : countryCode . toUpperCase ( ) , rating, reviewCount,
1142+ stars : null , price : "" , currency : "" , isSoldOut : false ,
1143+ propertyUrl : href , pageName, wishlistId, wishlistName,
10751144 } ) ;
10761145 }
10771146
10781147 return results ;
1079- } , count ) ;
1148+ } , wishlistNameOverride ) ;
10801149}
10811150
0 commit comments