# Facebook Group Posts Scraper — Posts & Member Contacts (`scrapersdelight/facebook-group-posts-scraper`) Actor

Scrape posts from any public Facebook Group without login: post text, author, reactions, comment and share counts, attachments, outbound links, and any phone or email a member left in the post. GraphQL feed, no browser, cursor pagination. Public groups only.

- **URL**: https://apify.com/scrapersdelight/facebook-group-posts-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Social media, Lead generation, Marketing
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.50 / 1,000 per post returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Facebook Group Posts Scraper — Posts & Member Contacts

Read the **public feed of any Facebook Group** — logged out, no account, no cookies of yours, no
browser — and get one clean row per post: **postId, postUrl, text, createdAt, authorName, authorId,
authorType, authorUrl, reactionCount, commentCount, shareCount, topReactions, attachments,
photoCount, link, linkDomain, linkTitle, isShare, sharedPost**, plus the group it came from
(**groupSlug, groupId, groupName, groupUrl, isPublicGroup**) — and, the reason most buyers are here,
**every phone number and email address a member typed into the post body**, in `phones[]`,
`emails[]` and `contactCount`.

Groups are where people *ask to be sold to*. "Looking for a wedding photographer in Austin — DM me
or call 512-…", "Need a contractor, here's my email", "Hiring a VA, apply to hello@…". Those posts
carry a name, an intent, a timestamp and a contact — a whole lead, in the post text, published
publicly by someone who wants a reply. This Actor pulls them out at **$0.0025 per post**, and only
charges the **$0.008 contact bonus on posts that actually carried a phone or an email**.

**Scope, stated up front: this reads the PUBLIC feed of groups anyone can view logged out.** Private,
closed and members-only groups serve a login stub instead of a feed — they are logged, skipped, and
**never billed**. There is no "search all of Facebook's groups by keyword" mode here, because
Facebook puts group search behind a login. You bring group URLs; this Actor turns them into rows.

```json
{
  "groupUrls": ["https://www.facebook.com/groups/selftaughtprogrammers"],
  "sorting": "CHRONOLOGICAL",
  "maxPosts": 25,
  "proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
```

Click **Try for free** and press **Start** — that block is literally the input the Actor ships
prefilled with. Up to 25 posts from one real public group, **$0.0625** on the per-post event (plus
$0.008 for each of those posts that carried a contact), so a first click costs a few cents and shows
you the exact row shape before you spend anything. Run it with a completely empty input (`{}`) and
you get the same documented sample rather than an error.

***

### The wedge: contacts in the post body, billed only when they exist

- **Phone/email extraction on every post, as a success-billed event.** Each post's text (and the
  text of the post it reshares, when it is a share) is scanned for email addresses and phone-shaped
  numbers. They land in `emails[]` and `phones[]`, with `contactCount` as the count. The
  **`contact-found`** event is charged **once per delivered post that carried at least one** — a
  post with no contact costs the per-post rate and nothing more. Flip `onlyWithContact` on and the
  dataset becomes a pure lead list: posts with no phone and no email are **never pushed and never
  billed**.
- **Real engagement numbers, read structurally.** `reactionCount`, `commentCount`, `shareCount` and
  the per-emotion breakdown in `topReactions[]` come out of Facebook's own feed payload. They are
  located by a bounded structural search for the leaf key rather than a hard-coded path, so a
  Facebook refactor of its `comet_sections` tree cannot silently turn your engagement column into
  zeros. Where Facebook does not render a counter to a logged-out reader the field is **`null`,
  never a fake `0`**.
- **No browser, no login, no cookies.** The logged-out group page prints, into its own HTML, the
  tokens *and* the fully-populated variables of the query the browser itself fires for the first
  batch (`CometGroupDiscussionRootSuccessQuery`). The Actor re-issues that query with a raised story
  count, then pages with `GroupsCometFeedRegularStoriesPaginationQuery` using the feed cursor. That
  is plain HTTP — which is why this Actor declares **512 MB** of memory instead of the multi-gigabyte
  allocation a Chromium-based scraper needs to run a browser.
- **Nothing you did not receive is charged.** Posts are de-duplicated on Facebook's numeric post id
  **before** billing, so the same post never bills twice across pagination batches. The date filter
  and the contacts-only filter run **before** the push. The push itself is charge-gated: at a budget
  cap you get whole rows and a stop, never a half-billed dataset, and `contact-found` is only ever
  charged for posts that actually shipped.

***

### Read this before you buy rows

1. **Public groups only — and that is Facebook's line, not ours.** A private, closed, members-only,
   deleted or restricted group answers the logged-out request with a login stub. The Actor detects
   that and skips the group, billing nothing (it logs `login-walled (private, non-existent, or
   restricted group)` or `no public feed on the group page (private or restricted)`). Note the
   detection is partly a heuristic: a group page that comes back without the feed-query context
   **and** is smaller than 200 KB is treated as private/restricted, so an unusually thin public
   group page can be reported as private. If a group you know is public is skipped this way, re-run
   it — a fresh session usually returns the full page. If your whole input list is private groups,
   the run ends **SUCCEEDED with 0 rows** and a status message saying exactly that.
