Discordで原神のニュースを取得・通知するbotをつく (りたか) った

:::note warn
※この記事で紹介している内容は不完全かつ初学者によるものです。完成したら更新するかもしれません
:::

## 思ったより長くなったので概略
プログラム初学者がノリでDiscord botの開発に手を出してみました。
Webをスクレイピングしてニュースの更新を通知するbotをつくりたかったので、やったことや使ったライブラリ、書いたコードや制作中の課題について書いてみました。
しかしbotは現状では上手く動いていないので、これからも頑張ります……。

https://github.com/Hyoira/Charlotte-bot

## 背景
Discordのbotをつくってみたかった。
調べてみたら [discord.py](https://discordpy.readthedocs.io/ja/latest/) というPython向けの非公式ライブラリがあるので、Pythonの勉強がてら触ってみることにした。

今回の内容は、1週間程度の試行錯誤でできたことのまとめとなっている。
内容の正確性については全くこれっぽっちも保証しない。

## つくりたいもの
- [原神公式サイトのニュース](https://genshin.hoyoverse.com/ja/news) の更新を取得し、Discordの指定チャンネルに送信するbot

![Pasted image 20231219171538.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/b22a3642-5b42-8971-693c-bcaed328ba45.png)

## 実際にやってみたこと
### Discord Developer Portalの登録
Discordのbotをつくるためには、[Discord Developer Portal](https://discord.com/developers/docs/intro)で開発者登録をする必要がある。

[公式のGetting Started (日本語)](https://discord.com/developers/docs/ja/getting-started) が充実しているのでこの辺を見ながら登録する。
(今回は触れないが、ここではJavaScriptをつかった開発ガイドが載っているので見てみるといいかもしれない)

特にbotを動かすために必要な秘匿情報である**トークン (TOKEN) は一度きりしか表示されない**ので、どこか安全な場所に置いておくといい。

![Pasted image 20231219155817.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/8d5701d8-838d-befa-20d8-e57b56ac270a.png)
(私的サーバーに置いておこうと思ったら怒られた)

こういう画面になればOK
![Pasted image 20231219170003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/b3f8e92d-6e27-368b-2c43-81023d9f946d.png)


ここで、Discordのデスクトップアプリケーションにおいて、設定>詳細設定にある「開発者モード」を有効にしておくといい。
開発者モードを有効にしておけば、チャンネルやユーザーを右クリックするとIDをコピーするオプションが表示されるなどの機能が追加されるため、プログラムを書くときに便利になる。
![Pasted image 20231219170614.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/d217f366-e472-b7f8-89a4-9f25c7df697a.png)


### botの構造を考える
何をするにも計画から、ということでプログラムの構造を考えた。

繰り返しになるが、Python初学者の構想なので間違いや非効率な点が十分に有り得ることは承知いただきたい。

botをつくるにあたり、botを動かす本体プログラムとそれ以外の機能は分離することを念頭に置いた。これは、今後機能を追加することになったときや不具合が生じたときの保守性を高めるためである。

したがって、今回はbot本体である `bot.py` とニュースの通知をするための `get_latest_news.py` という2つのプログラムに分けた。

また、ひとつの機能の中でも動作を細分化し、クラス化することとした。今回で言えば、
- `Scrape` (ページの取得)
- `Convert` (データの整形)
- `UpdateCheck` (更新検知)
の3段階に分けた。

概念図はこんな感じ。
![Pasted image 20231219155537.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/5b6b70ec-607b-6b74-34c6-0be6f910f1c8.png)

本体プログラム内で `get_latest_news.py` を1分間隔で実行することで更新を検知するようにする。

### 使ったライブラリや環境
Pythonには非常に多くのライブラリがあり、やりたいことを簡単に実現できる。

今回の目的のために使ったライブラリは以下の通り。

- `discord.py` (botを動かす)
- `selenium` (Webブラウザを自動操作し、ページを取得する)
- `Beautiful Soup` (HTMLの解析を行う)
- `Pandas` (データフレームを扱う)
- `python-dotenv` (環境変数を管理する ※後述)

これに加えて、`selenium` を動かすために必要なChromeDriverについても導入した。

また、Pythonの実行環境については [pyenv](https://github.com/pyenv/pyenv) を用いた。これはPythonのバージョン管理に適したツールで、コマンド一つで複数のバージョンをインストールしたり管理したりできる。

システムのPython環境を汚したくなかったので、このbotのためだけのバージョンを作成して利用した。他OSでの管理も楽になる。 (~~Dockerを使えって？すみません勉強中です~~)

### 環境変数の管理
環境変数とは、botを動かすためのTOKENやドライバのpath、DiscordのチャンネルIDなどの情報だ。
これらは秘匿情報なのでGitHubで管理する際には隠さなければならない (コードの中に直接記載してはならない) し、開発環境と実行環境ではpathを変えなければならないこともある。

これについては python-dotenv というライブラリで解決できる。

プロジェクトフォルダに `.env` というファイルを作成し、それをテキストエディタで開いて以下のように記述する。

```:.env
BOT_TOKEN = "token here"
CHANNEL_ID = "channel id here"
```

続いて、これらの情報が必要なプログラムの中で以下の内容を記述する。
今回であれば bot.py がそれにあたる。

```python:bot.py
import dotenv
import os

dotenv.load_dotenv(override=True)

# 環境変数
token = os.getenv('BOT_TOKEN')
channel_id = int(os.getenv('CHANNEL_ID'))
```

こうすることで、外部ファイルに記載した環境変数を読み込むことができる。

もちろん、`.gitignore` ファイルに `.env` ファイルを指定してGit管理下から外しておかなければならない。

### コードを書く
設計ができた。環境が整った。なら次はコードを書く。

なお以下のコードは本稿執筆時点でのものとなる。今後更新される可能性が大いにある。

まずは更新を取得する部分のコードだ。
先ほどの構造の通り、動作ごとにクラス化している。

```python: get_latest_news.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from bs4 import BeautifulSoup
import pandas as pd
import os

class Scrape: # データの取得
    @staticmethod
    def scrape(url):
        options = Options()
        options.add_argument("--no-sandbox") # サンドボックスを無効化
        options.add_argument("--headless") # GUIで起動しない
        options.add_argument("Accept-Language: ja-JP") # 日本語ページを指定
        options.add_argument('--lang=ja-JP') # 日本語ページを指定

        driver = webdriver.Chrome(options=options)

        try:
            driver.get(url)
            html = driver.page_source
            return html
        finally:
            driver.quit()

class Convert: # データの整形
    @staticmethod
    def convert(html):
        soup = BeautifulSoup(html, 'html.parser') # BeautifulSoupのオブジェクトを作成
        data = []

        # 記事のタイトル、URL、サムネイル画像、概要を取得
        for news_item in soup.find_all('a', class_='news__title news__content ellipsis'):
            url = f"https://genshin.hoyoverse.com{news_item['href']}"
            title = news_item.find('h3').get_text(strip=True)
            cover_image = news_item.find('img', class_='coverFit')['src']
            summary = news_item.find('p', class_='news__summary').get_text(strip=True)

            data.append({
                "Title": title,
                "URL": url,
                "Cover Image": cover_image,
                "Summary": summary
            })

        return pd.DataFrame(data) # データフレームで返す

class UpdateCheck: # データの比較
    @staticmethod
    def check_for_updates(old_file, new_data):
        # 前回の取得情報を読み込む
        try:
            old_data = pd.read_csv(old_file)
        except FileNotFoundError:
            print(f"'{old_file}'に前回の取得情報がありません。")
            return new_data

        merged_data = pd.merge(
            new_data,
            old_data,
            on=["Title", "URL", "Cover Image", "Summary"], 
            how='left', 
            indicator=True)
        new_entries = merged_data[merged_data['_merge'] == 'left_only']

        if not new_entries.empty:
            new_entries.to_csv(old_file, index=False)
            print(f"{len(new_entries)}件の新着記事があります")
        else:
            print("新着記事なし")

        return new_entries.drop(columns=['_merge'])
```

続いて、bot本体のプログラムは以下のようになった。

```python:bot.py
import discord
from discord.ext import tasks
import os
from get_latest_news import Scrape, Convert, UpdateCheck
import dotenv

# 環境変数の読み込み
dotenv.load_dotenv(override=True)
token = os.getenv('BOT_TOKEN')
channel_id = int(os.getenv('CHANNEL_ID'))

print(os.getenv('CHANNEL_ID'))

# 必要な intents を設定
intents = discord.Intents.default()
intents.messages = True
intents.guilds = True
intents.message_content = True

# intents を渡して Bot インスタンスを作成
client = discord.Client(intents=intents)

# ログイン時にターミナルに通知する
@client.event
async def on_ready():
    print(f'Logged in as {client.user.name}')

    # 指定されたチャンネルを取得
    # チャンネルが取得できていたら更新チェックを開始
    try:
        channel = await client.fetch_channel(channel_id)
    except discord.NotFound:
        print(f"指定されたIDのチャンネルが見つかりません: {channel_id}")
        return
    except discord.HTTPException as e:
        print(f"チャンネルの取得に失敗しました: {e}")
        return
    print(f"チャンネル「{channel.name}」が見つかりました")
    check_updates.start()
        

# ニュースの更新チェック
@tasks.loop(seconds=60)
async def check_updates():
    channel = await client.fetch_channel(channel_id)
    print('Checking for updates...')

    scraper = Scrape()
    html = scraper.scrape("https://genshin.hoyoverse.com/ja/news/")
    new_data = Convert.convert(html)
    checker = UpdateCheck()

    # 新しい更新がある行のみを取得
    updates = checker.check_for_updates('data_prev.csv', new_data)
    
    if not updates.empty:
        # 新しいデータをCSVファイルに保存 (次回更新チェック用)
        new_data.to_csv('data_prev.csv', index=False)

        # Discordに埋め込みメッセージとして送信
        for index, row in updates.iterrows():
            embed = discord.Embed(
                title=row['Title'],
                url=row['URL'],
                description=row['Summary'],
                color=0x00bfff)
            embed.set_image(url=row['Cover Image'])
            await channel.send(embed=embed)
    else:
        print("No updates")

# Botのトークンを環境変数から取得して実行
client.run(token)
```

正直、しっかりとPythonのプログラムを書いたことなんてほとんど無かったので、調べて書いて試して直して……の連続だった。

### 動作テスト
まずはニュースの取得・更新検知が上手く動くか。
こんなコードを追加して実行してみた。

```python
# テスト用
if __name__ == "__main__":
    url = "https://genshin.hoyoverse.com/ja/news/"
    html = Scrape.scrape(url)
    new_data = Convert.convert(html)

    checker = UpdateCheck()
    updates = checker.check_for_updates(os.path.abspath('data_prev.csv'), new_data)

    if not updates.empty:
        print(updates["Title"])
    else:
        print("新着記事なし")

    new_data.to_csv(os.path.abspath('data_prev.csv'), index=False)
```

お、取得できてるっぽい。
![Pasted image 20231219173734.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/577c8be0-9c80-fa7b-de59-e64ba70efe5a.png)

取得したデータを書き込むCSVファイルにも、必要な情報が適切にまとまっている。
![Pasted image 20231219173831.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/cc222da4-a7f2-335b-2242-8e8015e0f0a0.png)


ではbotを動かしてみる。
![Pasted image 20231219173939.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/4557024d-d0a2-f154-6b00-74a7055f7346.png)

こんな感じで埋め込みメッセージとして送信されてきた。

試しに「前回取得データ」のCSVファイルの一部を消してみても、新たに更新された分と比較されて、消した部分の記事だけ通知してくれた。

よっしゃ！これで問題ないで〜〜！！！


### 実際に運用
botを動かすためには、インターネットに繋がったサーバー上でbot本体のプログラムを走らせる必要がある。

今回は、自宅にある小型PCを使って動かすことにした。
ちなみにこのPCは普段はMinecraftのゲームサーバーとして動かしているので、その空きリソースを間借りする感じになる。

このプログラム自体に外部からのリクエストが飛んでくるわけではなく、トークンによって外部APIとやりとりするだけなので、ポート開放やファイアウォールの設定を特別に行う必要はない (と思う)。

そういうわけで、実行環境も整えてプログラムファイルなども移したので、いざ実行してみた。


**動かない。**


詳細は後述するが、うまく動かない。どうして……。

といったところまでが、ここ1週間ほどの成果となった。
現在も問題解決に向けて奮闘しているところだ。

制作中にトラブったことや今後の課題について、以下にまとめていく。

## 開発中にトラブったこと
### botがチャンネルを取得しない
メッセージを送信するためのチャンネルを指定しても、チャンネルの取得がうまくいかなかった。もちろんチャンネルIDの確認は何度もしている。

いろいろな解説では get_channel() 関数を用いてチャンネルを取得することが書かれているが、今回は以下のように記述することでチャンネルの取得ができた。

```python
channel = await client.fetch_channel(channel_id)
```

もしかしたら非効率だったりで推奨されない書き方かもしれないが、ひとまずこれで動作したということで。

また、原因の切り分けのために、botが送信先チャンネルを取得してからメインの機能を実行するようにしておくといいと思う。上記のプログラムではそのようにしている。

### 英語版のページを拾ってくることがある
これについては以下のように、ChromeDriverに日本語のオプションをつけることで改善されるらしい。
```python
options = Options()
        options.add_argument('--lang=ja-JP') # 日本語ページを指定
```

現状はこれで上手く行っているように見えるので経過観察中。

### 環境変数が読み込まれない
.env ファイルに記載した環境変数を変更しても、プログラム内では値が変更前のまま変わっていない現象に遭遇した。

おそらくキャッシュのようにプログラムのほうで値が残っているのだろうが、その際は環境変数を読み込む際に

```python
dotenv.load_dotenv(override=True)
```

というオプションをつけることで強制的に .env から読み込ませることができた。

## 今後の課題
### botがニュースの更新をうまく取得できていない
最大の課題である。プログラム単体 (get_latest_news.py) を動作させたときにはきちんとデータを取得し、差分比較で更新を検知できているのだが、botを起動して走らせると上手く動作しない。

未だに有効な解決策が見つかっていないので、原因を探っているところだ。
~~ここさえできていればもうちょっと堂々と記事をかけたのに~~

### botを動かすサーバーの管理
現在は自宅の小型PCのUbuntu上でbotを動作させているが、セキュリティ面 (サーバーへのアクセス方法など) や保守面 (停電対策・再起動など) については甘い部分だらけだと思う。

ネットワーク周りやUbuntuに関しての知識をつけつつ、安全に運用できるようにしていかなければならない。

## 謝辞
今回のプログラム作成にあたっては、LLMのチャットボットが大きく貢献してくれた。エラーから原因・解決策を提案してくたり、コードのリファクタリングの手伝いをしてくれたりと、専属のコーチのように助けてくれた。
利用したサービスは [リートン](https://www.wrtn.jp/)という国産サービスで、GPT-4 Turboを無料で使うことができた。

また、botの作成をしていることを話したところ、アイコンのために素晴らしいイラストを描いてくれた友人Nくんにも深く感謝したい。
ちなみに原神のニュースの更新通知ということで、記者のシャルロットをモチーフにすることもこの会話の中で決まった。

![kawaii!!](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204082/9fb6b5d7-08d3-2481-aeba-e57e454b19c9.png)


この素晴らしいアイコンを活かすためにも、安定した動作ができるようプログラムの改善を続けていかなければならない。