WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

LangChain WebBaseLoaderで競合サイトを自動分析|Python実装コード付き完全ガイド

生徒

競合サイトのコンテンツを定期的にチェックするのが大変です。AIで自動分析できれば楽なんですが……

ペン博士

LangChainのWebBaseLoaderを使えば、競合サイトのテキストを自動収集してAIで分析するツールが作れるんじゃ!URLを渡すだけでコンテンツ抽出・キーワード分析・比較レポート生成まで自動化できるぞ。今日はPython実装コードを全文掲載して完全解説するぞい!

目次

この記事でわかること

  • LangChain WebBaseLoaderを使って競合サイトのテキストを自動取得する方法
  • TextSplitterでLLMのトークン上限を超えずに長文を処理するテクニック
  • 競合サイト自動分析ツール(要約・キーワード抽出・比較レポート)の完全実装コード
  • EmbeddingsとFAISSでコンテンツギャップを数値化して発見する方法
  • GitHub Actionsで週次定期実行・Slack通知まで完全自動化する手順

LangChainのWebBaseLoaderを使えば、URLを渡すだけで競合サイトのテキストを抽出し、OpenAI APIで要約・キーワード抽出・比較レポートを自動生成できます。毎週数時間かかっていた競合リサーチが、Pythonスクリプトの実行数分に短縮されます。

本記事では、LangChainの概要・WebBaseLoaderの使い方・競合サイト自動分析ツールの実装コード・複数サイト比較・Embeddings活用・VectorStore・定期実行の自動化・コスト最適化・エラーハンドリングを完全解説します。

あわせて読みたい:
RAG構成でChatGPT APIを使った自社データ参照チャットボットの作り方(LangChain活用の基礎)


LangChainとWebBaseLoaderとは|競合分析に使える理由

LangChainは、ChatGPTなどのLLM(大規模言語モデル)を活用したAIアプリケーションを効率的に構築するためのオープンソースフレームワークです。Pythonを中心に、外部データとLLMを柔軟に連携させる機能を多数持っています。2022年10月にHarrison Chase氏によって公開されて以来、わずか数年でGitHub Star数が9万を超える急成長プロジェクトになりました。

LangChainの最大の強みは「データソースとLLMをつなぐグルー(接着剤)としての機能」にあります。Webページ・PDF・データベース・Slack・Notionなど多様なデータソースからテキストを取り込み、それをOpenAI・Anthropic・Google Geminiなど複数のLLMで処理するパイプラインを、最小限のコードで構築できます。競合分析においては「Webページの自動収集 → テキスト正規化 → LLMによる分析 → レポート生成」というフローをシームレスに実現できます。

WebBaseLoaderとは

WebBaseLoaderはLangChain Communityパッケージに含まれるドキュメントローダーの一種です。内部的にはPythonの有名なHTMLパーサーライブラリ「Beautiful Soup 4(bs4)」を使用して、指定URLのHTMLからテキストを抽出し、LangChainの標準データ構造であるDocumentオブジェクトに変換します。

【WebBaseLoaderの役割】

WebBaseLoader = LangChainに組み込まれたWebスクレイピング機能

できること:
✅ URLを渡すだけでWebページのテキストを抽出
✅ 複数URLを一括処理
✅ Beautiful Soup ベースのHTMLパース
✅ LangChainのDocument形式に自動変換(RAGやChainと直接連携できる)
✅ BeautifulSoupのSoupStrainerで取得対象要素を絞り込める
✅ カスタムHTTPヘッダーでUser-Agentを偽装できる
✅ プロキシ設定に対応

競合分析への活用イメージ:
URL[] → WebBaseLoader → テキスト抽出 → LLM分析 → レポート生成

WebBaseLoaderが返すDocumentオブジェクトには、page_content(テキスト)とmetadata(ソースURL・タイトルなど)が格納されています。このフォーマットはLangChainのすべてのコンポーネントと互換性があるため、テキストをロードしたらすぐにChain・Agent・VectorStoreと組み合わせられます

LangChainの主要コンポーネント

コンポーネント役割競合分析での使い所
WebBaseLoaderWebページのHTMLからテキストを抽出競合サイトのコンテンツ収集
TextSplitter長いテキストをチャンクに分割トークン制限を超えないよう分割処理
Embeddingsテキストをベクトルに変換コンテンツの類似度比較・重複検出
VectorStoreベクトルをDBに保存・検索大量ページの効率的な管理・検索
Chains複数の処理をパイプライン化「抽出→要約→比較→レポート」の一括実行
AgentsAIが自律的にツールを選択・実行「この競合の強みは何か?」への自動回答
PromptTemplate変数付きプロンプトを再利用可能な形で定義分析観点を統一したプロンプト管理

なぜWebBaseLoaderを競合分析に使うのか

競合分析の作業を手動で行う場合、対象サイトを一つひとつ開き、内容を読んで、スプレッドシートに転記して、比較する——という工程に毎回数時間かかります。これをPythonスクリプトに落とし込むだけなら通常のスクレイピングでも対応できますが、「テキストを取得した後にAIで意味を読み取る」という工程でLangChainが圧倒的に効率的です。

