<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Expedia Cars GraphQL Inspector: How I Turned Expedia's Network Traffic into Instant CSV Exports]]></title><description><![CDATA[Expedia Cars GraphQL Inspector: How I Turned Expedia's Network Traffic into Instant CSV Exports]]></description><link>https://graphql-inspector.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a454acb5d9058bde87a089e/d6961bc6-b707-4a72-818a-2e1b401a5a54.webp</url><title>Expedia Cars GraphQL Inspector: How I Turned Expedia&apos;s Network Traffic into Instant CSV Exports</title><link>https://graphql-inspector.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 06:18:37 GMT</lastBuildDate><atom:link href="https://graphql-inspector.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Expedia Cars GraphQL Inspector: How I Turned Expedia's Network Traffic into Instant CSV Exports]]></title><description><![CDATA[Expedia GraphQL Inspector v2.2 is a browser tool that intercepts graphql requests & collects every car data across every page and dumps it into a CSV.]]></description><link>https://graphql-inspector.hashnode.dev/expedia-cars-graphql-inspector-how-i-turned-expedia-s-network-traffic-into-instant-csv-exports</link><guid isPermaLink="true">https://graphql-inspector.hashnode.dev/expedia-cars-graphql-inspector-how-i-turned-expedia-s-network-traffic-into-instant-csv-exports</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[UserScript]]></category><category><![CDATA[automation]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[Tampermonkey]]></category><category><![CDATA[webscraping ]]></category><category><![CDATA[#DataExtractionTools]]></category><category><![CDATA[data extraction]]></category><dc:creator><![CDATA[Hamza]]></dc:creator><pubDate>Wed, 01 Jul 2026 17:42:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a454acb5d9058bde87a089e/826a49a1-0831-45aa-b70d-af178273f92a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Article Revision 2.0</em></p>
<p>I got tired of clicking "Load More."</p>
<p>You know the drill: you search for rental cars, the page shows 25 results, and there are 30 more pages hiding behind that little button. Click. Wait. Scroll. Click. Wait. Scroll. Your cursor finger starts hurting. Your soul starts dying.</p>
<p>So I built something better.</p>
<p><strong>Expedia GraphQL Inspector v2.5</strong> is a browser tool that intercepts Expedia's internal API calls, collects every rental car across every page in seconds, and dumps it into a CSV you can open in Excel. It runs in <em>your</em> browser. No Python, no Docker, no headless Chrome, no PhD in web scraping.</p>
<p>This is the story of how it works, piece by piece.</p>
<hr />
<h2>1. Fetch Interception: Catching Data Before It Renders</h2>
<p>Every time you visit a modern website, your browser plays middleman. It sends requests to servers, gets back JSON blobs, and hands them to JavaScript that paints pixels on screen.</p>
<p>The trick? <strong>Get in the middle before the pixels happen.</strong></p>
<p>The script monkey-patches <code>window.fetch</code> (the browser's built-in HTTP request function) and wraps it with a spy. Every single network request passes through this wrapper. The script inspects each one, asks "is this an Expedia car rental GraphQL query?", and if yes, clones the response JSON before the page ever sees it.</p>
<pre><code class="language-javascript">const originalFetch = window.fetch;

window.fetch = async function (...args) {
  const request = args[0];
  const options = args[1] || {};

  // Skip non-GraphQL URLs immediately
  const url = typeof request === "string" ? request : request.url;
  if (!url.includes("/graphql")) {
    return originalFetch.apply(this, args);
  }

  const response = await originalFetch.apply(this, args);

  // Only check request bodies for POSTs
  let requestBody = null;
  if (options.body) {
    try { requestBody = JSON.parse(options.body); }
    catch { /* not JSON, skip */ }
  }

  // Filter by operation name, not response shape
  if (!requestBody || requestBody.operationName !== "CarSearchV3") {
    return response;
  }

  const json = await response.clone().json();
  captureData(json);
  return response;
};
</code></pre>
<p>That's the entire foundation. The original <code>fetch</code> still works normally; the page never even knows it's being watched. We just take a peek, copy what we need, and let the data flow through.</p>
<p>A critical detail: the script sets a <code>__replaying</code> flag during its own programmatic requests. This prevents the hook from re-capturing pages that the auto-loader fetched itself, which would otherwise corrupt the log with duplicates.</p>
<p><strong>Why this beats scraping HTML:</strong> HTML is messy. It's designed for human eyes, not data extraction. Prices show up as formatted strings ("$123.45"), layout changes break your CSS selectors, and missing fields cause silent errors. Raw API data is pristine: typed, structured, complete. Why reconstruct what the server already sent perfectly?</p>
<hr />
<h2>2. GraphQL: One Endpoint to Rule Them All</h2>
<p>If you've only ever worked with REST APIs, GraphQL looks weird at first. Instead of ten different URLs (one for users, one for products, one for search results), GraphQL uses a <em>single endpoint</em> with a query language.</p>
<p>Expedia's car search runs on a single GraphQL endpoint. Every request (first page, next page, sort by price, filter by supplier) hits the same URL with a different query payload inside the POST body.</p>
<p>The script doesn't care about URLs. It cares about <strong>operation name</strong>. Every GraphQL request has one: a human-readable label like <code>CarSearchV3</code>. The script filters for exactly that:</p>
<pre><code class="language-javascript">if (requestBody.operationName === "CarSearchV3") {
  // This is the one we want
}
</code></pre>
<p>GraphQL responses have a predictable structure. The actual Expedia car search response looks like this (no edges, no nodes, no cursors):</p>
<pre><code class="language-json">{
  "data": {
    "carSearchOrRecommendations": {
      "carSearchResults": {
        "carsShoppingContext": {
          "searchId": "31c6ca70-7764-4452-a228-01f344ad6351"
        },
        "listings": [
          {
            "vehicle": {
              "category": "Economy",
              "description": "Chevrolet Spark or similar",
              "attributes": [
                { "icon": { "description": "transmission" }, "text": "Automatic" },
                { "icon": { "description": "mileage" }, "text": "Unlimited mileage" }
              ]
            },
            "vendor": { "image": { "description": "Budget" } },
            "priceSummary": {
              "lead": { "price": { "amount": 139.73, "currencyInfo": { "code": "USD" } } },
              "total": { "price": { "amount": 188.26, "currencyInfo": { "code": "USD" } } }
            },
            "review": { "rating": "46%" }
          }
        ],
        "loadMoreAction": {
          "searchPagination": {
            "startingIndex": 0,
            "hasNextPage": true,
            "size": 25
          }
        }
      }
    }
  }
}
</code></pre>
<p>One query, one shape, infinite cars. Beautiful.</p>
<hr />
<h2>3. Pagination: The Offset Trap</h2>
<p>Expedia doesn't send you 700 cars at once. That'd be too easy. It sends 25, plus an offset value that says "the next page starts here."</p>
<p>The page shows a "Show More" button. Click it, and the browser sends a new GraphQL query with an incremented page index in the request variables.</p>
<p>Manual pagination is torture. Auto-pagination is a solved problem:</p>
<ol>
<li>Capture the first page's response</li>
<li>Read <code>loadMoreAction.searchPagination.hasNextPage</code></li>
<li>Extract the current <code>startingIndex</code> and <code>size</code></li>
<li>Replay the same GraphQL query with <code>selPageIndex</code> set to the next offset inside <code>secondaryCriteria.selections</code></li>
<li>Repeat until <code>hasNextPage</code> is <code>false</code></li>
</ol>
<p>There's a subtle bug to watch out for: <strong>duplicates</strong>. Sometimes the same page offset appears twice (if the user refreshes or the session restores from localStorage). The script maintains a <code>Set</code> of <code>searchId:startingIndex</code> keys and skips any page it's already seen. Deduplication with zero overhead, just a simple <code>Set.has()</code> call (O(1) per page).</p>
<hr />
<h2>4. Replay Requests: Acting Like a Human, Moving Like a Machine</h2>
<p>The first page is captured from the user's real search. But what about page 2, 3, 4 through 47?</p>
<p>The script <strong>replays</strong> requests programmatically. It takes the original GraphQL query payload, deep-clones it, increments the page offset inside <code>secondaryCriteria.selections</code>, and fires a fresh <code>fetch</code> call. Same endpoint, same headers (minus <code>content-length</code> and <code>host</code>), same authentication cookies, just a different page index.</p>
<pre><code class="language-javascript">async function replayLoadMore(prevEntry) {
  const body = structuredClone(prevEntry.request);

  const selections = body.variables.secondaryCriteria.selections;
  const nextIndex = prevEntry.loadMore.searchPagination.startingIndex
                  + prevEntry.listingCount;

  // Set the next page offset into the request variables
  setSelection(selections, "selPageIndex", nextIndex);
  setSelection(selections, "searchId", prevEntry.searchId);
  setSelection(selections, "selPageCount", 25);

  const response = await fetch(prevEntry.url, {
    method: "POST",
    credentials: "include",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body)
  });

  return response.json();
}
</code></pre>
<p>The magic is that <strong>the user's session is intact</strong>. No login tokens to manage. No cookies to rotate. The browser handles authentication automatically. The script just borrows the connection.</p>
<p>But there's a catch: you can't fire 50 requests instantly. Servers notice. So the script has <strong>three speed presets</strong> with random jitter to avoid pattern detection:</p>
<table>
<thead>
<tr>
<th>Speed</th>
<th>Base Delay</th>
<th>Jitter Range</th>
<th>Effective Range</th>
</tr>
</thead>
<tbody><tr>
<td>Slow</td>
<td>4s</td>
<td>0–3s</td>
<td>4–7s</td>
</tr>
<tr>
<td>Medium</td>
<td>1.5s</td>
<td>0–1.5s</td>
<td>1.5–3s</td>
</tr>
<tr>
<td>Fast</td>
<td>0.6s</td>
<td>0–0.8s</td>
<td>0.6–1.4s</td>
</tr>
</tbody></table>
<p><strong>Smart Retry:</strong> If a request fails, the script retries with exponential backoff (base times 2^attempt). For 403 responses, it uses a more aggressive exponential base (times 3^attempt) and respects the server's <code>Retry-After</code> header if present. The timeout is capped at 30 seconds per request.</p>
<hr />
<h2>5. CSV Generation: From Nested JSON to Spreadsheet in One Click</h2>
<p>GraphQL returns deeply nested JSON. A single car listing looks like this in the actual Expedia response:</p>
<pre><code class="language-json">{
  "vehicle": {
    "category": "Compact",
    "description": "Kia Soul or similar",
    "attributes": [
      { "icon": { "id": "transmission" }, "text": "Automatic" },
      { "icon": { "id": "speed" }, "text": "Unlimited mileage" }
    ]
  },
  "vendor": { "image": { "description": "Budget" } },
  "priceSummary": {
    "lead": { "price": { "amount": 139.73, "currencyInfo": { "code": "USD" } } },
    "total": { "price": { "amount": 188.26, "currencyInfo": { "code": "USD" } } }
  },
  "review": { "rating": "46%" },
  "actionableConfidenceMessages": [
    { "value": "Free cancellation" },
    { "value": "Pay at pick-up" }
  ],
  "tripsSaveItemWrapper": {
    "tripsSaveItem": {
      "attributes": {
        "categoryCode": "C",
        "typeCode": "C",
        "transmissionDriveCode": "A",
        "fuelAcCode": "R",
        "searchCriteria": {
          "pickUpDateTime": { "day": 11, "month": 7, "year": 2026, "hour": 10, "minute": 30 },
          "dropOffDateTime": { "day": 12, "month": 7, "year": 2026, "hour": 10, "minute": 30 },
          "pickUpLocation": { "airportCode": "LGA" }
        }
      }
    }
  }
}
</code></pre>
<p>Your boss doesn't want that. Your boss wants columns: <strong>Supplier, Vehicle, Total Price, Daily Price, Pickup Date, Dropoff Date</strong>.</p>
<p>The script extracts values by walking known paths directly, no recursive field map needed:</p>
<pre><code class="language-javascript">car.vendor?.image?.description;                     // Supplier
car.vehicle?.description;                            // Vehicle example
car.priceSummary?.total?.price?.amount;              // Total price
car.priceSummary?.lead?.price?.amount;               // Daily price
car.tripsSaveItemWrapper?.tripsSaveItem?.attributes
  ?.searchCriteria?.pickUpDateTime;                  // Pickup date/time
