1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

車種詳細ページに「売れ筋ランキング」を埋め込んだら、ユーザーの行動が変わった📊

1
Posted at

車種詳細ページに「売れ筋ランキング」を埋め込んだら、ユーザーの行動が変わった📊

はじめに

バイクポータルサイト「MotoHub」を個人開発しています。

売れ筋ランキングページ(/ranking)を作ったはいいけど、わざわざランキングを見に行くユーザーは少ない

Google Analyticsを見ると、一番アクセスがあるのは車種詳細ページ/bikes/honda/super-cub-110 みたいなページ)。ここに約4,888車種分のページがあって、DAUの大半がここに来ている。

「ランキングを見に来てもらう」のではなく、「ユーザーが見ているページにランキングを持っていく」。

車種詳細ページに売れ筋データを埋め込んだ実装の全貌です。


環境

さくらVPS 4GB
├── Docker Compose
│   ├── PHP-FPM 8.3(Laravel 12)
│   ├── MySQL 8(listings: 228,691件)
│   └── Redis Alpine(キャッシュ → ここが重要)
└── Cloudflare CDN

ページ数:
├── 車種詳細: 約4,888ページ ← ここに埋め込む
├── 車両詳細: 約115,000ページ
└── 売り切れデータ: 112,863件 ← ここから集計

設計:「チラ見せ」と「詳細」の2段構え

車種詳細ページのスクショを見ながら設計しました。

image.png

①ヒーローエリアに順位だけ「チラ見せ」

既存の統計ボックス(中古平均価格 / 販売中台数 / オーナー評価)の横に、4つ目のボックスとして「🏆 売れ筋 ○位」を追加。

目に入る場所に置くのが大事。 ページの一番上に「この車種は全車種中2位」って書いてあったら、「人気あるんだ」と一瞬で伝わる。

image.png

②概要セクション直後に「ランキング詳細ブロック」

スペックテーブルの前に、2×2のグリッドカードを配置。

🏆 スーパーカブ110の売れ筋ランキング
┌──────────────────┬──────────────────┐
│ 総合ランキング    │ カテゴリ内ランキング│
│ 全車種中 2位     │ ミニバイクで 1位   │
├──────────────────┼──────────────────┤
│ 月間販売台数     │ 平均売却日数       │
│ 178台            │ 9日               │
└──────────────────┴──────────────────┘
📊 売れ筋ランキングを見る →
📈 スーパーカブ110の詳しい分析 →

image.png


実装①:全車種の順位を1クエリ + キャッシュ

4,888ページそれぞれで「この車種は何位?」を知る必要がある。毎リクエストで全車種集計していたら死ぬので、1日1回集計 → Redisキャッシュ

// Service or Controller

$rankingData = Cache::remember('ranking_positions', 86400, function () {
    return DB::table('listings')
        ->where('is_sold_out', true)
        ->where('updated_at', '>=', now()->subDays(30))
        ->whereNotNull('bike_model_id')
        ->select('bike_model_id', DB::raw('COUNT(*) as sold_count'))
        ->groupBy('bike_model_id')
        ->orderByDesc('sold_count')
        ->get()
        ->values()
        ->mapWithKeys(fn ($item, $index) => [
            $item->bike_model_id => [
                'rank'       => $index + 1,
                'sold_count' => $item->sold_count,
            ]
        ]);
});

ポイント: mapWithKeysbike_model_id => [rank, sold_count] のマップに変換。これで $rankingData[$bikeModel->id] でO(1)アクセスできる。

パフォーマンス比較

方式 所要時間 備考
毎リクエストでCOUNT集計 300〜500ms 4,888ページ × 全リクエスト = 死
キャッシュあり(初回) 約200ms 30日分の集計
キャッシュヒット < 1ms Redis最高

1日1回の集計で十分。 ランキングが1時間ごとに変わるわけじゃないし、DAU150〜200の段階でリアルタイム性は不要。


実装②:カテゴリ内順位

「全車種中2位」だけだと情報が足りない。「ミニバイクの中では1位」みたいなカテゴリ内順位も出したい。

$categoryRank = Cache::remember(
    "ranking_category_{$bikeModel->category_id}",
    86400,
    function () use ($bikeModel) {
        $models = DB::table('listings')
            ->join('bike_models', 'listings.bike_model_id', '=', 'bike_models.id')
            ->where('listings.is_sold_out', true)
            ->where('listings.updated_at', '>=', now()->subDays(30))
            ->where('bike_models.category_id', $bikeModel->category_id)
            ->whereNotNull('listings.bike_model_id')
            ->select('listings.bike_model_id', DB::raw('COUNT(*) as sold_count'))
            ->groupBy('listings.bike_model_id')
            ->orderByDesc('sold_count')
            ->get();

        $position = $models->search(fn ($item) => $item->bike_model_id === $bikeModel->id);
        return $position !== false ? $position + 1 : null;
    }
);

カテゴリごとにキャッシュキーを分けているので、キャッシュの粒度は ranking_category_{category_id}。カテゴリ数は20種類くらいなので、キャッシュの量は問題なし。


実装③:平均売却日数

「この車種は掲載されてから平均何日で売れるか」。ユーザーにとっては「早く買わないと売れちゃう」という判断材料になる。

$avgDays = Cache::remember(
    "avg_sell_days_{$bikeModel->id}",
    86400,
    function () use ($bikeModel) {
        return (int) DB::table('listings')
            ->where('bike_model_id', $bikeModel->id)
            ->where('is_sold_out', true)
            ->where('updated_at', '>=', now()->subDays(90))
            ->selectRaw('AVG(DATEDIFF(updated_at, created_at)) as avg_days')
            ->value('avg_days');
    }
);