2. **There is no keyword search across groups.** Facebook gates group *search* behind a login, so
   this Actor has no "find me every group post mentioning X" input. You supply the group URLs
   (typed, pasted, uploaded as a .txt/.csv, or linked from a Google Sheet) and it reads their feeds.
3. **A logged-out feed is shallower than the one a member sees.** Many groups surface fewer posts to
   a logged-out reader than your `maxPosts` cap asks for. When Facebook stops serving pages, the
   group ends there — you are only ever billed for posts actually returned, so a cap of 500 on a
   quiet group does not cost you 500 posts' worth of anything.
4. **`phones[]` is regex-detected text, not a validated phone book.** Any run of 8–15 digits with
   plausible separators is treated as a phone number, so an order number, a long price, a date range
   or a licence number in a post body can land in `phones[]`. The values are kept **as the member
   typed them** — not normalised to E.164, not country-inferred, not carrier-checked. `emails[]` is
   much cleaner (an address is an address) and is lower-cased and de-duplicated. **Eyeball or
   validate before you dial.**
5. **The date filter runs after Facebook answers.** `postedAfter` is applied to each post's
   `createdTimestamp` before billing — you are never charged for a post it removed — but Facebook
   still had to serve the batch. On `CHRONOLOGICAL` (and `RECENT_ACTIVITY`) the Actor also **stops
   paginating** once a whole batch is older than your cutoff, so a monitoring run ends early instead
   of paying its way through history. On `TOP_POSTS` there is no early stop, because "top" order
   says nothing about time. On `RECENT_ACTIVITY` be aware the ordering is by *newest comment*, not
   by creation time, so an old-but-active thread can end the walk early.
6. **Facebook soft-rate-limits a residential session after a burst.** A rate-limited response is
   never parsed as data and never billed: on the first batch the group is restarted once on a
   completely fresh session with fresh page tokens; mid-pagination it gets one cooldown-and-retry on
   a fresh session; if Facebook is still rate-limiting, that group stops at the posts it already has
   and the log says so (`still rate-limited — stopping this group at N post(s)`). Raise
   `requestDelayMs` if you scrape very large groups and see runs cut short.
7. **`maxPosts` counts posts Facebook returned for that group, not rows delivered.** With
   `postedAfter` or `onlyWithContact` on, a group can hit its cap having delivered far fewer rows —
   the filtered posts still came down the wire. That is the honest reading of the cap; your **cost**
   still tracks rows delivered only.

***

### What you get

One row per unique post, de-duplicated run-wide on Facebook's numeric `postId`. Timestamps are UTC:
`createdTimestamp` is Facebook's own epoch-seconds value, `createdAt` is the same instant as
ISO-8601.

| Group | Fields | What it holds |
|---|---|---|
| **Identity** | `postId`, `storyId`, `postUrl` | Facebook's numeric post id, the internal story id, and the permalink to the post |
| **Group** | `groupSlug`, `groupId`, `groupName`, `groupUrl`, `isPublicGroup` | Which group the post lives in. `groupName` and `isPublicGroup` come from the payload; `groupId` and `groupUrl` come from the payload with a fallback built from your input; `groupSlug` is the group you asked for |
| **Content** | `text`, `createdAt`, `createdTimestamp` | The full post body as written, and when it was posted |
| **Author** | `authorName`, `authorId`, `authorType`, `authorUrl`, `authorProfilePicUrl` | Who posted. `authorType` is Facebook's own `User` / `Page` typename — the tell for "a person asking" vs "a business posting" |
| **Engagement** | `reactionCount`, `commentCount`, `shareCount`, `topReactions` | The three counters plus a per-emotion breakdown: `topReactions[]` is `{ name, count }`; `name` is Facebook's localized reaction label, and every request is made with `Accept-Language: en-US`, so it comes back in English (`Like`, `Love`, `Haha`) |
| **Media** | `attachmentCount`, `photoCount`, `attachments` | `attachments[]` is flattened to `{ type, url, image, title, source, description }` per attachment |
| **Outbound link** | `link`, `linkDomain`, `linkTitle` | The first genuinely **external** URL a member shared — Facebook's own `/photo/` and CDN permalinks are excluded on purpose, so `linkDomain` is a real domain you can segment on |
| **Share context** | `isShare`, `sharedPost` | When the post reshares another story, `sharedPost` is `{ authorName, authorUrl, text, url }` — you get the original text too, and it is scanned for contacts |
| **Contacts** | `emails`, `phones`, `contactCount` | Every email address and phone-shaped string found in the post text (and the reshared text) |
| **Provenance** | `inputUrl`, `scrapedAt` | The group URL this row was collected under, and when |

The dataset ships with a saved **table view** — Group, Posted, Author, Post text, Reactions,
Comments, Shares, Link, Phones, Emails, Post — so the Console preview and a CSV export read like a
lead list without you configuring anything.

#### Which fields are always there, and which are best-effort

No fill percentages are published here because none have been measured on this Actor. What the
parser guarantees, and what it cannot, is exact:

