Google Apps ScriptとGemini APIでウェブサイト情報をスクレイピング【GAS】

2025年8月にGemini APIの新機能として「URL Context tool」と呼ばれる、Geminiが直接ページにアクセスしてプロンプトに従って情報を抽出するという機能が搭載されました。

この機能を利用して、GASから使える第四のスクレイピング手段として使えないかな?と思ったのですが、色々と壁があり自分が思ってるような動きにする為には他の仕組みも必要であったので、それらを包括してチャレンジした内容をまとめています。

今回利用する素材

今回の取り組み事例は「病院名を10件列挙し、病院名・住所・代表電話・院長名・ウェブサイトURL」の5セットを1レコードにしてスプレッドシートに書き出すというもの。病院名はGemini APIに適当に列挙してもらうわけですが、問題は対象のウェブサイトのURLは当然一個ずつ調べる必要があります。

また住所や電話番号の情報も別途必要なのですがこれらを手動で調べて用意して・・・ではあまりに手間が掛かりすぎる。これらを全て自動化して取得させるのが今回の目的です。

Google Apps Scriptでスクレイピングを極める【GAS】

URL Contextを使うための壁

URL Contextについて

Gemini API自身は実は素の状態ではライブ情報やウェブサイト情報に直接アクセスすることが出来ません。どれだけプロンプトで指示をだした所で、キャッシュや学習内容から払出しを行おうとします。故に学習内容が極端に古く、今回のような現在の情報を得ようとしたら全く使えません。

しかし、Geminiアプリで利用する際にはURLを添付することでアクセスしてくれます。ここがアプリとAPIの大きな違いで、APIで利用する場合には今回リリースされたURL Context」というオプションをリクエストに追加しなければなりません。しかも、必ずプロンプト本文内に該当のURLが入っていなければならない。

しかし、今回のような自動化の場合、病院名の列挙からデータ取得まで自動化であるため、事前に自分で病院のURLをデータとして揃えておいて・・・では手間が掛かりすぎる。そこで何らかの手法で各病院のURLを入手する必要があります。

また、院長名を取得するためには各病院のご挨拶ページに掲載されてる院長からの挨拶に名前が記載されているので、これを取得するには「ご挨拶ページのURL」も取得が必要です。故にこれ単独では目的を達成することが出来ません。該当のURLが単発で知ってる物であるならば、URL ContextにURL付きプロンプトを渡せば一発なのですが・・・

公開ウェブサイトにしかアクセスは出来ません。またウェブ上にあるPDFなどに直接アクセスさせて要約といったことも可能です。

Geminiのコード生成で正確性と確実性を重視させてみる

Google 検索によるグラウンディング

一方、もう一つToolとしてGoogle検索によるグラウンディングというものがあります。これは、URL Context同様にリクエストオプションに追加することで「Google検索してライブ情報を取得する」ということが可能になるものです。但しウェブサイトをスクレイピングする目的ではなく、例えば「今日の東京の天気をエリア毎に教えて」といった「今日」というライブ情報を調べる際に使うもので、特定のウェブサイトをスクレイピングするものではない為、目的に合致していませんし出来ません。

逆にGoogle検索によるグラウンディングを使わないと、Gemini API単体では「今日」のデータではなく、適当に捏造したデータや学習済みの過去のデータから払い出しをしようとし、無い場合には回答できないというケースに陥ります。

また、一方でこの検索によるグラウンディングで「◯◯病院の住所や電話番号、URLを調べて」としても残念ながら誤った情報やアクセス出来ないURL(存在しなかったり、ドメインがそもそも間違ってる)が返ってくるケースが多々ありました。故に今回のチャレンジでは外すことにしました。

他のAPIを併用する意味

そこでGoogle検索に関しては、別のAPIを利用したほうが100%確実な情報を取得できる為、Gemini APIではやらせない方向にしました。しかしここにも大きな壁があり、3つのAPIを駆使しなければ今回のようなデータを完成させられませんでした。

Gemini APIが担当する領域

Gemini APIが今回のチャレンジの中で担当するのは2箇所になります。

  • 病院名を適当にランダムに10件指定の要件で生成する
  • URL Contextを使って指定URLから院長名を抽出する

指定のURLを取得するのが困難であるため、その為に後述の3つのAPIを駆使して情報を集めます。

Custom Search JSON API

いわゆるGoogle検索をやらせて答えを得る為のAPI。但しこのAPIを使うには、プログラム可能な検索エンジンを用意する必要があり、そこで作成した検索エンジンのCX値が必要になります。