BeautifulSoupだけを使ったスクレイピングでは、抽出したテキストをOpenAI APIに渡すための前処理(トークン数の管理・チャンク分割・プロンプト設計)を自前で実装する必要があります。LangChainのWebBaseLoaderを使えば、この前処理が既にパッケージ化されており、load_summarize_chainなどの高レベルAPIを呼ぶだけで「要約」「キーワード抽出」「比較分析」ができます。


環境構築|LangChainのインストールと設定

LangChainはPython 3.9以上を対象としています。2024年以降はパッケージがlangchain(コアロジック)とlangchain-community(外部連携)とlangchain-openai(OpenAI専用)に分割されているため、必要なものをすべてインストールします。

# 仮想環境を作成して有効化
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# 必要なパッケージをインストール
pip install langchain langchain-community langchain-openai \
            beautifulsoup4 requests python-dotenv openai \
            faiss-cpu schedule

faiss-cpuはFacebook AI Research製のベクトル検索ライブラリです。後述するEmbeddingsを使った類似度比較に使います。GPUがある環境ではfaiss-gpuを使うとさらに高速化できます。

# .env ファイルに APIキーを設定(Gitに含めないこと)
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxx
# ファイル構成
competitor-analyzer/
├── main.py              # メイン処理・実行エントリーポイント
├── analyzer.py          # 分析ロジック(要約・キーワード抽出・比較)
├── embeddings_search.py # Embeddingsを使った類似度分析
├── notifier.py          # Slack通知
├── schedule_analyzer.py # 定期実行スケジューラ
├── .env                 # APIキー(Gitに含めない)
├── .gitignore           # .env・venv・reports/ を除外
├── requirements.txt     # pip freeze した内容
└── reports/             # レポート出力先(.gitignore推奨)

requirements.txtの作成

# requirements.txt
langchain>=0.3.0
langchain-community>=0.3.0
langchain-openai>=0.2.0
beautifulsoup4>=4.12.0
requests>=2.31.0
python-dotenv>=1.0.0
openai>=1.40.0
faiss-cpu>=1.8.0
schedule>=1.2.0

WebBaseLoaderの基本的な使い方|1URLから複数URLの一括処理まで

基本:1つのURLからテキストを抽出する

# basic_loader.py

from langchain_community.document_loaders import WebBaseLoader

# URLを指定してローダーを作成
loader = WebBaseLoader("https://example-competitor.com/blog/")

# ページを読み込んでDocumentオブジェクトに変換
documents = loader.load()

# テキスト内容を確認
for doc in documents:
    print(f"URL: {doc.metadata['source']}")
    print(f"テキスト長: {len(doc.page_content)} 文字")
    print(f"冒頭200文字: {doc.page_content[:200]}")
    print("---")

上記のコードでdocumentsにはDocumentオブジェクトのリストが返ります。page_contentにはHTMLタグを除去したテキスト、metadataには{'source': 'https://...'}が格納されます。

複数URLを一括処理する

# multi_loader.py

from langchain_community.document_loaders import WebBaseLoader

# 競合サイトのURLリスト
competitor_urls = [
    "https://competitor-a.com/services/",
    "https://competitor-b.com/about/",
    "https://competitor-c.com/blog/",
]

# 複数URLを一括ロード
loader = WebBaseLoader(competitor_urls)
documents = loader.load()

print(f"{len(documents)} ページを読み込みました")
for i, doc in enumerate(documents):
    print(f"[{i+1}] {doc.metadata['source']} - {len(doc.page_content)}文字")

BeautifulSoupの設定で不要なタグを除外する

デフォルトのWebBaseLoaderはナビゲーション・フッター・広告テキストも含めてすべての可視テキストを取得します。競合分析では本文テキストだけを抽出したい場合が多いため、bs_kwargsSoupStrainerを使って取得対象要素を絞り込みます。

# clean_loader.py

import bs4
from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader(
    web_paths=["https://competitor-a.com/services/"],
    bs_kwargs={
        "parse_only": bs4.SoupStrainer(
            class_=("post-content", "article-body", "entry-content")  # 本文エリアのみ取得
        )
    }
)

documents = loader.load()
# ナビゲーション・フッター・広告テキストを除外した本文のみを取得できる

カスタムヘッダーとUser-Agentの設定

一部のWebサイトはデフォルトのPython User-Agent(python-requests/x.x.x)でアクセスするとブロックされる場合があります。header_templateでUser-Agentを設定できます。

# custom_header_loader.py

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader(
    web_paths=["https://competitor-a.com/blog/"],
    header_template={
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                      "AppleWebKit/537.36 (KHTML, like Gecko) "
                      "Chrome/122.0.0.0 Safari/537.36",
        "Accept-Language": "ja,en-US;q=0.9,en;q=0.8",
    }
)

documents = loader.load()
print(f"取得成功: {len(documents[0].page_content)} 文字")

なお、User-Agentのカスタマイズはサイトの利用規約で禁止されている場合があります。robots.txtの確認と合わせて、適切なリクエスト間隔(後述)を設けることを強く推奨します。


テキスト分割戦略|TextSplitterの使い方