- **Always present on every row** (they are how a row is built): `postId`, `scrapedAt`, and the
  arrays `topReactions`, `attachments`, `emails`, `phones` with their counters `attachmentCount`,
  `photoCount`, `contactCount` — empty arrays and `0` where there is nothing, never missing keys.
  `isShare` is always a boolean. `groupSlug` and `inputUrl` are always the group you asked for.
  A post with no `postId` is discarded rather than shipped as a partial row.
- **Derived with a fallback, so practically always present**: `postUrl` (Facebook's own permalink
  when the payload carries one, otherwise built from the group slug + post id), `groupUrl` (same
  idea), `groupId` (from the payload, else from the page's own group id).
- **Best-effort — `null` when Facebook does not render it to a logged-out reader**: `text` (image-
  only posts genuinely have none), `createdAt` / `createdTimestamp`, `commentCount`, `shareCount`,
  `reactionCount`, `groupName`, `isPublicGroup`, `sharedPost` (`null` unless the post is a share),
  and `link` / `linkDomain` / `linkTitle` (`null` unless the post carries an external link).
- **Absent, not null, in one edge case**: the five `author*` fields are filled from the story's
  actor block. On the rare story Facebook ships with no actor at all, those keys are simply not
  written to the row. Code against `row.authorName ?? null`.

**Every run prints its own counts in the log** — posts pushed, posts carrying a contact, duplicates
dropped before billing, rows removed by filters, private/restricted groups skipped, and groups that
would not serve a feed. The run's status message carries the headline numbers (posts pushed and how
many had a contact), plus the private/no-feed/filtered breakdown whenever a run ends with zero rows.

***

### How to run it

#### 1. One group, newest first (the usual choice)

```json
{
  "groupUrls": ["https://www.facebook.com/groups/selftaughtprogrammers"],
  "sorting": "CHRONOLOGICAL",
  "maxPosts": 100
}
```

100 posts = **$0.25** on the per-post event. `CHRONOLOGICAL` is the right sort for anything you plan
to re-run: together with `RECENT_ACTIVITY` it lets the date filter stop the walk early (only
`TOP_POSTS` has no early stop), and it is the only ordering where "older batch" reliably means
"older post".

A group URL can be given in any of the shapes people actually have: the vanity URL
(`https://www.facebook.com/groups/selftaughtprogrammers`), the numeric-id URL
(`https://www.facebook.com/groups/123456789`), or the bare slug / id (`selftaughtprogrammers`).
Duplicated inputs are dropped before any group page is fetched, and the log tells you how many.

#### 2. Many groups at once — paste, upload, or link a sheet

```json
{
  "startUrls": [{ "requestsFromUrl": "https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit" }],
  "sorting": "CHRONOLOGICAL",
  "maxPosts": 50
}
```

`startUrls` is the bulk lane: paste a block of URLs, upload a `.txt`/`.csv`, or point at a Google
Sheet (the Actor converts a Sheet link into its CSV export automatically). Every
`facebook.com/groups/…` URL found anywhere in that file is added to the queue, then merged with
`groupUrls` and de-duplicated — so no group page is ever fetched twice. (The sheet or file itself is
fetched first, in order to read it.) **Note the one rule:** in `startUrls` only tokens containing
`facebook.com/groups/` are picked up — a sheet column of bare slugs will read as empty, so put bare
slugs in `groupUrls` instead. If the sheet or file cannot be fetched, the log says so and the run
continues with whatever else you supplied.

#### 3. Lead sweep — only the posts with a phone or an email

```json
{
  "groupUrls": [
    "https://www.facebook.com/groups/austinweddingvendors",
    "https://www.facebook.com/groups/austinsmallbusiness"
  ],
  "sorting": "CHRONOLOGICAL",
  "onlyWithContact": true,
  "maxPosts": 300
}
```

Every post whose text carries no phone and no email is dropped **before** the push, so it is never
delivered and never billed. What lands in the dataset is the subset you would have manually filtered
for anyway — with `authorName`, `authorUrl`, `text` and `postUrl` attached so you can see the ask
and reply in context. Because each delivered row here carries a contact by definition, expect the
`contact-found` bonus on essentially all of them: **$0.0025 + $0.008 = $0.0105 per lead row.**

#### 4. A monitor — new posts since a date, scheduled

```json
{
  "groupUrls": ["https://www.facebook.com/groups/selftaughtprogrammers"],
  "sorting": "CHRONOLOGICAL",
  "postedAfter": "2026-08-01",
  "maxPosts": 500
}
```

With `CHRONOLOGICAL` sorting the Actor stops paginating as soon as a whole batch predates your
cutoff, so a weekly monitor pays for the week, not for the group's history. Save it as a **Task**
(Console → the Actor → *Save as Task*), attach an Apify **Schedule**, and move `postedAfter` forward
each run. Posts the filter removed are never charged.

#### Scheduling, API and integrations

The dataset is available over the REST API and through the standard Apify integrations (Zapier,
Make, n8n, webhooks, MCP) like any other Actor. From the API:

```bash
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~facebook-group-posts-scraper/runs?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"groupUrls":["https://www.facebook.com/groups/selftaughtprogrammers"],"sorting":"CHRONOLOGICAL","maxPosts":100}'
```

