LoginSignup
5
1

GASでWebスクレイピングwithOpenAI

Last updated at Posted at 2024-06-21

GAS

GoogleAppScriptの略で、クラウド上で動作するVBAみたいな感じです。
無料で利用でき、一回の動作に6分(!)という制限が付きますが、
かなり高度なWebベースの処理開発が可能です。

この記事では、Qiitaの記事をOpenAIに読み込ませ、要約とおすすめポイントを生成AIから引き出し、スプレッドシートに保存します。

スクレイピングといいつつ、QiitaAPIで手抜きをしています。ご容赦ください。

*CodeForGifu GASハンズオンの資料としてこの記事は作成されました。

材料

  • OpenAIのAPIキー
    OpenAIからはAPIキーを取得する必要があり、今回はmodel=gpt-4oを利用します。
  • Googleアカウント
    アカウントがあれば無料で十分です。
    --
    APIキーの取得方法はこちらの記事を参考にしてください。
    https://note.com/noboruando/n/nfb49e8d44715
    *ハンズオンでは一緒に作成します。モデル制限などが難しいので。

つくりかた

GASのプロジェクトを作成する。

下記URLへアクセスします。
https://script.google.com/home/start

あたらしいプロジェクトを選択します。

image.png

  • プロジェクト名を新しくしておきます
  • 最初のコードを書きます
function myFunction() {
  console.log('hello');
}

Ctrl+Sで保存できます。

おわったら、▷実行ボタンを押します。

image.png

実行ログではconsoleに出力されたログを見ることができます。

実行中にこのようなエラー画面が出ることがあります。
image.png

詳細をクリック。
image.png

安全ではないページに移動します。
許可を促されるのでOKしてください。

QiitaAPIからデータを取得する。

/api/v2/items?page=1&per_page=5

qiitaの検索は、そのままqueryパラメータに使えるようです。

image.png

Qiitaからデータを取得する関数と実行結果はこのようになります。


/**
 * Qiitaのデータを取得する(JavaScript,TypeScriptをタグにもつ)
 */
function getQiitaLinks() {
  const now = new Date();
  now.setDate(now.getDate() - 1);

  //日付のフォーマット
  const dateStr = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd')

  //クエリにURLエンコードが必要な文字があるよ
  const result = UrlFetchApp.fetch(encodeURI(
    `https://qiita.com/api/v2/items?query=tag:JavaScript,TypeScript created:>=${dateStr}`
  ));

  const json = JSON.parse(result.getContentText('utf-8'));

  //title, url, tags, rendered_bodyのみ抽出
  return json.map(({ title, url, tags ,rendered_body }) => ({ title, url, tags,rendered_body }))

}

image.png

GASでは非同期処理を考えなくてもよいのがいいですね。
但し利用できる関数やクラスはブラウザやNodeで利用するものとは異なってきます。

OpenAIにデータを投げる。

👇こちらのガイドを参考にします。
https://platform.openai.com/docs/guides/text-generation

curlで投げるとこんな感じです。methodがなんでもいいみたいですね。おもしろい。

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [
      {
        "role": "user",
        "content": "Where was it played?"
      }
    ]
  }'

GASで実装するとこうなります。

  • Bodyが必要なので、content-typeをヘッダに指定すること
  • Bodyが必要なので、メソッドをGETではなくPOSTにする
  • payload部がBodyになるので、特定の形式のJSONを作成する
  • APITokenはauthorizationヘッダーにBearerで指定する

以上がポイントになります。

/**
 * OpenAIに挨拶する。
 */
function hello(apiToken){
  const resp = UrlFetchApp.fetch(
    `https://api.openai.com/v1/chat/completions`, {
      method : 'post',
      headers : {
        'content-type' : 'application/json',
        'authorization' : `Bearer ${apiToken}`
      },
      payload : JSON.stringify({
        model : 'gpt-4o',
        messages : [
          {
            role : 'user',
            content : 'hello'
          }
        ]
      })
    }
  )

  return JSON.parse(resp.getContentText('utf-8'))

}

