MIXI DEVELOPERS NOTE
😗

Webサイトを要約する便利Slackアプリを作って、社内で超簡単に運用する技術

2024/08/09に公開

最近よくある生成AIを使ったやつです。


Slack DM に URL を投稿すると要約してくれます。

Web だけではなく PDF も要約できます。

裏側は Azure OpenAI(*1) を叩いているだけなので、
実際に社内で提供するにあたり工夫したことを紹介します。

*1 社内で発行するのに Azure OpenAI の方が楽ちんなので

Slack アプリのアーキテクチャ

社外の向けのネットワーク(*2)に所属する Windows PC の中で WSL+Docker で動かしています。 そしてSlack とは SocketMode で接続しているため、固定のグローバルIPが不要な構成になっています。

そして、Slack からイベントを受け取るとそこに含まれる URL を取得し、コンテンツの取得+要約を行いその結果を投稿するといった処理になっています。

*2 AWS や Google Cloud などのクラウドを利用していないのは、取得先のウェブサイトによってはクラウドからのアクセスを弾いておりコンテンツが取得できないことがあるためです

外部コンテンツの取得について

CSR(Client Side Rendering) の対応

ウェブサイトによっては React や Vue による CSR で生成されています。 そのため単に cURL などでコンテンツを取得しても js が実行されずコンテンツが生成されない問題があるため chromedp を Headless で利用し、コンテンツが適切に取得できるようにしています。

args := chromedp.Tasks{
    chromedp.Navigate(url),
    chromedp.WaitReady(`body`, chromedp.ByQuery),
    chromedp.OuterHTML(`body`, &res, chromedp.NodeVisible, chromedp.ByQuery),
    chromedp.ActionFunc(func(ctx context.Context) error {

        // If the page is too short, wait for a while and try again
        if len(res) < retryMinLength {
            if err := chromedp.Sleep(5 * time.Second).Do(ctx); err != nil {
                return goerr.Wrap(err)
            }
            if err := chromedp.OuterHTML(`body`, &res, chromedp.NodeVisible, chromedp.ByQuery).Do(ctx); err != nil {
                return goerr.Wrap(err)
            }
        }
        return nil
    }),
}

if err := chromedp.Run(ctx, args...); err != nil {
    return nil, goerr.Wrap(err)
}

body 内が retryMinLength(適当な値) 未満だったら5秒待って取るだけのシンプル実装です

HTMLを必要なものだけに削る対応

HTML をすべて取得し生成AIに投げるのが楽ちんではありますが、それをすると大量のトークンを消費することになるため HTML タグを指定してフィルタリングしています。

doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(buf)))
if err != nil {
    return "", goerr.Wrap(err)
}

var content strings.Builder
doc.Find("p, h1, h2, h3, h4, h5").Each(func(i int, s *goquery.Selection) {
    content.WriteString(s.Text() + "\n")
})

return content.String(), nil

pタグhタグがあれば体感的には問題ありません。

PDF の対応

PDF もまとめてくれると嬉しいよねということで対応しました。

PDF を取得してマルチモーダルとしてそのままファイルを投げることも考えたのですが、ファイルサイズによっては難しいですし、PDF内の画像は邪魔になるため、一度テキストにしてから投げるようにしています。

agent := NewHTTPAgent(opts...)
buf, err := agent.Get(ctx, r.url)
if err != nil {
    return "", err
}

// Parse PDF data in memory
reader := bytes.NewReader(buf)
pdfReader, err := pdf.NewReader(reader, int64(len(buf)))
if err != nil {
    return "", goerr.Wrap(err)
}

var result strings.Builder
numPages := pdfReader.NumPage()
for i := 1; i <= numPages; i++ {
    page := pdfReader.Page(i)
    if page.V.IsNull() {
        continue
    }
    content, err := page.GetPlainText(nil)
    if err != nil {
        continue
    }
    result.WriteString(content)
}

return result.String(), nil

※たまに PDF encrypted で失敗するのでどうにかしたい

運用について

Windows PC で運用しているのですが、社外LANに接続している環境のためリモートデスクトップを禁止しています。しかしなるべく手軽に運用したいですね。

そこで以下のようにして、運用の負荷を下げています。

アプリの自動アップデート

image に latest を指定した compose.yaml を用意し、docker compose pull && docker compose up -d を叩くとその時点の最新の image を取得し再起動してくれます。

version: '3.8'

services:
  app:
    image: ghcr.io/xxx/xxx:latest
    container_name: xxx
    restart: always

これを利用し、WSL で入れた Ubuntu の cron エントリに以下のように書けば毎分更新されます。

* * * * * (docker compose pull && docker compose up -d)

つまり、image を更新するだけで勝手にアップデートしてくれます。(とても便利)

latest を使用する行為はセキュリティ的に問題があることもあるため利用には注意してください

Docker / WSL 再起動時に自動で起動して欲しい。

compose.yamlrestart: always と書きましょう。
これにより何らかの事情で WSL や Docker が再起動した場合にも自動復旧してくれます。

Windows の再起動時も自動で起動して欲しい

いくら Docker で対策しても WSL が起動していないと効果がありません。

法定停電などがあるとシャットダウンを余儀なくされますが、この時もどうにか運用レスでいきたいです。ということで Windows のタスクスカジューラーで自動起動するようにします。

バグトラッキング

省エネでいきたいので Incoming Webhook をつかって Slack に飛ばしています。

楽ちん!

まとめ

生成AIをつかって要約 Slack アプリを、いかに楽して運用するかを紹介しました。
よければ参考にしてみてください。

今回つくったアプリはこちら。

MIXI DEVELOPERS NOTE
MIXI DEVELOPERS NOTE

Discussion