Default run options are **3,600 s timeout, 512 MB memory**. You can lower the timeout to box a run;
the Actor derives its own internal deadline from it and stops with partial, already-pushed data
rather than being killed TIMED-OUT.

***

### Sample row

The field set below is exactly what the parser emits, key for key, in this order. **The values are
illustrative** — no field-fill or per-run measurement is published for this Actor, so this is the
shape to code against, not a captured record.

```jsonc
{
  "postId": "1043872215566108",
  "storyId": "UzpfSTEwMDA2NDMzMjA3NzY5NDoxMDQzODcyMjE1NTY2MTA4",
  "groupSlug": "selftaughtprogrammers",
  "groupId": "1508115756110573",
  "groupName": "Self Taught Programmers",
  "groupUrl": "https://www.facebook.com/groups/selftaughtprogrammers/",
  "postUrl": "https://www.facebook.com/groups/selftaughtprogrammers/posts/1043872215566108/",
  "text": "Finally shipped my first client project after 8 months of self-study. If anyone needs a junior dev for small React work, email me at sam.rivera.dev@gmail.com or call 512-555-0148 — happy to do a free first task to prove I can.",
  "createdAt": "2026-08-21T14:07:52.000Z",
  "createdTimestamp": 1787321272,
  "authorName": "Sam Rivera",
  "authorId": "100064332077694",
  "authorType": "User",
  "authorUrl": "https://www.facebook.com/100064332077694",
  "authorProfilePicUrl": "https://scontent.xx.fbcdn.net/v/t39.30808-1/…_n.jpg",
  "reactionCount": 214,
  "commentCount": 37,
  "shareCount": 4,
  "topReactions": [
    { "name": "Like", "count": 168 },
    { "name": "Love", "count": 39 },
    { "name": "Care", "count": 7 }
  ],
  "attachmentCount": 1,
  "photoCount": 0,
  "attachments": [
    {
      "type": "ShareLink",
      "url": "https://samrivera.dev/work/first-client",
      "image": "https://external.xx.fbcdn.net/emg1/v/t13/…jpg",
      "title": "My first client build — React + Supabase",
      "source": "samrivera.dev",
      "description": "A walkthrough of the booking app I shipped last week."
    }
  ],
  "link": "https://samrivera.dev/work/first-client",
  "linkDomain": "samrivera.dev",
  "linkTitle": "My first client build — React + Supabase",
  "isShare": false,
  "sharedPost": null,
  "isPublicGroup": true,
  "emails": ["sam.rivera.dev@gmail.com"],
  "phones": ["512-555-0148"],
  "contactCount": 2,
  "inputUrl": "https://www.facebook.com/groups/selftaughtprogrammers",
  "scrapedAt": "2026-08-26T09:12:41.884Z"
}
```

Fields people misread:

- **`contactCount` is emails + phones, not "people".** One post carrying an email *and* a phone has
  `contactCount: 2` — and is billed **one** `contact-found` event, because the event is per post,
  not per contact.
- **`link` is deliberately not "the first URL in the post".** Facebook's own photo permalinks and
  `fbcdn.net` asset URLs are filtered out, so `link` / `linkDomain` mean "an outside site this
  member pointed at". A post that only has photos has `link: null` and `photoCount > 0`.
- **`linkDomain` prefers Facebook's own rendered source label** over the parsed hostname when the
  attachment carries one, which is what a human sees under the link card.
- **`storyId` vs `postId`.** `postId` is the numeric id in the permalink and the key everything is
  de-duplicated on. `storyId` is Facebook's internal, opaque story identifier — useful for support
  tickets, not for joining.
- **`isPublicGroup` is `null`, not `false`, when Facebook did not print a privacy label** on that
  story. It is derived from the story's own privacy description, so `null` means "unknown", not
  "private" — and a private group would not have produced a row at all.
- **`authorType`** is Facebook's typename: `User` for a person, `Page` for a business Page posting
  into the group. It is the cheapest B2C/B2B split in the row.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| **🎯 Which groups** | | | |
