Skip to content

Repository files navigation

Facebook Page Scraper

Facebook Page Scraper

Facebook Page Scraper for extracting page names, page IDs, likes, followers and talking-about counts from Facebook.com. This repo has a free Facebook page web scraping script you can run right now, and a Facebook page data API that turns a vanity URL into structured JSON. Other public Facebook surfaces have their own reference in the Facebook Scraper repo.

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

This repo reads a Page's own public about surface only, and collects no personal data. Every capture, screenshot and example here targets a brand or organisation's own public Page (LEGO, NASA, Spotify, Netflix, National Geographic, Airbnb, TED, WWF). Nothing in this repo reads, stores or renders a Page's followers, posts, comments, or any individual's name or photo, and the endpoint has no surface that would return them.

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_page_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_page_scraper_api_codes/.

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

Those three lines return this, live from Facebook.com:

{
  "page_id": "100064814375235",
  "name": "LEGO",
  "url": "https://www.facebook.com/LEGO/",
  "likes": 15891414,
  "followers": 15891414,
  "talking_about_count": 178129,
  "category": null
}

...that is 7 of the 17 fields on the Page row; the full 17 are below. Point it at a set of brands and you get a row each:

Retrieved Facebook page data

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


Contents


Free Facebook Page Scraper

Facebook server-renders a public Page's name, engagement stats and profile image into the OpenGraph <meta> tags of the page URL, before any JavaScript runs. So you can extract page data without a headless browser, JavaScript rendering, or a login. No key, no cost:

python free_scraper/facebook_page_free_scraper.py LEGO

Source: free_scraper/facebook_page_free_scraper.py. It reads og:title, og:description, og:image and the al:* deep-link tags, and emits page, page_id, name, url, likes, talking_about_count, image. It parses first and only reports "no data" when the OpenGraph block is genuinely absent, so a page that renders is never miscounted as a block.

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

Free Facebook page scraper

Note what the counts actually are. Facebook does not expose likes as a field: it renders a sentence into og:description, LEGO. 15,891,414 likes · 178,129 talking about this. Building the future, one brick at a time., and the numbers have to be parsed back out of it. The numeric page id comes from the al:android:url deep link rather than from any data attribute. Both are formatting choices Facebook can change without warning.

Avoid getting blocked when scraping Facebook pages

Two things make this surface awkward, and neither of them is a 403.

The User-Agent that looks most legitimate is the one that gets refused. A full desktop Chrome string sent from a plain HTTP client came back 400 with a 1,542-byte page titled Error, on 6 of 6 requests across two brand page URLs. curl/8.4.0, python-requests/2.34.2 and this repo's own self-describing agent each got the real ~463 KB document on 6 of 6 in the same run:

Facebook served four different User-Agents

Reproduce it in about a minute:

python free_scraper/ua_test.py 3

That is why the free scraper here sends its own name, and why swapping in a browser string you copied from your own devtools will make things worse rather than better.

The second problem is that the numbers move under you. Facebook's engagement figures are live samples rather than ledger entries, and two calls seconds apart disagree by more than most people's change thresholds:

Count drift across two runs of five calls per page

python facebook_page_scraper_api_codes/count_drift.py 5

Across two runs of five consecutive calls to each of five brand pages, talking_about_count moved by up to 15.10% while likes never moved by more than 0.03%, and page_id and name held a single value throughout. That spread is not engagement changing. It is the same number sampled twice.

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

What bites you Why What it costs you
A copied browser User-Agent is refused A desktop Chrome string got 400 and 1,542 bytes on 6 of 6 requests, while curl, python-requests and a self-describing agent got the real document on 6 of 6. The one change most people make first is the one that breaks it, and a 400 sends you hunting for a bug in your own query string.
talking_about_count is a live sample It moved up to 15.10% between identical calls seconds apart, on pages where nothing happened. Diff two runs and you will "detect" engagement swings that never happened. Gate on a relative floor above the drift you measure.
HTTP 200 is not proof of data Facebook answers 200 and varies the body. The empty variant is a placeholder whose name is the string Facebook and whose counts are all null. If your pipeline trusts the status code, you will store nulls and never find out.
The counts are parsed out of a sentence likes and talking_about_count are rendered into og:description as prose, not exposed as fields. A wording change breaks your regex, and it breaks quietly, returning None rather than raising.
category is not served logged out It was null on all 36 calls we made across 8 brand pages. The About facet that looks like a category is an og:type string. Any pipeline keyed on business category needs another source.
Signed image URLs expire image carries Facebook's own oh signature and oe expiry parameters. The link in a row you stored last month is already dead. Fetch the bytes if you need to keep the image.

