ぽっぺんしゃんにょろりんこ

匿名・非追跡型アダルト動画検索エンジンの設計ノート

Node.js + Puppeteerでサムネイル画像を動画ページから抽出するという話

こんにちは、にょろりんこの備忘録的技術ブログです。

今回は、動画ページから最適なサムネイル画像URLを抽出するためのモジュール、fetch-image.js をご紹介します。

これは、私が開発・運用しているポルノ動画検索エンジン「Sae-Porns」の中でも、ユーザー体験に直結する非常に重要な処理のひとつです。

検索結果の画面で最初に目に入るのがサムネイル画像。つまり、ここで失敗するとクリックされない=見てもらえないわけです。

ティアラ_ティータイム

ところが実際の動画ページというのは、サイトによってHTML構造がバラバラ、さらには構造化データの欠落や、JavaScriptによる遅延読み込みなども日常茶飯事。一発で「正しい画像URL」を取り出せる保証はどこにもありません。

そこでこのモジュールでは、信頼性の高い順に複数の抽出手段を段階的に試すという「多段階フォールバック構造」を採用し、どんな状況でも、できるだけ確実に画像URLを取得するよう設計しています。

順を追って解説していきます。

async function fetchImage(page) {
  // 1) 遅延読み込み対応: og:image/metaタグが出るまで短時間待機
  try {
    await page.waitForSelector(
      'script[type="application/ld+json"], meta[property="og:image"], meta[name="og:image"]',
      { timeout: 3000 }
    );
  } catch (e) {
    // タイムアウト時も先へ進める
  }

ここで行っているのは「指定されたセレクタのいずれかがページに現れるまで最大3秒待機する」という処理です。重要なのは、この waitForSelector() はあくまで待機用であって、実際の存在チェックやフラグ用途ではないということです。

以前の記事

www.n-rinko.com

ここで、「大まかな流れとして fetchElements() で全体を抽出し、そこから個別情報を取り出す」という設計思想だったのでは?とお思いの方もいるかもしれません。

たしかに、fetchElements() 側で描画の安定はある程度担保されています。しかしこれはあくまで「静的描画の完了」を意味しており、「データ注入の完了」を保証するものではありません。

そのため実際の運用では、画像URLがあとから動的に注入されるケースに備える必要があります。

ページ読み込みが一見完了しているようでも、最近の JavaScript 駆動サイトでは、<meta> タグや JSON-LD があとから追加されるパターンが多々あるのです。そこで、描画直後に waitForSelector() を使って数秒だけ猶予を設けることで、こうしたケースにも対応できるようにしています。このひと工夫により、抽出成功率を大幅に引き上げることができます。

次に行うのは、フォールバックも含めた各エレメントの抽出です。中でも最も重要なのは、タイトル抽出のときと同様、JSON-LD形式の構造化データです。

やはり、これは検索エンジン向けに設計された要素であり、情報の信頼性・安定性が高いため、ここに記載されている内容を最優先で採用します。

    // ───① JSON-LD の VideoObject から thumbnailUrl / image を探す
    const scripts = document.querySelectorAll('script[type="application/ld+json"]');
    for (const script of scripts) {
      try {
        const data = JSON.parse(script.textContent);
        const items = Array.isArray(data) ? data : [data];
        for (const item of items) {
          if (item['@type'] === 'VideoObject') {
            if (item.thumbnailUrl) {
              thumb = Array.isArray(item.thumbnailUrl) ? item.thumbnailUrl[0] : item.thumbnailUrl;
            } else if (item.image) {
              thumb = Array.isArray(item.image) ? item.image[0] : item.image;
            }
            if (thumb) return thumb;
          }
        }
      } catch {
        // JSON parse error は無視
      }
    }

 

次に行うのは、<meta> タグからの画像URL抽出です。

これは、構造化データ(JSON-LD)が存在しない場合の第2のフォールバック手段となります。特に、SNS共有用に使われる og:image や twitter:image 系のタグは、動画ページでもサムネイル指定に広く使われており、比較的信頼性の高い情報源です。こうしたタグは property 属性や name 属性で指定されるため、両方を対象にチェックを行います。

ここでも最初に見つかった時点で即座に return し、処理を完了させています。実際、多くのサイトではこの段階で妥当なサムネイル画像が取得できるため、JSON-LDに次ぐ優先度としています。

  // ───② meta タグから og:image, twitter:image 系を探す
    const meta = Array.from(document.querySelectorAll('meta'));
    for (const m of meta) {
      const name = (m.getAttribute('name') || '').toLowerCase();
      const prop = (m.getAttribute('property') || '').toLowerCase();
      const content = m.getAttribute('content') || '';
      if (!content) continue;
      if (
        name === 'og:image' ||
        prop === 'og:image' ||
        name === 'twitter:image' ||
        prop === 'twitter:image' ||
        name === 'twitter:image:src' ||
        prop === 'twitter:image:src'
      ) {
        return content;
      }
    }

 

そして最後の手段として行うのが、ページ中の <img> タグをすべて走査し、もっとも面積の大きい画像をサムネイルとみなすというフォールバックです。

構造化データも、OGP系の <meta> タグも存在しないページでは、手がかりが完全に失われてしまうため、見た目上もっとも目立つ画像=サムネイルであろうという仮定に基づいたロジックを採用しています。

ここでは naturalWidth × naturalHeight を使って画像の面積を計算し、最大サイズのものを選出します。ページ内にアイコンやバナーのような画像がいくつもある場合でも、動画のメインビジュアルは往々にして最も大きい画像であることが多いため、一定の精度が見込めます。

もちろん、完璧な方法とは言えませんが、「それでも何かは出したい」という実運用上の要請に応えるための、実践的なフォールバック手段です。

  // ───③ 最終フォールバック: ページ中の <img> のうち最大サイズのものをサムネとみなす
    let best = null;
    const imgs = document.querySelectorAll('img');
    for (const img of imgs) {
      const src = img.currentSrc || img.src;
      if (!src) continue;
      const w = img.naturalWidth || img.width || 0;
      const h = img.naturalHeight || img.height || 0;
      const area = w * h;
      if (area > (best?.area || 0)) {
        best = { src, area };
      }
    }
    return best ? best.src : null;
  });