| `groupUrls` | string list | prefilled with one public group | One per line. Group URL, numeric-id group URL, or a bare slug / id. Empty = the documented sample run. |
| `startUrls` | request list sources | — | Bulk lane: paste, upload a .txt/.csv, or link a Google Sheet. Every `facebook.com/groups/…` URL found is added. Duplicates across both inputs are dropped, so no group page is ever fetched twice. |
| **📊 How many, and how sorted** | | | |
| `sorting` | select | `CHRONOLOGICAL` | `CHRONOLOGICAL` (new posts first) · `RECENT_ACTIVITY` (newest comments) · `TOP_POSTS` (Facebook's "most relevant"). |
| `maxPosts` | integer | `50` (prefilled `25`) | Stop after this many posts **per group**. Minimum 1. Facebook returns ~30 posts per feed batch. |
| `postedAfter` | string | — | `YYYY-MM-DD`. Keeps posts on or after that date; also ends pagination early on `CHRONOLOGICAL` / `RECENT_ACTIVITY`. An unparseable value is ignored with a warning, not an error. |
| `onlyWithContact` | boolean | `false` | Keep only posts whose text carries at least one phone or email. Removed posts are never pushed and never billed. |
| **🌐 Proxy** | | | |
| `proxyConfiguration` | proxy | Apify Proxy, **RESIDENTIAL** | Required, and it must be residential — see the transport table below. The default is already correct; leave it alone. |
| **⚙️ Advanced** | | | |
| `requestDelayMs` | integer | `700` | Pause between Facebook requests, 0–15,000. Raise it on very large groups if runs get cut short by rate limiting. |

`startUrls` and `postedAfter` have no schema default; when omitted the Actor behaves as if they were
`[]` and `""`.

That is the whole input surface. There is no keyword field, no comment-depth field and no member-list
toggle, because none of those are things this Actor does — see **Honest limits**.

***

### Pricing

**$0.0025 per post returned — $2.50 per 1,000 — charged on the `post-scraped` event.**
**$0.008 per delivered post that carried a phone or an email**, charged on the `contact-found`
event. No monthly fee from this Actor.

| Run | Posts | Per-post cost | If N of them carry a contact |
|---|---|---|---|
| The shipped default | 25 | **$0.0625** | +$0.008 each |
| One group, 100 posts | 100 | **$0.25** | 20 with a contact → **$0.41** total |
| One group, 500 posts | 500 | **$1.25** | 100 with a contact → **$2.05** total |
| A pure lead sweep (`onlyWithContact: true`) | every row is a lead | $0.0025/row | **$0.0105 per lead row** |

*(The contact columns are arithmetic on a hypothetical rate, not a measured one — how many posts in
your groups carry a contact depends entirely on the group.)*

- **You are charged per delivered row.** Posts are de-duplicated on the numeric `postId` **before**
  billing, so the same post never bills twice across pagination batches. Posts removed by
  `postedAfter` or `onlyWithContact` are never pushed and never charged. A private or restricted
  group that serves no public feed costs nothing at all — not a partial charge, nothing.
- **The contact bonus only bills on success.** `contact-found` is charged **once per post**, only
  for posts that **actually shipped** and **actually carried** at least one phone or email in their
  text. A post with an empty `emails[]` and `phones[]` is billed at the per-post rate alone. If the
  charge event cannot be recorded for any reason, the run logs it and moves on rather than
  double-charging you.
- **Charge-gated delivery.** Each batch is trimmed to what your run's `maxTotalChargeUsd` (or free-
  tier balance) can actually pay for, so you get whole rows and a clean stop — never rows delivered
  unbilled, never rows billed and not delivered. When the cap bites, the log names the exact split
  (`N of M post(s) in this batch were billed and shipped; the rest were neither billed nor
  delivered`) and the status message tells you to raise the cap to go further.
- **Rate-limited and failed requests are not data and are never billed.** A retry on a fresh session
  costs you nothing.

Why $0.0025 and not the floor: this is a **residential-only lane**. Facebook's answer to a datacenter
IP is a redirect to the login page, so every byte of every group feed rides a residential exit. A
group feed batch is a single compressed GraphQL response covering ~30 posts, so residential transfer
cost stays a small fraction of the row price — but it is not a free lane, and pricing it like one
would mean pricing a scraper that does not work.

***

### Honest limits

- **Public groups only.** Private, closed, members-only, restricted and deleted groups return no
  feed. This Actor does not log in, does not accept a session cookie, and has no mode that would
  reach inside a group you would have to join. If that is what you need, this is not the tool.
  Detection is partly a heuristic: a group page returned **without the feed-query context** *and*
  smaller than 200 KB is treated as private/restricted and skipped, so an unusually thin public group
  page can be reported as private. Re-run such a group — a fresh session usually returns the full page.
- **No keyword search across groups.** Facebook puts group search behind a login. You supply the
  group URLs. There is no input that changes this.
- **Comments are counted, not collected.** `commentCount` is a number; the comment *text* is a
  separate record type and belongs in a separate Actor. Likewise **group member lists** and the
  **group's own profile fields** are not here — one site plus one function per Actor is deliberate,
  so your dataset stays a single record type and your CSV never mixes rows of different shapes.
- **Contacts come from the post body only.** `emails[]` and `phones[]` are extracted from `text`
  (plus `sharedPost.text` when the post is a share). Nothing is read from the comments, from the
  author's profile, or from any external source — there is no email-guessing or enrichment step
  hiding behind the contact event.
- **Phone detection is a text pattern, and it over-matches.** Any 8–15 digit run with plausible
  separators qualifies, so licence numbers, order numbers, long prices and date ranges can appear in
  `phones[]`. Values are kept verbatim, not normalised to E.164 and not country-resolved.
- **Some counters are `null` logged out.** `commentCount`, `shareCount` and `reactionCount` are read
  from Facebook's rendered engagement blocks; where a block is not served to a logged-out reader the
  field stays `null` rather than being filled with a fabricated `0`. `topReactions[]` is likewise
  empty when Facebook does not print the breakdown.
