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?

157記事の電気代シミュレーションを「昨日の燃料価格」まで自動追従させた JSON × Next.js SSG × Python バッチの話

1
Last updated at Posted at 2026-04-25

はじめに

個人運営の電力比較メディア enegent.jp で、157 記事すべてに載っている 電気代シミュレーション値 (年額・月額・他社比較・節約額)を、毎日「 昨日までのDBの実績 」で自動更新する仕組みを作りました。

電気料金の記事は、書いた瞬間から数字が腐っていくコンテンツです。原油も LNG も石炭も毎月の貿易統計で動き、JEPX(日本卸電力取引所)のスポット価格は 30 分ごとに変わります。さらに 5 月には再エネ賦課金、10 月には政府補助金、というふうに制度側も毎月のように動きます。記事の数字を最新に保たないと、Google 上では「2024 年の数字を出す古いサイト」として埋もれていきます。

最初は手作業で全記事を Edit していました。43 ファイルに 年149,525 円 のような数字が散らばっていて、料金改定のたびに sed で置換、半分忘れて古い数字がいくつか本番に残る、という事故を 1 か月で 2 回やりました。今は同じ料金改定が来ると、DB を 1 行更新するだけで 157 記事すべての数字・他社比較・節約額が次のデプロイで一斉に追従します

本記事はその到達点までの設計の流れを、3 つの転機に沿って残します。

構成

Supabase DB(料金プラン・燃調プロファイル・JEPX・燃料価格 forecast)
        │
        ├─ 日次バッチ (Cloud Scheduler → Cloud Run)
        │   - JEPX スポット 30 分値の取込
        │   - 通関統計 3 燃料 (原油 / LNG / 一般炭) の月次取込
        │   - WTI / USDJPY / Pink Sheet → 将来 12 か月の forecast 構築
        │
        ├─ regenerate_all.py (月次 or 料金改定時に手動実行)
        │   ├─ Supabase から 全プラン × 全エリア × 標準条件で再計算
        │   ├─ web/app/articles/_data/review-sim-data.json
        │   ├─ web/app/articles/_data/area-sim-data.json
        │   ├─ web/app/articles/_data/household-sim-data.json
        │   └─ web/app/articles/_data/tariff-reference.json
        │
        ↓ import (build 時)
        │
Next.js 15 App Router (SSG)
   - 157 記事 + LP + シミュレーター + プラン一覧
   - 全部 force-static で生成
   - "use client" は局所化(onClick 等)
        │
        ↓
Vercel Production (静的 HTML 配信)

ポイントは「JSON が単一の真実 (Single Source of Truth)」で、記事本文・LP・シミュレーター・プラン一覧のすべてが同じ JSON を import することです。記事に数字をハードコードすると build エラーになる仕組みも入れています。

転機 1: 「数字をハードコードしない」JSON 駆動化

きっかけ

40 記事ほど書いた頃、新プランを追加登録しました。これだけのために 38 社 → 39 社 127 プラン → 128 プラン を全記事から sed で置換しないといけません。さらにトップページのヒーローには

<p class="hero-num">39</p>
<p class="hero-unit"></p>

のように数字と単位が別 DOMで書かれていて、39社 を grep しても引っかからず古い数字が本番に残る事故が起きました。

設計

全部 JSON にしまって、import する。シンプルです。

// web/app/articles/_data/review-sim-data.json
{
  "standard_sim": {
    "baseline_kwh_monthly": 330,
    "ampere": "30A",
    "tepco_annual": 145837,
    "last_updated": "2026-04-25"
  },
  "aggregate": {
    "companies_count": 48,
    "plans_count": 184,
    "companies_label": "48社",
    "plans_label": "184プラン",
    "companies_plans_label": "48社184プラン"
  },
  "retailers": {
    "test-retailer-review": {
      "display_name": "TestRetailerB",
      "plan_name": "TestPlanB",
      "current_annual": 158275,
      "current_annual_label": "年158,275円",
      "vs_tepco": "大手電力より年12,438円高い",
      "top_alternatives": [
        { "retailer": "TestRetailerA", "plan": "TestPlanA", "savings_label": "年19,321円節約" },
        ...
      ]
    },
    ...
  }
}

TS 側は薄いラッパーで型を付けて import するだけです。

// web/app/articles/_data/review-sim-data.ts
import rawData from "./review-sim-data.json";

export const STANDARD_SIM: StandardSim = rawData.standard_sim;
export const AGGREGATE: Aggregate = rawData.aggregate;
export const RETAILERS: Record<string, RetailerSimData> = rawData.retailers;

