Facebook Profile Scraper for extracting profile names, usernames, profile IDs, likes and profile pictures from Facebook.com. This repo has a free Facebook profile web scraping script you can run right now, and a Facebook profile data API that turns a handle into structured JSON.
Last updated: 2026-07-20. Working against Facebook.com as of July 2026, and re-verified whenever Meta changes their markup.
This repo reads public organisation profiles only, and collects no personal data. Every capture, screenshot and example here targets an organisation's own public Facebook presence (NASA, National Geographic, BBC News, the British Museum, Nike). Nothing in this repo reads, stores or renders a private individual's profile, name, photo or comments. The endpoint resolves any Facebook handle, so pointing it at a person is technically possible and deliberately out of scope here: keep it on brand, business and organisation profiles, which is what every sample, script default and image in this repo uses. 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_profile_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_profile_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python facebook_profile_scraper_api_codes/profile.py NASAThose three lines return this, live from Facebook.com:
{
"id": "100044561550831",
"username": "NASA",
"name": "NASA - National Aeronautics and Space Administration",
"category": null,
"about": "NASA - National Aeronautics and Space Administration. 28,656,732 likes · 20,516 talking about this. Explore the universe and discover our home planet. \nThere's space for everybody. ✨",
"is_verified": null
}...that is 6 of the 11 fields on a profile; the full 11 are below. The 28.6 million likes are in there, as text, which is the thing to know about this surface. Point it at a list of handles and you get a row each:
That is the whole point of this repo. The rest of this page is how it works, starting with the free Facebook profile scraper: one HTTP request, no key, real profile data.
- Free Facebook Profile Scraper
- Avoid getting blocked when scraping Facebook profiles
- Facebook Profile Scraper API reference
- Build a dataset of organisation profiles from a handle list
- Measured latency
- License
Facebook server-renders a public organisation profile's name, about text and picture into its OpenGraph <meta> tags, so you can extract profile data without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/facebook_profile_free_scraper.py NASASource: free_scraper/facebook_profile_free_scraper.py. It reads og:title, og:description, og:image and og:url, emits username, url, name, about, profile_image, and runs parse_about() over the about text to recover the likes and talking-about figures. It parses first and only reports "no data" when the OpenGraph block is genuinely absent, so a profile that renders is never miscounted as a block.
After running the command, your terminal should look something like this:
That works, and it kept working: 25 plain HTTP requests across the 5 public profiles sampled here, 5 per profile, 1.5 seconds apart, from one ordinary home internet connection on 2026-07-20, returned HTTP 200 with the og block present on 5 of 5 every time. Response sizes moved between requests (463,175 to 466,950 bytes on one profile), so those were 25 real fetches rather than one cached page served back 25 times. This surface is not walled, and nothing on this page needed a browser, a login or a proxy to reach.
What does bite is more specific, and the next section is what it is.
The one thing that gets refused outright is the thing most scraping guides tell you to do.
A desktop Chrome User-Agent sent from a plain HTTP client came back 400, with a 1,542-byte page titled Error, on 3 of 3 requests. In the same run, curl/8.4.0, python-requests/2.34.2 and this repo's own self-describing agent each got the real ~463 KB document with all 7 og: tags, on 3 of 3. Reproduce it in about twenty seconds:
python free_scraper/ua_test.py 3That is why the script here sends its own name, and it is worth knowing before you go looking for a proxy problem you do not have. If you "fixed" a scraper by pasting in a browser User-Agent, you broke it.
Here is what genuinely bites you, none of which a proxy fixes:
| What bites you | Why | What it costs you |
|---|---|---|
| A browser User-Agent is refused | 3 of 3 requests with a Chrome 126 agent returned 400 and 1,542 bytes titled Error. Three self-describing agents got the real document 3 of 3 in the same run. |
The one piece of standard scraping advice that backfires here. |
| HTTP 200 is not proof you got a profile | A group URL sent to this endpoint answers 200, with username: "groups" and the group's name in name. It is not a 404, a 400 or a redirect. |
A crawler fed a mixed URL list stores groups as profiles. Check username, not the status code. |
| The numbers are prose, not fields | The likes and talking-about figures live inside about as an English sentence. There is no likes field to read. |
You are writing a text parser, and a phrasing change breaks it without raising anything. |
talking_about scatters between identical calls |
Six identical calls per profile, seconds apart: NASA moved 3,251 (14.06%), Nike 14,933 (10.32%), National Geographic 12,984 (5.81%). likes moved by 0 to 3 across the same 24 calls. |
A raw delta on talking_about reports engagement swings that are measurement noise. likes is the one you can trend. |
name is not the brand name |
britishmuseum returns British Museum | London and bbcnews returns BBC News | London, while nike returns Nike and NASA returns its expanded legal name. |
Joining datasets on name silently drops rows. Join on id. |
| Every field traces to one og block | data_source was opengraph on 15 of 15 calls, so the whole row rests on a handful of <meta> tags. |
When Meta moves that block your parser returns None rather than an error. You need alerting that can tell "empty" from "broken". |
The managed option. The Chocodata Facebook Profile Scraper API turns a handle into parsed JSON instead of ~463 KB of HTML, with a ~99% success rate, a pinned egress location so two runs are comparable, and the User-Agent trap above handled for you. Across 15 calls to 5 public organisation profiles on 2026-07-20 it returned a parsed profile with id, username, name and about on 15 of 15, at a 1.8s median. 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.
Below is the Facebook Profile Scraper API reference to get you started: one endpoint, its parameters, a runnable call, and the response it returns.
curl "https://api.chocodata.com/api/v1/facebook/profile?api_key=YOUR_KEY&url=https://www.facebook.com/NASA"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/facebook/profile",
params={"api_key": "YOUR_KEY", "username": "NASA"},
timeout=90,
)
r.raise_for_status() # a 502 means no public profile document came back
p = r.json()
print(p["id"], "|", p["name"])
# 100044561550831 | NASA - National Aeronautics and Space Administrationid, username, url, name, about and both image fields come back on every call. category and is_verified do not, which the field notes cover.
After 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. 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. Verified on us, ca and jp: a parsed profile on 3 of 3 calls each. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (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 or upgrade. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
extraction_failed |
No public profile document came back for this handle or URL. | no | Retry once after a few seconds. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. The 400 and 401 bodies are shown below and committed in errors.json.
There is no 404. A handle that does not exist returns 502 extraction_failed, and so does a non-Facebook URL: example.com as a control returned 502 rather than a 200 with name: "Example Domain" under a profile schema. The five public profiles sampled here returned 200 on 15 of 15 calls, so on this endpoint a 502 reads as "no public profile document came back for this input". It does not tell you why, so do not read a deleted profile into it.
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/facebook/profile?api_key=totally_invalid_key_123&username=NASA"{"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 username is required",
"path": []
}
]
}The scripts in this repo map the statuses in the table above onto actionable messages, so a typo'd key does not hand you a stack trace:
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 typically takes ~2s (see Measured latency), and the examples use timeout=90 for headroom.
Sizing: at Pro (50 concurrent) and a ~1.8s median call, one worker pool sustains roughly 50 / 1.8 = 28 requests/second. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(handle):
r = requests.get("https://api.chocodata.com/api/v1/facebook/profile",
params={"api_key": KEY, "username": handle}, timeout=90)
return handle, (r.json() if r.ok else None)
handles = ["NASA", "natgeo", "bbcnews", "nike", "britishmuseum"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for handle, p in pool.map(one, handles):
print(handle, p["id"] if p else "no public document")A public profile's own about surface, by vanity handle or full URL. Profile-level metadata only: no posts, no friends, no followers list, no comments.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username / url |
- | Vanity handle, e.g. NASA. Case is preserved in the response but not required to match. |
url |
string (URL) | one of username / url |
- | Full profile URL, facebook.com/<handle>. |
country |
string (ISO-2) | no | us |
Egress location. |
curl "https://api.chocodata.com/api/v1/facebook/profile?api_key=YOUR_KEY&username=NASA"Real response. Complete object, all 11 fields verbatim; the two CDN image URLs are truncated where marked (full sample):
{
"id": "100044561550831",
"username": "NASA",
"url": "https://www.facebook.com/NASA/",
"name": "NASA - National Aeronautics and Space Administration",
"category": null,
"about": "NASA - National Aeronautics and Space Administration. 28,656,732 likes · 20,516 talking about this. Explore the universe and discover our home planet. \nThere's space for everybody. ✨",
"profile_image": "https://scontent-lax3-2.xx.fbcdn.net/v/t39.30808-1/243095782_416661036495945_3843362260429099279_n.p...",
"thumbnail": "https://scontent-lax3-2.xx.fbcdn.net/v/t39.30808-1/243095782_416661036495945_3843362260429099279_n.p...",
"is_verified": null,
"source": "facebook",
"data_source": "opengraph"
}id is the field to build on and about is the field to mine. Neither is the one people ask for first.
Read the field behaviour, it is load-bearing:
idis the join key. It is Facebook's numeric profile id (100044561550831for NASA), it is stable, and it is the only identifier here that is. A vanity handle can be changed by its owner andnameis neither unique nor stable, so key your own storage onidand treat the handle as a lookup.- The likes and talking-about figures are inside
about, as one English sentence: "NASA - National Aeronautics and Space Administration. 28,656,732 likes · 20,516 talking about this."parse_about()inprofile.pyregexes them out and returns{'likes': 28656732, 'talking_about': 20516, 'were_here': None}. Unlike a lot of Facebook's rendered counts these arrive at full precision, comma-grouped rather than rounded to "28M". were_hereonly exists on profiles with a physical location. The British Museum returns1,448,379 were hereand BBC News returns9,569, while NASA and National Geographic do not render it at all, soparse_about()returnsNonerather than zero.likesis steady andtalking_aboutis not. Six identical calls per profile on 2026-07-20, seconds apart:likesmoved by 0 to 3 units across four profiles (0.0000% relative), whiletalking_aboutmoved 3,251 on NASA (14.06%), 14,933 on Nike (10.32%), 12,984 on National Geographic (5.81%) and 189 on the British Museum (0.56%). Trendlikes; treattalking_aboutas an order of magnitude.categoryandis_verifiedwere null on 15 of 15 calls. Facebook does not render either to a logged-out request on this surface, so they come back null rather than guessed from the about text.namecarries whatever Facebook renders, which is often not the brand name.British Museum | LondonandBBC News | Londonboth append the city; NASA returns its expanded legal name;nikereturnsNike. Do not join on it.profile_imageandthumbnailare the same URL. It carries Facebook's ownohsignature andoeexpiry parameters, so it stops working: fetch the bytes if you need to keep the image.- A non-profile Facebook URL still answers
200. Point it at a group andusernamecomes back as the path segment it found (sample):
{
"id": "220885194946296",
"username": "groups",
"url": "https://www.facebook.com/groups/canvadesigncommunity/",
"name": "Canva Design Community (Official) | Facebook"
}A username of groups, events, marketplace or watch means you asked for a surface this endpoint does not describe. profile.py checks for exactly that and says so:
Runnable: facebook_profile_scraper_api_codes/profile.py
Turning a list of handles into a table with stable ids is the main reason people scrape Facebook profiles, so that use case is in the repo end to end rather than as a snippet. profile_dataset.py resolves any number of handles, stores every observation as a local dataset in SQLite, and prints what moved since the last run:
python facebook_profile_scraper_api_codes/profile_dataset.py NASA natgeo nike britishmuseumExport it with one command:
sqlite3 -header -csv facebook_profiles.db "select * from observations" > profiles.csvThat screenshot is the second run, seconds after the first, and every line of it is the script declining to report a change. The floors come from the scatter measured above: 0.001% on likes, which is over a hundred times the worst scatter observed there, and 20% on talking_about, which is about 1.4x its worst. Both are printed alongside the raw move, so you can see the noise rather than take the threshold on trust.
That second floor is where this field runs out. A 20% floor on NASA's talking_about means roughly 4,200 people have to move before the number is allowed to count, which is most of the range the field occupies in a quiet week. likes is the field that supports a trend line; talking_about supports a chart with a wide band on it and nothing more.
One API call per handle, per run. Your one-time 1,000 free requests covers roughly a month of daily checks on 30 profiles.
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/profile |
1.8s | 1.2 to 3.1s | 15/15 | 15 |
The range is tight, which is what you want from a surface whose whole job is one document fetch and a parse.
That is one laptop on one afternoon, and n=15 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.









