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

公開済み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になるはずです。そうならなければ、改行の出どころを読み違えています。

販売ページに持っていく

デジタル商品の販売ページは、共有されて初めて人の目に触れます。決済サービスのページでも自分のサイトでも、同じです。

公開前に流すなら、手順は短くて済みます。

  1. 販売ページのURLを1つ決める
  2. python3 ogp_check2.py <そのURL> を流す
  3. NGが出た理由だけを直し、もう一度流す

これで機械的に止められるのは、og:imageの相対パス、画像404、小さすぎる画像、題名や説明の前後の空白です。140ページの巡回は、初版で1分14秒でした。ページ数が少なければ、公開のたびに流しても負担になりません。

逆に、題名の言い回しが商品に合っているか、画像が商品の中身を伝えているかは、この検査では何も言えません。そこは人が見ます。

残っている作業

  • plainifyの戻り値にtrim()を入れて再公開する(未着手)
  • 公開後に同じ巡回を流し、ng=0になるか確かめる
  • 寸法タグを足すかどうかを決める

記事を毎日1本ずつ増やしていくと、見えなくなるのは1本の中身ではありません。全ページに共通する土台のほうです。

出典

この記事をシェア