@ads-repo/meta-creative-buckets 1.0.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.
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fetch Meta Ads daily ad-level data for one ad account and build a per-ad summary used to
5
+ * bucket creatives (Not enough data / Wait / Scale / Kill).
6
+ *
7
+ * The efficiency metric is
8
+ *
9
+ * CRR = spend / purchase revenue (expressed as %, 7d click)
10
+ *
11
+ * CRR is inverse ROAS (CRR 40% == ROAS 2.5) and LOWER is better. Purchase COUNT is fetched
12
+ * too, for the reference columns in the table.
13
+ *
14
+ * Which account is read comes from scripts/config.cjs — see that file for the resolution
15
+ * order (--client flag, BUCKETS_CLIENT, buckets.config.json).
16
+ *
17
+ * Usage: node fetch-buckets-data.cjs [days] [endDate] [--client slug]
18
+ * (default 30 days, ending today)
19
+ * Output:
20
+ * data/meta-ads/<client>/buckets-latest.json (per-ad summary + daily rows)
21
+ * data/meta-ads/<client>/history/YYYYMMDD-buckets.json (dated snapshot, day-over-day movement)
22
+ *
23
+ * Three rules worth keeping when editing this file:
24
+ * 1. Currency is READ FROM THE API, never assumed — and never inferred from the account
25
+ * NAME. An account called "… CZ" may well bill in EUR; guessing "Kč" from the name was
26
+ * once wrong by a factor of ~25.
27
+ * 2. Revenue is read from the NAMED 7d_click sub-field of action_values. Meta's plain
28
+ * `value` is the account default attribution (click+view) and can overstate revenue by
29
+ * roughly a third, which would silently understate CRR.
30
+ * 3. /ads with a nested creative{...} expansion trips the per-account rate limit (80004)
31
+ * on some accounts, so creative metadata is fetched in two cheap passes that degrade
32
+ * to media_type 'unknown' rather than discarding the expensive daily insights.
33
+ */
34
+
35
+ const https = require('https');
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const cfg = require('./config.cjs');
39
+
40
+ const API_VERSION = cfg.apiVersion;
41
+ const { token: ACCESS_TOKEN, accountId: ACCOUNT_ID } = cfg.requireCredentials();
42
+
43
+ const DAYS = parseInt(process.argv[2]) || 30;
44
+ // optional end date (YYYY-MM-DD); default today. Use yesterday to avoid a partial day.
45
+ const END_ARG = process.argv[3] && /^\d{4}-\d{2}-\d{2}$/.test(process.argv[3]) ? process.argv[3] : null;
46
+ const PAGE_SIZE = cfg.pageSize;
47
+ // rate-limit sensitive accounts want a longer idle between calls; override in .env
48
+ const RATE_LIMIT_DELAY = cfg.rateLimitDelay;
49
+
50
+ // date_start last, so daily rows carry the day.
51
+ // action_values is required for revenue — without it Meta returns purchases but no money.
52
+ const FIELDS = [
53
+ 'ad_id',
54
+ 'ad_name',
55
+ 'adset_name',
56
+ 'campaign_name',
57
+ 'spend',
58
+ 'impressions',
59
+ 'reach',
60
+ 'frequency',
61
+ 'clicks',
62
+ 'ctr',
63
+ 'cpm',
64
+ 'actions',
65
+ 'action_values',
66
+ 'date_start',
67
+ ].join(',');
68
+
69
+ function actionVal(actions, type) {
70
+ if (!Array.isArray(actions)) return 0;
71
+ const a = actions.find(x => x.action_type === type);
72
+ return a ? Number(a.value) || 0 : 0;
73
+ }
74
+
75
+ // Read a specific attribution window off an action_values entry. Meta's plain `value` is
76
+ // the account default attribution, NOT the requested window — the window lives in a named
77
+ // sub-field. Falls back to `value` only if the sub-field is absent.
78
+ function attrVal(rows, type, window) {
79
+ if (!Array.isArray(rows)) return 0;
80
+ const a = rows.find(x => x.action_type === type);
81
+ if (!a) return 0;
82
+ return Number(a[window] != null ? a[window] : a.value) || 0;
83
+ }
84
+
85
+ // ecommerce account: main conversion = purchase, and its revenue drives CRR
86
+ function purchaseCount(actions) {
87
+ return actionVal(actions, 'purchase') || actionVal(actions, 'omni_purchase');
88
+ }
89
+ function purchaseRevenue(actionValues) {
90
+ return attrVal(actionValues, 'purchase', '7d_click')
91
+ || attrVal(actionValues, 'omni_purchase', '7d_click');
92
+ }
93
+
94
+ function ymd(d) { return d.toISOString().slice(0, 10); }
95
+ function makeRequest(url) {
96
+ return new Promise((resolve, reject) => {
97
+ https.get(url, (res) => {
98
+ let data = '';
99
+ res.on('data', chunk => (data += chunk));
100
+ res.on('end', () => {
101
+ if (res.statusCode === 200) resolve(JSON.parse(data));
102
+ else reject(new Error(`HTTP ${res.statusCode}: ${data}`));
103
+ });
104
+ }).on('error', reject);
105
+ });
106
+ }
107
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
108
+ function generateDateRange(start, end) {
109
+ const dates = [];
110
+ const cur = new Date(start), last = new Date(end);
111
+ while (cur <= last) { dates.push(ymd(cur)); cur.setDate(cur.getDate() + 1); }
112
+ return dates;
113
+ }
114
+
115
+ async function fetchSingleDay(date) {
116
+ const all = [];
117
+ let url = `https://graph.facebook.com/${API_VERSION}/${ACCOUNT_ID}/insights?` +
118
+ `access_token=${ACCESS_TOKEN}&level=ad&fields=${FIELDS}` +
119
+ `&time_range={"since":"${date}","until":"${date}"}&time_increment=1` +
120
+ `&action_attribution_windows=["7d_click"]&limit=${PAGE_SIZE}`;
121
+ while (url) {
122
+ const resp = await makeRequest(url);
123
+ if (resp.data && resp.data.length) all.push(...resp.data);
124
+ url = resp.paging && resp.paging.next ? resp.paging.next : null;
125
+ if (url) await sleep(RATE_LIMIT_DELAY);
126
+ }
127
+ return all;
128
+ }
129
+
130
+ async function fetchDayByDay(startDate, endDate) {
131
+ const dates = generateDateRange(startDate, endDate);
132
+ const all = [];
133
+ for (let i = 0; i < dates.length; i++) {
134
+ process.stdout.write(` day ${i + 1}/${dates.length} ${dates[i]} ... `);
135
+ try {
136
+ const day = await fetchSingleDay(dates[i]);
137
+ all.push(...day);
138
+ console.log(`${day.length} rows (total ${all.length})`);
139
+ } catch (err) {
140
+ console.log(`SKIP (${err.message.slice(0, 80)})`);
141
+ }
142
+ await sleep(RATE_LIMIT_DELAY);
143
+ }
144
+ return all;
145
+ }
146
+
147
+ async function fetchInsights(startDate, endDate) {
148
+ const all = [];
149
+ let url = `https://graph.facebook.com/${API_VERSION}/${ACCOUNT_ID}/insights?` +
150
+ `access_token=${ACCESS_TOKEN}&level=ad&fields=${FIELDS}` +
151
+ `&time_range={"since":"${startDate}","until":"${endDate}"}&time_increment=1` +
152
+ `&action_attribution_windows=["7d_click"]&limit=${PAGE_SIZE}`;
153
+ let page = 0;
154
+ try {
155
+ while (url) {
156
+ page++;
157
+ console.log(` page ${page} ...`);
158
+ const resp = await makeRequest(url);
159
+ if (resp.data && resp.data.length) {
160
+ all.push(...resp.data);
161
+ console.log(` ${resp.data.length} rows (total ${all.length})`);
162
+ }
163
+ url = resp.paging && resp.paging.next ? resp.paging.next : null;
164
+ if (url) await sleep(RATE_LIMIT_DELAY);
165
+ }
166
+ return all;
167
+ } catch (err) {
168
+ // 500 = Meta choked on the range; error code 1 ("reduce the amount of data") and the
169
+ // 80004 rate limit are also survivable day-by-day. Falling back beats losing the run.
170
+ if (err.message && (err.message.startsWith('HTTP 500') || /reduce the amount of data|"code":\s*1\b|80004/.test(err.message))) {
171
+ console.warn('\n Meta refused the bulk range — switching to day-by-day mode...\n');
172
+ return await fetchDayByDay(startDate, endDate);
173
+ }
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ // video vs image. Many video ads use object_type "SHARE" with the video in
179
+ // asset_feed_spec.videos[] — must be checked or they read as image.
180
+ function classifyMediaType(creative) {
181
+ if (!creative) return 'image';
182
+ if (creative.object_type === 'VIDEO') return 'video';
183
+ if (creative.video_id) return 'video';
184
+ const afs = creative.asset_feed_spec || {};
185
+ if (afs.videos && afs.videos.length) return 'video';
186
+ const spec = creative.object_story_spec || {};
187
+ if (spec.video_data && spec.video_data.video_id) return 'video';
188
+ if (spec.link_data && spec.link_data.video_id) return 'video';
189
+ return 'image';
190
+ }
191
+
192
+ // ad -> { media_type, active, preview } for the ads passed in.
193
+ // Preview URLs are static CDN links (no FB login), so they render in the standalone HTML.
194
+ //
195
+ // Two batched passes over ids, never over the account's edges:
196
+ // 1) /?ids=<ad ids> -> status + creative id
197
+ // 2) /?ids=<creative ids> -> creative details (deduped, so usually fewer calls than pass 1)
198
+ //
199
+ // Both passes degrade: a failure leaves media_type 'unknown' rather than aborting the run,
200
+ // because the daily insights above are the expensive part and must not be thrown away.
201
+ async function fetchAdMeta(spendingAdIds) {
202
+ const typeMap = {}, statusMap = {}, previewMap = {};
203
+ const adCreative = {}; // ad_id -> creative_id
204
+
205
+ // Look ads up BY ID, in small batches, instead of walking the account's /ads edge.
206
+ // An account accumulates every ad ever created (8k+ is normal) while a 30-day window
207
+ // usually has a couple of hundred with spend. Paging the whole edge burns the per-account
208
+ // rate limit on ads that will never appear in the output, and the limit is counted per
209
+ // account per hour — so the cheap creative pass that follows gets refused for no reason.
210
+ const BATCH = 40;
211
+ async function fetchByIds(ids, fields, onRow) {
212
+ for (let i = 0; i < ids.length; i += BATCH) {
213
+ const chunk = ids.slice(i, i + BATCH);
214
+ const url = `https://graph.facebook.com/${API_VERSION}/?ids=${chunk.join(',')}` +
215
+ `&fields=${encodeURIComponent(fields)}&access_token=${ACCESS_TOKEN}`;
216
+ const resp = await makeRequest(url);
217
+ Object.values(resp || {}).forEach(row => { if (row && row.id) onRow(row); });
218
+ if (i + BATCH < ids.length) await sleep(Math.max(RATE_LIMIT_DELAY, 500));
219
+ }
220
+ }
221
+
222
+ // pass 1 — status + creative id for the ads that actually spent.
223
+ // thumbnail_url is NOT requestable on the batched /?ids= endpoint (only on the /ads edge);
224
+ // asking for it 400s the whole batch. Thumbnails come from the creative pass below.
225
+ try {
226
+ await fetchByIds(spendingAdIds, 'id,effective_status,creative{id}', ad => {
227
+ statusMap[ad.id] = ad.effective_status === 'ACTIVE';
228
+ if (ad.creative && ad.creative.id) adCreative[ad.id] = ad.creative.id;
229
+ });
230
+ console.log(` mapped ${Object.keys(statusMap).length}/${spendingAdIds.length} ads (status)`);
231
+ } catch (err) {
232
+ console.warn(` ⚠ ad lookup failed (${err.message.slice(0, 70)}) — status unknown`);
233
+ }
234
+
235
+ // pass 2 — creative details, only for the creatives those ads point at (deduped)
236
+ const creatives = {};
237
+ const uniqCreatives = [...new Set(Object.values(adCreative))];
238
+ try {
239
+ if (uniqCreatives.length) {
240
+ await fetchByIds(uniqCreatives,
241
+ 'id,object_type,video_id,asset_feed_spec{videos},object_story_spec,thumbnail_url,image_url',
242
+ cr => { creatives[cr.id] = cr; });
243
+ }
244
+ console.log(` mapped ${Object.keys(creatives).length}/${uniqCreatives.length} creatives (type + preview)`);
245
+ } catch (err) {
246
+ console.warn(` ⚠ creative fetch failed (${err.message.slice(0, 70)}) — media types unknown`);
247
+ }
248
+
249
+ for (const adId of spendingAdIds) {
250
+ const cr = creatives[adCreative[adId]];
251
+ typeMap[adId] = cr ? classifyMediaType(cr) : 'unknown';
252
+ previewMap[adId] = {
253
+ thumb: (cr && cr.thumbnail_url) || null,
254
+ image: (cr && cr.image_url) || null,
255
+ };
256
+ }
257
+ const known = Object.values(typeMap).filter(t => t !== 'unknown').length;
258
+ console.log(` ${known}/${Object.keys(typeMap).length} ads have a known media type`);
259
+ return { typeMap, statusMap, previewMap };
260
+ }
261
+
262
+ // Account currency, straight from Meta. Never assume it from the account NAME — an account
263
+ // named "… CZ" may still bill in EUR, and a hardcoded "Kč" label was once wrong by a factor
264
+ // of ~25. Everything downstream (axis, tooltips, table) derives its symbol from this field.
265
+ // The account name comes from the same call, so the dashboard header labels itself without
266
+ // anyone hardcoding a client. A config override wins, an API miss falls back to the id.
267
+ async function fetchAccountInfo() {
268
+ try {
269
+ const r = await makeRequest(`https://graph.facebook.com/${API_VERSION}/${ACCOUNT_ID}?fields=currency,name&access_token=${ACCESS_TOKEN}`);
270
+ if (r) {
271
+ if (r.currency) console.log(` account currency: ${r.currency}`);
272
+ if (r.name) console.log(` account name: ${r.name}`);
273
+ return { currency: r.currency || null, name: r.name || null };
274
+ }
275
+ } catch (err) {
276
+ console.warn(` ⚠ account info fetch failed (${err.message.slice(0, 60)})`);
277
+ }
278
+ return { currency: null, name: null };
279
+ }
280
+
281
+ // Collapse daily rows into one summary per ad (whole-window totals + a daily series so the
282
+ // dashboard can replay the window on its time slider). CRR/CTR/frequency are recomputed from
283
+ // the summed atoms, never averaged from daily rates.
284
+ function summarize(rows, typeMap, statusMap, previewMap) {
285
+ const byAd = {};
286
+ for (const r of rows) {
287
+ const id = r.ad_id;
288
+ if (!byAd[id]) {
289
+ byAd[id] = {
290
+ ad_id: id, ad_name: r.ad_name, adset_name: r.adset_name, campaign_name: r.campaign_name,
291
+ media_type: typeMap[id] || 'unknown', active: statusMap[id] === true,
292
+ preview_thumb: (previewMap[id] && previewMap[id].thumb) || null,
293
+ preview_image: (previewMap[id] && previewMap[id].image) || null,
294
+ spend: 0, impressions: 0, reach: 0, clicks: 0,
295
+ purchases: 0, revenue: 0, active_days: 0, daily: [],
296
+ };
297
+ }
298
+ const a = byAd[id];
299
+ const spend = +r.spend || 0, impr = +r.impressions || 0, clicks = +r.clicks || 0;
300
+ const purchases = purchaseCount(r.actions);
301
+ const revenue = purchaseRevenue(r.action_values);
302
+ a.spend += spend; a.impressions += impr; a.reach += (+r.reach || 0); a.clicks += clicks;
303
+ a.purchases += purchases; a.revenue += revenue;
304
+ if (impr > 0) a.active_days += 1;
305
+ a.daily.push({
306
+ date: r.date_start,
307
+ spend: Math.round(spend * 100) / 100,
308
+ purchases,
309
+ revenue: Math.round(revenue * 100) / 100,
310
+ impr, clicks,
311
+ });
312
+ }
313
+ return Object.values(byAd).map(a => {
314
+ a.daily.sort((x, y) => x.date.localeCompare(y.date));
315
+ // CRR as a percentage. null = no revenue yet, which is "unknown", not "infinitely bad" —
316
+ // the dashboard parks those ads in a separate no-result strip instead of the top of the axis.
317
+ a.crr = a.revenue > 0 ? (a.spend / a.revenue) * 100 : null;
318
+ a.roas = a.spend > 0 && a.revenue > 0 ? a.revenue / a.spend : null;
319
+ a.ctr = a.impressions > 0 ? (a.clicks / a.impressions) * 100 : 0;
320
+ a.frequency = a.reach > 0 ? a.impressions / a.reach : 0;
321
+ a.aov = a.purchases > 0 ? a.revenue / a.purchases : null;
322
+ a.spend = Math.round(a.spend * 100) / 100;
323
+ a.revenue = Math.round(a.revenue * 100) / 100;
324
+ a.crr = a.crr == null ? null : Math.round(a.crr * 10) / 10;
325
+ a.roas = a.roas == null ? null : Math.round(a.roas * 100) / 100;
326
+ a.ctr = Math.round(a.ctr * 100) / 100;
327
+ a.frequency = Math.round(a.frequency * 100) / 100;
328
+ a.aov = a.aov == null ? null : Math.round(a.aov * 100) / 100;
329
+ return a;
330
+ }).sort((x, y) => y.spend - x.spend);
331
+ }
332
+
333
+ async function main() {
334
+ const end = END_ARG ? new Date(END_ARG + 'T00:00:00Z') : new Date();
335
+ const start = new Date(end);
336
+ start.setDate(start.getDate() - (DAYS - 1));
337
+ const startDate = ymd(start), endDate = ymd(end);
338
+
339
+ console.log(`\nMeta Creative Buckets — data fetch`);
340
+ console.log(` Client: ${cfg.client}`);
341
+ console.log(` Account: ${ACCOUNT_ID}`);
342
+ console.log(` Range: ${startDate} → ${endDate} (${DAYS} days)`);
343
+ console.log(` CRR = spend ÷ purchase revenue (7d click), as %\n`);
344
+
345
+ console.log('Fetching account info...');
346
+ const { currency, name: apiName } = await fetchAccountInfo();
347
+ const accountName = cfg.accountName || apiName || ACCOUNT_ID;
348
+
349
+ console.log('\nFetching daily ad-level insights...');
350
+ const insights = await fetchInsights(startDate, endDate);
351
+ console.log(`✓ ${insights.length} daily rows\n`);
352
+
353
+ // Only the ads that appear in the window need creative metadata — look them up by id
354
+ // rather than paging the account's whole ad list, which is typically 30-40x larger and
355
+ // spends the per-account rate limit before the creative pass gets a turn.
356
+ const spendingAdIds = [...new Set(
357
+ insights.filter(r => (+r.spend || 0) > 0).map(r => r.ad_id)
358
+ )];
359
+ console.log(`Fetching creative types + status + preview for ${spendingAdIds.length} ads...`);
360
+ const { typeMap, statusMap, previewMap } = await fetchAdMeta(spendingAdIds);
361
+
362
+ const ads = summarize(insights, typeMap, statusMap, previewMap);
363
+ console.log(`✓ ${ads.length} ads summarized\n`);
364
+
365
+ // account-level blended numbers — the dashboard seeds its default Target CRR from these
366
+ const totSpend = ads.reduce((s, a) => s + a.spend, 0);
367
+ const totRevenue = ads.reduce((s, a) => s + a.revenue, 0);
368
+ const totPurchases = ads.reduce((s, a) => s + a.purchases, 0);
369
+ const blendedCrr = totRevenue > 0 ? Math.round((totSpend / totRevenue) * 1000) / 10 : null;
370
+ const blendedAov = totPurchases > 0 ? Math.round((totRevenue / totPurchases) * 100) / 100 : null;
371
+
372
+ console.log(` blended: spend ${Math.round(totSpend)} ${currency || ''} · revenue ${Math.round(totRevenue)} ${currency || ''}`);
373
+ console.log(` blended CRR ${blendedCrr == null ? 'n/a' : blendedCrr + '%'}` +
374
+ `${blendedCrr ? ` (ROAS ${Math.round(100 / blendedCrr * 100) / 100})` : ''}` +
375
+ ` · ${totPurchases} purchases · AOV ${blendedAov == null ? 'n/a' : blendedAov}\n`);
376
+
377
+ const payload = {
378
+ account: ACCOUNT_ID, accountName, client: cfg.client, currency,
379
+ startDate, endDate, days: DAYS, metric: 'crr',
380
+ generatedAt: new Date().toISOString(),
381
+ totals: { spend: Math.round(totSpend * 100) / 100, revenue: Math.round(totRevenue * 100) / 100, purchases: totPurchases, crr: blendedCrr, aov: blendedAov },
382
+ ads,
383
+ };
384
+
385
+ const histDir = cfg.historyDir;
386
+ fs.mkdirSync(histDir, { recursive: true });
387
+
388
+ const latest = cfg.latestFile;
389
+ const snapshot = path.join(histDir, `${endDate.replace(/-/g, '')}-buckets.json`);
390
+ fs.writeFileSync(latest, JSON.stringify(payload, null, 2));
391
+ fs.writeFileSync(snapshot, JSON.stringify(payload, null, 2));
392
+
393
+ console.log(`✓ Saved`);
394
+ console.log(` ${latest}`);
395
+ console.log(` ${snapshot} (day-over-day snapshot)`);
396
+ }
397
+
398
+ main().catch(e => { console.error('❌ Fatal:', e.message); process.exit(1); });
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OPTIONAL repair pass for media types + previews on
5
+ * data/meta-ads/<client>/buckets-latest.json.
6
+ *
7
+ * Some accounts trip Meta's per-account rate limit (code 80004) easily, and the main fetch
8
+ * deliberately degrades to media_type 'unknown' rather than throwing away the expensive
9
+ * daily insights. This script re-runs ONLY the cheap creative passes and patches the
10
+ * existing JSON in place, so a rate-limited run can be healed without re-fetching insights.
11
+ *
12
+ * It only fetches creatives for ads that actually appear in the summary (a few hundred),
13
+ * not the whole account, which is what makes the bulk pass trip the limit.
14
+ *
15
+ * The dashboard does not depend on this — on a hard-limited account it may never succeed,
16
+ * and the bucket maths never used media_type. Treat it as a nicety.
17
+ *
18
+ * Usage: node fetch-creative-meta.cjs [--client slug]
19
+ */
20
+
21
+ const https = require('https');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const cfg = require('./config.cjs');
25
+
26
+ const API_VERSION = cfg.apiVersion;
27
+ const { token: ACCESS_TOKEN, accountId: ACCOUNT_ID } = cfg.requireCredentials();
28
+
29
+ const LATEST = cfg.latestFile;
30
+ const DELAY = parseInt(process.env.META_API_RATE_LIMIT_DELAY) || 600;
31
+ const BATCH = 40; // ad ids per ?ids= call — small enough to stay under the account limit
32
+
33
+ function makeRequest(url) {
34
+ return new Promise((resolve, reject) => {
35
+ https.get(url, (res) => {
36
+ let data = '';
37
+ res.on('data', c => (data += c));
38
+ res.on('end', () => {
39
+ if (res.statusCode === 200) resolve(JSON.parse(data));
40
+ else reject(new Error(`HTTP ${res.statusCode}: ${data}`));
41
+ });
42
+ }).on('error', reject);
43
+ });
44
+ }
45
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
46
+
47
+ function classifyMediaType(creative) {
48
+ if (!creative) return 'image';
49
+ if (creative.object_type === 'VIDEO') return 'video';
50
+ if (creative.video_id) return 'video';
51
+ const afs = creative.asset_feed_spec || {};
52
+ if (afs.videos && afs.videos.length) return 'video';
53
+ const spec = creative.object_story_spec || {};
54
+ if (spec.video_data && spec.video_data.video_id) return 'video';
55
+ if (spec.link_data && spec.link_data.video_id) return 'video';
56
+ return 'image';
57
+ }
58
+
59
+ // Batched ?ids= lookup.
60
+ //
61
+ // Backoff policy: retry the 80004 rate limit with escalating waits, but ABORT THE WHOLE RUN
62
+ // once a few batches in a row have exhausted their retries. When this account is hard-limited
63
+ // no batch succeeds, and grinding through every batch's full 60+180+300s burns ~9 minutes each
64
+ // for nothing. Failing fast tells the user "come back later" in under a minute instead.
65
+ //
66
+ // Any NON-rate-limit error (e.g. a bad field) is a bug, not a wait: throw immediately rather
67
+ // than silently leaving every ad unpatched.
68
+ const GIVE_UP_AFTER = 2; // consecutive fully-exhausted batches before aborting the run
69
+
70
+ class RateLimitedError extends Error {}
71
+
72
+ async function fetchBatch(ids, fields, label) {
73
+ const out = {};
74
+ let consecutiveFailures = 0, succeeded = 0;
75
+ for (let i = 0; i < ids.length; i += BATCH) {
76
+ const chunk = ids.slice(i, i + BATCH);
77
+ const url = `https://graph.facebook.com/${API_VERSION}/?ids=${chunk.join(',')}` +
78
+ `&fields=${encodeURIComponent(fields)}&access_token=${ACCESS_TOKEN}`;
79
+ const n = Math.floor(i / BATCH) + 1, total = Math.ceil(ids.length / BATCH);
80
+ process.stdout.write(` ${label} batch ${n}/${total} ... `);
81
+ let done = false, ok = false;
82
+ const WAITS = [30000, 90000, 180000];
83
+ for (let attempt = 0; attempt <= WAITS.length && !done; attempt++) {
84
+ try {
85
+ const resp = await makeRequest(url);
86
+ Object.assign(out, resp);
87
+ console.log(`ok`);
88
+ done = true; ok = true;
89
+ } catch (err) {
90
+ const limited = /80004|too many calls|request limit/i.test(err.message);
91
+ if (!limited) throw new Error(`${label} batch ${n} failed: ${err.message.slice(0, 200)}`);
92
+ if (attempt === WAITS.length) { console.log(`SKIP (still rate limited)`); done = true; }
93
+ else { console.log(`rate limited — waiting ${WAITS[attempt] / 1000}s`); await sleep(WAITS[attempt]); }
94
+ }
95
+ }
96
+ if (ok) { succeeded++; consecutiveFailures = 0; }
97
+ else if (++consecutiveFailures >= GIVE_UP_AFTER && succeeded === 0) {
98
+ throw new RateLimitedError(
99
+ `account is hard rate-limited (${consecutiveFailures} batches exhausted their retries, none succeeded)`);
100
+ }
101
+ await sleep(DELAY);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ async function main() {
107
+ const payload = JSON.parse(fs.readFileSync(LATEST, 'utf-8'));
108
+ const ads = payload.ads.filter(a => a.spend > 0);
109
+ console.log(`\nCreative repair pass — ${cfg.client}`);
110
+ console.log(` ${ads.length} ads with spend in ${path.basename(LATEST)}\n`);
111
+
112
+ const adIds = ads.map(a => a.ad_id);
113
+
114
+ // pass 1 — ad -> creative id + status (flat, no nested expansion).
115
+ // NOTE: thumbnail_url is NOT requestable on the batched /?ids= endpoint (only on the
116
+ // /ads edge) — asking for it 400s the whole batch. Thumbnails come from pass 2 instead.
117
+ const adInfo = await fetchBatch(adIds, 'id,effective_status,creative{id}', 'ads');
118
+
119
+ const creativeIds = [];
120
+ const adCreative = {}, adStatus = {};
121
+ for (const id of adIds) {
122
+ const a = adInfo[id];
123
+ if (!a) continue;
124
+ adStatus[id] = a.effective_status === 'ACTIVE';
125
+ if (a.creative && a.creative.id) {
126
+ adCreative[id] = a.creative.id;
127
+ creativeIds.push(a.creative.id);
128
+ }
129
+ }
130
+ const uniqCreatives = [...new Set(creativeIds)];
131
+ console.log(`\n resolved ${Object.keys(adCreative).length}/${adIds.length} ads to ${uniqCreatives.length} creatives\n`);
132
+
133
+ // pass 2 — creative details
134
+ const crInfo = await fetchBatch(uniqCreatives,
135
+ 'id,object_type,video_id,asset_feed_spec{videos},object_story_spec,thumbnail_url,image_url', 'creatives');
136
+
137
+ let patched = 0, stillUnknown = 0;
138
+ for (const a of payload.ads) {
139
+ const cr = crInfo[adCreative[a.ad_id]];
140
+ if (cr) {
141
+ a.media_type = classifyMediaType(cr);
142
+ a.preview_thumb = cr.thumbnail_url || a.preview_thumb || null;
143
+ a.preview_image = cr.image_url || a.preview_image || null;
144
+ patched++;
145
+ } else if (a.media_type === 'unknown') {
146
+ stillUnknown++;
147
+ }
148
+ if (adStatus[a.ad_id] != null) a.active = adStatus[a.ad_id];
149
+ }
150
+
151
+ const counts = payload.ads.reduce((m, a) => (m[a.media_type] = (m[a.media_type] || 0) + 1, m), {});
152
+ console.log(`\n✓ patched ${patched} ads · still unknown: ${stillUnknown}`);
153
+ console.log(` media types: ${JSON.stringify(counts)}`);
154
+
155
+ fs.writeFileSync(LATEST, JSON.stringify(payload, null, 2));
156
+ console.log(`✓ Updated ${LATEST}`);
157
+ }
158
+
159
+ main().catch(e => {
160
+ // A hard rate limit is an expected, retryable state on this account — not a crash.
161
+ // Say so plainly and leave buckets-latest.json untouched (nothing was written).
162
+ if (e instanceof RateLimitedError) {
163
+ console.error(`\n⏳ Aborted: ${e.message}`);
164
+ console.error(' Nothing was changed — buckets-latest.json is intact.');
165
+ console.error(' Meta\'s ads-management limit resets on a rolling window; wait ~15-30 min and re-run:');
166
+ console.error(' npm run meta:buckets-creatives && npm run meta:buckets-chart');
167
+ console.error(' The dashboard works meanwhile; only the video/static badges stay "?".');
168
+ process.exit(2);
169
+ }
170
+ console.error('❌ Fatal:', e.message);
171
+ process.exit(1);
172
+ });
@@ -0,0 +1,65 @@
1
+ # Optional extras — not part of the standard workflow
2
+
3
+ The dashboard runs on the Meta Ads API alone. The two scripts in this folder add a promo
4
+ dimension and creative thumbnails on top of it, and **both need data this skill does not
5
+ produce**: a Meta Ad Library scrape, classified per creative.
6
+
7
+ They are kept here as a working reference for anyone who has that pipeline. If you do not,
8
+ ignore this folder — the matrix works without it, it just has no promo filter and falls back
9
+ to Meta's own preview images.
10
+
11
+ ## What they expect
12
+
13
+ Both read from `research/meta-ads-library/<client>/` in the project root:
14
+
15
+ | file | produced by | used for |
16
+ |---|---|---|
17
+ | `_data/promo-labels.json` | your classifier | ad id → promo verdict |
18
+ | `creatives-manifest.json` | your scraper | ad id → downloaded image files |
19
+ | `dedupe-map.json` | your scraper | file → its representative in `unique/` |
20
+ | `unique/*.jpg` | your scraper | the images themselves |
21
+
22
+ Paths are hardcoded to that layout. Adapt them, or write your own equivalent — the dashboard
23
+ only cares about the two output files.
24
+
25
+ ## The output contract
26
+
27
+ That is the part worth knowing, because you can produce these two files any way you like and
28
+ the dashboard will pick them up:
29
+
30
+ **`data/meta-ads/<client>/promo-labels.json`** — a flat map, ad id to one of three exact
31
+ strings:
32
+
33
+ ```json
34
+ {
35
+ "120210000000000001": "Promo - main message",
36
+ "120210000000000002": "Promo - secondary message",
37
+ "120210000000000003": "Non-promo"
38
+ }
39
+ ```
40
+
41
+ Meaning: the offer *is* the argument / an offer rides alongside a different argument / no
42
+ offer in the creative.
43
+
44
+ **`data/meta-ads/<client>/thumbs.json`** — ad id to an image path, relative to the dashboard
45
+ HTML:
46
+
47
+ ```json
48
+ { "120210000000000001": "thumbs/120210000000000001.jpg" }
49
+ ```
50
+
51
+ Write a second file per ad with an `@full` suffix (`thumbs/<id>@full.jpg`) for the lightbox.
52
+ A missing `@full` falls back to the small image rather than breaking.
53
+
54
+ ## Two things that bite
55
+
56
+ **Labels drop ads.** When `promo-labels.json` is present, ads without a label are dropped
57
+ from the dashboard entirely rather than shown as "Unclassified" — an unactionable bucket is
58
+ worse than a stated coverage gap. A **stale** label file is therefore the dangerous case: if
59
+ none of the current ad ids match, the dashboard comes out empty. Re-extract labels after
60
+ every fresh fetch, and read the "dropped" count the build prints.
61
+
62
+ **Join on your classifier's map, not on a rendered report page.** A report card typically
63
+ shows one representative creative per concept, so scraping ids out of the HTML silently drops
64
+ every other ad running the same visual. That mistake once labelled 88 of 208 ads where the
65
+ generator's own map covered 154.