export function getRetailerSim(slug: string): RetailerSimData {
  const data = RETAILERS[slug];
  if (!data) throw new Error(`No sim data for retailer slug: ${slug}`);
  return data;
}

記事側は具体的な数字を 1 行も書かず、JSON から動的に呼びます。

const SIM = getRetailerSim("test-retailer-review");

<h2>あなたの電気代</h2>
<p>{STANDARD_SIM.condition_text}の標準世帯で <strong>{SIM.current_annual_label}</strong></p>
<p>{SIM.vs_tepco}</p>

<h2>節約候補プラン</h2>
{SIM.top_alternatives.slice(0, 3).map((alt, i) => (
  <RankCard key={i} rank={i + 1} retailer={alt.retailer} plan={alt.plan} savings={alt.savings_label} />
))}

JSON 1 ファイルで「東京電力との比較・上位候補・節約額」が記事 157 本に同時に反映されます。

一番痛かった落とし穴: SSG が解除される

記事を Next.js App Router の Server Component で書いていたのですが、CTA ボタンに onClick で GA4 イベント送信を入れたら、"use client" が必要になりました。これを記事ファイル先頭に書いてしまうとファイル全体が Client Component 化して SSG が解除されます。157 ページ分の build で SSR 化されると Vercel build が一気に遅くなります。

対応は「onClick だけを子コンポーネントに分離」する古典的なやり方です。

// app/_components/LineClickTracker.tsx
"use client";
export function LineClickTracker({ href, source, children }) {
  return (
    <a href={href} onClick={() => sendGAEvent("line_register_click", { source })}>
      {children}
    </a>
  );
}

// 記事側 (Server Component のまま)
import { LineClickTracker } from "@/app/_components/LineClickTracker";

<LineClickTracker href="https://line.me/..." source="article_footer">
  LINE で相談する
</LineClickTracker>

これで親(記事)は SSG のまま、計測ボタンだけ Client。157 記事すべてが build 時に静的 HTML 化されます。

ガードレール: ハードコード違反を build 前に検出

JSON 駆動にしても、人間(や AI)が記事に 年149,525円 のような数字を直書きしてしまいます。これを防ぐために web/scripts/check-hardcoded-stats.mjsprebuild フックに入れました。

// web/scripts/check-hardcoded-stats.mjs (抜粋)
const STATS = JSON.parse(fs.readFileSync("app/articles/_data/review-sim-data.json"));
const expectedCompanies = STATS.aggregate.companies_count;
const expectedPlans = STATS.aggregate.plans_count;

const violations = [];
for (const file of glob("app/**/*.tsx")) {
  const content = fs.readFileSync(file, "utf-8");
  // 「N社」「Nプラン」を検出(業界用語の「大手電力10社」等は許容リストで除外)
  const matches = content.matchAll(/(\d+)\s*(社|プラン)/g);
  for (const m of matches) {
    const num = parseInt(m[1]);
    const unit = m[2];
    if (ALLOWED_NUMBERS.has(num)) continue;
    if (unit === "" && num !== expectedCompanies) violations.push(...);
    if (unit === "プラン" && num !== expectedPlans) violations.push(...);
  }
}

if (violations.length) {
  console.error(`[NG] ${violations.length}件のハードコーディング違反`);
  process.exit(1);
}

package.json:

{
  "scripts": {
    "prebuild": "node scripts/check-hardcoded-stats.mjs"
  }
}

Vercel の Cloud Build でも npm run build の前に必ず走るので、ハードコード違反が混じった PR は本番デプロイが失敗します。手で押す必要がありません。

転機 2: 比較記事を tariff.ts で「単価から動的計算」に

レビュー記事は JSON 駆動でうまくいきました。しかし、「東京電力 スタンダード S vs 従量電灯 B」のようなプランの単価レベルでの比較記事は、月使用量別 (150 / 200 / 300 / 400 / 500 kWh) の月額表が必要で、JSON だけでは表現しきれません。

最初はこれを記事側にハードコードしていました。

// 古いコード (NG)
<table>
  <tr><td>150kWh</td><td>約4,140円</td><td>約4,140円</td></tr>
  <tr><td>200kWh</td><td>約5,490円</td><td>約5,490円</td></tr>
  ...
</table>

そして起きたのが 2026-04-20 のファクトチェック事故です。

  • 比較記事 tepco-standard-s-vs-m で「東京電力には従量電灯 M というプランは存在しない」(実際は B のみ)
  • 基本料金加算漏れで 年額 8.9 万円ズレ
  • ハードコードしてあったので DB 側で正しくしても記事は古いまま

