📑

非UTF-8ページのOGPを取得する

2025/03/02に公開

いろんな方法があるのであくまでパターン・方法の1つという感じで残しておく

今回の私の場合の環境は以下の感じ

  • Cloudflare Workers上で動作する
  • OGPの取得そのものはopen graph scaraper liteを利用していた
  • コードはTypeScriptで記述されていた

https://github.com/jshemas/openGraphScraperLite

改良前(UTF-8のみ対応)

一部抜粋で書くとこんな感じだった

const response = fetch(url, {headers: {'User-Agent': 'bot'}})
const text = await response.text()

const options = {
  html: text,
  onlyGetOpenGraphInfo: true,
}
const ogs = await ogs(options)

const ogpData = ogs.result
const title = ogp.ogTitle
// 以下各パラメータ

OpenGraphScraperの方はUTF-8以外の文字コードも対応しているようだが、Liteの方はテキストでHTMLデータを渡してパースするのでやるならfetchした結果を自前でデコードして上げる必要がある

Cloudflare Workers環境では確かOpenGraphScraperはNodeAPIの都合で使えなかったはず(この技術選定は半年かもう少し前だったのと個人開発のため理由を全く残していないので詳細は不明)

というわけで改良する

改良

文字コードを取得する

まず変換元の文字コードを把握する必要がある。基本的にUTF-8以外の文字コードによるサイトは歴史的な都合でUTF-8を使えていないが、現在のブラウザに対応するため概ね以下のどちらか(あるいは両方)の仕様は満たしている

  • HTTP Response Headerのcontent-typeでcharsetを指定しているケース
  • HTMLのmetaエレメント内でcharsetを指定しているケース

どちらも満たしていないケースもあるかもしれないが、ひとまず上記に対応する

header内のcontent-typeで文字コードを取得する

指定方法の仕様は以下の通り

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type

content-typeは文字コード情報以外のものも入っているためそこに気をつける必要はある

const response = fetch(url, {headers: {'User-Agent': 'bot'}})
const contentType = response.headers.get('content-type')

const contentTypes = contentType?.split(';') // ; の区切りで一旦分解
const charsetPhrase = contentTypes?.find((v)=>v.split('=')[0] === 'charset') // charset がkeyのパラメータを捜索
const headerCharset = charsetPhrase?.split('=')[1] // 指定したcharsetを持ってくる

この headerCharset があった場合はそれに従ってTextDecorderを用意すればよい

HTMLのmeta要素から取得する

こちらの場合手法が指定方法は2つある

<meta charset="utf-8" /> <!-- パターン1 -->
<meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <!-- パターン2 -->

詳しくはMDNのメタデータ要素のページへ

https://developer.mozilla.org/ja/docs/Web/HTML/Element/meta

どちらのパターンであっても、一旦HTMLをパースしなくてはならない。今回のパース処理ではCloudflare Workersでの動作ということもありエッジサイドで動くHTMLRewriterを利用する

まずはRewriterの定義

charset情報を一緒に含んだラッパークラス的に定義している。HTMLRewriterのハンドラーはもうちょっといい書き方がある気がするが、ひとまずこれで動いているのでこのままで

本当はパターン1とパターン2をハンドラーのselectorごと切ったほうが拡張性が高くなりそうだが現状はそこまで必要ない

rewriter.ts
export class CharsetRewriter {
  private rewriter: HTMLRewriter
  private charset: string

