記事は書けても、アイキャッチで止まる。その工程は、テンプレート化できる。
Pillowで生成して、APIでアップロードするまでを作ります
📋 この記事でわかること
- 見出し画像アップロードAPIの仕様
- MIME typeを明示しないと500になる理由
- Pillowでタイトル入り画像を自動生成する処理
- 日本語フォントの指定と、文字の自動折り返し
- 記事ごとに色を変える仕組み
- 下書き一覧から一括で画像を設定する流れ
記事を書き終えてから、見出し画像で手が止まる——これは多くの人が経験することです。フリー素材を探す時間、デザインツールで加工する時間を合計すると、記事1本あたり15分以上かかることも珍しくありません。タイトルを入れただけのシンプルなアイキャッチでよければ、この工程は完全に自動化できます。Pillowで画像を生成し、APIでnoteにアップロードするまでをスクリプトで処理します。この記事では、その実装を解説します。見出し画像のサイズや役割についてはサムネイルの記事も参照してください。
必要なもの
- Python 3.10以降、
requests、Pillow(pip install Pillow) - Cookie認証の
note_auth_client.py - 日本語が表示できるフォントファイル(OS標準のもので可)
見出し画像アップロードの仕様
使うのは POST /api/v1/image_upload/note_eyecatch です。JSONではなくマルチパート形式でファイルを送ります。
| フィールド | 内容 | 注意点 |
|---|---|---|
| note_id | 対象記事の数値ID | keyではなくid |
| file | 画像ファイルの実体 | MIME typeの明示が必要 |
| width | 画像の幅(ピクセル) | 実際のサイズと一致させる |
| height | 画像の高さ(ピクセル) | 実際のサイズと一致させる |
MIME typeを省くと500が返る
これがこのAPIで最もつまずくポイントです。requests でファイルを送る際、files={"file": open(path, "rb")} と書くだけでは、Content-Typeが正しく設定されずサーバー側でエラーになります。必ず ("filename.jpg", ファイルオブジェクト, "image/jpeg") のようにタプル形式でMIME typeまで指定してください。原因が分かりにくいエラーなので、最初に押さえておく価値があります。
アップロード処理を実装する
まずはアップロード部分だけを作ります。手元にある画像ファイルを、指定した記事の見出し画像として設定する処理です。
"""
noteの見出し画像をアップロードする。
MIME typeの明示が必須である点に注意。
"""
import logging
import os
from PIL import Image
from note_auth_client import NoteAuthClient
logger = logging.getLogger(__name__)
MIME_MAP = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
MAX_BYTES = 10 * 1024 * 1024 # 10MBを上限とする
def detect_mime(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
mime = MIME_MAP.get(ext)
if not mime:
raise ValueError(f"対応していない拡張子です: {ext}")
return mime
def image_size(path: str) -> tuple:
"""画像の幅と高さを取得する"""
with Image.open(path) as img:
return img.size
def upload_eyecatch(client: NoteAuthClient, note_id: int,
image_path: str) -> dict:
"""見出し画像をアップロードする"""
if not os.path.exists(image_path):
raise FileNotFoundError(image_path)
size_bytes = os.path.getsize(image_path)
if size_bytes > MAX_BYTES:
raise ValueError(
f"ファイルが大きすぎます: {size_bytes / 1024 / 1024:.1f} MB"
)
width, height = image_size(image_path)
mime = detect_mime(image_path)
filename = os.path.basename(image_path)
logger.info("アップロード: %s(%sx%s / %s KB)",
filename, width, height, size_bytes // 1024)
with open(image_path, "rb") as f:
# ここが重要: MIME typeを第3要素として明示する
files = {
"file": (filename, f, mime),
}
data = {
"note_id": str(note_id),
"width": str(width),
"height": str(height),
}
res = client.request(
"POST",
"/api/v1/image_upload/note_eyecatch",
files=files,
data=data,
)
if res.status_code not in (200, 201):
logger.error("アップロード失敗 HTTP %s: %s",
res.status_code, res.text[:300])
return {"ok": False, "status": res.status_code}
try:
result = res.json().get("data", {})
except ValueError:
result = {}
logger.info("アップロードしました")
return {"ok": True, "data": result}
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
client = NoteAuthClient()
client.verify()
# 対象記事の数値IDと、画像のパスを指定する
upload_eyecatch(client, note_id=12345678,
image_path="eyecatch.jpg")
Content-Typeを自分で設定しない
マルチパート送信では、requests が境界文字列を含むContent-Typeヘッダーを自動生成します。headers で Content-Type: multipart/form-data を手動指定すると境界文字列が欠落して失敗します。ヘッダーは触らず、files パラメータに任せてください。認証クライアントのセッションが Accept: application/json だけを持っている状態が正解です。
Pillowでアイキャッチを生成する
次に、画像そのものを自動で作ります。タイトルを入れただけのシンプルなものですが、統一感が出るため見栄えは悪くありません。
"""
タイトルを入れた見出し画像を生成する。
noteの推奨サイズは 1280x670(比率およそ1.91:1)。
"""
import os
import platform
import random
from PIL import Image, ImageDraw, ImageFont
WIDTH = 1280
HEIGHT = 670
MARGIN = 90
# 配色のパターン(背景の上下2色と文字色)
PALETTES = [
((28, 24, 18), (74, 56, 38), (245, 238, 228)),
((22, 32, 40), (46, 72, 88), (235, 245, 250)),
((36, 24, 30), (86, 48, 60), (250, 236, 240)),
((24, 34, 28), (48, 82, 62), (234, 248, 238)),
((32, 28, 40), (68, 58, 92), (240, 236, 250)),
]
def find_font() -> str:
"""OSごとに日本語フォントを探す"""
candidates = {
"Windows": [
r"C:\Windows\Fonts\meiryob.ttc",
r"C:\Windows\Fonts\meiryo.ttc",
r"C:\Windows\Fonts\YuGothB.ttc",
r"C:\Windows\Fonts\msgothic.ttc",
],
"Darwin": [
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
],
"Linux": [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
],
}
for path in candidates.get(platform.system(), []):
if os.path.exists(path):
return path
raise RuntimeError(
"日本語フォントが見つかりません。"
"フォントファイルのパスを直接指定してください"
)
def wrap_text(text: str, font, draw, max_width: int) -> list:
"""幅に収まるように、日本語テキストを折り返す"""
lines = []
current = ""
for char in text:
candidate = current + char
bbox = draw.textbbox((0, 0), candidate, font=font)
if bbox[2] - bbox[0] > max_width and current:
lines.append(current)
current = char
else:
current = candidate
if current:
lines.append(current)
return lines
def make_gradient(top: tuple, bottom: tuple) -> Image.Image:
"""上下方向のグラデーション背景を作る"""
img = Image.new("RGB", (WIDTH, HEIGHT), top)
draw = ImageDraw.Draw(img)
for y in range(HEIGHT):
ratio = y / HEIGHT
color = tuple(
int(top[i] + (bottom[i] - top[i]) * ratio) for i in range(3)
)
draw.line([(0, y), (WIDTH, y)], fill=color)
return img
def generate(title: str, output_path: str,
subtitle: str = "", palette_index: int = None,
font_path: str = None) -> str:
"""見出し画像を生成して保存する"""
if palette_index is None:
# タイトルから決定的に色を選ぶ(同じ記事なら同じ色)
palette_index = sum(ord(c) for c in title) % len(PALETTES)
top, bottom, text_color = PALETTES[palette_index % len(PALETTES)]
img = make_gradient(top, bottom)
draw = ImageDraw.Draw(img)
font_file = font_path or find_font()
# タイトルの長さに応じてフォントサイズを決める
if len(title) <= 16:
size = 78
elif len(title) <= 28:
size = 64
elif len(title) <= 44:
size = 52
else:
size = 44
font = ImageFont.truetype(font_file, size)
lines = wrap_text(title, font, draw, WIDTH - MARGIN * 2)
# 行数が多すぎる場合はフォントを小さくして作り直す
while len(lines) > 4 and size > 32:
size -= 6
font = ImageFont.truetype(font_file, size)
lines = wrap_text(title, font, draw, WIDTH - MARGIN * 2)
line_height = int(size * 1.5)
total_height = line_height * len(lines)
y = (HEIGHT - total_height) // 2
if subtitle:
y -= 30
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
text_width = bbox[2] - bbox[0]
x = (WIDTH - text_width) // 2
draw.text((x, y), line, font=font, fill=text_color)
y += line_height
# サブタイトル(サイト名など)
if subtitle:
sub_font = ImageFont.truetype(font_file, 30)
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
x = (WIDTH - (bbox[2] - bbox[0])) // 2
faded = tuple(int(c * 0.72) for c in text_color)
draw.text((x, y + 24), subtitle, font=sub_font, fill=faded)
# 上下に細いラインを入れて締める
accent = tuple(min(255, int(c * 0.85)) for c in text_color)
draw.rectangle([(0, 0), (WIDTH, 8)], fill=accent)
draw.rectangle([(0, HEIGHT - 8), (WIDTH, HEIGHT)], fill=accent)
img.save(output_path, "JPEG", quality=88, optimize=True)
print(f"生成しました: {output_path}({WIDTH}x{HEIGHT})")
return output_path
if __name__ == "__main__":
generate(
"Pythonでnoteの見出し画像を自動生成する方法",
"sample_eyecatch.jpg",
subtitle="AMEPRESS LAB",
)
色をタイトルから決定的に選んでいる点がポイントです。同じ記事なら常に同じ色になり、かつ記事ごとには変化するため、再生成しても見た目が安定します。乱数を使うと実行のたびに色が変わってしまいます。
▶ 本文中の画像をアップロードする処理は、別のAPIを使います。
S3への2段階アップロードの実装です。
生成とアップロードをつなげる
2つの処理を組み合わせて、タイトルを渡すだけで見出し画像が設定される形にします。
"""
記事キーを指定すると、タイトルから見出し画像を生成して設定する。
python 01_auto_eyecatch.py nXXXXXXXX
"""
import os
import sys
import tempfile
from note_auth_client import NoteAuthClient, CookieExpiredError
from eyecatch_generator import generate
from eyecatch_uploader import upload_eyecatch
SUBTITLE = "" # サイト名などを入れたい場合はここに
def apply_eyecatch(client: NoteAuthClient, note_key: str,
keep_file: bool = False) -> bool:
"""記事のタイトルから見出し画像を作って設定する"""
data = client.get_json(
f"/api/v3/notes/{note_key}",
params={"draft": "true", "draft_reedit": "false"},
)
if not data:
print(f"記事を取得できません: {note_key}")
return False
note_id = data.get("id")
title = (data.get("name") or "").strip()
if not note_id:
print("記事IDを取得できません")
return False
if not title:
print("タイトルが未設定です。先にタイトルを入れてください")
return False
print(f"対象: {title}")
# 既に見出し画像があるか確認する
if data.get("eyecatch"):
print(" 既に見出し画像が設定されています")
if keep_file:
output = f"eyecatch_{note_key}.jpg"
else:
fd, output = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
try:
generate(title, output, subtitle=SUBTITLE)
result = upload_eyecatch(client, note_id, output)
return bool(result.get("ok"))
finally:
if not keep_file and os.path.exists(output):
os.remove(output)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使い方: python 01_auto_eyecatch.py <note_key>")
sys.exit(1)
try:
client = NoteAuthClient()
client.verify()
ok = apply_eyecatch(client, sys.argv[1], keep_file="--keep" in sys.argv)
sys.exit(0 if ok else 1)
except CookieExpiredError as e:
print("認証エラー:", e)
sys.exit(2)
画像が無い下書きに一括で設定する
下書きを一覧して、見出し画像が設定されていないものだけを対象にする処理です。
"""
見出し画像が未設定の下書きに、一括で画像を生成・設定する。
python 02_bulk_eyecatch.py # 対象の確認のみ
python 02_bulk_eyecatch.py --run # 実際に設定する
"""
import os
import sys
import tempfile
import time
from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
from eyecatch_generator import generate
from eyecatch_uploader import upload_eyecatch
SLEEP_SEC = 4.0
MAX_PER_RUN = 10
SUBTITLE = ""
def find_targets(drafts: list) -> list:
"""見出し画像が無く、タイトルが入っている下書きを抽出する"""
targets = []
for d in drafts:
title = (d.get("name") or "").strip()
if not title:
continue
if d.get("eyecatch"):
continue
if not d.get("id"):
continue
targets.append(d)
return targets
def main(dry_run: bool = True):
try:
client = NoteAuthClient()
client.verify()
except CookieExpiredError as e:
print("認証エラー:", e)
return
drafts = NoteDraft(client).list_drafts()
targets = find_targets(drafts)
if not targets:
print("対象の下書きはありません")
return
print(f"=== 見出し画像が未設定の下書き: {len(targets)} 件 ===")
for d in targets[:MAX_PER_RUN]:
print(f" {(d.get('name') or '')[:50]}")
if dry_run:
print("\n[確認モード] --run を付けると実際に設定します")
return
done = 0
for d in targets[:MAX_PER_RUN]:
title = (d.get("name") or "").strip()
note_id = d.get("id")
print(f"\n処理中: {title[:44]}")
fd, path = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
try:
generate(title, path, subtitle=SUBTITLE)
result = upload_eyecatch(client, note_id, path)
if result.get("ok"):
done += 1
except Exception as e:
print(f" エラー: {e}")
finally:
if os.path.exists(path):
os.remove(path)
time.sleep(SLEEP_SEC)
print(f"\n完了: {done} 件に見出し画像を設定しました")
if __name__ == "__main__":
main(dry_run="--run" not in sys.argv)
▼ RECOMMENDATION ▼
作業時間を削っても、読者数は自動では増えない
アイキャッチの自動生成で、記事1本あたり15分程度は短縮できます。ただし、その記事を読む人を増やす作業は別に必要です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。スクリプトを書く必要も、保守する必要もありません。noteと違ってASPアフィリエイトが使えるため、収益化まで含めた設計ができます。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
背景画像を使ったパターン
単色グラデーションでは物足りない場合、手持ちの写真を背景に使う方法もあります。文字が読めるよう、暗いオーバーレイを重ねるのがコツです。
"""
背景写真の上にタイトルを載せた見出し画像を作る。
backgrounds/ フォルダの画像をランダムまたは順番に使う。
"""
import os
import random
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from eyecatch_generator import (WIDTH, HEIGHT, MARGIN,
find_font, wrap_text)
BACKGROUND_DIR = "backgrounds"
OVERLAY_ALPHA = 130 # 暗さ(0〜255。大きいほど暗い)
BLUR_RADIUS = 2
def pick_background(title: str = "") -> str:
"""背景画像を選ぶ。タイトルがあれば決定的に選択する"""
if not os.path.isdir(BACKGROUND_DIR):
raise RuntimeError(f"{BACKGROUND_DIR}/ がありません")
files = sorted(
f for f in os.listdir(BACKGROUND_DIR)
if f.lower().endswith((".jpg", ".jpeg", ".png"))
)
if not files:
raise RuntimeError("背景画像がありません")
if title:
index = sum(ord(c) for c in title) % len(files)
return os.path.join(BACKGROUND_DIR, files[index])
return os.path.join(BACKGROUND_DIR, random.choice(files))
def fit_cover(img: Image.Image) -> Image.Image:
"""アスペクト比を保ったまま、指定サイズを埋めるように切り抜く"""
target_ratio = WIDTH / HEIGHT
src_ratio = img.width / img.height
if src_ratio > target_ratio:
new_height = HEIGHT
new_width = int(HEIGHT * src_ratio)
else:
new_width = WIDTH
new_height = int(WIDTH / src_ratio)
img = img.resize((new_width, new_height), Image.LANCZOS)
left = (new_width - WIDTH) // 2
top = (new_height - HEIGHT) // 2
return img.crop((left, top, left + WIDTH, top + HEIGHT))
def generate_with_photo(title: str, output_path: str,
subtitle: str = "",
background: str = None) -> str:
"""写真を背景にした見出し画像を作る"""
bg_path = background or pick_background(title)
with Image.open(bg_path) as src:
img = fit_cover(src.convert("RGB"))
# ぼかしと暗さで、文字を読みやすくする
img = img.filter(ImageFilter.GaussianBlur(BLUR_RADIUS))
img = ImageEnhance.Color(img).enhance(0.85)
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, OVERLAY_ALPHA))
img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
draw = ImageDraw.Draw(img)
font_file = find_font()
size = 72 if len(title) <= 20 else 56 if len(title) <= 40 else 46
font = ImageFont.truetype(font_file, size)
lines = wrap_text(title, font, draw, WIDTH - MARGIN * 2)
while len(lines) > 4 and size > 32:
size -= 6
font = ImageFont.truetype(font_file, size)
lines = wrap_text(title, font, draw, WIDTH - MARGIN * 2)
line_height = int(size * 1.5)
y = (HEIGHT - line_height * len(lines)) // 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
x = (WIDTH - (bbox[2] - bbox[0])) // 2
# 影を付けて可読性を上げる
draw.text((x + 2, y + 2), line, font=font, fill=(0, 0, 0))
draw.text((x, y), line, font=font, fill=(255, 255, 255))
y += line_height
if subtitle:
sub_font = ImageFont.truetype(font_file, 28)
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
x = (WIDTH - (bbox[2] - bbox[0])) // 2
draw.text((x, HEIGHT - 70), subtitle, font=sub_font,
fill=(230, 230, 230))
img.save(output_path, "JPEG", quality=88, optimize=True)
print(f"生成しました: {output_path}(背景 {os.path.basename(bg_path)})")
return output_path
if __name__ == "__main__":
generate_with_photo(
"写真を背景にした見出し画像のサンプル",
"photo_sample.jpg",
subtitle="AMEPRESS LAB",
)
背景写真の権利に注意
使用する写真は、商用利用可のフリー素材か、自分で撮影したものにしてください。ライセンスによっては、クレジット表記が必要だったり、加工が禁止されていたりします。自動生成の仕組みに組み込むと、意識せずに大量使用することになるため、最初に素材のライセンスを確認しておくことが重要です。
生成した画像を確認する
アップロード前に、生成された画像をまとめて確認できると安心です。
"""
複数のタイトルから画像を生成し、確認用の一覧を作る。
アップロード前に見た目をチェックする。
"""
import os
from PIL import Image
from eyecatch_generator import generate, WIDTH, HEIGHT
OUTPUT_DIR = "preview"
THUMB_WIDTH = 320
COLUMNS = 3
def build_previews(titles: list) -> list:
os.makedirs(OUTPUT_DIR, exist_ok=True)
paths = []
for i, title in enumerate(titles, start=1):
path = os.path.join(OUTPUT_DIR, f"{i:02d}.jpg")
generate(title, path)
paths.append(path)
return paths
def make_contact_sheet(paths: list,
output: str = "preview/contact_sheet.jpg"):
"""生成した画像を並べた一覧画像を作る"""
if not paths:
return
thumb_height = int(THUMB_WIDTH * HEIGHT / WIDTH)
rows = (len(paths) + COLUMNS - 1) // COLUMNS
sheet = Image.new(
"RGB",
(THUMB_WIDTH * COLUMNS, thumb_height * rows),
(240, 240, 240),
)
for i, path in enumerate(paths):
with Image.open(path) as img:
thumb = img.resize((THUMB_WIDTH, thumb_height), Image.LANCZOS)
x = (i % COLUMNS) * THUMB_WIDTH
y = (i // COLUMNS) * thumb_height
sheet.paste(thumb, (x, y))
sheet.save(output, "JPEG", quality=85)
print(f"一覧を作成しました: {output}")
if __name__ == "__main__":
titles = [
"Pythonでnoteの見出し画像を自動生成する",
"APIを使った記事バックアップの手順",
"検索データから需要を読む方法",
"予約投稿を自作するスケジューラ設計",
"タグ選定をデータで行う",
"認証を通すための現実的な手段",
]
paths = build_previews(titles)
make_contact_sheet(paths)
つまずきやすい点
| 症状 | 原因 | 対処 |
|---|---|---|
| 500エラーが返る | MIME typeが未指定 | filesにタプルで指定する |
| マルチパートが壊れる | Content-Typeを手動指定した | ヘッダーは触らない |
| 404が返る | keyを渡している | note_idは数値IDを使う |
| 文字が□になる | 日本語非対応フォント | 日本語フォントを指定する |
| 文字がはみ出す | 折り返し処理が無い | wrap_textを通す |
| 画像が粗い | サイズが小さい | 1280x670以上で生成する |
| 実行ごとに色が変わる | 乱数で色を選んでいる | タイトルから決定的に選ぶ |
まとめ:テンプレート化できる工程は自動化する
見出し画像は、記事の内容と直接関係のない作業です。しかし省くと一覧での見え方が悪くなるため、無視もできません。テンプレート化して自動生成するのが、労力と効果のバランスが良い解決策です。
この記事の要点
- アップロードは
POST /api/v1/image_upload/note_eyecatch - MIME typeをタプルの第3要素として必ず明示する
- Content-Typeヘッダーは手動で設定しない
- note_idは数値ID(keyではない)
- サイズは1280x670(比率およそ1.91:1)
- 日本語フォントのパスはOSごとに探す
- 色はタイトルから決定的に選ぶと安定する
- アップロード前に一覧で見た目を確認する
まずは1本の記事で試して、生成された画像の見た目を確認してください。フォントサイズや配色は好みが分かれる部分なので、自分のアカウントの雰囲気に合わせて調整すると、より統一感が出ます。
FAQ|見出し画像の自動化についてよくある質問
ほぼ確実にMIME typeの指定漏れです。files={"file": open(path, "rb")} と書いていませんか。正しくは files={"file": (filename, f, "image/jpeg")} のようにタプルで渡します。ファイル名とMIME typeの両方を明示することで、正しいマルチパートが構築されます。この記事の upload_eyecatch() がその形になっています。
1280×670ピクセルが目安です。比率はおよそ1.91対1で、SNSでシェアされたときのOGP画像の標準比率と同じです。これより小さいと表示時に粗くなり、大きすぎるとファイルサイズが増えます。この記事のコードは1280×670で生成しています。詳しいサイズの考え方はサムネイルの記事で解説しています。
この記事の find_font() はOS標準のフォントを探しますが、環境によっては見つからないことがあります。その場合はフォントファイルを直接ダウンロードして、パスを指定してください。Noto Sans JPなど、無料で商用利用可能な日本語フォントが公開されています。フォントファイルをプロジェクト内に置いておけば、環境が変わっても同じ見た目になります。
折り返し処理を通していないか、フォントサイズが大きすぎます。この記事の wrap_text() は1文字ずつ幅を測って折り返すため、日本語でも正しく処理できます。英単語の途中で折り返したくない場合は、スペースを区切りとする処理を追加してください。日本語主体であれば、1文字単位の折り返しで問題ありません。
読者にとっては、凝ったデザインより「記事の内容が分かること」のほうが重要です。タイトルが読めるシンプルな画像で十分に機能します。むしろ統一されたデザインは、クリエイターページで一覧表示されたときに整って見えるという利点があります。特定の記事だけ手作りの画像にする、という使い分けも可能です。
同じAPIを再度呼べば上書きされます。ただし、この記事の一括処理スクリプトは画像が未設定のものだけを対象にしています。既存の画像を意図せず上書きしないための安全策です。差し替えたい場合は、単体で処理する 01_auto_eyecatch.py を使ってください。
できます。記事の数値IDが分かれば、下書きでも公開済みでも同じ処理で設定できます。ただし、公開済み記事の見出し画像を変更すると、すでにSNSでシェアされたリンクのプレビュー画像はキャッシュにより変わらない場合があります。公開前に設定しておくのが理想です。
pip install Pillow で入ります。PIL という古い名前のパッケージではないので注意してください。インストールに失敗する場合は、Pythonのバージョンとpipを最新にしてから再試行してください。それでも駄目な環境では、画像生成部分を諦めて、手元で作った画像をアップロードする部分だけ使うという選択もできます。
公開された上限値は明示されていませんが、この記事のコードでは安全側に10MBを上限としています。1280×670のJPEGであれば、品質88でも200〜400KB程度に収まるため、上限を気にする場面はほとんどありません。もし大きくなる場合は、quality の値を下げるか、PNGではなくJPEGで保存してください。
Pillowでできることは幅広く、図形の描画、複数フォントの併用、透過画像の合成などが可能です。ただし、凝るほど生成処理の保守が必要になります。より複雑なデザインを求めるなら、HTMLでテンプレートを作り、ヘッドレスブラウザでスクリーンショットを撮る方法もあります。CSSで自由にデザインでき、結果を画像として保存できるため、デザインの自由度は大きく上がります。