Article

ショッピング広告の商品フィード最適化テクニック

高田晃太郎
ショッピング広告の商品フィード最適化テクニック

Googleの公開資料は「高品質な商品データが広告の関連性と成果に直結する」と明言しています¹。Googleショッピング広告(Shopping Ads)でも、同一の入札戦略やクリエイティブであっても、商品フィードを見直すだけでクリック率(CTR)が有意に伸び、無効アイテム率が下がって配信機会が増え、結果としてCV獲得単価の安定が期待できる、といった報告は珍しくありません。さらに、Performance Maxのような自動化キャンペーンでは、タイトルやGTIN(国際取引商品番号)、カテゴリといったフィード属性が初期学習の手がかり(一次シグナル)になりやすく²、データの欠損や整合性の乱れは機会損失に直結します。ここでは、エンジニアリングの観点から、商品フィードを設計・生成・配信・監視という流れで捉え、SEOで整えた構造化データとも連動させながら、着実にROIを押し上げるための実装テクニックを解説します。

なぜフィード最適化がROIを左右するのか:オークションと学習の観点

Googleショッピング広告は、従来のキーワード入札というよりも、ユーザーの検索クエリと商品カタログのマッチング精度で勝負が決まります。タイトル、説明、ブランド、GTIN、Google商品カテゴリ、価格、在庫、画像品質といった属性が、クエリの意図と広告ランクの双方に作用します。特にタイトルは情報量と可読性のバランスが重要で、モバイル表示では先頭の約全角35文字(おおむね70半角文字)程度が事実上の勝負所になります³。ブランドや主要属性(色、容量、対応機種、素材、型番など)を構造化して含めると、無関係なクエリのマッチングが減り、クリック効率の改善が見込めます⁴。GTINの提供はGoogleが推奨しており、識別子が正しく入っていることで、比較やレビュー集約などの利便性を受けやすくなります⁵。さらに、オンサイトのschema.org/Product(構造化データの規格)とフィードを一致させることで、クローラやMerchant Centerの検証で不整合検知が減り、審査や価格・在庫ミスマッチのアラートを抑制できます⁶。これらは広告学習の収束を早める方向に働くため、フィード最適化はメディア費用の消化効率だけでなく、立ち上がり速度にも影響します。

SEOとWeb広告を接続するデータ一貫性

SEOで整理したプロダクトデータベースを、広告用に再利用する発想が重要です。サイトのタイトルタグやH1、構造化データで表現している属性体系を、そのままフィード生成テンプレートに写経するのではなく、検索クエリで最も判別力の高い順に並べ替え、冗長な接頭辞やノイズ語を除去します。これにより、自然検索と有料検索で一貫したメッセージを維持しつつ、広告の可読性と一致度を最大化できます。オンサイトの価格や在庫の反映は、EdgeキャッシュやCDN(コンテンツ配信ネットワーク)の遅延を考慮して、フィード生成のデータソースを決めるとよいでしょう。多くの事故は「実在庫」ではなく「表示在庫」を参照して起きます。

設計:スキーマ、命名規則、テンプレート戦略

まず、商品マスタの最小公倍数を定めます。必須属性(id、title、description、link、image_link、price、availability、brand、gtin、mpn、google_product_category)に加えて、任意属性のうち学習とセグメンテーションで効くもの(condition、age_group、gender、color、size、material、pattern、shipping、sale_price、custom_label_0〜4)を運用ポリシーとして固定化します。custom_label_0〜4はGoogle Ads側での任意ラベリングに用い、入札や予算配分の調整に役立ちます。バリアントを多く持つ業態では、バリアント軸(色・サイズ等)をタイトルに含めるか、説明に回すか、カタログ全体の平均表示文字数やSKU拡張性を基準に決めるのが合理的です。Google商品カテゴリは自前のカテゴリと1対1対応させるマッピングテーブルを用意し、言語や国ごとの差分を吸収します。禁則文字や単位表記は、全角・半角、℃/度、ml/mL、“/インチなど齟齬が出やすいため、正規化ルールを実装しておきます。

BigQueryでの正規化と重複排除の基盤

