自然言語でWebを操作する — Firecrawlエージェントモードで実現するゼロコード情報収集

約44分で読めます by ぽんたぬき
自然言語でWebを操作する — Firecrawlエージェントモードで実現するゼロコード情報収集

自然言語でWebを操作する — Firecrawlエージェントモードで実現するゼロコード情報収集


1. はじめに:URLを書かずにWebデータを取得できる時代へ

ねえねえ、キミ!!「競合サイトの価格を毎日チェックして」って言われたとき、どうしてる!?😭

前の会社でマーケの人から「競合10社の価格、毎朝9時にSlackに流して」って言われたとき、マジで頭抱えたんだヨネ……。セレクタ書いて、XPath調べて、サイトごとにスクレイピングコード書いて——って、それだけで2週間かかっちゃったんだヨ。で、リリースしたら1ヶ月後にサイトリニューアルで全部壊れて、また1週間やり直し……😭💦

「もうイヤだ!!」ってなったよネ(笑)

でも、今は違うんだヨ!!「ワイヤレスイヤホンの競合10社の価格を調べて」ってプロンプトを一行書くだけで、URLも、セレクタも、XPathも——何も書かずに構造化されたデータが返ってくる時代になっちゃったんだヨ!?😆🎉

コレがね、Firecrawlのエージェントモードってやつダヨ。今日はコレを使って、「競合サイトの価格情報を毎日自動収集してSlackに通知する」パイプラインを一緒に作っちゃいましょうよ!!✨

1-1. 従来のスクレイピングの限界

キミも心当たりあるんじゃないかナ……😅

従来のWebスクレイピングって、こんな感じだよネ♪

# 昔ながらのスクレイピング
from bs4 import BeautifulSoup
import requests

soup = BeautifulSoup(requests.get("https://example-shop.com/products").text, "html.parser")
prices = soup.select("div.product-card > span.price")  # このセレクタが命綱

コレのどこがツラいって——🦝

① セレクタ・XPath・URL管理が地獄。サイトごとに書き方が全然違うし、JavaScriptでレンダリングされてるページはBeautifulSoupじゃ取れないし。SeleniumやらPlaywrightやらPuppeteerやら、ツールだけで選択肢が多すぎて笑えないよネ(笑)

② サイト変更のたびに壊れるdiv.product-cardarticle.item-cardになった瞬間、全部アウト。で、翌朝気づいたらSlackに何も通知されてなくて、マーケの人から「昨日のデータどこ?」って詰められる……😭 僕、これで3回くらいやらかしちゃいましたヨ……

③ 非エンジニアが扱えない組織的ボトルネック。「ちょっとこのサイトも追加して」ってお願いされるたびにエンジニアが手を動かさないといけない。これ、DXとか言ってる時代に全然スケールしないよネ😅

1-2. 「エージェントモード」が変えるパラダイム

そこに颯爽と登場するのが、Firecrawlなんですヨ!!🎉

FirecrawlはMendable社が開発したOSSのWebクロール&スクレイピングAPIなんダヨ。もともとはLLMアプリケーション向けにWebコンテンツをキレイなMarkdownに変換してくれるツールだったんだケド、エージェントモードが追加されてから次元が変わっちゃったんだヨネ✨

何が変わったかって!?

「ワイヤレスイヤホン 日本 ECサイト 価格 2024年」

このプロンプトを投げるだけで——URLを一切指定せずに——Firecrawlが自律的にWebを探索して、構造化されたJSONデータを返してくれるんだヨ!!😆🔥

この記事で最終的に作るものはコレ☆

[毎朝9時] GitHub Actions
     ↓
[自然言語クエリ] Firecrawlエージェント
     ↓ URLなし!プロンプトだけ!
[競合10サイトの価格データ] JSON
     ↓
[前日比較・差分検出] Python
     ↓
[価格変動レポート] Slack通知

キミ、センスあるネ!!このパイプライン、一緒に作っちゃおうよ!!😆


2. Firecrawlエージェントモードの仕組みを理解する

「なんか凄そうだケド、中身どうなってんの?」って思うよネ!!💡 僕もそこが気になって、夜中にGitHubのソース読み漁っちゃいましたヨ。妻に「また何時まで起きてるの」って怒られたケド!!(笑)

2-1. アーキテクチャ概観:何が「エージェント」なのか