Qiitaの内容をOpenAIに食べさせる

OpenAIにqiitaの内容を食べさせるコードは下記のようになります。
プロンプトをいろいろ凝ってみるのもいいですね。

/**
 * 読んでもらう。
 */
function thought(apiToken, content) {
  const prompt = [];

  prompt.push(`# 指令`)
  prompt.push(`あなたはプログラマです。URLを渡すので、要約と感想をお願いします。`);
  prompt.push(`#入力`)
  prompt.push(content);

  const resp = UrlFetchApp.fetch(
    `https://api.openai.com/v1/chat/completions`, {
    method: 'post',
    headers: {
      'content-type': 'application/json',
      'authorization': `Bearer ${apiToken}`
    },
    payload: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        {
          role: 'user',
          content: prompt.join('\n')
        }
      ]
    })
  }
  )

  return JSON.parse(resp.getContentText('utf-8'))
}


ここまでで、Qiitaのデータを取得してOpenAIに評価させられるようになりました。
さっそく試してみましょう。

function myFunction() {
  // console.log(getQiitaLinks());

  // console.log(hello(APITOKEN));

  const links = getQiitaLinks().filter((d, idx) => idx < 5);

  console.log(`qiita items retrieved.${links.length} items`);

  for (let { url, title, rendered_body } of links) {
    const result = thought(APITOKEN, url);

    console.log({ title, url });
    console.log(result.choices[0]);
  }
}

GoogleSpreadSheetに結果を書き込む

consoleに置いておいてもしょうがないので、GoogleSpreadSheetに書き込んで保存したいですよね。


/**
 * スプレッドシートのURLに配列を書き込みます。
 * @param {string} url
 * @param {any[][]} aoo
 */
function writeSpreadSheet(url, aoo) {
  if (0 == aoo.length) return;

  const book = SpreadsheetApp.openByUrl(url);

  const sheetName = Utilities.formatDate(new Date, 'Asia/Tokyo', 'yyyy-MM-dd');

  //シート名で取得。とれなかったら、新しく作る。
  const sheet = book.getSheetByName(sheetName) ?? book.insertSheet();
  sheet.setName(sheetName);

  sheet.activate();

  //全消し
  sheet.clear();

  const headers = Object.keys(aoo[0]);

  //header
  sheet.getRange(1, 1, 1, headers.length).setValues([headers])

  //data
  for (let r = 0; r < aoo.length; r++) {
    const item = aoo[r];
    for (let c = 0; c < headers.length; c++) {
      sheet.getRange(r + 2, c + 1).setValue(item[headers[c]])
    }
  }

  //行列の幅調整
  sheet.autoResizeColumns(1, headers.length);
  sheet.autoResizeRows(1, aoo.length + 1);

}


URLはスプレッドシートをGoogleDrive内で新しく作って、そのURLを指定すれば大丈夫です。(スプレッドシートを開いたときに、アドレスバーに出ているものでオッケー)

コード全文

さあ、結果をスプレッドシートに保存できるようになりました。

//OpenAIのAPIトークン
const APITOKEN = `sk-***`;

//SpreadSheetのURL
const SSURL = `https://docs.google.com/spreadsheets/d/***`;

function myFunction() {
  // console.log(getQiitaLinks());
  // console.log(hello(APITOKEN));

  const links = getQiitaLinks().filter((d, idx) => idx < 5);

  console.log(`qiita items retrieved.${links.length} items`);

  const aoo = [];

  for (let { url, title, tags, rendered_body } of links) {
    const result = thought(APITOKEN, rendered_body);

    console.log({ title, url });
    console.log(result.choices[0]);

    aoo.push({ url, title, tags: tags.map(tag => tag.name).join(','), openai: result.choices[0].message.content })
  }

  writeSpreadSheet(SSURL, aoo);
}

