Bỏ qua, tới nội dung
Kỹ thuật
6 phút đọc

Tạo ảnh bìa và sơ đồ blog từ HTML bằng headless Chrome, không cần Playwright

Một script Node render mọi ảnh bìa và sơ đồ của blog bằng chính Chrome có sẵn trên Mac: token theme từ globals.css, render 2x, sharp nén WebP dưới 200 KB.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: render ảnh bìa và sơ đồ blog từ HTML bằng Google Chrome chế độ headless
Trong bài này
  1. Vì sao dùng Chrome trơn mà không dùng Playwright
  2. Màu và font lấy từ site, không nằm trong script
  3. Cái regex khớp nhầm khối
  4. Sơ đồ: mẩu HTML với vài class
  5. Ảnh bìa: sinh ra từ front matter
  6. Chrome không chịu thoát, nên canh dòng "bytes written"
  7. Vào 2x, ra 1x, dưới 200 KB
  8. Rút ra

Bài nào trên blog này cũng có hai ảnh bìa (tiếng Anh và tiếng Việt) và thường hai, ba sơ đồ cho mỗi ngôn ngữ. Mười hai bài lúc ra mắt là 24 ảnh bìa và vài chục sơ đồ, và mình còn làm lại thiết kế ảnh bìa một lần ngay trong ngày chúng lên site. Ngồi vẽ tay từng cái trong công cụ thiết kế là chuyện không bao giờ xảy ra. Nên tất cả đều là HTML, và một script Node biến đống HTML đó thành file WebP.

Script là scripts/blog-images.mjs, chưa tới 400 dòng. Nó dùng Google Chrome đã cài sẵn trên Mac, chạy headless, cộng thêm sharp cho bước WebP. Không Playwright, không Puppeteer, không Chromium đóng gói kèm. Bài này đi qua cách nó chạy và ba chỗ mất thời gian nhất: màu lấy từ đâu, làm sao biết Chrome đã xong, và làm sao giữ mọi file dưới 200 KB.

Vì sao dùng Chrome trơn mà không dùng Playwright

Muốn "chụp màn hình một trang HTML" thì Playwright là câu trả lời quen thuộc. Nó cũng là một dependency nặng, tự tải về các bản browser riêng, mà trên máy này mình có quy tắc cố định là không dùng Playwright hay Chromium đóng gói. Chrome thì mình có sẵn. Chrome headless có cờ --screenshot từ nhiều năm nay. Với một trang tĩnh, không ai bấm, không chờ gọi mạng, không cần assert gì, thì cờ đó là toàn bộ tính năng mình cần.

Toàn bộ giao diện là một lệnh:

bash
npm run blog:images                          # mọi ảnh bìa và sơ đồ
node scripts/blog-images.mjs --only <slug>   # một bài
node scripts/blog-images.mjs covers          # hoặc: diagrams

Có thêm hai lệnh phụ cho ảnh chụp: strip xếp các screenshot điện thoại cạnh nhau trên nền của sơ đồ, shot thu nhỏ một screenshot. Cả hai chỉ dùng sharp.

Pipeline từ mẩu HTML tới file WebP
Token theme vào, HTML qua Chrome ở 2x, sharp ra

Màu và font lấy từ site, không nằm trong script

Quy tắc đầu tiên, ghi ngay đầu file: màu và font không được khai báo trong script. Nó đọc từ chính site.

  • Màu: parse src/app/globals.css, lấy token gốc trong :root, rồi tới các tên trong @theme trỏ vào chúng, và ghi hết vào một khối :root { … } trong trang được render.
  • Font: đọc các import next/font trong src/app/layout.tsx bằng regex, biến Be_Vietnam_Pro({ variable: "--font-be-vietnam-pro" … }) thành tên family Be Vietnam Pro, rồi link các family đó từ Google Fonts với weight 400 đến 800.
