@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.
- package/LICENSE +14 -0
- package/README.md +76 -0
- package/SKILL.md +231 -0
- package/buckets.config.example.json +8 -0
- package/env.example +29 -0
- package/install-to-claude.js +36 -0
- package/package.json +40 -0
- package/scripts/build-buckets-matrix.cjs +1367 -0
- package/scripts/config.cjs +133 -0
- package/scripts/encrypt-dashboard.cjs +241 -0
- package/scripts/fetch-buckets-data.cjs +398 -0
- package/scripts/fetch-creative-meta.cjs +172 -0
- package/scripts/optional/README.md +65 -0
- package/scripts/optional/extract-promo-labels.cjs +78 -0
- package/scripts/optional/extract-thumbs.cjs +169 -0
|
@@ -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)}/`);
|