2025年のGoogle I/Oで発表されたGemini APIの新機能「URL Context」を使って、Webサイトから自動で情報を抽出するシステムを構築しました。
「URLを投げるだけで中身をAIが理解してくれる」という魔法のような機能に見えましたが、実際に実装してみると様々な課題に直面しました。最終的に安定したシステムを構築するまでの試行錯誤をまとめます。
目次
URL Context機能とは
従来のWebスクレイピングでは、HTMLを取得してパースし、CSSセレクターやXPathで特定の要素を抽出する必要がありました。
1 2 3 4 5 6 7 | // 従来のスクレイピング const response = await fetch(url); const html = await response.text(); const $ = cheerio.load(html); const title = $('.page-title').text(); const content = $('.main-content').text(); // ... 各要素を個別に抽出 |
URL Context機能を使えば、これらの作業をAIに丸投げできます。
1 2 3 4 5 6 7 8 9 | // URL Context使用 const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent', { body: JSON.stringify({ contents: [{ parts: [{ text: `${url}から情報をJSON形式で抽出して` }] }], tools: [{ "url_context": {} }] // これが重要 }) }); |
最初の実装と失敗
当初、以下のようなシンプルな実装から始めました。
1 2 3 4 5 6 7 8 9 10 11 12 13 | export async function POST(request: NextRequest) { const { url } = await request.json(); const response = await fetch(geminiApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: `${url}の情報を抽出して` }] }] }) }); } |
発生した問題
間違った情報が返ってくる
- 入力: とあるWebサイトのページURL
- 出力: 全く関係ない別のサイトの情報
動作が不安定
- 同じURLでも結果が変わる
- 明らかに学習データから推測した内容
URL Context機能が動作していなかった
最大の原因はtools設定の不備でした。URL Context機能を使うには明示的に指定する必要があります。
1 2 3 4 5 | // 間違った実装 body: JSON.stringify({ contents: [{ parts: [{ text: `URL: ${url}` }] }] // tools設定なし → 通常のテキスト生成として処理される }) |
1 2 3 4 5 6 | // 正しい実装 body: JSON.stringify({ contents: [{ parts: [{ text: `URL: ${url}` }] }], tools: [{ "url_context": {} }], // 重要! model: "gemini-2.5-flash-preview-05-20" // 対応モデル }) |
また、URL Context機能は新しい機能のため、対応モデルが限定されていました。
原因分析
Webページが膨大だったのが大きな要因でした。
1 2 3 4 5 | 元のHTML: 208,922文字 ↓ Gemini内部処理: 6,377トークン ↓ 制限オーバー: 4,096トークン制限を超過 |
一般的なWebサイトには以下の要素が大量に含まれていました。
- JavaScript(数万行)
- CSS(複雑なスタイル定義)
- 広告・関連コンテンツ・ナビゲーション
- アナリティクス用のdata属性
解決策:ハイブリッドアプローチ
最終的に、従来のスクレイピング + AI分析のハイブリッドアプローチを採用しました。
実装フロー
- 自前でHTMLを取得
- 不要要素を徹底的に削除
- プレーンテキスト化
- Geminiで構造化データに変換
HTML軽量化の実装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | async function preprocessHTML(url) { // 1. HTMLを取得 const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } }); const html = await response.text(); // 2. 不要要素を削除 let cleanHtml = html .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // JavaScript .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // CSS .replace(/<!--[\s\S]*?-->/g, '') // コメント .replace(/class="[^"]*"/gi, '') // class属性 .replace(/id="[^"]*"/gi, '') // id属性 .replace(/style="[^"]*"/gi, '') // style属性 .replace(/data-[^=]*="[^"]*"/gi, ''); // data属性 // 3. HTMLタグを完全除去 cleanHtml = cleanHtml .replace(/<[^>]+>/g, ' ') // 全タグ削除 .replace(/\s+/g, ' ') // 空白正規化 .trim(); // 4. 文字数制限 return cleanHtml.substring(0, 8000); } |
Gemini APIへの送信
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | const response = await fetch(geminiApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: `以下のWebページテキストから必要な情報をJSON形式で抽出: ${preprocessedText} 出力形式: { "title": "タイトル", "category": "カテゴリ", "description": "説明", "details": "詳細情報" }` }] }], // URL Contextは使わない(前処理済みテキストのため) generationConfig: { temperature: 0.1, maxOutputTokens: 4096 } }) }); |
学んだこと
「URLを渡すだけ」という説明に惑わされ、tools設定を見落としており、公式ドキュメントをよく読むべきでした。
また、基本的にWebページは非常に重く、軽量化なしではAI APIの制限にすぐ引っかかるので考慮する必要がありそうです。
「AIですべて解決」ではなく、現状は従来技術と組み合わせることで、より確実で効率的なシステムを作ることを目指すのが良さそうです。
現在、一部の項目で「N/A」が出力される場合があり、サイト構造の分析と、より適切な文字数制限の設定が今後必要になりそうです。