文章封面批量生成:一条命令产出 1200×675 专属封面(PIL 实战)

1 阅读7分钟

文章封面批量生成:一条命令产出 1200×675 专属封面(PIL 实战)

写技术博客的人大多经历过这一刻:文章发了,列表页却挂着一张灰蒙蒙的默认图。点率差一截,还以为是自己标题起得不好。

这篇文章做一件事:用一条命令,给每篇文章生成一张 1200×675 的专属封面,并且讲清楚封面到底有没有挂上——不是"我以为传上去了",而是用可验证的判据确认。

全部代码基于真实工作流,PIL 12.3.0 实测,无虚构。

一、先立判据:封面挂没挂,谁说了算

在动手之前必须先定义"成功"。我踩过的坑是:上传接口返回 200、编辑器里也显示了图,但文章详情页的分享缩略图还是默认图。

后来固定用两个判据,互相印证:

  1. 文章页 og:image:抓详情页 HTML,读 <meta property="og:image" content="...">。是平台默认图(形如 .../202112131616291007.png)就是没挂上;是 i-blog.csdnimg.cn/direct/*.png 这种带 direct 路径的才是真封面。
  2. 列表页/详情页首图视觉确认:个别平台 og 更新有延迟,截图看一眼最稳妥。

经验:"接口成功"和"用户能看到"是两件事。凡是要交付到用户眼前的结果,都要有一个用户视角的验收动作。这条以后端开发也一样适用。

二、尺寸与规格:为什么是 1200×675

用途建议尺寸说明
CSDN 文章封面1200×67516:9,列表卡片不变形
通用 OG 图1200×630社交分享裁剪安全区
知乎/公众号头图900×383 / 1080×432更宽的横条

本文以 1200×675 为主。原则只有一条:按平台推荐尺寸的整数倍做,避免二次缩放发虚。

三、生成器设计:每篇文章一张"专属"图

"专属"的意思是:只看封面就知道是哪篇。做法是三要素——主色(按文章主题分配)+ 标题文字 + 关键词标签。

# gen_cover.py —— 一条命令生成一张封面
# 用法: python gen_cover.py <输出.png> <标题> <标签1> <标签2> [主色]
import sys, hashlib
from PIL import Image, ImageDraw, ImageFont

W, H = 1200, 675
# 主题色板:深底 + 高对比前景,暗色模式下也稳
PALETTE = [
    ((24, 32, 56), (96, 165, 250)),    # 深蓝 / 亮蓝
    ((28, 40, 38), (52, 211, 153)),    # 深绿 / 薄荷
    ((42, 28, 46), (244, 114, 182)),   # 深紫 / 粉
    ((46, 34, 24), (251, 191, 96)),    # 深棕 / 琥珀
    ((30, 36, 48), (148, 163, 184)),   # 石板灰 / 银
]

def pick_palette(seed_text):
    """同一篇文章每次生成同一配色——可复现,改内容才会改色"""
    idx = int(hashlib.md5(seed_text.encode("utf-8")).hexdigest(), 16) % len(PALETTE)
    return PALETTE[idx]

def load_font(size, bold=False):
    candidates = [
        "C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc",
        "C:/Windows/Fonts/simhei.ttf",
        "C:/Windows/Fonts/Arial.ttf",
    ]
    for path in candidates:
        try:
            return ImageFont.truetype(path, size)
        except OSError:
            continue
    return ImageFont.load_default()

def wrap_text(draw, text, font, max_w):
    """按像素宽度折行,中文无空格也能断"""
    lines, cur = [], ""
    for ch in text:
        trial = cur + ch
        if draw.textlength(trial, font=font) <= max_w:
            cur = trial
        else:
            if cur:
                lines.append(cur)
            cur = ch
    if cur:
        lines.append(cur)
    return lines

def main():
    out = sys.argv[1]
    title = sys.argv[2]
    tags = sys.argv[3:]
    bg, fg = pick_palette(title)

    img = Image.new("RGB", (W, H), bg)
    d = ImageDraw.Draw(img)

    # 左上角色带 + 右下角弱化圆,制造层次又不抢字
    d.rectangle([0, 0, W, 10], fill=fg)
    for r, alpha in ((150, 26), (110, 20), (70, 14)):
        d.ellipse([W - r, H - r, W + r, H + r], fill=fg)

    # 标题:自动缩放 + 折行,最多三行
    for size in (72, 64, 56, 48):
        f = load_font(size, bold=True)
        lines = wrap_text(d, title, f, W - 160)
        if len(lines) <= 3:
            break
    y = 150
    for ln in lines[:3]:
        d.text((80, y), ln, font=f, fill=(245, 247, 250))
        y += int(size * 1.45)

    # 标签:小胶囊,排在标题下方
    x = 80
    ft = load_font(30)
    for tg in tags[:3]:
        tw = d.textlength(tg, font=ft)
        pad = 18
        d.rounded_rectangle([x, y + 30, x + tw + pad * 2, y + 30 + 52],
                            radius=26, outline=fg, width=3)
        d.text((x + pad, y + 41), tg, font=ft, fill=fg)
        x += tw + pad * 2 + 20

    # 右下角落款,弱化处理
    fs = load_font(24)
    d.text((W - 260, H - 56), "技术实战笔记", font=fs, fill=(150, 158, 172))

    img.save(out, "PNG")
    print(f"OK {out} {W}x{H}")

if __name__ == "__main__":
    main()

跑一条:

python gen_cover.py cs-cover-484.png \
  "Chrome 自动化「点了没反应」" \
  "Chrome" "CDP" "自动化"

几个设计取舍,都是被现实毒打出来的:

  • 配色用标题做哈希种子:同一篇文章每次生成同一张图,改一次标题才变色。批次重跑不会出现"昨天蓝今天绿",方便对照。
  • 标题自动缩放 + 按像素折行:中文没有空格,按字符数折行必然溢出或参差。textlength 按实际字宽判断,长标题自动缩到 48px。
  • 暗色底 + 亮前景:CSDN 有深色模式,白底封面在深色列表里像一块膏药。
  • 圆角胶囊用 outline 不用填充:填充色一多就花,描边干净。

四、批量化:一次给 N 篇文章出封面

文章多了就该有个清单。用 JSON 驱动,一条命令全出:

# batch_cover.py —— 读 cover-list.json,批量生成 + 输出缺失报告
import json, subprocess, sys, os

LIST = "cover-list.json"   # [{"id":"167589876","title":"...","tags":["a","b"],"file":"cs-cover-876.png"}]

def main():
    items = json.load(open(LIST, encoding="utf-8"))
    missing = []
    for it in items:
        if os.path.exists(it["file"]):
            print(f"SKIP {it['file']} 已存在")
            continue
        cmd = [sys.executable, "gen_cover.py", it["file"], it["title"], *it.get("tags", [])]
        subprocess.run(cmd, check=True)
        missing.append(it["file"])
    print(f"DONE 新生成 {len(missing)} 张,清单共 {len(items)} 篇")

if __name__ == "__main__":
    main()

再配一个核查脚本,把上面说的判据落成代码——这才是交付的闭环:

# check_cover.py —— 逐篇读 og:image,报出"没挂封面"的文章
import re, sys, urllib.request

DEFAULT_HINT = "202112131616291007"   # 平台默认图特征串

def check(article_id):
    url = f"https://blog.csdn.net/qq_58833970/article/details/{article_id}"
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    html = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "ignore")
    m = re.search(r'<meta property="og:image" content="([^"]+)"', html)
    og = m.group(1) if m else ""
    status = "MISSING" if DEFAULT_HINT in og or not og else "OK"
    print(f"{article_id} {status} {og[:80]}")

if __name__ == "__main__":
    for aid in sys.argv[1:]:
        try:
            check(aid)
        except Exception as e:
            print(f"{aid} ERROR {e}")

踩坑提醒:curl 直抓文章页经常被拦(空响应/验证页)。稳妥做法是走浏览器:CDP 连上已登录的 Chrome,Runtime.evaluate 读 document.querySelector('meta[property="og:image"]').content。需要登录态的页面,别用裸 HTTP 客户端赌运气。

五、上传:封面的"最后一公里"

图生成了,怎么塞进发布流程才是真门槛。以 CSDN 为例,要点三条:

  1. 封面 input 是隐藏的:input.el-upload__input,用 DOM.setFileInputFiles 直接塞文件路径,比模拟点击文件选择对话框可靠一万倍。
  2. 裁剪弹窗的确认按钮文案是「确认上传」,而且它不一定是 button 标签——用 '*' 全标签遍历按文本匹配,别限定 button,div,span。
  3. 新建文章发布时塞文件会弹裁剪框;已发布文章回编辑页补封面时塞文件不弹框。两条路径都要留代码。
// 片段:塞文件 + 点「确认上传」(全标签遍历)
await send('DOM.setFileInputFiles', {
  nodeId: inputNodeId,
  files: [pngPath],
});
// 轮询等裁剪弹窗出现,再全标签找「确认上传」
const r = await ev(`(function(){
  var els = document.querySelectorAll('*');
  for (var i = 0; i < els.length; i++) {
    var t = (els[i].innerText || '').trim();
    if (t === '确认上传') { els[i].click(); return 'clicked'; }
  }
  return 'not-yet';
})()`);

改已发布文章会触发平台风控(一天内第二次保存要微信扫码)。所以补封面这种活儿,能批量就批量,能提前就提前——每改一次就要扫一次码,码还有 10 分钟寿命,人等码、码等人,两头空耗。

六、一条命令的完整形态

最终固化下来的日常用法:

# 1) 有新增文章就往 cover-list.json 里加一条
# 2) 一条命令:批量生成 + 上传 + 核查
python batch_cover.py && node cs-cover-batch.js

cs-cover-batch.js 负责:连 CDP → 逐篇打开发布页 → 塞图 → 确认 → 保存 → 当场回读 og:image 复核,最后打印一张 OK/MISSING 对照表。哪篇 MISSING,一眼看到,不用逐篇翻网页。

七、这套做法的复用价值

回头看,封面这件事真正的产出不是 5 张 png,而是三个可迁移的习惯:

  1. 先定验收判据再动手:og:image 是不是默认图,比"我觉得传上去了"可靠。
  2. 可复现生成:哈希定色 + JSON 清单,任何时候能重出一模一样的图。
  3. 批处理 + 自核查:生成、上传、验收串成一条命令,报告直接告诉你哪几篇没挂上。

下次平台改版、上传路径变了,要改的只有 cs-cover-batch.js 一个文件;清单和生成器原样复用。把"手工重复"关进脚本,是这类琐事唯一的解法。


本文方法来自真实的多平台发布工作流:PIL 12.3.0 / 1200×675 / 五篇 CSDN 文章封面全部核验挂载。所有代码可直接运行。