@saltcorn/meta-marketing-api 0.1.0 → 0.1.2

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
  ],
@@ -91,9 +91,40 @@ const DEFAULT_FIELDS = {
91
91
  "call_to_action_type",
92
92
  "effective_object_story_id",
93
93
  "object_story_spec",
94
+ "asset_feed_spec",
94
95
  ],
95
96
  };
96
97
 
98
+ // Where the wording of an ad can be found: the headline, and the primary text
99
+ // that runs above the image. Meta puts both in a different place depending on
100
+ // how the ad was built, and nowhere at all on the creative when the ad
101
+ // promotes a post that already exists on the page.
102
+ const CREATIVE_TEXT_FIELDS = [
103
+ "id",
104
+ "name",
105
+ "title",
106
+ "body",
107
+ "object_story_spec",
108
+ "asset_feed_spec",
109
+ "effective_object_story_id",
110
+ ];
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
+
97
128
  // Metrics that are valid at every insights level.
98
129
  const BASE_INSIGHTS_FIELDS = [
99
130
  "impressions",
@@ -420,6 +451,569 @@ const getAdPreview = async (adId, adFormat, cfg) => {
420
451
  return json?.data?.[0]?.body || "";
421
452
  };
422
453
 
454
+ /**
455
+ * Pull every headline out of a creative, best first. A carousel has one per
456
+ * card and a dynamic creative can carry several for Meta to choose between,
457
+ * so this returns a list rather than a single string.
458
+ */
459
+ const creativeHeadlines = (creative) => {
460
+ const spec = creative?.object_story_spec || {};
461
+ const found = [
462
+ creative?.title,
463
+ spec.link_data?.name,
464
+ spec.video_data?.title,
465
+ spec.template_data?.name,
466
+ ...(spec.link_data?.child_attachments || []).map((c) => c.name),
467
+ ...(creative?.asset_feed_spec?.titles || []).map((t) => t.text),
468
+ ];
469
+ return [...new Set(found.filter((h) => h))];
470
+ };
471
+
472
+ /**
473
+ * Pull every primary text out of a creative, best first. This is the longer
474
+ * wording that runs above the image, which Meta calls the primary text in Ads
475
+ * Manager and the body or the message in its API. As with headlines, a
476
+ * dynamic creative can carry several.
477
+ */
478
+ const creativeBodies = (creative) => {
479
+ const spec = creative?.object_story_spec || {};
480
+ const found = [
481
+ creative?.body,
482
+ spec.link_data?.message,
483
+ spec.video_data?.message,
484
+ spec.photo_data?.caption,
485
+ spec.text_data?.message,
486
+ spec.template_data?.message,
487
+ ...(creative?.asset_feed_spec?.bodies || []).map((b) => b.text),
488
+ ];
489
+ return [...new Set(found.filter((b) => b))];
490
+ };
491
+
492
+ // A page post is only shown to a token that carries the page's own
493
+ // permissions. A token for the page can be asked for, once per page.
494
+ const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000;
495
+ const pageTokenCache = new Map();
496
+
497
+ /**
498
+ * A token for one page, from the token in the settings. Comes back null when
499
+ * the settings token has no say over that page, which is the usual reason an
500
+ * ad that boosts a page post has nothing to show.
501
+ */
502
+ const getPageAccessToken = async (pageId, cfg) => {
503
+ if (!pageId) return null;
504
+ const key = `${pageId}|${cfg?.access_token || ""}`;
505
+ const hit = pageTokenCache.get(key);
506
+ if (hit && Date.now() - hit.at < PAGE_TOKEN_TTL_MS) return hit.token;
507
+ let token = null;
508
+ try {
509
+ const json = await graphFetch(
510
+ `/${pageId}`,
511
+ { query: { fields: "access_token" } },
512
+ cfg
513
+ );
514
+ token = json?.access_token || null;
515
+ } catch (e) {
516
+ if (cfg?.log_requests)
517
+ console.log(`Meta: no token for page ${pageId}`, e.message);
518
+ }
519
+ pageTokenCache.set(key, { token, at: Date.now() });
520
+ return token;
521
+ };
522
+
523
+ /**
524
+ * Which tokens to try when reading the post behind an ad, best first: one
525
+ * for the page that owns it, then the one from the settings.
526
+ */
527
+ const postTokens = async (creative, storyId, cfg, opts = {}) => {
528
+ const pageId =
529
+ creative?.object_story_spec?.page_id || `${storyId}`.split("_")[0];
530
+ const pageToken =
531
+ opts.page_token === false ? null : await getPageAccessToken(pageId, cfg);
532
+ return {
533
+ pageId,
534
+ pageToken,
535
+ cfgs: pageToken ? [{ ...cfg, access_token: pageToken }, cfg] : [cfg],
536
+ };
537
+ };
538
+
539
+ /**
540
+ * The wording of the page post an ad promotes: the post's own text, and the
541
+ * headline of the link it carries.
542
+ */
543
+ const storyText = async (storyId, cfg) => {
544
+ const json = await graphFetch(
545
+ `/${storyId}`,
546
+ { query: { fields: "message,attachments{title,description}" } },
547
+ cfg
548
+ );
549
+ const attachment = json?.attachments?.data?.[0] || {};
550
+ return {
551
+ headline: attachment.title || "",
552
+ body: json?.message || attachment.description || "",
553
+ };
554
+ };
555
+
556
+ /**
557
+ * The wording of an ad: { headline, body }, where body is the primary text
558
+ * above the image. Takes an ad id, or an ad that has already been read with
559
+ * its creative, in which case nothing is read again.
560
+ *
561
+ * Ads that promote a post which already exists on the page keep their wording
562
+ * on the post, which needs a second read and a token with access to the page.
563
+ * When that read is not allowed the wording comes back empty rather than
564
+ * throwing.
565
+ */
566
+ const getAdText = async (ad, cfg, opts = {}) => {
567
+ let creative = typeof ad === "object" ? ad?.creative : null;
568
+ if (!creative?.object_story_spec && !creative?.title && !creative?.body) {
569
+ const fetched = await graphFetch(
570
+ `/${typeof ad === "object" ? ad?.id : ad}`,
571
+ { query: { fields: `creative{${CREATIVE_TEXT_FIELDS.join(",")}}` } },
572
+ cfg
573
+ );
574
+ creative = fetched?.creative;
575
+ }
576
+ const text = {
577
+ headline: creativeHeadlines(creative)[0] || "",
578
+ body: creativeBodies(creative)[0] || "",
579
+ };
580
+ if ((!text.headline || !text.body) && creative?.effective_object_story_id) {
581
+ const storyId = creative.effective_object_story_id;
582
+ const { cfgs } = await postTokens(creative, storyId, cfg, opts);
583
+ for (const useCfg of cfgs)
584
+ try {
585
+ const story = await storyText(storyId, useCfg);
586
+ return {
587
+ headline: text.headline || story.headline,
588
+ body: text.body || story.body,
589
+ };
590
+ } catch (e) {
591
+ if (cfg?.log_requests)
592
+ console.log("Meta: could not read the post behind the ad", e.message);
593
+ }
594
+ }
595
+ return text;
596
+ };
597
+
598
+ /** The headline of an ad, as getAdText */
599
+ const getAdHeadline = async (ad, cfg) => (await getAdText(ad, cfg)).headline;
600
+
601
+ /** The primary text of an ad, the wording above the image, as getAdText */
602
+ const getAdBody = async (ad, cfg) => (await getAdText(ad, cfg)).body;
603
+
604
+ //
605
+ // Media: the picture or the film an ad is built on
606
+ //
607
+
608
+ // What tells two pieces of media apart. The same picture reached by two
609
+ // routes - once by hash and once by address - is the same picture.
610
+ const MEDIA_IDS = ["video_id", "image_hash", "url"];
611
+
612
+ /**
613
+ * Add one piece of media to the list, or, when it is already there under
614
+ * another of its names, fill in what that entry was missing.
615
+ */
616
+ const addMedia = (out, m) => {
617
+ if (!m || !MEDIA_IDS.some((k) => m[k])) return;
618
+ const same = out.find(
619
+ (o) => o.kind === m.kind && MEDIA_IDS.some((k) => m[k] && o[k] === m[k])
620
+ );
621
+ if (!same) {
622
+ out.push(m);
623
+ return;
624
+ }
625
+ Object.entries(m).forEach(([k, v]) => {
626
+ if (v && !same[k]) same[k] = v;
627
+ });
628
+ };
629
+
630
+ /** A carousel card, or a plain link ad, which are shaped the same way */
631
+ const linkMedia = (data) =>
632
+ data.video_id
633
+ ? {
634
+ kind: "video",
635
+ video_id: data.video_id,
636
+ thumbnail_url: data.picture || data.image_url,
637
+ image_hash: undefined,
638
+ thumbnail_hash: data.image_hash,
639
+ name: data.name,
640
+ link: data.link,
641
+ }
642
+ : {
643
+ kind: "image",
644
+ url: data.picture || data.image_url,
645
+ image_hash: data.image_hash,
646
+ name: data.name,
647
+ link: data.link,
648
+ };
649
+
650
+ /**
651
+ * Every piece of media on a creative, without reading anything further. A
652
+ * video carries its own still as a thumbnail rather than as a picture of its
653
+ * own, so a film never counts as a picture too.
654
+ */
655
+ const creativeMedia = (creative) => {
656
+ const spec = creative?.object_story_spec || {};
657
+ const feed = creative?.asset_feed_spec || {};
658
+ const out = [];
659
+
660
+ if (spec.video_data)
661
+ addMedia(out, {
662
+ kind: "video",
663
+ video_id: spec.video_data.video_id,
664
+ thumbnail_url: spec.video_data.image_url,
665
+ thumbnail_hash: spec.video_data.image_hash,
666
+ name: spec.video_data.title,
667
+ });
668
+ if (spec.photo_data)
669
+ addMedia(out, {
670
+ kind: "image",
671
+ url: spec.photo_data.url,
672
+ image_hash: spec.photo_data.image_hash,
673
+ });
674
+ [spec.link_data, spec.template_data].forEach((data) => {
675
+ if (!data) return;
676
+ const children = data.child_attachments || [];
677
+ if (children.length) children.forEach((c) => addMedia(out, linkMedia(c)));
678
+ else addMedia(out, linkMedia(data));
679
+ });
680
+
681
+ // Flexible and dynamic creatives keep a pool of assets for Meta to choose
682
+ // between, rather than one story
683
+ (feed.videos || []).forEach((v) =>
684
+ addMedia(out, {
685
+ kind: "video",
686
+ video_id: v.video_id,
687
+ thumbnail_url: v.thumbnail_url,
688
+ thumbnail_hash: v.thumbnail_hash,
689
+ })
690
+ );
691
+ (feed.images || []).forEach((i) =>
692
+ addMedia(out, { kind: "image", url: i.url, image_hash: i.hash })
693
+ );
694
+
695
+ // Last, what the creative says about itself. On many ads this is the same
696
+ // media again, which the key above discards.
697
+ if (creative?.video_id)
698
+ addMedia(out, {
699
+ kind: "video",
700
+ video_id: creative.video_id,
701
+ thumbnail_url: creative.thumbnail_url,
702
+ });
703
+ else if (creative?.image_url || creative?.image_hash)
704
+ addMedia(out, {
705
+ kind: "image",
706
+ url: creative.image_url,
707
+ image_hash: creative.image_hash,
708
+ });
709
+
710
+ return out;
711
+ };
712
+
713
+ /**
714
+ * The media on a page post an ad promotes, for ads that carry no creative of
715
+ * their own. An album post holds its pictures one level further down.
716
+ */
717
+ const storyMedia = async (storyId, cfg) => {
718
+ const json = await graphFetch(
719
+ `/${storyId}`,
720
+ {
721
+ query: {
722
+ fields:
723
+ "permalink_url,full_picture," +
724
+ "attachments{media_type,media,target,url,subattachments{media_type,media,target}}",
725
+ },
726
+ },
727
+ cfg
728
+ );
729
+ const out = [];
730
+ let album = false;
731
+ const fromAttachment = (att) => {
732
+ if (!att) return;
733
+ const type = `${att.media_type || ""}`.toLowerCase();
734
+ const still = att.media?.image?.src;
735
+ if (type.includes("video"))
736
+ addMedia(out, {
737
+ kind: "video",
738
+ video_id: att.target?.id,
739
+ thumbnail_url: still,
740
+ });
741
+ else if (still) addMedia(out, { kind: "image", url: still });
742
+ };
743
+ (json?.attachments?.data || []).forEach((att) => {
744
+ const subs = att.subattachments?.data || [];
745
+ if (subs.length > 1) album = true;
746
+ if (subs.length) subs.forEach(fromAttachment);
747
+ else fromAttachment(att);
748
+ });
749
+ // full_picture is whatever the post shows in a feed. For a film that is a
750
+ // still rather than the film, so it is offered as a thumbnail and never as
751
+ // a picture in its own right.
752
+ return {
753
+ media: out,
754
+ album,
755
+ thumbnail_url: json?.full_picture,
756
+ permalink_url: json?.permalink_url,
757
+ };
758
+ };
759
+
760
+ /**
761
+ * The media on the post an ad boosts, tried first with a token for the page
762
+ * that owns the post and then with the token from the settings. Never
763
+ * throws: what went wrong comes back as the reason, to be reported on the ad
764
+ * rather than stopping a run over a whole ad set.
765
+ */
766
+ const postMedia = async (creative, cfg, opts = {}) => {
767
+ const storyId = creative?.effective_object_story_id;
768
+ if (!storyId) return { media: [] };
769
+ const { pageId, pageToken, cfgs } = await postTokens(
770
+ creative,
771
+ storyId,
772
+ cfg,
773
+ opts
774
+ );
775
+ let reason;
776
+ for (const useCfg of cfgs) {
777
+ try {
778
+ const post = await storyMedia(storyId, useCfg);
779
+ if (post.media.length) return { ...post, story_id: storyId };
780
+ reason = `the post ${storyId} behind this ad carries no picture or film`;
781
+ } catch (e) {
782
+ reason = `could not read the post ${storyId} behind this ad: ${e.message}`;
783
+ if (cfg?.log_requests) console.log(`Meta: ${reason}`);
784
+ }
785
+ }
786
+ if (!pageToken)
787
+ 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`;
788
+ return { media: [], story_id: storyId, error: reason };
789
+ };
790
+
791
+ /**
792
+ * Turn the ids and hashes collected above into addresses a file can be
793
+ * downloaded from. Videos are read one at a time, pictures all in one go.
794
+ *
795
+ * The address of a video is only given out to a token that owns it, and it is
796
+ * signed and short lived, so download it now rather than storing it. When it
797
+ * cannot be read the entry keeps its id and its thumbnail.
798
+ */
799
+ const resolveMediaUrls = async (media, accountId, cfg) => {
800
+ const videos = media.filter((m) => m.kind === "video" && m.video_id);
801
+ for (const m of videos) {
802
+ try {
803
+ const v = await graphFetch(
804
+ `/${m.video_id}`,
805
+ {
806
+ query: {
807
+ fields: "id,source,picture,permalink_url,length,created_time",
808
+ },
809
+ },
810
+ cfg
811
+ );
812
+ m.url = v?.source || m.url;
813
+ m.thumbnail_url = m.thumbnail_url || v?.picture;
814
+ m.permalink_url = v?.permalink_url;
815
+ if (typeof v?.length === "number") m.length = v.length;
816
+ } catch (e) {
817
+ m.error = e.message;
818
+ if (cfg?.log_requests)
819
+ console.log(`Meta: could not read video ${m.video_id}`, e.message);
820
+ }
821
+ }
822
+
823
+ const needHash = media.filter((m) => !m.url && m.image_hash);
824
+ if (!needHash.length) return media;
825
+ if (!accountId) {
826
+ needHash.forEach((m) => {
827
+ m.error =
828
+ "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";
829
+ });
830
+ return media;
831
+ }
832
+
833
+ // The picture library only answers about so many at a time
834
+ const hashes = [...new Set(needHash.map((m) => m.image_hash))];
835
+ const byHash = {};
836
+ let reason;
837
+ for (let i = 0; i < hashes.length; i += 50) {
838
+ try {
839
+ const json = await graphFetch(
840
+ `/${actId(accountId)}/adimages`,
841
+ {
842
+ query: {
843
+ // a list of its own, not the comma separated kind Meta takes
844
+ // elsewhere
845
+ hashes: JSON.stringify(hashes.slice(i, i + 50)),
846
+ fields: "hash,url,permalink_url,width,height",
847
+ },
848
+ },
849
+ cfg
850
+ );
851
+ // Depending on the API version this comes back as a list or as an
852
+ // object keyed by hash
853
+ const images = Array.isArray(json?.data)
854
+ ? json.data
855
+ : Object.values(json?.images || {});
856
+ images.forEach((img) => {
857
+ if (img?.hash) byHash[img.hash] = img;
858
+ });
859
+ } catch (e) {
860
+ reason = `could not read the pictures of ad account ${actId(
861
+ accountId
862
+ )}: ${e.message}`;
863
+ if (cfg?.log_requests) console.log(`Meta: ${reason}`);
864
+ }
865
+ }
866
+ needHash.forEach((m) => {
867
+ const img = byHash[m.image_hash];
868
+ if (!img) {
869
+ m.error =
870
+ reason ||
871
+ `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`;
872
+ return;
873
+ }
874
+ m.url = img.url;
875
+ m.permalink_url = img.permalink_url;
876
+ m.width = img.width;
877
+ m.height = img.height;
878
+ });
879
+
880
+ return media;
881
+ };
882
+
883
+ /**
884
+ * Why some of what an ad is made of has no address to download it from,
885
+ * said once rather than once per picture.
886
+ */
887
+ const mediaErrors = (media) => {
888
+ const stuck = media.filter((m) => m.error && !m.url);
889
+ if (!stuck.length) return undefined;
890
+ const reasons = [...new Set(stuck.map((m) => m.error))];
891
+ return `${stuck.length} of ${media.length} could not be reached: ${reasons.join(
892
+ "; "
893
+ )}`;
894
+ };
895
+
896
+ /** What kind of ad this is, from the media it was built on */
897
+ const mediaType = (media) => {
898
+ const hasVideo = media.some((m) => m.kind === "video");
899
+ const hasImage = media.some((m) => m.kind === "image");
900
+ if (hasVideo && hasImage) return "mixed";
901
+ if (hasVideo) return "video";
902
+ if (hasImage) return "image";
903
+ return "unknown";
904
+ };
905
+
906
+ /**
907
+ * What an ad is made of and where to download it from:
908
+ *
909
+ * { type, carousel, media: [{ kind, url, thumbnail_url, ... }],
910
+ * creative_id, object_type, thumbnail_url, from_post, story_id, error }
911
+ *
912
+ * type is image, video, mixed - a dynamic creative offering Meta both - or
913
+ * unknown when nothing could be found. carousel says whether the ad holds
914
+ * more than one card; the cards are the media list, in the order they are
915
+ * shown. thumbnail_url is a picture of the ad as it appears, which is there
916
+ * even when the media itself cannot be reached, and error says why it could
917
+ * not.
918
+ *
919
+ * Takes an ad id, or an ad row that already has its creative. A creative
920
+ * that carries no media is read again in full, since the caller may have
921
+ * asked Meta for only some of the places media can hide, so an ad read with
922
+ * the media fields of its own saves a read here.
923
+ *
924
+ * Options: resolve_urls false skips turning video ids and image hashes into
925
+ * addresses, which saves a read per video when all you want is the type;
926
+ * page_token false stops it asking for a token for the page behind a boosted
927
+ * post.
928
+ */
929
+ const getAdMedia = async (ad, cfg, opts = {}) => {
930
+ const adId = typeof ad === "object" ? ad?.id : ad;
931
+ let creative = typeof ad === "object" ? ad?.creative : null;
932
+ let accountId =
933
+ opts.account_id ||
934
+ (typeof ad === "object" ? ad?.account_id : null) ||
935
+ creative?.account_id;
936
+ let media = creativeMedia(creative);
937
+
938
+ // Nothing on what we were handed. Ask for every field media can arrive in
939
+ // before giving up on the creative, and for a thumbnail big enough to be
940
+ // worth looking at.
941
+ if (!media.length && adId) {
942
+ const fetched = await graphFetch(
943
+ `/${adId}`,
944
+ {
945
+ query: {
946
+ fields: `account_id,creative{${CREATIVE_MEDIA_FIELDS.join(",")}}`,
947
+ thumbnail_width: opts.thumbnail_width || 1200,
948
+ thumbnail_height: opts.thumbnail_height || 1200,
949
+ },
950
+ },
951
+ cfg
952
+ );
953
+ creative = fetched?.creative || creative;
954
+ accountId = accountId || fetched?.account_id || creative?.account_id;
955
+ media = creativeMedia(creative);
956
+ }
957
+ accountId = accountId || cfg?.ad_account_id;
958
+
959
+ // Still nothing: an ad that boosts a post keeps its media on the post
960
+ let post = {};
961
+ if (!media.length) {
962
+ post = await postMedia(creative, cfg, opts);
963
+ media = post.media;
964
+ }
965
+
966
+ if (opts.resolve_urls !== false) {
967
+ await resolveMediaUrls(media, accountId, cfg);
968
+ // Found something, but none of it can be downloaded: a creative can name
969
+ // pictures the ad account does not hold. The post an ad boosts carries
970
+ // addresses that work, so it is worth asking after all.
971
+ if (
972
+ media.length &&
973
+ !media.some((m) => m.url) &&
974
+ !post.media &&
975
+ creative?.effective_object_story_id
976
+ ) {
977
+ const fallback = await postMedia(creative, cfg, opts);
978
+ await resolveMediaUrls(fallback.media, accountId, cfg);
979
+ if (fallback.media.some((m) => m.url)) {
980
+ post = fallback;
981
+ media = fallback.media;
982
+ }
983
+ }
984
+ }
985
+
986
+ const children =
987
+ creative?.object_story_spec?.link_data?.child_attachments ||
988
+ creative?.object_story_spec?.template_data?.child_attachments ||
989
+ [];
990
+ return {
991
+ type: mediaType(media),
992
+ carousel: children.length > 1 || !!post.album,
993
+ media,
994
+ creative_id: creative?.id,
995
+ object_type: creative?.object_type,
996
+ thumbnail_url: creative?.thumbnail_url || post.thumbnail_url,
997
+ from_post: !!post.media?.length,
998
+ story_id: post.story_id,
999
+ permalink_url: post.permalink_url,
1000
+ error: post.error || mediaErrors(media),
1001
+ };
1002
+ };
1003
+
1004
+ /** Whether an ad is an image, a video, mixed or unknown, as getAdMedia */
1005
+ const getAdMediaType = async (ad, cfg) =>
1006
+ (await getAdMedia(ad, cfg, { resolve_urls: false })).type;
1007
+
1008
+ /**
1009
+ * The address of the first picture or film in an ad, ready to download, or
1010
+ * the empty string when there is none
1011
+ */
1012
+ const getAdMediaUrl = async (ad, cfg) => {
1013
+ const { media } = await getAdMedia(ad, cfg);
1014
+ return media.find((m) => m.url)?.url || "";
1015
+ };
1016
+
423
1017
  //
424
1018
  // Insights
425
1019
  //
@@ -523,6 +1117,8 @@ module.exports = {
523
1117
  GRAPH_HOST,
524
1118
  DEFAULT_API_VERSION,
525
1119
  DEFAULT_FIELDS,
1120
+ CREATIVE_TEXT_FIELDS,
1121
+ CREATIVE_MEDIA_FIELDS,
526
1122
  BASE_INSIGHTS_FIELDS,
527
1123
  INSIGHTS_LEVEL_FIELDS,
528
1124
  NUMERIC_INSIGHTS_FIELDS,
@@ -551,6 +1147,22 @@ module.exports = {
551
1147
  getAdCreatives,
552
1148
  getAdCreative,
553
1149
  getAdPreview,
1150
+ creativeHeadlines,
1151
+ creativeBodies,
1152
+ storyText,
1153
+ getAdText,
1154
+ getAdHeadline,
1155
+ getAdBody,
1156
+ creativeMedia,
1157
+ storyMedia,
1158
+ postMedia,
1159
+ postTokens,
1160
+ getPageAccessToken,
1161
+ mediaErrors,
1162
+ mediaType,
1163
+ getAdMedia,
1164
+ getAdMediaType,
1165
+ getAdMediaUrl,
554
1166
  getInsights,
555
1167
  startInsightsReport,
556
1168
  getReportRun,