sold_at カラムがない問題

MotoHubのlistingsテーブルには sold_at(売れた日時)がありません。クローラーが在庫を取得して、次のクロールで在庫から消えていたら is_sold_out = true にしています。なので updated_at ≒ 売れたタイミングの近似値。

完璧ではないけど、「Z900RSは平均7日で売れる」「ジョルノは平均6日」みたいな傾向は十分つかめます。


実装④:バッジで視覚的に目立たせる

数字だけだと素通りされるので、バッジで強調します。

{{-- TOP3バッジ --}}
@if($rank <= 3)
    @php $medals = [1 => '🥇', 2 => '🥈', 3 => '🥉']; @endphp
    <span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-xs font-bold px-2 py-1 rounded">
        {{ $medals[$rank] }} トップ3!
    </span>
@elseif($rank <= 10)
    <span class="inline-flex items-center bg-blue-100 text-blue-800 text-xs font-bold px-2 py-1 rounded">
        🏆 TOP10
    </span>
@endif

{{-- 即売れバッジ --}}
@if($avgDays && $avgDays <= 14)
    <span class="inline-flex items-center bg-red-100 text-red-800 text-xs font-bold px-2 py-1 rounded">
        ⚡ 即売れ!
    </span>
@endif

{{-- 人気車種バッジ --}}
@if($soldCount >= 100)
    <span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-bold px-2 py-1 rounded">
        🔥 人気車種
    </span>
@endif

image.png


実装⑤:データがない車種の扱い

販売実績がゼロの車種(新モデルやマイナー車種)もある。ランキングデータがないのにセクションを表示すると、空っぽのカードが出て見栄えが悪い。

解決: データがなければセクション全体を非表示。

@if(isset($rankingData[$bikeModel->id]) && $rankingData[$bikeModel->id]['sold_count'] > 0)
    {{-- ランキングブロック全体 --}}
    <div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
        ...
    </div>
@endif

学び: 中途半端な表示はしない。データがある車種にだけ出す方が、ランキング情報の信頼性も上がる。


実装⑥:ランキングページへの導線

ランキングブロックの下に、ランキングページ(/ranking)と車種別分析ページ(/ranking/model/{id})へのリンクを設置。

<div class="flex flex-wrap gap-3 mt-4">
    <a href="{{ route('ranking.index') }}"
       class="text-blue-600 hover:text-blue-800 text-sm font-bold">
        📊 売れ筋ランキングを見る →
    </a>

    @if($hasModelStats)
        <a href="{{ route('ranking.model_stats', $bikeModel->id) }}"
           class="text-blue-600 hover:text-blue-800 text-sm font-bold">
            📈 {{ $bikeModel->name }}の詳しい分析 →
        </a>
    @endif
</div>

車種別分析ページが存在する車種(販売実績TOP100)だけリンクを表示。全車種にリンクを出すと、リンク切れになるので注意。


踏んだ罠:N+1クエリ

最初の実装では、Viewの中でキャッシュを取りに行っていました。

{{-- Bad: View内でキャッシュ取得 → 4,888ページ分のキャッシュアクセス --}}
@php
    $rank = Cache::get("ranking_positions")[$bikeModel->id] ?? null;
@endphp

これ自体はRedisだから速いんですが、コントローラーでまとめて取得してViewに渡す方がきれい

// Good: コントローラーで全部準備してViewに渡す
$rankingData = Cache::remember('ranking_positions', 86400, fn() => ...);
$modelRanking = $rankingData[$bikeModel->id] ?? null;

return view('bikes.model_detail', compact('bikeModel', 'modelRanking', ...));

Before / After

image.png

image.png

Before After
表示情報 平均価格・台数・評価 + 売れ筋順位・売却日数・カテゴリ内順位
ランキングページへの導線 なし あり(2リンク)
購入判断の材料 価格のみ 人気度・売却スピードも分かる

なぜこれが差別化になるのか

GooBikeの車種ページを見ると、表示されているのは「在庫一覧」と「スペック」だけ。「この車種がどれくらい人気なのか」は分からない。

MotoHubは:

  • 「全車種中○位」→ 人気の相対的な位置がわかる
  • 「月間○台売れている」→ 需要の大きさが数字でわかる
  • 「平均○日で売れる」→ 迷ってたら売れちゃうという切迫感
  • 「カテゴリ内○位」→ 同ジャンルでの比較ができる

「在庫を並べる」だけのサービスと、「市場データで購入判断を支援する」サービスの違い。 これはデータの蓄積がないと真似できない。


まとめ

実装 技術 キャッシュTTL
全車種順位 MySQL COUNT + mapWithKeys 24時間
カテゴリ内順位 JOINでカテゴリ絞り 24時間
平均売却日数 DATEDIFF(updated_at, created_at) 24時間
バッジ表示 Blade条件分岐 -
データなし非表示 @if(isset) -

車種詳細4,888ページ全てにランキングデータが自動で反映される。 DBが毎日更新されるので、ランキングも毎日変わる。手動更新ゼロ。


前回の記事:売り切れデータ11.2万件から「売れ筋ニュース」を毎朝自動生成してXに投稿する仕組みを作った

🏍 MotoHub: https://motohub.jp
X: https://x.com/motohub_jp
GitHub: https://github.com/ausssxi/MotoHub

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?