@saltcorn/meta-marketing-api 0.1.1 → 0.1.3

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.
package/api.js CHANGED
@@ -74,7 +74,7 @@ const DEFAULT_FIELDS = {
74
74
  "configured_status",
75
75
  "bid_amount",
76
76
  "preview_shareable_link",
77
- "creative{id,name,thumbnail_url}",
77
+ "creative{id,name,object_type,thumbnail_url,image_url,video_id}",
78
78
  "created_time",
79
79
  "updated_time",
80
80
  ],
@@ -109,6 +109,22 @@ const CREATIVE_TEXT_FIELDS = [
109
109
  "effective_object_story_id",
110
110
  ];
111
111
 
112
+ // Where the picture or the film of an ad can be found. As with the wording,
113
+ // Meta keeps this in a different place depending on how the ad was built.
114
+ const CREATIVE_MEDIA_FIELDS = [
115
+ "id",
116
+ "name",
117
+ "account_id",
118
+ "object_type",
119
+ "image_url",
120
+ "image_hash",
121
+ "video_id",
122
+ "thumbnail_url",
123
+ "object_story_spec",
124
+ "asset_feed_spec",
125
+ "effective_object_story_id",
126
+ ];
127
+
112
128
  // Metrics that are valid at every insights level.
113
129
  const BASE_INSIGHTS_FIELDS = [
114
130
  "impressions",
@@ -226,7 +242,7 @@ const mkApiError = (error, status) => {
226
242
  const e = new Error(
227
243
  `Meta Marketing API error (${bits.join(", ")}): ${
228
244
  error?.error_user_msg || error?.message || "unknown error"
229
- }`
245
+ }`,
230
246
  );
231
247
  e.metaError = error;
232
248
  e.status = status;
@@ -266,8 +282,7 @@ const graphFetch = async (path, opts = {}, cfg = {}) => {
266
282
  if (!noAuth && cfg?.access_token)
267
283
  headers.Authorization = `Bearer ${cfg.access_token}`;
268
284
 
269
- const maxRetries =
270
- typeof cfg?.max_retries === "number" ? cfg.max_retries : 3;
285
+ const maxRetries = typeof cfg?.max_retries === "number" ? cfg.max_retries : 3;
271
286
  let attempt = 0;
272
287
 
273
288
  for (;;) {
@@ -285,10 +300,10 @@ const graphFetch = async (path, opts = {}, cfg = {}) => {
285
300
  }
286
301
  console.error(
287
302
  `Meta Marketing API non-JSON response (HTTP ${response.status})`,
288
- body
303
+ body,
289
304
  );
290
305
  throw new Error(
291
- `Meta Marketing API: not a JSON response (HTTP ${response.status})`
306
+ `Meta Marketing API: not a JSON response (HTTP ${response.status})`,
292
307
  );
293
308
  }
294
309
  if (json && json.error) {
@@ -318,6 +333,8 @@ const getAllPages = async (path, query, cfg = {}, opts = {}) => {
318
333
  const rows = [...(json?.data || [])];
319
334
  let pages = 1;
320
335
  while (json?.paging?.next && pages < maxPages) {
336
+ if (cfg?.page_delay)
337
+ await new Promise((resolve) => setTimeout(resolve, cfg?.page_delay));
321
338
  json = await graphFetch(json.paging.next, {}, cfg);
322
339
  rows.push(...(json?.data || []));
323
340
  pages += 1;
@@ -342,7 +359,11 @@ const withInsightsFields = (query) => {
342
359
  //
343
360
 
344
361
  const getMe = async (query, cfg) =>
345
- await graphFetch("/me", { query: { fields: "id,name", ...(query || {}) } }, cfg);
362
+ await graphFetch(
363
+ "/me",
364
+ { query: { fields: "id,name", ...(query || {}) } },
365
+ cfg,
366
+ );
346
367
 
347
368
  const getAdAccounts = async (query, cfg) =>
348
369
  await getAllPages("/me/adaccounts", withFields(query, "adaccount"), cfg);
@@ -351,7 +372,7 @@ const getAdAccount = async (accountId, query, cfg) =>
351
372
  await graphFetch(
352
373
  `/${actId(accountId)}`,
353
374
  { query: withFields(query, "adaccount") },
354
- cfg
375
+ cfg,
355
376
  );
356
377
 
357
378
  const getBusinesses = async (query, cfg) =>
@@ -361,7 +382,7 @@ const getBusinessAdAccounts = async (businessId, query, cfg) =>
361
382
  await getAllPages(
362
383
  `/${businessId}/owned_ad_accounts`,
363
384
  withFields(query, "adaccount"),
364
- cfg
385
+ cfg,
365
386
  );
366
387
 
367
388
  //
@@ -372,29 +393,25 @@ const getCampaigns = async (accountId, query, cfg) =>
372
393
  await getAllPages(
373
394
  `/${actId(accountId)}/campaigns`,
374
395
  withFields(query, "campaign"),
375
- cfg
396
+ cfg,
376
397
  );
377
398
 
378
399
  const getCampaign = async (campaignId, query, cfg) =>
379
400
  await graphFetch(
380
401
  `/${campaignId}`,
381
402
  { query: withFields(query, "campaign") },
382
- cfg
403
+ cfg,
383
404
  );
384
405
 
385
406
  const getAdSets = async (accountId, query, cfg) =>
386
407
  await getAllPages(
387
408
  `/${actId(accountId)}/adsets`,
388
409
  withFields(query, "adset"),
389
- cfg
410
+ cfg,
390
411
  );
391
412
 
392
413
  const getCampaignAdSets = async (campaignId, query, cfg) =>
393
- await getAllPages(
394
- `/${campaignId}/adsets`,
395
- withFields(query, "adset"),
396
- cfg
397
- );
414
+ await getAllPages(`/${campaignId}/adsets`, withFields(query, "adset"), cfg);
398
415
 
399
416
  const getAdSet = async (adSetId, query, cfg) =>
400
417
  await graphFetch(`/${adSetId}`, { query: withFields(query, "adset") }, cfg);
@@ -415,14 +432,14 @@ const getAdCreatives = async (accountId, query, cfg) =>
415
432
  await getAllPages(
416
433
  `/${actId(accountId)}/adcreatives`,
417
434
  withFields(query, "adcreative"),
418
- cfg
435
+ cfg,
419
436
  );
420
437
 
421
438
  const getAdCreative = async (creativeId, query, cfg) =>
422
439
  await graphFetch(
423
440
  `/${creativeId}`,
424
441
  { query: withFields(query, "adcreative") },
425
- cfg
442
+ cfg,
426
443
  );
427
444
 
428
445
  /** Rendered HTML preview of an ad, as an iframe snippet */
@@ -430,7 +447,7 @@ const getAdPreview = async (adId, adFormat, cfg) => {
430
447
  const json = await graphFetch(
431
448
  `/${adId}/previews`,
432
449
  { query: { ad_format: adFormat || "DESKTOP_FEED_STANDARD" } },
433
- cfg
450
+ cfg,
434
451
  );
435
452
  return json?.data?.[0]?.body || "";
436
453
  };
@@ -473,6 +490,53 @@ const creativeBodies = (creative) => {
473
490
  return [...new Set(found.filter((b) => b))];
474
491
  };
475
492
 
493
+ // A page post is only shown to a token that carries the page's own
494
+ // permissions. A token for the page can be asked for, once per page.
495
+ const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000;
496
+ const pageTokenCache = new Map();
497
+
498
+ /**
499
+ * A token for one page, from the token in the settings. Comes back null when
500
+ * the settings token has no say over that page, which is the usual reason an
501
+ * ad that boosts a page post has nothing to show.
502
+ */
503
+ const getPageAccessToken = async (pageId, cfg) => {
504
+ if (!pageId) return null;
505
+ const key = `${pageId}|${cfg?.access_token || ""}`;
506
+ const hit = pageTokenCache.get(key);
507
+ if (hit && Date.now() - hit.at < PAGE_TOKEN_TTL_MS) return hit.token;
508
+ let token = null;
509
+ try {
510
+ const json = await graphFetch(
511
+ `/${pageId}`,
512
+ { query: { fields: "access_token" } },
513
+ cfg,
514
+ );
515
+ token = json?.access_token || null;
516
+ } catch (e) {
517
+ if (cfg?.log_requests)
518
+ console.log(`Meta: no token for page ${pageId}`, e.message);
519
+ }
520
+ pageTokenCache.set(key, { token, at: Date.now() });
521
+ return token;
522
+ };
523
+
524
+ /**
525
+ * Which tokens to try when reading the post behind an ad, best first: one
526
+ * for the page that owns it, then the one from the settings.
527
+ */
528
+ const postTokens = async (creative, storyId, cfg, opts = {}) => {
529
+ const pageId =
530
+ creative?.object_story_spec?.page_id || `${storyId}`.split("_")[0];
531
+ const pageToken =
532
+ opts.page_token === false ? null : await getPageAccessToken(pageId, cfg);
533
+ return {
534
+ pageId,
535
+ pageToken,
536
+ cfgs: pageToken ? [{ ...cfg, access_token: pageToken }, cfg] : [cfg],
537
+ };
538
+ };
539
+
476
540
  /**
477
541
  * The wording of the page post an ad promotes: the post's own text, and the
478
542
  * headline of the link it carries.
@@ -481,7 +545,7 @@ const storyText = async (storyId, cfg) => {
481
545
  const json = await graphFetch(
482
546
  `/${storyId}`,
483
547
  { query: { fields: "message,attachments{title,description}" } },
484
- cfg
548
+ cfg,
485
549
  );
486
550
  const attachment = json?.attachments?.data?.[0] || {};
487
551
  return {
@@ -500,13 +564,13 @@ const storyText = async (storyId, cfg) => {
500
564
  * When that read is not allowed the wording comes back empty rather than
501
565
  * throwing.
502
566
  */
503
- const getAdText = async (ad, cfg) => {
567
+ const getAdText = async (ad, cfg, opts = {}) => {
504
568
  let creative = typeof ad === "object" ? ad?.creative : null;
505
569
  if (!creative?.object_story_spec && !creative?.title && !creative?.body) {
506
570
  const fetched = await graphFetch(
507
571
  `/${typeof ad === "object" ? ad?.id : ad}`,
508
572
  { query: { fields: `creative{${CREATIVE_TEXT_FIELDS.join(",")}}` } },
509
- cfg
573
+ cfg,
510
574
  );
511
575
  creative = fetched?.creative;
512
576
  }
@@ -514,17 +578,21 @@ const getAdText = async (ad, cfg) => {
514
578
  headline: creativeHeadlines(creative)[0] || "",
515
579
  body: creativeBodies(creative)[0] || "",
516
580
  };
517
- if ((!text.headline || !text.body) && creative?.effective_object_story_id)
518
- try {
519
- const story = await storyText(creative.effective_object_story_id, cfg);
520
- return {
521
- headline: text.headline || story.headline,
522
- body: text.body || story.body,
523
- };
524
- } catch (e) {
525
- if (cfg?.log_requests)
526
- console.log("Meta: could not read the post behind the ad", e.message);
527
- }
581
+ if ((!text.headline || !text.body) && creative?.effective_object_story_id) {
582
+ const storyId = creative.effective_object_story_id;
583
+ const { cfgs } = await postTokens(creative, storyId, cfg, opts);
584
+ for (const useCfg of cfgs)
585
+ try {
586
+ const story = await storyText(storyId, useCfg);
587
+ return {
588
+ headline: text.headline || story.headline,
589
+ body: text.body || story.body,
590
+ };
591
+ } catch (e) {
592
+ if (cfg?.log_requests)
593
+ console.log("Meta: could not read the post behind the ad", e.message);
594
+ }
595
+ }
528
596
  return text;
529
597
  };
530
598
 
@@ -534,6 +602,419 @@ const getAdHeadline = async (ad, cfg) => (await getAdText(ad, cfg)).headline;
534
602
  /** The primary text of an ad, the wording above the image, as getAdText */
535
603
  const getAdBody = async (ad, cfg) => (await getAdText(ad, cfg)).body;
536
604
 
605
+ //
606
+ // Media: the picture or the film an ad is built on
607
+ //
608
+
609
+ // What tells two pieces of media apart. The same picture reached by two
610
+ // routes - once by hash and once by address - is the same picture.
611
+ const MEDIA_IDS = ["video_id", "image_hash", "url"];
612
+
613
+ /**
614
+ * Add one piece of media to the list, or, when it is already there under
615
+ * another of its names, fill in what that entry was missing.
616
+ */
617
+ const addMedia = (out, m) => {
618
+ if (!m || !MEDIA_IDS.some((k) => m[k])) return;
619
+ const same = out.find(
620
+ (o) => o.kind === m.kind && MEDIA_IDS.some((k) => m[k] && o[k] === m[k]),
621
+ );
622
+ if (!same) {
623
+ out.push(m);
624
+ return;
625
+ }
626
+ Object.entries(m).forEach(([k, v]) => {
627
+ if (v && !same[k]) same[k] = v;
628
+ });
629
+ };
630
+
631
+ /** A carousel card, or a plain link ad, which are shaped the same way */
632
+ const linkMedia = (data) =>
633
+ data.video_id
634
+ ? {
635
+ kind: "video",
636
+ video_id: data.video_id,
637
+ thumbnail_url: data.picture || data.image_url,
638
+ image_hash: undefined,
639
+ thumbnail_hash: data.image_hash,
640
+ name: data.name,
641
+ link: data.link,
642
+ }
643
+ : {
644
+ kind: "image",
645
+ url: data.picture || data.image_url,
646
+ image_hash: data.image_hash,
647
+ name: data.name,
648
+ link: data.link,
649
+ };
650
+
651
+ /**
652
+ * Every piece of media on a creative, without reading anything further. A
653
+ * video carries its own still as a thumbnail rather than as a picture of its
654
+ * own, so a film never counts as a picture too.
655
+ */
656
+ const creativeMedia = (creative) => {
657
+ const spec = creative?.object_story_spec || {};
658
+ const feed = creative?.asset_feed_spec || {};
659
+ const out = [];
660
+
661
+ if (spec.video_data)
662
+ addMedia(out, {
663
+ kind: "video",
664
+ video_id: spec.video_data.video_id,
665
+ thumbnail_url: spec.video_data.image_url,
666
+ thumbnail_hash: spec.video_data.image_hash,
667
+ name: spec.video_data.title,
668
+ });
669
+ if (spec.photo_data)
670
+ addMedia(out, {
671
+ kind: "image",
672
+ url: spec.photo_data.url,
673
+ image_hash: spec.photo_data.image_hash,
674
+ });
675
+ [spec.link_data, spec.template_data].forEach((data) => {
676
+ if (!data) return;
677
+ const children = data.child_attachments || [];
678
+ if (children.length) children.forEach((c) => addMedia(out, linkMedia(c)));
679
+ else addMedia(out, linkMedia(data));
680
+ });
681
+
682
+ // Flexible and dynamic creatives keep a pool of assets for Meta to choose
683
+ // between, rather than one story
684
+ (feed.videos || []).forEach((v) =>
685
+ addMedia(out, {
686
+ kind: "video",
687
+ video_id: v.video_id,
688
+ thumbnail_url: v.thumbnail_url,
689
+ thumbnail_hash: v.thumbnail_hash,
690
+ }),
691
+ );
692
+ (feed.images || []).forEach((i) =>
693
+ addMedia(out, { kind: "image", url: i.url, image_hash: i.hash }),
694
+ );
695
+
696
+ // Last, what the creative says about itself. On many ads this is the same
697
+ // media again, which the key above discards.
698
+ if (creative?.video_id)
699
+ addMedia(out, {
700
+ kind: "video",
701
+ video_id: creative.video_id,
702
+ thumbnail_url: creative.thumbnail_url,
703
+ });
704
+ else if (creative?.image_url || creative?.image_hash)
705
+ addMedia(out, {
706
+ kind: "image",
707
+ url: creative.image_url,
708
+ image_hash: creative.image_hash,
709
+ });
710
+
711
+ return out;
712
+ };
713
+
714
+ /**
715
+ * The media on a page post an ad promotes, for ads that carry no creative of
716
+ * their own. An album post holds its pictures one level further down.
717
+ */
718
+ const storyMedia = async (storyId, cfg) => {
719
+ const json = await graphFetch(
720
+ `/${storyId}`,
721
+ {
722
+ query: {
723
+ fields:
724
+ "permalink_url,full_picture," +
725
+ "attachments{media_type,media,target,url,subattachments{media_type,media,target}}",
726
+ },
727
+ },
728
+ cfg,
729
+ );
730
+ const out = [];
731
+ let album = false;
732
+ const fromAttachment = (att) => {
733
+ if (!att) return;
734
+ const type = `${att.media_type || ""}`.toLowerCase();
735
+ const still = att.media?.image?.src;
736
+ if (type.includes("video"))
737
+ addMedia(out, {
738
+ kind: "video",
739
+ video_id: att.target?.id,
740
+ thumbnail_url: still,
741
+ });
742
+ else if (still) addMedia(out, { kind: "image", url: still });
743
+ };
744
+ (json?.attachments?.data || []).forEach((att) => {
745
+ const subs = att.subattachments?.data || [];
746
+ if (subs.length > 1) album = true;
747
+ if (subs.length) subs.forEach(fromAttachment);
748
+ else fromAttachment(att);
749
+ });
750
+ // full_picture is whatever the post shows in a feed. For a film that is a
751
+ // still rather than the film, so it is offered as a thumbnail and never as
752
+ // a picture in its own right.
753
+ return {
754
+ media: out,
755
+ album,
756
+ thumbnail_url: json?.full_picture,
757
+ permalink_url: json?.permalink_url,
758
+ };
759
+ };
760
+
761
+ /**
762
+ * The media on the post an ad boosts, tried first with a token for the page
763
+ * that owns the post and then with the token from the settings. Never
764
+ * throws: what went wrong comes back as the reason, to be reported on the ad
765
+ * rather than stopping a run over a whole ad set.
766
+ */
767
+ const postMedia = async (creative, cfg, opts = {}) => {
768
+ const storyId = creative?.effective_object_story_id;
769
+ if (!storyId) return { media: [] };
770
+ const { pageId, pageToken, cfgs } = await postTokens(
771
+ creative,
772
+ storyId,
773
+ cfg,
774
+ opts,
775
+ );
776
+ let reason;
777
+ for (const useCfg of cfgs) {
778
+ try {
779
+ const post = await storyMedia(storyId, useCfg);
780
+ if (post.media.length) return { ...post, story_id: storyId };
781
+ reason = `the post ${storyId} behind this ad carries no picture or film`;
782
+ } catch (e) {
783
+ reason = `could not read the post ${storyId} behind this ad: ${e.message}`;
784
+ if (cfg?.log_requests) console.log(`Meta: ${reason}`);
785
+ }
786
+ }
787
+ if (!pageToken)
788
+ reason = `${reason}. The access token has no say over page ${pageId}: to see what an ad that boosts a page post is made of, use a token that can also read that page, with the pages_read_engagement permission`;
789
+ return { media: [], story_id: storyId, error: reason };
790
+ };
791
+
792
+ /**
793
+ * Turn the ids and hashes collected above into addresses a file can be
794
+ * downloaded from. Videos are read one at a time, pictures all in one go.
795
+ *
796
+ * The address of a video is only given out to a token that owns it, and it is
797
+ * signed and short lived, so download it now rather than storing it. When it
798
+ * cannot be read the entry keeps its id and its thumbnail.
799
+ */
800
+ const resolveMediaUrls = async (media, accountId, cfg) => {
801
+ const videos = media.filter((m) => m.kind === "video" && m.video_id);
802
+ for (const m of videos) {
803
+ try {
804
+ const v = await graphFetch(
805
+ `/${m.video_id}`,
806
+ {
807
+ query: {
808
+ fields: "id,source,picture,permalink_url,length,created_time",
809
+ },
810
+ },
811
+ cfg,
812
+ );
813
+ m.url = v?.source || m.url;
814
+ m.thumbnail_url = m.thumbnail_url || v?.picture;
815
+ m.permalink_url = v?.permalink_url;
816
+ if (typeof v?.length === "number") m.length = v.length;
817
+ } catch (e) {
818
+ m.error = e.message;
819
+ if (cfg?.log_requests)
820
+ console.log(`Meta: could not read video ${m.video_id}`, e.message);
821
+ }
822
+ }
823
+
824
+ const needHash = media.filter((m) => !m.url && m.image_hash);
825
+ if (!needHash.length) return media;
826
+ if (!accountId) {
827
+ needHash.forEach((m) => {
828
+ m.error =
829
+ "the address of this picture is held by the ad account, which is not known here: pass the ad account id, or set a default one in the settings";
830
+ });
831
+ return media;
832
+ }
833
+
834
+ // The picture library only answers about so many at a time
835
+ const hashes = [...new Set(needHash.map((m) => m.image_hash))];
836
+ const byHash = {};
837
+ let reason;
838
+ for (let i = 0; i < hashes.length; i += 50) {
839
+ try {
840
+ const json = await graphFetch(
841
+ `/${actId(accountId)}/adimages`,
842
+ {
843
+ query: {
844
+ // a list of its own, not the comma separated kind Meta takes
845
+ // elsewhere
846
+ hashes: JSON.stringify(hashes.slice(i, i + 50)),
847
+ fields: "hash,url,permalink_url,width,height",
848
+ },
849
+ },
850
+ cfg,
851
+ );
852
+ // Depending on the API version this comes back as a list or as an
853
+ // object keyed by hash
854
+ const images = Array.isArray(json?.data)
855
+ ? json.data
856
+ : Object.values(json?.images || {});
857
+ images.forEach((img) => {
858
+ if (img?.hash) byHash[img.hash] = img;
859
+ });
860
+ } catch (e) {
861
+ reason = `could not read the pictures of ad account ${actId(
862
+ accountId,
863
+ )}: ${e.message}`;
864
+ if (cfg?.log_requests) console.log(`Meta: ${reason}`);
865
+ }
866
+ }
867
+ needHash.forEach((m) => {
868
+ const img = byHash[m.image_hash];
869
+ if (!img) {
870
+ m.error =
871
+ reason ||
872
+ `the ad account has no picture with the hash ${m.image_hash}. It may belong to another ad account, or to the page rather than to the ad`;
873
+ return;
874
+ }
875
+ m.url = img.url;
876
+ m.permalink_url = img.permalink_url;
877
+ m.width = img.width;
878
+ m.height = img.height;
879
+ });
880
+
881
+ return media;
882
+ };
883
+
884
+ /**
885
+ * Why some of what an ad is made of has no address to download it from,
886
+ * said once rather than once per picture.
887
+ */
888
+ const mediaErrors = (media) => {
889
+ const stuck = media.filter((m) => m.error && !m.url);
890
+ if (!stuck.length) return undefined;
891
+ const reasons = [...new Set(stuck.map((m) => m.error))];
892
+ return `${stuck.length} of ${media.length} could not be reached: ${reasons.join(
893
+ "; ",
894
+ )}`;
895
+ };
896
+
897
+ /** What kind of ad this is, from the media it was built on */
898
+ const mediaType = (media) => {
899
+ const hasVideo = media.some((m) => m.kind === "video");
900
+ const hasImage = media.some((m) => m.kind === "image");
901
+ if (hasVideo && hasImage) return "mixed";
902
+ if (hasVideo) return "video";
903
+ if (hasImage) return "image";
904
+ return "unknown";
905
+ };
906
+
907
+ /**
908
+ * What an ad is made of and where to download it from:
909
+ *
910
+ * { type, carousel, media: [{ kind, url, thumbnail_url, ... }],
911
+ * creative_id, object_type, thumbnail_url, from_post, story_id, error }
912
+ *
913
+ * type is image, video, mixed - a dynamic creative offering Meta both - or
914
+ * unknown when nothing could be found. carousel says whether the ad holds
915
+ * more than one card; the cards are the media list, in the order they are
916
+ * shown. thumbnail_url is a picture of the ad as it appears, which is there
917
+ * even when the media itself cannot be reached, and error says why it could
918
+ * not.
919
+ *
920
+ * Takes an ad id, or an ad row that already has its creative. A creative
921
+ * that carries no media is read again in full, since the caller may have
922
+ * asked Meta for only some of the places media can hide, so an ad read with
923
+ * the media fields of its own saves a read here.
924
+ *
925
+ * Options: resolve_urls false skips turning video ids and image hashes into
926
+ * addresses, which saves a read per video when all you want is the type;
927
+ * page_token false stops it asking for a token for the page behind a boosted
928
+ * post.
929
+ */
930
+ const getAdMedia = async (ad, cfg, opts = {}) => {
931
+ const adId = typeof ad === "object" ? ad?.id : ad;
932
+ let creative = typeof ad === "object" ? ad?.creative : null;
933
+ let accountId =
934
+ opts.account_id ||
935
+ (typeof ad === "object" ? ad?.account_id : null) ||
936
+ creative?.account_id;
937
+ let media = creativeMedia(creative);
938
+
939
+ // Nothing on what we were handed. Ask for every field media can arrive in
940
+ // before giving up on the creative, and for a thumbnail big enough to be
941
+ // worth looking at.
942
+ if (!media.length && adId) {
943
+ const fetched = await graphFetch(
944
+ `/${adId}`,
945
+ {
946
+ query: {
947
+ fields: `account_id,creative{${CREATIVE_MEDIA_FIELDS.join(",")}}`,
948
+ thumbnail_width: opts.thumbnail_width || 1200,
949
+ thumbnail_height: opts.thumbnail_height || 1200,
950
+ },
951
+ },
952
+ cfg,
953
+ );
954
+ creative = fetched?.creative || creative;
955
+ accountId = accountId || fetched?.account_id || creative?.account_id;
956
+ media = creativeMedia(creative);
957
+ }
958
+ accountId = accountId || cfg?.ad_account_id;
959
+
960
+ // Still nothing: an ad that boosts a post keeps its media on the post
961
+ let post = {};
962
+ if (!media.length) {
963
+ post = await postMedia(creative, cfg, opts);
964
+ media = post.media;
965
+ }
966
+
967
+ if (opts.resolve_urls !== false) {
968
+ await resolveMediaUrls(media, accountId, cfg);
969
+ // Found something, but none of it can be downloaded: a creative can name
970
+ // pictures the ad account does not hold. The post an ad boosts carries
971
+ // addresses that work, so it is worth asking after all.
972
+ if (
973
+ media.length &&
974
+ !media.some((m) => m.url) &&
975
+ !post.media &&
976
+ creative?.effective_object_story_id
977
+ ) {
978
+ const fallback = await postMedia(creative, cfg, opts);
979
+ await resolveMediaUrls(fallback.media, accountId, cfg);
980
+ if (fallback.media.some((m) => m.url)) {
981
+ post = fallback;
982
+ media = fallback.media;
983
+ }
984
+ }
985
+ }
986
+
987
+ const children =
988
+ creative?.object_story_spec?.link_data?.child_attachments ||
989
+ creative?.object_story_spec?.template_data?.child_attachments ||
990
+ [];
991
+ return {
992
+ type: mediaType(media),
993
+ carousel: children.length > 1 || !!post.album,
994
+ media,
995
+ creative_id: creative?.id,
996
+ object_type: creative?.object_type,
997
+ thumbnail_url: creative?.thumbnail_url || post.thumbnail_url,
998
+ from_post: !!post.media?.length,
999
+ story_id: post.story_id,
1000
+ permalink_url: post.permalink_url,
1001
+ error: post.error || mediaErrors(media),
1002
+ };
1003
+ };
1004
+
1005
+ /** Whether an ad is an image, a video, mixed or unknown, as getAdMedia */
1006
+ const getAdMediaType = async (ad, cfg) =>
1007
+ (await getAdMedia(ad, cfg, { resolve_urls: false })).type;
1008
+
1009
+ /**
1010
+ * The address of the first picture or film in an ad, ready to download, or
1011
+ * the empty string when there is none
1012
+ */
1013
+ const getAdMediaUrl = async (ad, cfg) => {
1014
+ const { media } = await getAdMedia(ad, cfg);
1015
+ return media.find((m) => m.url)?.url || "";
1016
+ };
1017
+
537
1018
  //
538
1019
  // Insights
539
1020
  //
@@ -543,7 +1024,7 @@ const getInsights = async (objectId, query, cfg, opts) =>
543
1024
  `/${objectId}/insights`,
544
1025
  withInsightsFields(query),
545
1026
  cfg,
546
- opts
1027
+ opts,
547
1028
  );
548
1029
 
549
1030
  /** Kick off an asynchronous insights job, returns { report_run_id } */
@@ -551,7 +1032,7 @@ const startInsightsReport = async (objectId, query, cfg) =>
551
1032
  await graphFetch(
552
1033
  `/${objectId}/insights`,
553
1034
  { method: "POST", query: withInsightsFields(query) },
554
- cfg
1035
+ cfg,
555
1036
  );
556
1037
 
557
1038
  const getReportRun = async (reportRunId, cfg) =>
@@ -559,11 +1040,10 @@ const getReportRun = async (reportRunId, cfg) =>
559
1040
  `/${reportRunId}`,
560
1041
  {
561
1042
  query: {
562
- fields:
563
- "id,async_status,async_percent_completion,date_start,date_stop",
1043
+ fields: "id,async_status,async_percent_completion,date_start,date_stop",
564
1044
  },
565
1045
  },
566
- cfg
1046
+ cfg,
567
1047
  );
568
1048
 
569
1049
  const getReportRunInsights = async (reportRunId, query, cfg, opts) =>
@@ -586,11 +1066,11 @@ const getInsightsAsync = async (objectId, query, cfg, opts = {}) => {
586
1066
  if (run?.async_status === "Job Completed") break;
587
1067
  if (["Job Failed", "Job Skipped"].includes(run?.async_status))
588
1068
  throw new Error(
589
- `Meta Marketing API: insights job ${run.async_status} (${report_run_id})`
1069
+ `Meta Marketing API: insights job ${run.async_status} (${report_run_id})`,
590
1070
  );
591
1071
  if (Date.now() - startedAt > timeout)
592
1072
  throw new Error(
593
- `Meta Marketing API: insights job timed out (${report_run_id})`
1073
+ `Meta Marketing API: insights job timed out (${report_run_id})`,
594
1074
  );
595
1075
  await sleep(pollInterval);
596
1076
  }
@@ -614,7 +1094,7 @@ const exchangeLongLivedToken = async (app_id, app_secret, access_token, cfg) =>
614
1094
  fb_exchange_token: access_token,
615
1095
  },
616
1096
  },
617
- cfg
1097
+ cfg,
618
1098
  );
619
1099
 
620
1100
  /** Inspect a token: which app it belongs to, when it expires, its scopes */
@@ -628,7 +1108,7 @@ const debugToken = async (token, app_id, app_secret, cfg) => {
628
1108
  access_token: `${app_id}|${app_secret}`,
629
1109
  },
630
1110
  },
631
- cfg
1111
+ cfg,
632
1112
  );
633
1113
  return json?.data || json;
634
1114
  };
@@ -638,6 +1118,7 @@ module.exports = {
638
1118
  DEFAULT_API_VERSION,
639
1119
  DEFAULT_FIELDS,
640
1120
  CREATIVE_TEXT_FIELDS,
1121
+ CREATIVE_MEDIA_FIELDS,
641
1122
  BASE_INSIGHTS_FIELDS,
642
1123
  INSIGHTS_LEVEL_FIELDS,
643
1124
  NUMERIC_INSIGHTS_FIELDS,
@@ -672,6 +1153,16 @@ module.exports = {
672
1153
  getAdText,
673
1154
  getAdHeadline,
674
1155
  getAdBody,
1156
+ creativeMedia,
1157
+ storyMedia,
1158
+ postMedia,
1159
+ postTokens,
1160
+ getPageAccessToken,
1161
+ mediaErrors,
1162
+ mediaType,
1163
+ getAdMedia,
1164
+ getAdMediaType,
1165
+ getAdMediaUrl,
675
1166
  getInsights,
676
1167
  startInsightsReport,
677
1168
  getReportRun,