Skip to content

Releases: 2scraper/craigslist-scraper

v0.1.2 — the licence file

Choose a tag to compare

@jehrr jehrr released this 15 Sep 10:33

The licence FILE was wrong in v0.1.0 and v0.1.1. The badge, the README's own Licence section and pyproject.toml all said MIT, while LICENSE was 35 KB of GPL-3.0 inherited from the prototype this repo replaced. Both earlier tarballs carry that file.

LICENSE is now the MIT text every other repo in this family ships — which is what the rest of this one has claimed since 0.1.0.

A check asserts it cannot drift again: the LICENSE file must agree with everything in the repo that names a licence, and must carry no GPL text. Verified by putting the old file back and watching it fail on both counts.

No code changed.

v0.1.1 — the audit pass

Choose a tag to compare

@jehrr jehrr released this 14 Sep 16:02

If you took v0.1.0, replace it. Its scraper_api_client.py — a fourth, standalone CLI — still described a sibling site: that site's grid, its five-product fetch, its exit country, and an output prefix naming it. The file worked; everything it said about itself was about somewhere else. Nothing caught it because no entry point imports that module, so every engine-shaped check walked past it and its own --help was self-consistent.

The browserless path turns out to be the strong one here

Having adapted that file, I ran it. Measured with no CDP routing at all:

Scraper API playwright_scraper.py
rows 350 353
with a price 346 349
with structured data 302 314
wall clock ~5s ~2.5s
cost $0.0005 free

Same columns, same currency, within a percent. Two runs forty seconds apart returned all 350 of the same ids. The repo this file was copied from warns that its site returns five products from a single fetch; the difference is structural and reads the other way round here — Craigslist serves a complete result list in its first response.

python3 scraper_api_client.py --url "https://www.craigslist.org/search/area/newyork?cat=sss"

Also fixed

  • diff_runs.py was watching columns that cannot change. TRACKED_FIELDS still held sold and sold_is_floor, which this schema does not have, so two of its eight comparisons were no-ops on every row of every diff — while title, location and image_count, which a poster edits routinely, were not watched at all.
  • Its currency guard was backwards. The sibling's reasoning was "one host, one currency, so this can never fire". Here the currency follows the AREA and source is craigslist.org on both sides of every diff, so the currency is the only thing that catches a Tokyo run diffed against a Toronto one.
  • CONTRIBUTING.md described the other site in eight places.
  • TROUBLESHOOTING.md was referenced by an engine's --help and did not exist. Written.
  • captcha_solver.get_balance was referenced nowhere. Kept and given a consumer, because its error branch is the half nobody exercises: a wrong key returns HTTP 200 with an errorId in the body, so a client checking only the status code reads a failure as a balance.

A measurement worth having

A busy listing turns over in under two hours. On New York for sale, 451 of 729 dated rows were posted within the last hour and 278 in the one before. Two runs two hours apart shared no ids; two runs forty seconds apart shared all 350.

So diff_runs.py against that URL at a two-hour interval reports everything as delisted and everything as new — correctly, and uselessly. Diff at short intervals, or narrow the URL. It also sizes the 10,000-result ceiling: about twenty hours of that area's postings.

Guards, so none of this recurs silently

Every shipped file is counted for sibling-site names; every ALL_CAPS.md a file references must exist; every field diff_runs tracks must be a real column this site fills; every public name must be read somewhere.

Offline checks: 647, up from 455.

v0.1.0 — Craigslist, rewritten for the site as it is now

Choose a tag to compare

@jehrr jehrr released this 14 Sep 15:35

Superseded by v0.1.1. This tarball ships a scraper_api_client.py that describes a different site — the file works, but everything it says about itself is about somewhere else.

First release on the family architecture. It replaces an unreleased April 2026 prototype entirely, and not by choice: the site that prototype described no longer exists.

the prototype assumed what Craigslist does now
newyork.craigslist.org/search/sss 301 to www.craigslist.org/search/area/newyork?cat=sss
adverts at /d/{slug}/{10 digits}.html /view/d/{slug}/{22-char base64url token}
15 hard-coded city subdomains 714 area slugs under ONE hostname

Getting started

pip install -r requirements.txt -r requirements-playwright.txt
playwright install chromium

python playwright_scraper.py --url "https://www.craigslist.org/search/area/newyork?cat=sss"

No key, no proxy, no account. Measured 2026-09-14: a datacentre address in Germany was served the full New York listing, and across 22 captures covering six categories and five locales there were zero challenge markers of any vendor. The nightly canary runs from a bare GitHub runner for the same reason.

What it does

  • Three interchangeable engines — Playwright, Selenium, pyppeteer — agreeing on exit codes, run status, and whether a run crashes or spends money.
  • --mode listing reads a result list; --mode posting adds body text, the attribute bag, every image, both timestamps and the classic numeric post id.
  • JSON and CSV on the family row schema, a sidecar per run, and diff_runs.py.
  • 455 offline checks, and a canary that exercises both the served list and the walk.

Live, one batch, from a datacentre exit: 353 rows in ~2.5 seconds, 99% with a price, 89% with structured enrichment. --pages 3 returns 1,091.

Three things that will surprise you

There are no pages. ?page=2 and ?s=296 return a byte-identical response, so --pages walks the site's own virtualised list and --concurrency above 1 is refused — a worker is only useful if it can be handed an address of its own.

A single-batch run disables JavaScript. Craigslist serves a complete result list and its own application removes it about twenty seconds later, replacing it with a virtualised grid. With JavaScript off the list stays and the run takes ~2.5s.

Coordinates are absent outside North America. Craigslist publishes geo: {0,0} as a placeholder — 0 of 1,249 structured entries across the US and Canadian captures, 244 of 244 in Mexico City. Reported as null rather than as a point in the Gulf of Guinea.

The README has the rest, including the ones that look like bugs and are not.