overlay-factory-worker 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.
Files changed (54) hide show
  1. package/README.md +215 -0
  2. package/package.json +64 -0
  3. package/public/fonts/Handjet-variable.woff2 +0 -0
  4. package/remotion.config.ts +14 -0
  5. package/scripts/check-legibility.ts +284 -0
  6. package/scripts/check-safe-area.ts +148 -0
  7. package/scripts/custom-fonts.ts +114 -0
  8. package/scripts/export.sh +31 -0
  9. package/scripts/field-images.ts +93 -0
  10. package/scripts/ig-probe.ts +83 -0
  11. package/scripts/ig-sync.ts +204 -0
  12. package/scripts/ingest.sh +34 -0
  13. package/scripts/library.ts +143 -0
  14. package/scripts/look-card.ts +107 -0
  15. package/scripts/look-store.ts +176 -0
  16. package/scripts/make-card.ts +237 -0
  17. package/scripts/merge-index.ts +74 -0
  18. package/scripts/new-episode.ts +149 -0
  19. package/scripts/overlay-worker.ts +1119 -0
  20. package/scripts/place-overlay.ts +359 -0
  21. package/scripts/prep-card.ts +73 -0
  22. package/scripts/quality.ts +0 -0
  23. package/scripts/render-overlay.ts +150 -0
  24. package/scripts/report.ts +127 -0
  25. package/scripts/rerender-cards.ts +116 -0
  26. package/scripts/series.ts +816 -0
  27. package/scripts/set-difficulty.ts +62 -0
  28. package/scripts/state-dir.ts +102 -0
  29. package/scripts/stock.ts +254 -0
  30. package/scripts/verify.ts +149 -0
  31. package/scripts/wp-restock.ts +281 -0
  32. package/src/Root.tsx +112 -0
  33. package/src/index.css +1 -0
  34. package/src/index.ts +4 -0
  35. package/src/lab/FontLab.tsx +50 -0
  36. package/src/lab/FontSheet.tsx +188 -0
  37. package/src/lab/PillLab.tsx +121 -0
  38. package/src/overlay/Composition.tsx +297 -0
  39. package/src/overlay/DifficultyMeter.tsx +86 -0
  40. package/src/overlay/PixelText.tsx +134 -0
  41. package/src/overlay/Title.tsx +75 -0
  42. package/src/overlay/brandFonts.ts +58 -0
  43. package/src/overlay/cardLayout.ts +94 -0
  44. package/src/overlay/fonts.ts +19 -0
  45. package/src/overlay/look.ts +155 -0
  46. package/src/overlay/safeArea.ts +89 -0
  47. package/src/overlay/types.ts +134 -0
  48. package/src/series/what-prints/CodeCard.tsx +107 -0
  49. package/src/series/what-prints/Composition.tsx +106 -0
  50. package/src/series/what-prints/codeCardTypes.ts +105 -0
  51. package/src/series/what-prints/types.ts +22 -0
  52. package/tsconfig.json +17 -0
  53. package/worker/cli.mjs +151 -0
  54. package/worker/service.mjs +404 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Does anything land where Instagram's UI will sit on top of it?