最後に、取得した画像URLを { image: ... } というオブジェクト形式で返します。
これは後続の処理モジュール(DB登録や表示側)と整合性を取るためで、値が取得できなかった場合でも null を返すことで、「サムネイルが無い」という状態を明示的に扱えるようにしています。

このように、結果を常に同じ構造で返す設計にしておくと、呼び出し側の実装も安定し、後々の拡張にも強くなります。

以上、今回は動画ページからサムネイル画像を抽出する fetch-image.js モジュールをご紹介しました。

ページ構造がバラバラな実運用の中で、確実に画像を取得するためには「多段階のフォールバック」が不可欠です。
本モジュールでは、

  • 最優先:JSON-LD の構造化データ(VideoObject)
  • 第2候補:SNS共有向けの <meta> タグ(OGP / Twitter Card)
  • 最終手段:ページ内の <img> タグから最大サイズの画像を選出

という順序で、できるだけ確度の高い画像URLを抽出する設計にしています。

また、JavaScriptによる動的注入に対応するための短時間待機処理も組み込むことで、抽出成功率をより高めています。見た目は地味な処理ですが、サムネイルは検索結果やUIの第一印象を決定づける非常に重要な要素です。「確実に、そして適切に取得する」この小さな仕組みが、サービス全体の質を支えています。

このような地道な最適化を積み重ねながら、今日も Sae-Porns を磨いています。

※このコードが実際に使われている「追跡されないアダルト動画の検索エンジンSae-Porns」はこちら!よかったら見ていってください。(18歳未満の方はご利用できません。)

sae-porns.org