- **Facebook soft-rate-limits residential sessions after a burst.** The Actor restarts the group
  once on a fresh session for a first-batch rate-limit, retries once mid-pagination after a
  cooldown, and then stops that group with what it has. On very large groups, expect to raise
  `requestDelayMs` or split the work across scheduled runs.
- **Pagination depends on a persisted query id that Facebook rotates.** The id is discovered at run
  time by scanning up to 30 of the page's own JS bundles, with a pinned fallback
  (`28124359740524712`, observed live answering and paginating through residential proxies on
  2026-08-22). The *first* batch never depends on this — its query id is lifted straight out of the
  group page — so in the worst case (bundle scan fails **and** the pinned id has been rotated) you
  still get the first batch and a warning in the log, not a failed run.
- **Residential proxy is mandatory.** Measured on 2026-08-22: Apify's shared **DATACENTER** pool is
  **0/9** on this lane — every request 302s to `/login`. **RESIDENTIAL** served the logged-out group
  page **9/9**. The default is residential for that reason; overriding it to datacenter will produce
  zero rows.
- **De-duplication is per run.** Within one run a post can never be delivered or billed twice.
  Across runs, use `postedAfter` (or your own downstream key on `postId`) — the Actor does not
  remember what a previous run shipped.
- **Facebook's `robots.txt`** carries a blanket `Disallow: /` for `User-agent: *` — quoted verbatim
  in **Legal & fair use** below, along with what Facebook simultaneously publishes about public
  group posts. Read that section before you build a business on this.

***

### How it works, and what it cost to make reliable

The logged-out group page at `https://www.facebook.com/groups/<slug>` is server-rendered, but it
ships only a header and a handful of posts — nowhere near a usable feed. What it *also* ships, inside
its own HTML, is everything the browser needs to ask for the rest: the LSD token, the haste /
revision / spin parameters, the group id, and the **fully-populated variables and persisted-query id
of `CometGroupDiscussionRootSuccessQuery`** — the exact query Facebook's own front end fires for the
first batch, including its 27 Relay feature-gate "provided variables".

So the Actor lifts those verbatim, raises the story count, and re-issues the query. That returns the
first batch of posts plus the feed cursor. Every later batch uses
`GroupsCometFeedRegularStoriesPaginationQuery`, whose own persisted id is read at run time out of the
JS bundle that defines it (the module named `<QueryName>_facebookRelayOperation`), with a pinned
fallback so a Facebook rotation degrades to "first batch only" instead of "broken Actor". **No
Chromium is launched at any point** — which is why the Actor runs in 512 MB.

Parsing is where the real work is. A Facebook GraphQL response is a **multipart stream**: line 0 is
the base JSON, and each later line is a deferred chunk — a streamed feed edge, or the deferred
`page_info` carrying the cursor. Both the inline connection edges *and* the streamed-edge chunks have
to be collected, and the cursor can arrive in either place. Individual fields (reaction, comment and
share counts, the author, the group name) live at deep, churny paths inside each story's
`comet_sections` tree, so they are located by a **bounded structural search for the leaf key** rather
than a hard-coded path a Facebook refactor would silently break. A cursor of 20 characters or shorter
is treated as "no real next page" rather than paged into a loop.

**Transport ladder, measured 2026-08-22 through Apify:**

| Rung | Logged-out group page | First-batch query + cursor pagination |
|---|---|---|
| Apify **DATACENTER** | **0/9** — every request 302s to `/login` | not reachable (no page context to lift tokens from) |
| Apify **RESIDENTIAL** | **9/9** served the server-rendered feed page | answered and returned **unique posts on every settled call** |

That is the whole reason the proxy default is residential and the input description tells you to
leave it alone.

**Reliability decisions, all deliberate:**

- **An empty or placeholder input never throws.** `{}` runs the documented public-group sample with
  a small cap and a shortened internal deadline, so it finishes fast, SUCCEEDED, with real rows.
- **A run that finds nothing ends SUCCEEDED with a status message**, not a red FAILED. A scheduled
  monitor over a quiet group does not page you at 3 a.m.
- **A wall-clock budget derived from the run's own timeout gates every fetch, retry and backoff**,
  and clamps each request timeout to the time actually left. The run stops early and pushes what it
  has rather than being killed TIMED-OUT with an unflushed buffer.
- **Crash nets on `unhandledRejection` and `uncaughtException`** exit cleanly with whatever has
  already been pushed and a status message naming the failure, instead of losing the dataset.
- **Every group gets a fresh proxy session id**, and every retry a new one, so one poisoned exit does
  not sink the run.

***

### Duplicates and repeat runs

Facebook's feed pagination can re-serve a story that was already on an earlier page, and a post
matching two of your input rows (the same group entered as a slug and as a URL) would otherwise be
counted twice. The Actor keeps a **run-wide set keyed on the numeric `postId`** and de-duplicates
**before anything is charged or pushed**. (A story that carries no `postId` is discarded outright
rather than shipped, so every row in the dataset has a de-duplication key.) The count of duplicates
it dropped is printed in the run log and in the totals line, so you can see it rather than take it
on faith.

Duplicate input URLs are removed even earlier — before the first fetch — and the log reports how
many were dropped.

