はじめに
astro-notion-blog で Notion にブックマークした楽天市場の商品ページを表示すると、Cloudflareでデプロイしたサイトで、カード内のタイトルや説明文が文字化けしていました。
本記事では、原因の調査結果と src/lib/blog-helpers.ts への修正内容をまとめます。
症状
Notion の記事に楽天市場(item.rakuten.co.jp など)の URL をブックマークとして貼ると、ブログ上では次のような問題が起きていました。
- 商品名や説明文が 「æ¥½å¤©å¸‚å ´」のような文字化け で表示される
- Amazon や GitHub など UTF-8 のサイトでは問題なく表示される
- Notion 上では正常にプレビューが表示される
原因
ブックマークの OGP 情報(タイトル・説明・画像)は、ビルド時に buildURLToHTMLMap で各 URL の HTML を fetch し、metascraper で解析して取得しています。
変更前のコードは次のように Response.text() をそのまま使っていました。
const res = await fetch(url.toString(), { ... })
const html = await res.text() Response.text() は UTF-8 としてデコードする 仕様です。一方、楽天市場のページは EUC-JP や Shift_JIS で配信されることが多く、UTF-8 として読むと日本語部分が文字化けします。
いまだにEUC-JP使ってるんですね。🫢
処理の流れ
flowchart LR A["Notion Bookmark ブロック"] --> B["buildURLToHTMLMap"] B --> C["fetch HTML"] C --> D["metascraper で OGP 抽出"] D --> E["Bookmark.astro でカード表示"]
文字化けは C → D の段階で発生していました。HTML 自体が壊れているため、metascraper が抽出する title や description も文字化けしたままカードに表示されます。
修正内容
fetch で取得したレスポンスを、正しい文字コードでデコードしてから HTML 文字列に変換する decodeHtmlResponse 関数を追加しました。
1. charset の正規化
Content-Type ヘッダーや HTML の <meta charset> から得られる文字コード名を、TextDecoder が理解できる形式に揃えます。
const normalizeCharset = (charset: string): string => {
const normalized = charset.trim().toLowerCase().replace(/[_\s-]/g, '')
const charsetMap: Record<string, string> = {
utf8: 'utf-8',
shiftjis: 'shift_jis',
sjis: 'shift_jis',
windows932: 'shift_jis',
cp932: 'shift_jis',
eucjp: 'euc-jp',
iso88591: 'iso-8859-1',
}
return charsetMap[normalized] || charset.trim().toLowerCase()
}
2. charset の検出
次の順序で文字コードを特定します。
- Content-Type ヘッダー の
charset=パラメータ - HTML 先頭 4096 バイト 内の
<meta charset="...">タグ - どちらも見つからない場合は UTF-8 をデフォルトに使用
const extractCharsetFromContentType = (contentType: string | null): string | null => {
if (!contentType) return null
const match = contentType.match(/charset=([^;\s]+)/i)
return match ? match[1] : null
}
const extractCharsetFromHtml = (bytes: Uint8Array): string | null => {
const snippet = new TextDecoder('iso-8859-1').decode(bytes.slice(0, 4096))
const match = snippet.match(/charset\s*=\s*["']?([^"'\s;>]+)/i)
return match ? match[1] : null
} HTML の meta タグを解析するときは、まだ正しい charset が分からないため iso-8859-1(latin1) として先頭部分だけ読みます。ASCII 範囲の <meta charset="EUC-JP"> などはこの方法で問題なく抽出できます。
3. デコード処理
const decodeHtmlResponse = async (res: Response): Promise<string> => {
const buffer = await res.arrayBuffer()
const bytes = new Uint8Array(buffer)
const charset =
extractCharsetFromContentType(res.headers.get('content-type')) ||
extractCharsetFromHtml(bytes) ||
'utf-8'
const normalizedCharset = normalizeCharset(charset)
try {
return new TextDecoder(normalizedCharset).decode(buffer)
} catch (err) {
console.warn(
`[decodeHtmlResponse] failed to decode with charset=${normalizedCharset}, fallback to utf-8`,
err
)
return new TextDecoder('utf-8').decode(buffer)
}
} arrayBuffer() で生のバイト列を取得し、TextDecoder で正しい charset を指定してデコードします。未知の charset やデコード失敗時は UTF-8 にフォールバックします。
4. buildURLToHTMLMap への適用
// 変更前
const html = await res.text()
// 変更後
const html = await decodeHtmlResponse(res) この 1 行の差し替えで、楽天市場だけでなく Shift_JIS / EUC-JP のページ全般でブックマークカードの文字化けが解消されます。
修正後の効果
| 項目 | 修正前 | 修正後 |
|---|---|---|
| 楽天市場の商品名 | 文字化け | 正しい日本語で表示 |
| UTF-8 サイト(Amazon 等) | 正常 | 正常(影響なし) |
| charset 未指定の HTML | UTF-8 として処理 | UTF-8 フォールバック(従来通り) |
注意点
ビルド時 fetch に依存
ブックマークの OGP 情報は 静的ビルド時 に fetch して取得します。ビルド環境から対象 URL にアクセスできない場合、カードは空のままになります(文字化け修正以前から同じ挙動)。
HTML 内の charset 宣言が不正な場合
meta タグや Content-Type の charset 宣言が誤っているページでは、依然として文字化けする可能性があります。その場合はサイト側の問題であり、クライアント側で完全には補正できません。
XSS リスクは変わらない
HTML をそのまま metascraper に渡す既存の設計は変わりません。信頼できる URL のみブックマークする運用は引き続き重要です。
まとめ
Notion ブックマークの楽天カード文字化けは、fetch().text() が UTF-8 固定だったことが原因でした。レスポンスの charset を検出して TextDecoder でデコードする decodeHtmlResponse を追加することで、EUC-JP / Shift_JIS のページでも正しく OGP 情報を取得できるようになりました。
同様の症状が出ている astro-notion-blog 利用者の参考になれば幸いです。