文章封面批量生成:一条命令产出 1200×675 专属封面(PIL 实战)
写技术博客的人大多经历过这一刻:文章发了,列表页却挂着一张灰蒙蒙的默认图。点率差一截,还以为是自己标题起得不好。
这篇文章做一件事:用一条命令,给每篇文章生成一张 1200×675 的专属封面,并且讲清楚封面到底有没有挂上——不是"我以为传上去了",而是用可验证的判据确认。
全部代码基于真实工作流,PIL 12.3.0 实测,无虚构。
一、先立判据:封面挂没挂,谁说了算
在动手之前必须先定义"成功"。我踩过的坑是:上传接口返回 200、编辑器里也显示了图,但文章详情页的分享缩略图还是默认图。
后来固定用两个判据,互相印证:
- 文章页
og:image:抓详情页 HTML,读<meta property="og:image" content="...">。是平台默认图(形如.../202112131616291007.png)就是没挂上;是i-blog.csdnimg.cn/direct/*.png这种带direct路径的才是真封面。 - 列表页/详情页首图视觉确认:个别平台 og 更新有延迟,截图看一眼最稳妥。
经验:"接口成功"和"用户能看到"是两件事。凡是要交付到用户眼前的结果,都要有一个用户视角的验收动作。这条以后端开发也一样适用。
二、尺寸与规格:为什么是 1200×675
| 用途 | 建议尺寸 | 说明 |
|---|---|---|
| CSDN 文章封面 | 1200×675 | 16: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 为例,要点三条:
- 封面 input 是隐藏的:
input.el-upload__input,用DOM.setFileInputFiles直接塞文件路径,比模拟点击文件选择对话框可靠一万倍。 - 裁剪弹窗的确认按钮文案是「确认上传」,而且它不一定是
button标签——用'*'全标签遍历按文本匹配,别限定button,div,span。 - 新建文章发布时塞文件会弹裁剪框;已发布文章回编辑页补封面时塞文件不弹框。两条路径都要留代码。
// 片段:塞文件 + 点「确认上传」(全标签遍历)
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,而是三个可迁移的习惯:
- 先定验收判据再动手:
og:image是不是默认图,比"我觉得传上去了"可靠。 - 可复现生成:哈希定色 + JSON 清单,任何时候能重出一模一样的图。
- 批处理 + 自核查:生成、上传、验收串成一条命令,报告直接告诉你哪几篇没挂上。
下次平台改版、上传路径变了,要改的只有 cs-cover-batch.js 一个文件;清单和生成器原样复用。把"手工重复"关进脚本,是这类琐事唯一的解法。
本文方法来自真实的多平台发布工作流:PIL 12.3.0 / 1200×675 / 五篇 CSDN 文章封面全部核验挂载。所有代码可直接运行。