通常のFirecrawl APIはこう↓

モード 動作
/scrape URLを指定してそのページをMarkdown化
/crawl URLを起点にサイト全体をクロール
/search 検索クエリでページを探してスクレイプ
/extract(エージェントモード) 自然言語プロンプトで自律的にWeb探索

通常モードはキミが「どこを取るか」を全部指示しないといけないんだヨネ。でもエージェントモードは違うんだヨ!!「何を知りたいか」だけ言えばいいんだヨ!!✨

内部にLLM推論レイヤーが入っていて、そいつが「どのサイトを見ればいいか」を自律的に判断してくれるんだヨネ😆

2-2. 内部動作ステップ詳解

コレ見てよ!!エージェントの内部動作、こんな感じなんだヨ!!🔥

Step 1|自然言語クエリをサブタスクに分解

入力: "日本のECサイトで売られているワイヤレスイヤホンの価格情報"
  ↓
LLMが分解:
  - タスク1: "ワイヤレスイヤホン 価格比較 日本" で検索
  - タスク2: Amazon.co.jp, 楽天, ヨドバシなどの候補URLを特定
  - タスク3: 各ページの価格情報を抽出

Step 2|検索エンジン&サイトクロールで候補URLを自律収集

FirecrawlのエージェントはBingやGoogleの検索APIを内部で呼び出して、関連するURLを自動収集するんだヨネ。キミはURL一個も書かなくていいの!!😆 スゴくナイ!?

Step 3|ページコンテンツをMarkdown変換してLLMに供給

JavaScriptレンダリング対応のヘッドレスブラウザでページを取得してMarkdown変換。HTMLのゴミがなくなってLLMが読みやすい形になるんだヨ💡

ヘッドレスブラウザ:画面表示(GUI)なしでバックグラウンドで動作するブラウザのこと。ChromeやFirefoxをサーバー上で動かして、JavaScriptが生成する動的コンテンツも含めてページ全体を取得できる。

Step 4|スキーマに従った構造化データとして抽出・返却

キミが定義したJSONスキーマに合わせてLLMがデータを整形して返してくれる。型安全!!✨

2-3. 精度を左右する設計上のポイント

ここ、ちょっと聞いてヨ……僕、最初に「いい感じに価格取って」ってだけ書いたら、全然使えないデータが返ってきてサ😭 3時間溶かしちゃったんだヨネ……

精度を上げるコツは3つ☆

① プロンプトで抽出粒度を明示する:「商品名・通常価格・セール価格・通貨単位・ソースURL」って具体的に書く💡

② JSONスキーマでアウトプット型を縛る:Pydanticでモデルをしっかり定義して渡す🎯

③ タイムアウトを適切に設定する:エージェントは複数ページ探索するので、デフォルトより長め(60〜120秒)に設定する⏱️


3. 環境構築:5分ではじめる最小セットアップ

さあ、キミも手を動かす時間だヨ!!🎉 ファミコンのカセット吹いてた頃からのクセで、僕って何でも「とりあえず動かしてから考える」タイプなんだヨネ(笑)📟

3-1. 必要なもの一覧

  • Firecrawl APIキーfirecrawl.dev でサインアップ。無料枠は月500クレジット。エージェントモードは1クエリあたり約5〜20クレジット消費するから、テスト込みで月50〜80回くらい動かせるネ💡
  • Slack Incoming Webhook URL:Slack AppページからWebhookを発行。Slackのワークスペース管理者権限が必要だヨ
  • Python 3.11以上:3.10でも動くケド、型ヒントの恩恵が大きいから3.11推奨ネ
# 依存パッケージをインストール
pip3 install firecrawl-py httpx python-dotenv pydantic

3-2. プロジェクト初期化

ディレクトリ構成はコレ☆

price-monitor/
├── .env                    # APIキーはここに集約
├── collector.py            # エージェントクエリ+バリデーション
├── notifier.py             # Slack通知
├── schemas.py              # Pydanticモデル定義
├── cache/
│   └── prices.json         # 前日データのキャッシュ
└── .github/
    └── workflows/
        └── price-monitor.yml  # GitHub Actionsのワークフロー

.envファイルはこんな感じネ♪

# .env
FIRECRAWL_API_KEY=fc-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00000/B00000/xxxxxxxx

APIクライアントの初期化コードはコレ見てよ!!✨

