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.
@@ -59,6 +59,7 @@ function default_1(defaultFuncs, api, ctx) {
59
59
  let privacy = 'SELF';
60
60
  let photos = [];
61
61
  let videoId = null;
62
+ let groupID = null;
62
63
  if (typeof options === 'string') {
63
64
  postMessage = options;
64
65
  }
@@ -67,6 +68,7 @@ function default_1(defaultFuncs, api, ctx) {
67
68
  privacy = options.privacy || 'SELF';
68
69
  photos = options.photos || [];
69
70
  videoId = options.video || null;
71
+ groupID = options.groupID || null;
70
72
  }
71
73
  else {
72
74
  throw new Error('Invalid input: expected string or object.');
@@ -90,10 +92,11 @@ function default_1(defaultFuncs, api, ctx) {
90
92
  .toString(36)
91
93
  .substring(2)}`;
92
94
  const idempotenceToken = `${composerSessionId}_FEED`;
95
+ const isGroup = !!groupID;
93
96
  const variables = {
94
97
  input: {
95
98
  composer_entry_point: 'inline_composer',
96
- composer_source_surface: 'newsfeed',
99
+ composer_source_surface: isGroup ? 'group' : 'newsfeed',
97
100
  composer_type: 'feed',
98
101
  idempotence_token: idempotenceToken,
99
102
  source: 'WWW',
@@ -128,11 +131,11 @@ function default_1(defaultFuncs, api, ctx) {
128
131
  actor_id: ctx.userID,
129
132
  client_mutation_id: '1',
130
133
  },
131
- feedLocation: 'NEWSFEED',
134
+ feedLocation: isGroup ? 'GROUP' : 'NEWSFEED',
132
135
  feedbackSource: 1,
133
136
  focusCommentID: null,
134
137
  gridMediaWidth: null,
135
- groupID: null,
138
+ groupID: groupID,
136
139
  scale: 2,
137
140
  privacySelectorRenderLocation: 'COMET_STREAM',
138
141
  checkPhotosToReelsUpsellEligibility: true,
@@ -143,7 +146,7 @@ function default_1(defaultFuncs, api, ctx) {
143
146
  isFeed: true,
144
147
  isFundraiser: false,
145
148
  isFunFactPost: false,
146
- isGroup: false,
149
+ isGroup: isGroup,
147
150
  isEvent: false,
148
151
  isTimeline: false,
149
152
  isSocialLearning: false,
@@ -300,14 +303,26 @@ function default_1(defaultFuncs, api, ctx) {
300
303
  }
301
304
  }
302
305
  /**
303
- * Gets comments from a Facebook post.
304
- * @param {string|object} postID - The post ID (string) or options object.
305
- * @param {string} postID.story_fbid - The story FBID from the permalink URL.
306
- * @param {string} postID.id - The account ID from the permalink URL.
306
+ * Gets comments from a Facebook post, with pagination support.
307
+ * @param {string|object} postID - The post URL (string) or options object.
308
+ * @param {string} [postID.url] - Direct Facebook post URL.
309
+ * @param {string} [postID.story_fbid] - The story FBID.
310
+ * @param {string} [postID.id] - The account ID.
311
+ * @param {string} [postID.after] - Cursor string to fetch the next page.
312
+ * @param {string} [postID.feedback_id] - Feedback ID required for pagination (returned in page_info).
307
313
  * @param {Function} [callback] - Optional callback function.
308
- * @returns {Promise<Array>} Array of comments with author info, text, timestamps, etc.
314
+ * @returns {Promise<{comments: Array, page_info: object}>} Object with comments array and pagination info.
309
315
  */
310
- async function getPostComments(postID, callback) {
316
+ async function getPostComments(postID, optionsOrCallback, callback) {
317
+ // Support: getComments(url), getComments(url, cb), getComments(url, opts), getComments(url, opts, cb)
318
+ let paginationOpts = null;
319
+ if (typeof optionsOrCallback === 'function') {
320
+ callback = optionsOrCallback;
321
+ }
322
+ else if (typeof optionsOrCallback === 'object' &&
323
+ optionsOrCallback !== null) {
324
+ paginationOpts = optionsOrCallback;
325
+ }
311
326
  let resolveFunc = function () { };
312
327
  let rejectFunc = function () { };
313
328
  const returnPromise = new Promise(function (resolve, reject) {
@@ -322,31 +337,153 @@ function default_1(defaultFuncs, api, ctx) {
322
337
  resolveFunc(data);
323
338
  };
324
339
  try {
325
- let story_fbid, accountID;
326
- // Handle both string and object input
340
+ // ── Pagination mode: fetch next page via GraphQL ──────────────────
341
+ // Merge pagination from either postID (object) or second-arg options
342
+ const afterCursor = paginationOpts?.after ||
343
+ (typeof postID === 'object' && postID !== null
344
+ ? postID.after
345
+ : undefined);
346
+ const feedbackId = paginationOpts?.feedback_id ||
347
+ (typeof postID === 'object' && postID !== null
348
+ ? postID.feedback_id
349
+ : undefined);
350
+ if (afterCursor && feedbackId) {
351
+ // Use GraphQL to fetch next page of comments using the cursor
352
+ // feedbackId is the GraphQL ID (base64 of "feedback:<numeric_id>")
353
+ const variables = {
354
+ commentsAfterCount: -1,
355
+ commentsAfterCursor: afterCursor,
356
+ commentsBeforeCount: null,
357
+ commentsBeforeCursor: null,
358
+ commentsIntentToken: null,
359
+ feedLocation: 'POST_PERMALINK_DIALOG',
360
+ focusCommentID: null,
361
+ scale: 1,
362
+ useDefaultActor: false,
363
+ id: feedbackId,
364
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider: 'ORIGINAL',
365
+ __relay_internal__pv__CometUFICommentAvatarStickerAnimatedImagerelayprovider: false,
366
+ __relay_internal__pv__CometUFICommentActionLinksRewriteEnabledrelayprovider: false,
367
+ __relay_internal__pv__IsWorkUserrelayprovider: false,
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
+ const gqlRes = await defaultFuncs.post('https://www.facebook.com/api/graphql/', ctx.jar, form, ctx.globalOptions, ctx);
378
+ let gqlData;
379
+ if (typeof gqlRes.body === 'object' &&
380
+ gqlRes.body !== null &&
381
+ !Buffer.isBuffer(gqlRes.body)) {
382
+ gqlData = gqlRes.body;
383
+ }
384
+ else {
385
+ const body = gqlRes.body.toString();
386
+ try {
387
+ gqlData = JSON.parse(body);
388
+ }
389
+ catch (e) {
390
+ // Multi-line JSON stream — take first line
391
+ gqlData = JSON.parse(body.split('\n')[0]);
392
+ }
393
+ }
394
+ // Navigate to comments in GraphQL response
395
+ const commentsConnection = gqlData?.data?.node?.comment_rendering_instance_for_feed_location
396
+ ?.comments ??
397
+ gqlData?.data?.feedback?.comment_rendering_instance_for_feed_location
398
+ ?.comments ??
399
+ null;
400
+ const result = [];
401
+ let page_info = null;
402
+ if (commentsConnection) {
403
+ page_info = commentsConnection.page_info || null;
404
+ (commentsConnection.edges || []).forEach((edge) => {
405
+ if (!edge?.node)
406
+ return;
407
+ const c = {
408
+ id: edge.node.legacy_fbid,
409
+ graphql_id: edge.node.id,
410
+ text: edge.node.body?.text || edge.node.preferred_body?.text || '',
411
+ created_time: edge.node.created_time,
412
+ author: {
413
+ id: edge.node.author?.id,
414
+ name: edge.node.author?.name,
415
+ avatar: edge.node.author?.profile_picture_depth_0?.uri,
416
+ },
417
+ reply_count: edge.node.feedback?.replies_fields?.count || 0,
418
+ total_reply_count: edge.node.feedback?.replies_fields?.total_count || 0,
419
+ depth: edge.node.depth || 0,
420
+ attachments: edge.node.attachments || [],
421
+ };
422
+ if (edge.node.feedback?.replies_connection?.edges) {
423
+ c.replies = edge.node.feedback.replies_connection.edges
424
+ .map((re) => {
425
+ if (!re?.node)
426
+ return null;
427
+ return {
428
+ id: re.node.legacy_fbid,
429
+ text: re.node.body?.text || re.node.preferred_body?.text || '',
430
+ created_time: re.node.created_time,
431
+ author: {
432
+ id: re.node.author?.id,
433
+ name: re.node.author?.name,
434
+ avatar: re.node.author?.profile_picture_depth_0?.uri,
435
+ },
436
+ };
437
+ })
438
+ .filter((r) => r !== null);
439
+ }
440
+ result.push(c);
441
+ });
442
+ }
443
+ callback(null, { comments: result, page_info });
444
+ return returnPromise;
445
+ }
446
+ // ── Initial load mode: fetch HTML and parse ───────────────────────
447
+ let url;
327
448
  if (typeof postID === 'string') {
328
- // If it's a simple post ID, construct URL with user's ID
329
- story_fbid = postID;
330
- accountID = ctx.userID;
449
+ if (postID.startsWith('https://') || postID.startsWith('http://')) {
450
+ url = postID;
451
+ }
452
+ else if (postID.startsWith('/')) {
453
+ url = `https://www.facebook.com${postID}`;
454
+ }
455
+ else if (/^\d+$/.test(postID)) {
456
+ url = `https://www.facebook.com/permalink.php?story_fbid=${postID}&id=${ctx.userID}`;
457
+ }
458
+ else {
459
+ url = `https://www.facebook.com/permalink.php?story_fbid=${postID}&id=${ctx.userID}`;
460
+ }
331
461
  }
