公開済み140ページの共有カードを巡回すると、題名と説明の末尾に改行が付いていた

og:title が改行で終わっていないページは、140ページ中0件でした。
画像のほうは全部開けました。ところが題名と説明の文字、つまり og:title、og:description、twitter:title、twitter:description の4項目が、どのページでも末尾に改行を1つ抱えていました。
レビューで見るのは1本ごとの差分で、中身は記事のMarkdownとOG画像が1枚です。ページの<head>を組み立てるテンプレートは差分に出ません。ここに癖があっても、記事を何本レビューしても見つかりません。だから、公開済みのURLを読者や共有先と同じ側から取得して確かめます。
140ページの一覧を作る
巡回先は、サイトのsitemapから/entry/で始まるURLだけを抜き出しました。
mkdir -p "$TMPDIR/ogp" && cd "$TMPDIR/ogp"
curl -sL https://hibanas.net/sitemap-0.xml | grep -o '<loc>https://hibanas.net/entry/[^<]*' | sed 's/<loc>//' > urls.txt
wc -l < urls.txt
140
検査する項目は、最初はこう決めました。
- og:image が絶対URLで、実際に開けて、画像として返ってくる
- 画像が16:9に近い(誤差0.01以内)
- canonical と og:url が一致する
twitter:cardがsummary_large_imageになっている
最初の版を140ページに流すと、OKが106ページ、NGが34ページでした。NGは全部、画像の縦横比です。内訳は1200×630が22ページ、1376×768が11ページ、2752×1536が1ページ。
34という数字のあと、ページを直す前に規則のほうを見直します。1200×630の22ページは、記事に画像を持たないページが共通で使う、サイトのデフォルト画像です。ところがFacebookの資料は、まさに1200×630以上を推奨し、1.91:1に近いほど切り取られずに出ると書いています。推奨サイズをNGにする規則は、検査側が間違っています。1376×768と2752×1536のズレは、以前に測ったアイキャッチの寸法と同じ話で、共有の可否には関係しません。
規則を書いた時点では「16:9に揃えるのが正しい」という前提が混ざっていました。毎日のOG画像を16:9で作っている物差しを、そのまま共有先の仕様に当てはめた形です。
縦横比の合格条件は外しました。twitter:card の確認も、初版では140ページとも summary_large_image で通っていたので、書き直した版には入れていません。代わりにFacebookの資料にある数字を使っています。
- 画像ファイルは8MB以下
- 幅600、高さ315以上なら大きなカードで出る(これ未満だと小さなサムネイル扱い)
検査スクリプト
書き直した版です。画像の寸法はWebP、PNG、JPEGのヘッダーから読みます。外部ライブラリは使っていません。
ogp_check2.py の全文
import struct, sys, urllib.parse, urllib.request
from functools import lru_cache
from html.parser import HTMLParser
TEXT_KEYS = ("og:title", "og:description", "twitter:title", "twitter:description")
MAX_BYTES = 8 * 1024 * 1024 # Facebook: 8 MB
MIN_W, MIN_H = 600, 315 # Facebook: large card from 600x315
class Meta(HTMLParser):
def __init__(self):
super().__init__()
self.m, self.canonical = {}, None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "meta":
k = a.get("property") or a.get("name")
if k and k not in self.m:
self.m[k] = a.get("content", "")
elif tag == "link" and a.get("rel") == "canonical":
self.canonical = a.get("href")
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "ogp-check/0.2"})
with urllib.request.urlopen(req, timeout=20) as r:
return r.headers.get("content-type", ""), r.read()
@lru_cache(maxsize=None)
def fetch_image(url):
return fetch(url)
def dimensions(b):
if b[:8] == b"\x89PNG\r\n\x1a\n":
return struct.unpack(">II", b[16:24])
if b[:4] == b"RIFF" and b[8:12] == b"WEBP":
kind = b[12:16]
if kind == b"VP8X":
return 1 + int.from_bytes(b[24:27], "little"), 1 + int.from_bytes(b[27:30], "little")
if kind == b"VP8 ":
w, h = struct.unpack("<HH", b[26:30]); return w & 0x3FFF, h & 0x3FFF
if kind == b"VP8L":
v = int.from_bytes(b[21:25], "little"); return (v & 0x3FFF) + 1, ((v >> 14) & 0x3FFF) + 1
if b[:2] == b"\xff\xd8":
i = 2
while i + 9 < len(b):
if b[i] != 0xFF:
i += 1; continue
marker, size = b[i + 1], struct.unpack(">H", b[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack(">HH", b[i + 5:i + 9]); return w, h
i += 2 + size
return None
def check(url):
ng, warn = [], []
_, body = fetch(url)
p = Meta(); p.feed(body.decode("utf-8", "replace"))
for k in TEXT_KEYS:
v = p.m.get(k)
if v is None:
ng.append(f"{k} missing")
elif v != v.strip():
ng.append(f"{k} edge-space {v[len(v.rstrip()):]!r}")
if p.canonical and p.canonical != p.m.get("og:url"):
ng.append("canonical != og:url")
img = p.m.get("og:image")
if not img:
return ["og:image missing"], warn
if not urllib.parse.urlparse(img).scheme:
return [f"og:image is relative {img!r}"], warn
try:
ctype, data = fetch_image(img)
except Exception as e:
return ng + [f"og:image fetch failed {e}"], warn
if not ctype.startswith("image/"):
ng.append(f"og:image content-type {ctype}")
if len(data) > MAX_BYTES:
ng.append(f"og:image {len(data)} bytes")
wh = dimensions(data)
if wh is None:
ng.append("og:image size unreadable")
elif wh[0] < MIN_W or wh[1] < MIN_H:
ng.append(f"og:image {wh[0]}x{wh[1]} < {MIN_W}x{MIN_H}")
if "og:image:width" not in p.m or "og:image:height" not in p.m:
warn.append("no og:image:width/height")
return ng, warn
def main(urls):
if not urls:
print("usage: ogp_check2.py URL [URL ...]", file=sys.stderr)
return 2
n_ng = n_warn = 0
for u in urls:
try:
ng, warn = check(u)
except Exception as e:
ng, warn = [f"page fetch failed {e}"], []
n_ng += bool(ng); n_warn += bool(warn)
print(("NG " if ng else "OK ") + u + (" " + "; ".join(ng) if ng else ""))
print(f"checked={len(urls)} ng={n_ng} warn={n_warn}")
return 1 if n_ng else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
NGは終了コード1、使い方の誤り(URLなし)は終了コード2です。warnは終了コードに影響させていません。
画像は通って、文字が全件落ちた
まず1ページだけ流しました。11月5日に公開した記事です。
python3 ogp_check2.py https://hibanas.net/entry/ai-digital-product-webhook-signature-gate/; echo "exit=$?"
NG https://hibanas.net/entry/ai-digital-product-webhook-signature-gate/ og:title edge-space '\n'; og:description edge-space '\n'; twitter:title edge-space '\n'; twitter:description edge-space '\n'
checked=1 ng=1 warn=1
exit=1
最初は画像の規則に引っかかる想定でした。落ちたのは画像ではなく、文字の項目です。'\n'は、値の末尾に改行が1つ付いているという意味です。
140ページ全部に流して、NGの理由ごとに数えました。
python3 ogp_check2.py $(cat urls.txt) > run2.txt; echo "exit=$?"
tail -1 run2.txt
grep '^NG' run2.txt | sed -E 's/^NG [^ ]+ //' | sort | uniq -c
exit=1
checked=140 ng=140 warn=140
140 og:title edge-space '\n'; og:description edge-space '\n'; twitter:title edge-space '\n'; twitter:description edge-space '\n'
NG理由の種類は1つだけで、140ページ全部が同じ4項目です。画像の取得失敗、容量超過、寸法不足、canonicalのずれは0件でした。別に集計した画像は、119種類すべてがimage/webpで、容量は11,176バイトから149,624バイト、いちばん小さい寸法は912×513でした。Facebookの上限8MBと下限600×315に対して、どれも余裕があります。
直近の記事で見ていた画像側は、少なくとも共有カードとして取得できる状態でした。壊れていたのは別の場所です。
画像で1つだけ数えておいたのが、サイトのデフォルト画像を使うページです。140ページのうち22ページが、同じog-image.webpを指していました。古いIPAの手順記事、MacBookのリセット手順、Pinterest関連、初期のエージェント記事などです。残る118ページは、記事ごとの専用画像でした。
22ページは壊れているわけではありません。ただ、続けて共有すれば同じ画像が並びます。直すかどうかは別の判断です。ここでは数だけ。
改行の出どころ
なぜ140ページすべてに同じ改行が入るのか。この4項目は、テーマ側のplainifyという関数を通って出力されるからです。場所はsrc/lib/utils/textConverter.tsです。Markdownを一度HTMLにしてからタグを消す処理で、そのMarkdown変換が段落を<p>…</p>と改行で包みます。タグを消したあと、連続した改行は消えますが、末尾の改行1つは残ります。
同じ処理を、リポジトリで使っているmarkedのバージョン14.1.4で再現しました。
node -e '
const {marked}=require("marked");
const plainify=(c)=>{const p=marked.parse(c);const f=p.replace(/<\/?[^>]+(>|$)/gm,"");return f.replace(/[\r\n]\s*[\r\n]/gm,"")};
const s="AIにダウンロードURLを送らせる前に";
console.log(JSON.stringify(marked.parse(s)));
console.log(JSON.stringify(plainify(s)));
console.log(JSON.stringify(plainify(s).trim()));
'
"<p>AIにダウンロードURLを送らせる前に</p>\n"
"AIにダウンロードURLを送らせる前に\n"
"AIにダウンロードURLを送らせる前に"
2行目の末尾に\nが残り、3行目のtrim()をかけた結果では消えています。直すならplainifyの戻り値にtrim()を1回かけるだけだと見ています。ただし、確かめたのはNode上の文字列だけです。サイトのビルドには、まだ反映していません。
壊れたページを、ちゃんと落とせるか
全件でNGが出たので、規則が厳しすぎる可能性もあります。逆に、壊れたページを素通ししているなら、OKという結果も信用できません。確かめるために、手元で小さなページを7枚作ってローカルサーバーから配りました。
見本ページの作り方
mkdir -p "$TMPDIR/ogp/fx" && cd "$TMPDIR/ogp/fx"
magick -size 1200x630 xc:gray ok.png
magick -size 400x200 xc:gray small.png
python3 - <<'EOF'
base = "http://127.0.0.1:8765"
def page(name, title="見本", img=f"{base}/ok.png", wh=True):
c = f"{base}/{name}.html"
og = f'<meta property="og:image" content="{img}">' if img else ""
size = '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' if wh else ""
open(f"{name}.html", "w").write(
f'<link rel="canonical" href="{c}"><meta property="og:url" content="{c}">'
f'<meta property="og:title" content="{title}"><meta name="twitter:title" content="{title}">'
f'<meta property="og:description" content="説明"><meta name="twitter:description" content="説明">{og}{size}')
page("good"); page("nowh", wh=False); page("no_image", img=None); page("relative", img="/ok.png")
page("img404", img=f"{base}/missing.png"); page("small", img=f"{base}/small.png"); page("newline", title="見本\n")
EOF
別のターミナルで、見本ページのあるディレクトリを配ります。
cd "$TMPDIR/ogp/fx" && python3 -m http.server 8765 --bind 127.0.0.1
元のディレクトリに戻って、1枚ずつ検査します。末尾のchecked=の行は省きました。nopeは、配っていないページです。
cd "$TMPDIR/ogp"
for n in good nowh no_image relative img404 small newline nope; do
python3 ogp_check2.py http://127.0.0.1:8765/$n.html | grep -v '^checked'; echo "exit=${PIPESTATUS[0]}"
done
python3 ogp_check2.py; echo "exit=$?"
OK http://127.0.0.1:8765/good.html
exit=0
OK http://127.0.0.1:8765/nowh.html
exit=0
NG http://127.0.0.1:8765/no_image.html og:image missing
exit=1
NG http://127.0.0.1:8765/relative.html og:image is relative '/ok.png'
exit=1
NG http://127.0.0.1:8765/img404.html og:image fetch failed HTTP Error 404: File not found
exit=1
NG http://127.0.0.1:8765/small.html og:image 400x200 < 600x315
exit=1
NG http://127.0.0.1:8765/newline.html og:title edge-space '\n'; twitter:title edge-space '\n'
exit=1
NG http://127.0.0.1:8765/nope.html page fetch failed HTTP Error 404: File not found
exit=1
usage: ogp_check2.py URL [URL ...]
exit=2
画像なし、相対パス、画像404、小さすぎる画像、末尾の改行、ページそのものが404。どれも終了コード1で止まりました。URLを1つも渡さない場合は、「0件検査してOK」にならず、終了コード2です。
nowhだけは、寸法タグが無くてもOKで通ります。goodとnowhを続けて流すと、最後の行はchecked=2 ng=0 warn=1でした。warnは終了コードに影響しません。
寸法タグは140ページとも付いていなかった
もう1つ、警告のほうは140ページ全部に出ています。og:image:width と og:image:height の2つがありません。ogp.me にはこの2つが任意のプロパティとして載っています。Facebookの資料は、最初にそのURLを共有した人にはクローラーがまだ画像を取得していないため画像が出ないこと、これを避ける方法の1つが寸法タグだと書いています。
このサイトでは、その場面を再現していません。Facebookのデバッガーはログインが要るので使わず、実際にどう見えるかは未確認です。警告のまま残しておきます。
まだわかっていないこと
末尾の改行を、X、Facebook、Slackなどの共有先がどう扱うかは試していません。ogp.me のページ本文で空白や改行の扱いを探しましたが、規定は見つけられませんでした。多くの共有先は前後の空白を捨てているだろうとは思います。ただ、それは想像で、確かめた事実ではありません。
本番もまだ直っていません。plainifyを直して再公開したら、同じ巡回でng=0になるはずです。そうならなければ、改行の出どころを読み違えています。
販売ページに持っていく
デジタル商品の販売ページは、共有されて初めて人の目に触れます。決済サービスのページでも自分のサイトでも、同じです。
公開前に流すなら、手順は短くて済みます。
- 販売ページのURLを1つ決める
python3 ogp_check2.py <そのURL>を流す- NGが出た理由だけを直し、もう一度流す
これで機械的に止められるのは、og:imageの相対パス、画像404、小さすぎる画像、題名や説明の前後の空白です。140ページの巡回は、初版で1分14秒でした。ページ数が少なければ、公開のたびに流しても負担になりません。
逆に、題名の言い回しが商品に合っているか、画像が商品の中身を伝えているかは、この検査では何も言えません。そこは人が見ます。
残っている作業
plainifyの戻り値にtrim()を入れて再公開する(未着手)- 公開後に同じ巡回を流し、
ng=0になるか確かめる - 寸法タグを足すかどうかを決める
記事を毎日1本ずつ増やしていくと、見えなくなるのは1本の中身ではありません。全ページに共通する土台のほうです。
出典
- The Open Graph protocol: https://ogp.me/
- Facebook for Developers, Images in Link Shares: https://developers.facebook.com/docs/sharing/webmasters/images/
- Python documentation, html.parser: https://docs.python.org/3/library/html.parser.html
- sitemaps.org, Sitemaps XML format: https://www.sitemaps.org/protocol.html