LLMにはトークン数の上限があります(GPT-4o miniで128Kトークン)。Webページのテキストが長い場合、そのままAPIに渡すと上限を超えてエラーになります。LangChainのTextSplitterを使ってテキストをチャンクに分割します。

RecursiveCharacterTextSplitter(推奨)

# text_splitter_demo.py

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 競合分析向けの設定例
splitter = RecursiveCharacterTextSplitter(
    chunk_size=2000,        # 1チャンクあたりの最大文字数
    chunk_overlap=200,      # チャンク間のオーバーラップ(文脈の連続性を保つ)
    separators=["\n\n", "\n", "。", "、", " ", ""]
    # 日本語コンテンツの場合、句点・読点を優先的な分割点として設定
)

# Documentを分割
chunks = splitter.split_documents(documents)
print(f"分割後のチャンク数: {len(chunks)}")
for i, chunk in enumerate(chunks[:3]):
    print(f"\n--- チャンク {i+1} ---")
    print(f"文字数: {len(chunk.page_content)}")
    print(chunk.page_content[:200])

分割戦略の比較

TextSplitter分割方法向いているケース
RecursiveCharacterTextSplitter段落→改行→句点の順に試みて分割一般的なWebページ・ブログ記事(推奨)
CharacterTextSplitter指定文字で単純に分割区切り文字が明確なテキスト
TokenTextSplitterトークン数で分割OpenAIのトークン数を正確に管理したい場合
HTMLHeaderTextSplitterHTMLの見出しタグで分割構造化されたHTML(見出しベースの分割)
MarkdownHeaderTextSplitterMarkdownの見出しで分割Markdown形式のドキュメント

競合サイト自動分析ツールの実装コード全文

analyzer.py:コア分析ロジック

# analyzer.py

import os
import time
from dotenv import load_dotenv
from langchain_community.document_loaders import WebBaseLoader
from langchain_openai import ChatOpenAI
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains.summarize import load_summarize_chain
from langchain.prompts import PromptTemplate

load_dotenv()

class CompetitorAnalyzer:
    """
    競合サイトのコンテンツを自動収集・分析するクラス
    """

    def __init__(self, model: str = "gpt-4o-mini"):
        self.llm = ChatOpenAI(
            model=model,
            temperature=0.1,
            openai_api_key=os.getenv("OPENAI_API_KEY")
        )
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=2000,
            chunk_overlap=200,
            separators=["\n\n", "\n", "。", "、", " ", ""]
        )
        self.request_delay = 2.0  # リクエスト間隔(秒)

    def load_competitor_content(self, url: str) -> list:
        """URLから競合サイトのコンテンツを取得する"""
        try:
            time.sleep(self.request_delay)  # サーバー負荷軽減のための待機
            loader = WebBaseLoader(
                web_paths=[url],
                header_template={
                    "User-Agent": "Mozilla/5.0 (compatible; CompetitorAnalyzer/1.0)"
                }
            )
            documents = loader.load()
            # 長いコンテンツをチャンク分割
            return self.text_splitter.split_documents(documents)
        except Exception as e:
            print(f"⚠️ 読み込みエラー: {url} - {e}")
            return []

    def summarize_content(self, url: str) -> str:
        """競合サイトのコンテンツを要約する"""
        docs = self.load_competitor_content(url)
        if not docs:
            return "コンテンツの取得に失敗しました"

        # マップリデュース方式で要約(長文でも対応)
        # map_reduce: 各チャンクを個別に要約してから最終要約を生成
        chain = load_summarize_chain(
            self.llm,
            chain_type="map_reduce",
            verbose=False
        )
        summary = chain.invoke({"input_documents": docs})
        return summary["output_text"]

    def extract_keywords(self, url: str) -> str:
        """競合サイトの主要キーワードを抽出する"""
        docs = self.load_competitor_content(url)
        if not docs:
            return "キーワードの取得に失敗しました"

        # 最初の数チャンクからキーワードを抽出
        content = "\n\n".join([doc.page_content for doc in docs[:3]])

        prompt = PromptTemplate(
            input_variables=["content"],
            template="""
以下のWebページのコンテンツから、SEOキーワードとなりそうな重要語句を20個抽出してください。
検索意図を考慮し、ビジネス価値の高いキーワードを優先してください。

コンテンツ:
{content}

出力形式:
- キーワード1(検索意図: xxx、月間検索ボリューム想定: xxx)
- キーワード2(検索意図: xxx、月間検索ボリューム想定: xxx)
...
"""
        )

        response = self.llm.invoke(prompt.format(content=content[:3000]))
        return response.content

    def analyze_service_features(self, url: str) -> str:
        """競合サイトのサービス特徴・強みを分析する"""
        docs = self.load_competitor_content(url)
        if not docs:
            return "サービス情報の取得に失敗しました"

        content = "\n\n".join([doc.page_content for doc in docs[:4]])

        prompt = PromptTemplate(
            input_variables=["content", "url"],
            template="""
以下は {url} のWebページコンテンツです。
このサービス・企業の以下の情報を分析して日本語でまとめてください:

1. 主要サービス・製品
2. 強みとポジション
3. ターゲット顧客層
4. 価格帯・料金体系(記載がある場合)
5. 差別化ポイント
6. コンテンツ・情報発信の傾向
7. 弱みと改善余地(推測を含む)

コンテンツ:
{content}
"""
        )

        response = self.llm.invoke(
            prompt.format(content=content[:4000], url=url)
        )
        return response.content

    def analyze_content_strategy(self, url: str) -> str:
        """競合のコンテンツ戦略を分析する"""
        docs = self.load_competitor_content(url)
        if not docs:
            return "コンテンツ戦略の分析に失敗しました"

        content = "\n\n".join([doc.page_content for doc in docs[:5]])

        prompt = f"""
以下のWebページのコンテンツから、このサイトのコンテンツマーケティング戦略を分析してください。

分析観点:
1. メインのターゲットキーワード・トピック
2. コンテンツの更新頻度(推測)
3. コンテンツの深さと品質
4. CTA(Call to Action)の設置場所と訴求内容
5. SEO対策の状況(見出し構造・内部リンクなど)
6. ユーザーエンゲージメントを高める工夫

コンテンツ:
{content[:5000]}
"""
        response = self.llm.invoke(prompt)
        return response.content

    def compare_competitors(self, urls: list[str]) -> str:
        """複数の競合サイトを比較分析する"""
        analyses = {}
        for url in urls:
            print(f"  → 分析中: {url}")
            analyses[url] = self.analyze_service_features(url)

        # 比較プロンプト
        comparisons = "\n\n".join([
            f"【{url}】\n{analysis}"
            for url, analysis in analyses.items()
        ])

        compare_prompt = f"""
以下の複数の競合サービスの分析結果をもとに、詳細な比較レポートを作成してください。

{comparisons}

比較レポートの形式:
## 比較サマリー
(全体の傾向・共通点・差異の概要)

## 各社の強み・弱み
(各サービスの強みと弱みを箇条書きで)

## 市場のポジショニングマップ
(価格帯×サービス範囲の観点で整理)

## コンテンツ戦略の比較
(各社がどのようなトピック・キーワードで情報発信しているか)

## 競合に対する差別化戦略の示唆
(自社が差別化できる余地・ブルーオーシャンとなりうる領域)

## 短期・中長期の戦略提案
(3ヶ月・1年で実施すべき具体的なアクション)
"""
        response = self.llm.invoke(compare_prompt)
        return response.content

