Parse CarGurus search results with robust selectors, optional auto-scroll, state filtering, JSON copy and CSV download.
// ==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';
});
})();