bb-fca 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
24
24
  let privacy = 'SELF';
25
25
  let photos = [];
26
26
  let videoId: string | null = null;
27
+ let groupID: string | null = null;
27
28
 
28
29
  if (typeof options === 'string') {
29
30
  postMessage = options;
@@ -32,6 +33,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
32
33
  privacy = options.privacy || 'SELF';
33
34
  photos = options.photos || [];
34
35
  videoId = options.video || null;
36
+ groupID = options.groupID || null;
35
37
  } else {
36
38
  throw new Error('Invalid input: expected string or object.');
37
39
  }
@@ -59,10 +61,12 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
59
61
  .substring(2)}`;
60
62
  const idempotenceToken = `${composerSessionId}_FEED`;
61
63
 
64
+ const isGroup = !!groupID;
65
+
62
66
  const variables: any = {
63
67
  input: {
64
68
  composer_entry_point: 'inline_composer',
65
- composer_source_surface: 'newsfeed',
69
+ composer_source_surface: isGroup ? 'group' : 'newsfeed',
66
70
  composer_type: 'feed',
67
71
  idempotence_token: idempotenceToken,
68
72
  source: 'WWW',
@@ -97,11 +101,11 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
97
101
  actor_id: ctx.userID,
98
102
  client_mutation_id: '1',
99
103
  },
100
- feedLocation: 'NEWSFEED',
104
+ feedLocation: isGroup ? 'GROUP' : 'NEWSFEED',
101
105
  feedbackSource: 1,
102
106
  focusCommentID: null,
103
107
  gridMediaWidth: null,
104
- groupID: null,
108
+ groupID: groupID,
105
109
  scale: 2,
106
110
  privacySelectorRenderLocation: 'COMET_STREAM',
107
111
  checkPhotosToReelsUpsellEligibility: true,
@@ -112,7 +116,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
112
116
  isFeed: true,
113
117
  isFundraiser: false,
114
118
  isFunFactPost: false,
115
- isGroup: false,
119
+ isGroup: isGroup,
116
120
  isEvent: false,
117
121
  isTimeline: false,
118
122
  isSocialLearning: false,
@@ -123,7 +127,8 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
123
127
  canUserManageOffers: false,
124
128
  __relay_internal__pv__CometUFIShareActionMigrationrelayprovider: true,
125
129
  __relay_internal__pv__CometUFI_dedicated_comment_routable_dialog_gkrelayprovider: false,
126
- __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider: 'ORIGINAL',
130
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider:
131
+ 'ORIGINAL',
127
132
  __relay_internal__pv__IsWorkUserrelayprovider: false,
128
133
  __relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider: false,
129
134
  __relay_internal__pv__FBReels_deprecate_short_form_video_context_gkrelayprovider: true,
@@ -289,14 +294,28 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
289
294
  }
290
295
 
291
296
  /**
292
- * Gets comments from a Facebook post.
293
- * @param {string|object} postID - The post ID (string) or options object.
294
- * @param {string} postID.story_fbid - The story FBID from the permalink URL.
295
- * @param {string} postID.id - The account ID from the permalink URL.
297
+ * Gets comments from a Facebook post, with pagination support.
298
+ * @param {string|object} postID - The post URL (string) or options object.
299
+ * @param {string} [postID.url] - Direct Facebook post URL.
300
+ * @param {string} [postID.story_fbid] - The story FBID.
301
+ * @param {string} [postID.id] - The account ID.
302
+ * @param {string} [postID.after] - Cursor string to fetch the next page.
303
+ * @param {string} [postID.feedback_id] - Feedback ID required for pagination (returned in page_info).
296
304
  * @param {Function} [callback] - Optional callback function.
297
- * @returns {Promise<Array>} Array of comments with author info, text, timestamps, etc.
305
+ * @returns {Promise<{comments: Array, page_info: object}>} Object with comments array and pagination info.
298
306
  */
299
- async function getPostComments(postID, callback) {
307
+ async function getPostComments(postID, optionsOrCallback?, callback?) {
308
+ // Support: getComments(url), getComments(url, cb), getComments(url, opts), getComments(url, opts, cb)
309
+ let paginationOpts: any = null;
310
+ if (typeof optionsOrCallback === 'function') {
311
+ callback = optionsOrCallback;
312
+ } else if (
313
+ typeof optionsOrCallback === 'object' &&
314
+ optionsOrCallback !== null
315
+ ) {
316
+ paginationOpts = optionsOrCallback;
317
+ }
318
+
300
319
  let resolveFunc: Function = function() {};
301
320
  let rejectFunc: Function = function() {};
302
321
 
@@ -313,37 +332,173 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
313
332
  };
314
333
 
315
334
  try {
316
- let story_fbid, accountID;
335
+ // ── Pagination mode: fetch next page via GraphQL ──────────────────
336
+ // Merge pagination from either postID (object) or second-arg options
337
+ const afterCursor: string | undefined =
338
+ paginationOpts?.after ||
339
+ (typeof postID === 'object' && postID !== null
340
+ ? postID.after
341
+ : undefined);
342
+ const feedbackId: string | undefined =
343
+ paginationOpts?.feedback_id ||
344
+ (typeof postID === 'object' && postID !== null
345
+ ? postID.feedback_id
346
+ : undefined);
347
+
348
+ if (afterCursor && feedbackId) {
349
+ // Use GraphQL to fetch next page of comments using the cursor
350
+ // feedbackId is the GraphQL ID (base64 of "feedback:<numeric_id>")
351
+ const variables: Record<string, any> = {
352
+ commentsAfterCount: -1,
353
+ commentsAfterCursor: afterCursor,
354
+ commentsBeforeCount: null,
355
+ commentsBeforeCursor: null,
356
+ commentsIntentToken: null,
357
+ feedLocation: 'POST_PERMALINK_DIALOG',
358
+ focusCommentID: null,
359
+ scale: 1,
360
+ useDefaultActor: false,
361
+ id: feedbackId,
362
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider:
363
+ 'ORIGINAL',
364
+ __relay_internal__pv__CometUFICommentAvatarStickerAnimatedImagerelayprovider: false,
365
+ __relay_internal__pv__CometUFICommentActionLinksRewriteEnabledrelayprovider: false,
366
+ __relay_internal__pv__IsWorkUserrelayprovider: false,
367
+ };
368
+
369
+ const form = {
370
+ av: ctx.userID,
371
+ fb_api_caller_class: 'RelayModern',
372
+ fb_api_req_friendly_name: 'CommentsListComponentsPaginationQuery',
373
+ variables: JSON.stringify(variables),
374
+ doc_id: '35385759421023325',
375
+ server_timestamps: 'true',
376
+ };
377
+
378
+ const gqlRes = await defaultFuncs.post(
379
+ 'https://www.facebook.com/api/graphql/',
380
+ ctx.jar,
381
+ form,
382
+ ctx.globalOptions,
383
+ ctx,
384
+ );
385
+
386
+ let gqlData: any;
387
+ if (
388
+ typeof gqlRes.body === 'object' &&
389
+ gqlRes.body !== null &&
390
+ !Buffer.isBuffer(gqlRes.body)
391
+ ) {
392
+ gqlData = gqlRes.body;
393
+ } else {
394
+ const body = gqlRes.body.toString();
395
+ try {
396
+ gqlData = JSON.parse(body);
397
+ } catch (e) {
398
+ // Multi-line JSON stream — take first line
399
+ gqlData = JSON.parse(body.split('\n')[0]);
400
+ }
401
+ }
402
+
403
+ // Navigate to comments in GraphQL response
404
+ const commentsConnection =
405
+ gqlData?.data?.node?.comment_rendering_instance_for_feed_location
406
+ ?.comments ??
407
+ gqlData?.data?.feedback?.comment_rendering_instance_for_feed_location
408
+ ?.comments ??
409
+ null;
410
+
411
+ const result: any[] = [];
412
+ let page_info: any = null;
413
+
414
+ if (commentsConnection) {
415
+ page_info = commentsConnection.page_info || null;
416
+ (commentsConnection.edges || []).forEach((edge: any) => {
417
+ if (!edge?.node) return;
418
+ const c: any = {
419
+ id: edge.node.legacy_fbid,
420
+ graphql_id: edge.node.id,
421
+ text:
422
+ edge.node.body?.text || edge.node.preferred_body?.text || '',
423
+ created_time: edge.node.created_time,
424
+ author: {
425
+ id: edge.node.author?.id,
426
+ name: edge.node.author?.name,
427
+ avatar: edge.node.author?.profile_picture_depth_0?.uri,
428
+ },
429
+ reply_count: edge.node.feedback?.replies_fields?.count || 0,
430
+ total_reply_count:
431
+ edge.node.feedback?.replies_fields?.total_count || 0,
432
+ depth: edge.node.depth || 0,
433
+ attachments: edge.node.attachments || [],
434
+ };
435
+ if (edge.node.feedback?.replies_connection?.edges) {
436
+ c.replies = edge.node.feedback.replies_connection.edges
437
+ .map((re: any) => {
438
+ if (!re?.node) return null;
439
+ return {
440
+ id: re.node.legacy_fbid,
441
+ text:
442
+ re.node.body?.text || re.node.preferred_body?.text || '',
443
+ created_time: re.node.created_time,
444
+ author: {
445
+ id: re.node.author?.id,
446
+ name: re.node.author?.name,
447
+ avatar: re.node.author?.profile_picture_depth_0?.uri,
448
+ },
449
+ };
450
+ })
451
+ .filter((r: any) => r !== null);
452
+ }
453
+ result.push(c);
454
+ });
455
+ }
456
+
457
+ callback(null, { comments: result, page_info });
458
+ return returnPromise;
459
+ }
460
+
461
+ // ── Initial load mode: fetch HTML and parse ───────────────────────
462
+ let url: string;
317
463
 
318
- // Handle both string and object input
319
464
  if (typeof postID === 'string') {
320
- // If it's a simple post ID, construct URL with user's ID
321
- story_fbid = postID;
322
- accountID = ctx.userID;
323
- } else if (typeof postID === 'object') {
324
- story_fbid = postID.story_fbid;
325
- accountID = postID.id || ctx.userID;
465
+ if (postID.startsWith('https://') || postID.startsWith('http://')) {
466
+ url = postID;
467
+ } else if (postID.startsWith('/')) {
468
+ url = `https://www.facebook.com${postID}`;
469
+ } else if (/^\d+$/.test(postID)) {
470
+ url = `https://www.facebook.com/permalink.php?story_fbid=${postID}&id=${ctx.userID}`;
471
+ } else {
472
+ url = `https://www.facebook.com/permalink.php?story_fbid=${postID}&id=${ctx.userID}`;
473
+ }
474
+ } else if (typeof postID === 'object' && postID !== null) {
475
+ const story_fbid = postID.story_fbid;
476
+ const accountID = postID.id || ctx.userID;
477
+ const postUrl = postID.url;
478
+
479
+ if (postUrl) {
480
+ url = postUrl.startsWith('http')
481
+ ? postUrl
482
+ : `https://www.facebook.com${postUrl}`;
483
+ } else if (story_fbid) {
484
+ url = `https://www.facebook.com/permalink.php?story_fbid=${story_fbid}&id=${accountID}`;
485
+ } else {
486
+ throw new Error('Object input must have story_fbid or url field.');
487
+ }
326
488
  } else {
327
489
  throw new Error(
328
- 'Invalid input: expected string or object with story_fbid and id.',
490
+ 'Invalid input: expected string (URL or postID) or object with story_fbid/url.',
329
491
  );
330
492
  }
