diff --git a/.og/gen/why-telegram-bot-notifications-die.html b/.og/gen/why-telegram-bot-notifications-die.html index c680431..1ff19a4 100644 --- a/.og/gen/why-telegram-bot-notifications-die.html +++ b/.og/gen/why-telegram-bot-notifications-die.html @@ -15,90 +15,56 @@ position: relative; } - /* 顶部品牌条 */ .topbar { - display: flex; - align-items: center; - gap: 14px; + display: flex; align-items: center; gap: 14px; padding: 34px 48px 0; } .avatar { - width: 48px; height: 48px; - border-radius: 10px; + width: 48px; height: 48px; border-radius: 10px; display: flex; align-items: center; justify-content: center; - background: #ffffff; - padding: 4px; + background: #ffffff; padding: 4px; } .avatar img { width: 100%; height: 100%; object-fit: contain; display: block; } .brand { font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif; - font-weight: 700; - font-size: 22px; - color: #ffffff; - letter-spacing: 0.3px; + font-weight: 700; font-size: 22px; color: #ffffff; letter-spacing: 0.3px; } .brand .domain { color: #6b7c99; font-weight: 500; font-size: 19px; } - /* 中间:分类徽章 */ .tag { margin: 40px 48px 0; - display: inline-flex; - align-items: center; - gap: 8px; - width: fit-content; - padding: 7px 16px; - border-radius: 6px; + display: inline-flex; align-items: center; gap: 8px; + width: fit-content; padding: 7px 16px; border-radius: 6px; background: rgba(41, 92, 255, 0.16); border: 1px solid rgba(41, 92, 255, 0.5); - color: #7599ff; - font-size: 16px; - letter-spacing: 0.8px; - text-transform: uppercase; + color: #7599ff; font-size: 16px; letter-spacing: 0.8px; text-transform: uppercase; } .tag .dot { width: 8px; height: 8px; border-radius: 50%; background: #295cff; } - /* 标题 */ .title { margin: 30px 48px 0; font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif; - font-weight: 700; - font-size: 52px; - line-height: 1.15; - color: #ffffff; - max-width: 1000px; + font-weight: 700; font-size: 40px; + line-height: 1.15; color: #ffffff; max-width: 1000px; } .title .accent { color: #ff5c5c; } - /* 终端窗口 */ .terminal { margin: 34px 48px 0; - background: #161e2e; - border: 1px solid #2a3550; - border-radius: 10px; + background: #161e2e; border: 1px solid #2a3550; border-radius: 10px; padding: 22px 28px; font-family: "JetBrains Mono", "Fira Code", Menlo, monospace; - font-size: 21px; - line-height: 1.6; - max-width: 1040px; + font-size: 21px; line-height: 1.6; max-width: 1040px; } .terminal .line { display: flex; gap: 12px; } .terminal .prompt { color: #7599ff; } .terminal .cmd { color: #e6edf3; } - .terminal .err { - color: #ff5c5c; - background: rgba(255, 92, 92, 0.10); - padding: 1px 8px; - border-radius: 4px; - } + .terminal .err { color: #ff5c5c; background: rgba(255, 92, 92, 0.10); padding: 1px 8px; border-radius: 4px; } .terminal .fix { color: #3fb950; } - /* 底部 */ .footer { - margin-top: auto; - padding: 0 48px 34px; - display: flex; - align-items: center; - justify-content: space-between; + margin-top: auto; padding: 0 48px 34px; + display: flex; align-items: center; justify-content: space-between; } .footer .tags { color: #6b7c99; font-size: 17px; } .footer .tags span { margin-right: 18px; color: #7599ff; } @@ -111,32 +77,19 @@
Mr Hoelee · blog.hoelee.com
-
DevOps · Debugging
+
devops · DevOps
-
- Telegram Bot Notifications
That Died at 400 -
+
Why Telegram Bot Notifications Die: IPv6, DNS,
and a 400 That Took a Day
-
- $ - docker exec monitor python check_notify.py -
-
-   - telegram sendPhoto failed: 400 nginx/1.30.1 -
-
- $ - fix = IPv6 · DNS · multipart - → delivered ✓ -
+ +
$docker exec monitor python check_notify.py
+
 telegram sendPhoto failed: 400 nginx/1.30.1
+
$fix = IPv6 · DNS · multipart→ delivered ✓
diff --git a/.og/gen/why-telegram-bot-notifications-die.png b/.og/gen/why-telegram-bot-notifications-die.png index 947b965..0021f88 100644 Binary files a/.og/gen/why-telegram-bot-notifications-die.png and b/.og/gen/why-telegram-bot-notifications-die.png differ diff --git a/public/og/why-telegram-bot-notifications-die.png b/public/og/why-telegram-bot-notifications-die.png index 947b965..0021f88 100644 Binary files a/public/og/why-telegram-bot-notifications-die.png and b/public/og/why-telegram-bot-notifications-die.png differ diff --git a/scripts/og-gen/README.md b/scripts/og-gen/README.md new file mode 100644 index 0000000..4a6061c --- /dev/null +++ b/scripts/og-gen/README.md @@ -0,0 +1,44 @@ +# og-gen — per-post OG image generator + +Generates a branded 1200×630 social-share image for a blog post, matching the +site's cobalt-blue `#295cff` branding, the `logo-square.png` mark, and the +"terminal / debugging" visual style. + +## Usage + +```bash +node scripts/og-gen/generate.mjs +``` + +Reads frontmatter (`title`, `category`, `tags`) from +`src/content/posts/.md`, fills `template.html`, renders with headless +Chrome, and writes `public/og/.png`. + +The post's frontmatter needs `ogImage: /og/.png` so the blog serves it +(layout already renders `og:image` + width/height; PostList shows it as a +card thumbnail). + +## Customizing the terminal box + +Each post shows a "terminal" panel (the red error / green fix lines). Add an +entry keyed by slug in `TERMINALS` inside `generate.mjs`, using these tokens: + +```html +
$command
+
 error line
+
$fix→ done ✓
+``` + +No entry → a generic default terminal. + +## Requirements + +- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe` + (override with `CHROME_PATH` env var). +- Node 20+. + +## Notes + +- PNG (not JPG) is intentional: for flat UI/terminal art, PNG is smaller and + sharper than JPG (see commit history). +- The `logo-square.png` referenced lives at `public/logo-square.png`. \ No newline at end of file diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs new file mode 100644 index 0000000..0cc0427 --- /dev/null +++ b/scripts/og-gen/generate.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * og-gen — generate a 1200x630 OG image for a blog post. + * + * Usage: + * node scripts/og-gen/generate.mjs + * + * Reads the post's frontmatter from src/content/posts/.md (title, + * category, tags), fills the HTML template, and renders it to + * public/og/.png via headless Chrome. + * + * Terminal content (the red "error" line + green "fix" line) is looked up in + * TERMINALS below by slug; fall back to a generic default if absent. + * + * Requirements: Google Chrome installed at the default Windows path. + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..', '..'); + +const slug = process.argv[2]; +if (!slug) { + console.error('Usage: node scripts/og-gen/generate.mjs '); + process.exit(1); +} + +// ---------- per-post terminal content (tune per post) ---------- +// Each entry renders inside the terminal box. Use these tokens: +// {cmd} → a normal command line +// {err} → a red error line +// {fix} → a green fix line +const TERMINALS = { + 'why-telegram-bot-notifications-die': ` +
$docker exec monitor python check_notify.py
+
 telegram sendPhoto failed: 400 nginx/1.30.1
+
$fix = IPv6 · DNS · multipart→ delivered ✓
`, +}; + +const DEFAULT_TERMINAL = ` +
$engineering · devops · self-hosting
+
 read the full post →
`; + +// ---------- read frontmatter from the post ---------- +const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); +if (!existsSync(postPath)) { + console.error(`Post not found: ${postPath}`); + process.exit(1); +} +const raw = readFileSync(postPath, 'utf8'); +const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; +const get = (key) => { + const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'm')); + if (!m) return undefined; + let v = m[1].trim(); + // strip quotes + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1); + return v; +}; +const title = get('title') || slug.replace(/-/g, ' '); +const category = get('category') || 'devops'; +const tagsRaw = get('tags') || '[]'; +let tags = []; +// tags is a YAML flow array: [docker, telegram, "cloudflare"] +const stripped = tagsRaw.replace(/^\[|\]$/g, ''); +tags = stripped + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + +// ---------- build template substitutions ---------- +// split the long title into two balanced lines for readability +const words = title.split(' '); +const mid = Math.ceil(words.length / 2); +const line1 = words.slice(0, mid).join(' '); +const line2 = words.slice(mid).join(' '); +// escape HTML +const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); +// title size: short titles can be larger +const titleLen = title.length; +const titleSize = titleLen > 60 ? 40 : titleLen > 40 ? 46 : 52; +const titleHtml = `${esc(line1)}
${esc(line2)}`; + +const terminalHtml = TERMINALS[slug] || DEFAULT_TERMINAL; + +const tagsHtml = tags.map((t) => `#${esc(t)}`).join(''); + +const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/'); + +let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8'); +tpl = tpl + .replace('{{TITLE_SIZE}}', String(titleSize)) + .replace('{{LOGO_PATH}}', logoPath) + .replace('{{CATEGORY}}', esc(category)) + .replace('{{KIND}}', 'DevOps') + .replace('{{TITLE_HTML}}', titleHtml) + .replace('{{TERMINAL_HTML}}', terminalHtml) + .replace('{{TAGS_HTML}}', tagsHtml); + +// write temp html +mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true }); +const htmlPath = join(ROOT, '.og', 'gen', `${slug}.html`); +writeFileSync(htmlPath, tpl); + +// render with headless Chrome +const chrome = process.env.CHROME_PATH || + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; +const outPng = join(ROOT, '.og', 'gen', `${slug}.png`); +try { + execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1200,630 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' }); +} catch (e) { + console.error('Chrome render failed:', e.message); + process.exit(1); +} + +// move to public/og/ +const publicDir = join(ROOT, 'public', 'og'); +mkdirSync(publicDir, { recursive: true }); +const finalPng = join(publicDir, `${slug}.png`); +execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' }); + +console.log(`✓ OG image generated: public/og/${slug}.png`); +console.log(` title: ${title}`); +console.log(` category: ${category} | tags: ${tags.join(', ')}`); \ No newline at end of file diff --git a/scripts/og-gen/template.html b/scripts/og-gen/template.html new file mode 100644 index 0000000..8fd985a --- /dev/null +++ b/scripts/og-gen/template.html @@ -0,0 +1,93 @@ + + + + + + + +
+
+
Mr Hoelee · blog.hoelee.com
+
+ +
{{CATEGORY}} · {{KIND}}
+ +
{{TITLE_HTML}}
+ +
+ {{TERMINAL_HTML}} +
+ + + + \ No newline at end of file