Skip to content

ChocoData-com/facebook-marketplace-scraper

Repository files navigation

Facebook Marketplace Scraper

Facebook Marketplace Scraper

Facebook Marketplace Scraper for extracting listing titles, descriptions, photos and item IDs from Facebook.com. This repo has a free Facebook Marketplace web scraping script you can run right now, and a Facebook Marketplace listing data API that turns a listing URL into structured JSON.

Last updated: 2026-07-20. Working against Facebook Marketplace as of July 2026, and re-verified whenever Meta changes their markup.

This repo reads public listings only, and collects no personal data. The three listings sampled here are public classified adverts for a used vehicle or a machine part, captured on 2026-07-20. The API's seller field came back null on all 18 calls made to them, so no seller identity enters the data at all. Nothing in the committed samples, the scripts or the rendered images contains a person's name, photograph, address or contact details: a candidate listing whose text carried a home address was dropped rather than redacted, and no listing photo is rendered into any image on this page. Facebook Pages, posts, groups and events have their own reference in the Facebook Scraper repo.

Every JSON block on this page was captured from the live API on 2026-07-20. Long CDN image URLs are truncated where marked and each block says exactly what was cut; every field shown is verbatim. Full uncut samples are committed in facebook_marketplace_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_marketplace_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python facebook_marketplace_scraper_api_codes/listing.py

Those three lines return this, live from Facebook Marketplace:

{
  "id": "4948179818741919",
  "title": "1987 944S Porsche parts car",
  "description": "Rebuilt engine with crank scraper, with14,000 miles.lsd rear-end, sport leather seat, Fuchs, needs a new front wind shield, a gas tank repair. The previous owner ran ethanol and it gummed up in the...",
  "images": [
    "https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647..."
  ],
  "data_source": "opengraph"
}

...that is 5 of the 13 fields on a listing, picked for the intro; the full 13 are below. Point it at a set of listing URLs and you get a row each:

Retrieved Facebook Marketplace listing data

That is the whole point of this repo. The rest of this page is how it works, starting with the free Facebook Marketplace scraper: one HTTP request, no key, real listing data.


Contents


Free Facebook Marketplace Scraper

Facebook server-renders a public Marketplace listing's headline fields into its OpenGraph <meta> tags, so you can extract listing data without a headless browser, JavaScript rendering, or a login. No key, no cost:

python free_scraper/facebook_marketplace_free_scraper.py https://www.facebook.com/marketplace/item/4948179818741919/

Source: free_scraper/facebook_marketplace_free_scraper.py. It reads og:title, og:description, og:image and og:url, and emits id, url, title, description, image. It parses first and only reports "no data" when the OpenGraph block is genuinely absent, so a listing that renders is never miscounted as a block.

After running the command, your terminal should look something like this:

Free Facebook Marketplace scraper returning real listing data

That is a real run. For a listing URL you already have, the free path works. The next section covers the two things that decide whether a request gets through, and the thing this surface would not do for us at all.

Avoid getting blocked when scraping Facebook Marketplace

Facebook Marketplace has a reputation as a login-walled surface, and for browsing that reputation is exactly right. For a listing you already have the URL for, it is wrong. We ran the script above from an ordinary home internet connection on 2026-07-20 and it returned the listing on 15 of 15 requests across the three listings sampled. What changes the answer is your client string:

Facebook refusing a fake Chrome User-Agent with HTTP 400

Lying about your client is the one thing that reliably fails. Same URL, same machine, same minute, one header different. A full Mozilla/5.0 ... Chrome/126.0.0.0 Safari/537.36 User-Agent from a plain HTTP client was refused with a 400 and a 1,542-byte error page titled Error, on 3 of 3 attempts. An empty User-Agent got a 146,468-byte "Update Your Browser" document with zero og: tags, on 3 of 3. curl/8.7.1, python-requests/2.34.2 and this repo's own self-describing facebook-marketplace-scraper/1.0 each got the real ~548 KB listing document with 7 og: tags and the listing title, on 3 of 3. That is why the script here sends a truthful UA, and why you should leave it alone.

That table is the entire output of a script in this repo, so you can reproduce it yourself:

python free_scraper/ua_test.py

The harder problem is not the listing. It is finding one.

Marketplace serves no listings to a logged-out browse request

Marketplace browse and search returned no listings at all. Two category pages and one search page, tested on 2026-07-20, each answered HTTP 200 with half a megabyte of document containing zero /marketplace/item/ links. Search did not even keep its own title: /marketplace/nyc/search/?query=forklift came back titled Facebook. An item URL you already hold returns the listing. On the surfaces we could test, then, enumeration is not available: you bring the URLs, from your own inventory, a partner feed, or wherever your listings come from, and this repo turns each one into JSON.

python free_scraper/browse_test.py

Marketplace browse and search returning zero item links

Here is what genuinely bites you, none of which a proxy fixes:

What bites you Why What it costs you
Listings do not stay up A Marketplace listing is removed when it sells. The document stops being served, and the endpoint answers 502 instead of 200. Anything you did not store is gone. Snapshot on first sight; the watch script exists for exactly this.
A 502 can also be transient Live listings answered 200 on 14 of 15 calls in one run, and the exception resolved on the next attempt. Confirm before you conclude a listing has gone, or your archive will record takedowns that never happened.
You cannot enumerate listings Category and search pages returned 0 item links on 3 of 3 surfaces tested, at HTTP 200 and 526,679 to 529,865 bytes each. Any plan that starts "crawl Marketplace for..." does not survive contact. Source your URLs elsewhere.
A browser UA from a non-browser client is refused 400, 1,542 bytes, <title>Error</title>, on 3 of 3. A single header turns a working scraper into a silent failure.
An empty UA gets a decoy document HTTP 200, 146,468 bytes, <title>Update Your Browser</title>, 0 og: tags, on 3 of 3. Your parser sees nothing and you blame your parser.
HTTP 200 is not proof of data Every case above returns 200. The status code tells you Facebook answered, not that it answered with a listing. Check the payload. Both scripts here do.
The markup moves The og block shape changes a few times a year. Your regex silently returns None. Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken".

Using the Chocodata Facebook Marketplace Scraper API

The managed option. The Chocodata Facebook Marketplace Scraper API turns a listing URL into parsed JSON instead of ~548 KB of HTML, with a ~99% success rate, a pinned egress location so two runs are comparable, and the client-string handling above already solved. Free for the first 1,000 requests.

The API earns its money when the parser stops being something you want to own: when you need the response already parsed and shaped, the null-handling below already mapped, and someone else on the hook the next time Meta moves the markup.


Facebook Marketplace Scraper API reference

Below is the Facebook Marketplace Scraper API reference to get you started: one endpoint, its parameters, a runnable call, and the response it returns.

Quickstart

curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=YOUR_KEY&url=https://www.facebook.com/marketplace/item/4948179818741919/"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/facebook/marketplace",
    params={"api_key": "YOUR_KEY",
            "url": "https://www.facebook.com/marketplace/item/4948179818741919/"},
    timeout=90,
)
r.raise_for_status()          # a 502 means no public document came back
item = r.json()
print(item["title"], "|", len(item["images"]), "photo(s)")
# 1987 944S Porsche parts car | 1 photo(s)

After running the command, your terminal should look something like this:

Running the Facebook Marketplace Scraper API listing endpoint

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model.

Global parameters

Param Type Required Default Description
api_key string yes - Your Chocodata API key.
country string (ISO-2) no us Egress location. Must be exactly 2 characters: usa returns a 400. Facebook localises what it renders to the location it sees, so pin this for reproducible results.

Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).

Errors

Real captured error bodies, not paraphrases. Nothing below is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params A required param is missing or the wrong type. Body lists the exact issue under issues. no Fix the query string.
401 INVALID_API_KEY Key missing, unrecognised, or revoked. no Check api_key. Get one at chocodata.com.
402 INSUFFICIENT_CREDITS Balance exhausted. no Top up ($0.90 / 1,000 requests, never expires) or upgrade.
429 RATE_LIMITED Over your plan's concurrency. no Back off and retry; see Rate limits.
502 extraction_failed No public listing document came back for this URL. no Retry. A sold or removed listing looks exactly like this.

Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. Both are shown below, and both are committed in errors.json.

There is no 404. An item id that resolves to nothing returns 502, on 4 of 4 attempts, and so does a listing that has been taken down. See the listing endpoint.

The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:

Facebook Marketplace Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=totally_invalid_key_123&url=https://www.facebook.com/marketplace/item/4948179818741919/"
{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}

A missing required param, verbatim. Note it names the exact requirement rather than a generic 400:

{
  "error": "invalid_params",
  "issues": [
    {
      "code": "custom",
      "message": "url or id is required",
      "path": []
    }
  ]
}

Rate limits and concurrency

There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once.

Plan Concurrent requests
Free 10
Vibe 30
Pro 50
Custom 100 to 500+

Exceed it and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. A request can take up to ~5s (see Measured latency), which is why the examples use timeout=90.

Sizing: at Pro (50 concurrent) and a ~3.1s median call, one worker pool sustains roughly 50 / 3.1 = 16 requests/second. Fan out with a thread pool:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(url):
    r = requests.get("https://api.chocodata.com/api/v1/facebook/marketplace",
                     params={"api_key": KEY, "url": url}, timeout=90)
    return url, (r.json() if r.ok else None)

with ThreadPoolExecutor(max_workers=10) as pool:   # <= your plan's concurrency
    for url, item in pool.map(one, listing_urls):
        print(url, item["title"] if item else "no public document")

1. Marketplace listing: titles, descriptions, photos and item IDs

One public Marketplace listing, by item URL or item id.

