qr-send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

Seleniumで商品ページの本命画像を1枚選ぶ:複数の画像候補をスコアリングして決定する

Selenium・自動化

「ページ内で一番大きい画像」だけでは本命は選べない

商品ページやブログ記事のようなページから「この記事の代表画像」を自動で1枚選び出したい、という場面があります。最初に思いつくのは「一番サイズが大きいimgタグを選ぶ」というやり方ですが、これだけだと広告バナー、ロゴ、関連商品のサムネイル一覧など、本来欲しいものではない画像を拾ってしまうことが少なくありません。逆にog:imageメタタグだけを信用する方法にすると、そのタグが古い画像を指していたり、そもそも設定されていないページで失敗します。

つまり単一のルールでは、どこかのページで必ず外れてしまいます。そこで有効なのが、候補を1つに決め打ちするのではなく、ページ内のすべての画像候補をいったん広く集めて、それぞれに複数の観点からスコアを付け、最高得点のものを採用するというアプローチです。DOM上の情報を直接見て計算する必要があるため、execute_scriptでブラウザ内にJavaScriptを注入して処理します。

候補を広く集める

まずog:imageのようなメタタグと、imgタグのsrcdata-src系の遅延読み込み属性・srcsetまで、画像を指しうる属性を広く集めます。1種類の属性だけに頼ると、遅延読み込みの実装差で本命の画像を取りこぼすことがあるためです。

def extract_candidate_images(driver)
  driver.execute_script(<<~JS)
    const parseSrcset = (value) => (!value ? [] :
      value.split(',').map((part) => part.trim().split(/\\s+/)[0]).filter(Boolean));

    const candidates = [];

    document.querySelectorAll('meta[property="og:image"], meta[name="twitter:image"]')
      .forEach((meta) => candidates.push({ url: meta.getAttribute('content'), fromMeta: true }));

    document.querySelectorAll('img[src], img[data-src], img[data-lazy], picture source[srcset]')
      .forEach((node) => {
        const rect = node.getBoundingClientRect ? node.getBoundingClientRect() : { width: 0, height: 0 };
        const values = new Set([
          node.getAttribute('src'),
          node.getAttribute('data-src'),
          node.getAttribute('data-lazy')
        ].filter(Boolean));
        parseSrcset(node.getAttribute('srcset')).forEach((u) => values.add(u));

        values.forEach((url) => candidates.push({
          url,
          fromMeta: false,
          className: node.className || '',
          alt: node.getAttribute('alt') || '',
          displayWidth: rect.width || 0,
          displayHeight: rect.height || 0,
          naturalWidth: node.naturalWidth || 0,
          naturalHeight: node.naturalHeight || 0
        }));
      });

    return candidates;
  JS
end

ここでimg本体だけでなくmetaタグも同じ配列に混ぜて候補にしている点がポイントです。あとの採点処理では「候補の出自がどこか」を区別せず横並びで比較し、最後にメタタグ由来かどうかを加点要素の1つとして使うだけにしています。出自によって別ロジックを組むのではなく、同じ土俵でスコアを比較する方が、結果的にコードがシンプルになります。

URLパターン・クラス名・altテキストでスコアを付ける

候補が集まったら、それぞれに加点・減点していきます。ポイントは「本命らしい特徴」に加点し、「ノイズらしい特徴」には大きめの減点をすることです。ロゴやアイコン、サムネイル一覧、バナーなどは、URLやクラス名に特徴的な単語が含まれることが多いので、まずそこで弾きます。

function scoreCandidate(candidate) {
  let score = 0;
  const url = candidate.url || '';
  const text = [candidate.className, candidate.alt].join(' ').toLowerCase();

  // URLパターンによる加点・減点
  if (/product|item|main|gallery/i.test(url)) score += 30;
  if (/thumb|thumbnail|icon|logo|sprite|banner/i.test(url)) score -= 150;

  // クラス名・altテキストによる加点・減点
  if (/main|hero|gallery|product-image/i.test(text)) score += 100;
  if (/thumb|thumbnail|nav|pager|carousel|related|recommend/i.test(text)) score -= 120;
  if (/logo|icon|banner|ad|advertisement/i.test(text)) score -= 150;

  // 表示サイズ・実サイズによる加点(小さすぎる画像はまず候補として弱い)
  if (candidate.displayWidth >= 300 || candidate.displayHeight >= 300) score += 40;
  if (candidate.naturalWidth >= 600 && candidate.naturalHeight >= 600) score += 20;
  if (candidate.displayWidth > 0 && candidate.displayWidth < 150) score -= 60;

  // アスペクト比が極端なもの(横長バナーや縦長ナビ)を減点
  const aspect = candidate.naturalWidth > 0 && candidate.naturalHeight > 0
    ? candidate.naturalWidth / candidate.naturalHeight
    : 1;
  if (aspect < 0.3 || aspect > 3.0) score -= 100;

  // メタタグ由来は一定の信頼を置く
  if (candidate.fromMeta) score += 10;

  return score;
}