# init_check.py(動作確認用)
import os
from dotenv import load_dotenv
from firecrawl import FirecrawlApp

load_dotenv()

app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

# 動作確認:シンプルな1クエリテスト
result = app.extract(
    prompt="Sony WH-1000XM5 ワイヤレスイヤホンの最安値を1件だけ教えてください",
    schema={
        "type": "object",
        "properties": {
            "product_name": {"type": "string"},
            "price": {"type": "number"},
            "currency": {"type": "string"},
            "source_url": {"type": "string"}
        },
        "required": ["product_name", "price", "currency"]
    }
)

print(result)

コレを実行してJSONが返ってきたらセットアップ完了だヨ!!動いた時、思わず「うお!!」って声出ちゃいましたヨ😆


4. コア実装:自然言語だけで競合価格を収集する

いよいよ本番だヨ!!キミ、ここまでついてこれてる!?😆🔥 スゴいじゃん!!

4-1. 抽出スキーマの設計

取得したいフィールドを先に整理するのがポイントだヨネ💡

フィールド 説明
product_name str 商品名(正式名称)
price float 販売価格(数値のみ)
currency str 通貨コード(JPY等)
retailer str 販売店名
source_url str データ取得元URL
fetched_at str 取得日時(ISO 8601)

ISO 8601:日時の国際標準表記形式のこと。2026-03-28T09:00:00+00:00 のように年月日と時刻をハイフン・コロンで区切って表現する。タイムゾーン情報も含められるため、グローバルなシステム間でのデータ交換に広く使われている。

Pydanticモデルにするとコレになるネ!コレ見てよ!!スゴくナイ!?✨

# schemas.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime, timezone


class PriceRecord(BaseModel):
    """価格レコードの型定義"""
    product_name: str = Field(description="商品の正式名称")
    price: float = Field(description="販売価格(税込、数値のみ)", gt=0)
    currency: str = Field(default="JPY", description="通貨コード(ISO 4217)")
    retailer: str = Field(description="販売店名(例: Amazon, 楽天市場)")
    source_url: Optional[str] = Field(default=None, description="商品ページのURL")
    fetched_at: str = Field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat(),
        description="データ取得日時(ISO 8601)"
    )

    @field_validator("price", mode="before")
    @classmethod
    def clean_price(cls, v):
        """価格表記ゆれを正規化(「¥3,980」→ 3980.0)"""
        if isinstance(v, str):
            cleaned = v.replace("¥", "").replace(",", "").replace("円", "").strip()
            return float(cleaned)
        return float(v)

    @field_validator("currency")
    @classmethod
    def normalize_currency(cls, v):
        """通貨表記を正規化(「円」→「JPY」)"""
        mapping = {"円": "JPY", "yen": "JPY", "¥": "JPY"}
        return mapping.get(v, v.upper())


class PriceReport(BaseModel):
    """複数商品の価格レポート"""
    items: list[PriceRecord]
    query_summary: Optional[str] = Field(
        default=None, description="エージェントが実行したクエリの概要"
    )

field_validator:Pydanticが提供するバリデーション用デコレータ。フィールドへのデータ代入時に任意の変換・検証ロジックを実行できる。mode="before" を指定すると型変換の前に処理が走るため、文字列から数値への変換などに使いやすい。

field_validatorで価格の表記ゆれを吸収しておくのがミソだヨ!!「¥3,980」みたいな文字列が返ってきても、ちゃんと3980.0に変換してくれるネ😆

4-2. エージェントクエリの組み立て

ここが一番大事なところだヨ!!💡

プロンプトの善し悪しで取得精度が全然違うんだヨネ……僕、これでホントに悩んだんだヨ。夜中にプロンプト10種類くらい試して、翌日の仕事で目が開かなかったっていうやらかしもあったんだケド😭(笑)

NG例 vs OK例を見てみようヨ!!

# NG: 曖昧すぎて精度が出ない
bad_prompt = "ワイヤレスイヤホンの価格"

# OK: 対象・条件・出力形式を明示
good_prompt = """
以下の条件でワイヤレスイヤホンの価格情報を収集してください:

【対象商品】
- Sony WH-1000XM5
- Apple AirPods Pro(第2世代)
- Bose QuietComfort Ultra Headphones

【収集条件】
- 日本国内ECサイト(Amazon.co.jp、楽天市場、ヨドバシ.com、ビックカメラ.com)
- 税込価格
- 最新価格

【出力形式】
各商品について、商品名・価格(数値のみ)・販売店名・商品ページURLを返してください
"""

