Notionブックマークの楽天カード文字化けを修正した

はじめに

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-JPShift_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 が抽出する titledescription も文字化けしたままカードに表示されます。

修正内容

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 の検出

次の順序で文字コードを特定します。

  1. Content-Type ヘッダーcharset= パラメータ
  2. HTML 先頭 4096 バイト 内の <meta charset="..."> タグ
  3. どちらも見つからない場合は 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 のみブックマークする運用は引き続き重要です。

まとめ

Image in a image block

Notion ブックマークの楽天カード文字化けは、fetch().text() が UTF-8 固定だったことが原因でした。レスポンスの charset を検出して TextDecoder でデコードする decodeHtmlResponse を追加することで、EUC-JP / Shift_JIS のページでも正しく OGP 情報を取得できるようになりました。

同様の症状が出ている astro-notion-blog 利用者の参考になれば幸いです。