減点の値を加点より大きめに設定しているのは意図的です。「本命らしさ」の判定基準は多少甘くても、後段の面積スコアで補える一方、「ノイズらしさ」の判定を甘くすると、サイズだけが大きいバナー画像がそのまま選ばれてしまいます。ノイズ判定のほうを強めに効かせておくのが、この方式全体の安全弁になっています。

面積とスコアを組み合わせて最終順位を決める

サイズだけでも、パターンスコアだけでも一長一短があります。サイズだけで決めると巨大な背景画像に負けますし、パターンスコアだけで決めると同じスコアの候補が並んだときに決め手がありません。実用上は「面積の対数」と「パターンスコア」を足し合わせた合成スコアで順位付けすると、極端に大きいだけの背景画像や、極端に小さいアイコンの両方を排除しつつ、それらしいサイズと文脈を持つ画像を優先できます。

function pickBestImage(candidates) {
  const scored = candidates
    .filter((c) => c.url && !String(c.url).startsWith('data:'))
    .map((c) => {
      const area = Math.max((c.naturalWidth || 0) * (c.naturalHeight || 0), 1);
      return { ...c, composite: Math.log(area) * 30 + scoreCandidate(c) };
    })
    .sort((a, b) => b.composite - a.composite);

  return scored.length ? scored[0].url : null;
}

面積を対数で扱っているのは、単純な面積の差(例えば1000×1000と2000×2000)が合成スコアに与える影響を抑えるためです。面積を線形のまま足すと、クラス名やaltでどれだけノイズと判定されても、単に画素数が大きいというだけで上位に来てしまい、減点ロジックがほとんど意味を持たなくなります。対数を挟むことで、面積の効果を「大まかな目安」程度に弱め、パターンによる加点・減点が結果を左右できるようにしています。

Ruby側の呼び出しとフォールバック

ここで1つ注意が必要なのは、候補の収集・採点・選定という3段階の処理を、Ruby側とJS側で分割してはいけないということです。execute_scriptはJSON化できる値しか返せないため、収集したDOM要素の参照をRubyに戻してから改めてスコアを計算する、というような往復はできません。そのため実際にはextract_candidate_imagesscoreCandidatepickBestImageの3つを1本のJS文字列にまとめ、ブラウザ内で収集から選定までを一括して完了させてから、最終的なURLだけをRubyに返すという構成にします。

def extract_best_image_url(driver)
  result = driver.execute_script(build_scoring_script)
  result.to_s.empty? ? nil : result
rescue => e
  warn "画像抽出でエラー: #{e.message}"
  nil
end

build_scoring_scriptは、これまでの3つの処理をつなぎ合わせた1本のJSコードを組み立てて返すメソッドです。呼び出し側からは中身を意識せず、最終的なURL文字列(または見つからなければnil)だけを受け取れる形にしておくと、呼び出し側のコードが単純になります。抽出結果が空だった場合や、ページ構造が想定と大きく違っていて例外が飛んだ場合は、nilを返すだけにして、呼び出し元で「本命画像が取れなかったとき用の代替処理」に委ねます。

まとめ

  • 画像候補はog:imageだけ、img[src]だけに頼らず、遅延読み込み属性やsrcsetまで広く集める
  • URLパターン・クラス名・altテキストで「ノイズらしさ」を強めに減点し、「本命らしさ」を加点する。減点を強めにするのは、サイズだけで押し切られる誤選定を防ぐための安全弁
  • サイズは対数で効かせて合成スコアに混ぜることで、単純な画素数勝負にならないようにする
  • execute_scriptはJSON化できる値しか返せないため、収集・採点・選定はJS側で一括して行い、Rubyには最終結果だけを戻す
  • 抽出結果が空だったときのフォールバックをRuby側に用意しておくと、想定外のページ構造でも落ちにくくなる

コメント

タイトルとURLをコピーしました