この結果、特定検索ワードの結果の指定のURLを取得することが可能になります。但し、取得できるのはURLのみで、他の情報は正確に取得しがたい為、後述の2つのAPIで別途取得させているという状況です。

Find Place API

住所や電話番号といった対象病院の固有の情報を検索するには、Place APIを利用するのですがその為には「place_id」が必要になります。Place Details APIで利用する為に必要な場所固有に割り当てられてるIDであり、これを病院名を元に検索させてplace_idを取得させるのがFind Place APIの役割です。Place APIの内部にあるAPIとなります。

今回は古い従来版を使っています。新しいPlace Search APIというものもリリースされています。

Place Detail API

Find Place APIで取得したplace_idを元に、対象の病院の正確な住所・電話番号・公式サイトのURLを取得させるのがPlace Detail APIの役割です。こちらもPlace APIの内部にあるAPIとなります。

今回は古い従来版を使っていますが、新しいPlace Detail API (新規)というものもリリースされています。

事前準備

APIキーの取得

今回はプロジェクト連結ではなく、従来通りのAPIキーを利用した方法にしています。以下のエントリーにてAPIキーの取得や、取得したAPIキーをスクリプトプロパティに格納する方法をまとめていますので、事前に用意しておく必要があります。

apikeyというキーに対して取得しておいたAPIキーをスクリプトプロパティに格納しておきましょう。

GeminiのAPIキーの取得と学習の可否

APIを有効化

Google Cloud側で今回利用するAPIを有効化する必要性があります。

  1. Google Cloudを開き、サイドバーからAPIとサービスを開きます。
  2. 上部にある「APIとサービスの有効化」をクリック
  3. 「gemini」を検索し、Gemini APIクリックします。
  4. 有効化をクリックします。
  5. 同様に、「places api」で検索してNewではないほうのPlaces APIを有効化
  6. おなじく、「custom search」で検索してCustom Search APIを有効化します。

有効化するのがGemini APIなのに、ダッシュボード上ではGenerative Language APIとなってる点に要注意です。

図:Custom Search APIを有効化

図:Places APIを有効化

Custom Search Engineの設定

Custom Search JSON APIで利用する為にプログラム可能な検索エンジンというものを用意します。用意しただけでは課金はされませんが、Custom Search JSON APIで一回クエリを発行すると1回カウントされ、1日最大100件までは無料で使えます。

以下の手順で作成し、CX値というものを取得しておきます。

  1. こちらのサイトを開く
  2. 追加をクリックする
  3. 検索エンジン名は適当につける(今回はhospitalにしました)
  4. 特定のサイトまたはページを検索では、今回は「*.jp」としてJPドメインのみを指定しました。複数行で指定が可能なので、ドメインの一部などを続けて改行して入れると良いでしょう。
  5. reCaptchaがあるのでチェックを入れる
  6. 作成をクリックする
  7. 一覧に戻って自分が作った検索エンジン名をクリックする
  8. 検索エンジン IDというのがあるので、これがCX値となるのでコピーしておきます。

図:API用の検索エンジンを用意する

図:検索エンジン作成中の様子

図:CX値を取得する

ソースコード

グローバル設定

主にGemini APIのエンドポイント用の変数とonOpenのメニューを用意しておきます。前述で取得しておいた検索エンジンのCX値も変数としてここに入れておいてあげます。

//モデル
const model = "gemini-2.5-pro";

