Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Duplicate hotel prices usually come from duplicate page representations or from collapsing genuinely different offers. Make the itinerary and page state deterministic, extract each visible result card once, normalize every price dimension, and deduplicate with a semantic key that includes occupancy, room, rate plan, taxes, cancellation policy and provider. Never deduplicate on hotel name and amount alone.

What a duplicate hotel price really is

A hotel result is an offer for a specific context, not just a number. Google’s hotel-pricing model defines a price as the lowest price for a double-occupancy room for a particular check-in date and number of nights. Rate feeds can contain multiple rooms, rate plans, occupancies and refundability combinations. Two cards showing “$180” may therefore be different bookable offers.

Keep both records when any of these differ:

  • provider or hotel identity;
  • room type or room identifier;
  • rate-plan identifier;
  • check-in date or stay length;
  • occupancy;
  • currency, taxes or fees included;
  • meal plan;
  • refundable status or cancellation deadline.

Only merge records after those values have been normalized and shown to be equivalent. If you need the cheapest offer, select it only inside an identical semantic key.

Make the page state deterministic before extracting

Fix the itinerary and request context

Set check-in, check-out (or nights), occupants, currency, locale, country, device context and provider before navigation. Record those values with every extraction batch. Google documents that cached and live prices can vary with country, device and occupancy, so a second request is not necessarily a duplicate of the first; it may be a repriced or differently contextualized result.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Wait for both structure and content

Use a visible result-container wait, a network-idle wait when rates are fetched asynchronously, and a site-specific readiness predicate. Puppeteer’s waitForSelector can require visibility and has a default 30-second timeout. waitForNetworkIdle waits for the network to be idle and still waits at least the configured idle time; network idle alone does not prove that every price is final.

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-hotel-card]', {
  visible: true,
  timeout: 30000
});
await page.waitForNetworkIdle({ idleTime: 500 });
await page.waitForFunction(() => {
  const cards = [...document.querySelectorAll('[data-hotel-card]')]
    .filter(el => el.offsetParent !== null);
  return cards.length > 0 && cards.every(card =>
    /d/.test(card.querySelector('[data-total]')?.textContent || '')
  );
});

Replace the data attributes with selectors inspected on the target site. Prefer stable data attributes or embedded structured data over CSS classes that change during redesigns. A stronger predicate can require a known minimum number of cards, a “results loaded” flag, or a specific loading element to disappear.

Extract one object per visible result card

Query the repeated component, not a price selector across the entire document. A global price query can match desktop and mobile clones, sticky summaries, hidden templates and modal layers. Puppeteer’s $$eval evaluates all matching elements in one page-context pass; $eval evaluates only the first match.

const raw = await page.$$eval('[data-hotel-card]', cards => cards
  .filter(card => card.offsetParent !== null)
  .map(card => ({
    provider: card.dataset.provider || null,
    hotelId: card.dataset.hotelId || null,
    hotelName: card.querySelector('[data-hotel-name]')?.textContent || null,
    roomId: card.dataset.roomId || null,
    roomName: card.querySelector('[data-room-name]')?.textContent || null,
    ratePlanId: card.dataset.ratePlanId || null,
    checkIn: card.dataset.checkIn || null,
    nights: card.dataset.nights || null,
    occupancy: card.dataset.occupancy || null,
    currency: card.dataset.currency || null,
    totalText: card.querySelector('[data-total]')?.textContent || null,
    nightlyText: card.querySelector('[data-nightly]')?.textContent || null,
    taxesText: card.querySelector('[data-taxes]')?.textContent || null,
    feesText: card.querySelector('[data-fees]')?.textContent || null,
    mealPlan: card.querySelector('[data-meal-plan]')?.textContent || null,
    cancellationText: card.querySelector('[data-cancellation]')?.textContent || null,
    refundable: card.dataset.refundable || null
  })));

Return plain objects from the page context. Do not pass DOM nodes to Node.js and then attempt to read them after navigation; they are tied to the document that created them.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Normalize values before building a deduplication key

Text, dates and currency

Trim whitespace, collapse repeated spaces and normalize Unicode. Parse locale-specific separators deliberately: “1.234,56” and “1,234.56” can represent the same amount in different locales. Convert dates to an unambiguous form such as ISO calendar dates, and use ISO currency codes rather than symbols. Preserve the original display text for auditing.

Cancellation and inclusion rules

Cancellation prose should become structured fields such as refundable, deadline and penalty, while the original text remains available. Likewise, represent whether taxes and fees are included instead of assuming that two totals are comparable.

function cleanText(value) {
  return String(value ?? '')
    .normalize('NFKC')
    .replace(/s+/g, ' ')
    .trim();
}

