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;