main.py:実行スクリプト

# main.py

import datetime
import os
from analyzer import CompetitorAnalyzer

def main():
    # 分析対象の競合サイトURLリスト
    competitor_urls = [
        "https://competitor-a.com/services/",
        "https://competitor-b.com/pricing/",
        "https://competitor-c.com/about/",
    ]

    print("=" * 60)
    print("🔍 競合サイト自動分析ツール")
    print(f"実行日時: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}")
    print("=" * 60)

    analyzer = CompetitorAnalyzer(model="gpt-4o-mini")

    # 1. 各競合サイトの要約
    print("\n📝 1. コンテンツ要約")
    summaries = {}
    for url in competitor_urls:
        print(f"  → 要約中: {url}")
        summaries[url] = analyzer.summarize_content(url)

    # 2. キーワード抽出
    print("\n🔑 2. SEOキーワード抽出")
    keywords = {}
    for url in competitor_urls:
        print(f"  → キーワード抽出中: {url}")
        keywords[url] = analyzer.extract_keywords(url)

    # 3. コンテンツ戦略分析
    print("\n📈 3. コンテンツ戦略分析")
    content_strategies = {}
    for url in competitor_urls:
        print(f"  → コンテンツ戦略分析中: {url}")
        content_strategies[url] = analyzer.analyze_content_strategy(url)

    # 4. 競合比較レポート
    print("\n📊 4. 競合比較レポート生成")
    comparison_report = analyzer.compare_competitors(competitor_urls)

    # 5. レポートをファイルに保存
    os.makedirs("reports", exist_ok=True)
    report_path = f"reports/competitor_analysis_{datetime.datetime.now().strftime('%Y%m%d_%H%M')}.md"

    with open(report_path, "w", encoding="utf-8") as f:
        f.write(f"# 競合サイト分析レポート\n\n")
        f.write(f"実行日時: {datetime.datetime.now().strftime('%Y年%m月%d日 %H:%M')}\n\n")

        f.write("## 各サイト要約\n\n")
        for url, summary in summaries.items():
            f.write(f"### {url}\n{summary}\n\n")

        f.write("## SEOキーワード分析\n\n")
        for url, kw in keywords.items():
            f.write(f"### {url}\n{kw}\n\n")

        f.write("## コンテンツ戦略分析\n\n")
        for url, strategy in content_strategies.items():
            f.write(f"### {url}\n{strategy}\n\n")

        f.write("## 競合比較レポート\n\n")
        f.write(comparison_report)

    print(f"\n✅ レポートを保存しました: {report_path}")

if __name__ == "__main__":
    main()

実行方法

# 実行
python main.py

# 出力例
# ============================================================
# 🔍 競合サイト自動分析ツール
# 実行日時: 2026-03-14 18:14
# ============================================================
# 📝 1. コンテンツ要約
#   → 要約中: https://competitor-a.com/services/
# ...
# ✅ レポートを保存しました: reports/competitor_analysis_20260314_1820.md

