はじめに
複数のWebサイトを横断して情報を検索したいと思ったことはありませんか?例えば、技術的な質問に対して「Qiita」「Zenn」「Stack Overflow」を同時に検索したい、SNSの情報をまとめて検索したい、といったニーズがあると思います。
しかし、サイトを一つずつ順次検索していては、結果表示までにもどかしい時間を要します。例えば5つのサイトを検索する場合、1サイトあたり300msかかるとすると、順次検索では約1.5秒かかります。
本記事では、Google Custom Search JSON APIとNode.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: ガラスモーフィズムデザインのレスポンシブインターフェース
- 📊 結果のグループ化: サイトごとに結果を整理して表示
技術スタック
- バックエンド: 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
順次処理と並列処理の違いを視覚化。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側のエラー、または予期しないエラー | しばらく待ってから再試行 |
エラーハンドリングの設計思想:
- 部分的な失敗を許容: 一部のサイトでエラーが発生しても、他のサイトの結果は表示
- 詳細なログ出力: デバッグしやすいように、エラー詳細をコンソールに出力
- ユーザーへの適切な通知: エラーの種類に応じて、分かりやすいメッセージを返す
- エラーの分離: 個別サイトのエラーと全体のエラーを分けて処理
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をチェックするだけで十分です。
エラー表示の流れ:
-
サーバーがエラーレスポンスを返す(例:
{ error: 'APIキーが無効です' }) -
フロントエンドが
data.errorを検出 -
showError()メソッドでエラーメッセージを表示
}showLoading() {
this.resultsContainer.innerHTML =<div class="loading"> <p>検索中...</p> </div>;
}
showError(message) {
this.resultsContainer.innerHTML = `
<div class="error">
<p>${message}</p>
</div>
`;
}
}

検索実行時のローディング表示。スピナーアニメーションが表示されます。
結果のグループ化表示
サイトごとに結果を整理して表示します:
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"でセキュリティを確保

検索実行後の結果表示。複数のサイト(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);
}
半透明の背景とぼかし効果で、モダンな見た目を実現しています。
スマートフォンサイズでの表示。レスポンシブデザインにより、モバイルでも使いやすい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キーの取得
- Google Cloud Consoleにアクセス
- 新しいプロジェクトを作成または既存のプロジェクトを選択
- 「APIとサービス」→「ライブラリ」から「Custom Search JSON API」を検索して有効化
- 「認証情報」→「認証情報を作成」→「APIキー」でAPIキーを作成
Custom Search Engine IDの取得
- Programmable Search Engineにアクセス
- 「新しい検索エンジンを作成」をクリック
- サイトを指定(例:
www.google.comで全Web検索) - 検索エンジン名を入力して作成
- コントロールパネルで「検索エンジン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 にアクセスして使用できます。
使い方
- 検索したいサイトにチェックを入れる(複数選択可)
- 検索ボックスにキーワードを入力
- 「検索」ボタンをクリックまたはEnterキーを押下
- サイトごとにグループ化された結果が表示される
上記のトップ画面から検索を実行すると、検索結果画面のように複数サイトの結果が表示されます。
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を使用することで、比較的簡単に複数サイト横断検索アプリを実装できました。
実装のポイント
-
並列処理による高速化:
Promise.all()を使用することで、複数サイトの検索を同時に実行し、約3-5倍の高速化を実現 - 堅牢なエラーハンドリング: 個別サイトのエラーと全体のエラーを分離し、部分的な失敗を許容する設計
- シンプルなコード: フレームワークを使わずにVanilla JavaScriptで実装することで、依存関係が少なく、理解しやすいコードに
学んだこと
-
Promise.all()による並列処理と高速化- 複数の非同期処理を同時に実行し、約3-5倍の高速化を実現
- 個別の処理でエラーが発生しても全体が停止しない堅牢な設計
-
エラーハンドリングの設計パターン
- 一部のサイトでエラーが発生しても、他のサイトの結果は正常に表示
-
Promise.all()の特性を活用した部分的な失敗を許容する設計
-
X(Twitter)対応
-
site:x.com OR site:twitter.comという検索クエリで、X.comとTwitter.comの両方を検索
-
-
環境変数の優先順位
-
.env.localと.envの両方をサポート(.env.localが優先)
-
-
Google Custom Search APIの制限とコスト
- APIキーとCSE IDの役割の違い
- 無料枠の制限(1日100リクエスト)とコストの理解
-
セキュリティの考慮
- APIキーは絶対にクライアント側に露出させない
- 環境変数で管理し、
.gitignoreに追加
注意: 本番環境で運用する場合は、API制限とコストを十分に理解した上で実装してください。
ぜひ、このアプリをベースに自分好みの検索アプリを作ってみてください!
💬 質問・フィードバック
質問や感想はコメント欄でお気軽にどうぞ。より詳しい相談はプロフィールからどうぞ。
最後まで読んでいただき、ありがとうございました。ぜひ、このアプリを試してみてください!
参考リンク
技術スタック: Node.js, Express.js, Google Custom Search API, Vanilla JavaScript