Param Type Required Default Description
url string (URL) one of url / id - Full listing URL, facebook.com/marketplace/item/<id>/.
id string one of url / id - The numeric item id on its own.
country string (ISO-2) no us Egress location.
curl "https://api.chocodata.com/api/v1/facebook/marketplace?api_key=YOUR_KEY&url=https://www.facebook.com/marketplace/item/4948179818741919/"

Real response. Complete object, all 13 fields verbatim; the two CDN image URLs are truncated where marked and description carries Facebook's own trailing ... (full sample):

{
  "id": "4948179818741919",
  "url": "https://www.facebook.com/marketplace/item/4948179818741919/",
  "title": "1987 944S Porsche parts car",
  "price": null,
  "currency": "USD",
  "description": "Rebuilt engine with crank scraper, with14,000 miles.lsd rear-end, sport leather seat, Fuchs, needs a new front wind shield, a gas tank repair. The previous owner ran ethanol and it gummed up in the...",
  "location": null,
  "condition": null,
  "seller": null,
  "images": [
    "https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647..."
  ],
  "thumbnail": "https://scontent-dfw5-1.xx.fbcdn.net/v/t39.84726-6/736198361_898669625914112_4411632949091742647...",
  "source": "facebook",
  "data_source": "opengraph"
}

description is the field most people come for: it is the seller's own listing copy, which on a vehicle or a machine part is where the specification actually lives. title is the second, and it is whatever the seller typed. Some vehicle listings arrive with a middle dot separating the trim, so "2008 Ford Ranger · Xlt" splits cleanly on · (second sample); others, including the Porsche above, are free text with no separator at all. Parse defensively.

Read the field behaviour, it is load-bearing:

  • description is capped at 200 characters with a trailing ... that Facebook adds itself. Both long listings sampled came back at exactly 200; a short one came through complete at 69 characters. The cap is the source's, not ours.
  • price, location, condition and seller came back null on all 18 calls across the three listings in this repo on 2026-07-20. Sellers frequently type the number into the listing body instead, so listing.py ships a price_from_description() that pulls it back out: on the motorcycle sample it recovers 600 from "Selling as is \n600$OBO". It is a regex over free text, so treat it as a hint and not a price feed.
  • currency is USD on every listing sampled, and it is a fallback rather than a reading. With price null there is no source value behind it. Do not convert against it.
  • images held exactly 1 photo on all 18 calls, and thumbnail is that same URL. The URLs carry Facebook's own oh signature and oe expiry parameters, so they stop working: fetch the bytes if you need to keep the image.
  • data_source was opengraph on all 18 calls. It tells you the og block resolved rather than degrading to a <title> fallback, which is a useful gate for detecting a markup change.

There is no 404. An item id that resolves to nothing returns 502 extraction_failed, on 4 of 4 attempts, and retrying does not change it. That one status covers every reason a listing might have no public document, and it does not distinguish between them: of the nine item URLs collected while building this repo, four resolved and five came back 502. Live listings returned 200 on 18 of 18 calls in that session and on 14 of 15 in a later one, so a lone 502 is retryable rather than proof a listing has gone: listing_watch.py confirms one with a second call before it records a takedown.

Runnable: facebook_marketplace_scraper_api_codes/listing.py


Archive listings and catch the ones taken down

A Marketplace listing is a disappearing document, so the most useful thing you can do with this endpoint is run it on a schedule against the URLs you care about. listing_watch.py snapshots every listing it can still reach into a local SQLite dataset, reports title and description edits, and flags the ones that have gone:

python facebook_marketplace_scraper_api_codes/listing_watch.py

Pass your own item URLs as arguments; with none, it watches the three listings sampled in this repo.

Facebook Marketplace listing archiving and takedown detection

Those are two real runs, half an hour apart. The first has nothing to compare against, so it seeds the database and says so; the second diffs against the stored rows and reports them unchanged. A 200 -> 502 transition surfaces as TAKEN DOWN since the last run, confirmed with a second call first, and an edited body surfaces as TITLE OR DESCRIPTION EDITED. Export the archive with one command:

sqlite3 -header -csv facebook_listings.db "select * from snapshots" > listings.csv

One API call per listing per run. Your one-time 1,000 free requests covers roughly a month of daily checks on 30 listings.

Because a 502 does not say why a listing has no public document, the script records the transition rather than naming a cause. What it gives you is the date the document stopped resolving, and the last copy of it that did.


Measured latency

Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20. This includes the upstream fetch, the anti-bot handling, and the parse:

Endpoint Median Range 2xx n
/facebook/marketplace 3.1s 1.3 to 5.1s 8/8 8

Read the range, not just the median: the same listing URL, called eight times in a row, took 1.3s on its fastest call and 5.1s on its slowest, a 4x spread with no change on our side. Size your timeouts off the top of the range, not the middle.

That is one laptop on one afternoon, and n=8 is too small a sample to say anything about the ~99% platform benchmark either way. Reproduce it with the scripts in this repo.


License

MIT. See LICENSE.