function numberFromLocale(value) {
  const text = cleanText(value).replace(/[^0-9,.-]/g, '');
  if (!text) return null;
  const comma = text.lastIndexOf(',');
  const dot = text.lastIndexOf('.');
  let normalized = text;
  if (comma > dot) {
    normalized = text.replace(/./g, '').replace(',', '.');
  } else {
    normalized = text.replace(/,/g, '');
  }
  const result = Number(normalized);
  return Number.isFinite(result) ? result : null;
}

function normalizeOffer(raw) {
  return {
    provider: cleanText(raw.provider),
    hotelId: cleanText(raw.hotelId),
    hotelName: cleanText(raw.hotelName),
    roomId: cleanText(raw.roomId),
    roomName: cleanText(raw.roomName),
    ratePlanId: cleanText(raw.ratePlanId),
    checkIn: cleanText(raw.checkIn),
    nights: Number(raw.nights),
    occupancy: cleanText(raw.occupancy),
    currency: cleanText(raw.currency).toUpperCase(),
    total: numberFromLocale(raw.totalText),
    nightly: numberFromLocale(raw.nightlyText),
    taxes: numberFromLocale(raw.taxesText),
    fees: numberFromLocale(raw.feesText),
    mealPlan: cleanText(raw.mealPlan),
    refundable: cleanText(raw.refundable),
    cancellationText: cleanText(raw.cancellationText)
  };
}

function makeSemanticOfferKey(offer) {
  return JSON.stringify([
    offer.provider, offer.hotelId, offer.roomId, offer.ratePlanId,
    offer.checkIn, offer.nights, offer.occupancy, offer.currency,
    offer.total, offer.taxes, offer.fees, offer.mealPlan,
    offer.refundable, offer.cancellationText
  ]);
}

If the site exposes a machine-readable cancellation deadline or policy code, use that in the key and retain the prose as a display field. Do not use hotel name and total as a shortcut.

Deduplicate safely, including pagination and retries

A stable key handles repeated DOM nodes, but a scraper also needs a stable seen-set across pages and scroll operations. “Load more” interfaces often return existing cards again. Retries after a partial response can append the same batch. Tie each extraction to a navigation or request token and discard stale batches.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const byKey = new Map();
const seenBatch = new Set();

function mergeBatch(raw, navigationToken) {
  const normalized = raw.map(normalizeOffer);
  for (const offer of normalized) {
    if (!offer.provider || !offer.hotelId || !offer.checkIn ||
        !Number.isFinite(offer.nights) || !offer.occupancy ||
        !offer.currency || !Number.isFinite(offer.total) ||
        (!offer.roomId && !offer.ratePlanId)) {
      continue;
    }
    const key = makeSemanticOfferKey(offer);
    const batchKey = `${navigationToken}:${key}`;
    if (seenBatch.has(batchKey)) continue;
    seenBatch.add(batchKey);

    const previous = byKey.get(key);
    if (!previous || offer.total < previous.total) {
      byKey.set(key, {
        ...offer,
        dedupe_reason: previous ? 'same semantic offer; lower total retained' : 'first occurrence',
        merged_from: previous ? [...(previous.merged_from || []), previous] : []
      });
    }
  }
}

// After all pages, the unique offers are:
const offers = [...byKey.values()];

For auditability, store sourceUrl, scrapedAt, the request context and the raw record. A replacement should be possible to explain: identical semantic identity with a lower parsed total, not an unexplained overwrite.

Why duplicate prices appear

Cause What you see Correction
Repeated DOM representations Desktop/mobile cards, hidden templates, sticky summaries or modal details repeat the amount. Scope $$eval to the result-card component and keep visible, complete cards only.
Pagination or lazy loading A scroll or “load more” pass returns cards already processed. Maintain a stable semantic seen-set across batches.
Retries and navigation races A partial response is followed by the same page being appended. Attach a navigation token and discard stale batches.
Occupancy and rate-plan variants The amount matches but room, guest count, refundability or meal plan differs. Keep every differing dimension in the key and output.
Cache and freshness The same request context returns cached or newly repriced data. Record country, device, occupancy and retrieval time; revalidate before booking.

Validate records before showing or booking them

  1. Reject records missing provider or hotel identity, dates, nights, occupancy, currency, total, or a room/rate identifier.
  2. Check that the parsed total is non-negative and that the currency is present; retain the unparsed text for review.
  3. Group only by the complete semantic key. Choose the cheapest record only within that group.
  4. Before publishing or booking, verify the selected rate through an authorized endpoint or the site’s final booking step.
  5. Record the verification result and the time it occurred; a displayed search price can change before checkout.

Expedia’s Rapid Shopping API documentation describes verifying a previously selected rate and returning a booking link when the price matches. For sites that expose Schema.org microdata on the final visible stay price, automated navigation through the booking funnel can provide another validation path. Google’s documentation notes that legacy landing-page-only structured data is planned for deprecation in early 2027, so a scraper should not treat an old landing-page markup pattern as a permanent contract.