わかる!?違い!!😆 NG例は「どの商品?」「どのサイト?」「どんな形式で?」が全部曖昧なんだヨネ。OK例は対象・条件・出力形式を明示してるから、エージェントが迷わないんだヨ!!✨

4-3. collector.py 全体実装

コレ見てよ!!スゴくナイ!?✨ キャッシュ管理から差分検出まで、全部入りのコレクターだヨ!!🔥

# collector.py
import os
import json
import logging
from pathlib import Path
from datetime import datetime, timezone

from dotenv import load_dotenv
from firecrawl import FirecrawlApp
from schemas import PriceReport, PriceRecord

load_dotenv()

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

CACHE_FILE = Path("cache/prices.json")
CHANGES_FILE = Path("cache/changes.json")
CACHE_FILE.parent.mkdir(exist_ok=True)

# 監視対象の設定
WATCH_CONFIG = [
    {
        "category": "ワイヤレスイヤホン",
        "products": [
            "Sony WH-1000XM5",
            "Apple AirPods Pro 第2世代",
            "Bose QuietComfort Ultra Headphones",
        ],
        "retailers": ["Amazon.co.jp", "楽天市場", "ヨドバシ.com", "ビックカメラ.com"],
    }
]


def load_cache() -> dict[str, float]:
    """前回実行時の価格キャッシュを読み込む。ファイルが存在しない場合は空dictを返す。"""
    if not CACHE_FILE.exists():
        logger.info("キャッシュファイルが存在しません。初回実行として扱います。")
        return {}
    try:
        with CACHE_FILE.open("r", encoding="utf-8") as f:
            raw: list[dict] = json.load(f)
        # キー: "{retailer}::{product_name}", 値: price
        return {f"{item['retailer']}::{item['product_name']}": item["price"] for item in raw}
    except (json.JSONDecodeError, KeyError) as e:
        logger.warning("キャッシュの読み込みに失敗しました: %s", e)
        return {}


def save_cache(records: list[PriceRecord]) -> None:
    """今回取得した価格データをキャッシュとして保存する。"""
    payload = [record.model_dump() for record in records]
    with CACHE_FILE.open("w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
    logger.info("キャッシュを保存しました: %d件", len(payload))


def build_prompt(category: str, products: list[str], retailers: list[str]) -> str:
    """監視設定からFirecrawlエージェント向けプロンプトを生成する。"""
    product_list = "\n".join(f"- {p}" for p in products)
    retailer_list = "、".join(retailers)
    return f"""
以下の条件で{category}の価格情報を収集してください。

【対象商品】
{product_list}

【収集対象サイト】
{retailer_list}

【収集条件】
- 日本国内の税込価格(円)
- 最新の販売価格(セール価格がある場合はセール価格を優先)

【出力形式】
各商品・販売店の組み合わせについて、以下を返してください:
- product_name: 商品名(正式名称)
- price: 価格(数値のみ、カンマ・記号なし)
- currency: 通貨コード(JPY)
- retailer: 販売店名
- source_url: 商品ページのURL
""".strip()


def collect_prices() -> list[PriceRecord]:
    """Firecrawlエージェントを呼び出して全カテゴリの価格を収集する。"""
    app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])
    all_records: list[PriceRecord] = []

    for config in WATCH_CONFIG:
        prompt = build_prompt(
            category=config["category"],
            products=config["products"],
            retailers=config["retailers"],
        )
        logger.info("エージェントクエリを実行中: カテゴリ=%s", config["category"])

        try:
            result = app.extract(
                prompt=prompt,
                schema=PriceReport.model_json_schema(),
                timeout=120,  # エージェントは複数ページを探索するため長めに設定
            )
            report = PriceReport.model_validate(result)
            all_records.extend(report.items)
            logger.info("取得完了: %d件", len(report.items))
        except Exception as e:
            logger.error("価格収集中にエラーが発生しました: %s", e)
            raise

    return all_records


