個人開発で運営している自分のサイトの記事を SNS に共有したとき、私はカードの下端が切れていることに気づきました。サイト名とキャッチコピーを置いたはずの帯が、ごっそり無いのです。
画像そのものは正常でした。ブラウザで開けば、下まで全部あります。メタタグも 1200×630 と書いてあります。それなのにカードは切れていました。この食い違いの正体は、メタタグの数字が「宣言」でしかなく、実ファイルの寸法とは別物だった、ということです。
結論を先にお伝えします。og:image の寸法は、HTML に書いてある数字ではなく、画像ファイルを開いて読んだ数字で確かめます。 そして、確かめる場所はビルドの前です。共有してからでは遅いからです。
何が起きていたのか
このサイトの OGP 画像は、一度 1200×630 の横長に直していました。Twitter/X、Facebook、Slack、note.com の埋め込みが横長を前提にしているためです。twitter:card も summary_large_image にして、いったん片づいたつもりでいました。
ところが、実寸が 1200×1200 に戻っていました。戻った経緯を示す記録は、手元に残っていません。分かっているのは結果だけです。横長のカードで中央が切り取られ、下部のサイト名とキャッチが見えなくなっていました。10 月 23 日までに、日英別の 1200×630 に作り直しています。
こうなった原因を、私はいまとなっては特定できません。ただ、正方形の画像が必要な場面は別にあります。Stripe の商品画像や、LINE のサムネイルです。正方形を求める場所があるかぎり、同じ素材を正方形で書き出す機会は、これからもあるはずです。その一枚が og の置き場所に紛れ込めば、同じことがまた起きます。
だとすれば、直し方より先に、戻ったときに気づける仕組み が要ります。
切り取りで、どれだけ失われるのか
横長のカードは、おおよそ幅 1.91 : 高さ 1 の比率で表示されます。1200×1200 の画像を、この比率で中央から切り取るとどうなるか。数字にしておきます。
正方形のまま渡すと、高さのほぼ半分(47.7%)が消えます。下から 286 行といえば、サイト名を置く帯のちょうど真ん中です。プラットフォームごとの切り取り方は、細部が違うかもしれません。それでも「下端が安全ではない」という点は、どこでも変わらないと考えています。
検査1:宣言ではなく、実ファイルの寸法を読みます
最初の守りは単純です。public/og 以下の画像を総なめにして、実寸が 1200×630 でないものを列挙します。
#!/usr/bin/env python3
"""public/og 以下の画像を実寸で検査します。宣言ではなくピクセルを読みます。"""
import sys
from pathlib import Path
from PIL import Image
TARGET = ( 1200 , 630 ) # 共有カードに使う画像の実寸
EXEMPT = { "stripe.png" } # 正方形が必要な別用途のファイル名(og 配下に置く場合)
def check (root: Path):
bad, total = [], 0
for p in sorted (root.rglob( "*" )):
if p.suffix.lower() not in { ".png" , ".jpg" , ".jpeg" , ".webp" }:
continue
total += 1
if p.name in EXEMPT :
continue
with Image.open(p) as im: # ヘッダーだけ読むので速い
size = im.size
if size != TARGET :
bad.append((p, size))
return total, bad
if __name__ == "__main__" :
root = Path(sys.argv[ 1 ] if len (sys.argv) > 1 else "public/og" )
if not root.is_dir():
sys.exit( f "NG: { root } がありません" ) # 無音で通さない
total, bad = check(root)
if total == 0 :
sys.exit( f "NG: { root } に画像が 1 枚もありません" )
for p, (w, h) in bad:
print ( f "NG { p } { w } x { h } (期待 { TARGET [ 0 ] } x { TARGET [ 1 ] } )" )
print ( f "検査 { total } 枚 / 違反 { len (bad) } 枚" )
sys.exit( 1 if bad else 0 )
検査用に、1200×1200 と 1200×630 を 1 枚ずつ作って流したところ、出力は次のとおりでした。
NG public/og/site-ja.png 1200x1200 (期待 1200x630)
検査 2 枚 / 違反 1 枚
終了コードは 1 です。ビルドの前段に置けば、ここで止まります。
2 か所に、気を使いました。1 つ目は、フォルダが無いとき、画像が 0 枚のときも失敗にしていることです。パスが変わって「検査対象がゼロ」になると、違反ゼロと区別がつきません。何も検査していないのに緑になる のが、検査でいちばん怖い状態です。2 つ目は、Image.open がヘッダーだけを読むことです。記事ごとに画像を書き出すサイトでも、走査は一瞬で終わります。
検査2:失われる帯に、文字が載っていないかを見ます
寸法の検査は、もう一段手前の守りです。それとは別に、「寸法は想定外だが、意図したものだった」画像が紛れ込む場合があります。そのときに知りたいのは、切り取りで失われる帯に、読ませたい文字が載っているかどうかです。
背景がグラデーションでも使えるように、1 行ごとの輝度のばらつきを見ます。背景だけの行は、横に動いても色がほぼ変わりません。文字が載った行は、地の色と文字の色が交互に出るので、ばらつきが大きくなります。
#!/usr/bin/env python3
"""横長に中央切り取りされたとき、失われる帯に文字らしい変化があるかを調べます。"""
import sys
from PIL import Image
CARD_RATIO = 1.91 # 横長カードの目安(幅 / 高さ)
ROW_STD_THRESHOLD = 12 # 1 行の輝度の標準偏差。背景のグラデだけならこれを超えない
def lost_rows (h: int , w: int , ratio: float = CARD_RATIO ):
keep = round (w / ratio)
if keep >= h:
return []
top = (h - keep) // 2
return list ( range ( 0 , top)) + list ( range (top + keep, h))
def rows_with_content (im: Image.Image, rows):
g = im.convert( "L" )
w = g.width
hit = []
for y in rows:
line = list (g.crop(( 0 , y, w, y + 1 )).tobytes())
mean = sum (line) / w
std = ( sum ((v - mean) ** 2 for v in line) / w) ** 0.5
if std > ROW_STD_THRESHOLD :
hit.append(y)
return hit
if __name__ == "__main__" :
with Image.open(sys.argv[ 1 ]) as im:
im.load()
lost = lost_rows(im.height, im.width)
hit = rows_with_content(im, lost)
print ( f " { im.width } x { im.height } : 切り取りで失う { len (lost) } 行 / うち文字らしい行 { len (hit) } " )
if hit:
print ( f " 最初 y= { hit[ 0 ] } 最後 y= { hit[ - 1 ] } " )
sys.exit( 1 )
先ほどの検査用の画像に流した結果です。下端に帯状の文字を置いた 1200×1200 と、同じ構図の 1200×630 を比べています。
1200x1200: 切り取りで失う 572 行 / うち文字らしい行 24
最初 y=1099 最後 y=1124
1200x630: 切り取りで失う 2 行 / うち文字らしい行 0
正方形の側は、y=1099 から 1124 までの 24 行で、文字が失われると出ました。24 行というのは、検査用に置いた文字の高さです。実際の画像で何行になるかは、デザインによります。大切なのは件数そのものより、「失われる側に文字がある」と画像を開く前に分かる ことです。
しきい値の 12 は、この検査用の画像で決めた値です。ご自分の背景に合わせて、一度は手で当て直してください。背景に細かい模様がある場合は、誤検出が出ます。その際は、模様だけの画像を何枚か流して、誤検出が出ない最小の値を探すのが近道でした。
検査3:メタタグの宣言と、指す先の実ファイルを突き合わせます
最初に書いたとおり、HTML に書いてある og:image:width と og:image:height は宣言です。画像を作り直しても、メタタグの数字を直し忘れれば食い違いますし、逆に画像だけが戻っても、メタタグは「正しいまま」です。今回の事故が気づきにくかった理由は、ここにあります。
#!/usr/bin/env python3
"""HTML の og:image の宣言と、指す先の実ファイルの寸法を突き合わせます。"""
import sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urlparse
from PIL import Image
class Meta ( HTMLParser ):
def __init__ (self):
super (). __init__ ()
self .m = {}
def handle_starttag (self, tag, attrs):
if tag != "meta" :
return
a = dict (attrs)
key = a.get( "property" ) or a.get( "name" )
if key and "content" in a:
self .m.setdefault(key, a[ "content" ])
def check (html_path: Path, public_dir: Path):
p = Meta()
p.feed(html_path.read_text( encoding = "utf-8" ))
m, errs = p.m, []
if m.get( "twitter:card" ) != "summary_large_image" :
errs.append( f "twitter:card= { m.get( 'twitter:card' ) !r } " )
url = m.get( "og:image" )
if not url:
return errs + [ "og:image がありません" ]
f = public_dir / urlparse(url).path.lstrip( "/" )
if not f.is_file():
return errs + [ f "実ファイルがありません: { f } " ]
with Image.open(f) as im:
real = im.size
decl = ( int (m.get( "og:image:width" , 0 )), int (m.get( "og:image:height" , 0 )))
if decl != real:
errs.append( f "宣言 { decl } と実寸 { real } が違います" )
if real != ( 1200 , 630 ):
errs.append( f "実寸 { real } は 1200x630 ではありません" )
return errs
if __name__ == "__main__" :
html, public = Path(sys.argv[ 1 ]), Path(sys.argv[ 2 ])
errs = check(html, public)
for e in errs:
print ( "NG" , e)
print ( "OK" if not errs else f "違反 { len (errs) } 件" )
sys.exit( 1 if errs else 0 )
宣言は 1200×630 と書いたまま、指す先を 1200×1200 の画像にした HTML を用意して流しました。
NG 宣言 (1200, 630) と実寸 (1200, 1200) が違います
NG 実寸 (1200, 1200) は 1200x630 ではありません
違反 2 件
同じ HTML の指す先を 1200×630 の画像に変えると、OK で終了コード 0 になります。ビルドした HTML が出力されるフォルダに対して実行すれば、記事ごとの og:image も一度に確かめられます。
3 つをビルドの前段に並べます
検査は、実行されなければ意味がありません。package.json の prebuild に並べて、どれかが落ちたらビルドごと止めます。
{
"scripts" : {
"check:og" : "python3 scripts/og_size_check.py public/og && python3 scripts/og_lost_band.py public/og/site-ja.png && python3 scripts/og_lost_band.py public/og/site-en.png" ,
"prebuild" : "npm run check:og"
}
}
&& でつないでいるので、1 つ目で落ちれば後ろは走りません。メタタグの突き合わせ(検査3)は、出力された HTML が要るので、ビルドの後ろに置きます。同期フォルダの生成物でビルドが止まった件はDropbox 同期のビルド出力に競合コピーが増えたとき に書きました。あちらも同じで、止まるべきときに止まる場所に検査を置くことが、肝でした。
同じ日に確かめたい、もう 1 つの穴
画像が正しくても、クローラーが画像にたどり着けなければ、カードは出ません。OGP まわりでは、過去に 3 つの原因を踏んでいます。
3 つ目は、ブラウザで確かめても気づけません。ブラウザの言語設定が日本語なら、転送されないからです。クローラーを真似て、言語ヘッダー付きで確認します。
# 英語のクローラーを真似て、トップページの応答コードだけ見ます
curl -s -o /dev/null -I -w "%{http_code}\n" -H "Accept-Language: en-US,en;q=0.9" https://example.com/
検証のために、英語ヘッダーのときだけ 307 を返す小さなサーバーを手元に立てて試しました。ヘッダーを付けると 307、付けないと 200 が返り、差が出ることは確かめています。本番で 200 が返れば、疎通は問題ありません。307 なら、まだ直っていません。
画像を差し替える依頼を Claude Code に出すとき
画像の差し替えを Claude Code に任せる場面もあります。そのとき CLAUDE.md に 1 行だけ書いておくと、取りこぼしが減ります。
- OGP 画像を触ったら、完了報告の前に npm run check:og を実行し、結果の行をそのまま貼ること
この 1 行が確実に読まれるよう、CLAUDE.md の長さにも気をつけてください。サブフォルダ側のファイルが長いと後半が渡されない場合があることは、サブフォルダの CLAUDE.md の後半だけが効かないとき に書きました。
「確認しました」という言葉は、報告に書けてしまいます。出力の行を貼らせれば、実行したかどうかが見えます。私自身、依頼の完了報告だけを信じて後から食い違いに気づいた経験があり、報告に出力を貼る形を好んでいます。
落とし穴
検査の対象が空になる :フォルダ名の変更で、走査が 0 枚になることがあります。0 枚は失敗にしてください。
しきい値を鵜呑みにする :行ごとのばらつきの値は、背景の模様で変わります。誤検出が出ない最小値を、自分の画像で探してください。
正方形を og 配下に置く :Stripe や LINE 用の正方形は、別のフォルダへ。やむを得ず同居させるなら、除外リストに名前で載せます。
ブラウザだけで確認する :言語ヘッダーとクローラーの挙動は、ブラウザでは再現しません。
次に、1 つだけ
まず public/og の実寸を、一度だけ数えてみてください。python3 scripts/og_size_check.py public/og で済みます。違反が 0 枚なら、それが今日の収穫です。違反があれば、共有された誰かのカードが、いま切れているかもしれません。
直したあとの検査を prebuild に入れておけば、同じ画像が戻ってきても、共有される前に止まります。