Facebook (public, logged-out) — Worker-Internal Reference
Worker-internal routes for public Facebook surfaces. These are not public API routes — the API layer (apps/api) calls them over HTTP. Clients never call them directly.
Distinct from meta.md, which covers the Meta Ad Library only. The two share a hostname and nothing else: different documents, different Relay operations, different session requirements. Ad Library work belongs in meta, not here.
Grounded in research/facebook/ (observed 2026-08-11) and the browser-free verification in scripts/facebook_http_bootstrap.py.
Extending or debugging this? Read
research/facebook/METHODOLOGY.mdfirst. It records how this was built — the working method, the traps that fail silently, the rules of evidence, and a step-by-step for adding a new endpoint.
No browser required
The paginator operations were originally observed being replayed with request material minted by a real browser. They do not need one. A plain curl_cffi (Scrapling FetcherSession) GET of a Facebook document yields every token the subsequent /api/graphql/ POST needs — verified 24/24 across the feed and collection paginators.
Two things make this work:
- Pool warmup mints the token.
sites.yamlwarms each session againsthttps://www.facebook.com/, andstate.extract_warmup_metadataharvestslsd,jazoest,__spin_r/b/tandhsiout of that HTML into session metadata. Every route is then a single upstream request — no per-Page bootstrap leg. jazoestis derived, not scraped. It never appears in the document. It is"2"followed by the decimal sum of thelsdtoken's character codes.
A caution on that first point: the feed operation intermittently rejects warmed material with missing_required_variable_value. It was tempting — and wrong — to conclude the token must be minted per-Page; a byte-identical request measured 0/4 in one window and 3/3 in another, so the variable is load, not provenance. It is classified transient and retried on a fresh identity. Do not "fix" this by adding a bootstrap leg without re-measuring a control in the same window (see METHODOLOGY.md §5.1).
Non-obvious invariants
Each of these was learned by breaking it first. Changing any one silently returns empty or malformed data rather than an error.
Chrome TLS impersonation is load-bearing. With impersonation off, every request returns the same Facebook 400 error document (0/4 usable in the research). Generated browser headers alone do not repair it.
Never guess a Relay string/enum variable. Supplying a plausible literal for feedLocation, renderLocation, privacySelectorRenderLocation, or the auto-translation relay provider gets the whole request rejected with noncoercible_variable_value and no field-level detail. state.build_variables therefore sends blind defaults only for booleans and numbers, and omits unknown-shape strings unless the caller supplies a known-correct override. See operations.py for the observed schemas.
The collection paginator's variables.id is not the Page id. It is the collection widget's own opaque app_collection: Relay node id, embedded in SSR next to "items":{"count":N},"name":"Followers". Passing the Page's numeric id returns HTTP 200 with a 184-byte body containing none of the requested fields — a hollow success that anything checking only the status code will accept. parsers.extract_collection_id is the only correct source.
A response body is not one JSON object. GraphQL replies are concatenated JSON: the first object carries a placeholder page_info whose end_cursor is null, and later @defer patches append the edges and the real cursor. json.loads either throws or returns a short page with no cursor. Documents are worse — their state is locked inside <script type="application/json"> blobs, so a stream decoder alone returns nothing and the Page reads as having no posts. parsers.json_values dispatches on body shape to handle both.
A GraphQL errors array is not a failure here. Every validated Page-feed payload in the research carried one while still returning the expected posts and cursor. The adapter flags has_graphql_errors only when errors arrive and zero records were parsed; the route maps that to 502. Treating any errors array as fatal would reject the normal case.
Two more silent-zero traps, both verified. The videos paginator needs the Page-typed node id from the videos tab's own SSR — the Page's ordinary entity id resolves as a User-typed node and returns an empty connection. It also requires pageID and showReactions, which look optional in the compiled Relay artifact but are not. And no connection is named after its surface: video edges live under all_videos, albums under pageItems. Guessing a plausible key returns zero records with a valid cursor, which reads as a legitimately empty page.
The Albums collection id uses a different pattern than Followers — no intervening "items":{"count":N} between id and name — so extract_collection_id silently misses it. extract_albums_collection_id exists for exactly that reason.
Video search inverts the @defer rule above — deliberately. For SearchCometResultsInitialResultsQuery, the first object carries the entire result set and its page_info; the @defer patches are VideoPlayer...$defer$... rendering chrome (ad breaks, endscreens) with zero records. On one observed response that was 88 patches making up 13.6MB of a 14.6MB body. parse_video_search therefore reads serpResponse.results and ignores patches. A future reader "fixing" this to merge patch data would be applying the site's general rule correctly and still be wrong; test_defer_patches_contribute_no_records guards it.
Video search response size varies ~100× by query, and it is not the data. The same operation returned 235KB for nasa and 28–31MB for coffee recipe, while the useful payload stayed under 1MB in both cases. The excess is inlined JS resource maps in extensions. No variable was found that suppresses it. Budget worker memory and bandwidth for tens of MB per call.
Video search's two enum literals are the whole endpoint. args.callsite = "comet:watch_search" and args.experience.type = "WATCH_TAB_GLOBAL" were read from a real preloader payload. Nine equally real SERP_*_TAB literals from a genuine Facebook bundle were tested in that slot and all nine were rejected outright; GLOBAL_SEARCH coerced but returned zero records. Changing either value does not produce a helpful error — it produces nothing.
Two block signatures are deliberately absent. "Something went wrong" and "Please try again later" occur as ordinary localized UI strings inside perfectly healthy Page documents. Matching them (as the meta adapter safely can, since it only ever sees Ad Library JSON) would quarantine good sessions on every request.
Routes
| Route | Operation | Notes |
|---|---|---|
GET /facebook/page?slug= | Page document | Returns nodeId plus any posts SSR carried |
GET /facebook/about?slug= | About document | SSR-only; category, about_contact_info, websites_and_social_links, and basic_info on business Pages |
GET /facebook/transparency?slug= | Page Transparency document | SSR-only; page_id, creation_date, admin_info, ad_status, confirmed_owner |
GET /facebook/page-feed?node_id=&cursor= | ProfileCometTimelineFeedRefetchQuery | node_id comes from /page |
GET /facebook/collection?slug=&collection=followers|following | Collection document | Returns collectionId, totalCount, initial 8 records |
GET /facebook/collection-page?collection_id=&cursor= | ProfileCometAppCollectionListRendererPaginationQuery | collection_id comes from /collection |
GET /facebook/post?slug=&post_id= | Post permalink document | Includes initial comment edges and their page_info |
GET /facebook/photo?photo_id=&set_id= | Photo permalink document | |
GET /facebook/videos?slug= | Page videos tab | Returns pageTypedId for the paginator below |
GET /facebook/videos-page?page_typed_id=&cursor= | PagesCometChannelTabAllVideosCardImplPaginationQuery | Edges live under all_videos |
GET /facebook/albums?slug= | Page photos_albums tab | Returns the Albums collectionId |
GET /facebook/albums-page?collection_id=&cursor= | ProfileCometAppCollectionAlbumsRendererPaginationQuery | Reuses the pageItems connection |
GET /facebook/marketplace?category= | Marketplace browse/category document | 20 listings from SSR; marketplace_listings is a bare list, not an edges connection |
GET /facebook/marketplace-item?item_id= | Marketplace item permalink | Returns listing (the target) separately from related |
GET /facebook/video-search?query=&cursor=&count= | SearchCometResultsInitialResultsQuery | Video vertical only. POSTs the operation directly — never fetches a /search/ document, which 404s for guests |
Callers pass identifiers, never URLs. These routes build the Facebook URL themselves and execute it on a warm guest cookie jar behind a residential exit; accepting a caller-supplied URL would make them an open proxy. schemas.py pins the slug/id alphabet accordingly.
Deliberately not implemented
No operation is inferred from an unobserved path. Each of these was investigated and ruled out on evidence:
| Surface | Why not |
|---|---|
| Search — people, Pages, posts, groups, events, places | The /search/... documents 404 (18/18, re-confirmed 2026-08-15 on both curl_cffi and a real Chromium). The operation behind them is reachable, but only its video vertical returns records — see /facebook/video-search. SERP_*_TAB experience types are rejected; GLOBAL_SEARCH coerces and returns nothing. |
/directory/* | HTTP 200 but an empty shell: no SSR JSON, zero real entity links, an embedded login form. /directory/groups/ 500s. |
| Groups (any surface) | Login-gated as of 2026-08-15 — group documents now redirect to /login/?next=, 6/6, measured against a passing same-window Page control. Previously anonymous_http. Not a pagination problem any more; the document itself is gone. |
| Events pagination | Documents remain anonymous_http. Three operations (PublicEventCometRootQuery, EventCometPermalinkHeaderQuery, PublicEventCometAboutRootQuery) were discovered via expectedPreloaders on 2026-08-15 but not replayed — a real lead, not a dead end. |
| Marketplace search and category | Both sub-routes are login-gated as of 2026-08-15 (previously anonymous). Separately, CometMarketplaceSearchContentContainerQuery requires unknown-shape params.bqf.* strings. /marketplace/ home still works. |
| Comment / reply pagination | Zero compiled Relay query descriptors in all 9 bundles of both post documents (100% coverage). Facebook appears to lazy-load that chunk on click. Initial comment edges are available from the post document. |
| Hashtag feed | Anonymous, but SSR carries a single example post, not a feed. |
| Live video history | Hard auth error (Unauthorized logged out query, code 1675002), 2/2. |
| Watch home pagination | CometVideoHomeLOEHomeSectionsListPaginationQuery found and guest-scoped by name, but missing_required_variable_value across every variable set tried. Not guessed further. |
| Reels pagination | No operation found in the bundles checked (not exhaustive). |
Page tabs recommendations / offers / menu / events | Login-gated (4/4), or a generic "not configured" shell byte-identical across unrelated Pages. |
CometSinglePostDialogContentQuery (37591875023793055) is confirmed replayable browser-free (6/6) but is a fixed content fetch with no cursor variable — it returns a few more initial comments, not a next page.
Failure taxonomy
Facebook reports nearly every failure as an HTTP 200 with a GraphQL errors array, so status codes classify nothing. failures.py reduces a response to one actionable kind, and the route maps each to its own status and type — because they have different remedies, and one generic 500 hides which you are looking at.
| Kind | HTTP | Type | Meaning / remedy |
|---|---|---|---|
stale_doc_id | 503 | UPSTREAM_CONTRACT_CHANGED | Facebook rotated a persisted query. Re-pin (below). Breaks every GraphQL route at once. |
parse_failed | 502 | RESPONSE_PARSE_FAILED | Our parser no longer matches their markup — not an upstream outage. |
empty | 502 | UPSTREAM_EMPTY_RESPONSE | A hollow 200. Usually the wrong node id (see the collection-id trap above). |
transient | 503 | UPSTREAM_UNAVAILABLE | Retryable; engine quarantines the session and retries on another identity. |
rate_limited | 503 | UPSTREAM_RATE_LIMITED | Retryable; also escalates the proxy tier. |
auth_required | 403 | UPSTREAM_AUTH_REQUIRED | Surface is not public logged-out. Permanent. |
These are diagnostics about our own integration, not a passthrough of Facebook's internals: no upstream body is ever forwarded, and upstream_status stays in context, which the API gateway logs rather than returning to end clients.
missing_required_variable_value is classified transient on purpose. It was measured at 0/4 in one window and 3/3 in another with a byte-identical request, so it is a load-dependent upstream condition, not a contract error. Retrying on a fresh identity is the cure; "fixing" the request is not.
Detecting and fixing a rotated doc_id
Detection is automatic: the wire signature is HTTP 200 with "The GraphQL document with ID <id> was not found.". When seen, the adapter logs event=upstream_contract_changed at ERROR with the stale id and the remedy, and increments the worker_facebook_doc_id_stale_total metric. Alert on any non-zero rate of that counter — it means "re-pin the ids", not "the upstream is flaky".
To fix, re-derive the live ids from Facebook's own bundles:
uv run python scripts/facebook_doc_id_refresh.py # report drift
uv run python scripts/facebook_doc_id_refresh.py --check # exit 1 if drifted (CI/cron)
uv run python scripts/facebook_doc_id_refresh.py --post-url https://www.facebook.com/{slug}/posts/{id}It prints the env vars to set. The single-post id needs --post-url because that component ships only with a post permalink, not with a Page document. A "not found" result is reported as NOT VERIFIED, never as "matches" — absence from the scanned bundles is not evidence the pinned id is current.
doc_ids rotate individually, not as a build-wide set. Between 2026-08-11 and 2026-08-15, SearchCometInterestsDeepDivePostsQuery moved (38462946399962885 → 28524649783799211) while its sibling on the same document did not, and every Marketplace id held. So a single id being current says nothing about the others, and one stale id does not imply a fleet-wide rotation. The video-search id (FACEBOOK_VIDEO_SEARCH_DOC_ID) is not yet covered by the refresh script; re-derive it by reading expectedPreloaders from https://www.facebook.com/watch/search/?query=<anything>, which is where it was found in the first place.
Proxy tiers and rotation
Steady state is Core Residential (measured at parity with premium on this host for meta, at a fraction of the cost). On a block or rate-limit the adapter escalates to premium Residential for a 10-minute cooldown window, then reverts.
Rotation itself is the pool: every pooled session is a distinct sticky exit identity, so a larger pool spreads the same volume over more IPs, and a blocked session is quarantined and replaced rather than retried on a burned identity. Both tiers are env-overridable (FACEBOOK_PROXY_TIER, FACEBOOK_ESCALATED_PROXY_TIER) so a live incident can be answered without a deploy.
The escalation window is process-local rather than Redis-backed, on purpose: it is a cost hint, not correctness. A replica that has not personally seen a block simply keeps paying less, and each replica self-heals when the window lapses — versus adding a Redis round trip to every request to save a few cents.
Maintenance
The three Relay doc_ids are build artifacts that Facebook rotates on deploy. They are env-overridable (FACEBOOK_FEED_DOC_ID, FACEBOOK_COLLECTION_DOC_ID, FACEBOOK_SINGLE_POST_DOC_ID) so a rotation is a config change rather than a release. They are not present in the page HTML, but they are discoverable by fetching the document's own <script src> bundles and grepping — note the bundle set differs per document type (the collection doc_id appears only in the /followers/ page's bundles).
Cursor portability beyond a fresh cookie jar on the same IP is untested — cross-IP and after-one-hour reuse were never validated, so a cursor should not be treated as a durable public token.