@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,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Extract the promo label of every ad from the Ad Library report and write it as a
5
+ * plain ad_id -> label map that the buckets dashboard can join against.
6
+ *
7
+ * research/meta-ads-library/<client>/_data/promo-labels.json (your classifier's output)
8
+ * -> data/meta-ads/<client>/promo-labels.json
9
+ *
10
+ * The labels themselves are produced by your own creative classifier:
11
+ *
12
+ * "Promo - main message" the offer IS the argument of the ad
13
+ * "Promo - secondary message" carries an offer beside a different main argument
14
+ * "Non-promo" no offer
15
+ *
16
+ * Only STATIC ads carry a label — the underlying promo classification was run over the
17
+ * static creatives only, so videos deliberately have none and are reported as unlabelled
18
+ * rather than guessed at.
19
+ *
20
+ * The map comes straight from the generator, not from parsing the rendered page. A card only
21
+ * shows its concept's representative creative, so scraping ad ids out of the html misses every
22
+ * other ad running the same picture — that is how a 208-ad dashboard once labelled only 88.
23
+ *
24
+ * Coverage is partial by nature: the Ad Library scrape is a snapshot of what was public at
25
+ * scrape time, while the buckets data is every ad that spent in the window. Ads missing from
26
+ * the map (videos, and ads created after the scrape date) are DROPPED by the dashboard rather
27
+ * than defaulted to Non-promo — unknown is not the same as "no offer".
28
+ *
29
+ * Usage: node extract-promo-labels.cjs
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const cfg = require('../config.cjs');
36
+
37
+ const SRC = path.join(process.cwd(), 'research', 'meta-ads-library', cfg.client,
38
+ '_data', 'promo-labels.json');
39
+ const OUT_DIR = cfg.dataDir;
40
+ const OUT = path.join(OUT_DIR, 'promo-labels.json');
41
+ const BUCKETS = cfg.latestFile;
42
+
43
+ const VALID = new Set(['Promo - main message', 'Promo - secondary message', 'Non-promo']);
44
+
45
+ if (!fs.existsSync(SRC)) {
46
+ console.error(`Error: ${SRC} not found — run your classifier first.`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const map = JSON.parse(fs.readFileSync(SRC, 'utf-8'));
51
+
52
+ const counts = {};
53
+ for (const l of Object.values(map)) counts[l] = (counts[l] || 0) + 1;
54
+
55
+ console.log(`\nPromo labels — ${cfg.client}`);
56
+ console.log(` source: ${path.relative(process.cwd(), SRC)}`);
57
+ console.log(` ads labelled : ${Object.keys(map).length}`);
58
+ for (const [l, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
59
+ console.log(` ${l.padEnd(26)}: ${n}`);
60
+ }
61
+
62
+ // Report the join rate against the actual buckets data, so partial coverage is visible
63
+ // here rather than being discovered as a puzzling gap in the dashboard.
64
+ if (fs.existsSync(BUCKETS)) {
65
+ const b = JSON.parse(fs.readFileSync(BUCKETS, 'utf-8'));
66
+ const ads = b.ads.filter(a => a.spend > 0);
67
+ const hit = ads.filter(a => map[a.ad_id]);
68
+ const spendHit = hit.reduce((s, a) => s + a.spend, 0);
69
+ const spendAll = ads.reduce((s, a) => s + a.spend, 0);
70
+ console.log(`\n join against buckets-latest.json:`);
71
+ console.log(` ${hit.length}/${ads.length} ads matched` +
72
+ ` · ${(spendHit / spendAll * 100).toFixed(0)}% of spend`);
73
+ console.log(` ${ads.length - hit.length} ads have no label → dropped from the dashboard`);
74
+ }
75
+
76
+ fs.mkdirSync(OUT_DIR, { recursive: true });
77
+ fs.writeFileSync(OUT, JSON.stringify(map, null, 2));
78
+ console.log(`\n✓ Saved ${OUT}`);
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Build ad_id -> creative thumbnail for the buckets dashboard, from the Ad Library scrape.
5
+ *
6
+ * Meta's API is the natural source for previews, but this account is permanently rate
7
+ * limited (code 80004) and the creative pass keeps coming back empty — every ad ends up
8
+ * with preview_thumb: null. The Ad Library scrape already downloaded the same creatives,
9
+ * so this reads them off disk instead of asking Meta again.
10
+ *
11
+ * research/meta-ads-library/<client>/
12
+ * creatives-manifest.json ad -> downloaded files
13
+ * dedupe-map.json file -> its representative in unique/
14
+ * unique/ the actual (deduped, downscaled) images
15
+ * -> data/meta-ads/<client>/thumbs/<ad_id>.jpg + thumbs.json
16
+ *
17
+ * Two details that cost time when they are missed:
18
+ * - manifest adId is often prefixed ("ACME_CZ_120249…"), the insights API ad_id is not.
19
+ * Joining without stripping the prefix silently matches nothing.
20
+ * - dedupe-map keys are the ORIGINAL filenames (.png), while unique/ holds .jpg —
21
+ * hence the extension rewrite. Deduping is why coverage is high: several ads
22
+ * sharing one visual all resolve to the same representative file.
23
+ *
24
+ * Two sizes are written per ad:
25
+ * thumbs/<ad_id>.jpg small, shown in the table row (default 96px)
26
+ * thumbs/<ad_id>@full.jpg bigger, shown when the row thumbnail is clicked (default 600px)
27
+ *
28
+ * The originals are 1080×1350 and the whole unique/ folder is ~66 MB, so copying it wholesale
29
+ * would bloat the dashboard for no gain — 600px is already sharper than any lightbox needs.
30
+ * Only ads that actually appear in the dashboard get exported.
31
+ *
32
+ * Images are downscaled with `sips` (built into macOS); if sips is unavailable the original
33
+ * file is copied as-is.
34
+ *
35
+ * Usage: node extract-thumbs.cjs [size] [fullSize] (default 96 / 600, long edge)
36
+ */
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+ const { execFileSync } = require('child_process');
41
+
42
+ const cfg = require('../config.cjs');
43
+
44
+ const SRC_DIR = path.join(process.cwd(), 'research', 'meta-ads-library', cfg.client);
45
+ const UNIQUE = path.join(SRC_DIR, 'unique');
46
+ const OUT_DIR = cfg.dataDir;
47
+ const THUMB_DIR = path.join(OUT_DIR, 'thumbs');
48
+ const OUT_JSON = path.join(OUT_DIR, 'thumbs.json');
49
+ const BUCKETS = cfg.latestFile;
50
+
51
+ // Scrapers often namespace their ad ids ("ACME_CZ_120249…") while the insights API does not.
52
+ // Derived from the client slug; override with BUCKETS_AD_ID_PREFIX if yours differs.
53
+ const ID_PREFIX = process.env.BUCKETS_AD_ID_PREFIX != null
54
+ ? process.env.BUCKETS_AD_ID_PREFIX
55
+ : `${cfg.envSuffix}_`;
56
+
57
+ const SIZE = parseInt(process.argv[2]) || 96;
58
+ const FULL_SIZE = parseInt(process.argv[3]) || 600;
59
+
60
+ for (const f of ['creatives-manifest.json', 'dedupe-map.json']) {
61
+ if (!fs.existsSync(path.join(SRC_DIR, f))) {
62
+ console.error(`Error: ${f} not found in ${SRC_DIR} — run the Ad Library scrape first.`);
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ const manifest = JSON.parse(fs.readFileSync(path.join(SRC_DIR, 'creatives-manifest.json'), 'utf-8'));
68
+ const dedupe = JSON.parse(fs.readFileSync(path.join(SRC_DIR, 'dedupe-map.json'), 'utf-8'));
69
+
70
+ // original filename -> representative file in unique/ (always .jpg there)
71
+ const repOf = {};
72
+ for (const [rep, list] of Object.entries(dedupe)) {
73
+ const j = rep.replace(/\.[^.]+$/, '.jpg');
74
+ repOf[j] = j; // a representative maps to itself
75
+ for (const f of list) repOf[f] = j;
76
+ }
77
+
78
+ // A concept can merge several takes of one visual — typically the same layout with
79
+ // and without an offer badge burned in. The promo verdict is decided per CONCEPT, so
80
+ // an ad labelled "secondary message" can end up showing the variant with no badge on
81
+ // it, and the label then looks wrong to anyone comparing the two. Where the concept
82
+ // holds both, prefer the take that actually carries the offer.
83
+ const OFFER = /1\s*\+\s*1|2\s*\+\s*1|3\s*\+\s*3|ZDARMA|SLEVA|-\s*\d+\s*%|\d+\s*%\s*(SLEVA|OFF)|VYPRODEJ/i;
84
+ const promoOf = {}; // representative file -> the take showing the badge
85
+ try {
86
+ const ang = JSON.parse(fs.readFileSync(path.join(SRC_DIR, 'angles-static.json'), 'utf-8'));
87
+ const clus = JSON.parse(fs.readFileSync(
88
+ path.join(SRC_DIR, 'concept-clusters-ai.json'), 'utf-8')).clustery;
89
+ const desc = JSON.parse(fs.readFileSync(
90
+ path.join(SRC_DIR, 'ai-descriptions.json'), 'utf-8')).descriptions;
91
+ const jpg = f => f.replace(/\.[^.]+$/, '.jpg');
92
+
93
+ ang.forEach((c, i) => {
94
+ if (c.promo !== 'secondary') return;
95
+ const files = clus[i] || [];
96
+ if (files.length < 2) return;
97
+ const withBadge = files.find(f => OFFER.test((desc[f] || {}).text || ''));
98
+ if (!withBadge) return; // offer lives in the copy, not the picture
99
+ // cluster files are already representatives (the .jpg keys of dedupe-map),
100
+ // so they can be used as-is — repOf maps the other direction
101
+ for (const f of files) promoOf[jpg(f)] = jpg(withBadge);
102
+ });
103
+ } catch (e) {
104
+ console.warn(' ⚠ could not read the angle files — showing each concept\'s default take');
105
+ }
106
+
107
+ // ad_id (prefix stripped) -> representative image filename
108
+ const srcOf = {};
109
+ let swapped = 0;
110
+ for (const m of manifest) {
111
+ const id = ID_PREFIX && String(m.adId).startsWith(ID_PREFIX)
112
+ ? String(m.adId).slice(ID_PREFIX.length)
113
+ : String(m.adId);
114
+ const first = (m.files || []).map(x => x.file)[0];
115
+ if (!first) continue;
116
+ const rep = repOf[first] || first.replace(/\.[^.]+$/, '.jpg');
117
+ const better = promoOf[rep];
118
+ if (better && better !== rep) swapped++;
119
+ srcOf[id] = better || rep;
120
+ }
121
+
122
+ if (swapped) console.log(` ${swapped} ads switched to the take carrying the offer badge`);
123
+
124
+ // Only bother with ads the dashboard actually shows.
125
+ let wanted = Object.keys(srcOf);
126
+ if (fs.existsSync(BUCKETS)) {
127
+ const b = JSON.parse(fs.readFileSync(BUCKETS, 'utf-8'));
128
+ const spent = new Set(b.ads.filter(a => a.spend > 0).map(a => a.ad_id));
129
+ wanted = wanted.filter(id => spent.has(id));
130
+ }
131
+
132
+ fs.mkdirSync(THUMB_DIR, { recursive: true });
133
+
134
+ let ok = 0, missing = 0, copied = 0;
135
+ const map = {};
136
+ let sipsWorks = true;
137
+
138
+ for (const id of wanted) {
139
+ const src = path.join(UNIQUE, srcOf[id]);
140
+ if (!fs.existsSync(src)) { missing++; continue; }
141
+ // small (row) + full (lightbox), both derived from the same source image
142
+ for (const [suffix, px] of [['', SIZE], ['@full', FULL_SIZE]]) {
143
+ const dst = path.join(THUMB_DIR, `${id}${suffix}.jpg`);
144
+ if (fs.existsSync(dst)) continue;
145
+ let done = false;
146
+ if (sipsWorks) {
147
+ try {
148
+ execFileSync('sips', ['-Z', String(px), src, '--out', dst], { stdio: 'ignore' });
149
+ done = true;
150
+ } catch (e) {
151
+ sipsWorks = false; // not macOS / sips missing — fall back for the rest of the run
152
+ }
153
+ }
154
+ if (!done) { fs.copyFileSync(src, dst); copied++; }
155
+ }
156
+ map[id] = `thumbs/${id}.jpg`; // relative to the dashboard html; @full is derived from it
157
+ ok++;
158
+ }
159
+
160
+ fs.writeFileSync(OUT_JSON, JSON.stringify(map, null, 2));
161
+
162
+ console.log(`\nCreative thumbnails — ${cfg.client}`);
163
+ console.log(` source: ${path.relative(process.cwd(), UNIQUE)}`);
164
+ console.log(` thumbnails ready : ${ok}`);
165
+ if (copied) console.log(` copied full-size : ${copied} (sips unavailable)`);
166
+ if (missing) console.log(` source missing : ${missing}`);
167
+ console.log(` sizes : ${SIZE}px row · ${FULL_SIZE}px lightbox`);
168
+ console.log(`\n✓ Saved ${OUT_JSON}`);
169
+ console.log(` images in ${path.relative(process.cwd(), THUMB_DIR)}/`);