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;
}

面積を対数で扱っているのは、単純な面積の差が合成スコアに与える影響を抑えるためです。線形のまま足すと、クラス名やaltでどれだけノイズ判定されても、単に画素数が大きいというだけで上位に来てしまい、減点ロジックがほとんど意味を持たなくなります。対数を挟むことで、面積の効果を「大まかな目安」程度に弱め、パターンによる加点・減点で結果を左右できるようにしています(naturalWidthnaturalHeightは画像の読み込みが終わっていないと0のまま返ってくることがあり、私の環境でもたまに起きました。表示直後に呼ぶ場合は気をつけてください)。

ただ、この採点方式も万能ではありません。URLやクラス名がたまたま紛らわしい単語を含んでいると、狙った画像を取りこぼすことがあります。それでも、単一ルールだけに頼るよりは体感でだいぶ外れが減りました。

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)だけを受け取れる形にしておくと、呼び出し側のコードが単純になります。https://で始まるURLが返ってきたら成功です。抽出結果が空だったり、ページ構造が想定と大きく違っていて例外が飛んだりした場合はnilを返すだけにして、呼び出し元の「本命画像が取れなかったとき用の代替処理」に委ねましょう。

というわけで、たった1枚の代表画像を選ぶだけでも、ここまで考えることがあります。同じような画像選定で困っている方がいたら、加点・減点の重みを自分のサイトに合わせて調整してみてください。

コメント

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