331
493
 
332
- if (!story_fbid) {
333
- throw new Error('Post ID or story_fbid is required.');
334
- }
335
-
336
- // Construct the permalink URL
337
- const url = `https://www.facebook.com/permalink.php?story_fbid=${story_fbid}&id=${accountID}`;
338
-
339
494
  // Make GET request to fetch the page HTML with cookies
340
495
  const resData = await utils.get(url, ctx.jar, {}, ctx.globalOptions, ctx);
341
496
  const html = resData.body.toString();
342
497
 
343
- // Extract comments using new logic
344
- const commentList = extractCommentsFromHTML(html);
498
+ // Extract comments and page_info from HTML
499
+ const { comments, page_info } = extractCommentsFromHTML(html);
345
500
 
346
- callback(null, commentList);
501
+ callback(null, { comments, page_info });
347
502
  } catch (err) {
348
503
  utils.error('getPostComments', err);
349
504
  callback(err);
@@ -370,9 +525,13 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
370
525
  /**
371
526
  * Extract comments from HTML using new parsing logic
372
527
  * @private
528
+ * @returns {{ comments: any[], page_info: any | null }}
373
529
  */
374
- function extractCommentsFromHTML(htmlContent) {
375
- const comments = [];
530
+ function extractCommentsFromHTML(
531
+ htmlContent: string,
532
+ ): { comments: any[]; page_info: any | null } {
533
+ const comments: any[] = [];
534
+ let page_info: any = null;
376
535
 
377
536
  try {
378
537
  // Find all script tags containing JSON data
@@ -381,7 +540,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
381
540
  );
382
541
 
383
542
  if (!scriptMatches) {
384
- return comments;
543
+ return { comments, page_info };
385
544
  }
386
545
 
387
546
  // Loop through each script tag
@@ -442,8 +601,32 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
442
601
  commentData.edges &&
443
602
  Array.isArray(commentData.edges)
444
603
  ) {
604
+ // Extract page_info (cursor pagination info)
605
+ if (commentData.page_info && !page_info) {
606
+ page_info = {
607
+ end_cursor: commentData.page_info.end_cursor || null,
608
+ has_next_page:
609
+ commentData.page_info.has_next_page || false,
610
+ has_previous_page:
611
+ commentData.page_info.has_previous_page || false,
612
+ start_cursor:
613
+ commentData.page_info.start_cursor || null,
614
+ };
615
+ // Also try to capture feedback_id for GraphQL pagination
616
+ try {
617
+ const feedback =
618
+ result.data.node_v2?.comet_sections?.feedback?.story
619
+ ?.story_ufi_container?.story?.feedback_context
620
+ ?.feedback_target_with_context
621
+ ?.comment_list_renderer?.feedback;
622
+ if (feedback?.id && !page_info.feedback_id) {
623
+ page_info.feedback_id = feedback.id;
624
+ }
625
+ } catch (_) {}
626
+ }
627
+
445
628
  // Extract comments
446
- commentData.edges.forEach((edge) => {
629
+ commentData.edges.forEach((edge: any) => {
447
630
  if (!edge.node) return;
448
631
 
449
632
  const comment: any = {
@@ -472,7 +655,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
472
655
  // Get replies if available
473
656
  if (edge.node.feedback?.replies_connection?.edges) {
474
657
  comment.replies = edge.node.feedback.replies_connection.edges
475
- .map((replyEdge) => {
658
+ .map((replyEdge: any) => {
476
659
  if (!replyEdge.node) return null;
477
660
  return {
478
661
  id: replyEdge.node.legacy_fbid,
@@ -490,7 +673,7 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
490
673
  },
491
674
  };
492
675
  })
493
- .filter((r) => r !== null);
676
+ .filter((r: any) => r !== null);
494
677
  }
495
678
 
496
679
  comments.push(comment);
@@ -510,7 +693,218 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
510
693
  utils.error('extractCommentsFromHTML', error);
511
694
  }
512
695
 
513
- return comments;
696
+ return { comments, page_info };
697
+ }
698
+
699
+ /**
700
+ * Extract the numeric `post_id` (and related IDs) from a Facebook post HTML page.
701
+ *
702
+ * This follows the same pipeline as `getPostComments`:
703
+ * 1. Fetches the page HTML with session cookies (GET request).
704
+ * 2. Scans every `<script type="application/json" data-sjs>` block.
705
+ * 3. Searches each parsed JSON tree for `"post_id"` strings (and `"story_id"`,
706
+ * `"feedback_id"`, etc.) that appear alongside the post data.
707
+ *
708
+ * Works with **all** URL formats:
709
+ * - `/posts/pfbid{token}` ← pfbid (server resolves it during HTML render)
710
+ * - `/posts/{numericID}`
711
+ * - `permalink.php?story_fbid=...`
712
+ *
713
+ * @param {string} urlOrID - Full Facebook post URL, or a plain numeric post ID.
714
+ * If a numeric ID is passed, it is fetched as
715
+ * `facebook.com/permalink.php?story_fbid={id}&id={userID}`.
716
+ * @param {Function} [callback] - Optional node-style `(err, result)` callback.
717
+ * @returns {Promise<{
718
+ * post_id: string | null, // numeric post ID found in HTML ("post_id":"…")
719
+ * story_id: string | null, // numeric story/post ID (alias)
720
+ * feedback_id: string | null, // base64 GraphQL feedback ID
721
+ * post_url: string, // URL that was actually fetched
722
+ * }>}
723
+ *
724
+ * @example
725
+ * const { post_id } = await api.post.getPostIDFromURL(
726
+ * 'https://www.facebook.com/tuyetcollection/posts/pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl'
727
+ * );
728
+ * console.log(post_id); // '1510599910435387'
729
+ *
730
+ * // Then react using the real numeric ID
731
+ * await api.post.reactWithInfo(post_id, 'LOVE');
732
+ */
733
+ async function getPostIDFromURL(
734
+ urlOrID: string,
735
+ callback?: Function,
736
+ ): Promise<any> {
737
+ let resolveFunc: Function = function() {};
738
+ let rejectFunc: Function = function() {};
739
+
740
+ const returnPromise = new Promise<any>((resolve, reject) => {
741
+ resolveFunc = resolve;
742
+ rejectFunc = reject;
743
+ });
744
+
745
+ callback =
746
+ callback ||
747
+ function(err: any, data: any) {
748
+ if (err) return rejectFunc(err);
749
+ resolveFunc(data);
750
+ };
751
+
752
+ try {
753
+ if (!urlOrID) throw new Error('urlOrID is required.');
754
+
755
+ // ── Build fetch URL ────────────────────────────────────────────────
756
+ let fetchUrl: string;
757
+
758
+ if (urlOrID.startsWith('http://') || urlOrID.startsWith('https://')) {
759
+ fetchUrl = urlOrID;
760
+ } else if (urlOrID.startsWith('/')) {
761
+ fetchUrl = `https://www.facebook.com${urlOrID}`;
762
+ } else if (/^\d+$/.test(urlOrID)) {
763
+ // plain numeric ID → use permalink.php format (same as getPostComments)
764
+ fetchUrl = `https://www.facebook.com/permalink.php?story_fbid=${urlOrID}&id=${ctx.userID}`;
765
+ } else {
766
+ // pfbid token without full URL
767
+ fetchUrl = `https://www.facebook.com/permalink.php?story_fbid=${urlOrID}&id=${ctx.userID}`;
768
+ }
769
+
770
+ // ── Fetch HTML ─────────────────────────────────────────────────────
771
+ const resData = await utils.get(
772
+ fetchUrl,
773
+ ctx.jar,
774
+ {},
775
+ ctx.globalOptions,
776
+ ctx,
777
+ );
778
+ const html = resData.body.toString() as string;
779
+
780
+ // ── Helpers ────────────────────────────────────────────────────────
781
+ /** Deep-search an object tree; call visitor(value, key) on every value. */
782
+ function deepSearch(
783
+ obj: any,
784
+ visitor: (v: any, k: string | number) => void,
785
+ ): void {
786
+ if (!obj || typeof obj !== 'object') return;
787
+ for (const key of Object.keys(obj)) {
788
+ visitor(obj[key], key);
789
+ deepSearch(obj[key], visitor);
790
+ }
791
+ }
792
+
793
+ /** Find the first string value of `fieldName` anywhere in `obj`. */
794
+ function findField(obj: any, fieldName: string): string | null {
795
+ let found: string | null = null;
796
+ deepSearch(obj, (v, k) => {
797
+ if (found) return;
798
+ if (k === fieldName && typeof v === 'string' && v.length > 0) {
799
+ found = v;
800
+ }
801
+ });
802
+ return found;
803
+ }
804
+
805
+ // ── Parse <script data-sjs> blocks (same as getPostComments) ───────
806
+ const scriptMatches = html.match(
807
+ /<script type="application\/json"\s+data-content-len="\d+"\s+data-sjs>(.*?)<\/script>/gs,
808
+ );
809
+
810
+ let post_id: string | null = null;
811
+ let story_id: string | null = null;
812
+ let feedback_id: string | null = null;
813
+
814
+ if (scriptMatches) {
815
+ outer: for (const scriptTag of scriptMatches) {
816
+ try {
817
+ const inner = scriptTag.match(/<script[^>]*>(.*?)<\/script>/s);
818
+ if (!inner) continue;
819
+
820
+ const jsonData = JSON.parse(inner[1]);
821
+
822
+ if (!jsonData.require || !Array.isArray(jsonData.require)) continue;
823
+
824
+ for (const req of jsonData.require) {
825
+ if (!Array.isArray(req)) continue;
826
+
827
+ if (req[0] === 'ScheduledServerJS' && req[1] === 'handle') {
828
+ const payload = req[3];
829
+ if (!payload || !Array.isArray(payload)) continue;
830
+
831
+ for (const item of payload) {
832
+ if (!item.__bbox?.require) continue;
833
+
834
+ for (const bboxReq of item.__bbox.require) {
835
+ if (!Array.isArray(bboxReq)) continue;
836
+
837
+ if (
838
+ bboxReq[0] === 'RelayPrefetchedStreamCache' &&
839
+ bboxReq[1] === 'next'
840
+ ) {
841
+ const streamData = bboxReq[3];
842
+ if (!Array.isArray(streamData) || streamData.length < 2)
843
+ continue;
844
+
845
+ const bboxData = streamData[1];
846
+ if (!bboxData?.__bbox?.result) continue;
847
+
848
+ const result = bboxData.__bbox.result;
849
+
850
+ // ── Search for post_id / story_id ─────────────────
851
+ if (!post_id) post_id = findField(result, 'post_id');
852
+ if (!story_id) story_id = findField(result, 'story_id');
853
+
854
+ // ── Search for feedback_id (same path as getPostComments) ──
855
+ if (!feedback_id) {
856
+ try {
857
+ const feedback =
858
+ result.data?.node_v2?.comet_sections?.feedback
859
+ ?.story?.story_ufi_container?.story
860
+ ?.feedback_context?.feedback_target_with_context
861
+ ?.comment_list_renderer?.feedback;
862
+ if (feedback?.id) feedback_id = feedback.id;
863
+ } catch (_) {}
864
+
865
+ // Fallback: deep-search for feedback_id string
866
+ if (!feedback_id)
867
+ feedback_id = findField(result, 'feedback_id');
868
+ }
869
+
870
+ // Stop once we have the numeric post_id
871
+ if (post_id) break outer;
872
+ }
873
+ }
874
+ }
875
+ }
876
+ }
877
+ } catch (_) {
878
+ continue; // skip malformed JSON blocks
879
+ }
880
+ }
881
+ }
882
+
883
+ // ── Fallback: regex scan on raw HTML ────────────────────────────────
884
+ // "post_id":"1510599910435387" or "post_id":1510599910435387
885
+ if (!post_id) {
886
+ const m = html.match(/"post_id"\s*:\s*"?(\d{10,20})"?/);
887
+ if (m) post_id = m[1];
888
+ }
889
+ if (!story_id) {
890
+ const m = html.match(/"story_id"\s*:\s*"?(\d{10,20})"?/);
891
+ if (m) story_id = m[1];
892
+ }
893
+
894
+ const resultObj = {
895
+ post_id,
896
+ story_id: story_id || post_id, // they're often the same
897
+ feedback_id,
898
+ post_url: fetchUrl,
899
+ };
900
+
901
+ callback(null, resultObj);
902
+ } catch (err) {
903
+ utils.error('getPostIDFromURL', err);
904
+ callback(err);
905
+ }
906
+
907
+ return returnPromise;
514
908
  }
515
909
 
516
910
  /**
@@ -748,8 +1142,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
748
1142
  .post('https://www.facebook.com/api/graphql/', ctx.jar, configForm, {})
749
1143
  .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
750
1144
 
751
-
752
-
753
1145
  // Extract config (endpoints, service info)
754
1146
  // NOTE: config is returned as a JSON string under data.viewer
755
1147
  let startUri =
@@ -795,8 +1187,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
795
1187
  )
796
1188
  .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
797
1189
 
798
-
799
-
800
1190
  // ===== Step 3: Initialize Upload Session =====
801
1191
  // Browser sends: POST vupload2.facebook.com/ajax/video/upload/requests/start/?__a=1
802
1192
  // Cross-subdomain: must manually extract cookies + send full auth params
@@ -832,8 +1222,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
832
1222
  av: ctx.userID,
833
1223
  };
834
1224
 
835
-
836
-
837
1225
  const startRes = await utils
838
1226
  .post(startRequestUrl, ctx.jar, startForm, ctx.globalOptions, ctx, {
839
1227
  X_fb_video_waterfall_id: waterfallId,
@@ -847,8 +1235,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
847
1235
 
848
1236
  .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
849
1237
 
850
-
851
-
852
1238
  const videoId = startRes?.payload?.video_id || startRes?.video_id;
853
1239
  const uploadSessionId =
854
1240
  startRes?.payload?.upload_session_id || startRes?.upload_session_id;
@@ -891,7 +1277,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
891
1277
  'Sec-Fetch-Site': 'same-site',
892
1278
  };
893
1279
 
894
-
895
1280
  const probeRes = await utils.get(
896
1281
  ruploadBaseUrl,
897
1282
  ctx.jar,
@@ -901,7 +1286,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
901
1286
  corsHeaders,
902
1287
  );
903
1288
 
904
-
905
1289
  // ===== Step 5: Upload Binary Data =====
906
1290
  const uploadHeaders = {
907
1291
  'Content-Type': 'application/octet-stream',
@@ -918,7 +1302,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
918
1302
  ...corsHeaders,
919
1303
  };
920
1304
 
921
-
922
1305
  const uploadRes = await utils.postRaw(
923
1306
  ruploadBaseUrl,
924
1307
  ctx.jar,
@@ -977,7 +1360,6 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
977
1360
  lsd: ctx.lsd || '',
978
1361
  };
979
1362
 
980
-
981
1363
  const receiveRes = await utils
982
1364
  .post(
983
1365
  receiveUrl.toString(),
@@ -1026,11 +1408,686 @@ export default function(defaultFuncs: any, api: any, ctx: any) {
1026
1408
  return returnPromise;
1027
1409
  }
1028
1410
 
1411
+ /**
1412
+ * React to a Facebook post (like, love, haha, wow, sad, angry).
1413
+ *
1414
+ * @param {string} feedbackId - The feedback ID. Can be:
1415
+ * - A raw numeric post ID (e.g. "1624250182241514")
1416
+ * - A base64-encoded feedback ID (e.g. "ZmVlZGJhY2s6MTYyN...")
1417
+ * - The literal string "feedback:<postID>"
1418
+ * @param {string} [reaction="LIKE"] - Reaction type:
1419
+ * "LIKE" | "LOVE" | "HAHA" | "WOW" | "SAD" | "ANGRY" | "CARE"
1420
+ * or equivalent emojis: "👍" | "❤️" | "😂" | "😮" | "😢" | "😡" | "🤗"
1421
+ * @param {Function} [callback] - Optional callback function.
1422
+ * @returns {Promise<object>} The server response.
1423
+ */
1424
+ async function reactToPost(
1425
+ feedbackId: string,
1426
+ reaction: string = 'LIKE',
1427
+ callback?: Function,
1428
+ ) {
1429
+ try {
1430
+ if (!feedbackId) {
1431
+ throw new Error('feedbackId is required.');
1432
+ }
1433
+
1434
+ // ── Reaction ID map ────────────────────────────────────────────────
1435
+ // IDs sourced from live Facebook reaction metadata.
1436
+ // Only non-deprecated reactions are listed as primary keys.
1437
+ // Deprecated reactions kept for backward-compat.
1438
+ const REACTION_IDS: Record<string, string> = {
1439
+ // ── Active reactions ─────────────────────────────────────────────
1440
+ LIKE: '1635855486666999', // Thích (type:1) #0866FF
1441
+ LOVE: '1678524932434102', // Yêu thích (type:2) #f33e58
1442
+ WOW: '478547315650144', // Wow (type:3) #f7b125
1443
+ HAHA: '115940658764963', // Haha (type:4) #f7b125
1444
+ SAD: '908563459236466', // Buồn (type:7) #f7b125
1445
+ ANGRY: '444813342392137', // Phẫn nộ (type:8) #e9710f
1446
+ CARE: '613557422527858', // Thương thương (type:16) #f7b125
1447
+ SUPPORT: '613557422527858', // alias CARE
1448
+ DOROTHY: '1663186627268800', // Biết ơn (type:11) #7e64c4
1449
+ TOTO: '899779720071651', // Tự hào (type:12) #ec7ebd
1450
+ // ── Deprecated (kept for backward-compat) ────────────────────────
1451
+ YAY: '1667835766830853', // Chúc mừng (type:5 - deprecated)
1452
+ CONFUSED: '1536130110011063', // Khó hiểu (type:10 - deprecated)
1453
+ SELFIE: '869508936487422', // Ảnh selfie (type:13 - deprecated)
1454
+ FLAME: '938644726258608', // Bày tỏ (flame, type:14 - deprecated)
1455
+ PLANE: '1609920819308489', // Bày tỏ (plane, type:15 - deprecated)
1456
+ // ── Emoji aliases ────────────────────────────────────────────────
1457
+ '👍': '1635855486666999', // LIKE
1458
+ '❤️': '1678524932434102', // LOVE
1459
+ '😮': '478547315650144', // WOW
1460
+ '😂': '115940658764963', // HAHA
1461
+ '😢': '908563459236466', // SAD
1462
+ '😡': '444813342392137', // ANGRY
1463
+ '🤗': '613557422527858', // CARE/Support
1464
+ // ── Vietnamese name aliases ──────────────────────────────────────
1465
+ 'THÍCH': '1635855486666999',
1466
+ 'YÊU THÍCH': '1678524932434102',
1467
+ 'BUỒN': '908563459236466',
1468
+ 'PHẪN NỘ': '444813342392137',
1469
+ 'THƯƠNG THƯƠNG': '613557422527858',
1470
+ 'BIẾT ƠN': '1663186627268800',
1471
+ 'TỰ HÀO': '899779720071651',
1472
+ };
1473
+
1474
+ const reactionKey = reaction.toUpperCase
1475
+ ? reaction.toUpperCase()
1476
+ : reaction;
1477
+ const reactionId = REACTION_IDS[reactionKey] || REACTION_IDS[reaction];
1478
+ if (!reactionId) {
1479
+ throw new Error(
1480
+ `Unknown reaction: "${reaction}". Valid values: LIKE, LOVE, WOW, HAHA, SAD, ANGRY, CARE, SUPPORT, DOROTHY, TOTO`,
1481
+ );
1482
+ }
1483
+
1484
+ // ── Normalise feedbackId → base64 "feedback:<postID>" format ───────
1485
+ let encodedFeedbackId = feedbackId;
1486
+
1487
+ // Plain numeric post ID → encode
1488
+ if (/^\d+$/.test(feedbackId)) {
1489
+ encodedFeedbackId = Buffer.from(`feedback:${feedbackId}`).toString(
1490
+ 'base64',
1491
+ );
1492
+ } else {
1493
+ // Try to detect if it's already a valid base64 string by decoding
1494
+ try {
1495
+ const decoded = Buffer.from(feedbackId, 'base64').toString('utf-8');
1496
+ if (!decoded.startsWith('feedback:')) {
1497
+ // Not yet a feedback base64 – wrap and encode
1498
+ const raw = feedbackId.startsWith('feedback:')
1499
+ ? feedbackId
1500
+ : `feedback:${feedbackId}`;
1501
+ encodedFeedbackId = Buffer.from(raw).toString('base64');
1502
+ }
1503
+ // else already properly encoded – keep as-is
1504
+ } catch (_) {
1505
+ // keep original value
1506
+ }
1507
+ }
1508
+
1509
+ // ── Build session IDs ──────────────────────────────────────────────
1510
+ const crypto = require('node:crypto');
1511
+ const sessionId = crypto.randomUUID
1512
+ ? crypto.randomUUID()
1513
+ : [
1514
+ crypto.randomBytes(4).toString('hex'),
1515
+ crypto.randomBytes(2).toString('hex'),
1516
+ crypto.randomBytes(2).toString('hex'),
1517
+ crypto.randomBytes(2).toString('hex'),
1518
+ crypto.randomBytes(6).toString('hex'),
1519
+ ].join('-');
1520
+ const downstreamSessionId = crypto.randomUUID
1521
+ ? crypto.randomUUID()
1522
+ : [
1523
+ crypto.randomBytes(4).toString('hex'),
1524
+ crypto.randomBytes(2).toString('hex'),
1525
+ crypto.randomBytes(2).toString('hex'),
1526
+ crypto.randomBytes(2).toString('hex'),
1527
+ crypto.randomBytes(6).toString('hex'),
1528
+ ].join('-');
1529
+
1530
+ // ── Variables payload (mirrors CometUFIFeedbackReactMutation) ──────
1531
+ const variables = {
1532
+ input: {
1533
+ // attribution_id_v2 identifies the React component tree that triggered
1534
+ // the reaction (format: "<Component>,<surface>,<trigger>,<ts>,<seq>,<groupID>,,").
1535
+ // Pass ctx.attribution_id_v2 when you have it from a page scrape.
1536
+ attribution_id_v2: ctx.attribution_id_v2 || '',
1537
+ feedback_id: encodedFeedbackId,
1538
+ feedback_reaction_id: reactionId,
1539
+ feedback_source: 'OBJECT',
1540
+ is_tracking_encrypted: true,
1541
+ // tracking is an encrypted server-generated token array.
1542
+ // Carry it through ctx when available; fall back to [].
1543
+ tracking:
1544
+ Array.isArray(ctx.tracking) && ctx.tracking.length
1545
+ ? ctx.tracking
1546
+ : [],
1547
+ session_id: sessionId,
1548
+ downstream_share_session_id: downstreamSessionId,
1549
+ // The page URL where the reaction was performed
1550
+ downstream_share_session_origin_uri:
1551
+ ctx.currentUrl || 'https://www.facebook.com/',
1552
+ downstream_share_session_start_time: String(Date.now()),
1553
+ actor_id: ctx.userID,
1554
+ client_mutation_id: String(Math.floor(Math.random() * 10)),
1555
+ },
1556
+ useDefaultActor: false,
1557
+ __relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider: false,
1558
+ };
1559
+
1560
+ // ── POST to /api/graphql/ ──────────────────────────────────────────
1561
+ // Includes every form field present in the original captured request.
1562
+ const lsd = ctx.lsd || ctx.fb_dtsg;
1563
+ const form = {
1564
+ av: ctx.userID,
1565
+ __aaid: '0',
1566
+ __user: ctx.userID,
1567
+ __a: '1',
1568
+ __req: Math.floor(Math.random() * 99).toString(36),
1569
+ // Opaque session-state tokens generated by the browser on every page
1570
+ // load. Carry them through ctx when available so they match the live
1571
+ // session; otherwise fall back to empty strings (FB tolerates it).
1572
+ __hs: ctx.hs || '20536.HCSV2:comet_pkg.2.1...0',
1573
+ dpr: '1',
1574
+ __ccg: 'EXCELLENT',
1575
+ __rev: ctx.revision || '',
1576
+ __s: ctx.__s || '',
1577
+ __hsi: ctx.__hsi || '',
1578
+ __dyn: ctx.__dyn || '',
1579
+ __csr: ctx.__csr || '',
1580
+ __hsdp: ctx.__hsdp || '',
1581
+ __hblp: ctx.__hblp || '',
1582
+ __sjsp: ctx.__sjsp || '',
1583
+ __comet_req: '15',
1584
+ fb_dtsg: ctx.fb_dtsg,
1585
+ jazoest: ctx.jazoest,
1586
+ lsd,
1587
+ __spin_r: ctx.revision || '',
1588
+ __spin_b: 'trunk',
1589
+ __spin_t: Math.floor(Date.now() / 1000).toString(),
1590
+ // __crn is the Comet route name (page type). Use ctx value or a safe
1591
+ // default that works for both feed and group pages.
1592
+ __crn: ctx.__crn || 'comet.fbweb.CometHomeRoute',
1593
+ fb_api_caller_class: 'RelayModern',
1594
+ fb_api_req_friendly_name: 'CometUFIFeedbackReactMutation',
1595
+ server_timestamps: 'true',
1596
+ variables: JSON.stringify(variables),
1597
+ doc_id: '34430477113234631',
1598
+ };
1599
+
1600
+ const response = await defaultFuncs
1601
+ .post('https://www.facebook.com/api/graphql/', ctx.jar, form, {})
1602
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
1603
+
1604
+ if (response.errors) {
1605
+ throw new Error(JSON.stringify(response.errors));
1606
+ }
1607
+
1608
+ const result = {
1609
+ success: true,
1610
+ reaction: reactionKey,
1611
+ reactionId,
1612
+ feedbackId: encodedFeedbackId,
1613
+ data:
1614
+ response?.data?.feedback_react ||
1615
+ response?.data?.story_act ||
1616
+ response?.data ||
1617
+ response,
1618
+ };
1619
+
1620
+ if (callback) {
1621
+ callback(null, result);
1622
+ }
1623
+
1624
+ return result;
1625
+ } catch (err) {
1626
+ utils.error('reactToPost', err);
1627
+ if (callback) {
1628
+ callback(err);
1629
+ }
1630
+ throw err;
1631
+ }
1632
+ }
1633
+
1634
+ // ── Helper: parse CometSinglePostDialogContentQuery response ───────────
1635
+ function _extractTrackingFromNode(node: any): string[] {
1636
+ if (Array.isArray(node.tracking)) return node.tracking.filter(Boolean);
1637
+ if (node.tracking && typeof node.tracking === 'string')
1638
+ return [node.tracking];
1639
+ return [];
1640
+ }
1641
+
1642
+ function _extractFeedbackTarget(
1643
+ feedbackStory: any,
1644
+ ): { feedback_id: string | null; post_url: string | null } {
1645
+ const ufiCtx =
1646
+ feedbackStory.story_ufi_container?.story?.feedback_context ??
1647
+ feedbackStory.feedback_context ??
1648
+ null;
1649
+ if (!ufiCtx) return { feedback_id: null, post_url: null };
1650
+
1651
+ const feedbackTarget =
1652
+ ufiCtx.feedback_target_with_context ??
1653
+ ufiCtx.ufi_renderer?.feedback ??
1654
+ null;
1655
+
1656
+ return {
1657
+ feedback_id: feedbackTarget?.id ?? null,
1658
+ post_url: feedbackTarget?.url ?? null,
1659
+ };
1660
+ }
1661
+
1662
+ function _extractPostInfoFields(
1663
+ response: any,
1664
+ userID: string,
1665
+ ): {
1666
+ tracking: string[];
1667
+ feedback_id: string | null;
1668
+ attribution_id_v2: string;
1669
+ post_url: string | null;
1670
+ } {
1671
+ try {
1672
+ const node = response?.data?.node ?? response?.data?.node_v2 ?? null;
1673
+ if (!node)
1674
+ return {
1675
+ tracking: [],
1676
+ feedback_id: null,
1677
+ attribution_id_v2: '',
1678
+ post_url: null,
1679
+ };
1680
+
1681
+ let tracking = _extractTrackingFromNode(node);
1682
+ let feedback_id: string | null = null;
1683
+ let post_url: string | null = null;
1684
+ let attribution_id_v2 = '';
1685
+
1686
+ const feedbackStory =
1687
+ node.comet_sections?.feedback?.story ??
1688
+ node.comet_sections?.content?.story ??
1689
+ null;
1690
+
1691
+ if (feedbackStory) {
1692
+ // Prefer tracking from UFI story (more specific)
1693
+ const storyTracking = _extractTrackingFromNode(feedbackStory);
1694
+ if (storyTracking.length > 0) tracking = storyTracking;
1695
+
1696
+ post_url = feedbackStory.permalink ?? feedbackStory.url ?? null;
1697
+
1698
+ // Build attribution_id_v2 matching live browser format
1699
+ const ts = Date.now();
1700
+ const seq = Math.floor(Math.random() * 999999);
1701
+ attribution_id_v2 =
1702
+ `CometSinglePostDialogRoot.react,comet.post.single_dialog,unexpected,${ts},${seq},,,;` +
1703
+ `CometHomeRoot.react,comet.home,logo,${ts - 5000},${seq +
1704
+ 1},${userID},229#230#301,`;
1705
+
1706
+ const target = _extractFeedbackTarget(feedbackStory);
1707
+ feedback_id = target.feedback_id;
1708
+ if (target.post_url) post_url = target.post_url;
1709
+ }
1710
+
1711
+ // Fallback: deep path from node directly
1712
+ if (!feedback_id) {
1713
+ const fb =
1714
+ node.comet_sections?.feedback?.story?.story_ufi_container?.story
1715
+ ?.feedback_context?.feedback_target_with_context ?? null;
1716
+ if (fb?.id) feedback_id = fb.id;
1717
+ }
1718
+
1719
+ return { tracking, feedback_id, attribution_id_v2, post_url };
1720
+ } catch {
1721
+ return {
1722
+ tracking: [],
1723
+ feedback_id: null,
1724
+ attribution_id_v2: '',
1725
+ post_url: null,
1726
+ };
1727
+ }
1728
+ }
1729
+
1730
+ /**
1731
+
1732
+ * Fetch post metadata using CometSinglePostDialogContentQuery.
1733
+ * Returns the `tracking` token array, `feedback_id`, and `attribution_id_v2`
1734
+ * required for accurate reactions via reactToPost().
1735
+ *
1736
+ * @param {string} storyID - The story node ID (base64-encoded, e.g. "UzpfS...").
1737
+ * Can also be a raw numeric post ID — will be auto-encoded.
1738
+ * @param {Function} [callback] - Optional callback function.
1739
+ * @returns {Promise<object>} Object with tracking, feedback_id, attribution_id_v2, and raw data.
1740
+ *
1741
+ * @example
1742
+ * // Get tracking token then react
1743
+ * const info = await api.create.getPostInfo('1510599910435387');
1744
+ * await api.create.reactPost(info.feedback_id, 'LIKE');
1745
+ */
1746
+ async function getPostInfo(
1747
+ storyID: string,
1748
+ callback?: Function,
1749
+ ): Promise<any> {
1750
+ let _resolve: (v: any) => void;
1751
+ let _reject: (e: any) => void;
1752
+
1753
+ const returnPromise = new Promise<any>((res, rej) => {
1754
+ _resolve = res;
1755
+ _reject = rej;
1756
+ });
1757
+
1758
+ const done: Function =
1759
+ callback ??
1760
+ ((err: any, data: any) => {
1761
+ if (err) return _reject(err);
1762
+ _resolve(data);
1763
+ });
1764
+
1765
+ try {
1766
+ if (!storyID) {
1767
+ throw new Error('storyID is required.');
1768
+ }
1769
+
1770
+ // ── Encode storyID if it's a raw numeric post ID ──────────────────
1771
+ // Facebook storyID format: base64("S:<ownerID>:<postID>:<postID>") or
1772
+ // base64("UzpfS<ownerID>:<postID>:<postID>")
1773
+ // If user passes a plain numeric ID, encode it as feedback ID format
1774
+ // and let the server return the full story node.
1775
+ // Usually users pass the post numeric ID (story_fbid), so we build
1776
+ // the proper base64 story node ID: "S:f<userID>:<postID>:<postID>"
1777
+ let encodedStoryID = storyID;
1778
+ if (/^\d+$/.test(storyID)) {
1779
+ // Raw numeric post ID → build story node ID: S:f<actorID>:<postID>:<postID>
1780
+ const nodeString = `S:f${ctx.userID}:${storyID}:${storyID}`;
1781
+ const b64 = Buffer.from(nodeString)
1782
+ .toString('base64')
1783
+ .replaceAll('+', '-')
1784
+ .replaceAll('/', '_')
1785
+ .replaceAll('=', '');
1786
+ const pad = b64.length % 4;
1787
+ encodedStoryID = pad ? b64 + '='.repeat(4 - pad) : b64;
1788
+ }
1789
+
1790
+ // ── Variables matching CometSinglePostDialogContentQuery ──────────
1791
+ const variables = {
1792
+ feedbackSource: 2, // POST_PERMALINK_DIALOG
1793
+ feedLocation: 'POST_PERMALINK_DIALOG',
1794
+ focusCommentID: null,
1795
+ privacySelectorRenderLocation: 'COMET_STREAM',
1796
+ renderLocation: 'permalink',
1797
+ scale: 1,
1798
+ shouldChangeNodeFieldName: true,
1799
+ storyID: encodedStoryID,
1800
+ useDefaultActor: false,
1801
+ // Relay provider flags (copy from live request)
1802
+ __relay_internal__pv__GHLShouldChangeAdIdFieldNamerelayprovider: true,
1803
+ __relay_internal__pv__GHLShouldChangeSponsoredDataFieldNamerelayprovider: true,
1804
+ __relay_internal__pv__CometFeedStory_enable_post_permalink_white_space_clickrelayprovider: false,
1805
+ __relay_internal__pv__CometUFICommentActionLinksRewriteEnabledrelayprovider: false,
1806
+ __relay_internal__pv__CometUFICommentAvatarStickerAnimatedImagerelayprovider: false,
1807
+ __relay_internal__pv__IsWorkUserrelayprovider: false,
1808
+ __relay_internal__pv__TestPilotShouldIncludeDemoAdUseCaserelayprovider: false,
1809
+ __relay_internal__pv__FBReels_deprecate_short_form_video_context_gkrelayprovider: true,
1810
+ __relay_internal__pv__FBReels_enable_view_dubbed_audio_type_gkrelayprovider: true,
1811
+ __relay_internal__pv__CometImmersivePhotoCanUserDisable3DMotionrelayprovider: false,
1812
+ __relay_internal__pv__WorkCometIsEmployeeGKProviderrelayprovider: false,
1813
+ __relay_internal__pv__IsMergQAPollsrelayprovider: false,
1814
+ __relay_internal__pv__FBReelsMediaFooter_comet_enable_reels_ads_gkrelayprovider: true,
1815
+ __relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider: false,
1816
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider:
1817
+ 'ORIGINAL',
1818
+ __relay_internal__pv__CometUFIShareActionMigrationrelayprovider: true,
1819
+ __relay_internal__pv__CometUFISingleLineUFIrelayprovider: false,
1820
+ __relay_internal__pv__CometUFI_dedicated_comment_routable_dialog_gkrelayprovider: true,
1821
+ __relay_internal__pv__FBReelsIFUTileContent_reelsIFUPlayOnHoverrelayprovider: true,
1822
+ __relay_internal__pv__GroupsCometGYSJFeedItemHeightrelayprovider: 150,
1823
+ __relay_internal__pv__ShouldEnableBakedInTextStoriesrelayprovider: false,
1824
+ __relay_internal__pv__StoriesShouldIncludeFbNotesrelayprovider: false,
1825
+ };
1826
+
1827
+ const lsd = ctx.lsd || ctx.fb_dtsg;
1828
+ const form = {
1829
+ av: ctx.userID,
1830
+ __aaid: '0',
1831
+ __user: ctx.userID,
1832
+ __a: '1',
1833
+ __req: Math.floor(Math.random() * 99).toString(36),
1834
+ __hs: ctx.hs || '20536.HCSV2:comet_pkg.2.1...0',
1835
+ dpr: '1',
1836
+ __ccg: 'EXCELLENT',
1837
+ __rev: ctx.revision || '',
1838
+ __s: ctx.__s || '',
1839
+ __hsi: ctx.__hsi || '',
1840
+ __dyn: ctx.__dyn || '',
1841
+ __csr: ctx.__csr || '',
1842
+ __hsdp: ctx.__hsdp || '',
1843
+ __hblp: ctx.__hblp || '',
1844
+ __sjsp: ctx.__sjsp || '',
1845
+ __comet_req: '15',
1846
+ fb_dtsg: ctx.fb_dtsg,
1847
+ jazoest: ctx.jazoest,
1848
+ lsd,
1849
+ __spin_r: ctx.revision || '',
1850
+ __spin_b: 'trunk',
1851
+ __spin_t: Math.floor(Date.now() / 1000).toString(),
1852
+ __crn: ctx.__crn || 'comet.fbweb.CometHomeRoute',
1853
+ fb_api_caller_class: 'RelayModern',
1854
+ fb_api_req_friendly_name: 'CometSinglePostDialogContentQuery',
1855
+ server_timestamps: 'true',
1856
+ variables: JSON.stringify(variables),
1857
+ doc_id: '26187019677574345',
1858
+ };
1859
+
1860
+ const response = await defaultFuncs
1861
+ .post('https://www.facebook.com/api/graphql/', ctx.jar, form, {})
1862
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
1863
+
1864
+ if (response.errors) {
1865
+ throw new Error(JSON.stringify(response.errors));
1866
+ }
1867
+
1868
+ // ── Extract tracking, feedback_id, attribution_id_v2 from response ─
1869
+ // The response structure for CometSinglePostDialogContentQuery:
1870
+ // data.node → story node
1871
+ // .comet_sections.feedback.story → UFI (Unified Feedback Interface)
1872
+ // .story_ufi_container.story.feedback_context
1873
+ // .feedback_target_with_context
1874
+ // .id → feedback_id (base64)
1875
+ // .url → post URL
1876
+ // .tracking → encrypted tracking token array
1877
+ // .id → story graphql node ID
1878
+
1879
+ const {
1880
+ tracking,
1881
+ feedback_id,
1882
+ attribution_id_v2,
1883
+ post_url,
1884
+ } = _extractPostInfoFields(response, ctx.userID);
1885
+
1886
+ const result = {
1887
+ success: true,
1888
+ storyID: encodedStoryID,
1889
+ feedback_id,
1890
+ tracking,
1891
+ attribution_id_v2,
1892
+ post_url,
1893
+ data: response,
1894
+ };
1895
+
1896
+ done(null, result);
1897
+ } catch (err) {
1898
+ utils.error('getPostInfo', err);
1899
+ done(err);
1900
+ }
1901
+
1902
+ return returnPromise;
1903
+ }
1904
+
1905
+ /**
1906
+ * React to a post with full tracking accuracy.
1907
+ *
1908
+ * Combines `getPostInfo` + `reactToPost` in one call:
1909
+ * 1. Fetches real `tracking` tokens and `feedback_id` from Facebook via
1910
+ * `CometSinglePostDialogContentQuery`.
1911
+ * 2. Patches those values into `ctx` so `reactToPost` sends them correctly.
1912
+ * 3. Falls back gracefully if `getPostInfo` fails (uses original IDs).
1913
+ *
1914
+ * @param {string} storyID - Raw numeric post ID **or** base64 storyID.
1915
+ * Examples: `'1510599910435387'` or `'UzpfSTEwMDA0...'`
1916
+ * @param {string} [reaction='LIKE'] - Reaction type.
1917
+ * Valid: `'LIKE'|'LOVE'|'HAHA'|'WOW'|'SAD'|'ANGRY'|'CARE'`
1918
+ * or emoji aliases: `'👍'|'❤️'|'😂'|'😮'|'😢'|'😡'|'🤗'`
1919
+ * @param {Function} [callback] - Optional node-style callback `(err, result)`.
1920
+ * @returns {Promise<object>} Same shape as `reactToPost()`.
1921
+ *
1922
+ * @example
1923
+ * // Easiest usage — just pass the numeric post ID
1924
+ * const result = await api.post.reactWithInfo('1510599910435387', 'LOVE');
1925
+ *
1926
+ * // With callback
1927
+ * api.post.reactWithInfo('1510599910435387', 'HAHA', (err, res) => {
1928
+ * if (err) console.error(err);
1929
+ * else console.log('Reacted!', res);
1930
+ * });
1931
+ */
1932
+ async function reactToPostWithTracking(
1933
+ storyID: string,
1934
+ reaction: string = 'LIKE',
1935
+ callback?: Function,
1936
+ ): Promise<any> {
1937
+ try {
1938
+ if (!storyID) throw new Error('storyID is required.');
1939
+
1940
+ // ── Step 1: fetch tracking info from Facebook ──────────────────────
1941
+ let feedbackId = storyID; // fallback: use storyID as-is
1942
+ let infoErr: any = null;
1943
+
1944
+ try {
1945
+ const info = await getPostInfo(storyID);
1946
+
1947
+ // Patch tracking + attribution_id_v2 into ctx so reactToPost uses them
1948
+ if (info.tracking?.length) ctx.tracking = info.tracking;
1949
+ if (info.attribution_id_v2)
1950
+ ctx.attribution_id_v2 = info.attribution_id_v2;
1951
+ if (info.post_url) ctx.currentUrl = info.post_url;
1952
+
1953
+ // Prefer server-provided feedback_id; fall back to storyID
1954
+ if (info.feedback_id) feedbackId = info.feedback_id;
1955
+ } catch (err) {
1956
+ infoErr = err;
1957
+ utils.warn(
1958
+ 'reactToPostWithTracking',
1959
+ `getPostInfo failed (falling back to storyID as feedbackId): ${err}`,
1960
+ );
1961
+ }
1962
+
1963
+ // ── Step 2: react using the enriched ctx ───────────────────────────
1964
+ const result = await reactToPost(feedbackId, reaction);
1965
+
1966
+ // Attach info-fetch error to result so callers can inspect it
1967
+ if (infoErr) (result as any).infoError = String(infoErr);
1968
+
1969
+ if (callback) callback(null, result);
1970
+ return result;
1971
+ } catch (err) {
1972
+ utils.error('reactToPostWithTracking', err);
1973
+ if (callback) callback(err);
1974
+ throw err;
1975
+ }
1976
+ }
1977
+
1978
+ // ── pfbid (Permalink-Friendly Base ID) ───────────────────────────────────
1979
+ // pfbid is an opaque server-side token that Facebook generates.
1980
+ // It CANNOT be decoded client-side (it's internally hashed/encrypted).
1981
+ //
1982
+ // The correct workflow:
1983
+ // parsePostUrl(url) → extracts the pfbid token from the URL path
1984
+ // getPostInfo(token) → sends pfbid to Facebook's GraphQL; server resolves it
1985
+ // and returns the real numeric story_fbid in the response
1986
+ //
1987
+ // The returned storyID from parsePostUrl is either:
1988
+ // - A numeric string (e.g. "1510599910435387") → for /posts/{numericID} URLs
1989
+ // - A pfbid string (e.g. "pfbid0Yo6XDa...") → for /posts/pfbid{token} URLs
1990
+ //
1991
+ // Both work as input to getPostInfo / reactWithInfo.
1992
+
1993
+ /**
1994
+ * Parse any Facebook post URL into a storyID suitable for `getPostInfo`.
1995
+ *
1996
+ * Supports all common URL patterns:
1997
+ * - `https://www.facebook.com/{page}/posts/pfbid{token}` ← pfbid (new style)
1998
+ * - `https://www.facebook.com/{page}/posts/{numericID}` ← plain numeric
1999
+ * - `https://www.facebook.com/permalink.php?story_fbid={id}&id={page}`
2000
+ * - `https://www.facebook.com/photo?fbid={id}&...`
2001
+ * - `https://m.facebook.com/story.php?story_fbid={id}&...`
2002
+ *
2003
+ * @param {string} url - Any Facebook post URL.
2004
+ * @returns {{ storyID: string; pageSlug: string | null; format: 'pfbid'|'numeric'|'query' }}
2005
+ * `storyID` is either a numeric string or the full pfbid token (including "pfbid" prefix).
2006
+ * Pass it directly to `getPostInfo()` or `reactWithInfo()`.
2007
+ * @throws If no recognisable post ID can be extracted.
2008
+ *
2009
+ * @example
2010
+ * // pfbid URL → storyID is the pfbid token; pass it to getPostInfo which resolves it server-side
2011
+ * const { storyID } = parsePostUrl('https://www.facebook.com/tuyetcollection/posts/pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl');
2012
+ * // storyID = 'pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl'
2013
+ * const info = await api.post.getPostInfo(storyID); // Facebook resolves it → real ID in response
2014
+ * await api.post.reactToPost(info.feedback_id, 'LOVE');
2015
+ *
2016
+ * // Easiest: reactWithInfo handles all of this automatically
2017
+ * await api.post.reactWithInfo(storyID, 'LOVE');
2018
+ *
2019
+ * // Numeric URL → storyID is already the numeric ID
2020
+ * const { storyID } = parsePostUrl('https://www.facebook.com/tuyetcollection/posts/1510599910435387');
2021
+ * // storyID = '1510599910435387'
2022
+ */
2023
+ function parsePostUrl(
2024
+ url: string,
2025
+ ): { storyID: string; pageSlug: string | null; format: string } {
2026
+ let parsed: URL;
2027
+ try {
2028
+ parsed = new URL(url);
2029
+ } catch {
2030
+ throw new Error(`parsePostUrl: invalid URL "${url}"`);
2031
+ }
2032
+
2033
+ const path = parsed.pathname; // e.g. /tuyetcollection/posts/pfbid0Yo6...
2034
+ const segments = path.split('/').filter(Boolean);
2035
+
2036
+ // ── Pattern 1: /{slug}/posts/{id_or_pfbid} ───────────────────────────
2037
+ const postIdx = segments.indexOf('posts');
2038
+ if (postIdx !== -1 && segments[postIdx + 1]) {
2039
+ const token = segments[postIdx + 1];
2040
+ const pageSlug = postIdx > 0 ? segments[postIdx - 1] : null;
2041
+
2042
+ if (token.startsWith('pfbid')) {
2043
+ // pfbid is an opaque server token — cannot be decoded client-side.
2044
+ // Return the full pfbid as storyID; Facebook GraphQL resolves it server-side.
2045
+ return { storyID: token, pageSlug, format: 'pfbid' };
2046
+ }
2047
+ if (/^\d+$/.test(token)) {
2048
+ return { storyID: token, pageSlug, format: 'numeric' };
2049
+ }
2050
+ }
2051
+
2052
+ // ── Pattern 2: /photo/{numericID} or /video/{numericID} ──────────────
2053
+ for (const seg of ['photo', 'video', 'reel']) {
2054
+ const idx = segments.indexOf(seg);
2055
+ if (idx !== -1 && segments[idx + 1] && /^\d+$/.test(segments[idx + 1])) {
2056
+ return {
2057
+ storyID: segments[idx + 1],
2058
+ pageSlug: null,
2059
+ format: 'numeric',
2060
+ };
2061
+ }
2062
+ }
2063
+
2064
+ // ── Pattern 3: query params ───────────────────────────────────────────
2065
+ // permalink.php?story_fbid=&id= | photo?fbid= | story.php?story_fbid=
2066
+ const storyFbid = parsed.searchParams.get('story_fbid');
2067
+ if (storyFbid && /^\d+$/.test(storyFbid)) {
2068
+ return { storyID: storyFbid, pageSlug: null, format: 'query' };
2069
+ }
2070
+
2071
+ const fbid = parsed.searchParams.get('fbid');
2072
+ if (fbid && /^\d+$/.test(fbid)) {
2073
+ return { storyID: fbid, pageSlug: null, format: 'query' };
2074
+ }
2075
+
2076
+ throw new Error(`parsePostUrl: could not extract post ID from "${url}"`);
2077
+ }
2078
+
1029
2079
  return {
1030
2080
  create: createPost,
1031
2081
  createPost: createPost,
1032
2082
  delete: deletePost,
1033
2083
  getComments: getPostComments,
2084
+ getPostInfo: getPostInfo,
2085
+ getPostIDFromURL: getPostIDFromURL,
2086
+ parsePostUrl: parsePostUrl,
2087
+ react: reactToPost,
2088
+ reactToPost: reactToPost,
2089
+ reactWithInfo: reactToPostWithTracking,
2090
+ reactToPostWithTracking: reactToPostWithTracking,
1034
2091
  uploadPhoto: uploadPhoto,
1035
2092
  uploadVideo: uploadVideo,
1036
2093
  };