@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 +144 -2
- package/api.js +504 -13
- package/index.js +43 -0
- package/package.json +1 -1
- package/tests/api.test.js +114 -0
- package/tests/media.test.js +291 -0
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
|
|
181
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
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
|
+
});
|