Choosing DOM, structured data or an authorized API

Approach Strength Risk or limitation
DOM scraping Flexible and available on pages without a usable feed. Selectors are fragile; hidden clones and layout changes can create duplicates.
Structured data Can expose the final visible price without depending on visual CSS classes. Coverage and freshness depend on correct implementation; markup may be incomplete.
Authorized lodging API Usually best for production revalidation, stable identifiers and explicit policy fields. Coverage, latency, cost and program terms vary; authorization is required.

Use the least fragile source that meets your coverage and terms. Do not scrape a site in a way that violates its authorization, terms or access controls.

Performance, reliability and scale considerations

Bound waiting time

Set navigation, selector and function timeouts appropriate to the site, then fail a batch rather than emitting partial prices as complete. Network idle is useful for asynchronous requests, but a site-specific predicate is the completion signal.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Control memory and duplicate work

Process cards in batches when a page can contain thousands of results. Keep the key and a compact audit record instead of retaining every duplicate DOM object. For scroll pagination, wait for the card count or a stable cursor to change before extracting again.

Plan for itinerary volume

Google’s Hotel Prices “Pricing overview” guidance describes 9,900 itinerary entries (330 advance-booking days multiplied by 30-night stays) as a general planning figure. Treat that as a coverage example, not a promise that every property, market or provider supports the same range. At this scale, an authorized rate API and explicit revalidation workflow are generally easier to operate than thousands of layout-dependent browser sessions.

Troubleshooting duplicate or missing prices

Every price appears twice

Inspect matching elements in DevTools and check offsetParent, computed visibility and ancestor containers. You are likely matching a mobile clone, hidden template or sticky summary. Narrow the selector to the repeated card and filter visible elements.

The scraper returns zero cards

The selector may be wrong, the results may be inside an iframe, or the page may still be loading. Confirm the selector after the itinerary is submitted, wait for a visible card, and add a readiness predicate that checks for a numeric total.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Prices are present but incomplete

Do not publish after network idle alone. Wait for each visible card to contain its total, and reject records without occupancy, currency or room/rate identity. If details appear only after clicking, perform that click before extraction and wait for the detail selector.

Totals differ between runs

Compare the recorded country, device, occupancy, currency, cache state and retrieval time. These contexts can change cached or live pricing. Re-run the final selected offer through an authorized verification step immediately before booking.

A retry creates more duplicates

Generate a navigation/request token for every attempt. Discard records from an old token when a newer navigation succeeds, and keep a semantic key across “load more” batches.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

FAQ

Should I keep duplicate records for auditing?

Keep the raw records and a merged_from trail, but expose only one record for an identical semantic offer. This preserves evidence without confusing downstream users.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can a screenshot prove that two prices are the same offer?

No. A screenshot shows presentation, not stable provider, room, occupancy, tax and policy identifiers. Use structured extraction and retain a screenshot only as visual evidence.

When should the cheapest result be selected?

Only after required fields validate and the semantic key confirms that room, occupancy, policy, taxes, currency and provider all match. Then recheck the rate at the authorized booking step.

Or skip the browser setup

If your goal is a visual record of a hotel-results page rather than structured rate extraction, ScreenshotNeo provides a website screenshot API and MCP server. It can accept the consent banner like a visitor and remove more than 60 known consent platforms, newsletter popups and chat widgets before capture; each cleanup step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response reports the page verdict and billing status in X-Page-Verdict and X-Billed headers.

One GET request returns PNG, JPEG, WebP or PDF. The API also supports full-page lazy-image loading, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF page controls, custom CSS and JavaScript, clicks, hidden selectors, selector/delay/network-idle waits, request and resource blocking, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info and capture_pdf to Claude, Cursor and other MCP clients.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

See the ScreenshotNeo API documentation for parameters. Example:

curl -G "https://api.screenshotneo.com/v1/shot" 
  -d access_key=YOUR_API_KEY 
  --data-urlencode url=https://example.com/hotels 
  -o hotel-results.webp

Equivalent calls:

import requests
r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com/hotels"},
    timeout=90
)
r.raise_for_status()
open("hotel-results.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com/hotels'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
require('fs').writeFileSync('hotel-results.webp', Buffer.from(await res.arrayBuffer()));

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan, and yearly billing gives two months free. Create a free ScreenshotNeo account to start.

Frequently Asked Questions

Should I keep duplicate records for auditing?

Keep raw records and a merged_from trail, but expose one record for an identical semantic offer.

Can a screenshot prove that two prices are the same offer?

No. It shows presentation, not stable room, occupancy, tax and policy identifiers.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When should the cheapest result be selected?

After required fields validate, the semantic key matches, and the rate is rechecked at the authorized booking step.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.