🤖

GASでHackerNewsのランキングをChatGPTで要約しSlack通知

2024/11/02に公開

概要

今回はHackerNewsのトップ記事をChatGPT APIを使って要約してもらい、Slackに通知するBotをGAS (Google Apps Script) を使って作ってみたいと思います。
また今回モデルを gpt-4o-minitemperature: 0.5 で設定していますが、必要に応じて適宜変更してもらえればと思います。

Hacker News API

https://github.com/HackerNews/API

事前準備

事前にChatGPT APIのAPIキーを発行しておく必要があります。

https://platform.openai.com/docs/api-reference/authentication

構成

GASの定期実行を使って、HackerNewsの一覧を取得、GPTで要約 + 翻訳したものをSlackに投稿する。

image1.png

GAS プロジェクト作成

clasp + Docker 構成でGASプロジェクトを作成して行きたいと思います。

https://github.com/Slowhand0309/nodejs-devcontainer-boilerplate

👆をベースに環境構築していきます。

  • 最終的な compose.yml
volumes:
  clasp_data:
  modules_data:

name: gas_project
services:
  app:
    build: .
    volumes:
      - ..:/usr/src
      - clasp_data:/home/vscode/clasp
      - modules_data:/usr/src/node_modules
    command: /bin/sh -c "while sleep 1000; do :; done"
    working_dir: /usr/src
    environment:
      clasp_config_auth: /home/vscode/clasp/.clasprc.json
      SCRIPT_ID: ${SCRIPT_ID}

※ 本来は clasp login すると ~/.clasprc.json に書き出されるのですが、

clasp_config_auth を設定する事で場所を変更する事ができます。

  • postAttach.sh
sudo chown -R vscode /home/vscode/clasp

👆を追加し、/home/vscode/clasp を触れるようにしときます。

  • 初期化と必要なパッケージ追加
yarn init
yarn add -D @google/clasp @types/google-apps-script

clasp login

clasp login を実行し、ログインしときます。

※ 執筆時点ではコンテナ起動し、VSCode内のターミナルで clasp login をすると自動でホスト側と接続してくれてcallbackがちゃんと返りログインする事ができました。

GAS 作成

今回プロジェクト名を「HackerNewsBot」として作成します。

yarn clasp create --title "HackerNewsBot"

作成された main.tsappsscript.jsonsrc ディレクトリを作成して移動させときます。

ChatGPT APIを使った簡単なサンプル作成

早速、ChatGPT APIを使ったサンプルを試してみたいと思います。

src/main.ts を以下に修正します。

import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
import HttpMethod = GoogleAppsScript.URL_Fetch.HttpMethod;

const OPENAI_API_PROPERTY_KEY = "openai_api_key";
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";

interface Message {
  role: string;
  content: string;
}

const main = () => {
  Logger.log("hello world!");
};

const request = (messages: Message[]) => {
  const apiKey = PropertiesService.getScriptProperties().getProperty(
    OPENAI_API_PROPERTY_KEY
  );
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    "Content-type": "application/json",
  };
  const options: URLFetchRequestOptions = {
    muteHttpExceptions: true,
    headers: headers,
    method: "POST" as HttpMethod,
    payload: JSON.stringify({
      model: "gpt-4o-mini",
      temperature: 0.5,
      messages: messages,
    }),
  };
  const response = JSON.parse(
    UrlFetchApp.fetch(OPENAI_API_URL, options).getContentText()
  );
  return response;
};

const checkGpt = () => {
  const messages = [
    {
      role: "system",
      content: "必ず関西弁で答えてください",
    },
    { role: "user", content: "日本で最も高い山は?" },
  ];
  const response = request(messages);
  Logger.log(response.choices[0].message.content);
};

今回スクリプトプロパティを使ってAPIキーを管理しているので、事前に登録しておきます。

対象のGoogle Apps Scriptを開いて「プロジェクトの設定」>「スクリプト プロパティ」で

  • プロパティ: openai_api_key
  • 値: APIキー

を登録します。

image2.png

最後に UrlFetchApp を使う為に必要な権限をappsscript.json 内の oauthScopes に設定します。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

これで準備ができたので、以下コマンドでデプロイします。