def detect_changes(
    current: list[PriceRecord],
    cache: dict[str, float],
) -> list[dict]:
    """現在の価格と前回キャッシュを比較して変動を検出する。

    Returns:
        変動があったレコードのリスト。各要素は product_name / retailer /
        old_price / new_price / diff / diff_pct を持つ。
    """
    changes = []
    for record in current:
        key = f"{record.retailer}::{record.product_name}"
        old_price = cache.get(key)
        if old_price is None:
            continue  # 初回取得または新規追加商品はスキップ
        diff = record.price - old_price
        diff_pct = diff / old_price * 100
        if abs(diff_pct) >= 1.0:  # 1%以上の変動を検出対象とする
            changes.append(
                {
                    "product_name": record.product_name,
                    "retailer": record.retailer,
                    "old_price": old_price,
                    "new_price": record.price,
                    "diff": diff,
                    "diff_pct": round(diff_pct, 2),
                    "source_url": record.source_url,
                    "fetched_at": record.fetched_at,
                }
            )
    return changes


if __name__ == "__main__":
    logger.info("価格収集を開始します")
    cache = load_cache()
    records = collect_prices()
    changes = detect_changes(records, cache)
    save_cache(records)

    # notifier.py が読み込む変動データを書き出す
    CHANGES_FILE.write_text(
        json.dumps(changes, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    logger.info("変動データを保存しました: %d件", len(changes))

キミ、コレだけで「キャッシュ読み込み → 価格収集 → 差分検出 → キャッシュ保存」が全部カバーされてるんだヨネ!!スゴくナイ!?😆✨


5. GitHub Actions設定:毎朝9時に自動実行する

ここまで来たら、あとはコレを毎朝自動で動かすだけだヨ!!🎉 GitHub Actionsって、こういうバッチ処理に最高にマッチするんだヨネ。お金かからないし、管理サーバーいらないし——スゴくナイ!?😆

ちなみに、最近ようやく腰痛が良くなってきてネ。整体の先生から「PC作業中は30分おきに立て!!」って言われてるんだヨ。守れてないケド……😅 キミも気をつけてネ!!

5-1. ワークフローファイル全体

コレ見てよ!!スゴくナイ!?✨ .github/workflows/price-monitor.yml の完全版だヨ!!🔥

# .github/workflows/price-monitor.yml
name: Price Monitor

on:
  schedule:
    # 毎朝9時(JST = UTC 0:00)に実行
    - cron: "0 0 * * *"
  workflow_dispatch:
    # 手動実行も可能にしておく

permissions:
  contents: write

jobs:
  collect-and-notify:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Restore price cache
        uses: actions/cache@v4
        with:
          path: cache/prices.json
          key: price-cache-${{ github.run_id }}
          restore-keys: |
            price-cache-

      - name: Install dependencies
        run: pip install firecrawl-py httpx python-dotenv pydantic

      - name: Run price collector
        env:
          FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
        run: python collector.py

      - name: Send Slack notification
        if: success()
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: python notifier.py

      - name: Save updated cache
        uses: actions/cache@v4
        with:
          path: cache/prices.json
          key: price-cache-${{ github.run_id }}

5-2. Secrets登録手順

GitHubリポジトリの設定からAPIキーを登録するんだヨ!!手順はカンタン☆

  1. リポジトリページで Settings → Secrets and variables → Actions を開く
  2. New repository secret をクリック
  3. 以下の2つを登録する📝
Secret名
FIRECRAWL_API_KEY fc-xxxxxxxx...
SLACK_WEBHOOK_URL https://hooks.slack.com/services/...

Secretsはログに絶対出力されないから安心だヨ!!キミ、間違っても.envをそのままコミットしちゃダメだからネ!!😅 僕、1回やらかしちゃいましたヨ……😭 すぐGitHub側でキーが無効化されて、深夜に再発行する羽目になったんだヨネ……

5-3. キャッシュ戦略のポイント

actions/cache@v4restore-keys にプレフィックスだけ指定しているのがミソだヨ!!💡 price-cache- という共通プレフィックスを使うことで、「今回のrunに完全一致するキャッシュがなければ直近のキャッシュを復元する」という動作になるんだヨネ。つまり前日のデータが自動的に引き継がれるんだヨ!!スゴくナイ!?😆

実行タイミングの cron: "0 0 * * *" はUTC 0:00指定だヨ。GitHub ActionsのスケジュールはUTC基準なので、JST(日本標準時 = UTC+9)の朝9時に動かしたいなら UTC 0:00 を指定するんだヨネ✨


6. Slack通知実装:価格変動を即座にチームへ届ける

データが取れたら、チームに届けてなんぼだヨネ!!😆 ここではSlack通知の実装を丁寧に説明するヨ!!🔔

6-1. Incoming Webhook の発行手順

Incoming Webhook:SlackにHTTP POSTリクエストを送ることで、外部サービスやスクリプトからSlackチャンネルへメッセージを投稿できる仕組みのこと。専用のURLが発行され、そのURLにJSONをPOSTするだけで通知が届く。OAuth認証などは不要で、手軽に使えるのが特徴。

手順はコレだヨ!!キミも一緒にやってみようネ☆

  1. api.slack.com/apps にアクセスして Create New App → From scratch を選択
  2. App名(例: price-monitor)とワークスペースを選んで作成
  3. 左メニューの Incoming Webhooks をクリック → トグルを On に切り替え
  4. Add New Webhook to Workspace → 通知先チャンネル(例: #price-alerts)を選択
  5. 表示されたURL(https://hooks.slack.com/services/T.../B.../...)をコピーしてSecretsに登録

URLを発行したら、curlでサクッと動作確認できるダヨ!!💡

# 動作確認(ターミナルで実行)
curl -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"text": "テスト通知です!price-monitorの設定確認中"}'

Slackに「テスト通知です!」って届いたらOKだヨ!!😆

6-2. notifier.py 全体実装

コレ見てよ!!スゴくナイ!?✨ Block Kit形式でリッチな通知を送る完全実装だヨ!!🔥

# notifier.py
import os
import json
import logging
from pathlib import Path
from datetime import datetime, timezone

import httpx
from dotenv import load_dotenv

load_dotenv()

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

CACHE_FILE = Path("cache/prices.json")
CHANGES_FILE = Path("cache/changes.json")


def load_changes() -> list[dict]:
    """collector.pyが検出した変動データを読み込む。"""
    if not CHANGES_FILE.exists():
        return []
    with CHANGES_FILE.open("r", encoding="utf-8") as f:
        return json.load(f)


def format_change_block(change: dict) -> dict:
    """1件の価格変動をSlack Block Kit形式に変換する。"""
    sign = "📈" if change["diff"] > 0 else "📉"
    direction = "値上がり" if change["diff"] > 0 else "値下がり"
    diff_abs = abs(change["diff"])
    diff_pct = abs(change["diff_pct"])
    url_text = f"\n<{change['source_url']}|商品ページを見る>" if change.get("source_url") else ""

    return {
        "type": "section",
        "text": {
            "type": "mrkdwn",
            "text": (
                f"{sign} *{change['product_name']}* @ {change['retailer']}\n"
                f{change['old_price']:,.0f} → *¥{change['new_price']:,.0f}*"
                f"({direction} ¥{diff_abs:,.0f} / {diff_pct:.1f}%){url_text}"
            ),
        },
    }


def build_slack_payload(changes: list[dict], total_records: int) -> dict:
    """Slackに送信するペイロードを構築する。"""
    now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

    if not changes:
        return {
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            f"✅ *価格モニタリング完了* ({now})\n"
                            f"監視件数: {total_records}件 | 価格変動: なし"
                        ),
                    },
                }
            ]
        }

    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": f"💰 価格変動アラート({len(changes)}件)",
            },
        },
        {
            "type": "context",
            "elements": [
                {
                    "type": "mrkdwn",
                    "text": f"実行日時: {now} | 監視件数: {total_records}件",
                }
            ],
        },
        {"type": "divider"},
    ]

    for change in changes:
        blocks.append(format_change_block(change))

    blocks.append({"type": "divider"})
    blocks.append(
        {
            "type": "context",
            "elements": [
                {
                    "type": "mrkdwn",
                    "text": "1%以上の変動を検出した商品のみを表示しています",
                }
            ],
        }
    )

    return {"blocks": blocks}