332
- else if (typeof postID === 'object') {
333
- story_fbid = postID.story_fbid;
334
- accountID = postID.id || ctx.userID;
462
+ else if (typeof postID === 'object' && postID !== null) {
463
+ const story_fbid = postID.story_fbid;
464
+ const accountID = postID.id || ctx.userID;
465
+ const postUrl = postID.url;
466
+ if (postUrl) {
467
+ url = postUrl.startsWith('http')
468
+ ? postUrl
469
+ : `https://www.facebook.com${postUrl}`;
470
+ }
471
+ else if (story_fbid) {
472
+ url = `https://www.facebook.com/permalink.php?story_fbid=${story_fbid}&id=${accountID}`;
473
+ }
474
+ else {
475
+ throw new Error('Object input must have story_fbid or url field.');
476
+ }
335
477
  }
336
478
  else {
337
- throw new Error('Invalid input: expected string or object with story_fbid and id.');
338
- }
339
- if (!story_fbid) {
340
- throw new Error('Post ID or story_fbid is required.');
479
+ throw new Error('Invalid input: expected string (URL or postID) or object with story_fbid/url.');
341
480
  }
342
- // Construct the permalink URL
343
- const url = `https://www.facebook.com/permalink.php?story_fbid=${story_fbid}&id=${accountID}`;
344
481
  // Make GET request to fetch the page HTML with cookies
345
482
  const resData = await utils.get(url, ctx.jar, {}, ctx.globalOptions, ctx);
346
483
  const html = resData.body.toString();
347
- // Extract comments using new logic
348
- const commentList = extractCommentsFromHTML(html);
349
- callback(null, commentList);
484
+ // Extract comments and page_info from HTML
485
+ const { comments, page_info } = extractCommentsFromHTML(html);
486
+ callback(null, { comments, page_info });
350
487
  }