これを根本解決するために tariff.ts を作りました。

// web/app/articles/_lib/tariff.ts
import tariffData from "../_data/tariff-reference.json";

export type Plan = {
  retailer: string;
  plan_name: string;
  area: string;
  basic_charge: Record<string, number>;  // "30A": 763.62, "40A": 1018.16, ...
  usage_tiers: Array<{ from: number; to: number | null; yen: number }>;
  fuel_adjust: "regulated" | "standard" | "market_linked" | "none";
};

export function getPlan(area: string, retailer: string, planName: string): Plan { ... }

export function monthlyBill(plan: Plan, kwh: number, ampere: string): number {
  let bill = plan.basic_charge[ampere];
  let remaining = kwh;
  for (const tier of plan.usage_tiers) {
    const inTier = tier.to ? Math.min(remaining, tier.to - tier.from) : remaining;
    bill += inTier * tier.yen;
    remaining -= inTier;
    if (remaining <= 0) break;
  }
  return bill;
}

記事側は単価から動的計算するだけです。

const STANDARD_S = getPlan("tokyo", "東京電力エナジーパートナー", "スタンダードS");
const JURYOU_B = getPlan("tokyo", "東京電力エナジーパートナー", "従量電灯B");

const SIM = {
  s_300: monthlyBill(STANDARD_S, 300, "30A"),
  j_300: monthlyBill(JURYOU_B, 300, "30A"),
  // ...
};

<table>
  <tr><td>300kWh</td><td>{SIM.s_300}</td><td>{SIM.j_300}</td></tr>
</table>

tariff-reference.jsonscripts/dump_tariff_reference.py で Supabase から再生成します。料金改定があったら DB 1 行更新 → 再生成 → 比較記事 6 本の単価表が一斉更新されます。

さらに pytest で「DB の単価と記事の表記が一致するか」を 37 ケース検算する tests/test_article_calcs.py を追加しました。Cloud Build では走らないのですが、Claude Code が記事を書いた直後に必ず走らせる運用にしています。

転機 3: 「将来 12 か月」の燃料・JEPX 予測パイプライン

ここまでで「過去〜現在」の数字は追従できます。ただ、シミュレーターで「年間電気代」を出すには将来 12 か月の燃料価格と JEPXが必要です。

当初の素朴な実装と、その問題

最初は「過去 12 か月の燃調実績の単純平均を将来 12 か月分も使う」という実装にしていました。

これは世の中の比較サイトとほぼ同じ設計で、平常時は問題ないのですが、燃料価格が下落トレンドのときに将来予測が高すぎたり、逆に上昇トレンドで安すぎたりします。電気代を「今後 1 年でいくらになるか」で見せる比較サイトとしては致命的です。

改修: 3 燃料 forecast + JEPX 予測モデル

Plan を立て直して、次のパイプラインに作り変えました。

日次バッチ (Cloud Scheduler → Cloud Run)

[1] WTI / USDJPY / Pink Sheet → trade_fuel_prices に
    将来 13 か月分の forecast を UPSERT
    source = 'forecast_pinksheet_<date>'

[2] train_fuel_coefs.py (月 1 回)
    過去 24 か月の JEPX 月次 × 燃料価格を重回帰
    → fuel_forecast_coefs (β_wti / β_ttf / β_coal / γ)
    → jepx_seasonal_premium (エリア × 月別)

[3] api/fuel_forecast/build_tables.py
    9 エリア × 12 か月の JEPX 予測 →
    jepx_forecast_cache (run_at 付き、median / low / high)

計算エンジン側 (fuel_calculator.py) は、月を引数に受け取って次のように分岐します。

  • 過去月 → trade_fuel_prices 実績 / jepx_monthly_stats 実績
  • 将来月 → 同じテーブルから forecast レコードを取得(source 列で区別)

これらを透過的に返します。記事や JSON 駆動の再生成スクリプトは「2026 年 5 月の燃調を計算してくれ」と言うだけで、過去か将来かを意識しません。

これによって regenerate_retailer_reviews.py が出力する current_annual は「今後 12 か月の予測ベース」になります。市場連動型プランも同じ枠組みで計算されるので、市場連動の「今後の見通し」が他社と同じ条件で並びます。

# 月次 or 料金改定時にこれを走らせるだけ
py scripts/regenerate_all.py
# 内部で依存順:
#   export_plans_list → dump_tariff_reference → regenerate_simulator_retailers
#   → regenerate_retailer_reviews → regenerate_area_reviews → regenerate_household_reviews

5 つの JSON が「最新の DB + 将来 forecast」で一斉に書き換わります。

