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

Google Custom Search APIで爆速!Node.js & Promise.allで実現する「複数サイト横断検索アプリ」実装と高速化の秘訣

0
Posted at

はじめに

複数のWebサイトを横断して情報を検索したいと思ったことはありませんか?例えば、技術的な質問に対して「Qiita」「Zenn」「Stack Overflow」を同時に検索したい、SNSの情報をまとめて検索したい、といったニーズがあると思います。

しかし、サイトを一つずつ順次検索していては、結果表示までにもどかしい時間を要します。例えば5つのサイトを検索する場合、1サイトあたり300msかかるとすると、順次検索では約1.5秒かかります。

本記事では、Google Custom Search JSON APINode.jsのPromise.all()を使用して、複数サイトを並列検索することで約3-5倍の高速化を実現するWebアプリケーションの実装方法を紹介します。並列処理により、5サイトの検索が約300-500msで完了するようになります。

⚠️ 重要な注意事項

Google Custom Search JSON APIには以下の制限があります:

  • 無料枠: 1日100リクエストまで
  • 有料プラン: 100リクエスト以降は $5/1,000リクエスト
  • 複数サイト検索の影響: 5つのサイトを同時検索すると、1回の検索で5リクエスト消費します

この記事は学習目的での実装方法を紹介しています。本番環境で運用する場合は、上記の制限を十分に理解した上で実装してください。

このアプリの特徴

  • 🔍 複数サイトの同時検索: Qiita、Zenn、GitHub、X(Twitter)など、複数のサイトを一度に検索
  • 並列処理による高速化: サイトごとの検索を並列実行してパフォーマンスを最適化
  • 🎨 モダンなUI: ガラスモーフィズムデザインのレスポンシブインターフェース
  • 📊 結果のグループ化: サイトごとに結果を整理して表示

01-top-screen.png

技術スタック

  • バックエンド: Node.js + Express.js
  • フロントエンド: Vanilla JavaScript(フレームワーク不使用)
  • API: Google Custom Search JSON API
  • スタイリング: CSS3

使用パッケージ

