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

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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';
  });

})();