データウェアハウスに商品マスタを集約し、テンプレート生成に必要な正規化をSQLで実施します。次の例では、同一モデルの色違いを代表SKUに寄せるロジックと、カテゴリマッピングをJOINで解決しています。

-- BigQuery: バリアント代表SKUの選定とカテゴリマッピング
WITH base AS (
  SELECT 
    sku,
    model_id,
    color,
    size,
    brand,
    title_raw,
    description_raw,
    gtin,
    mpn,
    price,
    inventory,
    category_key,
    ROW_NUMBER() OVER (PARTITION BY model_id ORDER BY price ASC, sku ASC) AS rn
  FROM `proj.dataset.products`
  WHERE is_active = TRUE
), canonical AS (
  SELECT * FROM base WHERE rn = 1
)
SELECT 
  b.sku,
  c.sku AS canonical_sku,
  b.model_id,
  b.brand,
  b.color,
  b.size,
  SAFE_CAST(REGEXP_REPLACE(b.gtin, r"[^0-9]", "") AS STRING) AS gtin_norm,
  b.mpn,
  b.price,
  b.inventory,
  m.google_product_category,
  t.normalized_title,
  t.normalized_description
FROM base b
JOIN canonical c USING (model_id)
LEFT JOIN `proj.dataset.category_map` m USING (category_key)
LEFT JOIN `proj.dataset.text_norm` t USING (sku);

タイトル自動生成のテンプレートと禁則処理

テンプレートは単一ではなく、カテゴリ別に3〜5種類用意してA/Bを長期に回せるようにします。ブランド・モデル・主要属性・用途・数量といった順で可読性と判別力を両立させます。日本語は冗長語が混入しやすいため、ノイズ語辞書を用意し、記号や半角・全角のゆれを除去します。以下はPandasとJinja2でのタイトル生成の一例です。

# Python: タイトル生成(禁則処理・テンプレ対応)
import re
import pandas as pd
from jinja2 import Template

NOISE = ["送料無料", "公式", "最安値", "激安", "人気"]

def normalize(text: str) -> str:
    if not isinstance(text, str):
        return ""
    t = re.sub(r"\s+", " ", text)
    t = t.replace("ml", "mL").replace("ML", "mL").replace("㎖", "mL")
    t = t.replace("CM", "cm").replace("cm", "cm")
    for n in NOISE:
        t = t.replace(n, "")
    t = re.sub(r"[\|\[\]()\(\)]", " ", t)
    return re.sub(r"\s+", " ", t).strip()

TEMPLATES = {
    "default": Template("{{brand}} {{model}} {{attr}} {{qty}}"),
    "apparel": Template("{{brand}} {{model}} {{color}} {{size}}"),
    "electronics": Template("{{brand}} {{model}} {{compat}} {{attr}}"),
}

def build_title(row: pd.Series, key: str) -> str:
    tpl = TEMPLATES.get(key, TEMPLATES["default"]) 
    rendered = tpl.render(
        brand=normalize(row.get("brand", "")),
        model=normalize(row.get("model", "")),
        attr=normalize(row.get("attr", "")),
        qty=normalize(row.get("qty", "")),
        color=normalize(row.get("color", "")),
        size=normalize(row.get("size", "")),
        compat=normalize(row.get("compat", "")),
    ).strip()
    return re.sub(r"\s+", " ", rendered)[:70]  # モバイル考慮で安全側に短縮

# 使用例
df = pd.read_parquet("products.parquet")
df["title"] = df.apply(lambda r: build_title(r, r.get("template_key", "default")), axis=1)

生成と配信:画像品質、価格・在庫整合、API更新

画像はクリックの最大要因のひとつです。背景が白で不要余白が少なく、解像度が推奨値以上であることを担保します⁴。パッケージ商材は正面と斜めの2方向、アパレルは正面・背面・着用の順に優先度を決め、代替画像のフォールバックを用意します。価格・在庫はサイト表示と完全一致する必要があり、CDNやキャッシュの遅延を考慮してソース・オブ・トゥルース(正とするデータ)を明確にします⁶。更新はGoogle Content API for Shopping(Content API)で差分アップサートを行い、失敗時は指数バックオフで再試行します²。

