CarGurus Robust Parser (button + auto-scroll + CSV)

Parse CarGurus search results with robust selectors, optional auto-scroll, state filtering, JSON copy and CSV download.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         CarGurus Robust Parser (button + auto-scroll + CSV)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Parse CarGurus search results with robust selectors, optional auto-scroll, state filtering, JSON copy and CSV download.
// @author       Dinadeyohvsgi
// @match        https://www.cargurus.com/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  /* ---------- Utilities ---------- */
  const text = (el) => (el ? el.textContent.trim() : null);
  const q = (root, sel) => root.querySelector(sel);
  const qa = (root, sel) => Array.from(root.querySelectorAll(sel || ''));

  // normalize label e.g., "VIN:" -> "vin"
  const norm = (s) => s && s.replace(':', '').trim().toLowerCase();

  // robust single-text finder (tries multiple selectors)
  function findText(listing, selectors = []) {
    for (const sel of selectors) {
      const el = listing.querySelector(sel);
      if (el) {
        const t = text(el);
        if (t) return t;
      }
    }
    return null;
  }

  /* ---------- Core parsing ---------- */

  // Turn a dl of dt/dd pairs into a map {year: "...", make: "...", ...}
  function parseProperties(dl) {
    const obj = {};
    if (!dl) return obj;
    const dts = Array.from(dl.querySelectorAll('dt'));
    const dds = Array.from(dl.querySelectorAll('dd'));
    for (let i = 0; i < Math.min(dts.length, dds.length); i++) {
      const key = norm(text(dts[i]));
      const value = text(dds[i]);
      if (key) obj[key] = value;
    }
    return obj;
  }

  // Parse a single listing tile. resilient fallbacks.
  function parseListing(listing) {
    // dl with dt/dd pairs (primary)
    const dl = listing.querySelector('dl._propertiesList_7inth_1, dl[data-testid="properties-list"], dl');
    const props = parseProperties(dl);

    // price
    const price = findText(listing, [
      '[data-testid="srp-tile-price"]',
      'h4[class*="priceText"]',
      'h4[class*=price]',
      '.price',
    ]);

    // mileage (tile-level fallback)
    const mileageTile = findText(listing, [
      '[data-testid="srp-tile-mileage"]',
      'p[data-testid="srp-tile-mileage"]',
      '.mileage',
    ]) || props['mileage'] || props['miles'] || null;

    // location & distance
    const location = findText(listing, [
      '[data-testid="LocationSection-firstLine"]',
      '. _locationSection_eclgi_1 span:first-child',
      'div._locationSection_eclgi_1 span:first-child',
      '.location',
      'div[title$="GA"], div[title$="AL"], div[title*=","]',
    ]);

    const distance = findText(listing, [
      '[data-testid="LocationSection-secondLine"]',
      '. _locationSection_eclgi_1 span.JWQ7f',
      'div[title*="away"]',
    ]);

    // link
    const link = (listing.querySelector('a[data-testid="car-blade-link"], a._vdpLink_2o40s_1, a[href*="/details/"]') || {}).href || null;

    // VIN fallback (from dl map or any dd that contains VIN)
    let vin = props['vin'] || null;
    if (!vin) {
      const possible = Array.from(listing.querySelectorAll('dd, li, span')).map(el => text(el) || '');
      for (const p of possible) {
        if (/^[A-HJ-NPR-Z0-9]{17}$/i.test(p.replace(/\s/g, ''))) { vin = p; break; } // strict 17 chars VIN
        if (/vin[:\s]/i.test(p)) { vin = p.replace(/vin[:\s]/i, '').trim(); break; }
      }
    }

    // Build canonical object using best-available fields
    const obj = {
      year: props['year'] || props['yr'] || null,
      make: props['make'] || null,
      model: props['model'] || null,
      body: props['body type'] || props['body'] || null,
      doors: props['doors'] || null,
      drivetrain: props['drivetrain'] || null,
      engine: props['engine'] || props['engine:'] || null,
      exterior_color: props['exterior color'] || props['exterior'] || null,
      interior_color: props['interior color'] || null,
      mpg: props['combined gas mileage'] || props['combined gas mileage:'] || props['combined gas mileage'] || props['combined gas mileage'] || null,
      fuel_type: props['fuel type'] || null,
      transmission: props['transmission'] || null,
      mileage: mileageTile,
      stock: props['stock #'] || props['stock'] || null,
      vin: vin,
      price: price,
      monthly_payment: findText(listing, ['div._monthlyPayment_tppsb_7 span', '.monthlyPayment', 'span[data-testid="monthly-payment"]']) || null,
      location: location,
      distance: distance,
      link: link,
      raw_props: props
    };

    return obj;
  }

  /* ---------- Page scanning & loading ---------- */

  // robustly return all candidate tile elements on the page
  function getAllTileElements() {
    const selectors = [
      'div[data-testid="srp-listing-tile"]',
      'div._card_2o40s_15',
      'div[data-testid="listing-tile"]',
      'div._tileFrame_gscig_15 div[data-testid="srp-listing-tile"]',
      'article', // fallback (filter afterwards)
    ];
    const set = new Set();
    selectors.forEach(sel => {
      document.querySelectorAll(sel).forEach(e => set.add(e));
    });
    // Filter obviously non-listing articles by checking for a link to /details/
    return Array.from(set).filter(el => !!el.querySelector('a[href*="/details/"], a[data-testid="car-blade-link"]'));
  }

  // auto-scroll to bottom to encourage lazy-loaded tiles. returns after no new items or max steps.
  async function autoScrollLoad(maxSteps = 20, delayMs = 600) {
    let prevCount = 0;
    for (let step = 0; step < maxSteps; step++) {
      window.scrollBy({ top: window.innerHeight * 1.25, behavior: 'smooth' });
      // wait a bit for network & DOM
      await new Promise(r => setTimeout(r, delayMs));
      const tiles = getAllTileElements().length;
      if (tiles > prevCount) {
        prevCount = tiles;
        continue; // more loaded, keep going
      } else {
        break; // no change -> stop early
      }
    }
    // final small wait for any last rendering
    await new Promise(r => setTimeout(r, 300));
  }

  /* ---------- CSV helper ---------- */
  function objToCsv(rows) {
    if (!rows || rows.length === 0) return '';
    const keys = [
      'price','year','make','model','vin','mileage','location','distance','exterior_color','interior_color','transmission','fuel_type','engine','drivetrain','body','doors','stock','monthly_payment','link'
    ];
    const safe = (v) => v == null ? '' : String(v).replace(/"/g, '""');
    const header = keys.join(',');
    const lines = [header];
    rows.forEach(r => {
      const row = keys.map(k => `"${safe(r[k])}"`).join(',');
      lines.push(row);
    });
    return lines.join('\r\n');
  }

  /* ---------- UI ---------- */
  const panel = document.createElement('div');
  panel.style.position = 'fixed';
  panel.style.bottom = '16px';
  panel.style.left = '16px';
  panel.style.padding = '12px';
  panel.style.background = 'rgba(20,20,20,0.9)';
  panel.style.color = 'white';
  panel.style.fontSize = '13px';
  panel.style.borderRadius = '8px';
  panel.style.zIndex = 99999;
  panel.style.minWidth = '260px';
  panel.style.boxShadow = '0 6px 18px rgba(0,0,0,0.45)';
  panel.innerHTML = `
    <div style="font-weight:600;margin-bottom:6px">CarGurus Parser</div>
    <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
      <button id="cg-parse" style="flex:1;padding:6px 8px;border-radius:6px;border:none;cursor:pointer">Parse</button>
      <input id="cg-autoscroll" type="checkbox" title="Auto-scroll to load more" />
    </div>
    <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
      <input id="cg-state" placeholder="State filter (e.g. GA)" style="flex:1;padding:6px;border-radius:6px;border:none" />
      <button id="cg-copy" style="padding:6px 8px;border-radius:6px;border:none;cursor:pointer">Copy JSON</button>
    </div>
    <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
      <button id="cg-csv" style="flex:1;padding:6px 8px;border-radius:6px;border:none;cursor:pointer">Download CSV</button>
      <button id="cg-clear" style="padding:6px 8px;border-radius:6px;border:none;cursor:pointer">Clear</button>
    </div>
    <div id="cg-status" style="font-size:12px;opacity:0.9">Idle • 0 listings</div>
  `;
  document.body.appendChild(panel);

  // quick styling for buttons
  panel.querySelectorAll('button').forEach(b => {
    b.style.background = '#007bff';
    b.style.color = 'white';
  });

  const statusEl = panel.querySelector('#cg-status');
  const parseBtn = panel.querySelector('#cg-parse');
  const copyBtn = panel.querySelector('#cg-copy');
  const csvBtn = panel.querySelector('#cg-csv');
  const clearBtn = panel.querySelector('#cg-clear');
  const autoScrollCb = panel.querySelector('#cg-autoscroll');
  const stateInput = panel.querySelector('#cg-state');

  // internal storage of last results
  let LAST_RESULTS = [];

  function setStatus(msg) { statusEl.textContent = msg; }

  // main flow
  parseBtn.addEventListener('click', async () => {
    try {
      setStatus('Scanning page for tiles...');
      const doAuto = autoScrollCb.checked;
      if (doAuto) {
        setStatus('Auto-scrolling to load listings (may take a few seconds)...');
        await autoScrollLoad(25, 700); // tuned defaults
      }

      const tiles = getAllTileElements();
      setStatus(`Found ${tiles.length} candidate tiles — parsing...`);

      const parsed = tiles.map(t => parseListing(t));

      // filter out entries lacking a VIN AND price AND link? keep flexible: require at least link or vin
      const filtered = parsed.filter(p => p.link || p.vin || p.price);

      // state filter
      const stateFilterRaw = (stateInput.value || '').trim();
      let stateFilter = null;
      if (stateFilterRaw) {
        const f = stateFilterRaw.toLowerCase();
        if (f.length === 2) stateFilter = f; // GA
        else stateFilter = f; // 'georgia'
      }
      const final = filtered.filter(item => {
        if (!stateFilter) return true;
        const loc = (item.location || '').toLowerCase();
        return loc.includes(stateFilter) || (item.distance || '').toLowerCase().includes(stateFilter);
      });

      LAST_RESULTS = final;
      setStatus(`Parsed ${final.length} listings (from ${tiles.length} tiles).`);
      console.log('CarGurus parsed results:', final);
      // also copy to clipboard automatically
      try {
        await navigator.clipboard.writeText(JSON.stringify(final, null, 2));
        setStatus(`Parsed ${final.length} listings — copied JSON to clipboard.`);
      } catch (e) {
        setStatus(`Parsed ${final.length} listings — (clipboard unavailable, use Copy JSON).`);
      }
    } catch (err) {
      console.error(err);
      setStatus('Error during parse — check console.');
    }
  });

  copyBtn.addEventListener('click', async () => {
    if (!LAST_RESULTS || LAST_RESULTS.length === 0) { setStatus('No results to copy.'); return; }
    try {
      await navigator.clipboard.writeText(JSON.stringify(LAST_RESULTS, null, 2));
      setStatus(`Copied ${LAST_RESULTS.length} listings to clipboard.`);
    } catch (e) {
      console.error(e);
      setStatus('Clipboard failed (permissions). Open console to inspect LAST_RESULTS.');
    }
  });

  csvBtn.addEventListener('click', () => {
    if (!LAST_RESULTS || LAST_RESULTS.length === 0) { setStatus('No results to download.'); return; }
    const csv = objToCsv(LAST_RESULTS);
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'cargurus_listings.csv';
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
    setStatus(`CSV ready — ${LAST_RESULTS.length} rows downloaded.`);
  });

  clearBtn.addEventListener('click', () => {
    LAST_RESULTS = [];
    setStatus('Cleared results.');
  });

  // small accessibility: open/close with double-click on header
  panel.addEventListener('dblclick', () => {
    panel.style.display = panel.style.display === 'none' ? '' : 'none';
  });

})();