Skip to content

ChocoData-com/facebook-group-scraper

Repository files navigation

Facebook Group Scraper

Facebook Group Scraper

Facebook Group Scraper for extracting group names, descriptions, member counts, privacy and cover images from Facebook.com. This repo has a free Facebook group web scraping script you can run right now, and a Facebook group data API that turns a group URL 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 a group's own public about surface only, and collects no personal data. Every capture, screenshot and example here targets a brand or product community group's own public page (Instant Pot, Canva, Figma, Shopify). Nothing in this repo reads, stores or renders a group's member list, member posts, comments, or any individual's name or photo, and the endpoint has no surface that would return them. Facebook Pages, posts 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_group_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from facebook_group_scraper_api_codes/.

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

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

{
  "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,
  "data_source": "opengraph"
}

...that is 6 of the 11 fields on a group; the full 11 are below. Point it at a set of community groups and you get a row each:

Retrieved Facebook group data

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


Contents


Free Facebook Group Scraper

Facebook server-renders a public group's name, description and cover image into its OpenGraph <meta> tags, so you can extract group data without a headless browser, JavaScript rendering, or a login. No key, no cost:

python free_scraper/facebook_group_free_scraper.py canvadesigncircle

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

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

Free Facebook group scraper, both outcomes

Both of those are real runs, seconds apart. The free path works, and then sometimes it does not, on the same connection and the same day. That is not your code, and the next section is what is actually happening.

Avoid getting blocked when scraping Facebook groups

The interesting thing about this surface is that it rarely refuses you outright. It answered 200 on every request we made, and varied what it put in the response instead.

Facebook serves two documents for the same public group URL

Facebook serves more than one document for the same public group URL. Five plain HTTP requests per group, 1.5 seconds apart, from one ordinary home internet connection on 2026-07-20:

Group HTTP og block Response size <title> served
InstantPotCommunity 200 0 of 5 379,400-381,243 bytes Facebook
figmadesigners 200 0 of 5 379,277-381,930 bytes Facebook
canvadesigncircle 200 5 of 5 311,994-313,354 bytes Canva Design Community (Official)
shopifyentrepreneurs 200 5 of 5 312,016-313,406 bytes Shopify Entrepreneurs

A 380 KB page titled Facebook with zero og: tags is an HTTP 200 with no group in it. And this is not a property of those two groups: earlier the same day, from the same machine, InstantPotCommunity and figmadesigners both served the full og block on 3 of 3 requests, at 551,467 to 555,657 bytes. The variant changed underneath us between runs, which is the part worth designing for. Reproduce it:

python free_scraper/variant_test.py

The two group documents, measured across four groups

One thing does get refused outright, and it is worth knowing before you go looking for a network problem: a full Mozilla/5.0 ... Chrome/126.0.0.0 Safari/537.36 User-Agent sent from a plain HTTP client came back 400 with a 1,542-byte page titled Error, on 3 of 3 requests to a group URL. curl/8.7.1, python-requests/2.34.2 and this repo's own self-describing UA each got a real document on 3 of 3 in the same run. That is why the script here sends its own name and why you should leave it alone.

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

What bites you Why What it costs you
The same URL returns different documents 200 on all 20 requests, but the og block was present on only 10 of them across 4 groups, and which groups served it changed within a few hours. A single-shot scraper reports a healthy group as missing. Retry, and check the payload rather than the status.
HTTP 200 is not proof of data The empty variant is a 380 KB page titled Facebook. It is not a 404, a 403 or a redirect. If your pipeline trusts the status code, you will store nulls and never find out.
members_count is a rounded display figure Facebook renders "3.2M members", so the number is precise to two or three significant figures and no further. The same group returned 3,100,000 and 3,200,000 minutes apart, and another returned 105,000 then 104,000. Diff two runs and you will "detect" 100,000 new members who do not exist.
Even the rounding precision moves One group returned 45000 in one call and 45700 in another on the same afternoon: Facebook rendered "45K" once and "45.7K" the next time. A change in displayed precision looks identical to growth. Gate on a relative floor.
A private group still has a public about page privacy: "private" is a fact about joining, not about the about surface. Shopify Entrepreneurs is private and returns its name, description and member count. Do not assume private means unreachable, and do not assume public means you get more.
The markup moves The og 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 Group Scraper API

The managed option. The Chocodata Facebook Group Scraper API turns a group URL into parsed JSON instead of ~380 KB of HTML, with a ~99% success rate, a pinned egress location so two runs are comparable, and the document variance above absorbed for you. Across 28 calls to four public groups on 2026-07-20 it returned a parsed group with name, description and cover_image on 28 of 28, and on 60 of 60 in a longer run later the same day. In a third run, interleaved request for request against those same four URLs, it returned the group on 20 of 20 calls, including on the two groups that gave a plain client the empty variant five times out of five. 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 Group Scraper API reference

Below is the Facebook Group 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/group?api_key=YOUR_KEY&url=https://www.facebook.com/groups/InstantPotCommunity/"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/facebook/group",
    params={"api_key": "YOUR_KEY", "id": "InstantPotCommunity"},
    timeout=90,
)
r.raise_for_status()          # a 502 means no public document came back
g = r.json()
print(g["name"], "|", g["url"])
# Instant Pot® Community | https://www.facebook.com/groups/InstantPotCommunity/

name, url and description come back on every call. members_count and privacy do not, which the field notes cover and group.py handles with a retry.

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