</code></pre>
<p>Then it serializes to CSV using the standard RFC 4180 format; it handles commas inside values, wraps strings in quotes, and escapes double quotes. No library needed, just careful string building.</p>
<p>The download happens via a classic browser trick:</p>
<pre><code class="language-javascript">const blob = new Blob([csvContent], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "expedia_results.csv";
a.click();
URL.revokeObjectURL(url);
</code></pre>
<p><strong>No server involved.</strong> The CSV is generated entirely in the browser and downloaded instantly. Private data never touches a third party.</p>
<p>The exported CSV includes 18 columns: Provider, Location Code, Pickup Date, Pickup Time, Return Date, Duration Days, Supplier, Supplier Raw, ACRISS code, Vehicle Example, Currency, Total Price, Price Daily, Rating, Transmission, Mileage, Free Cancellation, Pay At Pickup, and Prepaid.</p>
<hr />
<h2>Putting It All Together: The 30-Second Workflow</h2>
<ol>
<li><strong>Search for cars</strong> on Expedia like you normally would, then wait for the results page to fully load</li>
<li>The script starts capturing automatically: you see a counter tick up</li>
<li>Click <strong>↻ Auto</strong> (the gold button), and the progress bar appears</li>
<li>Watch it fly through 30+ pages in about 30 seconds on Fast speed (longer on Medium/Slow)</li>
<li>A CSV downloads automatically: <strong>700+ cars, structured, ready</strong></li>
</ol>
<p>That's the difference between tapping the source directly vs. scraping rendered HTML. 30 seconds vs. 15 minutes. No Python setup, no Playwright config, no CAPTCHA headaches.</p>
<blockquote>
<p><strong>Note:</strong> The auto-loader requires at least one page of search results to be captured first. If you haven't searched yet or clicked Clear, the Auto button will prompt you to perform a search first. Times above are achieved on the Fast speed setting, while the default Medium speed takes roughly 45–90 seconds for 30 pages due to jitter-based delays.</p>
</blockquote>
<hr />
<h2>What I Learned Building This</h2>
<p><strong>Fetch interception is absurdly powerful.</strong> A dozen lines of monkey-patching gives you visibility into <em>everything</em> a website does. It's like having X-ray vision for network requests.</p>
<p><strong>GraphQL makes scraping easier, not harder.</strong> One query shape, transparent variable passing, and deterministic responses. If you understand the one query, you understand the whole API.</p>
<p><strong>Offset pagination is simpler than cursors for replay.</strong> No base64 tokens to manage, just increment an integer and set it in the request body's selections array.</p>
<p><strong>Replay with real sessions is king, but has constraints.</strong> No proxies, no auth tokens, no session management. The user's browser handles all of that. You just borrow the connection. This also means you're locked into being single-user and real-time dependent: close the tab and it stops. That's by design (it's a userscript), but worth knowing if you're considering production use.</p>
<p><strong>Schema versioning is the real maintenance headache.</strong> The CSV export traverses specific nested paths like <code>priceSummary.total.price.amount</code>. If Expedia restructures those fields, the export breaks silently. The fetch interception works as long as the <code>CarSearchV3</code> operation name stays the same, but the extraction logic is coupled to the schema. Duck-typing (matching on shape rather than name) would make this more resilient, something to explore in a future version.</p>
<p><strong>CSV is still the universal format.</strong> JSON is for machines. CSV is for humans with Excel. Don't overthink it, just flatten, serialize, and download.</p>
<p><strong>Expedia GraphQL Inspector v2.5</strong> exists because your time is worth more than clicking "Load More" 47 times.</p>
<hr />
<h2>Known Limitations</h2>
<p>Before using this in production or adapting it for other sites, be aware of these constraints:</p>
<ul>
<li><strong>Content Security Policy (CSP):</strong> If a site locks down <code>unsafe-eval</code> or inline scripts via CSP, the <code>fetch</code> monkey-patching may be blocked entirely. Expedia doesn't do this, but sites like Airbnb do.</li>
<li><strong>JWT/Session Expiry:</strong> The script borrows your browser session. If your authentication token expires mid-extraction (like a 30-minute JWT), subsequent replay requests will return 401s and the extraction stops.</li>
<li><strong>Persisted Queries:</strong> Some sites (Airbnb, GitHub) use persisted queries (opaque hashes instead of operation names). The <code>operationName</code> filter won't work there. You would need to match on URL patterns or query hash instead.</li>
<li><strong>Single-User, Real-Time:</strong> This is a userscript running in your browser. It's not a server-side daemon. Closing the tab ends the session.</li>
<li><strong>Expedia Car Rentals Only:</strong> Only captures the <code>CarSearchV3</code> query. Hotels, flights, and packages are not supported.</li>
</ul>
<hr />
<h2>Corrections by Users</h2>
<h3>Retry-After Support Added</h3>
<p>After @nazar_boyko (from Dev.to) pointed out that a fixed retry can actually make a <strong>429</strong> worse, I added <code>Retry-After</code> support to <strong>v2.3</strong>. The script now reads the server's <code>Retry-After</code> header when it gets hit with rate limiting, and backs off to whatever Expedia actually asks for instead of guessing. If no <code>Retry-After</code> is present it still uses the <strong>exponential backoff</strong> as before. There was also a quiet bug in <code>fetchWithRetry</code> where non-200 responses would loop back immediately with zero delay, and that's fixed too.</p>
<h3>Several Corrections In The Article Added</h3>
<p><em>Thanks to <a href="https://hashnode.com/@onlineproxyio" class="user-mention" data-type="mention" title="OnlineProxy">OnlineProxy</a> for the thoughtful code review. His feedback led to the Known Limitations section above, the schema versioning warning in What I Learned, and several corrections throughout the article. If you are building extractors for other travel sites, his advice on CSP, JWT expiry, and persisted queries is worth listening to.</em></p>
<hr />
<h2>Source Code</h2>
<p>The full source code is available on <a href="https://github.com/EchoCode101/expedia-car-graphql-inspector">GitHub</a>. Free, open-source, MIT licensed. Built by me (Hamza), powered by curiosity and an allergy to repetitive clicking.
<a href="https://youtu.be/j3hRxAaXbSM">Youtube link</a></p>
<hr />
]]></content:encoded></item></channel></rss>