{
  "dependencies": {
    "express": "^4.18.2",
    "axios": "^1.6.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}
  • express: Webサーバーフレームワーク
  • axios: HTTPクライアント(Google API呼び出し用)
  • cors: クロスオリジンリソース共有の設定
  • dotenv: 環境変数の管理
  • nodemon: 開発時の自動リロード

プロジェクト構成

webSerch/
├── src/
│   └── server.js          # Expressサーバー
├── public/
│   └── index.html         # フロントエンドHTML
├── js/
│   └── app.js            # フロントエンドJavaScript
├── css/
│   └── style.css         # スタイルシート
├── config/
│   └── google.js         # API設定の参考ファイル
└── package.json

実装のポイント

1. バックエンド実装(Express.js)

ミドルウェアの設定

const app = express();
const PORT = process.env.PORT || 3000;

// ミドルウェアの設定
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
app.use('/css', express.static('css'));
app.use('/js', express.static('js'));
  • cors(): すべてのオリジンからのリクエストを許可(開発環境用)
  • express.json(): JSONリクエストボディをパース
  • express.static(): 静的ファイル(HTML、CSS、JS)を配信

並列検索の実装

複数のサイトを同時に検索するため、Promise.all()を使用して並列処理を実現しています:

// サイトごとに並列で検索を実行
const searchPromises = sites.map(async (site) => {
    const url = `https://www.googleapis.com/customsearch/v1`;
    let searchSite = site;
    
    // X(旧Twitter)対応
    if (site === 'x.com') {
        searchSite = 'site:x.com OR site:twitter.com'; // キーコード:X.comとTwitter.comの両方を検索
    } else {
        searchSite = `site:${site}`;
    }

    const params = {
        key: GOOGLE_API_KEY,
        cx: GOOGLE_CSE_ID,
        q: `${query} ${searchSite}`,
        num: 10 // 各サイト10件ずつ
    };

    console.log(`Searching for ${site}... Query: ${params.q}`);

    try {
        const response = await axios.get(url, { params });
        console.log(`Success ${site}: ${response.data.items?.length || 0} items`);
        return {
            site: site,
            items: response.data.items || []
        };
    } catch (error) {
        console.error(`Error searching ${site}:`, error.message);
        if (error.response) {
            console.error('API Error Details:', JSON.stringify(error.response.data, null, 2));
        }
        return {
            site: site,
            items: [],
            error: error.response?.data?.error?.message || '検索に失敗しました'
        };
    }
});

const results = await Promise.all(searchPromises); // 重要: このPromise.all()が並列処理の核心です。すべての検索を同時に実行し、完了を待機します。

並列処理の効果(理論値):

Google Custom Search APIの1リクエストあたりの平均レスポンス時間は約300-500msです。5つのサイトを検索する場合:

  • 順次検索の場合: 各サイトを1つずつ検索

    • 合計時間 = 300ms × 5サイト = 約1.5秒
  • 並列検索の場合: すべてのサイトを同時に検索

    • 合計時間 = 最長のリクエスト時間 = 約300-500ms

結果:並列処理により約3-5倍の高速化を実現
sequential-vs-parallel-search-comparison.png

順次処理と並列処理の違いを視覚化。Promise.all()を使用することで、5サイトの検索が約1.5秒から約300-500msに短縮されます。

計算例:

  • サイト数が増えるほど効果が大きくなります
    • 3サイト: 順次900ms → 並列300ms(3倍
    • 5サイト: 順次1,500ms → 並列300ms(5倍
    • 10サイト: 順次3,000ms → 並列300ms(10倍

ポイント:

  • 各サイトの検索を独立して実行し、エラーが発生しても他のサイトの検索は継続
  • Promise.all()により、すべての検索が完了するまで待機
  • エラーログを出力してデバッグを容易に
  • サイト数が増えるほど、並列処理の効果が顕著に

サイト指定がない場合の処理

サイトが指定されていない場合は、通常のWeb検索を実行:

if (!sites || !Array.isArray(sites) || sites.length === 0) {
    const params = {
        key: GOOGLE_API_KEY,
        cx: GOOGLE_CSE_ID,
        q: query,
        num: 10
    };
    const response = await axios.get(url, { params });
    return res.json({
        results: [{
            site: 'Web全体',
            items: response.data.items || []
        }]
    });
}

エラーハンドリング

API制限や認証エラーを適切に処理します。エラーハンドリングは2段階で実装しています:

1. 個別サイト検索のエラーハンドリング

各サイトの検索でエラーが発生しても、他のサイトの検索は継続します:

try {
    const response = await axios.get(url, { params });
    return {
        site: site,
        items: response.data.items || []
    };
} catch (error) {
    console.error(`Error searching ${site}:`, error.message);
    if (error.response) {
        console.error('API Error Details:', JSON.stringify(error.response.data, null, 2));
    }
    return {
        site: site,
        items: [],
        error: error.response?.data?.error?.message || '検索に失敗しました'
    };
}

エラーの種類と対処:

  • ネットワークエラー: タイムアウトや接続エラー → 空の結果を返し、他のサイトの検索は継続
  • APIエラー(400, 403, 429など): API側のエラー → エラーメッセージを記録し、空の結果を返す
  • 予期しないエラー: その他のエラー → 汎用エラーメッセージを返す

2. 全体のエラーハンドリング

リクエスト全体でエラーが発生した場合の処理:

catch (error) {
    console.error('Search API error:', error.response?.data || error.message);
    
    if (error.response?.status === 403) {
        // APIキーが無効、またはAPIが有効化されていない
        res.status(403).json({ error: 'APIキーが無効です' });
    } else if (error.response?.status === 429) {
        // レート制限に達した(1日100リクエストを超えた)
        res.status(429).json({ error: 'API制限に達しました' });
    } else {
        // その他のエラー(500番台など)
        res.status(500).json({ error: '検索中にエラーが発生しました' });
    }
}

エラーレスポンスの詳細:

HTTPステータス エラーの種類 発生条件 対処方法
403 認証エラー APIキーが無効、またはAPIが有効化されていない APIキーとAPI有効化を確認
429 レート制限 1日の無料枠(100リクエスト)を超えた 24時間待つか有料プランにアップグレード
500 サーバーエラー Google API側のエラー、または予期しないエラー しばらく待ってから再試行

エラーハンドリングの設計思想:

  1. 部分的な失敗を許容: 一部のサイトでエラーが発生しても、他のサイトの結果は表示
  2. 詳細なログ出力: デバッグしやすいように、エラー詳細をコンソールに出力
  3. ユーザーへの適切な通知: エラーの種類に応じて、分かりやすいメッセージを返す
  4. エラーの分離: 個別サイトのエラーと全体のエラーを分けて処理

2. フロントエンド実装(Vanilla JavaScript)

クラスベースの設計

ES6のクラス構文を使用して、保守性の高いコードを実現:

class GoogleSearchApp {
    constructor() {
        this.searchInput = document.getElementById('searchInput');
        this.searchButton = document.getElementById('searchButton');
        this.resultsContainer = document.getElementById('results');
        this.init();
    }
    
    init() {
        this.searchButton.addEventListener('click', () => this.performSearch());
        this.searchInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                this.performSearch();
            }
        });
    }
    
    async performSearch() {
        const query = this.searchInput.value.trim();
        const checkedSites = Array.from(
            document.querySelectorAll('input[name="site"]:checked')
        ).map(cb => cb.value);
        
        if (!query) {
            this.showError('検索キーワードを入力してください');
            return;
        }
        
        this.showLoading();
        
        try {
            const response = await fetch('/api/search', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ 
                    query,
                    sites: checkedSites 
                })
            });
            
            const data = await response.json();
            
            if (data.error) {
                this.showError(data.error);
            } else {
                this.displayGroupedResults(data.results || []);
            }
        } catch (error) {
            this.showError('検索中にエラーが発生しました');
            console.error('Search error:', error);
        }

フロントエンドのエラーハンドリング:

フロントエンドでは、サーバーから返されたエラーメッセージをそのまま表示します。サーバー側で適切にエラーハンドリングされているため、フロントエンドではdata.errorをチェックするだけで十分です。

エラー表示の流れ:

  1. サーバーがエラーレスポンスを返す(例: { error: 'APIキーが無効です' }

  2. フロントエンドがdata.errorを検出

  3. showError()メソッドでエラーメッセージを表示
    }

    showLoading() {
    this.resultsContainer.innerHTML = <div class="loading"> <p>検索中...</p> </div>;
    }

    showError(message) {
        this.resultsContainer.innerHTML = `
            <div class="error">
                <p>${message}</p>
            </div>
        `;
    }
}