画像の検証とリサイズの自動化

Pillowで最小解像度の検証と余白の追加、Web向け圧縮を行います。ファイル壊れや色空間の例外を握り潰さないように、失敗はログ化し、ステータスを商品に返します。

# Python: 画像の最小要件チェックと標準化
from PIL import Image, ImageOps
import io, os, sys

def normalize_image(src_path: str, dst_path: str, min_side: int = 1024) -> bool:
    try:
        with Image.open(src_path) as im:
            im = im.convert("RGB")
            w, h = im.size
            if min(w, h) < min_side:
                scale = min_side / min(w, h)
                im = im.resize((int(w*scale), int(h*scale)))
            im = ImageOps.contain(im, (2000, 2000))
            im.save(dst_path, format="JPEG", quality=88, optimize=True)
            return True
    except Exception as e:
        print(f"image normalize failed: {src_path}: {e}", file=sys.stderr)
        return False

GTINの検証とクレンジング

GTINは数字以外を除去し、長さとチェックデジットを検証します。欠損が多い場合は外部プロバイダや仕入れ先のマスタと突合します⁵。

# Python: GTIN-14/13/12 のチェックデジット検証
import re

def valid_gtin(code: str) -> bool:
    if not code:
        return False
    s = re.sub(r"[^0-9]", "", code)
    if len(s) not in (12, 13, 14):
        return False
    total = 0
    parity_from_right = 0
    for d in s[-2::-1]:
        n = int(d)
        total += n * (3 if parity_from_right % 2 == 0 else 1)
        parity_from_right += 1
    check = (10 - (total % 10)) % 10
    return int(s[-1]) == check

Content APIによる差分アップサート

大規模SKUでXML一括を回すとレイテンシが嵩むため、変更差分のみAPIで更新する設計は実運用で効きます。次はgoogleapisを用いたNode.jsの例です。

// Node.js: Content API での製品更新(差分)
import { google } from 'googleapis';

async function upsertProduct(auth, merchantId, product) {
  const content = google.content({ version: 'v2.1', auth });
  try {
    const res = await content.products.insert({
      merchantId,
      requestBody: product,
    });
    return res.data;
  } catch (e) {
    if (e.code === 409) {
      const res = await content.products.update({
        merchantId,
        productId: product.id,
        requestBody: product,
      });
      return res.data;
    }
    throw e;
  }
}

監視と最適化:診断、異常検知、A/Bの運用

Merchant Centerの診断は最初の防波堤ですが、ダッシュボードの目視だけでは遅れがちです。APIでproductstatusesを定期取得し、重要な拒否・警告の件数変化を検知して通知する仕組みを用意します。価格・在庫不一致、画像の不承認、ポリシー警告の増加は、学習への悪影響が大きいため優先的に対処します。A/Bはタイトルテンプレート単位で行い、外的要因を慣らすため十分な期間を確保します。custom_labelを用いて、テンプレートバージョンや在庫深度、粗利帯をセグメント化すれば、入札や予算の配分最適化にも直結します。

ステータス監視からSlack通知まで

日次でproductstatusesを取得し、拒否率や警告の増加を検知してSlackに投稿します。例外は握り潰さず、リトライ戦略を明示します。

// Node.js: productstatuses 監視とSlack通知
import { google } from 'googleapis';
import fetch from 'node-fetch';

async function fetchStatuses(auth, merchantId) {
  const content = google.content({ version: 'v2.1', auth });
  const res = await content.productstatuses.list({ merchantId, maxResults: 250 });
  return res.data.resources || [];
}