351
488
  catch (err) {
352
489
  utils.error('getPostComments', err);
@@ -372,14 +509,16 @@ function default_1(defaultFuncs, api, ctx) {
372
509
  /**
373
510
  * Extract comments from HTML using new parsing logic
374
511
  * @private
512
+ * @returns {{ comments: any[], page_info: any | null }}
375
513
  */
376
514
  function extractCommentsFromHTML(htmlContent) {
377
515
  const comments = [];
516
+ let page_info = null;
378
517
  try {
379
518
  // Find all script tags containing JSON data
380
519
  const scriptMatches = htmlContent.match(/<script type="application\/json"\s+data-content-len="\d+"\s+data-sjs>(.*?)<\/script>/gs);
381
520
  if (!scriptMatches) {
382
- return comments;
521
+ return { comments, page_info };
383
522
  }
384
523
  // Loop through each script tag
385
524
  for (const scriptMatch of scriptMatches) {
@@ -427,6 +566,26 @@ function default_1(defaultFuncs, api, ctx) {
427
566
  if (commentData &&
428
567
  commentData.edges &&
429
568
  Array.isArray(commentData.edges)) {
569
+ // Extract page_info (cursor pagination info)
570
+ if (commentData.page_info && !page_info) {
571
+ page_info = {
572
+ end_cursor: commentData.page_info.end_cursor || null,
573
+ has_next_page: commentData.page_info.has_next_page || false,
574
+ has_previous_page: commentData.page_info.has_previous_page || false,
575
+ start_cursor: commentData.page_info.start_cursor || null,
576
+ };
577
+ // Also try to capture feedback_id for GraphQL pagination
578
+ try {
579
+ const feedback = result.data.node_v2?.comet_sections?.feedback?.story
580
+ ?.story_ufi_container?.story?.feedback_context
581
+ ?.feedback_target_with_context
582
+ ?.comment_list_renderer?.feedback;
583
+ if (feedback?.id && !page_info.feedback_id) {
584
+ page_info.feedback_id = feedback.id;
585
+ }
586
+ }
587
+ catch (_) { }
588
+ }
430
589
  // Extract comments
431
590
  commentData.edges.forEach((edge) => {
432
591
  if (!edge.node)
@@ -489,7 +648,195 @@ function default_1(defaultFuncs, api, ctx) {
489
648
  catch (error) {
490
649
  utils.error('extractCommentsFromHTML', error);
491
650
  }
492
- return comments;
651
+ return { comments, page_info };
652
+ }
653
+ /**
654
+ * Extract the numeric `post_id` (and related IDs) from a Facebook post HTML page.
655
+ *
656
+ * This follows the same pipeline as `getPostComments`:
657
+ * 1. Fetches the page HTML with session cookies (GET request).
658
+ * 2. Scans every `<script type="application/json" data-sjs>` block.
659
+ * 3. Searches each parsed JSON tree for `"post_id"` strings (and `"story_id"`,
660
+ * `"feedback_id"`, etc.) that appear alongside the post data.
661
+ *
662
+ * Works with **all** URL formats:
663
+ * - `/posts/pfbid{token}` ← pfbid (server resolves it during HTML render)
664
+ * - `/posts/{numericID}`
665
+ * - `permalink.php?story_fbid=...`
666
+ *
667
+ * @param {string} urlOrID - Full Facebook post URL, or a plain numeric post ID.
668
+ * If a numeric ID is passed, it is fetched as
669
+ * `facebook.com/permalink.php?story_fbid={id}&id={userID}`.
670
+ * @param {Function} [callback] - Optional node-style `(err, result)` callback.
671
+ * @returns {Promise<{
672
+ * post_id: string | null, // numeric post ID found in HTML ("post_id":"…")
673
+ * story_id: string | null, // numeric story/post ID (alias)
674
+ * feedback_id: string | null, // base64 GraphQL feedback ID
675
+ * post_url: string, // URL that was actually fetched
676
+ * }>}
677
+ *
678
+ * @example
679
+ * const { post_id } = await api.post.getPostIDFromURL(
680
+ * 'https://www.facebook.com/tuyetcollection/posts/pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl'
681
+ * );
682
+ * console.log(post_id); // '1510599910435387'
683
+ *
684
+ * // Then react using the real numeric ID
685
+ * await api.post.reactWithInfo(post_id, 'LOVE');
686
+ */
687
+ async function getPostIDFromURL(urlOrID, callback) {
688
+ let resolveFunc = function () { };
689
+ let rejectFunc = function () { };
690
+ const returnPromise = new Promise((resolve, reject) => {
691
+ resolveFunc = resolve;
692
+ rejectFunc = reject;
693
+ });
694
+ callback =
695
+ callback ||
696
+ function (err, data) {
697
+ if (err)
698
+ return rejectFunc(err);
699
+ resolveFunc(data);
700
+ };
701
+ try {
702
+ if (!urlOrID)
703
+ throw new Error('urlOrID is required.');
704
+ // ── Build fetch URL ────────────────────────────────────────────────
705
+ let fetchUrl;
706
+ if (urlOrID.startsWith('http://') || urlOrID.startsWith('https://')) {
707
+ fetchUrl = urlOrID;
708
+ }
709
+ else if (urlOrID.startsWith('/')) {
710
+ fetchUrl = `https://www.facebook.com${urlOrID}`;
711
+ }
712
+ else if (/^\d+$/.test(urlOrID)) {
713
+ // plain numeric ID → use permalink.php format (same as getPostComments)
714
+ fetchUrl = `https://www.facebook.com/permalink.php?story_fbid=${urlOrID}&id=${ctx.userID}`;
715
+ }
716
+ else {
717
+ // pfbid token without full URL
718
+ fetchUrl = `https://www.facebook.com/permalink.php?story_fbid=${urlOrID}&id=${ctx.userID}`;
719
+ }
720
+ // ── Fetch HTML ─────────────────────────────────────────────────────
721
+ const resData = await utils.get(fetchUrl, ctx.jar, {}, ctx.globalOptions, ctx);
722
+ const html = resData.body.toString();
723
+ // ── Helpers ────────────────────────────────────────────────────────
724
+ /** Deep-search an object tree; call visitor(value, key) on every value. */
725
+ function deepSearch(obj, visitor) {
726
+ if (!obj || typeof obj !== 'object')
727
+ return;
728
+ for (const key of Object.keys(obj)) {
729
+ visitor(obj[key], key);
730
+ deepSearch(obj[key], visitor);
731
+ }
732
+ }
733
+ /** Find the first string value of `fieldName` anywhere in `obj`. */
734
+ function findField(obj, fieldName) {
735
+ let found = null;
736
+ deepSearch(obj, (v, k) => {
737
+ if (found)
738
+ return;
739
+ if (k === fieldName && typeof v === 'string' && v.length > 0) {
740
+ found = v;
741
+ }
742
+ });
743
+ return found;
744
+ }
745
+ // ── Parse <script data-sjs> blocks (same as getPostComments) ───────
746
+ const scriptMatches = html.match(/<script type="application\/json"\s+data-content-len="\d+"\s+data-sjs>(.*?)<\/script>/gs);
747
+ let post_id = null;
748
+ let story_id = null;
749
+ let feedback_id = null;
750
+ if (scriptMatches) {
751
+ outer: for (const scriptTag of scriptMatches) {
752
+ try {
753
+ const inner = scriptTag.match(/<script[^>]*>(.*?)<\/script>/s);
754
+ if (!inner)
755
+ continue;
756
+ const jsonData = JSON.parse(inner[1]);
757
+ if (!jsonData.require || !Array.isArray(jsonData.require))
758
+ continue;
759
+ for (const req of jsonData.require) {
760
+ if (!Array.isArray(req))
761
+ continue;
762
+ if (req[0] === 'ScheduledServerJS' && req[1] === 'handle') {
763
+ const payload = req[3];
764
+ if (!payload || !Array.isArray(payload))
765
+ continue;
766
+ for (const item of payload) {
767
+ if (!item.__bbox?.require)
768
+ continue;
769
+ for (const bboxReq of item.__bbox.require) {
770
+ if (!Array.isArray(bboxReq))
771
+ continue;
772
+ if (bboxReq[0] === 'RelayPrefetchedStreamCache' &&
773
+ bboxReq[1] === 'next') {
774
+ const streamData = bboxReq[3];
775
+ if (!Array.isArray(streamData) || streamData.length < 2)
776
+ continue;
777
+ const bboxData = streamData[1];
778
+ if (!bboxData?.__bbox?.result)
779
+ continue;
780
+ const result = bboxData.__bbox.result;
781
+ // ── Search for post_id / story_id ─────────────────
782
+ if (!post_id)
783
+ post_id = findField(result, 'post_id');
784
+ if (!story_id)
785
+ story_id = findField(result, 'story_id');
786
+ // ── Search for feedback_id (same path as getPostComments) ──
787
+ if (!feedback_id) {
788
+ try {
789
+ const feedback = result.data?.node_v2?.comet_sections?.feedback
790
+ ?.story?.story_ufi_container?.story
791
+ ?.feedback_context?.feedback_target_with_context
792
+ ?.comment_list_renderer?.feedback;
793
+ if (feedback?.id)
794
+ feedback_id = feedback.id;
795
+ }
796
+ catch (_) { }
797
+ // Fallback: deep-search for feedback_id string
798
+ if (!feedback_id)
799
+ feedback_id = findField(result, 'feedback_id');
800
+ }
801
+ // Stop once we have the numeric post_id
802
+ if (post_id)
803
+ break outer;
804
+ }
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }
810
+ catch (_) {
811
+ continue; // skip malformed JSON blocks
812
+ }
813
+ }
814
+ }
815
+ // ── Fallback: regex scan on raw HTML ────────────────────────────────
816
+ // "post_id":"1510599910435387" or "post_id":1510599910435387
817
+ if (!post_id) {
818
+ const m = html.match(/"post_id"\s*:\s*"?(\d{10,20})"?/);
819
+ if (m)
820
+ post_id = m[1];
821
+ }
822
+ if (!story_id) {
823
+ const m = html.match(/"story_id"\s*:\s*"?(\d{10,20})"?/);
824
+ if (m)
825
+ story_id = m[1];
826
+ }
827
+ const resultObj = {
828
+ post_id,
829
+ story_id: story_id || post_id, // they're often the same
830
+ feedback_id,
831
+ post_url: fetchUrl,
832
+ };
833
+ callback(null, resultObj);
834
+ }
835
+ catch (err) {
836
+ utils.error('getPostIDFromURL', err);
837
+ callback(err);
838
+ }
839
+ return returnPromise;
493
840
  }
494
841
  /**
495
842
  * Upload a photo to Facebook
@@ -884,11 +1231,606 @@ function default_1(defaultFuncs, api, ctx) {
884
1231
  }
885
1232
  return returnPromise;
886
1233
  }
1234
+ /**
1235
+ * React to a Facebook post (like, love, haha, wow, sad, angry).
1236
+ *
1237
+ * @param {string} feedbackId - The feedback ID. Can be:
1238
+ * - A raw numeric post ID (e.g. "1624250182241514")
1239
+ * - A base64-encoded feedback ID (e.g. "ZmVlZGJhY2s6MTYyN...")
1240
+ * - The literal string "feedback:<postID>"
1241
+ * @param {string} [reaction="LIKE"] - Reaction type:
1242
+ * "LIKE" | "LOVE" | "HAHA" | "WOW" | "SAD" | "ANGRY" | "CARE"
1243
+ * or equivalent emojis: "👍" | "❤️" | "😂" | "😮" | "😢" | "😡" | "🤗"
1244
+ * @param {Function} [callback] - Optional callback function.
1245
+ * @returns {Promise<object>} The server response.
1246
+ */
1247
+ async function reactToPost(feedbackId, reaction = 'LIKE', callback) {
1248
+ try {
1249
+ if (!feedbackId) {
1250
+ throw new Error('feedbackId is required.');
1251
+ }
1252
+ // ── Reaction ID map ────────────────────────────────────────────────
1253
+ // IDs sourced from live Facebook reaction metadata.
1254
+ // Only non-deprecated reactions are listed as primary keys.
1255
+ // Deprecated reactions kept for backward-compat.
1256
+ const REACTION_IDS = {
1257
+ // ── Active reactions ─────────────────────────────────────────────
1258
+ LIKE: '1635855486666999', // Thích (type:1) #0866FF
1259
+ LOVE: '1678524932434102', // Yêu thích (type:2) #f33e58
1260
+ WOW: '478547315650144', // Wow (type:3) #f7b125
1261
+ HAHA: '115940658764963', // Haha (type:4) #f7b125
1262
+ SAD: '908563459236466', // Buồn (type:7) #f7b125
1263
+ ANGRY: '444813342392137', // Phẫn nộ (type:8) #e9710f
1264
+ CARE: '613557422527858', // Thương thương (type:16) #f7b125
1265
+ SUPPORT: '613557422527858', // alias CARE
1266
+ DOROTHY: '1663186627268800', // Biết ơn (type:11) #7e64c4
1267
+ TOTO: '899779720071651', // Tự hào (type:12) #ec7ebd
1268
+ // ── Deprecated (kept for backward-compat) ────────────────────────
1269
+ YAY: '1667835766830853', // Chúc mừng (type:5 - deprecated)
1270
+ CONFUSED: '1536130110011063', // Khó hiểu (type:10 - deprecated)
1271
+ SELFIE: '869508936487422', // Ảnh selfie (type:13 - deprecated)
1272
+ FLAME: '938644726258608', // Bày tỏ (flame, type:14 - deprecated)
1273
+ PLANE: '1609920819308489', // Bày tỏ (plane, type:15 - deprecated)
1274
+ // ── Emoji aliases ────────────────────────────────────────────────
1275
+ '👍': '1635855486666999', // LIKE
1276
+ '❤️': '1678524932434102', // LOVE
1277
+ '😮': '478547315650144', // WOW
1278
+ '😂': '115940658764963', // HAHA
1279
+ '😢': '908563459236466', // SAD
1280
+ '😡': '444813342392137', // ANGRY
1281
+ '🤗': '613557422527858', // CARE/Support
1282
+ // ── Vietnamese name aliases ──────────────────────────────────────
1283
+ 'THÍCH': '1635855486666999',
1284
+ 'YÊU THÍCH': '1678524932434102',
1285
+ 'BUỒN': '908563459236466',
1286
+ 'PHẪN NỘ': '444813342392137',
1287
+ 'THƯƠNG THƯƠNG': '613557422527858',
1288
+ 'BIẾT ƠN': '1663186627268800',
1289
+ 'TỰ HÀO': '899779720071651',
1290
+ };
1291
+ const reactionKey = reaction.toUpperCase
1292
+ ? reaction.toUpperCase()
1293
+ : reaction;
1294
+ const reactionId = REACTION_IDS[reactionKey] || REACTION_IDS[reaction];
1295
+ if (!reactionId) {
1296
+ throw new Error(`Unknown reaction: "${reaction}". Valid values: LIKE, LOVE, WOW, HAHA, SAD, ANGRY, CARE, SUPPORT, DOROTHY, TOTO`);
1297
+ }
1298
+ // ── Normalise feedbackId → base64 "feedback:<postID>" format ───────
1299
+ let encodedFeedbackId = feedbackId;
1300
+ // Plain numeric post ID → encode
1301
+ if (/^\d+$/.test(feedbackId)) {
1302
+ encodedFeedbackId = Buffer.from(`feedback:${feedbackId}`).toString('base64');
1303
+ }
1304
+ else {
1305
+ // Try to detect if it's already a valid base64 string by decoding
1306
+ try {
1307
+ const decoded = Buffer.from(feedbackId, 'base64').toString('utf-8');
1308
+ if (!decoded.startsWith('feedback:')) {
1309
+ // Not yet a feedback base64 – wrap and encode
1310
+ const raw = feedbackId.startsWith('feedback:')
1311
+ ? feedbackId
1312
+ : `feedback:${feedbackId}`;
1313
+ encodedFeedbackId = Buffer.from(raw).toString('base64');
1314
+ }
1315
+ // else already properly encoded – keep as-is
1316
+ }
1317
+ catch (_) {
1318
+ // keep original value
1319
+ }
1320
+ }
1321
+ // ── Build session IDs ──────────────────────────────────────────────
1322
+ const crypto = require('node:crypto');
1323
+ const sessionId = crypto.randomUUID
1324
+ ? crypto.randomUUID()
1325
+ : [
1326
+ crypto.randomBytes(4).toString('hex'),
1327
+ crypto.randomBytes(2).toString('hex'),
1328
+ crypto.randomBytes(2).toString('hex'),
1329
+ crypto.randomBytes(2).toString('hex'),
1330
+ crypto.randomBytes(6).toString('hex'),
1331
+ ].join('-');
1332
+ const downstreamSessionId = crypto.randomUUID
1333
+ ? crypto.randomUUID()
1334
+ : [
1335
+ crypto.randomBytes(4).toString('hex'),
1336
+ crypto.randomBytes(2).toString('hex'),
1337
+ crypto.randomBytes(2).toString('hex'),
1338
+ crypto.randomBytes(2).toString('hex'),
1339
+ crypto.randomBytes(6).toString('hex'),
1340
+ ].join('-');
1341
+ // ── Variables payload (mirrors CometUFIFeedbackReactMutation) ──────
1342
+ const variables = {
1343
+ input: {
1344
+ // attribution_id_v2 identifies the React component tree that triggered
1345
+ // the reaction (format: "<Component>,<surface>,<trigger>,<ts>,<seq>,<groupID>,,").
1346
+ // Pass ctx.attribution_id_v2 when you have it from a page scrape.
1347
+ attribution_id_v2: ctx.attribution_id_v2 || '',
1348
+ feedback_id: encodedFeedbackId,
1349
+ feedback_reaction_id: reactionId,
1350
+ feedback_source: 'OBJECT',
1351
+ is_tracking_encrypted: true,
1352
+ // tracking is an encrypted server-generated token array.
1353
+ // Carry it through ctx when available; fall back to [].
1354
+ tracking: Array.isArray(ctx.tracking) && ctx.tracking.length
1355
+ ? ctx.tracking
1356
+ : [],
1357
+ session_id: sessionId,
1358
+ downstream_share_session_id: downstreamSessionId,
1359
+ // The page URL where the reaction was performed
1360
+ downstream_share_session_origin_uri: ctx.currentUrl || 'https://www.facebook.com/',
1361
+ downstream_share_session_start_time: String(Date.now()),
1362
+ actor_id: ctx.userID,
1363
+ client_mutation_id: String(Math.floor(Math.random() * 10)),
1364
+ },
1365
+ useDefaultActor: false,
1366
+ __relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider: false,
1367
+ };
1368
+ // ── POST to /api/graphql/ ──────────────────────────────────────────
1369
+ // Includes every form field present in the original captured request.
1370
+ const lsd = ctx.lsd || ctx.fb_dtsg;
1371
+ const form = {
1372
+ av: ctx.userID,
1373
+ __aaid: '0',
1374
+ __user: ctx.userID,
1375
+ __a: '1',
1376
+ __req: Math.floor(Math.random() * 99).toString(36),
1377
+ // Opaque session-state tokens generated by the browser on every page
1378
+ // load. Carry them through ctx when available so they match the live
1379
+ // session; otherwise fall back to empty strings (FB tolerates it).
1380
+ __hs: ctx.hs || '20536.HCSV2:comet_pkg.2.1...0',
1381
+ dpr: '1',
1382
+ __ccg: 'EXCELLENT',
1383
+ __rev: ctx.revision || '',
1384
+ __s: ctx.__s || '',
1385
+ __hsi: ctx.__hsi || '',
1386
+ __dyn: ctx.__dyn || '',
1387
+ __csr: ctx.__csr || '',
1388
+ __hsdp: ctx.__hsdp || '',
1389
+ __hblp: ctx.__hblp || '',
1390
+ __sjsp: ctx.__sjsp || '',
1391
+ __comet_req: '15',
1392
+ fb_dtsg: ctx.fb_dtsg,
1393
+ jazoest: ctx.jazoest,
1394
+ lsd,
1395
+ __spin_r: ctx.revision || '',
1396
+ __spin_b: 'trunk',
1397
+ __spin_t: Math.floor(Date.now() / 1000).toString(),
1398
+ // __crn is the Comet route name (page type). Use ctx value or a safe
1399
+ // default that works for both feed and group pages.
1400
+ __crn: ctx.__crn || 'comet.fbweb.CometHomeRoute',
1401
+ fb_api_caller_class: 'RelayModern',
1402
+ fb_api_req_friendly_name: 'CometUFIFeedbackReactMutation',
1403
+ server_timestamps: 'true',
1404
+ variables: JSON.stringify(variables),
1405
+ doc_id: '34430477113234631',
1406
+ };
1407
+ const response = await defaultFuncs
1408
+ .post('https://www.facebook.com/api/graphql/', ctx.jar, form, {})
1409
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
1410
+ if (response.errors) {
1411
+ throw new Error(JSON.stringify(response.errors));
1412
+ }
1413
+ const result = {
1414
+ success: true,
1415
+ reaction: reactionKey,
1416
+ reactionId,
1417
+ feedbackId: encodedFeedbackId,
1418
+ data: response?.data?.feedback_react ||
1419
+ response?.data?.story_act ||
1420
+ response?.data ||
1421
+ response,
1422
+ };
1423
+ if (callback) {
1424
+ callback(null, result);
1425
+ }
1426
+ return result;
1427
+ }
1428
+ catch (err) {
1429
+ utils.error('reactToPost', err);
1430
+ if (callback) {
1431
+ callback(err);
1432
+ }
1433
+ throw err;
1434
+ }
1435
+ }
1436
+ // ── Helper: parse CometSinglePostDialogContentQuery response ───────────
1437
+ function _extractTrackingFromNode(node) {
1438
+ if (Array.isArray(node.tracking))
1439
+ return node.tracking.filter(Boolean);
1440
+ if (node.tracking && typeof node.tracking === 'string')
1441
+ return [node.tracking];
1442
+ return [];
1443
+ }
1444
+ function _extractFeedbackTarget(feedbackStory) {
1445
+ const ufiCtx = feedbackStory.story_ufi_container?.story?.feedback_context ??
1446
+ feedbackStory.feedback_context ??
1447
+ null;
1448
+ if (!ufiCtx)
1449
+ return { feedback_id: null, post_url: null };
1450
+ const feedbackTarget = ufiCtx.feedback_target_with_context ??
1451
+ ufiCtx.ufi_renderer?.feedback ??
1452
+ null;
1453
+ return {
1454
+ feedback_id: feedbackTarget?.id ?? null,
1455
+ post_url: feedbackTarget?.url ?? null,
1456
+ };
1457
+ }
1458
+ function _extractPostInfoFields(response, userID) {
1459
+ try {
1460
+ const node = response?.data?.node ?? response?.data?.node_v2 ?? null;
1461
+ if (!node)
1462
+ return {
1463
+ tracking: [],
1464
+ feedback_id: null,
1465
+ attribution_id_v2: '',
1466
+ post_url: null,
1467
+ };
1468
+ let tracking = _extractTrackingFromNode(node);
1469
+ let feedback_id = null;
1470
+ let post_url = null;
1471
+ let attribution_id_v2 = '';
1472
+ const feedbackStory = node.comet_sections?.feedback?.story ??
1473
+ node.comet_sections?.content?.story ??
1474
+ null;
1475
+ if (feedbackStory) {
1476
+ // Prefer tracking from UFI story (more specific)
1477
+ const storyTracking = _extractTrackingFromNode(feedbackStory);
1478
+ if (storyTracking.length > 0)
1479
+ tracking = storyTracking;
1480
+ post_url = feedbackStory.permalink ?? feedbackStory.url ?? null;
1481
+ // Build attribution_id_v2 matching live browser format
1482
+ const ts = Date.now();
1483
+ const seq = Math.floor(Math.random() * 999999);
1484
+ attribution_id_v2 =
1485
+ `CometSinglePostDialogRoot.react,comet.post.single_dialog,unexpected,${ts},${seq},,,;` +
1486
+ `CometHomeRoot.react,comet.home,logo,${ts - 5000},${seq +
1487
+ 1},${userID},229#230#301,`;
1488
+ const target = _extractFeedbackTarget(feedbackStory);
1489
+ feedback_id = target.feedback_id;
1490
+ if (target.post_url)
1491
+ post_url = target.post_url;
1492
+ }
1493
+ // Fallback: deep path from node directly
1494
+ if (!feedback_id) {
1495
+ const fb = node.comet_sections?.feedback?.story?.story_ufi_container?.story
1496
+ ?.feedback_context?.feedback_target_with_context ?? null;
1497
+ if (fb?.id)
1498
+ feedback_id = fb.id;
1499
+ }
1500
+ return { tracking, feedback_id, attribution_id_v2, post_url };
1501
+ }
1502
+ catch {
1503
+ return {
1504
+ tracking: [],
1505
+ feedback_id: null,
1506
+ attribution_id_v2: '',
1507
+ post_url: null,
1508
+ };
1509
+ }
1510
+ }
1511
+ /**
1512
+
1513
+ * Fetch post metadata using CometSinglePostDialogContentQuery.
1514
+ * Returns the `tracking` token array, `feedback_id`, and `attribution_id_v2`
1515
+ * required for accurate reactions via reactToPost().
1516
+ *
1517
+ * @param {string} storyID - The story node ID (base64-encoded, e.g. "UzpfS...").
1518
+ * Can also be a raw numeric post ID — will be auto-encoded.
1519
+ * @param {Function} [callback] - Optional callback function.
1520
+ * @returns {Promise<object>} Object with tracking, feedback_id, attribution_id_v2, and raw data.
1521
+ *
1522
+ * @example
1523
+ * // Get tracking token then react
1524
+ * const info = await api.create.getPostInfo('1510599910435387');
1525
+ * await api.create.reactPost(info.feedback_id, 'LIKE');
1526
+ */
1527
+ async function getPostInfo(storyID, callback) {
1528
+ let _resolve;
1529
+ let _reject;
1530
+ const returnPromise = new Promise((res, rej) => {
1531
+ _resolve = res;
1532
+ _reject = rej;
1533
+ });
1534
+ const done = callback ??
1535
+ ((err, data) => {
1536
+ if (err)
1537
+ return _reject(err);
1538
+ _resolve(data);
1539
+ });
1540
+ try {
1541
+ if (!storyID) {
1542
+ throw new Error('storyID is required.');
1543
+ }
1544
+ // ── Encode storyID if it's a raw numeric post ID ──────────────────
1545
+ // Facebook storyID format: base64("S:<ownerID>:<postID>:<postID>") or
1546
+ // base64("UzpfS<ownerID>:<postID>:<postID>")
1547
+ // If user passes a plain numeric ID, encode it as feedback ID format
1548
+ // and let the server return the full story node.
1549
+ // Usually users pass the post numeric ID (story_fbid), so we build
1550
+ // the proper base64 story node ID: "S:f<userID>:<postID>:<postID>"
1551
+ let encodedStoryID = storyID;
1552
+ if (/^\d+$/.test(storyID)) {
1553
+ // Raw numeric post ID → build story node ID: S:f<actorID>:<postID>:<postID>
1554
+ const nodeString = `S:f${ctx.userID}:${storyID}:${storyID}`;
1555
+ const b64 = Buffer.from(nodeString)
1556
+ .toString('base64')
1557
+ .replaceAll('+', '-')
1558
+ .replaceAll('/', '_')
1559
+ .replaceAll('=', '');
1560
+ const pad = b64.length % 4;
1561
+ encodedStoryID = pad ? b64 + '='.repeat(4 - pad) : b64;
1562
+ }
1563
+ // ── Variables matching CometSinglePostDialogContentQuery ──────────
1564
+ const variables = {
1565
+ feedbackSource: 2, // POST_PERMALINK_DIALOG
1566
+ feedLocation: 'POST_PERMALINK_DIALOG',
1567
+ focusCommentID: null,
1568
+ privacySelectorRenderLocation: 'COMET_STREAM',
1569
+ renderLocation: 'permalink',
1570
+ scale: 1,
1571
+ shouldChangeNodeFieldName: true,
1572
+ storyID: encodedStoryID,
1573
+ useDefaultActor: false,
1574
+ // Relay provider flags (copy from live request)
1575
+ __relay_internal__pv__GHLShouldChangeAdIdFieldNamerelayprovider: true,
1576
+ __relay_internal__pv__GHLShouldChangeSponsoredDataFieldNamerelayprovider: true,
1577
+ __relay_internal__pv__CometFeedStory_enable_post_permalink_white_space_clickrelayprovider: false,
1578
+ __relay_internal__pv__CometUFICommentActionLinksRewriteEnabledrelayprovider: false,
1579
+ __relay_internal__pv__CometUFICommentAvatarStickerAnimatedImagerelayprovider: false,
1580
+ __relay_internal__pv__IsWorkUserrelayprovider: false,
1581
+ __relay_internal__pv__TestPilotShouldIncludeDemoAdUseCaserelayprovider: false,
1582
+ __relay_internal__pv__FBReels_deprecate_short_form_video_context_gkrelayprovider: true,
1583
+ __relay_internal__pv__FBReels_enable_view_dubbed_audio_type_gkrelayprovider: true,
1584
+ __relay_internal__pv__CometImmersivePhotoCanUserDisable3DMotionrelayprovider: false,
1585
+ __relay_internal__pv__WorkCometIsEmployeeGKProviderrelayprovider: false,
1586
+ __relay_internal__pv__IsMergQAPollsrelayprovider: false,
1587
+ __relay_internal__pv__FBReelsMediaFooter_comet_enable_reels_ads_gkrelayprovider: true,
1588
+ __relay_internal__pv__CometUFIReactionsEnableShortNamerelayprovider: false,
1589
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider: 'ORIGINAL',
1590
+ __relay_internal__pv__CometUFIShareActionMigrationrelayprovider: true,
1591
+ __relay_internal__pv__CometUFISingleLineUFIrelayprovider: false,
1592
+ __relay_internal__pv__CometUFI_dedicated_comment_routable_dialog_gkrelayprovider: true,
1593
+ __relay_internal__pv__FBReelsIFUTileContent_reelsIFUPlayOnHoverrelayprovider: true,
1594
+ __relay_internal__pv__GroupsCometGYSJFeedItemHeightrelayprovider: 150,
1595
+ __relay_internal__pv__ShouldEnableBakedInTextStoriesrelayprovider: false,
1596
+ __relay_internal__pv__StoriesShouldIncludeFbNotesrelayprovider: false,
1597
+ };
1598
+ const lsd = ctx.lsd || ctx.fb_dtsg;
1599
+ const form = {
1600
+ av: ctx.userID,
1601
+ __aaid: '0',
1602
+ __user: ctx.userID,
1603
+ __a: '1',
1604
+ __req: Math.floor(Math.random() * 99).toString(36),
1605
+ __hs: ctx.hs || '20536.HCSV2:comet_pkg.2.1...0',
1606
+ dpr: '1',
1607
+ __ccg: 'EXCELLENT',
1608
+ __rev: ctx.revision || '',
1609
+ __s: ctx.__s || '',
1610
+ __hsi: ctx.__hsi || '',
1611
+ __dyn: ctx.__dyn || '',
1612
+ __csr: ctx.__csr || '',
1613
+ __hsdp: ctx.__hsdp || '',
1614
+ __hblp: ctx.__hblp || '',
1615
+ __sjsp: ctx.__sjsp || '',
1616
+ __comet_req: '15',
1617
+ fb_dtsg: ctx.fb_dtsg,
1618
+ jazoest: ctx.jazoest,
1619
+ lsd,
1620
+ __spin_r: ctx.revision || '',
1621
+ __spin_b: 'trunk',
1622
+ __spin_t: Math.floor(Date.now() / 1000).toString(),
1623
+ __crn: ctx.__crn || 'comet.fbweb.CometHomeRoute',
1624
+ fb_api_caller_class: 'RelayModern',
1625
+ fb_api_req_friendly_name: 'CometSinglePostDialogContentQuery',
1626
+ server_timestamps: 'true',
1627
+ variables: JSON.stringify(variables),
1628
+ doc_id: '26187019677574345',
1629
+ };
1630
+ const response = await defaultFuncs
1631
+ .post('https://www.facebook.com/api/graphql/', ctx.jar, form, {})
1632
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
1633
+ if (response.errors) {
1634
+ throw new Error(JSON.stringify(response.errors));
1635
+ }
1636
+ // ── Extract tracking, feedback_id, attribution_id_v2 from response ─
1637
+ // The response structure for CometSinglePostDialogContentQuery:
1638
+ // data.node → story node
1639
+ // .comet_sections.feedback.story → UFI (Unified Feedback Interface)
1640
+ // .story_ufi_container.story.feedback_context
1641
+ // .feedback_target_with_context
1642
+ // .id → feedback_id (base64)
1643
+ // .url → post URL
1644
+ // .tracking → encrypted tracking token array
1645
+ // .id → story graphql node ID
1646
+ const { tracking, feedback_id, attribution_id_v2, post_url, } = _extractPostInfoFields(response, ctx.userID);
1647
+ const result = {
1648
+ success: true,
1649
+ storyID: encodedStoryID,
1650
+ feedback_id,
1651
+ tracking,
1652
+ attribution_id_v2,
1653
+ post_url,
1654
+ data: response,
1655
+ };
1656
+ done(null, result);
1657
+ }
1658
+ catch (err) {
1659
+ utils.error('getPostInfo', err);
1660
+ done(err);
1661
+ }
1662
+ return returnPromise;
1663
+ }
1664
+ /**
1665
+ * React to a post with full tracking accuracy.
1666
+ *
1667
+ * Combines `getPostInfo` + `reactToPost` in one call:
1668
+ * 1. Fetches real `tracking` tokens and `feedback_id` from Facebook via
1669
+ * `CometSinglePostDialogContentQuery`.
1670
+ * 2. Patches those values into `ctx` so `reactToPost` sends them correctly.
1671
+ * 3. Falls back gracefully if `getPostInfo` fails (uses original IDs).
1672
+ *
1673
+ * @param {string} storyID - Raw numeric post ID **or** base64 storyID.
1674
+ * Examples: `'1510599910435387'` or `'UzpfSTEwMDA0...'`
1675
+ * @param {string} [reaction='LIKE'] - Reaction type.
1676
+ * Valid: `'LIKE'|'LOVE'|'HAHA'|'WOW'|'SAD'|'ANGRY'|'CARE'`
1677
+ * or emoji aliases: `'👍'|'❤️'|'😂'|'😮'|'😢'|'😡'|'🤗'`
1678
+ * @param {Function} [callback] - Optional node-style callback `(err, result)`.
1679
+ * @returns {Promise<object>} Same shape as `reactToPost()`.
1680
+ *
1681
+ * @example
1682
+ * // Easiest usage — just pass the numeric post ID
1683
+ * const result = await api.post.reactWithInfo('1510599910435387', 'LOVE');
1684
+ *
1685
+ * // With callback
1686
+ * api.post.reactWithInfo('1510599910435387', 'HAHA', (err, res) => {
1687
+ * if (err) console.error(err);
1688
+ * else console.log('Reacted!', res);
1689
+ * });
1690
+ */
1691
+ async function reactToPostWithTracking(storyID, reaction = 'LIKE', callback) {
1692
+ try {
1693
+ if (!storyID)
1694
+ throw new Error('storyID is required.');
1695
+ // ── Step 1: fetch tracking info from Facebook ──────────────────────
1696
+ let feedbackId = storyID; // fallback: use storyID as-is
1697
+ let infoErr = null;
1698
+ try {
1699
+ const info = await getPostInfo(storyID);
1700
+ // Patch tracking + attribution_id_v2 into ctx so reactToPost uses them
1701
+ if (info.tracking?.length)
1702
+ ctx.tracking = info.tracking;
1703
+ if (info.attribution_id_v2)
1704
+ ctx.attribution_id_v2 = info.attribution_id_v2;
1705
+ if (info.post_url)
1706
+ ctx.currentUrl = info.post_url;
1707
+ // Prefer server-provided feedback_id; fall back to storyID
1708
+ if (info.feedback_id)
1709
+ feedbackId = info.feedback_id;
1710
+ }
1711
+ catch (err) {
1712
+ infoErr = err;
1713
+ utils.warn('reactToPostWithTracking', `getPostInfo failed (falling back to storyID as feedbackId): ${err}`);
1714
+ }
1715
+ // ── Step 2: react using the enriched ctx ───────────────────────────
1716
+ const result = await reactToPost(feedbackId, reaction);
1717
+ // Attach info-fetch error to result so callers can inspect it
1718
+ if (infoErr)
1719
+ result.infoError = String(infoErr);
1720
+ if (callback)
1721
+ callback(null, result);
1722
+ return result;
1723
+ }
1724
+ catch (err) {
1725
+ utils.error('reactToPostWithTracking', err);
1726
+ if (callback)
1727
+ callback(err);
1728
+ throw err;
1729
+ }
1730
+ }
1731
+ // ── pfbid (Permalink-Friendly Base ID) ───────────────────────────────────
1732
+ // pfbid is an opaque server-side token that Facebook generates.
1733
+ // It CANNOT be decoded client-side (it's internally hashed/encrypted).
1734
+ //
1735
+ // The correct workflow:
1736
+ // parsePostUrl(url) → extracts the pfbid token from the URL path
1737
+ // getPostInfo(token) → sends pfbid to Facebook's GraphQL; server resolves it
1738
+ // and returns the real numeric story_fbid in the response
1739
+ //
1740
+ // The returned storyID from parsePostUrl is either:
1741
+ // - A numeric string (e.g. "1510599910435387") → for /posts/{numericID} URLs
1742
+ // - A pfbid string (e.g. "pfbid0Yo6XDa...") → for /posts/pfbid{token} URLs
1743
+ //
1744
+ // Both work as input to getPostInfo / reactWithInfo.
1745
+ /**
1746
+ * Parse any Facebook post URL into a storyID suitable for `getPostInfo`.
1747
+ *
1748
+ * Supports all common URL patterns:
1749
+ * - `https://www.facebook.com/{page}/posts/pfbid{token}` ← pfbid (new style)
1750
+ * - `https://www.facebook.com/{page}/posts/{numericID}` ← plain numeric
1751
+ * - `https://www.facebook.com/permalink.php?story_fbid={id}&id={page}`
1752
+ * - `https://www.facebook.com/photo?fbid={id}&...`
1753
+ * - `https://m.facebook.com/story.php?story_fbid={id}&...`
1754
+ *
1755
+ * @param {string} url - Any Facebook post URL.
1756
+ * @returns {{ storyID: string; pageSlug: string | null; format: 'pfbid'|'numeric'|'query' }}
1757
+ * `storyID` is either a numeric string or the full pfbid token (including "pfbid" prefix).
1758
+ * Pass it directly to `getPostInfo()` or `reactWithInfo()`.
1759
+ * @throws If no recognisable post ID can be extracted.
1760
+ *
1761
+ * @example
1762
+ * // pfbid URL → storyID is the pfbid token; pass it to getPostInfo which resolves it server-side
1763
+ * const { storyID } = parsePostUrl('https://www.facebook.com/tuyetcollection/posts/pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl');
1764
+ * // storyID = 'pfbid0Yo6XDaKKnFKpENu3g2c2to1zJe7jFoZNr2NqjiXRiHmwjsy8dwbE84y4n7syCSfMl'
1765
+ * const info = await api.post.getPostInfo(storyID); // Facebook resolves it → real ID in response
1766
+ * await api.post.reactToPost(info.feedback_id, 'LOVE');
1767
+ *
1768
+ * // Easiest: reactWithInfo handles all of this automatically
1769
+ * await api.post.reactWithInfo(storyID, 'LOVE');
1770
+ *
1771
+ * // Numeric URL → storyID is already the numeric ID
1772
+ * const { storyID } = parsePostUrl('https://www.facebook.com/tuyetcollection/posts/1510599910435387');
1773
+ * // storyID = '1510599910435387'
1774
+ */
1775
+ function parsePostUrl(url) {
1776
+ let parsed;
1777
+ try {
1778
+ parsed = new URL(url);
1779
+ }
1780
+ catch {
1781
+ throw new Error(`parsePostUrl: invalid URL "${url}"`);
1782
+ }
1783
+ const path = parsed.pathname; // e.g. /tuyetcollection/posts/pfbid0Yo6...
1784
+ const segments = path.split('/').filter(Boolean);
1785
+ // ── Pattern 1: /{slug}/posts/{id_or_pfbid} ───────────────────────────
1786
+ const postIdx = segments.indexOf('posts');
1787
+ if (postIdx !== -1 && segments[postIdx + 1]) {
1788
+ const token = segments[postIdx + 1];
1789
+ const pageSlug = postIdx > 0 ? segments[postIdx - 1] : null;
1790
+ if (token.startsWith('pfbid')) {
1791
+ // pfbid is an opaque server token — cannot be decoded client-side.
1792
+ // Return the full pfbid as storyID; Facebook GraphQL resolves it server-side.
1793
+ return { storyID: token, pageSlug, format: 'pfbid' };
1794
+ }
1795
+ if (/^\d+$/.test(token)) {
1796
+ return { storyID: token, pageSlug, format: 'numeric' };
1797
+ }
1798
+ }
1799
+ // ── Pattern 2: /photo/{numericID} or /video/{numericID} ──────────────
1800
+ for (const seg of ['photo', 'video', 'reel']) {
1801
+ const idx = segments.indexOf(seg);
1802
+ if (idx !== -1 && segments[idx + 1] && /^\d+$/.test(segments[idx + 1])) {
1803
+ return {
1804
+ storyID: segments[idx + 1],
1805
+ pageSlug: null,
1806
+ format: 'numeric',
1807
+ };
1808
+ }
1809
+ }
1810
+ // ── Pattern 3: query params ───────────────────────────────────────────
1811
+ // permalink.php?story_fbid=&id= | photo?fbid= | story.php?story_fbid=
1812
+ const storyFbid = parsed.searchParams.get('story_fbid');
1813
+ if (storyFbid && /^\d+$/.test(storyFbid)) {
1814
+ return { storyID: storyFbid, pageSlug: null, format: 'query' };
1815
+ }
1816
+ const fbid = parsed.searchParams.get('fbid');
1817
+ if (fbid && /^\d+$/.test(fbid)) {
1818
+ return { storyID: fbid, pageSlug: null, format: 'query' };
1819
+ }
1820
+ throw new Error(`parsePostUrl: could not extract post ID from "${url}"`);
1821
+ }
887
1822
  return {
888
1823
  create: createPost,
889
1824
  createPost: createPost,
890
1825
  delete: deletePost,
891
1826
  getComments: getPostComments,
1827
+ getPostInfo: getPostInfo,
1828
+ getPostIDFromURL: getPostIDFromURL,
1829
+ parsePostUrl: parsePostUrl,
1830
+ react: reactToPost,
1831
+ reactToPost: reactToPost,
1832
+ reactWithInfo: reactToPostWithTracking,
1833
+ reactToPostWithTracking: reactToPostWithTracking,
892
1834
  uploadPhoto: uploadPhoto,
893
1835
  uploadVideo: uploadVideo,
894
1836
  };