03-loading-screen.png
検索実行時のローディング表示。スピナーアニメーションが表示されます。

結果のグループ化表示

サイトごとに結果を整理して表示します:

displayGroupedResults(results) {
    if (results.length === 0 || results.every(r => r.items.length === 0)) {
        this.resultsContainer.innerHTML = `
            <div class="loading">
                <p>検索結果が見つかりませんでした</p>
            </div>
        `;
        return;
    }

    const resultsHTML = results.map(group => {
        if (group.items.length === 0) return '';

        const itemsHTML = group.items.map(result => `
            <div class="result-item">
                <a href="${result.link}" class="result-title" target="_blank" rel="noopener">
                    ${result.title}
                </a>
                <p class="result-snippet">${result.snippet}</p>
                <p class="result-url">${result.displayLink}</p>
            </div>
        `).join('');

        return `
            <div class="site-results-group">
                <h2 class="site-title">${this.getSiteName(group.site)}</h2>
                <div class="site-items">${itemsHTML}</div>
            </div>
        `;
    }).join('');
    
    this.resultsContainer.innerHTML = resultsHTML;
}

getSiteName(domain) {
    const names = {
        'qiita.com': 'Qiita',
        'linkedin.com': 'LinkedIn',
        'x.com': 'X (Twitter)',
        'instagram.com': 'Instagram',
        'zenn.dev': 'Zenn',
        'note.com': 'note',
        'stackoverflow.com': 'Stack Overflow',
        'github.com': 'GitHub',
        'wikipedia.org': 'Wikipedia',
        'Web全体': 'Web全体'
    };
    return names[domain] || domain;
}

