@saltcorn/meta-marketing-api 0.1.0 → 0.1.1
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 +18 -0
- package/api.js +121 -0
- package/index.js +30 -0
- package/package.json +1 -1
- package/tests/api.test.js +109 -0
package/README.md
CHANGED
|
@@ -133,6 +133,9 @@ a different token.
|
|
|
133
133
|
| `get_meta_ad(adId, query)` | One ad |
|
|
134
134
|
| `get_meta_ad_creatives(accountId, query)` | The creatives in an ad account |
|
|
135
135
|
| `get_meta_ad_creative(creativeId, query)` | One creative |
|
|
136
|
+
| `get_meta_ad_headline(ad)` | The headline of an ad, from an ad id or an ad you have already read |
|
|
137
|
+
| `get_meta_ad_body(ad)` | The primary text of an ad: the longer wording above the image |
|
|
138
|
+
| `get_meta_ad_text(ad)` | Both of the above together, as `{ headline, body }`, in one read |
|
|
136
139
|
| `get_meta_ad_preview(adId, adFormat)` | A ready made HTML preview of an ad |
|
|
137
140
|
| `get_meta_insights(objectId, query)` | Performance figures for an account, campaign, ad set or ad |
|
|
138
141
|
| `get_meta_insights_async(objectId, query)` | The same, run as a background report, for large date ranges |
|
|
@@ -164,6 +167,21 @@ get_meta_ads("", {
|
|
|
164
167
|
})
|
|
165
168
|
```
|
|
166
169
|
|
|
170
|
+
The wording of an ad is not in the ad itself, it sits on the creative, and
|
|
171
|
+
Meta keeps it in a different place for each kind of ad. `get_meta_ad_text`
|
|
172
|
+
finds it for you, wherever it is:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
get_meta_ad_text(ad_id)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
which gives you `{ headline: "...", body: "..." }`, where the body is the
|
|
179
|
+
longer text above the image. For an ad that is boosting a post already on
|
|
180
|
+
your page, the wording belongs to the post rather than to the ad, and reading
|
|
181
|
+
it needs a token that can also read the page. When it cannot be read you get
|
|
182
|
+
empty text back rather than an error; `get_meta_ad_preview` will still show
|
|
183
|
+
you the ad as it appears.
|
|
184
|
+
|
|
167
185
|
## Things to know
|
|
168
186
|
|
|
169
187
|
- **Money is in cents.** Budgets, bids and amounts spent come from Meta as
|
package/api.js
CHANGED
|
@@ -91,9 +91,24 @@ 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
|
+
|
|
97
112
|
// Metrics that are valid at every insights level.
|
|
98
113
|
const BASE_INSIGHTS_FIELDS = [
|
|
99
114
|
"impressions",
|
|
@@ -420,6 +435,105 @@ const getAdPreview = async (adId, adFormat, cfg) => {
|
|
|
420
435
|
return json?.data?.[0]?.body || "";
|
|
421
436
|
};
|
|
422
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Pull every headline out of a creative, best first. A carousel has one per
|
|
440
|
+
* card and a dynamic creative can carry several for Meta to choose between,
|
|
441
|
+
* so this returns a list rather than a single string.
|
|
442
|
+
*/
|
|
443
|
+
const creativeHeadlines = (creative) => {
|
|
444
|
+
const spec = creative?.object_story_spec || {};
|
|
445
|
+
const found = [
|
|
446
|
+
creative?.title,
|
|
447
|
+
spec.link_data?.name,
|
|
448
|
+
spec.video_data?.title,
|
|
449
|
+
spec.template_data?.name,
|
|
450
|
+
...(spec.link_data?.child_attachments || []).map((c) => c.name),
|
|
451
|
+
...(creative?.asset_feed_spec?.titles || []).map((t) => t.text),
|
|
452
|
+
];
|
|
453
|
+
return [...new Set(found.filter((h) => h))];
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Pull every primary text out of a creative, best first. This is the longer
|
|
458
|
+
* wording that runs above the image, which Meta calls the primary text in Ads
|
|
459
|
+
* Manager and the body or the message in its API. As with headlines, a
|
|
460
|
+
* dynamic creative can carry several.
|
|
461
|
+
*/
|
|
462
|
+
const creativeBodies = (creative) => {
|
|
463
|
+
const spec = creative?.object_story_spec || {};
|
|
464
|
+
const found = [
|
|
465
|
+
creative?.body,
|
|
466
|
+
spec.link_data?.message,
|
|
467
|
+
spec.video_data?.message,
|
|
468
|
+
spec.photo_data?.caption,
|
|
469
|
+
spec.text_data?.message,
|
|
470
|
+
spec.template_data?.message,
|
|
471
|
+
...(creative?.asset_feed_spec?.bodies || []).map((b) => b.text),
|
|
472
|
+
];
|
|
473
|
+
return [...new Set(found.filter((b) => b))];
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The wording of the page post an ad promotes: the post's own text, and the
|
|
478
|
+
* headline of the link it carries.
|
|
479
|
+
*/
|
|
480
|
+
const storyText = async (storyId, cfg) => {
|
|
481
|
+
const json = await graphFetch(
|
|
482
|
+
`/${storyId}`,
|
|
483
|
+
{ query: { fields: "message,attachments{title,description}" } },
|
|
484
|
+
cfg
|
|
485
|
+
);
|
|
486
|
+
const attachment = json?.attachments?.data?.[0] || {};
|
|
487
|
+
return {
|
|
488
|
+
headline: attachment.title || "",
|
|
489
|
+
body: json?.message || attachment.description || "",
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The wording of an ad: { headline, body }, where body is the primary text
|
|
495
|
+
* above the image. Takes an ad id, or an ad that has already been read with
|
|
496
|
+
* its creative, in which case nothing is read again.
|
|
497
|
+
*
|
|
498
|
+
* Ads that promote a post which already exists on the page keep their wording
|
|
499
|
+
* on the post, which needs a second read and a token with access to the page.
|
|
500
|
+
* When that read is not allowed the wording comes back empty rather than
|
|
501
|
+
* throwing.
|
|
502
|
+
*/
|
|
503
|
+
const getAdText = async (ad, cfg) => {
|
|
504
|
+
let creative = typeof ad === "object" ? ad?.creative : null;
|
|
505
|
+
if (!creative?.object_story_spec && !creative?.title && !creative?.body) {
|
|
506
|
+
const fetched = await graphFetch(
|
|
507
|
+
`/${typeof ad === "object" ? ad?.id : ad}`,
|
|
508
|
+
{ query: { fields: `creative{${CREATIVE_TEXT_FIELDS.join(",")}}` } },
|
|
509
|
+
cfg
|
|
510
|
+
);
|
|
511
|
+
creative = fetched?.creative;
|
|
512
|
+
}
|
|
513
|
+
const text = {
|
|
514
|
+
headline: creativeHeadlines(creative)[0] || "",
|
|
515
|
+
body: creativeBodies(creative)[0] || "",
|
|
516
|
+
};
|
|
517
|
+
if ((!text.headline || !text.body) && creative?.effective_object_story_id)
|
|
518
|
+
try {
|
|
519
|
+
const story = await storyText(creative.effective_object_story_id, cfg);
|
|
520
|
+
return {
|
|
521
|
+
headline: text.headline || story.headline,
|
|
522
|
+
body: text.body || story.body,
|
|
523
|
+
};
|
|
524
|
+
} catch (e) {
|
|
525
|
+
if (cfg?.log_requests)
|
|
526
|
+
console.log("Meta: could not read the post behind the ad", e.message);
|
|
527
|
+
}
|
|
528
|
+
return text;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/** The headline of an ad, as getAdText */
|
|
532
|
+
const getAdHeadline = async (ad, cfg) => (await getAdText(ad, cfg)).headline;
|
|
533
|
+
|
|
534
|
+
/** The primary text of an ad, the wording above the image, as getAdText */
|
|
535
|
+
const getAdBody = async (ad, cfg) => (await getAdText(ad, cfg)).body;
|
|
536
|
+
|
|
423
537
|
//
|
|
424
538
|
// Insights
|
|
425
539
|
//
|
|
@@ -523,6 +637,7 @@ module.exports = {
|
|
|
523
637
|
GRAPH_HOST,
|
|
524
638
|
DEFAULT_API_VERSION,
|
|
525
639
|
DEFAULT_FIELDS,
|
|
640
|
+
CREATIVE_TEXT_FIELDS,
|
|
526
641
|
BASE_INSIGHTS_FIELDS,
|
|
527
642
|
INSIGHTS_LEVEL_FIELDS,
|
|
528
643
|
NUMERIC_INSIGHTS_FIELDS,
|
|
@@ -551,6 +666,12 @@ module.exports = {
|
|
|
551
666
|
getAdCreatives,
|
|
552
667
|
getAdCreative,
|
|
553
668
|
getAdPreview,
|
|
669
|
+
creativeHeadlines,
|
|
670
|
+
creativeBodies,
|
|
671
|
+
storyText,
|
|
672
|
+
getAdText,
|
|
673
|
+
getAdHeadline,
|
|
674
|
+
getAdBody,
|
|
554
675
|
getInsights,
|
|
555
676
|
startInsightsReport,
|
|
556
677
|
getReportRun,
|
package/index.js
CHANGED
|
@@ -24,6 +24,9 @@ const {
|
|
|
24
24
|
getAdCreatives,
|
|
25
25
|
getAdCreative,
|
|
26
26
|
getAdPreview,
|
|
27
|
+
getAdText,
|
|
28
|
+
getAdHeadline,
|
|
29
|
+
getAdBody,
|
|
27
30
|
getInsights,
|
|
28
31
|
getInsightsAsync,
|
|
29
32
|
exchangeLongLivedToken,
|
|
@@ -383,6 +386,33 @@ module.exports = {
|
|
|
383
386
|
{ name: "query", type: "JSON" },
|
|
384
387
|
],
|
|
385
388
|
},
|
|
389
|
+
get_meta_ad_headline: {
|
|
390
|
+
async run(ad, cfgOverRide) {
|
|
391
|
+
return await getAdHeadline(ad, { ...cfg, ...cfgOverRide });
|
|
392
|
+
},
|
|
393
|
+
isAsync: true,
|
|
394
|
+
description:
|
|
395
|
+
"Get the headline of an ad, wherever Meta keeps it for that kind of ad. Takes an ad id or an ad row that already has its creative.",
|
|
396
|
+
arguments: [{ name: "ad", type: "String" }],
|
|
397
|
+
},
|
|
398
|
+
get_meta_ad_body: {
|
|
399
|
+
async run(ad, cfgOverRide) {
|
|
400
|
+
return await getAdBody(ad, { ...cfg, ...cfgOverRide });
|
|
401
|
+
},
|
|
402
|
+
isAsync: true,
|
|
403
|
+
description:
|
|
404
|
+
"Get the primary text of an ad, the longer wording above the image. Takes an ad id or an ad row that already has its creative.",
|
|
405
|
+
arguments: [{ name: "ad", type: "String" }],
|
|
406
|
+
},
|
|
407
|
+
get_meta_ad_text: {
|
|
408
|
+
async run(ad, cfgOverRide) {
|
|
409
|
+
return await getAdText(ad, { ...cfg, ...cfgOverRide });
|
|
410
|
+
},
|
|
411
|
+
isAsync: true,
|
|
412
|
+
description:
|
|
413
|
+
"Get the headline and the primary text of an ad together, as { headline, body }, in one read",
|
|
414
|
+
arguments: [{ name: "ad", type: "String" }],
|
|
415
|
+
},
|
|
386
416
|
get_meta_ad_preview: {
|
|
387
417
|
async run(adId, adFormat, cfgOverRide) {
|
|
388
418
|
return await getAdPreview(adId, adFormat, {
|
package/package.json
CHANGED
package/tests/api.test.js
CHANGED
|
@@ -6,6 +6,8 @@ const {
|
|
|
6
6
|
isTransientError,
|
|
7
7
|
insightsFields,
|
|
8
8
|
DEFAULT_FIELDS,
|
|
9
|
+
creativeHeadlines,
|
|
10
|
+
creativeBodies,
|
|
9
11
|
} = require("../api");
|
|
10
12
|
|
|
11
13
|
describe("query string encoding", () => {
|
|
@@ -92,3 +94,110 @@ describe("default fields", () => {
|
|
|
92
94
|
});
|
|
93
95
|
});
|
|
94
96
|
});
|
|
97
|
+
|
|
98
|
+
describe("finding the headline on a creative", () => {
|
|
99
|
+
it("reads the headline of a link ad", () => {
|
|
100
|
+
expect(
|
|
101
|
+
creativeHeadlines({
|
|
102
|
+
object_story_spec: {
|
|
103
|
+
link_data: { name: "Half price today", message: "Primary text" },
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
).toEqual(["Half price today"]);
|
|
107
|
+
});
|
|
108
|
+
it("reads the headline of a video ad", () => {
|
|
109
|
+
expect(
|
|
110
|
+
creativeHeadlines({ object_story_spec: { video_data: { title: "Watch" } } })
|
|
111
|
+
).toEqual(["Watch"]);
|
|
112
|
+
});
|
|
113
|
+
it("reads the old style title field", () => {
|
|
114
|
+
expect(creativeHeadlines({ title: "Legacy headline" })).toEqual([
|
|
115
|
+
"Legacy headline",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
it("returns one headline per carousel card", () => {
|
|
119
|
+
expect(
|
|
120
|
+
creativeHeadlines({
|
|
121
|
+
object_story_spec: {
|
|
122
|
+
link_data: {
|
|
123
|
+
name: "Our shop",
|
|
124
|
+
child_attachments: [{ name: "Shoes" }, { name: "Hats" }],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
).toEqual(["Our shop", "Shoes", "Hats"]);
|
|
129
|
+
});
|
|
130
|
+
it("returns every headline of a dynamic creative, without repeats", () => {
|
|
131
|
+
expect(
|
|
132
|
+
creativeHeadlines({
|
|
133
|
+
title: "Half price",
|
|
134
|
+
asset_feed_spec: {
|
|
135
|
+
titles: [{ text: "Half price" }, { text: "50% off" }],
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
).toEqual(["Half price", "50% off"]);
|
|
139
|
+
});
|
|
140
|
+
it("comes back empty when there is no headline to find", () => {
|
|
141
|
+
expect(creativeHeadlines({ effective_object_story_id: "1_2" })).toEqual([]);
|
|
142
|
+
expect(creativeHeadlines(null)).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("finding the primary text on a creative", () => {
|
|
147
|
+
it("reads the text above the image of a link ad", () => {
|
|
148
|
+
expect(
|
|
149
|
+
creativeBodies({
|
|
150
|
+
object_story_spec: {
|
|
151
|
+
link_data: { name: "A headline", message: "The longer wording" },
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
).toEqual(["The longer wording"]);
|
|
155
|
+
});
|
|
156
|
+
it("reads the text of video, photo and text only ads", () => {
|
|
157
|
+
expect(
|
|
158
|
+
creativeBodies({ object_story_spec: { video_data: { message: "Video" } } })
|
|
159
|
+
).toEqual(["Video"]);
|
|
160
|
+
expect(
|
|
161
|
+
creativeBodies({ object_story_spec: { photo_data: { caption: "Photo" } } })
|
|
162
|
+
).toEqual(["Photo"]);
|
|
163
|
+
expect(
|
|
164
|
+
creativeBodies({ object_story_spec: { text_data: { message: "Text" } } })
|
|
165
|
+
).toEqual(["Text"]);
|
|
166
|
+
});
|
|
167
|
+
it("reads the old style body field", () => {
|
|
168
|
+
expect(creativeBodies({ body: "Legacy body" })).toEqual(["Legacy body"]);
|
|
169
|
+
});
|
|
170
|
+
it("returns every primary text of a dynamic creative, without repeats", () => {
|
|
171
|
+
expect(
|
|
172
|
+
creativeBodies({
|
|
173
|
+
body: "Shop the sale",
|
|
174
|
+
asset_feed_spec: {
|
|
175
|
+
bodies: [{ text: "Shop the sale" }, { text: "Everything reduced" }],
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
).toEqual(["Shop the sale", "Everything reduced"]);
|
|
179
|
+
});
|
|
180
|
+
it("does not mistake the headline for the primary text", () => {
|
|
181
|
+
const creative = {
|
|
182
|
+
title: "A headline",
|
|
183
|
+
object_story_spec: { link_data: { name: "A headline" } },
|
|
184
|
+
};
|
|
185
|
+
expect(creativeBodies(creative)).toEqual([]);
|
|
186
|
+
expect(creativeHeadlines(creative)).toEqual(["A headline"]);
|
|
187
|
+
});
|
|
188
|
+
it("comes back empty when there is no text to find", () => {
|
|
189
|
+
expect(creativeBodies({ effective_object_story_id: "1_2" })).toEqual([]);
|
|
190
|
+
expect(creativeBodies(null)).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("default fields", () => {
|
|
195
|
+
it("asks for the dynamic creative assets", () => {
|
|
196
|
+
expect(DEFAULT_FIELDS.adcreative).toContain("asset_feed_spec");
|
|
197
|
+
expect(DEFAULT_FIELDS.adcreative).toContain("effective_object_story_id");
|
|
198
|
+
});
|
|
199
|
+
it("asks for both the headline and the primary text", () => {
|
|
200
|
+
expect(DEFAULT_FIELDS.adcreative).toContain("title");
|
|
201
|
+
expect(DEFAULT_FIELDS.adcreative).toContain("body");
|
|
202
|
+
});
|
|
203
|
+
});
|