食品の価格はローカルです。同じ卵1パックでも、郊外の店舗、都心の店舗、そして同じ棚の商品に独自のマークアップを乗せるデリバリーアプリとで、それぞれ価格が異なることがあります。地域の競合に合わせて価格を決める、メーカーのプロモーションを確認する、地域別の食品インフレを追うといった目的には、全国平均の1つの数字ではなく、対象となる各都市の価格が必要です。これはプライスインテリジェンスの中核となる業務であり、食品は一般的な小売よりも難易度が高い分野です。
このガイドでは、食品価格がどこで変わるのか、デリバリーアプリが表示する店舗をどう決めているのか、そして各都市を現地の買い物客と同じように見るための収集方法を解説します。
食品価格が変わるポイント
スーパーが1つの価格表だけで運営していることはほとんどありません。一般的には次のような層があります。
- 価格ゾーン。 チェーンは地域の競争状況やコストに応じて店舗をゾーンに分けています。数キロしか離れていない同じチェーンの2店舗が、別のゾーンに属していることもあります。
- 店舗単位のプロモーション。 週替わりのチラシやポイント会員向けの特典は地域によって異なることが多く、一部の店舗だけで有効なセールもあります。
- デリバリーアプリのマークアップ。 サードパーティのアプリでは、店頭価格より高い価格が表示されることがあり、さらに地域や時間帯によって変わるサービス料や配送料がかかります。
- 在庫状況。 ある店舗では在庫があっても別の店舗では欠品していることがあり、バスケットの実際の金額が変わります。
- 容量とプライベートブランド。 同じ商品ラインでも地域によって容量が異なる場合があり、単価で比較しないと価格変更が見えなくなります。
全国を1回スクレイピングするだけでは、これらがすべて混ざり、誤解を招く1つの数字になってしまいます。記録するすべての価格に、店舗、都市、チャネルを紐づける必要があります。
デリバリーアプリと食品サイトが店舗を選ぶ仕組み
多くの食品サイトは、ユーザーの居場所がわかるまで全商品を表示しません。判断はおおよそ次の順で行われます。
- 保存済みの店舗または住所。 Cookieやアカウントから読み取ります。
- 入力した住所や郵便番号。 店舗選択画面や配送エリアの確認で入力したものです。
- IPアドレスの位置情報。 何も選択していない段階での最初の推測に使われます。
これには実務上の意味があります。IPは正しい都市に入り、最初の推測を目的の店舗の近くに当てるためのものです。郵便番号単位での正確な店舗は、サイトに入力した住所で決まります。そのため確実な方法は両方を行うことです。対象都市のレジデンシャルIPを使い、同じセッション内で店舗または配送先住所を入力して、両者が食い違わないようにします。IPと選択した場所の不一致がサイトの表示内容をどう変えるかは、都市レベルのプロキシターゲティングが重要になる場面の記事で解説しています。
バスケットを作る
収集を始める前に、比較する対象を固定しましょう。
- 固定バスケット。 50〜300品目で、定番商品、自社の売れ筋、競合が最も力を入れて販促している商品を含めます。
- 商品ごとの照合キー。 サイトがバーコード(GTIN)を公開していればそれを使い、なければブランド、商品名、容量で照合し、一度だけ手作業で確認します。
- 単価。 店頭価格と並べて1kgあたり、1リットルあたり、1個あたりの単価を記録し、容量の変更が価格変更に見えないようにします。
- 都市ごとの店舗リスト。 自社にとってその都市を代表する店舗と配送エリアを決めます。
バスケットは固定しておきましょう。毎週入れ替えると、トレンドが読み取れなくなります。
都市ごとに収集する
都市ごとに、その都市を指定したレジデンシャルプロキシ経由でセッションを開き、サイト上で店舗を設定してからバスケットを読み取ります。スティッキーセッションIDを使ってセッション全体で同じIPを維持し、買い物客が途中で場所を移動したようにサイトから見えないようにします。
customer-USERNAME-country-us-city-chicago-sid-chi-store-118-ttl-900:PASSWORD
セッションID(sid)は、ttl で指定した秒数まで同じ出口IPを維持します。店舗ごとに1つのIDを使えば、各店舗への訪問が1人の買い物客のように見えます。大きな市場では、国全体を1か所から読み取るのではなく、主要都市に作業を分散させましょう。たとえばアメリカのページにあるシカゴ、ダラス、アトランタなどです。
Pythonでの最小限のループは次のとおりです。
import requests
GATEWAY = "p.shifter.io:443"
USER, PASS = "USERNAME", "PASSWORD"
def session_for(city, store_id):
s = requests.Session()
proxy = f"http://customer-{USER}-country-us-city-{city}-sid-{city}-{store_id}-ttl-900:{PASS}@{GATEWAY}"
s.proxies = {"http": proxy, "https": proxy}
return s
def read_basket(city, store_id, products):
s = session_for(city, store_id)
# 1. Set the store the way the site expects (store picker or address check).
s.post("https://grocer.example/api/store/select", json={"storeId": store_id}, timeout=30)
rows = []
for p in products:
r = s.get(f"https://grocer.example/api/product/{p['sku']}", timeout=30)
if r.status_code != 200:
rows.append({**p, "city": city, "store": store_id, "error": r.status_code})
continue
d = r.json()
rows.append({**p, "city": city, "store": store_id,
"price": d.get("price"), "unit_price": d.get("unitPrice"),
"promo": d.get("promotion"), "in_stock": d.get("available")})
return rows
ここでのエンドポイントはプレースホルダーです。多くの食品サイトは、店舗が設定された後にJSONエンドポイントから価格を読み込みます。そのエンドポイントを読み取るほうが、ページを解析するよりも軽く、安定しています。
デリバリーアプリを読み取る
デリバリーアプリでは、アプリ内の商品価格と手数料という2つの要素を分けて記録する必要があります。次の項目を取得しましょう。
- アプリ内の商品価格。 スーパー自身のサイトの店頭価格と並べて記録し、マークアップを測ります。
- サービス料と配送料。 手数料は注文金額によって変わることが多いため、固定のバスケット金額で記録します。
- 収集時刻。 手数料と在庫状況は1日の中で変動するためです。
比較は同じ条件で行いましょう。同じ店舗、同じバスケット、同じ時間帯です。
データをきれいに保つ
- すべての価格を都市、店舗、チャネル、時刻、単価とともに保存する。 店舗がわからなければ、変化の理由を説明できません。
- プロモーションは平均に混ぜずにフラグを立てる。 2週間のセールは値下げではありません。
- 欠品に注意する。 価格が空欄の場合は在庫切れであることが多く、それ自体がシグナルです。
- パーソナライズされた価格を確認する。 ログイン中の会員価格はゲスト価格と異なることがあります。会員価格を調べるのでなければ、ゲストとして収集しましょう。両者を切り分ける方法は、地域別にパーソナライズされた価格とダイナミックプライシングを検出する方法のガイドで紹介しています。
- 公開されている価格だけを収集する。 控えめなペースで実行し、各サイトの利用規約を守りましょう。
まとめ
食品の価格は、都市、店舗、チャネルが紐づいて初めて意味を持ちます。バスケットを固定し、各都市を代表する店舗を決め、その都市にあるレジデンシャルIPから各都市に入り、同じセッション内でサイト上の店舗を設定し、単価、プロモーション、手数料を分けて記録しましょう。定番商品は週1回、競争の激しい商品は毎日実行すれば、ぼやけた全国平均の数字ではなく、地域の価格変動をリアルタイムで把握できます。