ポイント:

  • 空の結果(items.length === 0)のサイトは表示しない
  • すべてのサイトで結果がない場合のみ「検索結果が見つかりませんでした」を表示
  • getSiteName()でドメイン名を読みやすい名前に変換
  • rel="noopener"でセキュリティを確保

02-search-results.png
検索実行後の結果表示。複数のサイト(Qiita、LinkedInなど)の結果がサイトごとにグループ化されて表示されます。

3. UI実装(ガラスモーフィズムデザイン)

ガラスモーフィズムデザインの主要な実装ポイント:

.search-wrapper {
    background: rgba(255, 255, 255, 0.85);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255, 255, 255, 0.4);
    border-radius: 24px;
    box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
}

半透明の背景とぼかし効果で、モダンな見た目を実現しています。

05-mobile-view.png

スマートフォンサイズでの表示。レスポンシブデザインにより、モバイルでも使いやすいUIになっています。

セットアップ方法

1. 依存パッケージのインストール

npm install

2. Google APIの設定

APIキーとCSE IDの役割

  • APIキー: 誰がAPIを使用するかを識別する認証情報(Google Cloud Consoleで作成)
  • CSE ID(Custom Search Engine ID): どの検索エンジンを使うかを指定するID(Programmable Search Engineで作成)

両方とも必要です。APIキーは「認証」、CSE IDは「検索対象の設定」を担当します。

APIキーの取得

  1. Google Cloud Consoleにアクセス
  2. 新しいプロジェクトを作成または既存のプロジェクトを選択
  3. 「APIとサービス」→「ライブラリ」から「Custom Search JSON API」を検索して有効化
  4. 「認証情報」→「認証情報を作成」→「APIキー」でAPIキーを作成

Custom Search Engine IDの取得

  1. Programmable Search Engineにアクセス
  2. 「新しい検索エンジンを作成」をクリック
  3. サイトを指定(例: www.google.com で全Web検索)
  4. 検索エンジン名を入力して作成
  5. コントロールパネルで「検索エンジンID」を確認

Custom Search Engineの設定について:

  • 全Web検索: サイト指定にwww.google.comを入力すると、Web全体から検索できます
  • 特定サイトのみ検索: コントロールパネルの「サイトを追加」から、検索対象のサイトを追加できます
  • サイトの除外: コントロールパネルで特定のサイトを除外することも可能です

このアプリでは、コード内でsite:オペレーターを使用して特定サイトに絞り込むため、CSEは全Web検索の設定で問題ありません。

3. 環境変数の設定

.envファイルを作成:

GOOGLE_API_KEY=あなたのGoogle APIキー
GOOGLE_CSE_ID=あなたのCustom Search Engine ID
PORT=3000

重要: .envファイルは.gitignoreに追加して、リポジトリにコミットしないでください。

4. アプリケーションの起動

# 開発モード
npm run dev

# 本番モード
npm start

ブラウザで http://localhost:3000 にアクセスして使用できます。

使い方

  1. 検索したいサイトにチェックを入れる(複数選択可)
  2. 検索ボックスにキーワードを入力
  3. 「検索」ボタンをクリックまたはEnterキーを押下
  4. サイトごとにグループ化された結果が表示される

上記のトップ画面から検索を実行すると、検索結果画面のように複数サイトの結果が表示されます。

APIエンドポイント

POST /api/search

検索を実行するエンドポイント。

リクエストボディ:

