From 8c7770fbf5ad02a72cc774c8f4882c561a48f7bc Mon Sep 17 00:00:00 2001 From: Ibrahim Ramadan Date: Tue, 15 Sep 2026 12:07:27 +0200 Subject: [PATCH] fix(facebook-page-profile-posts): read doc_id/provided variables at runtime instead of hard-coding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facebook rotates the persisted query doc_id and its __relay_internal__pv__* provided variables when it deploys updates. The hard-coded values in get-page-posts.py go stale, and calls fail with "missing_required_variable_value" instead of the documented "Unexpected response format" — the recapture steps in SKILL.md don't even match this failure mode. Both values are exposed by the page's own Relay modules and can be read live instead of captured by hand: - require('ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation') → current doc_id - require('ProfileCometTimelineFeedRefetchQuery.graphql') .params.providedVariables → call .get() on each entry The hard-coded values are kept as a fallback for when those modules aren't loaded (e.g. calling from a non-Facebook tab). Also updates extractMedia and the likes/comments/shares extraction: Facebook moved multi-photo albums into per-count subattachments lists (two_photos_subattachments, etc.) and moved reaction/comment/share counts directly onto the UFI feedback object, so the old paths were returning null/empty even when the query itself succeeded. Tested against a live public Facebook Page timeline. Co-Authored-By: Claude Sonnet 5 --- .../facebook-page-profile-posts/SKILL.md | 11 ++-- .../scripts/get-page-posts.py | 65 +++++++++++-------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/solutions/social-listening/facebook-page-profile-posts/SKILL.md b/solutions/social-listening/facebook-page-profile-posts/SKILL.md index d1589b8..78b3752 100644 --- a/solutions/social-listening/facebook-page-profile-posts/SKILL.md +++ b/solutions/social-listening/facebook-page-profile-posts/SKILL.md @@ -173,12 +173,11 @@ Error handling: If response contains `"error": true`, check that the target page - `viewsCount` for video posts is always `null` — Facebook does not include video view counts in the timeline feed GraphQL response; this field requires a separate video-specific API call not covered by this Skill - `media.playableUrlSd/Hd` requires the user to be logged in; unauthenticated sessions may return null - `collaborators` is an empty array for most posts (only populated when the post has tagged co-authors) -- The `doc_id: 27278869228466784` is a Facebook internal query ID that may change when Facebook deploys updates; if all calls return `"Unexpected response format"` errors, recapture via: - 1. Open any Facebook page while logged in - 2. Scroll down to trigger a new batch of posts - 3. `network requests --filter api/graphql --method POST` - 4. Find the request with `X-FB-Friendly-Name: ProfileCometTimelineFeedRefetchQuery` - 5. Extract `doc_id` from the POST body and update the value in `scripts/get-page-posts.py` +- The persisted query `doc_id` and its `__relay_internal__pv__*` provided variables change when Facebook deploys updates (a stale set fails with `missing_required_variable_value`). `scripts/get-page-posts.py` reads both at runtime from the page's own Relay modules, so no manual recapture is normally needed: + - `require('ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation')` → current `doc_id` string + - `require('ProfileCometTimelineFeedRefetchQuery.graphql').params.providedVariables` → provider objects; call `.get()` on each for its value + - The hard-coded `doc_id` in the script is only a fallback for when those modules aren't loaded — open the target page (not a bare facebook.com tab) before calling +- If the query succeeds but fields come back `null` (e.g. likes/comments/shares, media), Facebook changed the response shape rather than the query — inspect a raw node and re-map the paths in `extractPost` - `pageName` is derived from the URL path and may be incorrect for profile pages with numeric IDs (e.g., `https://www.facebook.com/100012345` → pageName = `100012345`) - Requires an active Facebook login session; public/unauthenticated access is not supported - Date filtering (`afterTime`/`beforeTime`) applies to the timeline cursor position, not a strict server-side filter; posts near the boundary may occasionally appear outside the specified range diff --git a/solutions/social-listening/facebook-page-profile-posts/scripts/get-page-posts.py b/solutions/social-listening/facebook-page-profile-posts/scripts/get-page-posts.py index a3820a5..83b7efb 100644 --- a/solutions/social-listening/facebook-page-profile-posts/scripts/get-page-posts.py +++ b/solutions/social-listening/facebook-page-profile-posts/scripts/get-page-posts.py @@ -29,6 +29,22 @@ def main(): const lsdM = allScripts.match(/"LSD"[^}}]*"token":"([^"]+)"/); if (lsdM) lsd = lsdM[1]; + // Facebook rotates the persisted query doc_id and its provided (__relay_internal__pv__*) variables + // on deploys. Read both from the page's own Relay modules at runtime; the hard-coded values below + // are only a fallback for when the modules are not loaded. + let docId = '29183747561227112'; + let providedVars = {{}}; + try {{ + const liveDocId = require('ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation'); + if (typeof liveDocId === 'string' && liveDocId) docId = liveDocId; + }} catch(e) {{}} + try {{ + const op = require('ProfileCometTimelineFeedRefetchQuery.graphql'); + for (const [k, p] of Object.entries(op?.params?.providedVariables || {{}})) {{ + try {{ providedVars[k] = typeof p?.get === 'function' ? p.get() : p; }} catch(e) {{ providedVars[k] = null; }} + }} + }} catch(e) {{}} + const variables = {{ afterTime: {after_val}, beforeTime: {before_val}, @@ -50,33 +66,14 @@ def main(): trackingCode: null, useDefaultActor: false, id: '{args.page_id}', - '__relay_internal__pv__GHLShouldChangeAdIdFieldNamerelayprovider': true, - '__relay_internal__pv__GHLShouldChangeSponsoredDataFieldNamerelayprovider': true, - '__relay_internal__pv__IsWorkUserrelayprovider': false, - '__relay_internal__pv__IsMergQAPollsrelayprovider': false, - '__relay_internal__pv__FBReelsEnableVideoWindowedReplayrelayprovider': false, - '__relay_internal__pv__StoriesArmadilloReplyEnabledrelayprovider': false, - '__relay_internal__pv__EventCometCardImage_prefetchEventImagerelayprovider': false, - '__relay_internal__pv__FBReelsMediaFooter_comet_enable_reels_ads_gkrelayprovider': false, - '__relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider': false, - '__relay_internal__pv__CometUFIShareActionMigrationrelayprovider': true, - '__relay_internal__pv__IncludeCommentWithAttachmentrelayprovider': true, - '__relay_internal__pv__GHLShouldUpdateVideoPreviewImagerelayprovider': false, - '__relay_internal__pv__GHLVideoTimestampOnShortsrelayprovider': false, - '__relay_internal__pv__CometFeedStoryDangerouslySetInnerFeedItemDisplayContentrelayprovider': false, - '__relay_internal__pv__StoriesRingrelayprovider': false, - '__relay_internal__pv__UseCometRouter_cometRouterrelayprovider': false, - '__relay_internal__pv__FBReelsFeedbackActionsrelayprovider': false, - '__relay_internal__pv__FBReelsMediaFooter_comet_enable_reels_ads_gkv2relayprovider': false, - '__relay_internal__pv__GHLShouldChangeSponsoredDataFieldNameForFeedV2relayprovider': true, - '__relay_internal__pv__GHLShouldChangeAdIdFieldNameForFeedV2relayprovider': true + ...providedVars }}; const bodyParams = new URLSearchParams({{ fb_dtsg: fbDtsg, lsd: lsd, variables: JSON.stringify(variables), - doc_id: '27278869228466784', + doc_id: docId, server_timestamps: 'true', fb_api_caller_class: 'RelayModern', fb_api_req_friendly_name: 'ProfileCometTimelineFeedRefetchQuery' @@ -136,14 +133,23 @@ def main(): function extractMedia(node) {{ const atts = node?.attachments; if (!atts || atts.length === 0) return []; - return atts.map(att => {{ - const m = att?.styles?.attachment?.media; + // Multi-photo posts (StoryAttachmentAlbumFrameStyleRenderer) list each photo under a per-layout + // subattachments list; frame_sublayout_subattachments only carries dimensions, so prefer the others. + const subLists = ['five_photos_subattachments', 'four_photos_subattachments', 'three_photos_subattachments', + 'two_photos_subattachments', 'all_subattachments', 'frame_sublayout_subattachments']; + const mediaItems = atts.flatMap(att => {{ + const sa = att?.styles?.attachment; + const listKey = subLists.find(k => sa?.[k]?.nodes?.some(n => n?.media?.image?.uri || n?.media?.viewer_image?.uri)); + if (listKey) return sa[listKey].nodes.map(n => n?.media); + return [sa?.media || att?.media]; + }}); + return mediaItems.map(m => {{ if (!m) return null; const t = m.__typename; const base = {{ __typename: t, id: m.id || null }}; if (t === 'Photo') {{ - const pi = m.photo_image; + const pi = m.photo_image || m.viewer_image || m.image; return Object.assign(base, {{ thumbnail: pi?.uri || null, photo_image: pi ? {{ uri: pi.uri, width: pi.width, height: pi.height }} : null, @@ -212,9 +218,14 @@ def main(): }})); const flat = flatReactions(topReactions); - const likes = renderers?.[0]?.feedback?.reaction_count?.count ?? null; - const comments = renderers?.[1]?.feedback?.comment_rendering_instance?.comments?.total_count ?? null; - const shares = renderers?.[2]?.feedback?.share_count?.count ?? null; + // Counts now live directly on the UFI feedback object; the per-action renderer paths are older fallbacks + const likes = ufi?.reaction_count?.count + ?? renderers?.[0]?.feedback?.reaction_count?.count ?? null; + const comments = ufi?.comment_rendering_instance?.comments?.total_count + ?? ufi?.comments_count_summary_renderer?.feedback?.comment_rendering_instance?.comments?.total_count + ?? renderers?.[1]?.feedback?.comment_rendering_instance?.comments?.total_count ?? null; + const shares = ufi?.share_count?.count + ?? renderers?.[2]?.feedback?.share_count?.count ?? null; const creationTime = node?.comet_sections?.timestamp?.story?.creation_time || null; const facebookId = actor0?.id || null;