@saltcorn/meta-marketing-api 0.1.1 → 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/README.md CHANGED
@@ -136,6 +136,10 @@ a different token.
136
136
  | `get_meta_ad_headline(ad)` | The headline of an ad, from an ad id or an ad you have already read |
137
137
  | `get_meta_ad_body(ad)` | The primary text of an ad: the longer wording above the image |
138
138
  | `get_meta_ad_text(ad)` | Both of the above together, as `{ headline, body }`, in one read |
139
+ | `get_meta_ad_media(ad, options)` | What an ad is made of and where to download it |
140
+ | `get_meta_ad_media_type(ad)` | Whether an ad is an `image`, a `video`, `mixed` or `unknown` |
141
+ | `get_meta_ad_media_url(ad)` | The address of the picture or the film in an ad |
142
+ | `get_meta_page_access_token(pageId)` | A token for one of your pages, or nothing when you have no access to it |
139
143
  | `get_meta_ad_preview(adId, adFormat)` | A ready made HTML preview of an ad |
140
144
  | `get_meta_insights(objectId, query)` | Performance figures for an account, campaign, ad set or ad |
141
145
  | `get_meta_insights_async(objectId, query)` | The same, run as a background report, for large date ranges |
@@ -177,11 +181,149 @@ get_meta_ad_text(ad_id)
177
181
 
178
182
  which gives you `{ headline: "...", body: "..." }`, where the body is the
179
183
  longer text above the image. For an ad that is boosting a post already on
180
- your page, the wording belongs to the post rather than to the ad, and reading
181
- it needs a token that can also read the page. When it cannot be read you get
184
+ your page, the wording belongs to the post rather than to the ad; the module
185
+ asks Meta for a token for that page and reads it with that, which needs the
186
+ access token in the settings to have a say over the page, as described under
187
+ *Ads that boost a post on your page* below. When it cannot be read you get
182
188
  empty text back rather than an error; `get_meta_ad_preview` will still show
183
189
  you the ad as it appears.
184
190
 