async function notifySlack(webhook, text) {
  await fetch(webhook, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
}

async function run(auth, merchantId, webhook) {
  try {
    const statuses = await fetchStatuses(auth, merchantId);
    const total = statuses.length;
    const disapproved = statuses.filter(s => (s.itemLevelIssues||[]).some(i => i.severity === 'critical')).length;
    const warnings = statuses.filter(s => (s.itemLevelIssues||[]).some(i => i.severity === 'warning')).length;
    const msg = `Feed Status: total=${total}, disapproved=${disapproved}, warnings=${warnings}`;
    await notifySlack(webhook, msg);
  } catch (e) {
    await notifySlack(webhook, `Feed monitor failed: ${e}`);
    throw e;
  }
}

オンサイト構造化データとの突合で不一致を低減

価格や在庫の不一致は、サイト側の構造化データ更新とタイミングがズレることが多いです。サイトから定期クロールでProductスキーマ(JSON-LD)を収集し、フィード生成前に差分を検証してから配信します⁶。

# Python: 構造化データ(embedded JSON-LD)とフィードの突合
import json, requests, re
from bs4 import BeautifulSoup

def fetch_product_ldjson(url: str) -> dict:
    r = requests.get(url, timeout=10)
    r.raise_for_status()
    soup = BeautifulSoup(r.text, 'html.parser')
    for tag in soup.find_all('script', type='application/ld+json'):
        try:
            data = json.loads(tag.string)
            if isinstance(data, dict) and data.get('@type') == 'Product':
                return data
        except Exception:
            continue
    return {}

ld = fetch_product_ldjson('https://example.com/p/sku123')
price_on_site = ld.get('offers', {}).get('price')
availability_on_site = ld.get('offers', {}).get('availability')
# 取得値とフィード元データを比較して差異があれば隔離・保留

ビジネスインパクト:指標設計と投資回収の見積もり

指標はクリック率(CTR)、コンバージョン率(CVR)、獲得単価(CPA)、広告費用対効果(ROAS)だけでなく、無効アイテム率、警告率、価格・在庫不一致率、商品あたりの露出クエリ数の推移も追います。フィード刷新の効果測定は、タイトルテンプレートA/Bの中央値で比較し、学習の冷え込みを避けるために段階的に展開します。実務では、タイトルの情報量を適切化しGTINを整備することでCTRが伸び、同時に不一致アラートが減ることで学習が安定し、結果としてROASが持続的に改善する傾向が報告されています³⁵。投資回収は、エンジニアリングの初期実装コストと運用の固定工数、改善による粗利増分から算出します。SKU 10万規模の小売で、CTRが10%改善、CVRが3%改善、平均CPC(1クリックあたりの費用)が一定と仮定すると、同じ予算での売上はおおよそ13%増える概算になります。粗利率30%であれば、改善幅の3〜4割が純増益に寄与します。これはあくまで仮定に基づく概算例ですが、数か月間効果が持続するなら、フィード刷新に要した数十〜数百時間の開発投資は短期間で回収可能です。

運用プロセスの定常化と権限設計

フィード生成はビルドと捉え、CIの一環として扱います。スキーマ変更はPRでレビューし、ステージングのMerchant Centerに向けてカナリア配信を行い、診断の差分を確認してから本番へ反映します。APIキーやOAuthクライアントの管理、Merchant CenterとGoogle Adsのリンク権限、価格規約に抵触しない設定値の上限下限など、ガバナンスもあらかじめ文書化します。広告チームと開発チームの役割は、テンプレートや辞書の調整を広告側が、実装と配信・監視の信頼性を開発側が担う形がワークします。これにより、戦術的な微修正を素早く回しながら、基盤の健全性を維持できます。

カテゴリマッピングをデータ駆動で保守する

Google商品カテゴリは仕様更新があるため、ハードコードではなくテーブル駆動で保守します。内部カテゴリキーを軸に、国・言語で適用を切り替え、正例・負例データから推定モデルで候補をサジェストして、最終は人手で確定する半自動フローにするとスケールします。クエリログと実際のマッチング成績を用い、カテゴリ単位でテンプレートとタイトル長を最適化する運用を続けると、季節変動に強い体制になります。

dbtでの整備例:カテゴリ候補の合意形成

分析層でカテゴリ候補を事前に出しておき、承認されたものだけが配信層に流れるようにします。dbtのモデル分離で、変更差分と責任の所在が明確になります。

-- dbtモデル: カテゴリ候補と最終承認の分離
WITH candidates AS (
  SELECT sku, category_key,
         APPROX_TOP_COUNT(ngram, 1)[OFFSET(0)].value AS top_token
  FROM `proj.dataset.query_tokens` WHERE dt >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
  GROUP BY sku, category_key
),
approved AS (
  SELECT category_key, google_product_category
  FROM {{ ref('category_approved') }}
)
SELECT p.sku, a.google_product_category
FROM candidates p
JOIN approved a USING (category_key);

実装を成功させるための現実解:小さく始めて、大きく外さない

最初から全SKUを刷新するのではなく、代表カテゴリでテンプレートを試し、勝ち筋を見つけてから横展開するのが安全です。タイトルや説明、画像の改善に加え、価格・在庫の整合性とAPI安定性が担保されて初めて成果が定着します。エラー時のフォールバック(最新安定版の維持、差分のみロールバック)、監視の冗長化(API監視+ページ監視)、データ辞書のバージョニングといった信頼性の作法が、中長期での学習の安定に効きます。組織面では、広告の意思決定を速めるために、テンプレートと辞書の変更をデータドリブンに小刻みに回せる合意フローを設計します。

Cloud Functionsでの定時生成と配信の一例

クラウドのスケジューラから関数を起動し、最新データでフィードを生成・差分更新します。例外時は再試行し、失敗は障害として扱います。

# Python (Cloud Functions): フィード生成と配信のオーケストレーション
import os, json, time
from google.cloud import bigquery
from google.oauth2 import service_account
import requests

PROJECT = os.environ.get('GCP_PROJECT')
MERCHANT_ID = os.environ.get('MERCHANT_ID')
CONTENT_API_TOKEN = os.environ.get('CONTENT_API_TOKEN')

bq = bigquery.Client()

def fetch_feed_rows():
    q = """
    SELECT sku AS id, title, description, link, image_link, price, availability, brand, gtin, mpn, google_product_category
    FROM `proj.dataset.feed_ready`
    """
    return [dict(r) for r in bq.query(q).result()]

def content_api_upsert(item):
    url = f"https://shoppingcontent.googleapis.com/content/v2.1/{MERCHANT_ID}/products"
    headers = {"Authorization": f"Bearer {CONTENT_API_TOKEN}", "Content-Type": "application/json"}
    for attempt in range(3):
        r = requests.post(url, headers=headers, data=json.dumps(item), timeout=10)
        if r.status_code in (200, 201):
            return True
        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        raise RuntimeError(f"Upsert failed: {r.status_code} {r.text}")
    return False

def main(request):
    rows = fetch_feed_rows()
    ok = 0
    for row in rows:
        if content_api_upsert(row):
            ok += 1
    return (json.dumps({"updated": ok, "total": len(rows)}), 200, {"Content-Type": "application/json"})

まとめ:データで勝つショッピング広告へ

フィードは広告のクリエイティブであると同時に、機械学習の燃料でもあります。ブランドや主要属性を構造化したタイトル、正確なGTINとカテゴリ、整った画像、価格・在庫の整合性、そして迅速な配信と堅牢な監視。これらが噛み合うと、入札やキャンペーン構成の巧拙に先立って、露出の質と量が底上げされます。エンジニアリング投資は、テンプレートの内製化とデータ基盤の整備から始められます。まずは代表カテゴリで小さく検証し、勝ちパターンを辞書とテンプレートに刻み、API配信と監視をCIに組み込んでいきましょう。次の四半期に向けて、あなたの組織はどの属性から整えますか。タイトルの一行、画像の一枚、GTINの一桁が、ROIの曲線を確かに変えていきます。

参考文献

  1. Google Merchant Center ヘルプ: 商品データの質を高める方法(高品質な商品データの提供)
  2. Google 広告ヘルプ: Performance Max の最適化(商品データフィードの強化と更新の推奨)
  3. Google Merchant Center ヘルプ: ショッピング広告のデータ最適化(タイトルは先頭の約70文字が表示)
  4. Google Merchant Center ヘルプ: ショッピング広告のベスト プラクティス(タイトルに重要属性、画像品質、更新の推奨)
  5. Google Merchant Center ヘルプ: GTIN(JAN)を指定する(正しいGTINは利便性とパフォーマンス向上に寄与)
  6. Google Merchant Center ヘルプ: 構造化データに関するガイドライン(ページ表示値との一致要件)