2
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?

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

2
Last updated at Posted at 2026-04-08

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

はじめに

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

MotoHubのDBには22.8万台の中古バイクデータがあって、そのうち11.2万件は is_sold_out = 1(売り切れ)です。普通は売れたら消すけど、MotoHubは全部残している

「このデータ、ニュースにしたら面白くない?」

ということで、毎朝6時に売れ筋ランキングニュースを自動生成して、Xにも自動投稿する仕組みを作りました。手動作業ゼロです。

image.png


環境

さくらVPS 4GB
├── Docker Compose
│   ├── PHP-FPM 8.3(Laravel 12)
│   ├── MySQL 8
│   │   └── listings: 228,691件
│   │       ├── アクティブ: 115,828件
│   │       └── 売り切れ: 112,863件 ← これが主役
│   ├── Redis Alpine(キャッシュ)
│   └── Meilisearch
├── Cloudflare CDN
└── Twitter API v2(abraham/twitteroauth)

なぜ「自動ニュース」なのか

正直に言うと、きっかけはDAUの伸び悩みです。

MotoHubのDAUは150〜200人で安定していたんですが、Googleのコアアップデート(3/27開始)で週間ユーザーが35%減(1,600→1,043)しました。

image.png

検索流入に依存するのは危険。自分でコンテンツを生み出して、SNSからも流入を作らないといけない。

でも本業ありの個人開発で、毎日ニュース記事を書く時間なんてない。

だったら自動生成すればいい。 しかもMotoHubには他のメディアが持っていない「実際に売れたデータ」がある。


設計:3種類のランキングニュース

タイプ 頻度 内容 ニュースURL例
daily 毎日6:00 昨日のTOP10 + 前日比変動 /news/518
weekly 毎週月曜6:30 先週のTOP10 + 前週比 /news/519
monthly 毎月1日7:00 先月のTOP20 + is_featured=true /news/520

月間ランキングだけ is_featured = true にして、ニュース一覧の「注目のニュース」セクションに表示されるようにしています。

image.png


実装①:販売データの集計

コアのクエリはシンプルです。is_sold_out が期間内に true になったListingを bike_model_id でグルーピング。

// app/Console/Commands/GenerateRankingNews.php

$startDate = match($type) {
    'daily'   => now()->subDay()->startOfDay(),
    'weekly'  => now()->subWeek()->startOfWeek(),
    'monthly' => now()->subMonth()->startOfMonth(),
};

$endDate = match($type) {
    'daily'   => now()->subDay()->endOfDay(),
    'weekly'  => now()->subWeek()->endOfWeek(),
    'monthly' => now()->subMonth()->endOfMonth(),
};

$rankings = Listing::where('is_sold_out', true)
    ->whereBetween('updated_at', [$startDate, $endDate])
    ->whereNotNull('bike_model_id')
    ->select('bike_model_id', DB::raw('COUNT(*) as sold_count'))
    ->groupBy('bike_model_id')
    ->orderByDesc('sold_count')
    ->limit($limit)
    ->get();

注意: sold_at カラムは持っていないので、updated_at で代用しています。is_sold_outtrue になったタイミング ≒ 売れたタイミング。完璧ではないけど、ランキングの傾向を掴むには十分です。


実装②:前期間との比較で順位変動を出す

ランキングが面白くなるのは**「前回からの変動」**があるとき。

「レブル250が3ランクアップ!」とか「Z900RSが初ランクイン!」みたいな情報があると、ただの数字の羅列じゃなくなる。

// 前期間のランキングを取得して、bike_model_id => 順位 のマップを作る
$prevRankings = $this->getPreviousPeriodRankings($type, $startDate);

foreach ($rankings as $rank => $item) {
    $currentRank = $rank + 1;
    $prevRank = $prevRankings[$item->bike_model_id] ?? null;

    $change = match(true) {
        $prevRank === null              => 'NEW',     // 前回圏外
        $prevRank === $currentRank      => '=',       // 変動なし
        $prevRank > $currentRank        => '+' . ($prevRank - $currentRank),  // ランクアップ
        default                         => '-' . ($currentRank - $prevRank),  // ランクダウン
    };
}

表示は色分けで直感的に:

  • ↑3(緑): 3ランクアップ
  • ↓2(赤): 2ランクダウン
  • →(グレー): 変動なし
  • NEW(オレンジ): 前回圏外からランクイン

image.png


実装③:自動コメント生成が意外と大事

最初はランキングの数字だけ出していたんですが、読み物として面白くない

そこで、条件ベースの自動コメントを付けました。