clasp push

正しくデプロイされたら

image3.png

①関数を 「checkGpt」に設定し②実行してやると、実行ログに結果が表示されます 🎉

HackerNews APIを使った簡単なサンプル作成

まずはHackerNewsのAPIをリクエストする専用のクライアントクラスを作成します。

src/hackenews/client.ts を以下内容で作成します。

import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
import HttpMethod = GoogleAppsScript.URL_Fetch.HttpMethod;

const HACKER_NEWS_API_URL = "https://hacker-news.firebaseio.com/v0";

export class HackerNewsClient {
  constructor() {}

  public topStories() {
    const url = `${HACKER_NEWS_API_URL}/topstories.json`;
    const response = this.fetch(url) as number[];
    response.slice(0, 10).forEach((id: number) => {
      const item = this.item(id);
      Logger.log(`id: ${id}, link: ${item.url}`);
    });
  }

  public item(id: number) {
    const url = `${HACKER_NEWS_API_URL}/item/${id}.json`;
    const response = this.fetch(url);
    return response;
  }

  private fetch(url: string) {
    const headers = {
      "Content-type": "application/json",
    };
    const options: URLFetchRequestOptions = {
      headers: headers,
      method: "GET" as HttpMethod,
    };
    const response = UrlFetchApp.fetch(url, options).getContentText();
    const json = JSON.parse(response);
    return json;
  }
}

次にChatGPT APIの時と同じようにテスト用の checkHackerNews を作成します。

const checkHackerNews = () => {
  const client = new HackerNewsClient();
  const topStories = client.topStories();
  Logger.log(topStories);
};

先程と同じ様に実行する関数に「checkHackerNews」を選択して実行してやるとログが表示されるかと思います。

HackerNews をChatGPT APIで要約する

いよいよ本題といきたい所ですが、HackerNews APIで返ってくるURLの内容を取得する処理を書きたいと思います。

URLの内容を取得後、パースする為に cheerio をGASでも使えるようにしたライブラリを使って実装していこうと思います。

src/appsscript.jsondependencies に以下を追加します。

  "dependencies": {
    "libraries": [
      {
        "userSymbol": "Cheerio",
        "version": "16",
        "libraryId": "1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0",
        "developmentMode": true
      }
    ]
  },

パース専用のclassを src/parser.ts として作成します。

今回は <main> タグ > <article> タグ > <body> タグの順で存在するタグの中を抽出しています。

import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
import HttpMethod = GoogleAppsScript.URL_Fetch.HttpMethod;

export class Parser {
  constructor() {}

  parse(url: string) {
    const options: URLFetchRequestOptions = {
      method: "GET" as HttpMethod,
    };
    try {
      const response = UrlFetchApp.fetch(url, options).getContentText();
      const $ = Cheerio.load(response);
      if ($("main").length > 0) {
        return $("main").text();
      } else if ($("article").length > 0) {
        return $("article").text();
      } else {
        return $("body").text();
      }
    } catch (e) {
      Logger.log(e);
      return "";
    }
  }
}

この時 Cheerio をimportせず使っているのでエラーが出るかと思います。なので @types/cheerio だけインストールしimportしてやります。

yarn add -D @types/cheerio

先ほどの src/parser.ts にimportを追加

import * as Cheerio from "cheerio";

次にChatGPT API扱う為のclassを作成します。

src/chatgpt/client.ts を以下内容で作成します。

import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
import HttpMethod = GoogleAppsScript.URL_Fetch.HttpMethod;

const OPENAI_API_PROPERTY_KEY = "openai_api_key";
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";

interface Message {
  role: string;
  content: string;
}

export class ChatGptClient {
  private _apiKey: string;

  constructor() {
    this._apiKey = PropertiesService.getScriptProperties().getProperty(
      OPENAI_API_PROPERTY_KEY
    );
  }

  summarize(content: string) {
    if (!content) return "";
    const input = `以下のコンテンツについて、内容を日本語で300文字程度でわかりやすく箇条書きで要約して下さい

========

${content}

========
`;
    const messages = [
      {
        role: "system",
        content: "あなたはプロのライターです",
      },
      { role: "user", content: input },
    ];
    const response = this.request(messages);
    return response?.choices[0].message.content ?? "";
  }

