Facebook Scraper for extracting page names, IDs, likes, followers, talking-about counts, posts and groups from Facebook.com. This repo has a free Facebook web scraping script you can run right now, and a Facebook page data API with 4 endpoints returning real structured JSON.
Last updated: 2026-07-17. Working against Facebook.com as of July 2026, and re-verified whenever Meta changes their markup.
This repo reads public brand and business pages only, and collects no personal data. Every capture, screenshot and example here targets a company's own public Facebook presence (Nike, Coca-Cola, adidas, NASA, an official brand community group, a public venue event). Nothing in this repo reads, stores or renders a private individual's profile, name, photo or comments, and the endpoints that only make sense pointed at people are deliberately left out.
Every JSON block on this page was captured from the live API on 2026-07-16. Long arrays are trimmed to the first item or two and long image URLs are truncated where marked (each block says exactly what was cut); every field shown is verbatim. Full uncut samples are committed in facebook_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python facebook_scraper_api_codes/page.pyThose three lines return this, live from Facebook.com:
{
"name": "Nike",
"page_id": "100044541544829",
"url": "https://www.facebook.com/nike/",
"likes": 39588952,
"followers": 39588952,
"talking_about_count": 139519,
"category": null,
"is_verified": null
}...that is 8 of the 17 fields on the page row, picked for the intro; the full 17 are below, decomposed into 8 rows:
That is the whole point of this repo. The rest of this page is how it works, endpoint by endpoint, starting with the free Facebook scraper: one HTTP request, no key, real page data.
- Free Facebook Scraper
- Avoid getting blocked when scraping Facebook
- Facebook Scraper API reference
- Track brand and competitor page likes over time
- Measured latency
- License
Facebook server-renders a public Page's headline stats into its OpenGraph <meta> tags, so you can extract structured Facebook page data without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/facebook_free_scraper.py nikeSource: free_scraper/facebook_free_scraper.py. It reads the og:title, og:description and al:* deep-link tags, and emits name, page_id, likes, talking_about, tagline, url, image.
After running the command, your terminal should look something like this:
That is a real run. For public brand pages the free path is viable, and the next section covers what determines whether a request gets through.
Facebook is the most login-gated surface on the public web, so the intuition is that a plain HTTP request gets walled. For a public brand Page, that intuition is wrong. We ran the script above from a normal residential connection, 20 times in a row with no delay, on 2026-07-16:
| What we measured | Value |
|---|---|
| HTTP 200 | 20 of 20 |
| Response size | ~462,000 bytes (a real page document) |
og: tags present |
7 |
| Rate limiting | none observed at 20 sequential requests |
| Login wall | not served to a public brand page |
So what actually blocks you? Almost always, this:
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 with zero og: tags, on 5 of 5 attempts. curl/8.0.1, python-requests/2.34.2, a self-describing facebook-scraper/1.0 and no UA at all each got the real ~462 KB Nike document with 7 og: tags, on 5 of 5. A UA that claims to be a browser when nothing else about the connection is one is worse than no UA. That is why the script in this repo 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.pySource: free_scraper/ua_test.py. If Meta changes this behaviour, that script is how you find out.
Here is what genuinely bites you, none of which a proxy fixes:
| What bites you | Why | What it costs you |
|---|---|---|
| HTTP 200 is not proof of data | An unknown vanity does not 404. Facebook returns a 200 with a 303 KB page whose og:title is absent, and the API turns that into a 200 whose page row is literally "name": "Facebook" with every count null. |
A typo in a page name looks like a real page with zero engagement. Check the payload, not the status code. Both scripts here do. |
| The counts move between calls | talking_about_count returned 11.4% apart on identical calls seconds apart (adidas), while page_id and name never moved. |
Diff two runs and you will "detect" engagement swings that never happened. |
| Most of Facebook has no logged-out surface at all | Search, Marketplace item detail and post comments return a login wall or a bare shell to any logged-out request. Page category came back on 0 of 10 calls. Post reaction/comment/share counts are always null. |
Confirm the fields you need are in the responses below before you design around them. |
| The og surface is shallow | You get the headline stats Facebook publishes for link previews. You do not get post-level engagement, member lists, or the follower/like split. | Scope your product to what exists before you build on it. |
| The markup moves | The og/Relay shape changes a few times a year. Your regex silently returns None. |
Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken". |
The counts drifting between calls is worth sitting with. It tells you the response is genuinely fetched per call rather than replayed from a cache, and it tells you the number itself is approximate. Treat talking_about_count as a live sample, not a ledger.
The managed option. The Chocodata Facebook Scraper API has four endpoints for Facebook page data extraction at scale (pages, posts, groups and events), a ~99% success rate, parsed JSON instead of 462 KB of HTML, and a pinned US egress so the numbers are comparable between runs. 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 above already mapped, the egress pinned to one country so two runs are comparable, and someone else on the hook the next time Meta moves the markup.
Below is the Facebook Scraper API reference to get you started: four endpoints, each with its parameters, a runnable call, and the response it returns.
curl "https://api.chocodata.com/api/v1/facebook/page?api_key=YOUR_KEY&page=nike"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/facebook/page",
params={"api_key": "YOUR_KEY", "page": "nike"},
timeout=90,
)
row = r.json()["results"][0]
print(row["name"], row["likes"])
# Nike 39588952After running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
country |
string (ISO-2) | no | us |
Egress location, accepted by post, group and event. Facebook localises what it renders to the location it sees, so pin this for reproducible results. Note page does not take this param: it is always fetched from a US egress. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
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 |
Facebook served a login wall or a JS shell instead of public data for this request. | no | Retry. |
Two response shapes exist: auth/billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. Both are shown below.
There is no 404. A page that does not exist comes back as a 200, which is the single most important gotcha on this page. See the page 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:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/facebook/page?api_key=totally_invalid_key_123&page=nike"{"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 page + post_id, is required",
"path": []
}
]
}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. Every endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. A request can take up to ~15s (see Measured latency), which is why the examples use timeout=90.
Sizing: at Pro (50 concurrent) and a ~1.9s median page call, one worker pool sustains roughly 50 / 1.9 = 26 requests/second. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(page):
r = requests.get("https://api.chocodata.com/api/v1/facebook/page",
params={"api_key": KEY, "page": page}, timeout=90)
return page, (r.json()["results"][0] if r.ok else None)
brands = ["nike", "adidas", "puma", "cocacola", "NASA"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for page, row in pool.map(one, brands):
print(page, row["likes"] if row else "failed")A public Facebook Page's identity and headline engagement stats, decomposed into the Page object plus its About facets.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
string | yes | - | The public Page vanity or numeric id (e.g. nike, 100044541544829). |
curl "https://api.chocodata.com/api/v1/facebook/page?api_key=YOUR_KEY&page=nike"Real response. results is cut to 2 of 8 and the two image URLs are truncated where marked; the page object itself is complete, all 17 fields verbatim (full sample):
{
"query": "nike",
"page": 1,
"results_count": 8,
"total_results": 8,
"results": [
{
"position": 1,
"id": "100044541544829",
"title": "Nike",
"url": "https://www.facebook.com/nike/",
"type": "page",
"page": "nike",
"page_id": "100044541544829",
"name": "Nike",
"description": "Nike. 39,588,952 likes · 139,519 talking about this · 17,881 were here. Just Do It.",
"tagline": "· 17,881 were here. Just Do It.",
"image": "https://scontent-iad3-1.xx.fbcdn.net/v/t39.30808-1/284964043_10159903868513445_76963539849676741...",
"thumbnail": "https://scontent-iad3-1.xx.fbcdn.net/v/t39.30808-1/284964043_10159903868513445_76963539849676741...",
"category": null,
"likes": 39588952,
"followers": 39588952,
"talking_about_count": 139519,
"is_verified": null
},
{
"position": 2,
"id": "100044541544829:Name",
"title": "Name: Nike",
"url": "https://www.facebook.com/nike/",
"type": "about",
"page": "nike",
"field": "Name",
"value": "Nike"
}
]
}page_id is the field most people come for: 100044541544829 is Nike's stable numeric Page id, and it is the join key that survives a vanity rename. Row 1 is the Page (type: "page"); rows 2 to 8 are type: "about" facets carrying the same values in a flat field/value shape.
Read the nulls, they are load-bearing:
categoryisnull, always. Facebook does not render a Page's business category to a logged-out request. It came back null on 10 of 10 calls. TheCategoryAbout facet is not a rescue: it falls back to the OpenGraph type, so for Nike that row reads"value": "video.other". That is real output, and it is anog:typestring, not a business category. Do not map it to one.followersequalslikeson every page we captured, because the only follower figure Facebook exposes logged-out is the like count. They are not two independent measurements.is_verifiedisnull. The blue-check state is not in the logged-out payload, so we return null rather than guess from the page name.
There is no 404. Ask for a vanity that does not exist and you get a 200 whose page row is "name": "Facebook" with likes: null and page_id echoing your input back at you. If your pipeline trusts the status code, a typo becomes a real-looking brand with zero engagement. Check likes is not None (or name != "Facebook") on every row before you store it. page.py has this check in page_row(); copy it.
Runnable: facebook_scraper_api_codes/page.py
One public post published by a brand Page, by permalink or by page + post_id.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | one of url / page+post_id |
- | Full post permalink. |
page |
string | with post_id |
- | Page vanity or numeric id. |
post_id |
string | with page |
- | The post / story id. |
country |
string (ISO-2) | no | us |
Egress location. |
curl "https://api.chocodata.com/api/v1/facebook/post?api_key=YOUR_KEY&url=https://www.facebook.com/designrush/posts/1858081705614138/"Real response. Complete object, all 16 fields verbatim; the two image URLs are truncated where marked and caption carries Facebook's own trailing ... (full sample):
{
"id": "1858081705614138",
"page": "designrush",
"page_id": null,
"post_id": "1858081705614138",
"url": "https://www.facebook.com/designrush/posts/fifa-made-someone-tape-over-60000-individual-seat-logos-at-gillette-stadium-by-h/1858081705614138/",
"author": "DesignRush",
"author_url": "https://www.facebook.com/designrush/",
"caption": "FIFA made someone tape over 60,000 individual seat logos at Gillette Stadium by hand. \n\nGillette covered its stadium sign to look like shaving foam. One image. No ad spend. Global coverage. \n\nThe...",
"title": "FIFA made someone tape over 60,000 individual seat logos at Gillette Stadium by hand. \n\nGillette covered its stadium sign to look like shaving foam. One image. No ad spend. Global coverage. \n\nThe...",
"image": "https://scontent-bos5-1.xx.fbcdn.net/v/t51.82787-15/726987970_18445953595190539_7673042071570162...",
"thumbnail": "https://scontent-bos5-1.xx.fbcdn.net/v/t51.82787-15/726987970_18445953595190539_7673042071570162...",
"reactions_count": null,
"comments_count": null,
"shares_count": null,
"source": "facebook",
"data_source": "opengraph"
}caption is what people come for: the full post copy, straight from og:description. author is the Page that published it, and data_source: "opengraph" tells you the full og block resolved rather than degrading to the <title> fallback, which is a useful gate for detecting a markup change.
reactions_count, comments_count and shares_count are null on every post: Facebook loads post engagement over an authenticated GraphQL call and puts none of it in the logged-out document, so we return null rather than invent a number. What this endpoint gives you is the creative: copy, media, author, permalink. caption is truncated by Facebook itself at roughly 200 characters with a trailing ..., and page_id is null here even though page resolves.
Runnable: facebook_scraper_api_codes/post.py
A public group's about surface. Group-level metadata only: no members, no member posts, no personal data.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
id |
string | one of id / url |
- | Group slug or numeric id (e.g. InstantPotCommunity). |
url |
string (URL) | one of id / url |
- | Full group URL. |
country |
string (ISO-2) | no | us |
Egress location. |
curl "https://api.chocodata.com/api/v1/facebook/group?api_key=YOUR_KEY&id=InstantPotCommunity"Real response. Complete object, all 11 fields verbatim; the two image URLs are truncated where marked and description carries Facebook's own trailing ... (full sample):
{
"id": "InstantPotCommunity",
"url": "https://www.facebook.com/groups/InstantPotCommunity/",
"name": "Instant Pot® Community",
"members_count": 3200000,
"privacy": "public",
"description": "Welcome to the worldwide Instant Pot® Community, a growing group of people just like you who are excited and ready to create mealtime amazement! \n\nHere...",
"category": null,
"cover_image": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-6/571211886_1238145355004206_294438163312966189...",
"thumbnail": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-6/571211886_1238145355004206_294438163312966189...",
"source": "facebook",
"data_source": "opengraph"
}Field availability. Measured on 2026-07-16 over 10 identical calls to the group above:
| Field | Came back |
|---|---|
name, description, cover_image |
10 / 10 |
members_count, privacy |
7 / 10 |
category |
0 / 10 |
Facebook serves a reduced variant of the group document about a third of the time, and members_count and privacy are the fields it drops. When members_count does arrive it is Facebook's own rounded display figure (it renders "3.2M members"), so it is precise to two significant figures and no further: we saw both 3100000 and 3200000 for this group on the same day. category has no logged-out surface. A run that hit the reduced variant:
Retry on null when you need members_count or privacy. group.py detects the reduced variant and tells you which call you got.
Runnable: facebook_scraper_api_codes/group.py
A public event page (brand, venue or organisation events).
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
id |
string | one of id / url |
- | Numeric event id. |
url |
string (URL) | one of id / url |
- | Full event URL. |
country |
string (ISO-2) | no | us |
Egress location. |
curl "https://api.chocodata.com/api/v1/facebook/event?api_key=YOUR_KEY&id=1257836046493803"Real response. Complete object, all 15 fields verbatim, nothing cut (full sample):
{
"id": "1257836046493803",
"url": "https://www.facebook.com/events/raceway-venray/bimmerfest-2026-official-event-page/1257836046493803/",
"name": "BimmerFest 2026 (official event page)",
"description": "Event in Vredepeel, Netherlands by BimmerFest Europe on Sunday, June 7 2026 with 209 people interested and 100 people going. 13 posts in the discussion.",
"start_time": null,
"end_time": null,
"location": null,
"host": null,
"attending_count": null,
"interested_count": null,
"cover_image": "https://lookaside.fbsbx.com/lookaside/crawler/media/?media_id=1257836046493803",
"thumbnail": "https://lookaside.fbsbx.com/lookaside/crawler/media/?media_id=1257836046493803",
"is_online": null,
"source": "facebook",
"data_source": "opengraph"
}Field availability. Across 3 public events on 2026-07-16, name, description and cover_image came back 3 of 3, and start_time, end_time, location, host, attending_count, interested_count and is_online came back 0 of 3.
The information is not gone, it is just prose. The date, the place, the host and both counts are all sitting inside description as an English sentence:
"Event in Vredepeel, Netherlands by BimmerFest Europe on Sunday, June 7 2026 with 209 people interested and 100 people going."
event.py ships a parse_description() that regexes those five values back out, and running it recovers {'place_text': 'Vredepeel, Netherlands', 'host_text': 'BimmerFest Europe', 'date_text': 'Sunday, June 7 2026', 'interested_text': 209, 'going_text': 100}. It is a regex over English prose, so it will break on a non-English event or a phrasing change.
Runnable: facebook_scraper_api_codes/event.py
Tracking your own brand pages against your competitors' is the main reason people scrape Facebook, so that use case is in the repo end to end rather than as a snippet. page_monitor.py polls any number of public brand pages, stores every observation as a local dataset in SQLite (export it to CSV with one sqlite3 command), and prints the diff since the last run:
python facebook_scraper_api_codes/page_monitor.py nike adidas pumaThat is a real first run: the first pass has nothing to compare against, so it seeds the database and says so. One API call per page, per run.
The interesting part of that script is not the polling, it is the noise floor, and it is why the numbers above are trustworthy. Facebook's public counts scatter between calls. Measured 2026-07-16, 6 identical calls per brand seconds apart:
| Brand | likes spread |
talking_about_count spread |
||
|---|---|---|---|---|
| nike | 1 | 0.0000% | 11,606 | 7.7% |
| adidas | 5,406 | 0.0124% | 13,690 | 11.4% |
| puma | 2,366 | 0.0102% | 650 | 3.6% |
| cocacola | 28,953 | 0.0270% | 54 | 3.7% |
page_id and name never moved. So a raw delta is worthless: an early version of this script proudly reported "PUMA +473 likes" over a three-second gap, which is scatter, not growth. The script now only reports a move that clears a relative floor set from those measurements: 0.05% on likes (about twice the worst observed) and 15% on talking_about_count (about 1.3x the worst observed). Re-running it seconds later correctly reports 0 changes.
In relative terms likes is tight enough to trend a real percentage-level move. talking_about_count is not, at any threshold that would still catch a real change. Anyone selling you Facebook "engagement trends" off two calls is selling you noise.
Your one-time 1,000 free requests covers roughly a month of daily checks on 30 brand pages.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-16. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | 2xx | n |
|---|---|---|---|---|
/facebook/page |
1.9s | 1.6 to 3.5s | 5/5 | 5 |
/facebook/post |
1.8s | 0.9 to 15.5s | 4/5 | 5 |
/facebook/group |
1.6s | 0.9 to 2.0s | 5/5 | 5 |
/facebook/event |
1.5s | 1.2 to 1.9s | 5/5 | 5 |
Read the ranges, not just the medians. /facebook/post has the widest spread: one call took 15.5s, roughly 9x its own median. That is a request that ran into Facebook's gate and cost us real work to get data out of anyway. Absorbing that, silently, is part of what you are buying. We do not publish the specifics.
That is one laptop on one afternoon, and n=5 per endpoint is too small a sample to say anything about the ~99% platform benchmark either way. Reproduce it with the scripts in this repo.
MIT. See LICENSE.