Using the Chocodata Facebook Page Scraper API

The managed option. The Chocodata Facebook Page Scraper API turns a vanity URL 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 counts already parsed out of the rendered sentence for you. Across 36 calls to 8 public brand pages on 2026-07-20 it returned the full 8-row page object, with page_id, name and likes resolved, on 36 of 36. 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 Page Scraper API reference

Below is the Facebook Page 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/page?api_key=YOUR_KEY&page=LEGO"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/facebook/page",
    params={"api_key": "YOUR_KEY", "page": "LEGO"},
    timeout=90,
)
r.raise_for_status()          # a 502 means no public page document came back
page = r.json()["results"][0]
print(page["page_id"], "|", page["name"], "|", f'{page["likes"]:,}')
# 100064814375235 | LEGO | 15,891,414

page_id, name, likes, followers and talking_about_count 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:

Running the Facebook Page Scraper API page 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.
page string yes - The Page vanity or numeric id. This endpoint takes no other input.

Unlike most endpoints in this catalogue, /facebook/page does not take a country. It resolves from a US egress, which is what Facebook serves a full Page document to. Passing a country anyway is not rejected but does change the result. Across four interleaved rounds against the same page, no country and country=us each returned the real page 4 times out of 4; country=gb returned the empty placeholder 4 times out of 4, and country=de returned it 3 times with the fourth call failing outright. Neither returned the page once. Leave it unset.

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 page is missing or empty. 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 page document came back for this vanity. no Retry once with a few seconds' gap.

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 captured bodies are committed in errors.json.

There is no 404 on this endpoint. See the field notes below for what a vanity that does not resolve returns instead.

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

Facebook Page Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/facebook/page?api_key=totally_invalid_key_123&page=LEGO"
{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}

An empty page, verbatim. Note it names the exact field and constraint rather than returning a generic 400:

{
  "error": "invalid_params",
  "issues": [
    {
      "code": "too_small",
      "minimum": 1,
      "type": "string",
      "inclusive": true,
      "exact": false,
      "message": "String must contain at least 1 character(s)",
      "path": ["page"]
    }
  ]
}

Rate limits and concurrency

Two limits apply. A rate limit of 120 requests per 60 seconds per key (a sustained 2 requests/second), and a concurrency cap on 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 ~1.7s (see Measured latency), and the examples use timeout=90 for headroom.

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

from concurrent.futures import ThreadPoolExecutor
import requests

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

brands = ["LEGO", "NASA", "Spotify", "Netflix", "natgeo", "Airbnb", "TED", "WWF"]
with ThreadPoolExecutor(max_workers=8) as pool:   # <= your plan's concurrency
    for vanity, payload in pool.map(one, brands):
        print(vanity, payload["results"][0]["page_id"] if payload else "no public document")

1. Page: page names, page IDs, likes, followers and talking-about counts

A public Page's identity and headline engagement stats, by vanity or numeric id. The response decomposes the Page into the page object plus its About facets: 8 rows on every brand page we sampled, one type: "page" followed by seven type: "about" rows carrying the same values in a flat field/value shape. Page-level metadata only: a follower count but never a follower list, no posts, no personal data.