Embeddingsを使ったコンテンツ類似度分析

テキストの要約や比較だけでなく、Embeddingsを使うことでコンテンツの「意味的な類似度」を数値として計算できます。「自社コンテンツと競合コンテンツの重複度を調べる」「競合が扱っていないトピックを発見する」といった応用が可能です。

Embeddingsとは

EmbeddingsはテキストをN次元の浮動小数点ベクトルに変換する技術です。意味が似ているテキストは近いベクトルになるため、ベクトル同士のコサイン類似度を計算することで「テキストの類似性」を数値化できます。例えば「Pythonでスクレイピングする方法」と「Pythonを使ったWebデータ収集」は意味が近いため、高い類似度スコアになります。

# embeddings_search.py

import numpy as np
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import WebBaseLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

class ContentSimilarityAnalyzer:
    """
    Embeddingsを使ったコンテンツ類似度分析クラス
    """

    def __init__(self):
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=500, chunk_overlap=50
        )

    def build_vectorstore(self, urls: list[str]) -> FAISS:
        """複数URLのコンテンツからVectorStoreを構築"""
        all_docs = []
        for url in urls:
            loader = WebBaseLoader(url)
            docs = loader.load()
            chunks = self.splitter.split_documents(docs)
            all_docs.extend(chunks)

        print(f"✅ {len(all_docs)} チャンクをVectorStoreに格納")
        return FAISS.from_documents(all_docs, self.embeddings)

    def find_gaps(
        self,
        my_url: str,
        competitor_urls: list[str],
        top_k: int = 5
    ) -> str:
        """
        自社コンテンツにないが競合にあるトピックを発見する
        """
        # 競合のVectorStoreを構築
        competitor_store = self.build_vectorstore(competitor_urls)

        # 自社コンテンツを読み込み
        my_loader = WebBaseLoader(my_url)
        my_docs = my_loader.load()
        my_chunks = self.splitter.split_documents(my_docs)

        gaps = []
        for chunk in my_chunks[:20]:  # 最初の20チャンクをクエリとして使用
            # 競合のVectorStoreで類似チャンクを検索
            similar = competitor_store.similarity_search_with_score(
                chunk.page_content, k=top_k
            )

            for doc, score in similar:
                # スコアが低い(類似度が低い)=自社にないコンテンツ
                if score > 0.5:  # FAISSのL2距離、値が大きいほど非類似
                    gaps.append({
                        "competitor_content": doc.page_content[:200],
                        "source": doc.metadata.get("source", ""),
                        "similarity_gap": score
                    })

        # ギャップが大きいものをソート
        gaps.sort(key=lambda x: x["similarity_gap"], reverse=True)

        result = "## コンテンツギャップ分析結果\n\n"
        result += "自社にないが競合にあるトピック(類似度が低いもの上位):\n\n"
        for i, gap in enumerate(gaps[:10], 1):
            result += f"{i}. [{gap['source']}]\n"
            result += f"   {gap['competitor_content']}\n\n"

        return result

    def calculate_content_overlap(
        self, url_a: str, url_b: str
    ) -> float:
        """
        2つのサイトのコンテンツ重複率を計算する(0.0〜1.0)
        """
        loader_a = WebBaseLoader(url_a)
        loader_b = WebBaseLoader(url_b)

        docs_a = self.splitter.split_documents(loader_a.load())
        docs_b = self.splitter.split_documents(loader_b.load())

        # 各テキストのEmbeddingsを取得
        texts_a = [doc.page_content for doc in docs_a[:10]]
        texts_b = [doc.page_content for doc in docs_b[:10]]

        vectors_a = self.embeddings.embed_documents(texts_a)
        vectors_b = self.embeddings.embed_documents(texts_b)

        # コサイン類似度の平均を計算
        similarities = []
        for va in vectors_a:
            for vb in vectors_b:
                va_np = np.array(va)
                vb_np = np.array(vb)
                cos_sim = np.dot(va_np, vb_np) / (
                    np.linalg.norm(va_np) * np.linalg.norm(vb_np)
                )
                similarities.append(cos_sim)

        return float(np.mean(similarities))

応用:定期実行の自動化とSlack通知

Slack通知の実装

分析レポートが生成されたときにSlackのチャンネルに通知を送ることで、チーム全体で競合情報を素早く共有できます。Slack APIのIncoming Webhookを使います。

# notifier.py

import os
import requests
from dotenv import load_dotenv

load_dotenv()

def send_slack_notification(report_summary: str, report_path: str) -> bool:
    """
    競合分析レポートをSlackに通知する

    事前準備:
    1. Slack APIの「Incoming Webhooks」でWebhook URLを取得
    2. .envに SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx を設定
    """
    webhook_url = os.getenv("SLACK_WEBHOOK_URL")
    if not webhook_url:
        print("⚠️ SLACK_WEBHOOK_URL が設定されていません")
        return False

    payload = {
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": "🔍 競合サイト自動分析レポート",
                    "emoji": True
                }
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*レポートファイル:* `{report_path}`"
                }
            },
            {
                "type": "divider"
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*分析サマリー:*\n{report_summary[:500]}..."
                }
            }
        ]
    }

    response = requests.post(webhook_url, json=payload)
    if response.status_code == 200:
        print("✅ Slack通知を送信しました")
        return True
    else:
        print(f"❌ Slack通知の送信に失敗: {response.status_code}")
        return False