{
  "query": "検索キーワード",
  "sites": ["qiita.com", "zenn.dev", "github.com"]
}

レスポンス:

{
  "results": [
    {
      "site": "qiita.com",
      "items": [
        {
          "title": "記事タイトル",
          "link": "https://...",
          "snippet": "記事の抜粋...",
          "displayLink": "qiita.com"
        }
      ]
    }
  ]
}

GET /health

ヘルスチェックエンドポイント。サーバーの状態を確認できます。

{
  "status": "OK",
  "timestamp": "2024-01-01T00:00:00.000Z"
}

トラブルシューティング

よくあるエラーと対処法

APIキーが無効です(403エラー)

  • .envファイルのGOOGLE_API_KEYを確認
  • Google Cloud Consoleで「Custom Search JSON API」が有効化されているか確認

API制限に達しました(429エラー)

  • 1日の無料枠(100リクエスト)を超えた場合、24時間待つか有料プランにアップグレード

環境変数が読み込まれない

  • .envファイルがpackage.jsonと同じディレクトリにあるか確認
  • .envファイルの形式を確認(KEY=valueの形式、スペースなし)

検索結果が返ってこない

  • 検索キーワードを変更してみる
  • ブラウザの開発者ツールでネットワークリクエストを確認
  • サーバーのコンソールログを確認(Searching for...Success...のログを確認)

一部のサイトだけ検索結果が返ってこない

  • これは正常な動作です。各サイトの検索は独立して実行されるため、一部のサイトでエラーが発生しても他のサイトの結果は表示されます
  • サーバーのコンソールログでError searching...のメッセージを確認し、エラーの原因を特定してください
  • よくある原因:指定したサイトに該当する結果がない、サイトのドメインが正しくない

ネットワークエラーが発生する

  • インターネット接続を確認
  • Google APIのステータスを確認(Google Cloud Status
  • タイムアウトが発生している場合は、しばらく待ってから再試行

📝まとめ

Google Custom Search JSON APIを使用することで、比較的簡単に複数サイト横断検索アプリを実装できました。

実装のポイント

  1. 並列処理による高速化: Promise.all()を使用することで、複数サイトの検索を同時に実行し、約3-5倍の高速化を実現
  2. 堅牢なエラーハンドリング: 個別サイトのエラーと全体のエラーを分離し、部分的な失敗を許容する設計
  3. シンプルなコード: フレームワークを使わずにVanilla JavaScriptで実装することで、依存関係が少なく、理解しやすいコードに

学んだこと

  1. Promise.all()による並列処理と高速化

    • 複数の非同期処理を同時に実行し、約3-5倍の高速化を実現
    • 個別の処理でエラーが発生しても全体が停止しない堅牢な設計
  2. エラーハンドリングの設計パターン

    • 一部のサイトでエラーが発生しても、他のサイトの結果は正常に表示
    • Promise.all()の特性を活用した部分的な失敗を許容する設計
  3. X(Twitter)対応

    • site:x.com OR site:twitter.comという検索クエリで、X.comとTwitter.comの両方を検索
  4. 環境変数の優先順位

    • .env.local.envの両方をサポート(.env.localが優先)
  5. Google Custom Search APIの制限とコスト

    • APIキーとCSE IDの役割の違い
    • 無料枠の制限(1日100リクエスト)とコストの理解
  6. セキュリティの考慮

    • APIキーは絶対にクライアント側に露出させない
    • 環境変数で管理し、.gitignoreに追加

注意: 本番環境で運用する場合は、API制限とコストを十分に理解した上で実装してください。

ぜひ、このアプリをベースに自分好みの検索アプリを作ってみてください!


💬 質問・フィードバック

質問や感想はコメント欄でお気軽にどうぞ。より詳しい相談はプロフィールからどうぞ。


最後まで読んでいただき、ありがとうございました。ぜひ、このアプリを試してみてください!

参考リンク


技術スタック: Node.js, Express.js, Google Custom Search API, Vanilla JavaScript

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