//エンドポイントURL
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=`;

//Custom Search Engine (CSE) の設定
const CX = "ここにCX値をいれてあげる";

function onOpen(e) {
  let ui = SpreadsheetApp.getUi();
  ui.createMenu('▶プログラム')
      .addItem('スクレイピング実行', 'geminiScraper')
      .addToUi();
}

呼び出すメイン関数

メインの呼び出す側関数であるため、具体的な処理については次項の補助関数達が行っています。情報をまとめあげてスプシに書き出しなどをコントロールするための関数になります。

//ウェブ情報を収集してスプシに書き出すメイン関数
function fetchHospitalData() {
  const prop = PropertiesService.getScriptProperties();
  const apikey = prop.getProperty("apikey"); 

  //Gemini API で東京都内の病院名を生成
  const hospitalNames = generateHospitalNames(apikey);
  const results = [];

  for (let name of hospitalNames) {
    // Places API で place_id, 住所, 電話番号を取得
    const placeInfo = getPlaceInfo(name, apikey);
    if (!placeInfo) continue;

    // Custom Search API で「院長 挨拶」ページを探す
    const greetingUrl = searchGreetingPage(name, apikey);

    results.push({
      hospital: name,
      place_id: placeInfo.place_id,
      address: placeInfo.address,
      tel: placeInfo.phone,
      website: placeInfo.website,
      greeting: greetingUrl
    });
  }

  //病院情報からgreetingのURLを使って院長名をまとめて取得(Gemini APIのURL Contextを使う)
  let lasthosp = addDirectorsBatch(results);

  //集約結果をスプレッドシートに書き出す
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const sheet = ss.getSheetByName("hospital");

  //シート情報をクリアする
  sheet.getRange("A2:F").clearContent();

  //情報を配列に作り直す
  let array = [];

  for(let i = 0;i<lasthosp.length;i++){
    //レコードを一個取り出す
    let rec = lasthosp[i];

    //uuidを生成する
    let uuid = Utilities.getUuid()

    //一時配列を構成する
    let temparr = [
      uuid,
      rec.hospital,
      rec.address,
      rec.tel,
      rec.director,
      rec.website
    ]

    //書き出し用配列に追加
    array.push(temparr);
  }

  //データの一括出力
  let lastColumn = array[0].length; //カラムの数を取得する
  let lastRow = array.length;       //行の数を取得する
  sheet.getRange(2,1,lastRow,lastColumn).setValues(array);

  //終了メッセージ
  ui.alert("情報取得完了");
}

その他の情報を収集する補助関数

病院名を生成する

プロンプト指定で適当に「東京都内の病院でクリニックや歯科を除いた10件の病院名を列挙」させる為だけの関数です。このリストを持ってして次の処理を行わせることになります。

// Geminiで病院名生成
function generateHospitalNames(apiKey) {
  //リクエストURLを構築
  const requesturl = endpoint + apiKey;

  //プロンプトを構築
  const prompt = `
  東京都内に存在する「病院」をランダムに10件リスト化してください。
  - クリニック、歯科は除外
  - 病院名のみを返す
  - JSON配列 ["病院1","病院2",...] の形式で返す
  `;

  //ランダムに病院名を生成する
  const payload = {
    contents: [{ parts: [{ text: prompt }] }],
    generationConfig: { temperature: 0.9 }      //ランダム性を挙げるために0より1に近づける
  };

  //リクエストオプション
  const options = {
    method: "POST",
    contentType: "application/json",
    payload: JSON.stringify(payload)
  };

  //Gemini APIリクエストを実行
  const res = UrlFetchApp.fetch(requesturl, options);
  const json = JSON.parse(res.getContentText());
  let text = json.candidates[0].content.parts[0].text;

  //余計な文字を除外する
  let ret = text.replace("```json","");
  ret = ret.replace("```javascript","");
  ret = ret.replace("```","");
  ret = ret.replace("\n","");

  //答えを返す
  return JSON.parse(ret);
}

place_idを取得する関数

病院名を元にplace_idを取得し、その値をもってして住所や電話番号、ウェブサイトアドレスを取得して返す関数です。Google Maps登録の情報を使っている為非常に正確です。

// Places APIでplace_id取得
function getPlaceInfo(name, apiKey) {
  // Find Place API
  const url = `https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=${encodeURIComponent(name)}&inputtype=textquery&fields=place_id,name,formatted_address&language=ja&key=${apiKey}`;
  const res = UrlFetchApp.fetch(url);
  const data = JSON.parse(res.getContentText());
  if (!data.candidates || data.candidates.length === 0) return null;

  //Place_idを取得する
  const placeId = data.candidates[0].place_id;

  // Place Details API
  const detailsUrl = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=name,formatted_address,formatted_phone_number,website&language=ja&key=${apiKey}`;
  const detailsRes = UrlFetchApp.fetch(detailsUrl);
  const details = JSON.parse(detailsRes.getContentText());

  if (!details.result) return null;

  //Placeの他の情報をマージして返す
  return {
    place_id: placeId,
    address: details.result.formatted_address || "",
    phone: details.result.formatted_phone_number || "",
    website: details.result.website || ""
  };
}

挨拶ページURL取得する関数

日本の病院のウェブサイトは割と統一されており、「病院名+院長+挨拶」でおおよそ院長の名前が掲載されているページを調べ上げることが可能です。このご挨拶のページのURLを取得するのが目的の補助関数です。URLしか取得出来ないので、実際の中身からスクレイピングするには次項のURL Contextを使った関数がまとめて担当します。

// Custom Search JSON APIで挨拶ページURL取得
function searchGreetingPage(name, apiKey) {
  //検索クエリとURL
  const query = `${name} 院長 挨拶`;
  const url = `https://www.googleapis.com/customsearch/v1?q=${encodeURIComponent(query)}&cx=${CX}&key=${apiKey}`;

  //URL検索実行
  const res = UrlFetchApp.fetch(url);
  const data = JSON.parse(res.getContentText());
  if (!data.items || data.items.length === 0) return null;

  // 検索結果から「挨拶」「ごあいさつ」を含むURLを優先
  for (let item of data.items) {
    if (item.title.includes("挨拶") || item.title.includes("ごあいさつ")) {
      return item.link;
    }
  }

  // 見つからなければ1件目を返す
  return data.items[0].link;
}

