Facebook Group Posts Scraper — Posts & Member Contacts avatar

Facebook Group Posts Scraper — Posts & Member Contacts

Pricing

from $2.50 / 1,000 per post returneds

Go to Apify Store
Facebook Group Posts Scraper — Posts & Member Contacts

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

Scrapers Delight

Maintained by Community

Actor stats

0

Bookmarked

1

Total users

0

Monthly active users

8 days ago

Last modified

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[] 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.

GroupFieldsWhat it holds
IdentitypostId, storyId, postUrlFacebook's numeric post id, the internal story id, and the permalink to the post
GroupgroupSlug, groupId, groupName, groupUrl, isPublicGroupWhich 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
Contenttext, createdAt, createdTimestampThe full post body as written, and when it was posted
AuthorauthorName, authorId, authorType, authorUrl, authorProfilePicUrlWho posted. authorType is Facebook's own User / Page typename — the tell for "a person asking" vs "a business posting"
EngagementreactionCount, commentCount, shareCount, topReactionsThe 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)
MediaattachmentCount, photoCount, attachmentsattachments[] is flattened to { type, url, image, title, source, description } per attachment
Outbound linklink, linkDomain, linkTitleThe 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 contextisShare, sharedPostWhen the post reshares another story, sharedPost is { authorName, authorUrl, text, url } — you get the original text too, and it is scanned for contacts
Contactsemails, phones, contactCountEvery email address and phone-shaped string found in the post text (and the reshared text)
ProvenanceinputUrl, scrapedAtThe 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)

{
"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.

{
"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:

  • 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

FieldTypeDefaultWhat it does
🎯 Which groups
groupUrlsstring listprefilled with one public groupOne per line. Group URL, numeric-id group URL, or a bare slug / id. Empty = the documented sample run.
startUrlsrequest list sourcesBulk 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
sortingselectCHRONOLOGICALCHRONOLOGICAL (new posts first) · RECENT_ACTIVITY (newest comments) · TOP_POSTS (Facebook's "most relevant").
maxPostsinteger50 (prefilled 25)Stop after this many posts per group. Minimum 1. Facebook returns ~30 posts per feed batch.
postedAfterstringYYYY-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.
onlyWithContactbooleanfalseKeep only posts whose text carries at least one phone or email. Removed posts are never pushed and never billed.
🌐 Proxy
proxyConfigurationproxyApify Proxy, RESIDENTIALRequired, and it must be residential — see the transport table below. The default is already correct; leave it alone.
⚙️ Advanced
requestDelayMsinteger700Pause 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.

RunPostsPer-post costIf N of them carry a contact
The shipped default25$0.0625+$0.008 each
One group, 100 posts100$0.2520 with a contact → $0.41 total
One group, 500 posts500$1.25100 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:

RungLogged-out group pageFirst-batch query + cursor pagination
Apify DATACENTER0/9 — every request 302s to /loginnot reachable (no page context to lift tokens from)
Apify RESIDENTIAL9/9 served the server-rendered feed pageanswered 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

ActorWhat it isWhy you would use it instead
Facebook Page ScraperPublic business Pages in bulk: name, category, phone, e-mail, website, address, likes, talking-about count, bioWhen you want the business record rather than what members are posting in a group
Facebook Reels ScraperEvery Reel a public Page posts: permalink, MP4 URL, thumbnail, caption, views, reactions, comments, shares, durationWhen 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.


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.