191
+ ### The picture or the film in an ad
192
+
193
+ `get_meta_ad_media` tells you what kind of ad you are looking at and where to
194
+ download the file it is built on:
195
+
196
+ ```
197
+ get_meta_ad_media(ad_id)
198
+ ```
199
+
200
+ gives you
201
+
202
+ ```
203
+ {
204
+ type: "video",
205
+ carousel: false,
206
+ media: [
207
+ {
208
+ kind: "video",
209
+ video_id: "1234",
210
+ url: "https://video.xx.fbcdn.net/...",
211
+ thumbnail_url: "https://scontent.xx.fbcdn.net/...",
212
+ permalink_url: "...",
213
+ length: 15
214
+ }
215
+ ],
216
+ creative_id: "5678",
217
+ object_type: "VIDEO",
218
+ thumbnail_url: "https://scontent.xx.fbcdn.net/...",
219
+ from_post: false,
220
+ error: undefined
221
+ }
222
+ ```
223
+
224
+ - **type** is `image`, `video`, `mixed` or `unknown`. `mixed` means the ad
225
+ offers Meta both to choose between, which is what a flexible or dynamic
226
+ creative does, or a carousel with both in it. `unknown` means there is no
227
+ picture or film to be found, as on a text only ad.
228
+ - **carousel** says whether the ad holds more than one card. The cards are
229
+ the entries in `media`, in the order they are shown.
230
+ - **media** has one entry per picture or film, each with a `url` you can
231
+ download. The still that a film shows before it plays is on that film's
232
+ entry as `thumbnail_url`, and does not count as a picture of its own. An
233
+ entry that could not be reached has no `url` and carries an `error` saying
234
+ why instead.
235
+ - **thumbnail_url** is a picture of the ad as it appears. It is there even
236
+ when nothing else is, so it is worth keeping as a fallback: for a film it
237
+ is a still rather than the film itself, which is why it is not in `media`.
238
+ - **from_post** says the ad is boosting a post that was already on your page,
239
+ so the media was read from the post rather than from the ad.
240
+ - **error** is there only when something could not be read, and says what.
241
+ An ad never stops a run over an ad set because of it: you get `unknown`
242
+ and the reason.
243
+
244
+ ### Ads that boost a post on your page
245
+
246
+ Much of what is advertised on Facebook and Instagram is a post that already
247
+ exists on a page. Such an ad keeps nothing on the creative but the id of the
248
+ page and of the post:
249
+
250
+ ```
251
+ {
252
+ "object_type": "SHARE",
253
+ "object_story_spec": { "page_id": "1067...", "instagram_user_id": "1784..." },
254
+ "effective_object_story_id": "1067..._1334..."
255
+ }
256
+ ```
257
+
258
+ The picture or the film is on the post, and Meta only shows a post to a token
259
+ that carries that page's own permissions. `get_meta_ad_media` asks for a
260
+ token for the page by itself and reads the post with it, so these ads work
261
+ like any other, as long as the access token in the settings has a say over
262
+ the page. That means:
263
+
264
+ - the token must have the **pages_read_engagement** permission, and
265
+ - the person or system user it belongs to must have a role on that page. In
266
+ Business Manager, add the page to the same business as the ad account and
267
+ give the system user access to it.
268
+
269
+ When that is missing you get `type: "unknown"` and an `error` saying so,
270
+ rather than silence. To check one page on its own:
271
+
272
+ ```
273
+ get_meta_page_access_token("106755536029753")
274
+ ```
275
+
276
+ Nothing back means the token has no say over that page. `thumbnail_url` is
277
+ still filled in for these ads, so you have a picture of the ad to look at
278
+ even when the post itself cannot be read.
279
+
280
+ A few things are worth knowing before you download:
281
+
282
+ - **Video addresses are signed and short lived.** Fetch the file as soon as
283
+ you have the address rather than storing the address for later. Meta only
284
+ gives the address out to a token that owns the video: with a read only
285
+ token you may get the video's id and its thumbnail but no `url`, and the
286
+ entry then carries an `error` saying why. `permalink_url` is a stable
287
+ address for watching it, not for downloading it.
288
+ - **Working out the type is cheaper than finding the addresses.** Every film
289
+ costs one extra read to look up. If all you want is image against video,
290
+ use `get_meta_ad_media_type`, or pass the options `{ resolve_urls: false }`.
291
+ - **Pictures are often named by a hash rather than by an address.** The
292
+ address is then held by the ad account's own picture library, which is
293
+ looked up for you, so reading ads of a second ad account works better when
294
+ you pass that account's id: `get_meta_ad_media(ad, { account_id: "..." })`,
295
+ or read the ads with `account_id` among the fields. When the library does
296
+ not hold the picture and the ad boosts a post, the post is read instead.
297
+ - **An ad whose creative shows no media is read again in full.** You may have
298
+ asked Meta for only some of the places media can hide, so rather than
299
+ answer `unknown` too readily it asks for all of them. Handing it an ad you
300
+ read with the creative fields below saves that second read.
301
+
302
+ To go through a whole ad set:
303
+
304
+ ```
305
+ const ads = await get_meta_adset_ads(adset_id, {
306
+ fields:
307
+ "id,name,account_id,creative{id,name,object_type,image_url,image_hash," +
308
+ "video_id,thumbnail_url,object_story_spec,asset_feed_spec," +
309
+ "effective_object_story_id}"
310
+ });
311
+ for (const ad of ads) {
312
+ const { type, media } = await get_meta_ad_media(ad);
313
+ console.log(ad.name, type, media.map((m) => m.url));
314
+ }
315
+ ```
316
+
317
+ Asking for those creative fields is worth doing on a loop like this: given an
318
+ ad that already carries them, `get_meta_ad_media` works from what you have
319
+ instead of reading each ad again. Given only an ad id, it asks for them
320
+ itself.
321
+
322
+ Ads read as a table row, or by `get_meta_ads` without a `fields` of your own,
323
+ carry `creative_object_type`. That is a rough answer on its own: most ads
324
+ that link somewhere are reported as `SHARE` whether the media is a picture or
325
+ a film, so use `get_meta_ad_media` when it matters.
326
+
185
327
  ## Things to know
186
328
 
187
329
  - **Money is in cents.** Budgets, bids and amounts spent come from Meta as
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",
@@ -473,6 +489,53 @@ const creativeBodies = (creative) => {
473
489
  return [...new Set(found.filter((b) => b))];
474
490
  };
475
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
+
476
539
  /**
477
540
  * The wording of the page post an ad promotes: the post's own text, and the
478
541
  * headline of the link it carries.
@@ -500,7 +563,7 @@ const storyText = async (storyId, cfg) => {
500
563
  * When that read is not allowed the wording comes back empty rather than
501
564
  * throwing.
502
565
  */