  constructor() {
    this.rewriter = new HTMLRewriter()
    this.rewriter.on('meta', {
      element: (element) => {
        const httpEquiv = element.getAttribute('http-equiv')
        if(["Content-Type", "content-type"].include(httpEquiv) {
          // パターン2
          const content = element.getAttribute('content')
          const contentTypes = content?.split(";")
          const charsetPhrase = contentTypes?.find((v)=>v.split("=")[0] === "charset")
          const charset = charsetPhrase?.split("=")[1]
          if(charset) {
            this.charset = charset
          }
        } else if(element.getAttribute('charset')){
          // パターン1
          const charset = element.getAttribute('charset')
          if(charset){
            this.charset=charset
          }
        }
      },
    })
  }

  public async execute(html: string){
    const response = new Response(html)
    return this.rewriter.transform(response).text()
  }

  public getCharset(){
    return this.charset
  }
}

というわけで上記のラッパークラスを利用したうえで以下のような処理になる

// 一旦UTF-8でデコードする
const buffer = await response.arrayBuffer()
const decoder = new TextDecorder()
const text = decoder.decode(buffer)

const htmlRewriter = new CharsetRewriter()
await htmlRewriter.execute(text)
const metaCharset = htmlRewriter.getCharset()

各情報で得た文字コード情報をまとめてHTMLをデコードして返す処理を作る

最後に上記をまとめて fetch したうえで文字コードを考慮してデコードした html 文字列を返す処理でまとめてしまう(上記コードに加えて半角スペースとかの処理を加えたりメソッドチェーンで一括化したりしている)

fetcher.ts
export async function fetchAndGetHtmlText(target: string): Promise<string> {
  const response = await fetch(target, { headers: { 'User-Agent': 'bot' } })

  if (!response.ok) {
    throw new Error(`Failed to fetch: ${response.statusText}`)
  }

  // response header の charset でエンコーディングを取得する
  const contentType = response.headers.get('content-type')
  const headerCharset = contentType
    ?.replaceAll(' ', '')
    ?.split(';')
    .find((v) => v.split('=')[0] === 'charset')
    ?.split('=')[1]

  if (headerCharset) {
    // header に charset が指定されている場合。それに従ってデコードする
    const buffer = await response.arrayBuffer()
    const decoder = new TextDecoder(headerCharset)
    const decodedText = decoder.decode(buffer)
    return decodedText
  } else {
    // undefinedだった場合、UTF-8 もしくは html の header に指定されている場合がある
    // まず buffer を取得したうえで、text でデコードして分析する
    const buffer = await response.arrayBuffer()
    const decoder = new TextDecoder()
    const text = decoder.decode(buffer)

    // htmlrewriterでhtml要素を解析し、header->meta->charsetを取得する
    const htmlRewriter = new CharsetRewriter()
    await htmlRewriter.execute(text)
    const metaCharset = htmlRewriter.getCharset()

    // 特に指定がない、または UTF-8 指定の場合
    // text がそのまま UTF-8 のときのHTML要素なのでそれを返す
    if (metaCharset === undefined || metaCharset === 'utf-8') {
      return text
    } else {
      // metaタグの charset の指定に従ってデコードして返す
      const decoder = new TextDecoder(metaCharset)
      const decodedText = decoder.decode(buffer)
      return decodedText
    }
  }
}

あとは改良前の fetch と response.text() 部分を上記の処理に置き換えると完了

const text = await fetchAndGetHtmlText(url)

const options = {
  html: text,
  onlyGetOpenGraphInfo: true,
}
const ogs = await ogs(options)
const ogp = ogs.result
// 以下OGPのデータ取得処理

補足

他の方法はあるか?という話

まず別言語だった場合でも、基本的なアプローチ方法は変わらないで目的を達成できるはず。ヘッダー内に指定があればそれに従い、ない場合は一旦UTF-8で処理してヘッダー要素を確認し、文字コード指定があればそれに従う

他のパッケージの場合、もしかしたら最初から文字コード情報を受け取ってうまく処理してくれるものがあるかもしれない。その場合fetchもやってくれるならヘッダーを見てくれる可能性はあるが、fetchした結果を渡すケースではこちらでヘッダー要素を見つける必要があることになる(もしかしたら response オブジェクトを渡せばいいケースならそれに限らない

htmlの中にメタデータで入っている場合はheader要素内に絞るというのも一つの手かもしれない。処理量が減り、事故の危険性も下がる(文字コードを利用した攻撃もあるので下手に読みすぎないほうがいい気はする

また、今回はきちんと文字コードを指定しているページを対象にした処理だったがもし文字コード指定がないページでUTF-8以外の文字コードを利用していた場合はかなり難しくなる。方法としてはバイトコードを読み取っていって特定のコードに反応して……というアプローチはあるが、この手の処理は下手にやると脆弱性を生みかねないので個人的にはおすすめできない

できるならばそういうケースは「対応しない」ときっぱり決めてしまったほうが安全だとは思う。が、どうしても何かしらの都合で対応しないといけないという場合はあるので、そのときはドメイン名やURLと文字コードをセットにしたテーブルを用意するのが次善策だと思う。何も無理に全て自動でやる必要はない……と私は考える

Discussion