scheduleライブラリによる定期実行

# schedule_analyzer.py - cronやGitHub Actionsで定期実行する例

import schedule
import time
from main import main

# 毎週月曜日の朝9時に自動実行
schedule.every().monday.at("09:00").do(main)

print("⏰ 競合分析スケジューラを起動しました(Ctrl+Cで停止)")
while True:
    schedule.run_pending()
    time.sleep(60)

GitHub Actionsによるクラウド定期実行

常時起動のサーバーがない場合は、GitHub Actionsのscheduleトリガーを使って無料でクラウド実行できます。毎週月曜日の朝9時(JST)に自動実行し、レポートをアーティファクトとして保存します。

# .github/workflows/competitor-analysis.yml

name: 競合サイト自動分析
on:
  schedule:
    - cron: '0 0 * * 1'  # 毎週月曜日 09:00 JST(UTC 0:00)
  workflow_dispatch:      # 手動実行も可能

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: python main.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      - uses: actions/upload-artifact@v4
        with:
          name: competitor-report
          path: reports/
          retention-days: 30  # 30日間保存

エラーハンドリングとレート制限対策

競合サイトのスクレイピングでは、ネットワークエラー・HTTPエラー・サイト構造の変化・OpenAI APIのレート制限など、さまざまなエラーが発生します。プロダクションレベルの実装では適切なエラーハンドリングが不可欠です。

堅牢なローダー実装

# robust_loader.py

import time
import logging
from typing import Optional
from langchain_community.document_loaders import WebBaseLoader

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def load_with_retry(
    url: str,
    max_retries: int = 3,
    delay: float = 2.0
) -> Optional[list]:
    """
    リトライ機能付きのWebBaseLoader

    Args:
        url: スクレイピング対象のURL
        max_retries: 最大リトライ回数
        delay: リトライ間の待機時間(秒)
    """
    for attempt in range(max_retries + 1):
        try:
            time.sleep(delay)  # リクエスト間隔
            loader = WebBaseLoader(url)
            documents = loader.load()

            if not documents or not documents[0].page_content.strip():
                raise ValueError(f"コンテンツが空でした: {url}")

            logger.info(f"✅ 取得成功: {url} ({len(documents[0].page_content)}文字)")
            return documents

        except Exception as e:
            if attempt == max_retries:
                logger.error(f"❌ 最大リトライ回数に達しました: {url} - {e}")
                return None

            wait_time = delay * (2 ** attempt)  # 指数バックオフ
            logger.warning(
                f"⚠️ 取得失敗 (試行 {attempt + 1}/{max_retries}): {e} "
                f"→ {wait_time}秒後にリトライ"
            )
            time.sleep(wait_time)

    return None

OpenAI APIのレート制限対策

# rate_limit_handler.py

import time
import openai
from langchain_openai import ChatOpenAI

def create_llm_with_retry() -> ChatOpenAI:
    """
    レート制限を考慮したLLMクライアントを生成する

    OpenAI APIのレート制限(2026年3月時点):
    - GPT-4o mini: 500 RPM(リクエスト/分)、150,000 TPM(トークン/分)
    - GPT-4o:      500 RPM、30,000 TPM(Tier 1の場合)
    """
    return ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0.1,
        max_retries=5,           # LangChain組み込みのリトライ
        request_timeout=60,      # タイムアウト設定
    )

def safe_llm_invoke(llm: ChatOpenAI, prompt: str, max_retries: int = 3) -> str:
    """
    レート制限エラーを処理しながらLLMを呼び出す
    """
    for attempt in range(max_retries):
        try:
            response = llm.invoke(prompt)
            return response.content
        except openai.RateLimitError as e:
            if attempt == max_retries - 1:
                raise
            # レート制限エラーの場合は60秒待機
            wait_time = 60 * (attempt + 1)
            print(f"⏳ レート制限に達しました。{wait_time}秒待機します...")
            time.sleep(wait_time)
        except openai.APIConnectionError as e:
            if attempt == max_retries - 1:
                raise
            print(f"🔌 接続エラー: {e}. 10秒後にリトライ...")
            time.sleep(10)
    return ""

コスト管理と最適化

競合分析ツールを実際に運用するにあたって、OpenAI APIのコストは重要な考慮点です。競合サイトを毎週5社分析した場合のコストを試算してみましょう。

処理使用モデル想定トークン数コスト(週次)
コンテンツ要約(5サイト)GPT-4o mini10,000トークン約$0.015
キーワード抽出(5サイト)GPT-4o mini15,000トークン約$0.023
サービス分析(5サイト)GPT-4o mini20,000トークン約$0.030
比較レポート生成(1回)GPT-4o mini8,000トークン約$0.012
Embeddings(5サイト)text-embedding-3-small50,000トークン約$0.005
合計(週次)103,000トークン約$0.085

