0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

経産省登録808社の電力料金表をLLMで自動構造化するパイプラインを作った

0
Last updated at Posted at 2026-04-09

はじめに

日本には現在 808社 の登録小売電気事業者がある(経済産業省 資源エネルギー庁公開データ、2026年4月時点)。2016年の電力小売全面自由化以降、新規参入が相次いだ結果だ。

ところが、各社の料金表ページは統一フォーマットなど存在しない。段階制料金、基本料金0円の定額制、市場連動型、時間帯別......。HTML構造もバラバラで、テーブルタグを使っている会社もあれば、CSSグリッドで料金を並べている会社もある。PDFだけの会社もある。

この混沌を「横断で比較可能なJSON」に構造化できれば、電力プラン比較サービスの基盤データになる。手作業は不可能な規模なので、スクレイピング + LLM構造化のパイプラインを構築した。

: 808社すべての料金表を一度に構造化するのは現実的ではない (HP 記載があるのは 173 社のみ・廃止プラン混在等)。 本記事のパイプラインは「経産省名簿 808 社を起点に、 主要新電力の料金表を構造化対象として絞り込む」 設計です。 執筆時点で約 130 プラン (主要 46 社程度) を対象としています。

対象データ: 経産省の登録小売電気事業者一覧

資源エネルギー庁は 登録小売電気事業者一覧 をExcelで公開している。

# scrapers/fetch_retailers_list.py の一部
BASE_URL = "https://www.enecho.meti.go.jp/.../retailers_list/"

def find_excel_links(html: str) -> list[str]:
    """ページ内のExcelリンクを全て抽出して絶対URL化"""
    soup = BeautifulSoup(html, "html.parser")
    links = []
    for a in soup.find_all("a", href=True):
        if re.search(r"\.xlsx?$", a["href"], re.IGNORECASE):
            links.append(urllib.parse.urljoin(BASE_URL, a["href"]))
    return list(dict.fromkeys(links))

このExcelを解析したところ、以下の内訳が判明した。

区分 社数
総登録数 808
稼働中 765
休止・廃止 43
稼働中 かつ HP記載あり 173
稼働中 かつ HP記載なし 592

HPのカバー率はわずか22.6%。経産省のExcelにHPアドレスを登録していない事業者が大半だ。そこでまずHP記載のある173社を起点にして、主要事業者を9電力エリアに展開する形で約130プラン分のスクレイプ対象を定義した。

スクレイピング戦略: httpx + Playwright のハイブリッド

基本方針

  1. まず httpx で静的に取得を試みる
  2. 失敗したら Playwright(headless Chromium)でフォールバック
  3. robots.txt を事前にチェックし、拒否されたらスキップ
async def scrape_one(target: ScrapeTarget) -> ScrapeResult:
    # robots.txt確認
    if not await can_fetch(target.url):
        return ScrapeResult(target=target, status="blocked_by_robots",
                           method="none")

    # 1st: httpx(軽量・高速)
    html, err = await fetch_with_httpx(target.url)
    if html:
        h = hashlib.sha256(html.encode()).hexdigest()[:16]
        return ScrapeResult(target=target, status="success",
                           method="httpx", html=html, content_hash=h)

    # 2nd: Playwrightフォールバック(JS描画が必要なサイト向け)
    html, err = await fetch_with_playwright(target.url)
    if html:
        h = hashlib.sha256(html.encode()).hexdigest()[:16]
        return ScrapeResult(target=target, status="success",
                           method="playwright", html=html, content_hash=h)

    return ScrapeResult(target=target, status="failed", method="none", error=err)

実行結果

50プラン分のテスト実行で以下の結果を得た。

取得方法 件数 割合
httpx(静的取得) 42 84%
Playwright(ブラウザ取得) 8 16%
失敗 0 0%

成功率100%。Playwrightが必要だったサイトの例:

  • オクトパスエナジー: React SPAで料金表を動的描画
  • ドコモでんき: JavaScriptでタブ切り替え後に料金表が出現
  • Japan電力、ONEでんき: Bot対策によりhttpxでは空レスポンス

Playwright側は wait_until="domcontentloaded" の後にさらに2秒待機することで、動的コンテンツの描画完了を待っている。

礼儀としてのRate Limiting

RATE_LIMIT_SECONDS = float(os.getenv("SCRAPER_RATE_LIMIT_SECONDS", "2"))