Param Type Required Default Description
page string yes - The public Page vanity (LEGO) or numeric id (100064814375235). Both resolve to the same 8-row response.
curl "https://api.chocodata.com/api/v1/facebook/page?api_key=YOUR_KEY&page=LEGO"

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": "LEGO",
  "page": 1,
  "results_count": 8,
  "total_results": 8,
  "results": [
    {
      "position": 1,
      "id": "100064814375235",
      "title": "LEGO",
      "url": "https://www.facebook.com/LEGO/",
      "type": "page",
      "page": "LEGO",
      "page_id": "100064814375235",
      "name": "LEGO",
      "description": "LEGO. 15,891,414 likes · 178,129 talking about this. Building the future, one brick at a time.",
      "tagline": "Building the future, one brick at a time.",
      "image": "https://scontent.fagc3-1.fna.fbcdn.net/v/t39.30808-1/456508994_927489786088159_49334715581...",
      "thumbnail": "https://scontent.fagc3-1.fna.fbcdn.net/v/t39.30808-1/456508994_927489786088159_49334715581...",
      "category": null,
      "likes": 15891414,
      "followers": 15891414,
      "talking_about_count": 178129,
      "is_verified": null
    },
    {
      "position": 2,
      "id": "100064814375235:Name",
      "title": "Name: LEGO",
      "url": "https://www.facebook.com/LEGO/",
      "type": "about",
      "page": "LEGO",
      "field": "Name",
      "value": "LEGO"
    }
  ]
}

page_id is the field most people come for: 100064814375235 is LEGO's stable numeric Page id, and it is the join key that survives a vanity rename.

Read the field behaviour, it is load-bearing:

  • page_id and name are the stable pair. Across 36 calls to 8 brand pages they returned exactly one value per page, while likes moved on almost every call. Two consecutive runs of page_ids.py thirty seconds apart returned identical page_id values on 8 of 8 brands and different likes on 6 of 8. Key your storage on page_id, never on the vanity or the counts.
  • talking_about_count is a live sample, not a total. Two runs of five consecutive calls per page put its spread between 0.35% and 15.10%, against 0.03% at worst for likes. If you publish a count, say when you measured it, and set any change threshold above the drift you measure yourself.
  • A vanity that does not resolve returns HTTP 200, not a 404: the response is 3 rows instead of 8, name is the literal string Facebook, every count is null, and page_id echoes your own input back at you, which held on 12 of 12 synthetic vanities we tried (sample); check likes is not None before you store a row, as page_row() in page.py does.
  • category is null, always. Facebook does not render a Page's business category to a logged-out request. It came back null on all 36 calls. The Category About facet is not a rescue: it falls back to the OpenGraph type, so for LEGO that row reads "value": "video.other". That is real output, and it is an og:type string, not a business category. Do not map it to one.
  • followers equals likes on every page we captured, across the 11 brand-page responses we kept in full. The only follower figure Facebook exposes logged out is the like count, so these are one measurement printed twice, not two.
  • is_verified is null on all 36 calls. The blue-check state is not in the logged-out payload, so we return null rather than guess it from the page name.
  • description is the raw rendered sentence (LEGO. 15,891,414 likes · 178,129 talking about this. Building the future, one brick at a time.) and tagline is the human half of it with the counts stripped. If you want the brand's own words, use tagline.
  • url is Facebook's canonical form, not your input. The response for a numeric id still carries the vanity URL. Store page_id alongside whatever you keyed on.

Runnable: facebook_page_scraper_api_codes/page.py


Resolve brand vanity URLs to stable page IDs

A vanity is a label a brand can change. page_id is the identity underneath it, and it is what lets you join Facebook rows to anything else you hold about that brand. Building that lookup table is the main reason people scrape Facebook Pages, so that use case is in the repo end to end rather than as a snippet. page_ids.py resolves any number of vanities, stores them as a local dataset in SQLite, and re-checks them on every run:

python facebook_page_scraper_api_codes/page_ids.py LEGO NASA Spotify Netflix

Resolving Facebook vanity URLs to page IDs

Those are two consecutive real runs, thirty seconds apart. Every page_id is identical across both; six of the eight like counts are not. That contrast is the whole argument for the table: the counts are what you sample, and page_id is what you join on.

Export it with one command:

sqlite3 -header -csv facebook_pages.db "select * from page_ids" > page_ids.csv

The script writes nothing it did not actually resolve. A vanity that comes back without page data is reported and skipped rather than stored as a row with null counts, which matters more here than in most pipelines: a table of page IDs is only useful if every row in it is real. And because it compares against what it stored last time, a brand that moves to a new Page shows up as a page_id that changed under a vanity you already had, which is the one event this dataset exists to catch.


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/page 1.7s 1.4 to 3.4s 8/8 8

Read the range, not just the median: the slowest call in that sample took twice the median. 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.