js
for (const m of layout.matchAll(/=\s*([A-Z][A-Za-z_]+)\(\{\s*variable:\s*"(--[\w-]+)"/g)) {
  const family = m[1].replace(/_/g, " ");
  families.push(family);
  vars[m[2]] = `"${family}"`;
}

Cái lợi lộ ra ngay ngày 01/10/2026, khi buổi chiều mình đổi font tiêu đề của site (chuyện dấu tiếng Việt). Chỉ cần chạy lại script là mọi ảnh bìa và sơ đồ dùng font mới. Không phải sửa gì trong ảnh.

Link Google Fonts dùng display=block chứ không phải swap. Trên website thì swap dễ chịu hơn. Với ảnh chụp thì sai, vì lần vẽ đầu có thể dùng font dự phòng, và đó có thể chính là khung hình bị chụp lại.

Cái regex khớp nhầm khối

Có một bug ở đây không báo lỗi gì cả. globals.css của mình có hai khối @theme: một khối Tailwind v4 @theme inline { … } chứa mọi token màu và font, và một khối @theme { … } nhỏ phía dưới chỉ có hai đường easing. Script thì đi tìm @theme {:

js
// trước: bỏ qua "@theme inline {" và tìm ra khối easing
css.match(/@theme\s*{([\s\S]*?)\n}/)
// sau: khối @theme đầu tiên, có inline hay không cũng được
css.match(/@theme[^{]*{([\s\S]*?)\n}/)

Tức là pattern cũ vẫn khớp, chỉ là khớp nhầm: hai đường easing và không một màu nào. Mọi biến màu trong trang render đều undefined, mà CSS coi biến undefined là "dùng giá trị mặc định" chứ không phải lỗi. Bản sửa đi vào 905b8bd cùng đợt làm lại ảnh bìa. Nếu bạn dựng pipeline kiểu này, hãy in ra số token màu tìm được. Con số thấp bất thường thì phải dừng luôn.

Các sơ đồ viết từ mấy tháng trước dùng thang màu ink cũ (--color-ink-100 … --color-ink-950) và --color-accent. Thay vì viết lại, script map các tên đó vào bảng màu hiện tại, nên sơ đồ cũ và mới nhìn như nhau.

Sơ đồ: mẩu HTML với vài class

Một sơ đồ là một mẩu HTML nhỏ ở content/blog/<slug>/diagrams/<name>.<lang>.html. Không có <head>, không có style riêng. Script bọc nó vào một trang mang theo theme và một stylesheet ngắn gồm các class layout: canvas, kicker, title, row, col, grow, box (kèm box-accent, box-bad, box-dark), tag, arrow, mono. Đó là toàn bộ design system cho sơ đồ, và nhờ nó mà năm mươi sơ đồ nhìn vẫn như một bộ.

Khung mặc định là 1200×675. Một comment ở dòng đầu sẽ ghi đè:

html
<!-- size: 1200x560 -->
<div class="canvas">
  <div class="kicker">One task, one worktree</div>
  <div class="title">The lifecycle every agent follows</div>
  <div class="row">…</div>
</div>

Ngôn ngữ lấy từ tên file, còn file ra nằm ở public/blog/<slug>/<name>.<lang>.webp, đúng đường dẫn mà Markdown trỏ tới.

Ảnh bìa: sinh ra từ front matter

Ảnh bìa không có file HTML nào. Script dựng chúng từ front matter của từng bài:

  • Bảng màu theo chuyên mục. Năm chuyên mục, mỗi cái một gradient hai điểm màu và một màu quầng sáng: engineering từ xám đá sang gần đen với quầng xanh, monetization màu xanh lá với quầng vàng, growth từ san hô sang đỏ thẫm, v.v. Biểu tượng của chuyên mục nằm trong một ô kính mờ bên phải.
  • Icon app lấy từ tag. Bài nào gắn tag là tên một app của mình (Lockboxy, Talkzy, …) thì icon app đó bay quanh ô kính, tối đa năm cái, ở các vị trí cố định để ảnh bìa nào cũng cùng một nhịp. Bài chung về indie app thì hiện cả gia đình. Bài không dính app nào thì có hai ô kính trống để giữ nguyên chiều sâu.
  • Cỡ tiêu đề theo độ dài. 62px cho tiêu đề ngắn, rồi 56, 50 và 46 khi vượt 44, 58 và 72 ký tự. Tiêu đề dùng text-wrap: balance, line-height 1.17 và tracking -0.012em, đúng thứ mà tiêu đề tiếng Việt cần.
  • Con số lấy từ trường number trong front matter, không có thì lấy vị trí của bài trong danh sách thư mục.

Tag nào là tên app thì bị loại khỏi mấy chip tag nhỏ, để một bài về Lockboxy không hiện cả icon lẫn chip "Lockboxy" cạnh nhau.

Thứ quyết định từng phần của ảnh bìa
Mọi lựa chọn hình ảnh trên ảnh bìa đều đọc từ bài viết

Chrome không chịu thoát, nên canh dòng "bytes written"

Chỗ này mình mất nhiều thời gian nhất mới hiểu. Trên macOS, Chrome headless ghi xong ảnh rồi rất hay cứ thế chạy tiếp. Nếu script ngồi chờ process thoát, nó sẽ chờ mãi ở vài file và không chờ ở mấy file khác.

Nên một lần render được coi là xong khi Chrome báo đã ghi file. Chrome in ra một dòng có chữ bytes written to file. Script nghe cả stdout lẫn stderr, đợi 150 ms sau dòng đó, rồi kill process:

js
const child = spawn(CHROME, [
  "--headless=new",
  "--disable-gpu",
  "--hide-scrollbars",
  `--user-data-dir=${path.join(tmp, "profile")}`,
  "--force-device-scale-factor=2",
  `--window-size=${width},${height}`,
  "--virtual-time-budget=6000",
  `--screenshot=${outPng}`,
  `file://${file}`,
]);
const onData = (d) => {
  log += d;
  if (/bytes written to file/.test(log)) setTimeout(() => finish(), 150);
};

Mấy cờ còn lại, mỗi cái gỡ một vấn đề nhỏ hơn:

  • --user-data-dir trỏ vào một thư mục tạm mới tinh. Không có nó, Chrome headless có thể đụng vào Chrome bạn đang dùng, cái đang mở bằng profile bình thường.
  • --virtual-time-budget=6000 cho trang tối đa sáu giây thời gian ảo để tải web font và ảnh trước khi chụp.
  • --force-device-scale-factor=2 render ở 2x. Nói thêm ở dưới.
  • Một bộ đếm 60 giây kill Chrome và đánh fail lần chạy nếu dòng kia không bao giờ xuất hiện, để một file lỗi không treo cả mẻ.

Ảnh bên trong ảnh bìa (icon app, avatar của mình) được load bằng đường dẫn file:// từ public/, chạy được vì bản thân trang cũng là một URL file://.

Vào 2x, ra 1x, dưới 200 KB

Chrome render mọi ảnh với device scale factor 2, nên một ảnh bìa 1200×630 được chụp thành PNG 2400×1260. sharp thu nó về lại 1200×630 rồi encode WebP. Render to rồi thu nhỏ cho viền chữ sạch hơn và gradient mượt hơn render ở 1x, quan trọng nhất với mấy nhãn chữ nhỏ trong sơ đồ.

Giới hạn dung lượng là một vòng lặp, không phải đoán:

js
let quality = 86;
do {
  buf = await sharp(input)
    .resize({ width, height, fit: "cover", withoutEnlargement: true })
    .webp({ quality, effort: 6, smartSubsample: true })
    .toBuffer();
  quality -= 6;
} while (buf.length > 200 * 1024 && quality > 30);

Bắt đầu ở quality 86, mỗi lần giảm 6 cho tới khi file lọt vào 200 KB. Thực tế gần như không bao giờ phải lặp. Sơ đồ toàn màu phẳng và chữ, WebP nén cực tốt: sơ đồ của bài worktree mỗi cái chỉ 25 đến 43 KB. Ảnh bìa tăng từ khoảng 26–29 KB lên 32–49 KB khi mình thêm gradient, quầng sáng và icon app, vẫn còn xa giới hạn. Vòng lặp có mặt là vì mấy dải screenshot thật, vốn là ảnh chụp và nặng hơn nhiều.

File nào cũng được log kèm dung lượng, nên có gì phình ra là thấy ngay trong terminal:

txt
  public/blog/<slug>/cover.vi.webp  1200x630  41 KB

Rút ra

  • Chuyển HTML tĩnh thành ảnh thì cờ --screenshot của Chrome headless là đủ. Không cần Playwright cho một trang không ai bấm.
  • Đọc màu và font từ chính CSS và import font của site, để đổi theme chỉ cần chạy lại. Coi "tìm được gần như không token nào" là lỗi.
  • Dùng display=block cho web font khi chụp, và cho trang một --virtual-time-budget để tải chúng.
  • Trên macOS, đừng chờ Chrome headless tự thoát. Chờ dòng "bytes written to file", rồi kill, có timeout đứng sau.
  • Dùng --user-data-dir tạm để lần render không bao giờ đụng vào Chrome thật của bạn.
  • Render 2x, thu nhỏ bằng sharp, và hạ dần quality WebP trong vòng lặp cho tới khi file vừa ngân sách.

Các app có icon xuất hiện trên mấy ảnh bìa này đều ở apps.vanthuongdao.id.vn.

  • #Node.js
  • #Chrome
  • #Images
  • #Blog
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.