Across runs, de-duplication is your side of the line: the Actor has no memory of what a previous run
shipped. Use `postedAfter`, moved forward each run, or key on `postId` downstream.

***

### When a run fails

This Actor is built not to fail. Almost everything ends **SUCCEEDED with a status message** that
says exactly what happened:

- **0 rows** → the message names the cause and the counts: how many groups were private or
  restricted, how many served no public feed, how many posts your filters removed, plus the reminder
  that this is public groups only and that the proxy should be residential.
- **Charge cap reached** → *"Stopped at the run's charge cap after N post(s)… Every row shipped was
  billed; nothing was delivered unbilled. Raise 'Max total charge' to go further."*
- **Run time budget** → *"Stopped early to stay within the run time limit; pushed N post(s)… Raise
  the run timeout or lower 'Max posts per group' for a complete pass."*
- **No groups supplied** → the sample runs, and the message says so, with a nudge to set **Group
  URLs**.
- **A private group, a rate-limit, an unreadable sheet, an unparseable date** → all warnings in the
  log, all non-fatal, none billed.
- **An unexpected crash** → the safety net still exits SUCCEEDED with the rows already pushed and a
  status message naming the error, rather than throwing your dataset away.

***

### Who buys this

- **Local-service and B2C lead gen** — buy-sell-trade, neighbourhood, wedding-vendor, trades and
  "recommend me a…" groups are full of people posting a need *with their phone number in the post*.
  `onlyWithContact: true` turns a group into a queue of those, newest first.
- **Recruiters and staffing** — hiring posts and "available for work" posts in professional groups,
  with `authorName`, `authorUrl` and an application email in the body.
- **Community and brand managers** — monitor the groups your category lives in: what is being asked,
  which posts get real reaction and comment counts, and which outside sites (`linkDomain`) members
  are actually sharing.
- **Market and product researchers** — complaint threads, "what should I buy" threads and
  competitor mentions, with engagement numbers to rank them and full text to read.
- **Agencies reselling lead lists** — a per-row price, a hard per-run charge cap, a single record
  type and an ISO timestamp on every row makes the unit economics trivial to quote.
- **AI / RAG pipelines** — one flat record type, stable ids, ISO timestamps, arrays that are always
  arrays, and provenance (`inputUrl`, `scrapedAt`) on every row. No HTML to clean.

***

### Our other Facebook Actors