転機 (4): sitemap の lastmod も「git log + JSON 更新時刻」で正確化

最後に、記事の更新を Google に「いつ・どの記事が更新されたか」正しく伝える話です。

Next.js 15 の app/sitemap.tslastModified: new Date() と書くと、全記事が「今ビルドした時刻」で出ます。Google から見ると「全記事が毎ビルド更新されている」と誤認識され、クロール優先度の信号として機能しなくなります。

そこで web/scripts/generate-lastmod.mjsprebuild フックに追加し、各記事の lastModified を次の式で計算しています。

max(
  記事ファイル (page.tsx) の git log での最終コミット時刻,
  該当 JSON データソース (review-sim-data.json) の更新時刻,
  共通コンポーネント globalBaseline
)

これを事前計算 → web/app/_data/lastmod.json に書き出し → sitemap.ts が import します。

// web/scripts/generate-lastmod.mjs (抜粋)
const articleLastMod = {};
for (const slug of ALL_SLUGS) {
  const articlePath = `app/articles/${slug}/page.tsx`;
  const articleMod = execSync(`git log -1 --format=%cI -- ${articlePath}`).toString().trim();
  const dataMod = articleHasJsonDriven(slug)
    ? fs.statSync("app/articles/_data/review-sim-data.json").mtime.toISOString()
    : null;
  articleLastMod[slug] = maxIso([articleMod, dataMod, GLOBAL_BASELINE]);
}
fs.writeFileSync("app/_data/lastmod.json", JSON.stringify({ articles: articleLastMod, ... }));
// app/sitemap.ts
import lastmodData from "./_data/lastmod.json";

export default function sitemap() {
  return REVIEW_SLUGS.map((slug) => ({
    url: `https://enegent.jp/articles/${slug}`,
    lastModified: lastmodData.articles[slug] ?? lastmodData.globalBaseline,
    changeFrequency: "monthly",
    priority: 0.85,
  }));
}

これで「JSON が更新された=関連 157 記事の lastModified が一斉に進む」の信号が Google に届くようになりました。差分のみ IndexNow で送るスクリプトもあわせて、データ更新から検索エンジン通知までが一気通貫の自動化になっています。

結果: 何が嬉しくなったか

Before

  • 料金改定が来るたび 43 ファイルを sed で置換 → 半分忘却で本番に古い数字が残る
  • 新プラン 1 件追加で半日溶ける
  • 比較記事の単価が古いまま放置 → ファクトチェック指摘 → 年額 8.9 万円ズレ事故

After

  • DB 1 行更新 → regenerate_all.py 1 回 → vercel --prod --yes で 157 記事が一斉追従
  • ハードコード違反は build エラーで気付く
  • pytest で「DB と記事表記の整合」が 37 ケース通る
  • sitemap の lastmod で Google にも「実質更新があった記事だけ」が伝わる
  • forecast パイプライン経由で「今後 12 か月の予測」がシミュレーターと記事で同じ前提で並ぶ

数字で見える効果

  • 料金改定の反映工数: 半日 → 5 分 (DB 更新 + 1 コマンド + デプロイ)
  • ファクトチェック指摘: 月平均 5 件 → 月 0〜1 件
  • Vercel build 時間: SSG 化が崩れず 約 2 分 で 157 ページ生成

個人開発でここまでやる必要はあるか

正直、最初の 30 記事くらいまでなら手動 Edit で十分でした。

ただ、SEO で「電気料金は鮮度が命」のジャンルに張ったら、避けて通れません。Google が「最終更新が 2024 年の記事」と判断したら、検索結果に出てきません。出てこなければ書いた意味がありません。

そして一度この仕組みを作ると、「次の月次更新が 5 分で終わる」ので、新しい記事を書く時間が確保できます。仕組み投資の回収サイクルが短いです。

JSON 駆動 × SSG × バッチ更新の組み合わせは、個人開発で「鮮度が命のメディア」をやるときの定番パターンとして、もっと知られていい設計だと思います。

まとめ

  • 記事に数字をハードコードしない。「JSON が単一の真実
  • ハードコード違反は prebuild check で build エラー化
  • 比較記事の単価表は tariff.ts で動的計算
  • 「将来 12 か月の燃料・JEPX」も DB の forecast レコードから透過的に取得
  • sitemap の lastmod は git log + JSON mtime で正確化
  • 結果: 料金改定の反映が半日 → 5 分

スクリプトと設計図は個人プロジェクトのナレッジに残してあるので、もし同じような「数字が腐っていくメディア」を運営している人がいたら、参考になる箇所を切り出して使ってください。

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?