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