503
- const getAdText = async (ad, cfg) => {
566
+ const getAdText = async (ad, cfg, opts = {}) => {
504
567
  let creative = typeof ad === "object" ? ad?.creative : null;
505
568
  if (!creative?.object_story_spec && !creative?.title && !creative?.body) {
506
569
  const fetched = await graphFetch(
@@ -514,17 +577,21 @@ const getAdText = async (ad, cfg) => {
514
577
  headline: creativeHeadlines(creative)[0] || "",
515
578
  body: creativeBodies(creative)[0] || "",
516
579
  };
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
- }
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
+ }
528
595
  return text;
529
596
  };
530
597
 
@@ -534,6 +601,419 @@ const getAdHeadline = async (ad, cfg) => (await getAdText(ad, cfg)).headline;
534
601
  /** The primary text of an ad, the wording above the image, as getAdText */
535
602
  const getAdBody = async (ad, cfg) => (await getAdText(ad, cfg)).body;
536
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
+
537
1017
  //
538
1018
  // Insights
539
1019
  //
@@ -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,
package/index.js CHANGED
@@ -27,6 +27,10 @@ const {
27
27
  getAdText,
28
28
  getAdHeadline,
29
29
  getAdBody,
30
+ getAdMedia,
31
+ getAdMediaType,
32
+ getAdMediaUrl,
33
+ getPageAccessToken,
30
34
  getInsights,
31
35
  getInsightsAsync,
32
36
  exchangeLongLivedToken,
@@ -413,6 +417,45 @@ module.exports = {
413
417
  "Get the headline and the primary text of an ad together, as { headline, body }, in one read",
414
418
  arguments: [{ name: "ad", type: "String" }],
415
419
  },
420
+ get_meta_ad_media: {
421
+ async run(ad, opts, cfgOverRide) {
422
+ return await getAdMedia(ad, { ...cfg, ...cfgOverRide }, opts || {});
423
+ },
424
+ isAsync: true,
425
+ description:
426
+ "Get what an ad is made of and where to download it: { type, carousel, media }, where type is image, video, mixed or unknown and each entry in media has a url. Takes an ad id or an ad row that already has its creative. Set resolve_urls false in the options to only work out the type, without reading the address of every file.",
427
+ arguments: [
428
+ { name: "ad", type: "String" },
429
+ { name: "opts", type: "JSON" },
430
+ ],
431
+ },
432
+ get_meta_ad_media_type: {
433
+ async run(ad, cfgOverRide) {
434
+ return await getAdMediaType(ad, { ...cfg, ...cfgOverRide });
435
+ },
436
+ isAsync: true,
437
+ description:
438
+ "Whether an ad is an image, a video, mixed or unknown. Takes an ad id or an ad row that already has its creative.",
439
+ arguments: [{ name: "ad", type: "String" }],
440
+ },
441
+ get_meta_ad_media_url: {
442
+ async run(ad, cfgOverRide) {
443
+ return await getAdMediaUrl(ad, { ...cfg, ...cfgOverRide });
444
+ },
445
+ isAsync: true,
446
+ description:
447
+ "The address of the picture or the film in an ad, ready to download. Video addresses are signed and short lived, so download the file straight away.",
448
+ arguments: [{ name: "ad", type: "String" }],
449
+ },
450
+ get_meta_page_access_token: {
451
+ async run(pageId, cfgOverRide) {
452
+ return await getPageAccessToken(pageId, { ...cfg, ...cfgOverRide });
453
+ },
454
+ isAsync: true,
455
+ description:
456
+ "A token for one of your pages, or nothing when your access token has no say over that page. Use it to check why an ad that boosts a page post shows nothing.",
457
+ arguments: [{ name: "pageId", type: "String" }],
458
+ },
416
459
  get_meta_ad_preview: {
417
460
  async run(adId, adFormat, cfgOverRide) {
418
461
  return await getAdPreview(adId, adFormat, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/meta-marketing-api",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Read ads on Facebook and Instagram with the Meta Marketing API",
5
5
  "main": "index.js",
6
6
  "dependencies": {
package/tests/api.test.js CHANGED
@@ -8,6 +8,8 @@ const {
8
8
  DEFAULT_FIELDS,
9
9
  creativeHeadlines,
10
10
  creativeBodies,
11
+ creativeMedia,
12
+ mediaType,
11
13
  } = require("../api");
12
14
 
13
15
  describe("query string encoding", () => {
@@ -201,3 +203,115 @@ describe("default fields", () => {
201
203
  expect(DEFAULT_FIELDS.adcreative).toContain("body");
202
204
  });
203
205
  });
206
+
207
+ describe("finding the media on a creative", () => {
208
+ it("reads the film of a video ad, with its still as the thumbnail", () => {
209
+ const media = creativeMedia({
210
+ video_id: "77",
211
+ object_story_spec: {
212
+ video_data: {
213
+ video_id: "77",
214
+ title: "Watch",
215
+ image_url: "https://example.com/still.jpg",
216
+ image_hash: "abc",
217
+ },
218
+ },
219
+ });
220
+ expect(media).toEqual([
221
+ {
222
+ kind: "video",
223
+ video_id: "77",
224
+ thumbnail_url: "https://example.com/still.jpg",
225
+ thumbnail_hash: "abc",
226
+ name: "Watch",
227
+ },
228
+ ]);
229
+ // the still of a film is not a picture of its own
230
+ expect(mediaType(media)).toBe("video");
231
+ });
232
+ it("reads the picture of a link ad", () => {
233
+ const media = creativeMedia({
234
+ object_story_spec: {
235
+ link_data: {
236
+ name: "Half price",
237
+ image_hash: "hash1",
238
+ picture: "https://example.com/pic.jpg",
239
+ link: "https://example.com",
240
+ },
241
+ },
242
+ });
243
+ expect(media).toEqual([
244
+ {
245
+ kind: "image",
246
+ url: "https://example.com/pic.jpg",
247
+ image_hash: "hash1",
248
+ name: "Half price",
249
+ link: "https://example.com",
250
+ },
251
+ ]);
252
+ expect(mediaType(media)).toBe("image");
253
+ });
254
+ it("reads a photo ad", () => {
255
+ expect(
256
+ creativeMedia({
257
+ object_story_spec: {
258
+ photo_data: { url: "https://example.com/photo.jpg", image_hash: "h" },
259
+ },
260
+ })
261
+ ).toEqual([
262
+ { kind: "image", url: "https://example.com/photo.jpg", image_hash: "h" },
263
+ ]);
264
+ });
265
+ it("returns one entry per carousel card, in order", () => {
266
+ const media = creativeMedia({
267
+ object_story_spec: {
268
+ link_data: {
269
+ image_hash: "cover",
270
+ child_attachments: [
271
+ { image_hash: "h1", name: "Shoes" },
272
+ { video_id: "v2", name: "Hats", picture: "https://x/2.jpg" },
273
+ ],
274
+ },
275
+ },
276
+ });
277
+ expect(media.map((m) => m.kind)).toEqual(["image", "video"]);
278
+ expect(media.map((m) => m.name)).toEqual(["Shoes", "Hats"]);
279
+ // a carousel with a film in it counts as a mixed ad
280
+ expect(mediaType(media)).toBe("mixed");
281
+ });
282
+ it("reads the assets of a dynamic creative, without repeats", () => {
283
+ const media = creativeMedia({
284
+ image_url: "https://example.com/a.jpg",
285
+ asset_feed_spec: {
286
+ images: [{ hash: "h1", url: "https://example.com/a.jpg" }],
287
+ videos: [{ video_id: "v1", thumbnail_url: "https://example.com/t.jpg" }],
288
+ },
289
+ });
290
+ expect(media).toHaveLength(2);
291
+ expect(mediaType(media)).toBe("mixed");
292
+ });
293
+ it("does not repeat the same picture reached by two routes", () => {
294
+ const media = creativeMedia({
295
+ image_hash: "h1",
296
+ image_url: "https://example.com/a.jpg",
297
+ object_story_spec: {
298
+ link_data: { image_hash: "h1", picture: "https://example.com/a.jpg" },
299
+ },
300
+ });
301
+ expect(media).toHaveLength(1);
302
+ });
303
+ it("comes back empty for an ad that promotes an existing post", () => {
304
+ expect(creativeMedia({ effective_object_story_id: "1_2" })).toEqual([]);
305
+ expect(creativeMedia(null)).toEqual([]);
306
+ expect(mediaType([])).toBe("unknown");
307
+ });
308
+ });
309
+
310
+ describe("default fields", () => {
311
+ it("asks what kind of ad each ad is", () => {
312
+ const creative = DEFAULT_FIELDS.ad.find((f) => f.startsWith("creative{"));
313
+ expect(creative).toContain("object_type");
314
+ expect(creative).toContain("video_id");
315
+ expect(creative).toContain("image_url");
316
+ });
317
+ });
@@ -0,0 +1,291 @@
1
+ jest.mock("node-fetch");
2
+ const fetch = require("node-fetch");
3
+ const { getAdMedia, getAdMediaType, getAdText } = require("../api");
4
+
5
+ const cfg = { access_token: "USERTOKEN", max_retries: 0 };
6
+
7
+ // Answer each call from a list of [what the address must contain, the reply],
8
+ // and keep what was asked so a test can check which token was used
9
+ let calls;
10
+ const serve = (routes) => {
11
+ calls = [];
12
+ fetch.mockImplementation(async (url, opts) => {
13
+ calls.push({ url, auth: opts?.headers?.Authorization });
14
+ const hit = routes.find(([match]) => url.includes(match));
15
+ return {
16
+ status: 200,
17
+ headers: { get: () => null },
18
+ text: async () => JSON.stringify(hit ? hit[1] : { error: { code: 100, message: `no route for ${url}` } }),
19
+ };
20
+ });
21
+ };
22
+
23
+ // An ad that boosts a post already on the page: the creative names the page
24
+ // and the post, and carries no media of its own
25
+ const boostedAd = {
26
+ id: "120242825926110547",
27
+ name: "Version AI Remake",
28
+ creative: {
29
+ id: "782942930901202",
30
+ object_type: "SHARE",
31
+ object_story_spec: { page_id: "106755536029753", instagram_user_id: "17841401937168196" },
32
+ effective_object_story_id: "106755536029753_1333493868811467",
33
+ },
34
+ };
35
+
36
+ const videoPost = {
37
+ permalink_url: "https://facebook.com/post",
38
+ full_picture: "https://example.com/full.jpg",
39
+ attachments: {
40
+ data: [
41
+ {
42
+ media_type: "video",
43
+ media: { image: { src: "https://example.com/still.jpg" } },
44
+ target: { id: "999" },
45
+ },
46
+ ],
47
+ },
48
+ };
49
+
50
+ describe("an ad that boosts a page post", () => {
51
+ it("reads the post with a token for the page", async () => {
52
+ serve([
53
+ ["/120242825926110547", { account_id: "1", creative: { ...boostedAd.creative, thumbnail_url: "https://example.com/thumb.jpg" } }],
54
+ ["/106755536029753?", { access_token: "PAGETOKEN" }],
55
+ ["/106755536029753_1333493868811467", videoPost],
56
+ ["/999", { id: "999", source: "https://video.fb/film.mp4", picture: "https://example.com/still.jpg", length: 15 }],
57
+ ]);
58
+ const res = await getAdMedia(boostedAd, cfg);
59
+ expect(res.type).toBe("video");
60
+ expect(res.from_post).toBe(true);
61
+ expect(res.story_id).toBe("106755536029753_1333493868811467");
62
+ expect(res.media).toHaveLength(1);
63
+ expect(res.media[0].url).toBe("https://video.fb/film.mp4");
64
+ expect(res.media[0].thumbnail_url).toBe("https://example.com/still.jpg");
65
+ expect(res.error).toBeUndefined();
66
+ // the post is read as the page, not as the user
67
+ const post = calls.find((c) => c.url.includes("106755536029753_1333"));
68
+ expect(post.auth).toBe("Bearer PAGETOKEN");
69
+ });
70
+
71
+ it("says why when the token has no say over the page", async () => {
72
+ const ad = {
73
+ ...boostedAd,
74
+ creative: {
75
+ ...boostedAd.creative,
76
+ object_story_spec: { page_id: "222" },
77
+ effective_object_story_id: "222_333",
78
+ },
79
+ };
80
+ serve([
81
+ ["/120242825926110547", { account_id: "1", creative: { ...ad.creative, thumbnail_url: "https://example.com/thumb.jpg" } }],
82
+ ["/222?", { error: { code: 190, message: "no page access" } }],
83
+ ["/222_333", { error: { code: 100, message: "Unsupported get request" } }],
84
+ ]);
85
+ const res = await getAdMedia(ad, cfg);
86
+ expect(res.type).toBe("unknown");
87
+ expect(res.media).toEqual([]);
88
+ // not silence: the reason, and something to look at
89
+ expect(res.error).toMatch(/could not read the post 222_333/);
90
+ expect(res.error).toMatch(/pages_read_engagement/);
91
+ expect(res.thumbnail_url).toBe("https://example.com/thumb.jpg");
92
+ });
93
+ });
94
+
95
+ describe("reading the ad again", () => {
96
+ it("asks for every media field when handed a creative with none", async () => {
97
+ serve([
98
+ ["/120242825926110547", { account_id: "1", creative: { id: "782942930901202", video_id: "555", thumbnail_url: "https://example.com/t.jpg" } }],
99
+ ["/555", { id: "555", source: "https://video.fb/film.mp4" }],
100
+ ]);
101
+ // the caller asked Meta for a narrow set of fields, so image_hash,
102
+ // video_id and asset_feed_spec were never in the ad we were given
103
+ const res = await getAdMedia(boostedAd, cfg);
104
+ expect(res.type).toBe("video");
105
+ expect(res.media[0].url).toBe("https://video.fb/film.mp4");
106
+ const asked = calls[0].url;
107
+ expect(asked).toContain("video_id");
108
+ expect(asked).toContain("asset_feed_spec");
109
+ expect(asked).toContain("image_hash");
110
+ });
111
+
112
+ it("does not read the ad again when the creative already has media", async () => {
113
+ serve([["/act_", { data: [] }]]);
114
+ const ad = {
115
+ id: "1",
116
+ account_id: "9",
117
+ creative: {
118
+ id: "2",
119
+ object_story_spec: {
120
+ link_data: { image_hash: "h", picture: "https://example.com/p.jpg" },
121
+ },
122
+ },
123
+ };
124
+ const res = await getAdMedia(ad, cfg, { resolve_urls: false });
125
+ expect(res.type).toBe("image");
126
+ expect(calls).toHaveLength(0);
127
+ });
128
+
129
+ it("works out the type without looking up every address", async () => {
130
+ serve([
131
+ ["/1?", { account_id: "9", creative: { id: "2", video_id: "555" } }],
132
+ ]);
133
+ expect(await getAdMediaType({ id: "1" }, cfg)).toBe("video");
134
+ // one read for the ad, and no read of the video
135
+ expect(calls).toHaveLength(1);
136
+ });
137
+ });
138
+
139
+ describe("what counts as a carousel", () => {
140
+ it("a post with several pictures in it does", async () => {
141
+ serve([
142
+ ["/1?", { account_id: "9", creative: { id: "2", object_story_spec: { page_id: "444" }, effective_object_story_id: "444_555" } }],
143
+ ["/444?", { access_token: "PAGETOKEN" }],
144
+ ["/444_555", {
145
+ attachments: {
146
+ data: [
147
+ {
148
+ media_type: "album",
149
+ subattachments: {
150
+ data: [
151
+ { media_type: "photo", media: { image: { src: "https://example.com/1.jpg" } } },
152
+ { media_type: "photo", media: { image: { src: "https://example.com/2.jpg" } } },
153
+ ],
154
+ },
155
+ },
156
+ ],
157
+ },
158
+ }],
159
+ ]);
160
+ const res = await getAdMedia({ id: "1" }, cfg);
161
+ expect(res.type).toBe("image");
162
+ expect(res.carousel).toBe(true);
163
+ expect(res.media).toHaveLength(2);
164
+ });
165
+
166
+ it("a dynamic creative offering Meta a choice does not", async () => {
167
+ serve([["/act_", { data: [] }]]);
168
+ const ad = {
169
+ id: "1",
170
+ account_id: "9",
171
+ creative: {
172
+ id: "2",
173
+ asset_feed_spec: {
174
+ images: [
175
+ { hash: "h1", url: "https://example.com/1.jpg" },
176
+ { hash: "h2", url: "https://example.com/2.jpg" },
177
+ ],
178
+ videos: [{ video_id: "v1" }],
179
+ },
180
+ },
181
+ };
182
+ const res = await getAdMedia(ad, cfg, { resolve_urls: false });
183
+ expect(res.carousel).toBe(false);
184
+ expect(res.type).toBe("mixed");
185
+ expect(res.media).toHaveLength(3);
186
+ });
187
+ });
188
+
189
+ describe("the wording of an ad that boosts a post", () => {
190
+ it("reads the post with a token for the page", async () => {
191
+ serve([
192
+ ["/7?", { creative: { id: "2", object_story_spec: { page_id: "888" }, effective_object_story_id: "888_999" } }],
193
+ ["/888?", { access_token: "PAGETOKEN" }],
194
+ ["/888_999", { message: "The primary text", attachments: { data: [{ title: "The headline" }] } }],
195
+ ]);
196
+ expect(await getAdText("7", cfg)).toEqual({
197
+ headline: "The headline",
198
+ body: "The primary text",
199
+ });
200
+ const post = calls.find((c) => c.url.includes("888_999"));
201
+ expect(post.auth).toBe("Bearer PAGETOKEN");
202
+ });
203
+ });
204
+
205
+ // A flexible creative names its pictures by hash only. The address of each
206
+ // one is held by the ad account's picture library.
207
+ const hashAd = {
208
+ id: "120254266274370547",
209
+ name: "3_STJ16890_FB_Dental",
210
+ creative: {
211
+ id: "1700780627695097",
212
+ object_type: "SHARE",
213
+ object_story_spec: { page_id: "106755536029753" },
214
+ effective_object_story_id: "106755536029753_1515321970628655",
215
+ },
216
+ };
217
+
218
+ const hashCreative = {
219
+ ...hashAd.creative,
220
+ asset_feed_spec: {
221
+ images: [{ hash: "aaa" }, { hash: "bbb" }],
222
+ },
223
+ };
224
+
225
+ describe("pictures named by hash", () => {
226
+ it("asks the picture library as a list, which is how Meta wants hashes", async () => {
227
+ serve([
228
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
229
+ ["/adimages", {
230
+ data: [
231
+ { hash: "aaa", url: "https://example.com/a.jpg", width: 1080, height: 1080 },
232
+ { hash: "bbb", url: "https://example.com/b.jpg", width: 1080, height: 1080 },
233
+ ],
234
+ }],
235
+ ]);
236
+ const res = await getAdMedia(hashAd, cfg);
237
+ expect(res.type).toBe("image");
238
+ expect(res.media.map((m) => m.url)).toEqual([
239
+ "https://example.com/a.jpg",
240
+ "https://example.com/b.jpg",
241
+ ]);
242
+ expect(res.media[0].width).toBe(1080);
243
+ expect(res.error).toBeUndefined();
244
+ const lookup = calls.find((c) => c.url.includes("adimages"));
245
+ expect(lookup.url).toContain("act_998");
246
+ // a JSON list, not the comma separated kind Meta takes for fields
247
+ expect(decodeURIComponent(lookup.url)).toContain('hashes=["aaa","bbb"]');
248
+ });
249
+
250
+ it("falls back to the post when the ad account does not hold them", async () => {
251
+ serve([
252
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
253
+ ["/adimages", { data: [] }],
254
+ ["/106755536029753?", { access_token: "PAGETOKEN" }],
255
+ ["/106755536029753_1515321970628655", {
256
+ attachments: {
257
+ data: [{ media_type: "photo", media: { image: { src: "https://example.com/post.jpg" } } }],
258
+ },
259
+ }],
260
+ ]);
261
+ const res = await getAdMedia(hashAd, cfg);
262
+ expect(res.type).toBe("image");
263
+ expect(res.from_post).toBe(true);
264
+ expect(res.media).toEqual([{ kind: "image", url: "https://example.com/post.jpg" }]);
265
+ expect(res.error).toBeUndefined();
266
+ });
267
+
268
+ it("says why when neither the library nor the post has them", async () => {
269
+ serve([
270
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
271
+ ["/adimages", { error: { code: 100, message: "Invalid hashes" } }],
272
+ ["/106755536029753?", { error: { code: 190, message: "no page access" } }],
273
+ ["/106755536029753_1515321970628655", { error: { code: 100, message: "Unsupported get request" } }],
274
+ ]);
275
+ const res = await getAdMedia(hashAd, cfg);
276
+ expect(res.type).toBe("image");
277
+ expect(res.media.every((m) => !m.url)).toBe(true);
278
+ expect(res.error).toMatch(/2 of 2 could not be reached/);
279
+ expect(res.error).toMatch(/could not read the pictures of ad account act_998/);
280
+ });
281
+
282
+ it("says so when there is no ad account to ask", async () => {
283
+ serve([]);
284
+ const ad = {
285
+ id: "1",
286
+ creative: { id: "2", asset_feed_spec: { images: [{ hash: "aaa" }] } },
287
+ };
288
+ const res = await getAdMedia(ad, {});
289
+ expect(res.error).toMatch(/held by the ad account/);
290
+ });
291
+ });