@saltcorn/meta-marketing-api 0.1.0

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/api.js ADDED
@@ -0,0 +1,561 @@
1
+ const fetch = require("node-fetch");
2
+ const crypto = require("crypto");
3
+
4
+ const GRAPH_HOST = "https://graph.facebook.com";
5
+
6
+ // The Graph/Marketing API version used when none is set in the plugin
7
+ // configuration. Meta releases a new version roughly every three months and
8
+ // keeps each one alive for about two years.
9
+ const DEFAULT_API_VERSION = "v26.0";
10
+
11
+ // Meta's Graph API only returns the object id unless you ask for more, so
12
+ // every read below has a sensible default set of fields.
13
+ const DEFAULT_FIELDS = {
14
+ adaccount: [
15
+ "id",
16
+ "account_id",
17
+ "name",
18
+ "account_status",
19
+ "currency",
20
+ "timezone_name",
21
+ "amount_spent",
22
+ "balance",
23
+ "spend_cap",
24
+ "business_name",
25
+ "created_time",
26
+ ],
27
+ business: ["id", "name", "created_time", "verification_status"],
28
+ campaign: [
29
+ "id",
30
+ "account_id",
31
+ "name",
32
+ "status",
33
+ "effective_status",
34
+ "configured_status",
35
+ "objective",
36
+ "buying_type",
37
+ "bid_strategy",
38
+ "daily_budget",
39
+ "lifetime_budget",
40
+ "budget_remaining",
41
+ "start_time",
42
+ "stop_time",
43
+ "created_time",
44
+ "updated_time",
45
+ ],
46
+ adset: [
47
+ "id",
48
+ "account_id",
49
+ "campaign_id",
50
+ "name",
51
+ "status",
52
+ "effective_status",
53
+ "configured_status",
54
+ "optimization_goal",
55
+ "billing_event",
56
+ "bid_amount",
57
+ "bid_strategy",
58
+ "daily_budget",
59
+ "lifetime_budget",
60
+ "budget_remaining",
61
+ "start_time",
62
+ "end_time",
63
+ "created_time",
64
+ "updated_time",
65
+ ],
66
+ ad: [
67
+ "id",
68
+ "account_id",
69
+ "campaign_id",
70
+ "adset_id",
71
+ "name",
72
+ "status",
73
+ "effective_status",
74
+ "configured_status",
75
+ "bid_amount",
76
+ "preview_shareable_link",
77
+ "creative{id,name,thumbnail_url}",
78
+ "created_time",
79
+ "updated_time",
80
+ ],
81
+ adcreative: [
82
+ "id",
83
+ "account_id",
84
+ "name",
85
+ "status",
86
+ "title",
87
+ "body",
88
+ "image_url",
89
+ "thumbnail_url",
90
+ "link_url",
91
+ "call_to_action_type",
92
+ "effective_object_story_id",
93
+ "object_story_spec",
94
+ ],
95
+ };
96
+
97
+ // Metrics that are valid at every insights level.
98
+ const BASE_INSIGHTS_FIELDS = [
99
+ "impressions",
100
+ "reach",
101
+ "frequency",
102
+ "clicks",
103
+ "ctr",
104
+ "cpc",
105
+ "cpm",
106
+ "spend",
107
+ "actions",
108
+ "action_values",
109
+ "date_start",
110
+ "date_stop",
111
+ ];
112
+
113
+ // Naming the level adds the dimension columns that are valid at that level.
114
+ const INSIGHTS_LEVEL_FIELDS = {
115
+ account: ["account_id", "account_name"],
116
+ campaign: ["account_id", "account_name", "campaign_id", "campaign_name"],
117
+ adset: [
118
+ "account_id",
119
+ "account_name",
120
+ "campaign_id",
121
+ "campaign_name",
122
+ "adset_id",
123
+ "adset_name",
124
+ ],
125
+ ad: [
126
+ "account_id",
127
+ "account_name",
128
+ "campaign_id",
129
+ "campaign_name",
130
+ "adset_id",
131
+ "adset_name",
132
+ "ad_id",
133
+ "ad_name",
134
+ ],
135
+ };
136
+
137
+ // Insights columns that are returned as strings but are really numbers.
138
+ const NUMERIC_INSIGHTS_FIELDS = new Set([
139
+ "impressions",
140
+ "reach",
141
+ "frequency",
142
+ "clicks",
143
+ "unique_clicks",
144
+ "inline_link_clicks",
145
+ "ctr",
146
+ "unique_ctr",
147
+ "inline_link_click_ctr",
148
+ "cpc",
149
+ "cpm",
150
+ "cpp",
151
+ "cost_per_inline_link_click",
152
+ "spend",
153
+ "social_spend",
154
+ "objective_results",
155
+ ]);
156
+
157
+ const insightsFields = (level) => [
158
+ ...(INSIGHTS_LEVEL_FIELDS[level] || []),
159
+ ...BASE_INSIGHTS_FIELDS,
160
+ ];
161
+
162
+ /**
163
+ * Encode one value for the Graph API query string. Arrays of scalars become
164
+ * comma separated lists (fields, breakdowns); anything else structured -
165
+ * time_range, filtering - becomes JSON, which is what Meta expects.
166
+ */
167
+ const queryValue = (v) => {
168
+ if (v === null || typeof v === "undefined") return null;
169
+ if (Array.isArray(v))
170
+ return v.every((e) => typeof e === "string" || typeof e === "number")
171
+ ? v.join(",")
172
+ : JSON.stringify(v);
173
+ if (v instanceof Date) return v.toISOString();
174
+ if (typeof v === "object") return JSON.stringify(v);
175
+ if (typeof v === "boolean") return v ? "true" : "false";
176
+ return String(v);
177
+ };
178
+
179
+ const toQueryString = (q) =>
180
+ Object.entries(q || {})
181
+ .map(([k, v]) => [k, queryValue(v)])
182
+ .filter(([k, v]) => v !== null && v !== "")
183
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
184
+ .join("&");
185
+
186
+ /** Ad account ids are addressed as act_<id> in the Graph API */
187
+ const actId = (accountId) => {
188
+ const s = `${accountId}`.trim();
189
+ return s.startsWith("act_") ? s : `act_${s}`;
190
+ };
191
+
192
+ const appSecretProof = (access_token, app_secret) =>
193
+ crypto.createHmac("sha256", app_secret).update(access_token).digest("hex");
194
+
195
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
196
+
197
+ // Error codes worth trying again: transient failures and the various
198
+ // rate limit / business use case throttling codes.
199
+ const TRANSIENT_ERROR_CODES = new Set([1, 2, 4, 17, 32, 341, 613]);
200
+
201
+ const isTransientError = (error) => {
202
+ const code = +error?.code;
203
+ if (TRANSIENT_ERROR_CODES.has(code)) return true;
204
+ return code >= 80000 && code <= 80014;
205
+ };
206
+
207
+ const mkApiError = (error, status) => {
208
+ const bits = [`code ${error?.code}`];
209
+ if (error?.error_subcode) bits.push(`subcode ${error.error_subcode}`);
210
+ if (error?.fbtrace_id) bits.push(`fbtrace_id ${error.fbtrace_id}`);
211
+ const e = new Error(
212
+ `Meta Marketing API error (${bits.join(", ")}): ${
213
+ error?.error_user_msg || error?.message || "unknown error"
214
+ }`
215
+ );
216
+ e.metaError = error;
217
+ e.status = status;
218
+ return e;
219
+ };
220
+
221
+ const apiUrl = (path, cfg) => {
222
+ if (/^https?:\/\//.test(path)) return path;
223
+ const version = cfg?.api_version || DEFAULT_API_VERSION;
224
+ return `${GRAPH_HOST}/${version}${path.startsWith("/") ? path : `/${path}`}`;
225
+ };
226
+
227
+ /**
228
+ * The single point through which every request to Meta goes.
229
+ *
230
+ * cfg: { access_token, app_id, app_secret, api_version, use_appsecret_proof,
231
+ * max_retries, log_requests }
232
+ * opts: { method, query, noAuth }
233
+ */
234
+ const graphFetch = async (path, opts = {}, cfg = {}) => {
235
+ const { method = "GET", query, noAuth } = opts;
236
+ const url = apiUrl(path, cfg);
237
+ const params = { ...(query || {}) };
238
+ if (
239
+ !noAuth &&
240
+ cfg?.use_appsecret_proof &&
241
+ cfg?.app_secret &&
242
+ cfg?.access_token &&
243
+ !url.includes("appsecret_proof=")
244
+ )
245
+ params.appsecret_proof = appSecretProof(cfg.access_token, cfg.app_secret);
246
+
247
+ const qs = toQueryString(params);
248
+ const fullUrl = qs ? `${url}${url.includes("?") ? "&" : "?"}${qs}` : url;
249
+
250
+ const headers = { Accept: "application/json" };
251
+ if (!noAuth && cfg?.access_token)
252
+ headers.Authorization = `Bearer ${cfg.access_token}`;
253
+
254
+ const maxRetries =
255
+ typeof cfg?.max_retries === "number" ? cfg.max_retries : 3;
256
+ let attempt = 0;
257
+
258
+ for (;;) {
259
+ if (cfg?.log_requests) console.log(`Meta ${method} ${fullUrl}`);
260
+ const response = await fetch(fullUrl, { method, headers });
261
+ const body = await response.text();
262
+ let json;
263
+ try {
264
+ json = JSON.parse(body);
265
+ } catch (e) {
266
+ if (response.status >= 500 && attempt < maxRetries) {
267
+ attempt += 1;
268
+ await sleep(1000 * Math.pow(2, attempt));
269
+ continue;
270
+ }
271
+ console.error(
272
+ `Meta Marketing API non-JSON response (HTTP ${response.status})`,
273
+ body
274
+ );
275
+ throw new Error(
276
+ `Meta Marketing API: not a JSON response (HTTP ${response.status})`
277
+ );
278
+ }
279
+ if (json && json.error) {
280
+ if (attempt < maxRetries && isTransientError(json.error)) {
281
+ attempt += 1;
282
+ await sleep(1000 * Math.pow(2, attempt));
283
+ continue;
284
+ }
285
+ throw mkApiError(json.error, response.status);
286
+ }
287
+ if (cfg?.log_requests) {
288
+ const usage = response.headers.get("x-business-use-case-usage");
289
+ if (usage) console.log("Meta business use case usage", usage);
290
+ }
291
+ return json;
292
+ }
293
+ };
294
+
295
+ /**
296
+ * Follow the cursor pagination on an edge and return the concatenated rows.
297
+ * Stops after max_pages pages so a misconfigured view cannot walk a whole
298
+ * account's history.
299
+ */
300
+ const getAllPages = async (path, query, cfg = {}, opts = {}) => {
301
+ const maxPages = opts.max_pages || cfg?.max_pages || 10;
302
+ let json = await graphFetch(path, { query }, cfg);
303
+ const rows = [...(json?.data || [])];
304
+ let pages = 1;
305
+ while (json?.paging?.next && pages < maxPages) {
306
+ json = await graphFetch(json.paging.next, {}, cfg);
307
+ rows.push(...(json?.data || []));
308
+ pages += 1;
309
+ }
310
+ return rows;
311
+ };
312
+
313
+ const withFields = (query, kind) => {
314
+ const q = { ...(query || {}) };
315
+ if (!q.fields && DEFAULT_FIELDS[kind]) q.fields = DEFAULT_FIELDS[kind];
316
+ return q;
317
+ };
318
+
319
+ const withInsightsFields = (query) => {
320
+ const q = { ...(query || {}) };
321
+ if (!q.fields) q.fields = insightsFields(q.level);
322
+ return q;
323
+ };
324
+
325
+ //
326
+ // Accounts and businesses
327
+ //
328
+
329
+ const getMe = async (query, cfg) =>
330
+ await graphFetch("/me", { query: { fields: "id,name", ...(query || {}) } }, cfg);
331
+
332
+ const getAdAccounts = async (query, cfg) =>
333
+ await getAllPages("/me/adaccounts", withFields(query, "adaccount"), cfg);
334
+
335
+ const getAdAccount = async (accountId, query, cfg) =>
336
+ await graphFetch(
337
+ `/${actId(accountId)}`,
338
+ { query: withFields(query, "adaccount") },
339
+ cfg
340
+ );
341
+
342
+ const getBusinesses = async (query, cfg) =>
343
+ await getAllPages("/me/businesses", withFields(query, "business"), cfg);
344
+
345
+ const getBusinessAdAccounts = async (businessId, query, cfg) =>
346
+ await getAllPages(
347
+ `/${businessId}/owned_ad_accounts`,
348
+ withFields(query, "adaccount"),
349
+ cfg
350
+ );
351
+
352
+ //
353
+ // Campaigns, ad sets, ads and creatives
354
+ //
355
+
356
+ const getCampaigns = async (accountId, query, cfg) =>
357
+ await getAllPages(
358
+ `/${actId(accountId)}/campaigns`,
359
+ withFields(query, "campaign"),
360
+ cfg
361
+ );
362
+
363
+ const getCampaign = async (campaignId, query, cfg) =>
364
+ await graphFetch(
365
+ `/${campaignId}`,
366
+ { query: withFields(query, "campaign") },
367
+ cfg
368
+ );
369
+
370
+ const getAdSets = async (accountId, query, cfg) =>
371
+ await getAllPages(
372
+ `/${actId(accountId)}/adsets`,
373
+ withFields(query, "adset"),
374
+ cfg
375
+ );
376
+
377
+ const getCampaignAdSets = async (campaignId, query, cfg) =>
378
+ await getAllPages(
379
+ `/${campaignId}/adsets`,
380
+ withFields(query, "adset"),
381
+ cfg
382
+ );
383
+
384
+ const getAdSet = async (adSetId, query, cfg) =>
385
+ await graphFetch(`/${adSetId}`, { query: withFields(query, "adset") }, cfg);
386
+
387
+ const getAds = async (accountId, query, cfg) =>
388
+ await getAllPages(`/${actId(accountId)}/ads`, withFields(query, "ad"), cfg);
389
+
390
+ const getCampaignAds = async (campaignId, query, cfg) =>
391
+ await getAllPages(`/${campaignId}/ads`, withFields(query, "ad"), cfg);
392
+
393
+ const getAdSetAds = async (adSetId, query, cfg) =>
394
+ await getAllPages(`/${adSetId}/ads`, withFields(query, "ad"), cfg);
395
+
396
+ const getAd = async (adId, query, cfg) =>
397
+ await graphFetch(`/${adId}`, { query: withFields(query, "ad") }, cfg);
398
+
399
+ const getAdCreatives = async (accountId, query, cfg) =>
400
+ await getAllPages(
401
+ `/${actId(accountId)}/adcreatives`,
402
+ withFields(query, "adcreative"),
403
+ cfg
404
+ );
405
+
406
+ const getAdCreative = async (creativeId, query, cfg) =>
407
+ await graphFetch(
408
+ `/${creativeId}`,
409
+ { query: withFields(query, "adcreative") },
410
+ cfg
411
+ );
412
+
413
+ /** Rendered HTML preview of an ad, as an iframe snippet */
414
+ const getAdPreview = async (adId, adFormat, cfg) => {
415
+ const json = await graphFetch(
416
+ `/${adId}/previews`,
417
+ { query: { ad_format: adFormat || "DESKTOP_FEED_STANDARD" } },
418
+ cfg
419
+ );
420
+ return json?.data?.[0]?.body || "";
421
+ };
422
+
423
+ //
424
+ // Insights
425
+ //
426
+
427
+ const getInsights = async (objectId, query, cfg, opts) =>
428
+ await getAllPages(
429
+ `/${objectId}/insights`,
430
+ withInsightsFields(query),
431
+ cfg,
432
+ opts
433
+ );
434
+
435
+ /** Kick off an asynchronous insights job, returns { report_run_id } */
436
+ const startInsightsReport = async (objectId, query, cfg) =>
437
+ await graphFetch(
438
+ `/${objectId}/insights`,
439
+ { method: "POST", query: withInsightsFields(query) },
440
+ cfg
441
+ );
442
+
443
+ const getReportRun = async (reportRunId, cfg) =>
444
+ await graphFetch(
445
+ `/${reportRunId}`,
446
+ {
447
+ query: {
448
+ fields:
449
+ "id,async_status,async_percent_completion,date_start,date_stop",
450
+ },
451
+ },
452
+ cfg
453
+ );
454
+
455
+ const getReportRunInsights = async (reportRunId, query, cfg, opts) =>
456
+ await getAllPages(`/${reportRunId}/insights`, query, cfg, opts);
457
+
458
+ /**
459
+ * The full asynchronous insights flow: submit the job, poll it to completion
460
+ * and read the results. Use this for large reports that time out when read
461
+ * synchronously.
462
+ */
463
+ const getInsightsAsync = async (objectId, query, cfg, opts = {}) => {
464
+ const pollInterval = opts.poll_interval_ms || 5000;
465
+ const timeout = opts.timeout_ms || 10 * 60 * 1000;
466
+ const { report_run_id } = await startInsightsReport(objectId, query, cfg);
467
+ if (!report_run_id)
468
+ throw new Error("Meta Marketing API: no report_run_id returned");
469
+ const startedAt = Date.now();
470
+ for (;;) {
471
+ const run = await getReportRun(report_run_id, cfg);
472
+ if (run?.async_status === "Job Completed") break;
473
+ if (["Job Failed", "Job Skipped"].includes(run?.async_status))
474
+ throw new Error(
475
+ `Meta Marketing API: insights job ${run.async_status} (${report_run_id})`
476
+ );
477
+ if (Date.now() - startedAt > timeout)
478
+ throw new Error(
479
+ `Meta Marketing API: insights job timed out (${report_run_id})`
480
+ );
481
+ await sleep(pollInterval);
482
+ }
483
+ return await getReportRunInsights(report_run_id, {}, cfg, opts);
484
+ };
485
+
486
+ //
487
+ // Tokens
488
+ //
489
+
490
+ /** Exchange a short lived (or expiring) user token for a long lived one */
491
+ const exchangeLongLivedToken = async (app_id, app_secret, access_token, cfg) =>
492
+ await graphFetch(
493
+ "/oauth/access_token",
494
+ {
495
+ noAuth: true,
496
+ query: {
497
+ grant_type: "fb_exchange_token",
498
+ client_id: app_id,
499
+ client_secret: app_secret,
500
+ fb_exchange_token: access_token,
501
+ },
502
+ },
503
+ cfg
504
+ );
505
+
506
+ /** Inspect a token: which app it belongs to, when it expires, its scopes */
507
+ const debugToken = async (token, app_id, app_secret, cfg) => {
508
+ const json = await graphFetch(
509
+ "/debug_token",
510
+ {
511
+ noAuth: true,
512
+ query: {
513
+ input_token: token,
514
+ access_token: `${app_id}|${app_secret}`,
515
+ },
516
+ },
517
+ cfg
518
+ );
519
+ return json?.data || json;
520
+ };
521
+
522
+ module.exports = {
523
+ GRAPH_HOST,
524
+ DEFAULT_API_VERSION,
525
+ DEFAULT_FIELDS,
526
+ BASE_INSIGHTS_FIELDS,
527
+ INSIGHTS_LEVEL_FIELDS,
528
+ NUMERIC_INSIGHTS_FIELDS,
529
+ insightsFields,
530
+ queryValue,
531
+ toQueryString,
532
+ actId,
533
+ appSecretProof,
534
+ isTransientError,
535
+ graphFetch,
536
+ getAllPages,
537
+ getMe,
538
+ getAdAccounts,
539
+ getAdAccount,
540
+ getBusinesses,
541
+ getBusinessAdAccounts,
542
+ getCampaigns,
543
+ getCampaign,
544
+ getAdSets,
545
+ getCampaignAdSets,
546
+ getAdSet,
547
+ getAds,
548
+ getCampaignAds,
549
+ getAdSetAds,
550
+ getAd,
551
+ getAdCreatives,
552
+ getAdCreative,
553
+ getAdPreview,
554
+ getInsights,
555
+ startInsightsReport,
556
+ getReportRun,
557
+ getReportRunInsights,
558
+ getInsightsAsync,
559
+ exchangeLongLivedToken,
560
+ debugToken,
561
+ };