/**
 * Qiitaのデータを取得する(JavaScript,TypeScriptをタグにもつ)
 */
function getQiitaLinks() {
  const now = new Date();
  now.setDate(now.getDate() - 1);

  //日付のフォーマット
  const dateStr = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd')

  //クエリにURLエンコードが必要な文字があるよ
  const result = UrlFetchApp.fetch(encodeURI(
    `https://qiita.com/api/v2/items?query=tag:JavaScript,TypeScript created:>=${dateStr}`
  ));

  const json = JSON.parse(result.getContentText('utf-8'));

  //title, url, tags, rendered_bodyのみ抽出
  return json.map(({ title, url, tags, rendered_body }) => ({ title, url, tags, rendered_body }))

}

/**
 * OpenAIに挨拶する。
 */
function hello(apiToken) {
  const resp = UrlFetchApp.fetch(
    `https://api.openai.com/v1/chat/completions`, {
    method: 'post',
    headers: {
      'content-type': 'application/json',
      'authorization': `Bearer ${apiToken}`
    },
    payload: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        {
          role: 'user',
          content: 'hello'
        }
      ]
    })
  }
  )

  return JSON.parse(resp.getContentText('utf-8'))

}

/**
 * 読んでもらう。
 */
function thought(apiToken, content) {
  const prompt = [];

  prompt.push(`# 指令`)
  prompt.push(`あなたはプログラマです。URLを渡すので、要約と感想をお願いします。`);
  prompt.push(`#入力`)
  prompt.push(content);

  const resp = UrlFetchApp.fetch(
    `https://api.openai.com/v1/chat/completions`, {
    method: 'post',
    headers: {
      'content-type': 'application/json',
      'authorization': `Bearer ${apiToken}`
    },
    payload: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        {
          role: 'user',
          content: prompt.join('\n')
        }
      ]
    })
  }
  )

  return JSON.parse(resp.getContentText('utf-8'))
}

/**
 * スプレッドシートのURLに配列を書き込みます。
 * @param {string} url
 * @param {any[][]} aoo
 */
function writeSpreadSheet(url, aoo) {
  if (0 == aoo.length) return;

  const book = SpreadsheetApp.openByUrl(url);

  const sheetName = Utilities.formatDate(new Date, 'Asia/Tokyo', 'yyyy-MM-dd');

  //シート名で取得。とれなかったら、新しく作る。
  const sheet = book.getSheetByName(sheetName) ?? book.insertSheet();
  sheet.setName(sheetName);

  //全消し
  sheet.clear();

  const headers = Object.keys(aoo[0]);

  //header
  sheet.getRange(1, 1, 1, headers.length).setValues([headers])

  //data
  for (let r = 0; r < aoo.length; r++) {
    const item = aoo[r];
    for (let c = 0; c < headers.length; c++) {
      sheet.getRange(r + 2, c + 1).setValue(item[headers[c]])
    }
  }

  //行列の幅調整
  sheet.autoResizeColumns(1, headers.length);
  sheet.autoResizeRows(1, aoo.length + 1);

}

スケジューリング

ここまでで、昨日書かれた記事の要約が取れましたので、このプログラムを毎日動かしたい。

トリガー機能を利用します。左のペインから選択。

image.png

トリガーを追加
image.png

時間ベースのトリガーを「日付ベースのタイマー」に
時刻の選択を「0時~1時」に設定します。

すべての実行ログは「実行数」から確認できます。トリガーの下にあります。
image.png

まとめ

結果がどうなるか、この記事では載せていません。試してみてね!

エラーハンドリングやロギングなどを省いて、さくっとGASとOpenAIのAPIを試す目的で書きました。OpenAIがURLをいつも読んでくれるわけではなく、500エラーも出ますのでリトライは必要ですね。

このあと時間があれば、claspによるプロジェクト管理とGmailでのメール送付についてもやってみたいと思います。

またTypeScriptにて@types/google-app-scriptを利用しての開発なども時間があれば・・・。

5
1
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
5
1