$comment = match(true) {
    $rank === 1 && $prevRank === 1 => '連続1位!圧倒的な人気を維持',
    $rank === 1 && $prevRank !== 1 => '今期ついに1位に!',
    $rankChange >= 5               => "{$rankChange}ランクの大幅ジャンプ!注目度急上昇",
    $rankChange >= 3               => '人気上昇中!',
    $rankChange <= -5              => '大幅ダウン…在庫減少の影響か',
    $isNew                         => '初のランクイン!要注目',
    $priceDown > 3                 => "平均価格が{$priceDown}万円ダウン、買い時かも",
    $priceUp > 3                   => "平均価格が{$priceUp}万円アップ、高値傾向",
    $soldCount >= 200              => '安定した販売台数をキープ',
    default                        => null, // コメントなし
};

地味な機能ですが、「💬 人気上昇中!」みたいなコメントが1行あるだけで、ニュースっぽさが一気に出ます。

学び: 自動生成コンテンツでも「人間っぽい一言」を添えるだけで読み応えが変わる。


実装④:リッチHTMLで本文を生成

生成するHTMLは、TOP3とそれ以降でデザインを分けています。

TOP3:大きめカード(メダル + 画像 + 統計 + コメント)

$medals = ['🥇', '🥈', '🥉'];

$html .= '<div style="display:flex; align-items:center; gap:16px; padding:16px; background:#f9fafb; border-radius:8px; margin-bottom:12px;">';
$html .= '<div style="font-size:24px; font-weight:bold;">' . $medals[$rank] . '</div>';
$html .= '<img src="' . $imageUrl . '" style="width:96px; height:64px; object-fit:cover; border-radius:4px;">';
$html .= '<div>';
$html .= '<div style="font-weight:bold;">' . $model->name . '</div>';
$html .= '<div style="color:#6b7280; font-size:14px;">' . $manufacturer->name . '</div>';
$html .= '<div style="color:#2563eb; font-weight:bold;">' . $soldCount . '台 / 平均' . $avgPrice . '万円</div>';
if ($comment) {
    $html .= '<div style="color:#6b7280; font-size:12px; margin-top:4px;">💬 ' . $comment . '</div>';
}
$html .= '</div></div>';

4位以降:コンパクトリスト

$html .= '<div style="display:flex; align-items:center; gap:12px; padding:8px 0; border-bottom:1px solid #e5e7eb;">';
$html .= '<span style="width:32px; text-align:center; font-weight:bold;">' . $currentRank . '</span>';
$html .= '<img src="' . $imageUrl . '" style="width:64px; height:40px; object-fit:cover; border-radius:4px;">';
$html .= '<div style="flex:1;"><span style="font-weight:bold;">' . $model->name . '</span>';
$html .= ' <span style="color:#6b7280; font-size:14px;">' . $manufacturer->name . '</span></div>';
$html .= '<span style="color:#2563eb; font-weight:bold;">' . $soldCount . '台</span>';
$html .= '</div>';

なぜBladeテンプレートを使わないのか?

ニュースの本文は bike_news.content カラムにHTMLとして保存します。Bladeは使えないので、PHPの文字列結合でHTMLを組み立てています。Blade使いたかったけど、ここは割り切り。

画像のフォールバックチェーン

バイクの画像が必ずあるとは限らないので、フォールバックを4段階で設定。

$imageUrl = $model->local_image_path
    ? asset('storage/' . ltrim($model->local_image_path, '/'))
    : ($model->image_url
        ?: ($manufacturer->local_logo_path
            ? asset('storage/' . ltrim($manufacturer->local_logo_path, '/'))
            : null));

image.png


実装⑤:X(Twitter)に自動投稿

既存のTwitter投稿基盤(abraham/twitteroauth + API v2)をそのまま流用しました。MotoHubでは既にお買い得バイクの自動投稿をやっているので、同じ仕組みに乗せるだけ。

// app/Console/Commands/TweetRanking.php

$text = "🏍️【MotoHub調べ】{$dateLabel} バイク売れ筋{$typeLabel}ランキング\n\n";
$text .= "🥇 {$top[0]->name}{$top[0]->sold_count}台)\n";
$text .= "🥈 {$top[1]->name}{$top[1]->sold_count}台)\n";
$text .= "🥉 {$top[2]->name}{$top[2]->sold_count}台)\n\n";
$text .= "詳しくはこちら👇\n{$newsUrl}\n\n";
$text .= "#バイク #中古バイク #売れ筋ランキング #MotoHub";

// 車種名をハッシュタグに
foreach (array_slice($top, 0, 3) as $item) {
    $tag = '#' . str_replace([' ', '-', ' '], '', $item->name);
    if (mb_strlen($text . " {$tag}") <= 280) {
        $text .= " {$tag}";
    }
}

月間ランキングにはメディア向けメッセージを追加

