Skip to content

Commit 6408ced

Browse files
kiclaude
authored andcommitted
feat: Instagram cover image automation + repost with covers
- generate-cover.mjs: text overlay on first photo (centered layout, Dedicated/Friendly badge) - post-instagram.py: cover as first carousel image via Cloudinary - All 6 existing posts re-uploaded with cover images Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0d0fb94 commit 6408ced

2 files changed

Lines changed: 170 additions & 2 deletions

File tree

scripts/generate-cover.mjs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Instagram 커버 이미지 생성
5+
*
6+
* 첫 번째 사진에 하단 그라데이션 + 매장 정보 텍스트 오버레이
7+
*
8+
* Usage:
9+
* node scripts/generate-cover.mjs vegetus
10+
* node scripts/generate-cover.mjs vegetus feeke x-ake
11+
* node scripts/generate-cover.mjs --all
12+
*/
13+
14+
import fs from "node:fs/promises";
15+
import path from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
import sharp from "sharp";
18+
19+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
20+
const rootDir = path.resolve(__dirname, "..");
21+
const placesPath = path.join(rootDir, "data", "places.json");
22+
const imagesDir = path.join(rootDir, "public", "images", "places");
23+
const outputDir = path.join(rootDir, "data", "instagram-covers");
24+
25+
const SIZE = 1080;
26+
27+
const TYPE_LABEL = {
28+
Restaurant: "Restaurant",
29+
Cafe: "Cafe",
30+
Bakery: "Bakery",
31+
Bar: "Bar",
32+
};
33+
34+
function escapeXml(str) {
35+
return str
36+
.replace(/&/g, "&amp;")
37+
.replace(/</g, "&lt;")
38+
.replace(/>/g, "&gt;")
39+
.replace(/"/g, "&quot;")
40+
.replace(/'/g, "&apos;");
41+
}
42+
43+
function createOverlaySvg(place) {
44+
// Strip common suffixes for cleaner cover text
45+
const rawName = place.nameEn || place.name;
46+
const name = escapeXml(
47+
rawName.replace(/\s*(Gluten[- ]?Free|GF|Cafe|Restaurant|Bakery|Bar)\b/gi, "").trim()
48+
|| rawName
49+
);
50+
const cx = SIZE / 2; // center x
51+
52+
const isDedicated =
53+
DEDICATED_SLUGS.has(place.slug) ||
54+
place.note?.toLowerCase().includes("dedicated") ||
55+
place.note?.toLowerCase().includes("100%");
56+
const badge = isDedicated ? "DEDICATED GLUTEN-FREE" : "GLUTEN-FREE FRIENDLY";
57+
const badgeColor = isDedicated ? "#4ADE80" : "#FBBF24";
58+
const badgeWidth = badge.length * 15 + 40;
59+
60+
return `<svg width="${SIZE}" height="${SIZE}" xmlns="http://www.w3.org/2000/svg">
61+
<defs>
62+
<linearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
63+
<stop offset="0%" stop-color="rgba(0,0,0,0.15)" />
64+
<stop offset="35%" stop-color="rgba(0,0,0,0.4)" />
65+
<stop offset="65%" stop-color="rgba(0,0,0,0.4)" />
66+
<stop offset="100%" stop-color="rgba(0,0,0,0.15)" />
67+
</linearGradient>
68+
</defs>
69+
<rect width="${SIZE}" height="${SIZE}" fill="url(#grad)" />
70+
71+
<!-- Decorative line above badge -->
72+
<rect x="${cx - 40}" y="${cx - 120}" width="80" height="3" rx="1.5" fill="${badgeColor}" opacity="0.8" />
73+
74+
<!-- Badge pill -->
75+
<rect x="${cx - badgeWidth / 2}" y="${cx - 95}" width="${badgeWidth}" height="48" rx="24" fill="${badgeColor}" />
76+
<text x="${cx}" y="${cx - 64}" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="800" fill="#1a1a1a" letter-spacing="1.5" text-anchor="middle">${escapeXml(badge)}</text>
77+
78+
<!-- Place name — generous spacing below badge -->
79+
<text x="${cx}" y="${cx + 40}" font-family="Arial, Helvetica, sans-serif" font-size="88" font-weight="800" fill="white" text-anchor="middle" letter-spacing="1">${name}</text>
80+
81+
<!-- Decorative line between name and location -->
82+
<rect x="${cx - 40}" y="${cx + 62}" width="80" height="3" rx="1.5" fill="rgba(255,255,255,0.4)" />
83+
84+
<!-- Location — below divider -->
85+
<text x="${cx}" y="${cx + 100}" font-family="Arial, Helvetica, sans-serif" font-size="30" fill="rgba(255,255,255,0.75)" text-anchor="middle">${escapeXml(place.location || "")}</text>
86+
87+
<!-- Brand — top right -->
88+
<text x="${SIZE - 44}" y="50" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="600" fill="rgba(255,255,255,0.5)" text-anchor="end">noglutenkorea.com</text>
89+
</svg>`;
90+
}
91+
92+
// Slugs that should always show "DEDICATED GLUTEN-FREE"
93+
const DEDICATED_SLUGS = new Set([
94+
"sisemdal-atelier",
95+
"237-pizza",
96+
"cafe-rebirths",
97+
"monil2-house",
98+
"feeke",
99+
"x-ake",
100+
]);
101+
102+
async function generateCover(place) {
103+
const srcImage = path.join(imagesDir, place.slug, "01.webp");
104+
105+
try {
106+
await fs.access(srcImage);
107+
} catch {
108+
console.log(` ⏭ ${place.slug} — no source image`);
109+
return null;
110+
}
111+
112+
const outputPath = path.join(outputDir, `${place.slug}-cover.jpg`);
113+
114+
// 1) Resize + center crop to 1080x1080
115+
const base = await sharp(srcImage)
116+
.resize(SIZE, SIZE, { fit: "cover", position: "centre" })
117+
.jpeg({ quality: 95 })
118+
.toBuffer();
119+
120+
// 2) Create SVG overlay
121+
const svg = createOverlaySvg(place);
122+
const overlay = Buffer.from(svg);
123+
124+
// 3) Composite
125+
await sharp(base)
126+
.composite([{ input: overlay, top: 0, left: 0 }])
127+
.jpeg({ quality: 92 })
128+
.toFile(outputPath);
129+
130+
const stat = await fs.stat(outputPath);
131+
console.log(` ✓ ${place.slug}${(stat.size / 1024).toFixed(0)}KB → ${path.relative(rootDir, outputPath)}`);
132+
return outputPath;
133+
}
134+
135+
async function main() {
136+
const args = process.argv.slice(2);
137+
const places = JSON.parse(await fs.readFile(placesPath, "utf-8"));
138+
139+
await fs.mkdir(outputDir, { recursive: true });
140+
141+
let targets;
142+
if (args.includes("--all")) {
143+
targets = places.filter((p) => p.images?.length > 0);
144+
} else if (args.length > 0) {
145+
targets = args
146+
.map((slug) => places.find((p) => p.slug === slug))
147+
.filter(Boolean);
148+
const missing = args.filter((s) => !places.find((p) => p.slug === s));
149+
if (missing.length) console.log(` ⚠ Not found: ${missing.join(", ")}`);
150+
} else {
151+
console.log("Usage: node scripts/generate-cover.mjs <slug...> | --all");
152+
process.exit(1);
153+
}
154+
155+
console.log(`\n🎨 Generating Instagram covers (${targets.length} places)\n`);
156+
157+
for (const place of targets) {
158+
await generateCover(place);
159+
}
160+
161+
console.log("\nDone!\n");
162+
}
163+
164+
main().catch((err) => {
165+
console.error(err);
166+
process.exit(1);
167+
});

scripts/post-instagram.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ def main():
7070
with open(caption_path) as f:
7171
caption = f.read().strip()
7272

73-
# Build image URLs
73+
# Build image URLs — cover first if it exists on Cloudinary
7474
images = place.get("images", [])
7575
if not images:
7676
print("No images found for this place")
7777
return
7878

79-
urls = [
79+
cover_url = f"https://res.cloudinary.com/{CLOUD_NAME}/image/upload/c_fill,w_1080,h_1080,q_90/places/{args.slug}/cover"
80+
urls = [cover_url] + [
8081
f"https://res.cloudinary.com/{CLOUD_NAME}/image/upload/c_fill,w_1080,h_1080,q_90/{img}"
8182
for img in images
8283
]

0 commit comments

Comments
 (0)