async def scrape_all(targets: list[ScrapeTarget]) -> list[ScrapeResult]:
    results = []
    for t in targets:
        result = await scrape_one(t)
        save_raw_html(result)
        results.append(result)
        await asyncio.sleep(RATE_LIMIT_SECONDS)  # 最低2秒間隔
    return results

並列化はあえてしていない。1サイトあたり最低2秒のインターバルを空け、User-Agentで目的を明示している。

Gemini で料金表HTMLをJSONに構造化

システムプロンプトの設計

構造化の肝はプロンプト設計にある。試行錯誤の末、以下のポイントに落ち着いた。

SYSTEM_PROMPT = """あなたは日本の電力会社の料金プランを構造化するアシスタントです。
与えられたHTMLテキストから、家庭用(低圧)電気料金プランの情報を抽出してJSON形式で返してください。

必須フィールド:
- retailer_name: 事業者名
- plan_name: プラン名
- area: エリア識別子 (tepco/kepco/chuden/tohoku/... のいずれか)
- plan_type: flat(単一単価) / tiered(段階制) / tou(時間帯別)
             / market_linked(市場連動) / ev / unknown
- basic_charge: [{"ampere":"30A","yen_per_month":935.25}, ...]
- usage_charge: [{"from":0,"to":120,"yen_per_kwh":29.80}, ...]

注意:
- JSONだけを返してください。説明文は不要
- 数値は整数・小数で、文字列化しないこと
- 不明な値は null"""

特に重要だったのは:

  • plan_type を6種に限定: 自由に分類させると「段階料金」「3段階」「従量制」など表記揺れが発生するため、enumで縛った
  • 数値の型指定: 「935.25円」のような文字列ではなく、935.25 というnumberで返させる
  • ヒント情報の付与: LLMへのユーザプロンプトに事業者名・プラン名・エリアのヒントを渡すことで、ページ内に複数プランが記載されていても正しいものを抽出できる

Pydanticスキーマでバリデーション

LLMの出力は必ずしも信頼できない。Pydanticモデルで厳密に検証する。

class StructuredPlan(BaseModel):
    retailer_name: str
    plan_name: str
    area: str
    plan_type: Literal["flat", "tiered", "tou", "market_linked", "ev", "unknown"]
    basic_charge: list[BasicChargeEntry]
    usage_charge: list[UsageTier]
    fuel_cost_adjustment: str | None = None
    cancellation_fee: float | None = None
    special_notes: str | None = None
    source_url: str | None = None

バリデーションに通らなかった場合は .raw.json として保存し、後から目視確認できるようにしている。

response_mime_type の活用