def send_notification(payload: dict) -> None:
    """SlackのIncoming Webhook URLにペイロードを送信する。"""
    webhook_url = os.environ["SLACK_WEBHOOK_URL"]
    response = httpx.post(webhook_url, json=payload, timeout=10)
    response.raise_for_status()
    logger.info("Slack通知を送信しました")


def count_records() -> int:
    """キャッシュファイルに保存されたレコード数を返す。"""
    if not CACHE_FILE.exists():
        return 0
    with CACHE_FILE.open("r", encoding="utf-8") as f:
        return len(json.load(f))


if __name__ == "__main__":
    changes = load_changes()
    total = count_records()
    payload = build_slack_payload(changes, total)
    send_notification(payload)
    logger.info("通知完了: 変動%d件 / 監視%d件", len(changes), total)

format_change_blockで価格の上昇・下落を絵文字で区別してるのがミソだヨ!!📈 Slackの通知がパッと見でわかりやすくなるんだヨネ😆✨

6-3. ローカルテスト方法

GitHub Actionsに上げる前に、ローカルで動作確認しておこうネ!!コレ見てよ!!テスト方法はコレだヨ!!✨

# テスト用のchanges.jsonを手動作成
mkdir -p cache
cat > cache/changes.json << 'EOF'
[
  {
    "product_name": "Sony WH-1000XM5",
    "retailer": "Amazon.co.jp",
    "old_price": 39800,
    "new_price": 35800,
    "diff": -4000,
    "diff_pct": -10.05,
    "source_url": "https://www.amazon.co.jp/dp/B09XS7JWHH",
    "fetched_at": "2026-03-28T00:00:00+00:00"
  }
]
EOF

