はじめに
個人開発した「技術書ランキングサイト」が、おかげさまで公開初日に1,000View・収益5,000円を突破しました。
「Engineer's Book Hub」
一見順調に見えますが、実はリリース直前、 「定期更新バッチが全くデータを処理してくれない」 という致命的なトラブルに見舞われました。
原因は、メインテーブルとランキング用テーブルの 「DB構成(スキーマ)の不一致」 。
今回は、その失敗から学んだ「データ集計バッチを確実に回すための教訓」を共有します。
1. 実装したかったこと:全自動のランキング更新
このサイトでは、以下の3つの期間でランキングを生成しています。
- 全期間 (book_rankings)
- 年間 (yearly_rankings)
- 月間 (monthly_rankings)
これらをPythonのバッチ処理で毎日1回自動更新し、常に「今熱い本」が表示される仕組みを目指しました。
2. 絶望の始まり:DB構成の「微妙な違い」
ローカル環境のテスト用データでは動いていたのに、本番のDB(Supabase)に接続して定期実行をかけた瞬間、 「データが1件も挿入されない(空っぽ)」または「カラムの型エラー」 が頻発しました。
失敗の原因1:カテゴリ配列の不一致
メインの books テーブルにはジャンル情報がないのに、集計後の rankings テーブルには categories (text[]) という配列カラムを用意していました。
バッチ処理の中で「どのカテゴリに属するか」を判定するロジックが、メインテーブルの構成に依存しすぎていたため、バッチ実行時に空の配列が送られ続けていたのです。
失敗の原因2:period カラムの文字列形式
月間集計では 2026-02、年間では 2026、全期間では all という文字列を period カラムに格納していましたが、バッチ側の生成ロジックとフロントエンドの検索クエリで形式が1文字でもズレると、フロント側には「データなし」と表示されてしまいます。
「DBには入っているのに表示されない」という、最もデバッグが面倒なパターンに陥りました。
3. どう解決したか:データ生成ロジックの「正規化」
力技で修正するのではなく、 「どの期間の集計でも、全く同じロジックを通る」 ように関数を再定義しました。
改善した判定関数(Python)
書籍タイトルだけでなく、紐づくQiita記事の「タグ」を結合して一つの判定ソースに集約。これにより、DBの構成に関わらず安定してジャンル分けができるようになりました。
def determine_categories(book_title, articles):
# 判定用のソースを一箇所に集約
all_source = book_title.lower()
for art in articles:
all_source += " ".join(art.get('tags', [])).lower()
matched = []
for cat, keywords in CATEGORIES_MAP.items():
if any(kw.lower() in all_source for kw in keywords):
matched.append(cat)
return matched # これをPostgresのArray型として挿入
集計テーブルの一本化思想
「月間も全期間も、同じペイロード(データ構造)を作る」というルールを徹底したことで、定期実行スクリプトの保守性が劇的に向上しました。
4. 結果:1日1000Viewを支える「生きたデータ」
この改修を経て、バッチ処理は無事に完走。
「今、現場でどの本が話題か」が正確に反映されるようになり、公開直後から多くのアクセスをいただくことができました。
結果として、1日1000View・初日収益5,000円という、個人開発としてはこれ以上ないスタートを切ることができました。
まとめ:学んだこと
• 手動で動く = 定期実行で動く ではない。
• DBのスキーマ設計は、集計ロジックと密結合させすぎない。
• 「データが空」の時こそ、エラーログではなくスキーマの「型」と「値の形式」を疑う。
もし、実際のランキングがどう動いているか興味がある方は、ぜひサイトを覗いてみてください。
「Engineer's Book Hub」