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