挨拶ページのURLから院長名をまとめて取得

最後にある程度の情報をまとめあげた病院情報の配列を受け取って、ご挨拶ページのURL(greetingの値)を元に、一括してGemini APIのURL Contextで院長名を抽出し、病院情報の配列のdirector項目に指名をマージする処理になります。

URLだけをmapで配列にしておきプロンプト内で指定、"url_context": {}をtoolsに指定するだけです。これで対象のページ内をプロンプトに従って探索してくれます。

最後に病院情報の配列に対してdirector項目として加えて返し完了となります。

//greetingのURLから院長名をまとめて取得
function addDirectorsBatch(hospitals) {
  //プロパティの値を取得する
  let prop = PropertiesService.getScriptProperties()
  let apikey = prop.getProperty("apikey");

  //リクエストURLを構築する
  let targeturl = endpoint + apikey;

  // 全URLをまとめる
  let urls = hospitals.map(h => h.greeting);
  console.log(urls)

  //プロンプト
  let prompt = `
    以下のURLのデータ配列より、対象病院の「院長名」を取得してください。但し、以下の要件を満たした答えを返してください。
    
    #重要事項
    - 必要な情報は、人の名前だけです。
    - 出力事例に従ったデータだけを返してください。余計な解説や説明は一切不要です。
    - 取得結果に人名以外が含まれている場合には全て削除してください。
    - 病院名も「The content of the page clearly states」や「I need to extract」といった文言も不要です
    - 改行コード等も不要なので削除してください。
    
    #対象のURLのデータ配列
    ${urls}
    
    #出力事例
    [
      {"url": "URL", "director": "院長名"},
      {"url": "URL", "director": "院長名"},
    ] 
  `;

  //リクエスト用のペイロード
  const payload = {
    contents: [
      {
        role: "user",
        parts: [
          {
            text: prompt
          }
        ]
      }
    ],
    "tools": [
      {
        "url_context": {}
      }
    ]
  };

  const options = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  const response = UrlFetchApp.fetch(targeturl, options);
  const data = JSON.parse(response.getContentText());

  //余計な文字を除外する
  let rawText = data.candidates[0].content.parts[0].text;
  
  // コードブロックや余計な記号を除去
  let body = rawText
  .replace(/```json/g, "")
  .replace(/```javascript/g, "")
  .replace(/```/g, "")
  .replace(/\*\*\*/g, "")
  .trim();

  let directors = [];
  try {
    directors = JSON.parse(body);
  } catch (e) {
    console.log("JSONパース失敗: " + e);
    console.log(data);
    return;
  }

  //URLに対応するdirectorをマージ
  //院長名が画像になってしまってると失敗する
  for (let h of hospitals) {
    const found = directors.find(d => d.url === h.greeting);
    h.director = found ? found.director : "取得失敗";
  }

  console.log(JSON.stringify(hospitals, null, 2));
  return hospitals;
}

出力結果

GASの「fetchHospitalData関数」を実行すると、Gemini APIやCustom Search JSON APIなど様々なAPIを呼び出してデータを収集し、URL Contextを持ってしてご挨拶ページ内の「院長名」を取得し1レコードにまとめあげて一気にスプシに書き出します。

一部、取得失敗となってるのは、「院長名が画像に含まれてしまってる」ケースになります。

また、URL Contextはそこまで高速ではないので、結構時間が掛かります。100件程度一括で行うとタイムアウトする可能性がありますので要注意。本来は1件ずつURLを投げて処理をする所ですが、今回は複数のURLをまとめてなげて1回のリクエストで10件分を処理させています。

図:見事にリスト化出来た

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)