Reading a Facebook group's posts
log facebook read group reads the posts (and, if you ask, the comments) of a Facebook group an account has joined. It runs in the account's already-open browser, scrolls the group, and reads the posts from Facebook's own responses rather than from the screen. This page explains how a read works, step by step. For how reads fit into automatic group listening, see Listening to Facebook groups, which wins where the two differ. What it must do is in the spec, Facebook groups.
log already joins groups and answers their entry questions (src/facebook/group/join, src/facebook/group/answer) and keeps each group in Twenty as an agencyGroup. This adds reading the posts.
Where the approach comes from
An earlier Facebook client of ours built group listening first and got the approach right, so log keeps it:
| Earlier client | What it does | In log |
|---|---|---|
domain_extraction/response_capture.py | Reads the page's own responses while it scrolls; never calls Facebook's GraphQL itself. | Every step already records every response in its own HAR, so posts are read from that recording. There is no second listener. |
domain_extraction/post_extractor.py | Takes posts out of the page's embedded Relay JSON, keeping only those with a post_id, the expected group and a creation_time. | The same rules, in TypeScript, on the step's HAR and the first page's HTML, tested on a recorded run. |
domain_records/normalization.py | Rejects a post with no id or the wrong group; never fills in made-up values. | The same. |
domain_posts/listen.py, domain_cursors/* | One cursor per group, so each read yields only new posts; posts are saved before the cursor moves. | The same, with the cursor on the agencyGroup in Twenty. |
scripts/listen_group.py, listen_continuous.py | Scroll one group, poll several. | A flow from a recording, run as an XState machine in the account's open browser. |
What changes is how it runs: in the account's browser through the driver, in the browser kept open for the day, with every step recorded and read with rea, and every command available as a CLI command, an MCP tool and through the agent hooks.
What Facebook sends
One query is the whole feed: GroupsCometFeedRegularStoriesPaginationQuery.
- It fires on load and on every scroll, with
count: 3,CHRONOLOGICAL,DISCUSSION, the group'sidand acursor. - Its answer is streamed in 4-10 JSON chunks. For each post it holds the
post_id, the group's id,creation_time, the author (actors), the text (message.text), the permalink, thefeedbackid, and the nextend_cursor/has_next_page. - The first posts come embedded in the group page's HTML (about 2 MB), which the recording keeps whole (
MAX_DOCUMENT_BODY).
Reading posts from the recording
Code: src/facebook/group/read (postsFromFeedResponse, feedResponses, newPosts, postsFromGroupPage).
- It reads the streamed chunks: the first unit under
group_feed.edges, each$streamchunk'snode, and the deferredpage_info. - A post is kept only with its
post_id, its group (target_group.id, this group only) andcreation_time. Every rejection is counted. - The page's HTML carries the same chunk shapes, with the feed under
data.groupinstead ofdata.node. It starts with a "New posts" header unit, which is skipped and counted (skipped), not rejected. - "Already read" goes by post id, not time. The cursor keeps the ids the latest reads saw (
seenIds, at most 500). A post is new when its id isn't there, whatever its time. The read stops scrolling once the feed has shown 3 seen posts (seenBy,reachedCursor,STOP_AFTER_SEEN).
On a recorded run (load, a snapshot, three scrolls) this gave 13 posts, 1 from the HTML and 12 over 4 feed pages, none rejected, newest first. A second read with the cursor kept 0.
Reading comments
Code: src/facebook/group/read (commentsFromResponse, commentsFromPostPage, commentResponses, COMMENT_QUERIES).
A post's comments come from its own page and three requests:
- The post's page (
/groups/<id>/permalink/<post>/), with the first comments in its HTML. Opening the post in a dialog firesCometSinglePostDialogContentQuery. - Switching the sort to Newest fires
CommentListComponentsRootQuerywithcommentsIntentToken: REVERSE_CHRONOLOGICAL_UNFILTERED_INTENT_V1. The page shows "Most relevant" first, which hides some comments; Newest shows every comment except potential spam ("All comments" adds those). - Scrolling the dialog fires
CommentsListComponentsPaginationQuerywithcommentsAfterCursor.
A comment is kept only with its legacy_fbid, its post (its relay id decodes to comment:<postId>_<commentId>), its group (group_comment_info.group.id, this group only) and created_time. A reply's parent is comment_direct_parent. A reply's parent stub (parent_object_ent, with no legacy_fbid) is never counted as a comment.
Example: a post Facebook said had 16 comments showed 10 in its HTML (Most relevant), 10 on Newest and 5 more on scrolling, giving 15 distinct comments, none rejected. Switching the sort is a click, so it needs --live. Nothing is typed, reacted to or sent.
How a read runs
groupReadMachine (src/facebook/group/read/machine.ts) is an XState machine. Its only side effect is a step in the account's open browser.
- Opening: go to the group and take the first posts from its HTML. If the group was given by name, its numeric id is found too (
groupIdFromStep). - Scrolling:
scroll 2 700, as recorded, until the last read's posts show (reachedCursor), the feed ends (has_next_page: false) or--scrollsruns out. If the scrolls run out first, the output says so (gap). - Settled:
newPostsgives the new posts and the cursor after them. Comments are read only for posts whose comment count grew since they were last read (commentTargets), at most--commentPosts. Each post's count comes fromcomment_rendering_instance.comments.total_count. - For each of those posts: go to its page. With
--live, switch "Most relevant" to "Newest". Then scroll its dialog ("<author>'s post") while Facebook says there are more, at most 4 scrolls.
A checkpoint or login page stops the read. A failed step on one post only ends that post's comments; they aren't marked read, so the next read opens the post again.
The recorded flows groupPostComments and groupPostDialog stay as they are. The read machine sends the same steps itself, so it can choose between them based on what each step's recording held.
The command
log facebook read group --account <a> --group <url|id> [--scrolls <n>] [--commentPosts <n>] [--live] [--bypass] [--json]--showprints the group's read cursor from Twenty instead of reading (src/cli/read).- It refuses without the account's open browser (
groupBrowserNotOpen) and outside its active hours. - MCP tools:
readGroupandreadGroupLive. It's a long run, so watch its live log.
What is stored in Twenty
- On
agencyGroup:readCursor({ lastPostId, watermark, idsAtWatermark }, plusseenIds),lastReadAtandlastReadReason. This is all a read writes now. agencyGroupPostandagencyGroupCommentwere added for posts and comments (author, text, permalink, feedback id, times, counts, reply depth). They still exist, but nothing writes to them any more: posts now go to the group-listening service instead (see Listening to Facebook groups).
The client lives in src/facebook/group/read/client.ts (readStateOf before a read). After any field change, regenerate the Twenty client with bun run twenty:client.
Tests
src/facebook/group/read/readMachine.test.ts: a first read, reaching the cursor, a gap, a checkpoint,--livewith a failed post, the comment targets, and reading twice (the second adds nothing, and a stopped read leaves the cursor alone).src/cli/read/read.test.ts: the command end to end, using recorded step folders.
Still to do
- A full live run end to end, read with rea before anything is changed.
- Running the group-listening service against this worker where log runs.
- A host running live with its groups.
The rules behind this are in AGENTS.md.