月次換算で約$0.34(約50円)。非常に低コストで競合分析の自動化が実現できます。もし高精度な分析が必要な場合はGPT-4oを使っても、月次で1,500〜3,000円程度に収まるはずです。

コスト削減のテクニック

# cost_optimizer.py - コスト削減テクニック集

import hashlib
import json
import os
from datetime import datetime, timedelta

class CachedAnalyzer:
    """
    キャッシュ機能付きの競合分析クラス
    同じURLの分析結果を一定期間キャッシュして API呼び出しを減らす
    """

    CACHE_DIR = ".analysis_cache"
    CACHE_TTL_HOURS = 24  # キャッシュ有効期限(時間)

    def __init__(self, analyzer):
        self.analyzer = analyzer
        os.makedirs(self.CACHE_DIR, exist_ok=True)

    def _get_cache_key(self, url: str, analysis_type: str) -> str:
        return hashlib.md5(f"{url}:{analysis_type}".encode()).hexdigest()

    def _load_cache(self, cache_key: str) -> str | None:
        cache_file = os.path.join(self.CACHE_DIR, f"{cache_key}.json")
        if not os.path.exists(cache_file):
            return None

        with open(cache_file, "r", encoding="utf-8") as f:
            data = json.load(f)

        # TTLチェック
        created_at = datetime.fromisoformat(data["created_at"])
        if datetime.now() - created_at > timedelta(hours=self.CACHE_TTL_HOURS):
            return None

        return data["result"]

    def _save_cache(self, cache_key: str, result: str):
        cache_file = os.path.join(self.CACHE_DIR, f"{cache_key}.json")
        with open(cache_file, "w", encoding="utf-8") as f:
            json.dump({
                "result": result,
                "created_at": datetime.now().isoformat()
            }, f, ensure_ascii=False)

    def analyze_with_cache(self, url: str, analysis_type: str) -> str:
        cache_key = self._get_cache_key(url, analysis_type)

        # キャッシュヒット確認
        cached = self._load_cache(cache_key)
        if cached:
            print(f"  💾 キャッシュから取得: {url} ({analysis_type})")
            return cached

        # APIを呼び出して分析
        if analysis_type == "summary":
            result = self.analyzer.summarize_content(url)
        elif analysis_type == "keywords":
            result = self.analyzer.extract_keywords(url)
        elif analysis_type == "features":
            result = self.analyzer.analyze_service_features(url)
        else:
            raise ValueError(f"未知の分析タイプ: {analysis_type}")

        # キャッシュに保存
        self._save_cache(cache_key, result)
        return result

実際のユースケースと活用例

ユースケース1:SaaSの競合ランディングページ分析

SaaS企業のマーケティング担当者が、競合他社のランディングページを毎月分析してレポートにまとめる作業を自動化した例です。従来は1社あたり1〜2時間かかっていた分析が、スクリプト実行の20〜30分に短縮されました(API呼び出し時間を含む)。

特に有効だったのが「キャッチコピーの変化検知」です。前週と今週のコンテンツを比較することで、「競合がA/Bテストを開始した」「価格表の訴求を変更した」などの変化を素早く把握できるようになりました。

ユースケース2:コンテンツマーケティングのギャップ分析

ブログメディアの編集チームが、競合メディアの記事一覧ページを定期的にクロールしてキーワードトレンドを分析する用途にも活用できます。競合が最近注力しているトピック(Embeddingsの類似度分析で検出)と自社コンテンツの重複率を把握することで、「競合が書いていないが検索需要がある領域」を狙った記事企画が立てやすくなります。

ユースケース3:採用情報の変化モニタリング

競合他社の採用ページをモニタリングすることで、「エンジニアの大量採用を開始した」「新しい事業領域(MLOps)の求人が出た」などの戦略的な変化を早期に察知できます。この情報は事業戦略の意思決定に活用できます。


よくある質問(FAQ)

JavaScriptで動的に生成されるページも取得できますか?

WebBaseLoaderは静的HTMLの取得に特化しており、JavaScriptレンダリングが必要なSPAには対応していません。動的ページには SeleniumURLLoaderPlaywrightURLLoader(LangChain Community に含まれる)を使ってください。

# PlaywrightURLLoader の使用例(動的ページ対応)
# pip install playwright && playwright install chromium

from langchain_community.document_loaders import PlaywrightURLLoader

loader = PlaywrightURLLoader(
    urls=["https://spa-competitor.com/services/"],
    remove_selectors=["nav", "footer", ".ads"]  # 除外する要素
)
documents = loader.load()

robots.txtを無視してスクレイピングしてもいいですか?

robots.txtの指示には従う必要があります。Disallowに指定されたURLへのアクセスは利用規約違反や不正アクセスとみなされる可能性があります。また、短時間に大量のリクエストを送るとサーバーに負荷をかけるため、リクエスト間隔(time.sleep())を設けることを強く推奨します。自動化ツールを実装する際は、robots.txtの確認・適切なリクエスト間隔・User-Agentの正直な設定(自社ツールであることを示す)の3点を徹底してください。

LangChainとLlamaIndexの使い分けは?