if ($type === 'monthly') {
    $text .= "\n\nメディア・ブロガーの方へ:\nこのデータは自由にお使いいただけます📊\n";
    $text .= "詳細→ {$newsUrl}";
}

これは被リンク獲得を狙った施策。実際にこのポストを見たメディアがデータを使ってくれたら、記事中に「MotoHub調べ」と書かれる → 被リンクが増える → SEOに効く。

dry-runオプション

本番投稿前にテストできるよう、--dry-run オプションを付けています。

# テスト(投稿しない)
php artisan twitter:post-ranking --type=daily --dry-run

# 本番投稿
php artisan twitter:post-ranking --type=daily


スケジュール設定

// routes/console.php

// Step1: ニュース生成
Schedule::command('news:generate-ranking --type=daily')->dailyAt('06:00');
Schedule::command('news:generate-ranking --type=weekly')->weeklyOn(1, '06:30');
Schedule::command('news:generate-ranking --type=monthly')->monthlyOn(1, '07:00');

// Step2: X投稿(5分後に実行)
Schedule::command('twitter:post-ranking --type=daily')->dailyAt('06:05');
Schedule::command('twitter:post-ranking --type=weekly')->weeklyOn(1, '06:35');
Schedule::command('twitter:post-ranking --type=monthly')->monthlyOn(1, '07:05');

5分の間隔を空けているのは、ニュース記事がDBに保存されてからXに投稿したいため。投稿文中のURLがニュース記事のURLになるので、記事が先に存在している必要がある。


重複防止

同じ日に同じタイプのランキングが2回生成されないようにガードを入れています。

$exists = BikeNews::where('title', 'LIKE', "%{$dateLabel}%{$typeLabel}%")
    ->where('source', 'MotoHub')
    ->exists();

if ($exists && !$this->option('force')) {
    $this->info("既に生成済みです。--force で上書き可能。");
    return;
}

cronが2回走っても安全。--force を付ければ再生成も可能。


サイトマップにも自動登録

生成されたニュースは自動的にサイトマップに含まれます。GenerateSitemap コマンドで BikeNews をchunk処理。

// sitemap-news.xml に個別ニュースを追加
BikeNews::select('id', 'published_at', 'updated_at')
    ->orderByDesc('published_at')
    ->chunk(1000, function ($news) use ($handle, &$newsCount) {
        foreach ($news as $article) {
            $this->writeUrl(
                $handle,
                route('news.show', $article->id),
                ($article->updated_at ?? $article->published_at)->format('Y-m-d'),
                'weekly',
                '0.6'
            );
            $newsCount++;
        }
    });

ニュースが増えるたびにサイトマップも更新 → Googleにクロールされる → インデックスが増える。完全自動の好循環。


結果と今後

image.png

今できていること

  • ✅ 毎朝6時にデイリーランキングニュースが自動生成
  • ✅ 毎週月曜にウィークリーランキングが自動生成
  • ✅ 毎月1日にマンスリーランキングが自動生成(注目ニュース扱い)
  • ✅ 各ニュース生成の5分後にXに自動投稿
  • ✅ 順位変動・自動コメント付きでニュースとしての読み応えあり
  • ✅ サイトマップに自動登録

次にやること

  • メディアにデータ提供を営業中(ヤングマシン・バイクのニュース・Webオートバイ等に連絡済み)
  • 「MotoHub調べ」のクレジットで被リンクを獲得する戦略
  • ランキングデータをPDFレポート化して、メディア向けに配布

まとめ

作ったもの 技術
ランキング集計 MySQL GROUP BY + is_sold_out + 期間フィルタ
前期間比較 2期間の集計結果をmapWithKeysで突き合わせ
自動コメント match式の条件ベースロジック
HTML本文生成 PHP文字列結合(Blade不使用)
ニュース保存 BikeNewsモデル(content + is_featuredカラム追加)
X自動投稿 abraham/twitteroauth + API v2
スケジュール Laravel Schedule(daily/weekly/monthly)
重複防止 titleの日付文字列で存在チェック

is_sold_outの11.2万件は、ただの「売り切れフラグ」じゃない。「実際に売れた記録」。 このデータを使い倒すことで、GooBikeにもバイクブロスにも出せないオリジナルコンテンツが毎日自動で生まれます。

個人開発で「コンテンツを毎日更新し続ける」のは普通は無理。でも自動生成なら、コードを一度書けば毎日動き続ける。プログラマーの特権です。


前回の記事:3.9万件のバイク駐車場にストリートビューを埋め込んだ実装ガイド

🏍 MotoHub: https://motohub.jp
ランキング: https://motohub.jp/ranking
X: https://x.com/motohub_jp
GitHub: https://github.com/ausssxi/MotoHub

2
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
2
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?