Facebook Group Posts Scraper — Posts & Member Contacts
Pricing
from $2.50 / 1,000 per post returneds
Facebook Group Posts Scraper — Posts & Member Contacts
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.
Pricing
from $2.50 / 1,000 per post returneds
Rating
0.0
(0)
Developer
Scrapers Delight
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
8 days ago
Last modified
Categories
Share
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.
{"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[]andphones[], withcontactCountas the count. Thecontact-foundevent is charged once per delivered post that carried at least one — a post with no contact costs the per-post rate and nothing more. FliponlyWithContacton 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,shareCountand the per-emotion breakdown intopReactions[]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 itscomet_sectionstree cannot silently turn your engagement column into zeros. Where Facebook does not render a counter to a logged-out reader the field isnull, never a fake0. - 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 withGroupsCometFeedRegularStoriesPaginationQueryusing 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-foundis only ever charged for posts that actually shipped.
Read this before you buy rows
- 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 orlogin-walled (private, non-existent, or restricted group)
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. - 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.
- A logged-out feed is shallower than the one a member sees. Many groups surface fewer posts to
a logged-out reader than your
maxPostscap 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. 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 inphones[]. 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.- The date filter runs after Facebook answers.
postedAfteris applied to each post'screatedTimestampbefore billing — you are never charged for a post it removed — but Facebook still had to serve the batch. OnCHRONOLOGICAL(andRECENT_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. OnTOP_POSTSthere is no early stop, because "top" order says nothing about time. OnRECENT_ACTIVITYbe aware the ordering is by newest comment, not by creation time, so an old-but-active thread can end the walk early. - 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)). RaiserequestDelayMsif you scrape very large groups and see runs cut short. maxPostscounts posts Facebook returned for that group, not rows delivered. WithpostedAfteroronlyWithContacton, 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 arraystopReactions,attachments,emails,phoneswith their countersattachmentCount,photoCount,contactCount— empty arrays and0where there is nothing, never missing keys.isShareis always a boolean.groupSlugandinputUrlare always the group you asked for. A post with nopostIdis 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 —
nullwhen 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(nullunless the post is a share), andlink/linkDomain/linkTitle(nullunless 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 againstrow.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)
{"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
{"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
{"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
{"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:
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.
{"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:
contactCountis emails + phones, not "people". One post carrying an email and a phone hascontactCount: 2— and is billed onecontact-foundevent, because the event is per post, not per contact.linkis deliberately not "the first URL in the post". Facebook's own photo permalinks andfbcdn.netasset URLs are filtered out, solink/linkDomainmean "an outside site this member pointed at". A post that only has photos haslink: nullandphotoCount > 0.linkDomainprefers 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.storyIdvspostId.postIdis the numeric id in the permalink and the key everything is de-duplicated on.storyIdis Facebook's internal, opaque story identifier — useful for support tickets, not for joining.isPublicGroupisnull, notfalse, when Facebook did not print a privacy label on that story. It is derived from the story's own privacy description, sonullmeans "unknown", not "private" — and a private group would not have produced a row at all.authorTypeis Facebook's typename:Userfor a person,Pagefor 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
postIdbefore billing, so the same post never bills twice across pagination batches. Posts removed bypostedAfteroronlyWithContactare 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-foundis 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 emptyemails[]andphones[]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 () and the status message tells you to raise the cap to go further.N of M post(s) in this batch were billed and shipped; the rest were neither billed nor delivered - 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.
commentCountis 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[]andphones[]are extracted fromtext(plussharedPost.textwhen 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
nulllogged out.commentCount,shareCountandreactionCountare read from Facebook's rendered engagement blocks; where a block is not served to a logged-out reader the field staysnullrather than being filled with a fabricated0.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
requestDelayMsor 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 onpostId) — the Actor does not remember what a previous run shipped. - Facebook's
robots.txtcarries a blanketDisallow: /forUser-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
unhandledRejectionanduncaughtExceptionexit 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: trueturns a group into a queue of those, newest first. - Recruiters and staffing — hiring posts and "available for work" posts in professional groups,
with
authorName,authorUrland 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 | 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 | 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.