Running the Facebook Group Scraper API group 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 group document came back for this URL. no Retry.

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. A group slug that does not exist returns 502 extraction_failed, on 4 of 4 attempts, and retrying does not change it. The four public groups sampled here returned 200 on 28 of 28 calls in one session and 60 of 60 in a later one, so on this endpoint a 502 reads as "no public group document came back for this URL" and not as a general failure mode. It does not tell you why, so do not read a deleted group into it.

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 Group Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/facebook/group?api_key=totally_invalid_key_123&id=InstantPotCommunity"
{"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 typically takes ~2s (see Measured latency), and the examples use timeout=90 for headroom.

Sizing: at Pro (50 concurrent) and a ~2.0s median call, one worker pool sustains roughly 50 / 2.0 = 25 requests/second. Budget for the retries in the field notes if you need members_count. Fan out with a thread pool:

from concurrent.futures import ThreadPoolExecutor
import requests

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

groups = ["InstantPotCommunity", "figmadesigners", "canvadesigncircle", "shopifyentrepreneurs"]
with ThreadPoolExecutor(max_workers=10) as pool:   # <= your plan's concurrency
    for slug, g in pool.map(one, groups):
        print(slug, g["name"] if g else "no public document")

1. Group: names, descriptions, member counts and privacy

A public group's about surface, by slug, numeric id, or full URL. 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, facebook.com/groups/<slug>/.
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 CDN 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.fhyw1-1.fna.fbcdn.net/v/t39.30808-6/571211886_1238145355004206_2944381633129661...",
  "thumbnail": "https://scontent.fhyw1-1.fna.fbcdn.net/v/t39.30808-6/571211886_1238145355004206_2944381633129661...",
  "source": "facebook",
  "data_source": "opengraph"
}

members_count is the field most people come for, and it is the one that needs the most care. name and description are the reliable pair: they came back on every call.

Field coverage across 28 calls to four public groups

Read the field behaviour, it is load-bearing:

  • members_count and privacy arrived together on 41 of 88 calls, and the rate itself moved during the day: 9 of 28 in an early run, 32 of 60 in a later one. Facebook serves a reduced document the rest of the time, in which both are null while name, description and cover_image still resolve. The reduced sample is committed next to the full one so you can diff them: same group, same day, same 11 keys, two values missing. get_group_with_counts() in group.py retries up to four times and tells you how many calls it took; budget 2 to 2.5 calls per observation depending on which way the rate has gone.
  • members_count is Facebook's own rounded display figure, not a headcount. It renders "3.2M members", so you get two or three significant figures. The same group returned 3100000 and 3200000 on the same afternoon; the run screenshot above caught 3100000 while the committed sample holds 3200000, which is the drift rather than an inconsistency. A group at 45,000 returned 45000 once and 45700 another time, because the rendered string changed from "45K" to "45.7K".
  • privacy is public or private, and private does not mean empty. Shopify Entrepreneurs is a private group and still returns its name, description, cover image and a member count of 104,000. What private governs is joining and reading posts, neither of which this endpoint touches.
  • description is capped at 154 characters with a trailing ... that Facebook adds itself. Three different groups all came back at exactly 154; a short one came through complete (Figma designers is 17 characters). The cap is the source's, not ours.
  • category was null on all 28 calls. Facebook does not render a group's category to a logged-out request, so we return null rather than guess one from the description.
  • url is Facebook's canonical form, not your input. Ask for figmadesigners and the response carries https://www.facebook.com/groups/FigmaDesigners/. Key your own storage on the id you sent, or on the canonical URL, but do not assume they match.
  • cover_image and thumbnail are the same URL. It carries Facebook's own oh signature and oe expiry parameters, so it stops working: fetch the bytes if you need to keep the image.

Runnable: facebook_group_scraper_api_codes/group.py


Track member counts across brand community groups

Watching your own community group against comparable ones is the main reason people scrape Facebook groups, so that use case is in the repo end to end rather than as a snippet. group_monitor.py polls any number of public groups, stores every observation as a local dataset in SQLite, and prints what moved since the last run:

python facebook_group_scraper_api_codes/group_monitor.py \
    InstantPotCommunity figmadesigners canvadesigncircle

Facebook group member count monitoring

That is a real first run: the first pass has nothing to compare against, so it seeds the database and says so. Export it with one command:

sqlite3 -header -csv facebook_groups.db "select * from observations" > groups.csv

The interesting part of that script is not the polling, it is the rounding floor, and it is why its numbers are worth trusting. Because members_count is a rendered display figure, a raw delta is meaningless: the largest jump we measured on a stable group was 3,100,000 to 3,200,000, a 3.2% move in which nobody joined. The script only reports a change that clears a relative floor of 5%, comfortably above that step, and it prints the measured move when a change does not clear it so you can see the noise for yourself.

That floor is a real constraint on what this data can tell you. A 3.2M-member group has to gain roughly 160,000 members before the number is allowed to move, so this is a monthly-trend instrument for large groups and a much sharper one for small ones, where the rendered figure carries more significant digits. Anyone selling you day-over-day Facebook group "growth" off two calls is selling you rounding.

Budget for the retries when you size a run. At the worse of the two measured arrival rates, get_group_with_counts() averages about 2.5 calls per observation, so your one-time 1,000 free requests covers roughly a month of daily checks on a dozen groups.


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/group 2.0s 1.4 to 2.2s 8/8 8

The range is tight here, which is worth noting because it is the same endpoint absorbing the document variance described above: the retry that turns an empty variant into a parsed group happens upstream, inside a call that still returns in about two seconds.

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.