【個人開発】discordに貼ったURLを要約してnotionにまとめるbot作った

# はじめに
この記事を閲覧していただきありがとうございます。
この記事では**discordに貼ったURLの要約をスレッドに作成し、notionにも追記するdiscord bot** を作った話を書きたいと思います.

使用技術に関して説明は行いますが、網羅的な内容ではありませんので、ご了承ください.

# 制作物概要
## 作った物

discordに貼ったURLの要約をスレッドに作成し、notionにも追記するdiscord bot　を作成し、デプロイまで行いました。
githubのREADME.mdにデモ動画があります。

https://github.com/you22fy/discord_notion_link

:::note warn
このbotはOpenAIのAPIを利用します。したがって、実行のたびにAPIの使用料金がかかる点に注意してください。
料金の詳細は[Pricing | OpenAI](https://openai.com/api/pricing/)をご確認ください。
:::


## 使用技術
- 開発環境
    - MacBook Air (M2 2022)
    - macOS Sonoma14.5
- プログラミング言語
    - python 3.11
- ライブラリ
    - [discord.py](https://discordpy.readthedocs.io/ja/latest/)
    - [requests](https://pypi.org/project/requests/)
    - [beautifulsoup4](https://pypi.org/project/beautifulsoup4/)
    - [openai](https://github.com/openai/openai-python)
- APIサービス
    - chatGPT API
    - discord Bot
    - Notion API
- デプロイ
    - [fly.io](https://fly.io/)

## 制作の背景
- Twitter等で気になった記事の内容をざっと知る手段と、その記事を蓄積させていきたかったから
- ちょうどいいタイミングでGPT4oのAPIが公開されたから^^

# 実装
## botの作成
[Discord Developer Portal](https://discord.com/developers/applications/) から**New Application** を選択し、新しくbotを作成します.
Botが作成できたら、サイドメニューの**OAuth2**から必要な権限を付与してください.


こちらの記事に画像付きで説明されていたものを参考にしました。

https://qiita.com/Naoya_pro/items/d8259d072d1e8f046a93#discord-developer-portal%E3%81%AB%E7%99%BB%E9%8C%B2


## pythonでのコード実装

### ディレクトリの作成
筆者は`poetry`を用いて環境構築を行いました。
任意のディレクトリで以下のコマンドを実行してください
```
poetry new <作成するディレクトリ名>
```
poetryでなくても、pythonの3.11が利用できる環境であれば問題なく動作すると思われます。

<details><summary>最終的なディレクトリ構成を添付しておきます</summary>

```bash
.
├── Dockerfile
├── README.md
├── fly.toml
├── make_thread_summary
│   ├── __init__.py
│   ├── main.py
│   └── utils
│       ├── __init__.py
│       ├── __pycache__
│       │   ├── __init__.cpython-311.pyc
│       │   ├── get_content_by_url.cpython-311.pyc
│       │   ├── has_url_in_text.cpython-311.pyc
│       │   ├── insert_notion.cpython-311.pyc
│       │   └── summarize_text_by_openai.cpython-311.pyc
│       ├── get_content_by_url.py
│       ├── has_url_in_text.py
│       ├── insert_notion.py
│       └── summarize_text_by_openai.py
├── poetry.lock
├── pyproject.toml
├── requirements.txt
└── tests
    └── __init__.py
```

</details>

ここからは各ファイルの実装を簡単に解説します.上述のGitHubにコード載せています.
### main.py

```python
import discord
import os
from dotenv import load_dotenv

from utils.has_url_in_text import has_url_in_text
from utils.get_content_by_url import get_content_by_url
from utils.summarize_text_by_openai import summarize_text
from utils.insert_notion import insert_notion

load_dotenv()
DISCORD_API_KEY = os.getenv("DISCORD_API_KEY")

intents = discord.Intents.default()
intents.message_content = True

client = discord.Client(intents=intents)


@client.event
async def on_ready():
    print(f"We have logged in as {client.user}")


@client.event
async def on_message(message):
    if message.author == client.user:
        return
    if url := has_url_in_text(message.content):
        try:
            title, ogp_url, body_text = await get_content_by_url(url)
            summary = await summarize_text(body_text)
            thread = await message.channel.create_thread(
                name=f"{str(title)}の議論スレッド"[:100],
                message=message,
            )
            await thread.send(ogp_url)
            await thread.send(f"# 要約\n{summary}")

            # ここにNotionに追加する処理を追加する
            await insert_notion(title, url, ogp_url, summary)

        except Exception as e:
            print(f"Error creating thread: {e}")
            return


client.run(DISCORD_API_KEY)

```
discord上で送信されるメッセージを取得し、URLが含まれているものに対して要約・および保存を行う処理が書かれています。何点か特筆する点を紹介します.

- `.env`からAPIキーやBotのトークンを読んでいるのでpython-dotenvを利用しています
- `if url := has_url_in_text(message.content):`の部分ではセイウチ演算子(ウォルラス演算子)を利用しています[詳しくはこちら](https://qiita.com/Yu_unI1/items/3bfcd1b0fbe12ea2dda9)
- `name=f"{str(title)}の議論スレッド"[:100]`で100文字の制限をつけているのは、discordのスレッドタイトルが100文字までであるためです


---
ここからは、各種関数についてみていきます。それぞれの関数はutilsフォルダに格納しています.(ディレクトリ名が微妙な気もしますが、見逃してください🥺)
### utils/has_url_in_text.py
```python
import re


def has_url_in_text(text: str) -> str | None:
    url_pattern = r"(https?://\S+)"
    match = re.search(url_pattern, text)
    if match:
        return match.group(0)
    else:
        return None


if __name__ == "__main__":
    text = "Check out this cool website: https://www.example.com/hoge/fuga This is a cool website!"
    url = has_url_in_text(text)
    print(url)  # Output: https://www.example.com

```
`has_url_in_text.py`では与えられた文字列中にURLが含まれるかどうかを正規表現で判定する関数があります。引数の文字列中にURLが含まれればそれを返し、なければ`None`を返すようになっています。

### utils/get_content_by_url.py
```python
import requests
from bs4 import BeautifulSoup
import asyncio


async def get_content_by_url(url: str):
    """
    urlを受け取ってそのページのタイトル、OGP画像、テキストを取得する
    Parameters
    ----------
    url : str
        取得したいページのURL
    Returns
    -------
    tuple
        ページのタイトル, OGP画像のURL, ページのテキスト
    """
    try:
        response = requests.get(url)
        soup = BeautifulSoup(response.text, "html.parser")
        if soup is None:
            raise Exception("soup is None. Failed to parse HTML.")

        text = soup.get_text()
        if len(text) < 100:
            raise Exception("text is too short. Probably not a valid article.")

        title = soup.title.string if soup.title else ""
        ogp_link = (
            soup.find("meta", property="og:image")["content"]
            if soup.find("meta", property="og:image")
            else ""
        )

        return (
            title,
            ogp_link,
            text,
        )

    except Exception as e:
        raise Exception(f"Error in get_content_by_url: {e}")


async def main():
    url = "https://ai.google.dev/competition?hl=ja"
    title, ogp, text = await get_content_by_url(url)
    print(title)
    print(ogp)
    print(text)


if __name__ == "__main__":
    asyncio.run(main())

```

`get_content_by_url`では引数で与えられたURLの先のページをスクレイピングし、タイトル・OGP・本文を取得する関数です。
ここで特筆すべき点をいくつか紹介します.

- `if len(text) < 100:`の部分で100文字以下の際に、例外を投げるのは、JavaScript等で作成されるページにおいて正しくコンテンツが取れない場合を避けるためです。必ず100字以下になる保証はありませんが、一定は弾くことができま(**要工夫**)
- OGP画像を取得するのは、notionに追記する際、OGP画像があるとギャラリービューで記事の見栄えが良くなるためです


### utils/summarize_text_by_openai.py
```python
from openai import OpenAI
from dotenv import load_dotenv
import os
import asyncio

load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")


async def summarize_text(text):
    """
    与えられたテキストをchatGPT APIを用いて要約する
    ここで、応答の末尾には使用モデル・入力トークン数・出力トークン数の情報を付す
    Parameters
    ----------
    text : str
        要約したいテキスト
    Returns
    -------
    str
        要約されたテキスト
    """
    client = OpenAI()
    OpenAI.api_key = OPENAI_API_KEY
    system_prompt = """
    あなたはwebサイト上からスクレイピングされたテキストを要約するAIです。
    これからユーザーがテキストを入力します。そのテキストを日本語で要約してください.
    要約する際には以下の点を考慮してください:
    - 要約の結果にはマークダウンを用いることができます。
    - 要約でできるだけテキストだけではなく、箇条書きやリストを用いてわかりやすく要約してください
    - 元の記事が構造的になっている場合、箇条書き等を用いてわかりやすく要約してください
    - 要約時は元のテキストの要点を押さえ、冗長な情報は省いてください
    - 要約の結果は日本語で出力してください
    - 要約の結果には、元のテキストのリンクを含めないでください
    - 元のテキストには記事の本文以外にも、広告やコメントなどが含まれることがありますが、要約の際には本文のみを要約してください
    """
    try:
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "system",
                    "content": system_prompt,
                },
                {
                    "role": "user",
                    "content": text,
                },
            ],
            max_tokens=1000,
        )
        meta_info = "\n||"
        meta_info += f"モデル: {response.model}"
        meta_info += f"入力トークン数: {response.usage.prompt_tokens}"
        meta_info += f"出力トークン数: {response.usage.completion_tokens}"
        meta_info += "||"

        return response.choices[0].message.content + meta_info
    except Exception as e:
        raise Exception(f"要約時エラー: {e}")


async def main():
    sample_text = "Python is a programming language that lets you work quickly and integrate systems more effectively."
    summary = await summarize_text("")
    print(summary)


if __name__ == "__main__":
    asyncio.run(main())
```
`summarize_text_by_openai.py`では与えられたテキストをもとにOpenAIの APIを用いて要約を行う関数です.


### utils/insert_notion.py
```python
import os
import requests

from dotenv import load_dotenv
import asyncio

load_dotenv()
NOTION_API_KEY = os.getenv("NOTION_API_KEY")
NOTION_DB_ID = os.getenv("NOTION_DB_ID")


async def insert_notion(title: str, url: str, ogp_url: str | None, body: str) -> None:
    """
    [title]と[url]と[body]を引数として受け取り、それをNotion API経由でNotionのDBに追加する関数
    """
    base_url = "https://api.notion.com/v1/pages"
    headers = {
        "Notion-Version": "2022-06-28",
        "Authorization": "Bearer " + NOTION_API_KEY,
        "Content-Type": "application/json",
    }
    json_data = {
        "parent": {
            "database_id": NOTION_DB_ID,
        },
        "properties": {
            "title": {
                "title": [
                    {
                        "text": {"content": title},
                    },
                ],
            },
            "link": {
                "url": url,
            },
        },
        "children": [
            {
                "type": "image",
                "image": {
                    "type": "external",
                    "external": {
                        "url": (
                            ogp_url
                            if ogp_url
                            else "https://stat.ameba.jp/user_images/55/19/10063987699.gif"
                        ),
                    },
                },
            },
            {
                "object": "block",
                "type": "heading_1",
                "heading_1": {
                    "rich_text": [
                        {
                            "type": "text",
                            "text": {
                                "content": "要約",
                            },
                        }
                    ]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [
                        {
                            "text": {
                                "content": body,
                            },
                        },
                    ],
                },
            },
        ],
    }
    r = requests.post(
        base_url,
        headers=headers,
        json=json_data,
    )
    return


async def main():
    await insert_notion(
        "pythonで追加",
        "https://example.com",
        "https://stat.ameba.jp/user_images/55/19/10063987699.gif",
        "body",
    )


if __name__ == "__main__":
    asyncio.run(main())

```
`insert_notion.py`ではnotionデータベースに対して、記事の情報や要約した情報を保存する関数です。

notionAPIの利用方法は以下のリファレンスに載っています。
https://developers.notion.com/
APIキーの取得は[こちらのページ](https://www.notion.so/my-integrations)からインテグレーションを作成することで可能です。

### 実行
上記のコードがかけたら以下のコマンドで実行してみてください。おそらく、discord上でボットがオンライン扱いに変更され、URLを送ると要約までしてくれるかと思います！
```bash
poetry run python /hoge/main.py
                   ↑この部分はmain.pyへのパスにしてください
```

## デプロイ
今回は`fly.io`を用いてデプロイを行いました。

こちらの記事が大変参考になりました。
https://qiita.com/Erytheia/items/f134f210789842340066
また、ページ内に配置するblockの作成方法は[公式リファレンス](https://developers.notion.com/reference/block)が一番わかりやすいと感じたので載せておきます！

具体的な手順としては
### 1. `Dockerfile`を作成
```
FROM python:3.11
WORKDIR /bot
COPY requirements.txt /bot/
RUN pip install -r requirements.txt
COPY . /bot
CMD python make_thread_summary/main.py
```

### 2. `fly launch`コマンドの実行＋fly側の設定

### 3. `fly.toml`二以下の内容を記載

```toml
app = 'make-thread-summary'
primary_region = 'nrt'
kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[env]

```

### 4.`fly deploy`コマンドの実行

の４ステップで可能でした!

# 改善点
今回botを実装してみてまだまだ改善したいポイントが見つかったので備忘録的に残します。

- urlの検出・要約を行うチャンネルをdiscord側から限定できるようにする
    - 要約と保存をする or 保存だけをする or 何もしない　
- コードが適当だったので設計してから実装する


# おわりに
最後まで読んでいただきありがとうございました。

改めて見返してみると、あまり綺麗なコードとは言えませんが個人的にはかなり便利なボットが作れたので良かったです！

誤字脱字や内容の誤りがあれば、編集リクエストやコメント等で**や さ し く**ご指摘いただけると幸いです🥺