GASでWebスクレイピングwithOpenAI

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

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

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

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

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

# つくりかた
## GASのプロジェクトを作成する。
下記URLへアクセスします。
https://script.google.com/home/start

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/77db2313-788b-647d-8b37-95026b9e5d41.png)


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

Ctrl+Sで保存できます。

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/adf6ba36-c44d-8f68-535b-4850432beec6.png)

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

実行中にこのようなエラー画面が出ることがあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/55a37397-e0e5-cde1-7a4e-29e57874bb9a.png)

詳細をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/9a907960-1302-26fe-9e9b-a3b0b5bdb636.png)

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


## QiitaAPIからデータを取得する。
https://qiita.com/api/v2/docs

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/70d82ff5-7238-4143-73e2-01b8aa52702c.png)

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

/**
 * 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/48544f7c-2511-6fad-899d-9f26b89d6d39.png)

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

## OpenAIにデータを投げる。
👇こちらのガイドを参考にします。
https://platform.openai.com/docs/guides/text-generation

curlで投げるとこんな感じです。methodがなんでもいいみたいですね。おもしろい。
```bash
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で指定する

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

```javascript
/**
 * 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の内容を食べさせるコードは下記のようになります。
プロンプトをいろいろ凝ってみるのもいいですね。
```javascript
/**
 * 読んでもらう。
 */
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に評価させられるようになりました。
さっそく試してみましょう。
```javascript
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に書き込んで保存したいですよね。

```javascript

/**
 * スプレッドシートの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を指定すれば大丈夫です。（スプレッドシートを開いたときに、アドレスバーに出ているものでオッケー）


## コード全文
さあ、結果をスプレッドシートに保存できるようになりました。
```javascript
//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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/3b690c47-5966-7b02-e3c1-6365c6fa9ee9.png)

トリガーを追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/0643c3ae-e7a5-39f2-fa4c-e592c4aa1dea.png)

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

すべての実行ログは「実行数」から確認できます。トリガーの下にあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/1746d279-344e-4aa0-b35c-02aac802207c.png)



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

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

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

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