| Actor | What it is | Why you would use it instead |
|---|---|---|
| [Facebook Page Scraper](https://apify.com/scrapersdelight/facebook-page-scraper) | Public business Pages in bulk: name, category, phone, e-mail, website, address, likes, talking-about count, bio | When you want the **business** record rather than what members are posting in a group |
| [Facebook Reels Scraper](https://apify.com/scrapersdelight/facebook-reels-scraper) | Every Reel a public Page posts: permalink, MP4 URL, thumbnail, caption, views, reactions, comments, shares, duration | When you are after a Page's **video output and its performance**, not group discussion |

***

### FAQ

**Does this need a Facebook account, a login, or cookies?**
No. It reads the same public feed a logged-out visitor sees, over plain HTTP on a residential IP. No
account, no session cookie, no API key, and nothing of yours is ever sent to Facebook.

**Can it read a private / closed / members-only group?**
No — and it will not pretend to. Facebook serves a login stub instead of a feed; the group is logged,
skipped and never billed.

**Can I search all Facebook groups for a keyword?**
No. Facebook gates group search behind a login. Supply the group URLs (typed, pasted, uploaded, or
from a Google Sheet) and this Actor reads their public feeds.

**Where do the phone numbers and emails come from?**
Only from the post's own text (and the text of the post it reshares). Nothing is looked up about the
author, and there is no email-guessing step. If a member did not type a contact into the post, the
row has empty `emails[]` and `phones[]` — and no `contact-found` charge.

**Do the contacts cost extra?**
$0.008 per **delivered** post that actually carried at least one phone or email. A post with no
contact is billed at the $0.0025 per-post rate alone.

**How many posts can I get from one group?**
As many as Facebook serves a logged-out reader, up to your `maxPosts` cap; Facebook returns ~30 posts
per feed batch and the Actor pages with the feed cursor. Many groups surface fewer than the cap — you
are only ever billed for posts actually returned.

**Do I need a residential proxy?**
Yes, and it is already the default. Measured 2026-08-22, Apify's shared datacenter pool was 0/9 on
this lane (every request redirected to the login page) while residential served the feed 9/9.

**Will I get comments?**
The **count** (`commentCount`), yes. The comment text is a different record type and belongs in a
different Actor, so your dataset stays one clean shape.

**Two runs — will I get duplicates?**
Never within one run: de-duplication is on the numeric `postId`, before billing. Across runs, move
`postedAfter` forward or key on `postId` downstream.

**Will a run ever succeed with zero rows?**
Yes, on purpose — with a status message naming the cause (all groups private, no public feed served,
or your filters removed everything). A scheduled monitor over a quiet group is not flagged broken.

**What does it cost to try?**
Press Start on the shipped input: up to 25 posts from one public group, **$0.0625** on the per-post
event plus $0.008 for any of those that carried a contact.

***

### Legal & fair use

This Actor reads the public feed of public Facebook Groups as a logged-out visitor. It does not log
in, does not use any account, cookie or API key, does not join groups, and collects nothing behind
authentication.

Facebook's `robots.txt` (fetched 2026-08-26) opens with this notice, verbatim:

```
## Notice: Collection of data on Facebook through automated means is
## prohibited unless you have express written permission from Facebook
## and may only be conducted for the limited purpose contained in said
## permission.
```

and its wildcard block reads, verbatim:

```
User-agent: *
Disallow: /
```

The same file also publishes 180 `Sitemap:` lines, the great majority of which are sitemaps of
**public group posts** submitted to search engines (for example
`https://www.facebook.com/sitemap/public_active_groups_sitemap.xml.gz` and
`https://www.facebook.com/sitemap/groups_latest_posts_0.xml.gz`). Both facts are true at once, and
we state both rather than quoting whichever one flatters us: Meta asks automated collectors for
written permission, and Meta simultaneously ships public group posts to search-engine crawlers.
Meta's Terms of Service, its Automated Data Collection Terms and the applicable law where you
operate govern what you may do here — that judgement is yours, not this README's.

Group posts are user-generated content and carry names, profile links and, by design in this Actor's
case, phone numbers and email addresses. **That is personal data.** You are responsible for handling
it lawfully — GDPR/CCPA obligations, lawful basis, retention, deletion requests — and for the rules
on unsolicited contact (GDPR/ePrivacy, CAN-SPAM, TCPA and the equivalents where your recipients
live). A phone number posted publicly in a group is not consent to be cold-called.

Facebook® and Meta® are trademarks of their owner. This Actor is not affiliated with, endorsed by,
or connected to Meta Platforms, Inc.

***

### Feedback

Missing a field, or want a filter that is not here? Open an issue on the **Issues** tab — group
feeds change and specific requests get built. If the Actor earns it, a review on the **Reviews** tab
helps other buyers find it.

# Actor input Schema

## `groupUrls` (type: `array`):

One per line. A group URL (https://www.facebook.com/groups/selftaughtprogrammers), a numeric-id group URL (https://www.facebook.com/groups/123456789), or a bare group slug / id. Posts are read from that group's public feed.

## `startUrls` (type: `array`):

For bulk runs: paste many group URLs at once, upload a .txt/.csv of them, or link a Google Sheet. Every facebook.com/groups/… URL found is added to the list above. Duplicates across both inputs are dropped, so no group page is ever fetched twice.

## `sorting` (type: `string`):

How Facebook orders the feed. CHRONOLOGICAL = new posts first (best for monitoring and for the "posts after" date filter). RECENT\_ACTIVITY = posts with the newest comments. TOP\_POSTS = Facebook's "most relevant".

## `maxPosts` (type: `integer`):

Stop after this many posts for each group. Facebook returns ~30 posts per feed batch. Cost per group: 25 posts = $0.0625, 100 = $0.25, 500 = $1.25. Many groups surface fewer posts than the cap logged out — you are only ever billed for posts actually returned.

## `postedAfter` (type: `string`):

YYYY-MM-DD. Keeps only posts published on or after this date. On CHRONOLOGICAL sorting the Actor also stops paginating once it reaches older posts, so a monitoring run ends early instead of paying for history. You are never billed for a post the filter removed.

## `onlyWithContact` (type: `boolean`):

Keep only posts whose text carries at least one phone number or email address — the lead subset. Posts removed by this filter are never pushed and never billed. Off by default.

## `proxyConfiguration` (type: `object`):

Apify Proxy is required, and it must be RESIDENTIAL: Facebook 302-redirects datacenter IPs to a login page. The default (Apify Proxy, RESIDENTIAL) is what the numbers above were measured on.

## `requestDelayMs` (type: `integer`):

Pause between Facebook requests. 700 ms is the measured-good default; Facebook soft-rate-limits a residential session after a burst, so raise this if you scrape very large groups and see the run cut short.

## Actor input object example

```json
{
  "groupUrls": [
    "https://www.facebook.com/groups/selftaughtprogrammers"
  ],
  "sorting": "CHRONOLOGICAL",
  "maxPosts": 25,
  "postedAfter": "2026-01-01",
  "onlyWithContact": false,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "requestDelayMs": 700
}
```

# Actor output Schema

## `items` (type: `string`):

One row per post: text, author, reactions, comment and share counts, attachments, outbound link, shared-post context, and phone/email contacts found in the post.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "groupUrls": [
        "https://www.facebook.com/groups/selftaughtprogrammers"
    ],
    "maxPosts": 25,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/facebook-group-posts-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "groupUrls": ["https://www.facebook.com/groups/selftaughtprogrammers"],
    "maxPosts": 25,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/facebook-group-posts-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "groupUrls": [
    "https://www.facebook.com/groups/selftaughtprogrammers"
  ],
  "maxPosts": 25,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call scrapersdelight/facebook-group-posts-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/facebook-group-posts-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/t48XMS9inUJfAEWg5/builds/SwGkmgaqZ2P5v5xEx/openapi.json