Gemini APIの response_mime_type: "application/json" を指定すると、モデルが確実にJSONだけを返す。マークダウンの ```json ラッパーが混入する問題をAPI側で防げる。

generation_config = genai.types.GenerationConfig(
    max_output_tokens=max_tokens,
    temperature=0.1,
    response_mime_type="application/json",  # JSONのみ出力を強制
)

構造化の精度と課題

131プランを全件処理した結果:

結果 件数 割合
構造化成功(バリデーション通過) 125 95.4%
バリデーション失敗(.raw.json) 6 4.6%

段階制料金の抽出例

東京電力エナジーパートナー「スタンダードS」のような典型的な3段階料金はほぼ完璧に抽出できる。ENEOSでんきVプランの構造化結果:

{
  "retailer_name": "ENEOSでんき",
  "plan_name": "Vプラン",
  "plan_type": "tiered",
  "basic_charge": [
    {"ampere": "30A", "yen_per_month": 935.25},
    {"ampere": "40A", "yen_per_month": 1247.0}
  ],
  "usage_charge": [
    {"from": 0, "to": 120, "yen_per_kwh": 29.8},
    {"from": 120, "to": 300, "yen_per_kwh": 34.85},
    {"from": 300, "to": null, "yen_per_kwh": 36.9}
  ]
}

市場連動型の扱い

Looopでんき「スマートタイムONE」のような市場連動型は、従量単価が固定値ではないため plan_type: "market_linked" として分類し、usage_charge には基準値または空配列を入れる運用にしている。完全な比較には30分ごとのJEPX価格データとの突合が必要で、これは今後の課題。

BRAND_MAP の必要性

意外に厄介だったのが「ブランド名と正式事業者名の不一致」問題だ。

BRAND_MAP = {
    "ENEOSでんき": "ENEOS",
    "auでんき": "KDDI",
    "ソフトバンクでんき": "SBパワー",
    "ドコモでんき": "NTTアノードエナジー",
    "楽天でんき": "楽天エナジー",
    # ほか、運営会社がブランド名と異なる事業者を随時追加
}

経産省のExcelには正式な登録事業者名(法人名)が入っているが、料金表ページにはブランド名で表記されている。このマッピングを手動で持たないと、Supabase上で事業者IDの紐付けができない。現在7件だが、対象事業者を増やすたびに育てていく必要がある辞書だ。

Supabaseへの投入と差分検知

content_hashによる差分検知

料金改定を検知するため、料金データのSHA-256ハッシュ(先頭16文字)を保存している。

content_hash = hashlib.sha256(
    json.dumps({"basic": basic_charge, "tiers": usage_tiers},
               sort_keys=True).encode()
).hexdigest()[:16]

if resp.data and resp.data[0].get("raw_source_hash") == content_hash:
    logger.info("  plan_rates: no change (hash match)")
    return True  # 変更なし → スキップ

ハッシュが変わった場合は、既存レコードに valid_to を設定して終了させ、新レコードを挿入する。これにより料金の履歴を追跡できる。

月次自動更新パイプライン

3ステップを順次実行するパイプラインを組んでいる。

# scripts/update_plans.py
def main():
    # 1. スクレイプ(全社HTML取得)
    run_step("Scrape", ["py", "scrapers/scraper_v2.py", "--all"])
    # 2. 構造化(HTML→JSON、既存スキップ付き)
    run_step("Structurize", ["py", "structurizer/main.py", "--all",
                             "--rate-limit", "15"])
    # 3. DB投入(Supabase UPSERT)
    run_step("Upload", ["py", "structurizer/upload_to_supabase.py"])

構造化ステップの --rate-limit 15 は、Gemini Free枠のRPM制限対策。15秒間隔なら1分あたり4リクエストに収まる。

コスト

Gemini Free枠の活用

Gemini API のFree枠(2026年4月時点):

  • gemini-2.0-flash: 15 RPM / 1,500 RPD
  • gemini-2.5-flash: 10 RPM / 500 RPD

131プランなら1日のFree枠で余裕で処理できる。ただしRPM制限に引っかかるため、4モデルのローテーションを実装した。

class GeminiProvider:
    FALLBACK_MODELS = [
        "gemini-2.5-flash",
        "gemini-2.5-flash-lite",
        "gemini-2.0-flash",
        "gemini-2.0-flash-lite",
    ]

あるモデルが日次上限に達したら次のモデルにフォールバックし、全モデルが上限に達したら5分待って再試行する。こうすることでFree枠だけで全プランを処理できている。

有料枠に移行した場合の試算

項目 数値
1プランあたりの入力トークン 約3,000
1プランあたりの出力トークン 約500
月次実行プラン数 131
Gemini 2.0 Flash 料金 入力$0.10/1Mtok, 出力$0.40/1Mtok
月額コスト概算 約$0.07(約10円)

事実上無料に近い。規模を10倍(1,300プラン)にしても月100円程度。

まとめ

やったこと:

  1. 経産省のExcelから808社を取得し、稼働765社・HP記載173社を特定
  2. httpx + Playwright のハイブリッドスクレイパーで 成功率100% のHTML取得を実現
  3. Gemini APIで料金表HTMLをJSON構造化(成功率95.4%
  4. Pydanticバリデーション + Supabase投入 + content_hashで差分検知
  5. 月次自動更新パイプラインを構築、Gemini Free枠のみで運用

学んだこと:

  • 電力料金の構造化で一番厄介なのは「ブランド名と法人名の不一致」と「市場連動型の扱い」
  • response_mime_type: "application/json" はJSON構造化タスクでは必須級
  • Gemini Free枠の複数モデルローテーションで、課金ゼロでも実用的なバッチ処理が回る
  • Playwrightフォールバックは全体の16%程度だが、主要な新電力ブランドが含まれるため省略できない

今後は対象プランの拡大(808社の残り)と、市場連動型プランへの対応(JEPX価格データとの統合)を進めていく。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?