  request(messages: Message[]) {
    const headers = {
      Authorization: `Bearer ${this._apiKey}`,
      "Content-type": "application/json",
    };
    const options: URLFetchRequestOptions = {
      headers: headers,
      method: "POST" as HttpMethod,
      payload: JSON.stringify({
        model: "gpt-4o-mini",
        temperature: 0.5,
        messages: messages,
      }),
    };
    try {
      const response = JSON.parse(
        UrlFetchApp.fetch(OPENAI_API_URL, options).getContentText()
      );
      return response;
    } catch (e) {
      Logger.log(e);
      return null;
    }
  }
}

先ほどの src/main.ts を少し修正します。


import { ChatGptClient } from "./chatgpt/client";

// ....

const checkGpt = () => {
  const messages = [
    {
      role: "system",
      content: "必ず関西弁で答えてください",
    },
    { role: "user", content: "日本で最も高い山は?" },
  ];
  // ChatGptClientを使うように修正
  const gpt = new ChatGptClient();
  const response = gpt.request(messages);
  Logger.log(response?.choices[0].message.content);
};

これでデプロイして先ほどと同じように返答が返ってきていたらOKです。

次に src/main.tscheckHackerNews に修正します。

const checkHackerNews = () => {
  const client = new HackerNewsClient();
  const topStories = client.topStories();

  const parser = new Parser();
  const content = parser.parse(topStories[0].url);

  const gpt = new ChatGptClient();
  const response = gpt.summarize(content);
  Logger.log(response);
};

これでデプロイ、実行して要約がログに出力されていれば成功です!

image4.png

※ urlによっては上手くparseできないurlもあります。その時は別のurlで試してみて下さい。

Slackに通知する

最後に要約した記事一覧をSlackにポストするようにしてみたいと思います。

slack通知用のclassを src/notifier/slack.ts に作成します。

import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
import HttpMethod = GoogleAppsScript.URL_Fetch.HttpMethod;

const SLACK_WEBHOOK_URL_KEY = "slack_webhook_url";

export interface ISlackPayload {
  username?: string;
  icon_emoji?: string;
  icon_url?: string;
  channel?: string;
  text: string;
}

export class SlackNotifier {
  constructor() {
    this._webhookUrl = PropertiesService.getScriptProperties().getProperty(
      SLACK_WEBHOOK_URL_KEY
    );
  }
  private _webhookUrl: string;

  public postChannel(payload: ISlackPayload): void {
    const result = this.post(payload);
    Logger.log(`result: ${result.getContentText()}`);
  }

  private post(
    playload: ISlackPayload
  ): GoogleAppsScript.URL_Fetch.HTTPResponse {
    const options: URLFetchRequestOptions = {
      method: "POST" as HttpMethod,
      payload: JSON.stringify(playload),
    };
    return UrlFetchApp.fetch(this._webhookUrl, options);
  }
}

こちらも ChatGptClient の時と同じ様にスクリプトプロパティにwebhookのurlを設定し、使う際にそこから取得して使っています。

image5.png

最後に src/main.tsmain を以下に修正します。

const main = () => {
  const client = new HackerNewsClient();
  const topStories = client.topStories();
  Logger.log(`Query hacker news top stories ${topStories.length} items`);

  const parser = new Parser();
  const gpt = new ChatGptClient();
  const notifier = new SlackNotifier();

  for (const story of topStories) {
    const content = parser.parse(story.url);
    const response = gpt.summarize(content);
    // 必要に応じてchannel, username, icon_emoji等を設定
    const playload: ISlackPayload = {
      text: `Title: ${story.title}\nURL: ${story.url}\nSummary: ${response}`,
    };
    notifier.postChannel(playload);
  }
};

デプロイして実行すると、Slackに通知が来れば成功です ✨

image6.png

今回は通知のフォーマットに関しては何も考えていませんが、必要に応じて見やすいフォーマットに変更して試してもらえればと思います。

参考URL

https://zenn.dev/satto_sann/articles/86543f2ad9a09e

https://zenn.dev/st_little/articles/can-not-clasp-login-with-devcontainer

https://tome123.com/post-420/

Discussion