Collect publicly accessible Facebook photos with high accuracy. Extract content image URLs, album photos, post images, and metadata. Ideal for researchers, brands, and engineers who need organized, large-volume visual data without manual downloading.
Root cause (primary, blocking): Facebook now rejects the plain-Python TLS/HTTP2
fingerprint used by requests and aiohttp outright. Live testing against
https://www.facebook.com/ — even the bare homepage, with or without cookies,
with or without a proxy (direct, and via Apify's residential proxy) — returned
an immediate HTTP 400 "Sorry, something went wrong" from Facebook's edge for
every request made with plain requests.Session() / aiohttp.ClientSession(),
before any page content, login-wall, or checkpoint was ever served. Reissuing
the identical request with a browser-impersonating TLS/HTTP2 ClientHello
(curl_cffi, impersonate="chrome124") — same headers, same IP, same proxy —
returned a normal 200 with real page content immediately. This means the
actor's entire pipeline (stage-1 HTML/JS-bundle fetch in
doc_id_utils.py/node_id_utils.py, and the stage-2 GraphQL POST in
main.py) was being blocked at the network layer regardless of any
header/doc_id correctness — no amount of realistic User-Agent rotation fixes
this, because the block keys on the TLS/HTTP2 handshake itself, not the HTTP
headers layered on top of it.
Root cause (secondary, logic bug — same shape as the sibling "Facebook
Photos Scraper With Engagement Analytics" actor's 0.2 fix): even when stage-1
fetching succeeds, the photo-viewer GraphQL query
(CometPhotoRootContentQuery, used for every per-photo detail request) was
resolved with the wrong doc_id. The profile/page /photos gallery page —
and the JS bundles it references — never embeds this query's doc_id; that
component only loads once a specific photo is opened in its own view. Every
attempt to find photo_viewer_doc_id via the gallery HTML/JS-bundle scan
(extract_doc_ids_from_html, extract_photo_viewer_doc_id_from_js_bundles)
or the base-profile-page scan (extract_doc_id) therefore failed and returned
None. scrape_facebook_photos_async() then silently papered over that
failure with photo_viewer_doc_id = pagination_doc_id — sending the
pagination query's doc_id (ProfileCometAppCollectionPhotosRendererPaginationQuery)
to /api/graphql/ with the photo-viewer's nodeID/variables shape, which
Facebook's API rejects outright. parse_photo_data() correctly recognized
that malformed response as unparseable and discarded it — so every single
photo silently failed, on every run, regardless of target or proxy setting.
Fix:
requirements.txt: replaced requests with curl_cffi (kept aiohttp,
used only for the fbcdn.net media download, and brotli).
src/utils/proxy_utils.py, src/utils/doc_id_utils.py,
src/utils/node_id_utils.py: swapped requests.Session() for
curl_cffi.requests.Session() and added impersonate="chrome124" to every
request. Simplified get_human_headers() / DEFAULT_HEADERS down to the
few semantic headers (Accept, Accept-Language, referer) actually worth
controlling — the previous hand-rolled User-Agent/sec-ch-ua rotation did
nothing for the TLS-layer block and, once impersonation is in use, risks
disagreeing with the impersonated fingerprint (a stronger bot signal than
having no custom headers at all).
src/utils/node_id_utils.py: added
extract_photo_viewer_doc_id_from_photo_page(), which fetches a single
photo's permalink page (https://www.facebook.com/photo/?fbid=<id>, using
an id already collected from the gallery page) and extracts the real
CometPhotoRootContentQuery doc_id from the Relay preloader entry Facebook
inlines there ("preloaderID":"...CometPhotoRootContentQueryRelayPreloader...","queryID":"<doc_id>")
— the only page Facebook actually embeds that doc_id on for a logged-out
visitor. Wired into collect_facebook_photos_ids() ahead of the
previously-always-failing gallery/JS-bundle scan fallbacks.
src/main.py: removed the photo_viewer_doc_id = pagination_doc_id
fallback — it silently guaranteed an API error on every photo instead of
failing loudly, and is never a valid substitute. Replaced the aiohttp-based
stage-2 GraphQL client with a curl_cffi.requests.AsyncSession
(impersonate="chrome124") for the same TLS-fingerprint reason as above;
the aiohttp client is kept, unchanged, for the optional fbcdn.net media
download (downloadMedia), which is not fingerprint-gated the same way.
Verified live against facebook.com/nasa (2026-08-30, direct connection,
no cookies): stage-1 fetch now returns 200 with real gallery HTML, the
photo-viewer doc_id resolves correctly from the permalink page
(27980426738285870 at time of testing, vs. the pagination query's
27028962643386672 that the old fallback would have sent instead), and the
GraphQL detail request for each photo ID returns real parsed data — genuine
accessibilityCaption, caption, createdTime, and reactionCount on every
photo in the sample.