# 通知テスト実行
python notifier.py

Slackに「📉 Sony WH-1000XM5 @ Amazon.co.jp 値下がり」の通知が届いたら完璧だヨ!!😆🎉


7. まとめ:「書かないスクレイピング」の時代へ

キミ、最後までついてきてくれてアリガトウ!!ホントにスゴいじゃん!!😆🎉 最後にパイプライン全体を振り返っちゃおうヨ!!

7-1. 今回作ったコンポーネント一覧

ファイル 役割
schemas.py Pydanticによる型安全なデータモデル定義
collector.py Firecrawlエージェント呼び出し・差分検出・キャッシュ管理
notifier.py Slack Block Kit形式の通知送信
.github/workflows/price-monitor.yml GitHub Actionsによるスケジュール実行
cache/prices.json 前日価格キャッシュ(Actions Cache経由で永続化)

7-2. 従来手法との比較表

コレ見てよ!!スゴくナイ!?✨ 違いが一目瞭然だヨ!!🔥

観点 従来のスクレイピング Firecrawlエージェントモード
URL指定 必須 不要
セレクタ・XPath 必須 不要
サイト変更への耐性 低い(すぐ壊れる) 高い(LLMが吸収)
非エンジニアでの運用 困難 プロンプト変更のみでOK
JavaScript対応 別途ヘッドレスブラウザ必要 組み込み済み
初期構築コスト 高い 低い
API料金 なし あり(月500クレジット無料枠)

7-3. 拡張アイデア

このパイプライン、実はまだまだ広げられるんだヨネ!!💡 キミのアイデア次第でこんな方向にも発展できるネ♪

① 監視カテゴリの追加WATCH_CONFIGにオブジェクトを追加するだけで、家電・アパレル・食品なんでも監視できるネ。プロンプトの調整だけだヨ!!✨

② 価格推移グラフの自動生成:キャッシュをSQLiteやCSVに蓄積して、matplotlibでグラフ化してSlackに画像投稿する拡張もカンタンだヨ📊

③ BigQueryへのデータ蓄積google-cloud-bigqueryライブラリを追加してsave_cacheの代わりにBQに書き込むと、長期分析もできちゃうネ!!🗄️

④ アラート閾値のカスタマイズ:現在は1%変動で検知しているケド、商品カテゴリごとに閾値を変えると精度がさらに上がるダヨ💡

⑤ 複数通知チャンネル対応:カテゴリ別に通知先Slackチャンネルを分けると、チームごとに関係する情報だけ届けられるネ🔔

7-4. 締め

キミ、今日一緒に「URLゼロ・セレクタゼロ」の価格監視パイプラインを作り上げちゃったネ!!😆🎉

セレクタが壊れる恐怖から解放されて、プロンプトを磨くだけでいい時代。キミのチームの情報収集フローが、コレで少し楽になったらうれしいヨ!!✨

また次の記事でも一緒に面白いもの作っちゃおうよ!!待ってるヨ!!🔥

関連記事

コメント

0/2000