LangChainはAgents・Chains・Tool連携など「LLMを使ったアプリケーション全般」を構築するフレームワークです。LlamaIndexはRAG(文書検索×LLM)の構築に特化しており、大量文書のインデックス管理が得意です。競合分析のような「Webからデータを取得→LLMで処理」というフローではLangChainが適しています。一方、大量の社内ドキュメントを検索可能にしてLLMで回答させるシステムにはLlamaIndexが向いています。

OpenAI以外のLLM(Claude・Gemini等)でも使えますか?

使えます。LangChainはAnthropicのClaude・GoogleのGemini・Meta Llama・MistralなどさまざまなLLMに対応しています。ChatOpenAIの部分をChatAnthropicなどに差し替えるだけでほぼ同じコードが動きます。ローカルで動作するOllama経由のモデルも利用できるため、APIコストを完全にゼロにすることも可能です。

# Claudeを使う場合
# pip install langchain-anthropic
from langchain_anthropic import ChatAnthropic

llm = ChatAnthropic(
    model="claude-3-5-sonnet-20241022",
    temperature=0.1
)

# Ollamaでローカル実行する場合(APIコスト0円)
# ollama pull llama3.2 で事前にモデルをダウンロード
from langchain_ollama import ChatOllama

llm = ChatOllama(model="llama3.2", temperature=0.1)

分析結果の精度を上げるにはどうすればいいですか?

精度向上のためには以下のアプローチが有効です。①プロンプトの改善:分析観点を具体的に指定し、出力フォーマットをJSONや箇条書きで制約する。②モデルのアップグレード:GPT-4o miniからGPT-4oに変更することで分析の深さが向上します。③コンテキストの充実:取得するチャンク数を増やすか、自社のサービス説明をシステムプロンプトに追加することで、より文脈を踏まえた分析になります。④Few-shotプロンプト:過去の分析レポートの例をプロンプトに含めることで出力品質が安定します。


生徒

競合サイトの分析をAIで自動化できるんですね!週次で自動レポートが届く仕組みを作りたいです。Embeddingsでコンテンツギャップを発見できるのも面白いです!

ペン博士

GitHub Actionsと組み合わせれば完全自動化できるんじゃ!一度設定すれば毎週月曜日に競合レポートが生成されてくるので、戦略立案に集中できるようになるぞい。コスト面でも月数十円〜数百円で運用できるから、費用対効果は抜群じゃ。WithCodeでPythonとAPI連携を学べば、このような自動化ツールを自分で作れるようになるんじゃ!


まとめ

  • WebBaseLoader:URLを渡すだけでテキスト抽出、LangChainのDocument形式に自動変換してRAGやChainと直接連携できる。bs_kwargsでスクレイピング対象要素を絞り込み、header_templateでUser-Agentをカスタマイズできる。
  • TextSplitter:LLMのトークン上限を超えないようにテキストをチャンクに分割する。日本語コンテンツにはseparatorsに句点・読点を指定したRecursiveCharacterTextSplitterが推奨。
  • 実装構成:CompetitorAnalyzerクラスに要約・キーワード抽出・比較分析を集約し、main.pyから呼び出す設計。キャッシュ機能でAPIコストを削減できる。
  • Embeddings活用:FAISS VectorStoreと組み合わせることで、自社にないコンテンツギャップを発見したり、2サイト間の重複率を数値化できる。
  • 自動化:scheduleライブラリまたはGitHub Actionsで週次実行することで定期的な競合モニタリングを完全自動化できる。Slack通知との組み合わせでチーム共有もスムーズ。
  • コスト:GPT-4o miniを使えば週次5社分析でも約$0.085(月$0.34≒50円)。コスト効率に優れた自動化が実現できる。
  • 注意点:robots.txtの遵守・リクエスト間隔の確保・動的ページにはSelenium/Playwrightを使用する。

LangChain WebBaseLoaderで競合分析を自動化することで、毎週数時間かかっていたリサーチ作業を数分に短縮できます。Embeddingsを活用したコンテンツギャップ分析や、GitHub Actionsによる完全自動化まで、Pythonの基礎があれば今日から実装できます。


関連記事


参考リンク(公式・一次情報)


WithCodeを体験できる初級コース公開中!

WithCodeを体験できる初級コース公開中!

初級コース(¥49,800)が完全無料に!

  • 期間:1週間
  • 学習内容:
    ロードマップ/基礎知識/環境構築/HTML/CSS/LP・ポートフォリオ作成
    正しい学習方法で「確かな成長」を実感できるカリキュラム。

副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?

未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!

この記事を書いた人

WithCode(ウィズコード)は「目指すなら稼げる人材」をビジョンに、累計400名以上のフリーランスを輩出してきた超実践型プログラミングスクールです。150社以上の実案件支援を特徴にWeb制作・Webデザインなどの役立つ情報を現場のノウハウに基づいて発信していきます。

– service –WithGroupの運営サービス

  • WithCode
    - ウィズコード -

    スクール

    「未経験」から
    現場で通用する
    スキルを身に付けよう!

    詳細はこちら
  • WithFree
    - ウィズフリ -

    実案件サポート

    制作会社のサポート下で
    実務経験を積んでいこう!

    詳細はこちら

公式サイト より
今すぐ
無料カウンセリング
予約!

目次