@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/meta-marketing-api",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Read ads on Facebook and Instagram with the Meta Marketing API",
5
5
  "main": "index.js",
6
6
  "dependencies": {
package/tests/api.test.js CHANGED
@@ -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
+ });