Skip to content

Commit 09568db

Browse files
committed
fix(saved): use network interception to capture GraphQL across all wishlists
Switches from manual GraphQL calls (blocked by CSRF) to response interception. page.on('response') captures the natural GraphQL calls the page makes on load, giving us clean structured data for ALL wishlists without CSRF complexity. Result: returns properties from all wishlists (Tuscany, My next trip x2, etc.) with full hotel name, location, rating, reviewCount, stars, price, propertyUrl. Made-with: Cursor
1 parent 740659b commit 09568db

1 file changed

Lines changed: 147 additions & 78 deletions

File tree

src/scraper.ts

Lines changed: 147 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
946946
export 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(/wl_id=(\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(/\/hotel\/([a-z]{2})\/([^.?#]+)/);
1128+
const pageMatch = href.match(/\/hotel\/([a-z]{2})\/([^/]+)(?:\.html)?$/);
10481129
const countryCode = pageMatch?.[1] ?? "";
10491130
const pageName = pageMatch?.[2] ?? "";
10501131

1051-
// Rating
10521132
const ratingMatch = rawText.match(/\b(8\.\d|9\.\d|10(?:\.0)?)\b/);
10531133
const rating = ratingMatch ? parseFloat(ratingMatch[1] ?? "0") : null;
10541134

1055-
// Reviews
10561135
const reviewMatch = rawText.match(/(\d[\d,]+)\s+(?:review|Rating)/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

Comments
 (0)