cargurus.ca scraper
Pricing
from $5.00 / 1,000 results
cargurus.ca scraper
This actor scrapes vehicle listings from CarGurus.ca search result pages. Pass any CarGurus.ca inventory search URL and get back structured JSON with listing details — price, mileage, year, make, model, dealer name, location, images, and CPO status.
Pricing
from $5.00 / 1,000 results
Rating
0.0
(0)
Developer
Mohamed Ibrahim
Maintained by CommunityActor stats
0
Bookmarked
15
Total users
0
Monthly active users
3 days ago
Last modified
Categories
Share
PRIVATE — internal cartell infrastructure. Not an Apify Store actor: no Store listing, no categories, no pay-per-event charging, no
charging.ts. Thedocs/PUBLISHING.mdanddocs/LEGAL.mdchecklists in this repo govern the public@canadian-actors/*lane and do not apply here. See ../../docs/ARCHITECTURE.md → "Two actor lanes".
Provenance. Moved from the cartell repo at
apify/cargurus-scraper/(cartell@29094b8d).main.js,Dockerfileand.actor/*.jsoncame across byte-identical; onlypackage.json/package-lock.jsonnamefields changed (rescoped to@cartell-actors/…, plus"private": true). The directory was renamedcargurus-scraper→cargurus-ca-scraperso it matches the actor name, which is this repo's convention. Full history:git log -- apify/cargurus-scraperin cartell.
Scrapes vehicle listings from CarGurus.ca search result pages. Given any CarGurus.ca inventory search URL, it returns structured JSON — price, mileage, year, make, model, dealer name, location, images, CPO status.
Why an actor
CarGurus.ca is protected by DataDome bot detection. This actor gets through it
with Chrome TLS fingerprinting via got-scraping plus Apify residential proxies —
no headless browser, which keeps runs fast and cheap. Same bucket as AutoTrader
(Incapsula), Nissan/Infiniti (Akamai) and Porsche (Vercel Attack Challenge Mode).
How it works
- HTTP-only — no Playwright or Puppeteer overhead; a page fetches in seconds.
- Session warm-up — establishes cookies on the homepage, then fetches search results as an established session.
- Multi-strategy extraction — parses the embedded
__PREFLIGHT__JSON first, with DOM and alternative-endpoint fallbacks if the page structure rotates. - Pagination — ~24 listings per page; reads the total from CarGurus's metadata,
fetches the pages needed to reach
maxItemswith randomized 1.5–2.5s delays, and dedupes by listing ID.
Input
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
searchUrl | string | Yes | — | A CarGurus.ca inventory search URL |
maxItems | integer | No | 25 | Max listings to return (1–200). Fetches multiple pages if needed. |
maxPages | integer | No | 10 | Max pages to fetch (1–20). Each page adds ~2s of run time. |
yearMin | integer | No | — | Filter to model years >= this value |
yearMax | integer | No | — | Filter to model years <= this value |
debug | boolean | No | false | Enable verbose logging for troubleshooting |
To build a searchUrl: run the search you want on cargurus.ca with your filters
(make, model, location, price range) and copy the URL from the address bar.
Example input
{"searchUrl": "https://www.cargurus.ca/Cars/inventorylisting/viewDetailsFilterViewInventoryListing.action?zip=M5V%203A8&distance=50&entitySelectingHelper.selectedEntity=m7","maxItems": 100,"maxPages": 5}
Known gap:
.actor/actor.jsonhas no"input"field, so this input schema is very likely not registered on the platform (the no-inputfallback looks for an uppercaseINPUT_SCHEMA.json). Adding"input": "./input_schema.json"would start enforcingrequired: ["searchUrl"]and the numeric bounds on a live production path, so it is deliberately left alone here — fix it in its own PR with a verification run.
Output
One dataset item per listing:
{"listingId": "432711846","listingUrl": "https://www.cargurus.ca/Cars/inventorylisting/viewDetailsFilterViewInventoryListing.action?#listing=432711846","listingTitle": "2022 Audi A4 45 TFSI quattro Progressiv S line AWD","year": 2022,"make": "Audi","model": "A4","mileage": 34200,"priceCAD": 38995,"imageUrl": "https://static.cargurus.com/images/forsale/...","dealerName": "Toronto Auto Gallery","location": "Toronto, ON","isCertifiedUsed": false}
| Field | Type | Description |
|---|---|---|
listingId | string | CarGurus listing ID |
listingUrl | string | Direct link to the listing |
listingTitle | string | Full listing title (year, make, model, trim) |
year | number | Model year |
make | string | Vehicle manufacturer |
model | string | Vehicle model |
mileage | number | Odometer reading in km |
priceCAD | number | Listed price in CAD |
imageUrl | string | Primary listing photo URL |
dealerName | string | Dealer or seller name |
location | string | City/province of the listing |
isCertifiedUsed | boolean | Whether the vehicle is Certified Pre-Owned |
Consumer-side mapping lives in cartell at lib/connectors/cargurus/client.ts, which
also enforces the search radius (CarGurus reads distance in miles, so the
connector re-filters). Keeping the actor extraction-free means a storefront schema
rotation needs a parser change, not a redeploy.
Run cost
| Scenario | Run time | Approx. cost |
|---|---|---|
| 25 listings (1 page) | 5–15s | ~$0.01–0.03 |
| 50 listings (2–3 pages) | 10–25s | ~$0.02–0.05 |
| 100 listings (4–5 pages) | 20–40s | ~$0.04–0.08 |
| 200 listings (8–10 pages) | 40–70s | ~$0.08–0.15 |
Local development
cd actors/cargurus-ca-scrapernpm ciAPIFY_LOCAL_STORAGE_DIR=./storage npm start
Use npm, not pnpm — this actor is excluded from the pnpm workspace on purpose. An Apify account with RESIDENTIAL/CA proxy access is needed to actually clear DataDome.
Deploying
This actor has no build step, so it does not deploy via pnpm -F … push (that
would silently match nothing). Either push a tag actor/cargurus-ca-scraper/v* to
run ../../.github/workflows/apify-push.yml, or
push manually:
cd actors/cargurus-ca-scraperapify push
You must be logged in as the Apify account that already owns the live actor. A
push from a different account silently creates a new actor under that account
while cartell's CARGURUS_APIFY_ACTOR keeps pointing at the untouched original; a
Created Actor with name … line in the push log is the failure signal.
cartell resolves this actor as CARGURUS_APIFY_ACTOR=<apify-username>~cargurus-ca-scraper.
The .actor/actor.json name is therefore load-bearing — never rename it.
Maintenance notes
main.jsis intentionally frozen: it is not linted or formatted by this repo's Biome config (seebiome.jsonfiles.ignore), because reformatting a working anti-bot bypass is risk without benefit. Do not "clean it up".- Unlike the other two actors in this lane, the
Dockerfilerunsnpm install, notnpm ci— so the committedpackage-lock.jsondoes not currently pin the deployed dependency tree. Switching it tonpm ciis a real behaviour change to a working bypass; do it as its own PR with a live smoke run. - The
Dockerfilealso omits--chown=myuseron itsCOPYlines, which the current Apify base images expect. Pre-existing; same follow-up PR. version: "0.0"+buildTag: "latest"means every push overwrites the live build. Treat any push as a production deploy.