3
+ *
4
+ * Measures rather than assumes: renders a frame with and without the overlays,
5
+ * diffs them to get the exact pixels the composition draws, and reports the
6
+ * bounding box of each overlay against the safe area. No hand-measuring a
7
+ * screenshot, and it re-checks itself every time the layout changes.
8
+ *
9
+ * npx tsx scripts/check-safe-area.ts episodes/what-prints-002.json
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import sharp from "sharp";
16
+ import {
17
+ UI_ZONES,
18
+ FRAME,
19
+ GRID_CROP,
20
+ overlaps,
21
+ } from "../src/overlay/safeArea";
22
+
23
+ const ROOT = join(import.meta.dirname, "..");
24
+ const { width: W, height: H } = FRAME;
25
+
26
+ /** Vertical bands to attribute overlay pixels to, so the report names names. */
27
+ const BANDS = [
28
+ { name: "title", from: 0, to: 600 },
29
+ { name: "card", from: 600, to: 1300 },
30
+ { name: "meter", from: 1300, to: H },
31
+ ];
32
+
33
+ type Episode = { revealAtSec: number; durationSec: number };
34
+
35
+ const renderStill = (
36
+ episodePath: string,
37
+ frame: number,
38
+ out: string,
39
+ hideOverlays: boolean,
40
+ ) => {
41
+ const episode = JSON.parse(readFileSync(episodePath, "utf8")) as object;
42
+ const props = join(ROOT, ".safe-area-props.json");
43
+ writeFileSync(props, JSON.stringify({ ...episode, hideOverlays }));
44
+ try {
45
+ execFileSync(
46
+ "npx",
47
+ [
48
+ "remotion",
49
+ "still",
50
+ "WhatPrints",
51
+ out,
52
+ `--props=${props}`,
53
+ `--frame=${frame}`,
54
+ "--log=error",
55
+ ],
56
+ { cwd: ROOT, stdio: "inherit" },
57
+ );
58
+ } finally {
59
+ rmSync(props, { force: true });
60
+ }
61
+ };
62
+
63
+ const raw = async (file: string) => {
64
+ const { data, info } = await sharp(file)
65
+ .resize(W, H, { fit: "fill" })
66
+ .removeAlpha()
67
+ .raw()
68
+ .toBuffer({ resolveWithObject: true });
69
+ return { data, channels: info.channels };
70
+ };
71
+
72
+ const main = async () => {
73
+ const episodePath = process.argv[2];
74
+ if (!episodePath) {
75
+ console.error("usage: check-safe-area.ts <episode.json>");
76
+ process.exit(1);
77
+ }
78
+ const ep = JSON.parse(readFileSync(episodePath, "utf8")) as Episode;
79
+ const dir = mkdtempSync(join(tmpdir(), "reel-safe-"));
80
+ const frame = Math.round((ep.revealAtSec + 0.5) * 30);
81
+
82
+ const a = join(dir, "full.png");
83
+ const b = join(dir, "bg.png");
84
+ renderStill(episodePath, frame, a, false);
85
+ renderStill(episodePath, frame, b, true);
86
+ const [full, bg] = [await raw(a), await raw(b)];
87
+ const ch = full.channels;
88
+
89
+ // Bounding box of drawn pixels, per band. The scrim is a full-frame gradient
90
+ // and would swamp everything, so only strongly-changed pixels count.
91
+ const boxes = BANDS.map((band) => {
92
+ let top: number = H;
93
+ let bottom = -1;
94
+ let left: number = W;
95
+ let right = -1;
96
+ for (let y = band.from; y < band.to; y++) {
97
+ for (let x = 0; x < W; x++) {
98
+ const i = (y * W + x) * ch;
99
+ const d =
100
+ Math.abs(full.data[i] - bg.data[i]) +
101
+ Math.abs(full.data[i + 1] - bg.data[i + 1]) +
102
+ Math.abs(full.data[i + 2] - bg.data[i + 2]);
103
+ if (d < 120) continue;
104
+ if (y < top) top = y;
105
+ if (y > bottom) bottom = y;
106
+ if (x < left) left = x;
107
+ if (x > right) right = x;
108
+ }
109
+ }
110
+ return bottom < 0 ? null : { name: band.name, top, bottom, left, right };
111
+ }).filter((b): b is NonNullable<typeof b> => b !== null);
112
+
113
+ rmSync(dir, { recursive: true, force: true });
114
+
115
+ console.log(`\n${episodePath} — overlay bounds vs Instagram's UI`);
116
+ for (const z of UI_ZONES)
117
+ console.log(
118
+ ` IG ${z.name.padEnd(11)} x ${z.x}-${z.x + z.width} y ${z.y}-${z.y + z.height}`,
119
+ );
120
+ console.log("");
121
+
122
+ let bad = false;
123
+ for (const box of boxes) {
124
+ const hits = UI_ZONES.filter((z) => overlaps(box, z)).map((z) => z.name);
125
+
126
+ const grid =
127
+ box.top < GRID_CROP.top || box.bottom > H - GRID_CROP.bottom
128
+ ? " (also cropped out of the profile grid thumbnail)"
129
+ : "";
130
+
131
+ if (hits.length) bad = true;
132
+ console.log(
133
+ ` ${box.name.padEnd(6)} x ${String(box.left).padStart(4)}-${String(box.right).padEnd(4)} ` +
134
+ `y ${String(box.top).padStart(4)}-${String(box.bottom).padEnd(4)} ` +
135
+ (hits.length ? `UNDER ${hits.join(" + ")}` : "ok") +
136
+ grid,
137
+ );
138
+ }
139
+
140
+ if (bad)
141
+ console.log(
142
+ `\nInstagram draws its own controls over those areas. Move the element in\n` +
143
+ `src/series/what-prints/Composition.tsx, or shrink it.`,
144
+ );
145
+ process.exit(bad ? 1 : 0);
146
+ };
147
+
148
+ main();
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Fonts the user uploaded, made usable by the renderer.
3
+ *
4
+ * A font file is arbitrary binary handed to a browser's font parser, and it
5
+ * arrives from an upload form. So it is checked rather than trusted:
6
+ *
7
+ * - the magic bytes must actually be a font (the browser's reported content
8
+ * type is worthless here — .woff2 routinely uploads as octet-stream)
9
+ * - it's fetched to disk once and rendered from a local file, so a render
10
+ * never waits on the network mid-frame
11
+ * - the family name is quoted into a CSS stack, so anything that could close
12
+ * a declaration is rejected upstream in addBrandFont
13
+ *
14
+ * A file that fails any of that is skipped with a warning. One bad font must
15
+ * not take down a render that would otherwise be fine.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ const ROOT = join(__dirname, "..");
21
+ const DIR = join(ROOT, "public/fonts/custom");
22
+
23
+ export type CustomFont = { family: string; url: string };
24
+ export type ResolvedFont = { family: string; file: string; format: string };
25
+
26
+ /**
27
+ * Font container signatures.
28
+ *
29
+ * TrueType is 0x00010000, and "true"/"ttcf" are the Apple variants. Anything
30
+ * else — a renamed zip, an image, a script — is not a font and is dropped.
31
+ */
32
+ const sniff = (buf: Buffer): string | null => {
33
+ const tag = buf.subarray(0, 4).toString("latin1");
34
+ if (tag === "wOF2") return "woff2";
35
+ if (tag === "wOFF") return "woff";
36
+ if (tag === "OTTO") return "otf";
37
+ if (tag === "true" || tag === "ttcf") return "ttf";
38
+ if (buf.readUInt32BE(0) === 0x00010000) return "ttf";
39
+ return null;
40
+ };
41
+
42
+ const safeName = (family: string) =>
43
+ family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
44
+
45
+ /**
46
+ * Download and verify each font, returning the ones that are usable.
47
+ * Cached on disk by family + extension, so repeat renders don't refetch.
48
+ */
49
+ export const resolveCustomFonts = async (
50
+ fonts: CustomFont[],
51
+ ): Promise<ResolvedFont[]> => {
52
+ if (!fonts.length) return [];
53
+ mkdirSync(DIR, { recursive: true });
54
+ const out: ResolvedFont[] = [];
55
+
56
+ for (const f of fonts) {
57
+ if (!f.family || !/^https:\/\//.test(f.url)) continue;
58
+ const base = safeName(f.family);
59
+ if (!base) continue;
60
+
61
+ try {
62
+ const cached = ["woff2", "woff", "otf", "ttf"]
63
+ .map((ext) => ({ ext, p: join(DIR, `${base}.${ext}`) }))
64
+ .find(({ p }) => existsSync(p));
65
+
66
+ if (cached) {
67
+ out.push({
68
+ family: f.family,
69
+ file: `fonts/custom/${base}.${cached.ext}`,
70
+ format: cached.ext,
71
+ });
72
+ continue;
73
+ }
74
+
75
+ const res = await fetch(f.url);
76
+ if (!res.ok) throw new Error(`download failed: ${res.status}`);
77
+ const buf = Buffer.from(await res.arrayBuffer());
78
+
79
+ const format = sniff(buf);
80
+ if (!format) {
81
+ console.warn(` ! "${f.family}" isn't a font file — skipped`);
82
+ continue;
83
+ }
84
+
85
+ const file = join(DIR, `${base}.${format}`);
86
+ writeFileSync(file, buf);
87
+ out.push({
88
+ family: f.family,
89
+ file: `fonts/custom/${base}.${format}`,
90
+ format,
91
+ });
92
+ } catch (e) {
93
+ console.warn(
94
+ ` ! "${f.family}" skipped (${e instanceof Error ? e.message : e})`,
95
+ );
96
+ }
97
+ }
98
+ return out;
99
+ };
100
+
101
+ /** What a verified font is called on disk, for the @font-face src. */
102
+ export const fontFaceCss = (
103
+ fonts: ResolvedFont[],
104
+ staticFileUrl: (p: string) => string,
105
+ ) =>
106
+ fonts
107
+ .map(
108
+ (f) => `@font-face{font-family:"${f.family}";src:url("${staticFileUrl(
109
+ f.file,
110
+ )}") format("${f.format}");font-display:block;}`,
111
+ )
112
+ .join("\n");
113
+
114
+ export { readFileSync };
@@ -0,0 +1,31 @@
1
+ #!/bin/bash
2
+ # Copy a rendered MP4 (and its caption, if present) to
3
+ # ~/Desktop/Automate Editing/exports/ via Finder (TCC workaround).
4
+ # Usage: scripts/export.sh out/what-prints-001.mp4
5
+ set -euo pipefail
6
+
7
+ FILE="${1:?usage: export.sh <rendered.mp4>}"
8
+ FILE="$(cd "$(dirname "$FILE")" && pwd)/$(basename "$FILE")"
9
+ CAPTION="${FILE%.mp4}.caption.txt"
10
+
11
+ osascript >/dev/null <<EOF
12
+ tell application "Finder"
13
+ set base to folder "Automate Editing" of desktop
14
+ if not (exists folder "exports" of base) then
15
+ make new folder at base with properties {name:"exports"}
16
+ end if
17
+ set dest to folder "exports" of base
18
+ duplicate (POSIX file "$FILE" as alias) to dest with replacing
19
+ end tell
20
+ EOF
21
+
22
+ if [ -f "$CAPTION" ]; then
23
+ osascript >/dev/null <<EOF
24
+ tell application "Finder"
25
+ set dest to folder "exports" of folder "Automate Editing" of desktop
26
+ duplicate (POSIX file "$CAPTION" as alias) to dest with replacing
27
+ end tell
28
+ EOF
29
+ fi
30
+
31
+ echo "Exported $(basename "$FILE") to Desktop/Automate Editing/exports/"
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Photos a series asks for, fetched to disk before anything renders.
3
+ *
4
+ * Templates are forbidden from containing remote URLs, and that rule stays:
5
+ * a render that pauses mid-frame waiting on someone's CDN is a render that
6
+ * can hang for minutes or produce a frame with a hole in it. Instead the
7
+ * worker downloads the picture first and substitutes a LOCAL path, so by the
8
+ * time Chrome sees the markup the file is already on the disk beside it.
9
+ *
10
+ * The bytes are sniffed for the same reason uploaded fonts are: this is a file
11
+ * from an upload form being handed to a browser's image decoder.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import { join } from "node:path";
16
+
17
+ const ROOT = join(__dirname, "..");
18
+ const DIR = join(ROOT, "public/assets/field-images");
19
+
20
+ /** Container signatures for the formats the upload form accepts. */
21
+ const sniff = (buf: Buffer): string | null => {
22
+ if (buf.length > 8 && buf.subarray(0, 8).toString("latin1") === "\x89PNG\r\n\x1a\n") {
23
+ return "png";
24
+ }
25
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
26
+ return "jpg";
27
+ }
28
+ if (
29
+ buf.length > 12 &&
30
+ buf.subarray(0, 4).toString("latin1") === "RIFF" &&
31
+ buf.subarray(8, 12).toString("latin1") === "WEBP"
32
+ ) {
33
+ return "webp";
34
+ }
35
+ return null;
36
+ };
37
+
38
+ /**
39
+ * Download one image and return its path under public/, or null if it isn't a
40
+ * usable picture. Cached by URL hash so repeat renders don't refetch.
41
+ */
42
+ export const fetchFieldImage = async (url: string): Promise<string | null> => {
43
+ if (!/^https:\/\//.test(url)) return null;
44
+ mkdirSync(DIR, { recursive: true });
45
+
46
+ const key = createHash("sha1").update(url).digest("hex").slice(0, 16);
47
+ for (const ext of ["png", "jpg", "webp"]) {
48
+ if (existsSync(join(DIR, `${key}.${ext}`))) {
49
+ return `assets/field-images/${key}.${ext}`;
50
+ }
51
+ }
52
+
53
+ try {
54
+ const res = await fetch(url);
55
+ if (!res.ok) throw new Error(`download failed: ${res.status}`);
56
+ const buf = Buffer.from(await res.arrayBuffer());
57
+ const ext = sniff(buf);
58
+ if (!ext) {
59
+ console.warn(" ! an uploaded photo isn't an image file — skipped");
60
+ return null;
61
+ }
62
+ writeFileSync(join(DIR, `${key}.${ext}`), buf);
63
+ return `assets/field-images/${key}.${ext}`;
64
+ } catch (e) {
65
+ console.warn(
66
+ ` ! photo skipped (${e instanceof Error ? e.message : e})`,
67
+ );
68
+ return null;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Replace every image-typed field's URL with a local path.
74
+ *
75
+ * Fields that aren't images pass through untouched, and an image that fails to
76
+ * download becomes an empty string — a template with a missing picture still
77
+ * renders, which beats failing the whole episode over one asset.
78
+ */
79
+ export const resolveFieldImages = async (
80
+ values: Record<string, string>,
81
+ imageKeys: string[],
82
+ ): Promise<Record<string, string>> => {
83
+ if (!imageKeys.length) return values;
84
+ const out = { ...values };
85
+ for (const key of imageKeys) {
86
+ const url = values[key];
87
+ if (!url) continue;
88
+ out[key] = (await fetchFieldImage(url)) ?? "";
89
+ }
90
+ return out;
91
+ };
92
+
93
+ export { readFileSync };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Read-only probe: can the Instagram tokens Erin already has read post insights?
3
+ *
4
+ * Checks the token identity, lists recent media, and tries to pull per-post
5
+ * insights for one item. Prints no secrets — only lengths and API responses.
6
+ *
7
+ * npx tsx scripts/ig-probe.ts
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const ENV_FILES = [
12
+ `${process.env.HOME}/code/ig-auto-dm/.env`,
13
+ `${process.env.HOME}/code/crossposter/.env`,
14
+ ];
15
+
16
+ const loadEnv = (file: string) => {
17
+ const env: Record<string, string> = {};
18
+ for (const line of readFileSync(file, "utf8").split("\n")) {
19
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
20
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
21
+ }
22
+ return env;
23
+ };
24
+
25
+ const get = async (url: string) => {
26
+ const r = await fetch(url);
27
+ const body = (await r.json()) as unknown;
28
+ return { status: r.status, body };
29
+ };
30
+
31
+ const show = (label: string, v: unknown) =>
32
+ console.log(` ${label}: ${JSON.stringify(v).slice(0, 500)}`);
33
+
34
+ const probe = async (file: string) => {
35
+ console.log(`\n=== ${file.split("/").slice(-2).join("/")}`);
36
+ let env: Record<string, string>;
37
+ try {
38
+ env = loadEnv(file);
39
+ } catch {
40
+ console.log(" unreadable / missing");
41
+ return;
42
+ }
43
+
44
+ const token = env.IG_ACCESS_TOKEN;
45
+ const userId = env.IG_USER_ID;
46
+ console.log(
47
+ ` token: ${token ? `present (${token.length} chars)` : "MISSING"} user id: ${userId ?? "MISSING"}`,
48
+ );
49
+ if (!token) return;
50
+
51
+ // Who is this token for, and is it still valid?
52
+ const me = await get(
53
+ `https://graph.instagram.com/v21.0/me?fields=id,username,account_type,media_count&access_token=${token}`,
54
+ );
55
+ show(`me [${me.status}]`, me.body);
56
+ if (me.status !== 200) return;
57
+
58
+ // Recent posts.
59
+ const media = await get(
60
+ `https://graph.instagram.com/v21.0/me/media?fields=id,caption,media_type,media_product_type,timestamp,permalink,like_count,comments_count&limit=5&access_token=${token}`,
61
+ );
62
+ const items = (media.body as { data?: Record<string, unknown>[] }).data ?? [];
63
+ console.log(` media [${media.status}]: ${items.length} returned`);
64
+ for (const m of items.slice(0, 3))
65
+ console.log(
66
+ ` ${m.timestamp} ${m.media_type}/${m.media_product_type} likes=${m.like_count} comments=${m.comments_count} ${String(m.caption ?? "").slice(0, 60).replace(/\n/g, " ")}`,
67
+ );
68
+ if (!items.length) return;
69
+
70
+ // The one that actually matters: per-post insights.
71
+ const id = items[0].id as string;
72
+ const metrics = "reach,saved,shares,total_interactions,views";
73
+ const ins = await get(
74
+ `https://graph.instagram.com/v21.0/${id}/insights?metric=${metrics}&access_token=${token}`,
75
+ );
76
+ show(`insights [${ins.status}]`, ins.body);
77
+ };
78
+
79
+ const main = async () => {
80
+ for (const f of ENV_FILES) await probe(f);
81
+ };
82
+
83
+ main();
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Pull Instagram post performance into episodes/performance.json.
3
+ *
4
+ * Two passes so we don't burn the insights rate limit on unrelated posts:
5
+ * 1. page through all media (cheap, captions + likes + comments)
6
+ * 2. fetch per-post insights only for posts that look like series episodes
7
+ *
8
+ * npx tsx scripts/ig-sync.ts # refresh media, insights for matches
9
+ * npx tsx scripts/ig-sync.ts --all # insights for every reel
10
+ *
11
+ * Reads IG_ACCESS_TOKEN from ~/code/crossposter/.env. Read-only against the API.
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ const ROOT = join(import.meta.dirname, "..");
17
+ const OUT = join(ROOT, "episodes/performance.json");
18
+ /**
19
+ * Where the Instagram token lives.
20
+ *
21
+ * IG_ACCESS_TOKEN wins; otherwise this falls back to the crossposter checkout,
22
+ * which is where it happens to live on the machine this was written on. That
23
+ * fallback is a convenience, not a requirement — anyone else sets the env var.
24
+ */
25
+ const ENV = process.env.IG_TOKEN_FILE ?? `${process.env.HOME}/code/crossposter/.env`;
26
+
27
+ export type Post = {
28
+ id: string;
29
+ caption: string;
30
+ timestamp: string;
31
+ permalink: string;
32
+ mediaType: string;
33
+ productType: string;
34
+ likes: number;
35
+ comments: number;
36
+ insights?: Record<string, number>;
37
+ };
38
+
39
+ const token = () => {
40
+ if (process.env.IG_ACCESS_TOKEN) return process.env.IG_ACCESS_TOKEN;
41
+ if (!existsSync(ENV)) {
42
+ throw new Error(
43
+ `No Instagram token. Set IG_ACCESS_TOKEN, or point IG_TOKEN_FILE at a file containing it (looked in ${ENV}).`,
44
+ );
45
+ }
46
+ const m = readFileSync(ENV, "utf8").match(/^\s*IG_ACCESS_TOKEN\s*=\s*(.*)$/m);
47
+ if (!m) throw new Error(`no IG_ACCESS_TOKEN in ${ENV}`);
48
+ return m[1].replace(/^["']|["']$/g, "").trim();
49
+ };
50
+
51
+ const api = async <T>(url: string): Promise<T> => {
52
+ const r = await fetch(url);
53
+ const body = (await r.json()) as T & { error?: { message: string } };
54
+ if (body.error) throw new Error(body.error.message);
55
+ return body;
56
+ };
57
+
58
+ const fetchAllMedia = async (tok: string) => {
59
+ const fields =
60
+ "id,caption,media_type,media_product_type,timestamp,permalink,like_count,comments_count";
61
+ let url = `https://graph.instagram.com/v21.0/me/media?fields=${fields}&limit=100&access_token=${tok}`;
62
+ const posts: Post[] = [];
63
+ while (url) {
64
+ const page = await api<{
65
+ data: Record<string, unknown>[];
66
+ paging?: { next?: string };
67
+ }>(url);
68
+ for (const m of page.data)
69
+ posts.push({
70
+ id: m.id as string,
71
+ caption: (m.caption as string) ?? "",
72
+ timestamp: m.timestamp as string,
73
+ permalink: m.permalink as string,
74
+ mediaType: m.media_type as string,
75
+ productType: (m.media_product_type as string) ?? "",
76
+ likes: (m.like_count as number) ?? 0,
77
+ comments: (m.comments_count as number) ?? 0,
78
+ });
79
+ url = page.paging?.next ?? "";
80
+ }
81
+ return posts;
82
+ };
83
+
84
+ /** Reels report a different metric set than feed posts; ask for both shapes. */
85
+ const fetchInsights = async (tok: string, post: Post) => {
86
+ const metrics =
87
+ post.productType === "REELS"
88
+ ? "reach,saved,shares,total_interactions,views,comments,likes"
89
+ : "reach,saved,shares,total_interactions,views";
90
+ try {
91
+ const res = await api<{
92
+ data: { name: string; values: { value: number }[] }[];
93
+ }>(
94
+ `https://graph.instagram.com/v21.0/${post.id}/insights?metric=${metrics}&access_token=${tok}`,
95
+ );
96
+ return Object.fromEntries(
97
+ res.data.map((d) => [d.name, d.values[0]?.value ?? 0]),
98
+ );
99
+ } catch (e) {
100
+ console.warn(` ! insights failed for ${post.id}: ${(e as Error).message}`);
101
+ return undefined;
102
+ }
103
+ };
104
+
105
+ /** Captions that read like a code-quiz episode. */
106
+ export const isSeriesPost = (p: Post) =>
107
+ /what (does this |will this )?print|what prints|guess the output|\bpython\b.*\?|comment your answer/i.test(
108
+ p.caption,
109
+ );
110
+
111
+ /** Captions minus hashtags, punctuation and case — enough to match a post to a card. */
112
+ const captionKey = (s: string) =>
113
+ s
114
+ .replace(/#\w+/g, "")
115
+ .toLowerCase()
116
+ .replace(/[^a-z0-9 ]/g, " ")
117
+ .replace(/\s+/g, " ")
118
+ .trim();
119
+
120
+ type Episode = {
121
+ card: string;
122
+ name: string;
123
+ caption: string | null;
124
+ permalink?: string;
125
+ postedAt?: string;
126
+ metrics?: Record<string, number>;
127
+ };
128
+
129
+ /**
130
+ * Attach each published post back to the card it came from. We write the
131
+ * captions, so a card's caption appears verbatim on its post — that's the join
132
+ * key. Anything Erin rewrote by hand before posting simply won't match.
133
+ */
134
+ const linkEpisodes = (posts: Post[]) => {
135
+ const file = join(ROOT, "episodes/index.json");
136
+ const index = JSON.parse(readFileSync(file, "utf8")) as Episode[];
137
+ let linked = 0;
138
+
139
+ for (const e of index) {
140
+ // An explicit permalink wins — that's how episodes posted before we wrote
141
+ // their captions, or captions Erin rewrote by hand, get attached.
142
+ const key = e.caption ? captionKey(e.caption) : "";
143
+ const hit = e.permalink
144
+ ? posts.find((p) => p.permalink === e.permalink)
145
+ : key.length >= 20
146
+ ? posts.find((p) => {
147
+ const pk = captionKey(p.caption);
148
+ return pk.includes(key) || key.includes(pk);
149
+ })
150
+ : undefined;
151
+ if (!hit) continue;
152
+
153
+ e.permalink = hit.permalink;
154
+ e.postedAt = hit.timestamp;
155
+ e.metrics = {
156
+ likes: hit.likes,
157
+ comments: hit.comments,
158
+ ...(hit.insights ?? {}),
159
+ };
160
+ linked++;
161
+ }
162
+
163
+ writeFileSync(file, JSON.stringify(index, null, 2) + "\n");
164
+ console.log(`linked ${linked} cards to published posts`);
165
+ };
166
+
167
+ const main = async () => {
168
+ const tok = token();
169
+ const all = await fetchAllMedia(tok);
170
+ console.log(`fetched ${all.length} posts`);
171
+
172
+ const wantAll = process.argv.includes("--all");
173
+ const since = process.argv
174
+ .find((a) => a.startsWith("--since="))
175
+ ?.split("=")[1];
176
+ const targets = all.filter(
177
+ (p) =>
178
+ (wantAll && p.productType === "REELS") ||
179
+ (since ? p.timestamp >= since : false) ||
180
+ isSeriesPost(p),
181
+ );
182
+ console.log(`fetching insights for ${targets.length}`);
183
+
184
+ // Keep insights we already pulled so re-runs stay cheap.
185
+ const prev: Post[] = existsSync(OUT)
186
+ ? (JSON.parse(readFileSync(OUT, "utf8")) as Post[])
187
+ : [];
188
+ const cached = new Map(prev.filter((p) => p.insights).map((p) => [p.id, p]));
189
+
190
+ // Carry every insight we've ever fetched onto the fresh media list first —
191
+ // otherwise a narrow run would throw away earlier, wider ones.
192
+ for (const p of all) {
193
+ const hit = cached.get(p.id);
194
+ if (hit?.insights) p.insights = hit.insights;
195
+ }
196
+ for (const p of targets)
197
+ if (!p.insights) p.insights = await fetchInsights(tok, p);
198
+
199
+ writeFileSync(OUT, JSON.stringify(all, null, 2) + "\n");
200
+ console.log(`wrote ${OUT}`);
201
+ linkEpisodes(all);
202
+ };
203
+
204
+ if (process.argv[1]?.endsWith("ig-sync.ts")) main();