FF14コミュニティファインダーの投稿をDiscord BOTにDMしてもらいたい

## 2024/11/26追記
コミュニティファインダーにアップデートがあったようで、コメント投稿者が該当コミュニティ参加者でなければ、コメントが閲覧できなくなったようです。プライバシー対応かな？
そのためBOTからBS4でのコメント閲覧ができなくなってしまいました。

## １．なぜ作ったの？
QiitaでDiscordのタイムラインを見ていると、
[Discordで原神のニュースを取得・通知するbotをつく (りたか) った](https://qiita.com/Hyoira/items/95974b1bb92c4c345f38 "Discordで原神のニュースを取得・通知するbotをつく (りたか) った")
という記事を見かけて、Discord BOTにWebスクレイピングを組み込むのかー、なるほどー！と。
MMO FF14には公式でギルドメンバーを募集するウェブサイト [コミュニティファインダー](https://jp.finalfantasyxiv.com/lodestone/community_finder/ "コミュニティファインダー")というものがあるのですが、掲示板的に投稿やコメントができるものの、新規コメントの通知は公式のユーザページにしかなく、新たなコメントに気づきにくいものでした。
先ほどの記事の実装と似た感じで、コミュニティファインダーの新規投稿を、Discord BOTにDMしてもらえば、気づくのが早くなるのでは？
という思いつきです。

## ２．実装
どこを対象にするかChrome 検証で確認しながら作成しました。
現状では古い投稿も全てDMされてしまいますが、古い投稿は削除する運用にしているので、まぁとりあえずいいか、と思っています。
<details><summary>discordbot.py (20240212)</summary>

```python:discordbot.py (20240212)
import discord
from discord.ext import commands, tasks
import requests
from bs4 import BeautifulSoup
import os

# Webスクレイピング対象のURL
TARGET_URL = 'https://jp.finalfantasyxiv.com/lodestone/community_finder/*********'

@tasks.loop(hours=3)  # 3時間ごとに実行
async def check_website():
    try:
        response = requests.get(TARGET_URL)
        soup = BeautifulSoup(response.content, 'html.parser')

        # 特定のクラスを持つ要素を選択
        for news_item in soup.find_all('div', class_='cf-comment'):

            comment__name = news_item.find('div', class_='cf-comment__header').get_text(strip=False)
            comment__body = news_item.find('p', class_='cf-comment__body').get_text(strip=False)

            embed = discord.Embed(title="FF14コミュニティファインダー新着メッセージ",description="新着メッセージがありました！")
            embed.add_field(name="name",value=comment__name,inline=False)
            embed.add_field(name="body",value=comment__body,inline=False)
            user = await bot.fetch_user(*************)  # 特定のユーザーのIDを指定
            await user.send(embed=embed)  # ユーザーにDMを送信

    except Exception as e:
        print(f'エラーが発生しました: {e}')

# Botが準備完了したときに実行されるイベント
@bot.event
async def on_ready():
    print("bot is ready")
    check_website.start()  # タスクを開始
```
</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/be3e8fb5-a3eb-27a7-0eed-9c1b1611901f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/905762e9-7ff0-863a-ad54-dc1c5571758e.png)

## ３．悩んだポイント
動いてそうだけど、DiscordのDMが来ないなー？？
と思ったら、Discordがスパムと判断して、BOTからのDMを受け取らないようになっていたようでした。
BOT宛てに一度DMを送ると、BOTからユーザ宛てにDMが届くようになりました。

## ４．今後
* できれば差分でDM送信してくれるとありがたい。
* DMにキャラクターの画像とかも添付できたらかっこいいよね。

## ５．その後
~~やっぱりBOTからのDMはスパム扱いされるのか、送信されてこなくなるので、どうしたものかと考え中
普通にチャンネルに投稿しようかなぁ・・・
なら差分機能がほしくなるよねー~~
メンバーと他の人だと投稿した時のCSSのクラス設定が違っていて、正しく取得できていなかっただけでした。

## ６．非同期処理（20240213追加）
ぼんやりQiitaのタイムラインを眺めていると、
[【23日目】コードは非同期処理で。【PythonでDiscordBOTを作ろう！】](https://qiita.com/nattyan_tv/items/d0d8e7258c3b150fb08a)
という記事を見つけて。
なるほどWindowsのアプリのように、非同期処理でマルチタスクな感じにする方がいいよね！
というわけで、記事を参考にrequestsをaiohttpに書き直し。
requests_htmlというのを使うと非同期もWebスクレイピングもできそうだったけど、読み解くのが面倒だったのでBeautifulSoupのままです。
<details><summary>discordbot.py (20240213)</summary>

```python:discordbot.py (20240213)
# Webスクレイピング対象のURL
TARGET_URL = os.getenv('TARGET_URL')

@tasks.loop(hours=1)  # 1時間ごとに実行
async def check_website():
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(TARGET_URL) as response:
                data = await response.text()
                soup = BeautifulSoup(data, 'html.parser')

                # 特定のクラスを持つ要素を選択
                for news_item in soup.find_all('div', class_='cf-comment'):

                    comment__name = news_item.find('div', class_='cf-comment__header').get_text(strip=False)
                    comment__body = news_item.find('p', class_='cf-comment__body').get_text(strip=False)

                    embed = discord.Embed(title="FF14コミュニティファインダー新着メッセージ",description="新着メッセージがありました！")
                    embed.add_field(name="name",value=comment__name,inline=False)
                    embed.add_field(name="body",value=comment__body,inline=False)
                    user = await bot.fetch_user(os.getenv('USER_ID'))  # 特定のユーザーのIDを指定
                    await user.send(embed=embed)  # ユーザーにDMを送信

    except Exception as e:
        print(f'エラーが発生しました: {e}')

# Botが準備完了したときに実行されるイベント
@bot.event
async def on_ready():
    print("bot is ready")
    check_website.start()  # タスクを開始
```
</details>

## ７．差分送信（以下20240215追記）
新着有無がわかればいい、と思っていましたが、消してないコメントはずっとDM送信されてきてしまうのはやはり鬱陶しく、このままでは本当に新着メッセージがある時にDMを見ない、ということにもなりかねない、ということで差分の抽出に取り組みました。
なかなか思ったように動作せずいろいろ試行錯誤しましたが、うまく差分で送信できるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/115f2e0c-08a6-a1bd-bd0a-885e33636e64.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/8bbd91ba-d3df-1974-64a7-58144e9d2c35.png)

```python:discordbot.py (20240215)
import discord
from discord.ext import commands, tasks
import aiohttp
from bs4 import BeautifulSoup
import os
import pandas as pd

intents = discord.Intents.default()
intents.message_content = True
# bot = discord.Client(intents=intents)
bot = commands.Bot(command_prefix='!', intents=intents)

# Webスクレイピング対象のURL
TARGET_URL = os.getenv('TARGET_URL')

@tasks.loop(hours=1)  # 1時間ごとに実行
async def check_website():
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(TARGET_URL) as response:
                com_html = await response.text()
                soup = BeautifulSoup(com_html, 'html.parser')
                data = []

                user = await bot.fetch_user(os.getenv('USER_ID'))  # 特定のユーザーのIDを指定

                # 特定のクラスを持つ要素を選択
                for news_item in soup.find_all('div', class_='cf-comment'):

                    comment__face = news_item.find('img')['src']
                    comment__name = news_item.find('div', class_='cf-comment__header').get_text(strip=False)
                    comment__body = news_item.find('p', class_='cf-comment__body').get_text(strip=False)

                    data.append({
                        'name':comment__name,
                        'body':comment__body,
                        'face':comment__face
                    })

                new=pd.DataFrame(data)
                if os.path.isfile('/tmp/scrap.pkl') == False:
                    old=pd.DataFrame({'name': ['a'],'body': ['a']})
                else:
                    old=pd.read_pickle('/tmp/scrap.pkl')

                merged_data = pd.merge(new,old,on=['name','body'],how='left', indicator=True)
                new_entries = merged_data[merged_data['_merge'] == 'left_only']

                for row in new_entries.to_dict(orient="records"):
                    embed = discord.Embed(title="FF14コミュニティファインダー新着メッセージ",url=TARGET_URL,description="新着メッセージがありました！")

                    embed.add_field(name='name',value=row['name'],inline=True)
                    embed.add_field(name='body',value=row['body'],inline=False)
                    embed.set_thumbnail(url=row['face'])
                    embed.set_footer(text="クロスワールドリンクシェル Holiday-AOMA メンバー募集")

                    await user.send(embed=embed)  # ユーザーにDMを送信

                new.drop(columns=['face']).to_pickle('/tmp/scrap.pkl')

    except Exception as e:
        print(f'エラーが発生しました: {e}')

# Botが準備完了したときに実行されるイベント
@bot.event
async def on_ready():
    print("bot is ready")
    check_website.start()  # タスクを開始
```

### 差分苦労ポイント１：CSV入出力がそもそも使えない
PANDASではファイル入出力もあって、df.read_csv('hoge.csv')とか、df.to_csv('hoge.csv')で、csvへ読み書きできて、これまた便利なのですが、ファイル出力部分でエラーになっているような雰囲気。よくよく考えると、取得した名前部分が
```
コミュニティメンバー

Calocen RietiChocobo [Mana]
```
と改行が入っていることに気づきました。
BeautifulSoupで分割できないかと思いましたが、途中にタグが挿入してあり単純な分割が難しそうだったので、CSV以外の入出力方法を[PANDAS公式のファイル入出力](https://pandas.pydata.org/docs/user_guide/io.html)で確認。
pickel形式がpythonぽく、しかも応答も良さそうだったのでpickel形式にしました。

### 差分苦労ポイント２：PANDASデータフレームがなんか遅い
参考とした記事で、PANDASのデータフレームで新旧をマージしてLeft OUTERで差分抽出するような記述となっており、なるほどこれは直感的でわかりやすい、便利なライブラリがあるんだなーと思って下のような形で記述していたのですが、
```python:discordbot.py 抜粋
                 for index, row in new_entries.iterrows():
                    embed = discord.Embed(title="FF14コミュニティファインダー新着メッセージ",url=TARGET_URL,description="新着メッセージがありました！")

                    embed.add_field(name='name',value=row['name'],inline=True)
                    embed.add_field(name='body',value=row['body'],inline=False)
                    embed.set_thumbnail(url=row['face'])
                    embed.set_footer(text="クロスワールドリンクシェル Holiday-AOMA メンバー募集")

                    await user.send(embed=embed)  # ユーザーにDMを送信
```
このコードでは、うまくDiscordでDM送信できませんでした。
どうもembedにNullが渡されエラーとなっているような雰囲気、たぶん。
DM送信部分はawaitされ非同期処理ですが、その他の部分は同期処理となっているので、たぶんループよりDM送信が先に動こうとしてるのではないか？と予想して、調査。
Qiitaで検索してみると、iterrows() でのデータフレームの行単位のループが遅いらしいという記事が散見されました。
[pandasのデータフレームを使用したfor文において脱iterrows()を試みたら実行時間が約70倍高速化した話](https://qiita.com/daikikatsuragawa/items/1658134af600be2c1c16)
こちらの記事を参考に、iterrows()の代わりに、.to_dict(orient="records") として処理速度改善を試みました。
本当はループで行っているEmbedの代入処理を、gatherでの非同期処理にしたかったのですが、どのように実装すればいいのかわからず、早々に諦めてしまいました・・・できたらかっこよかったのになぁ。

### 差分苦労ポイント３：GAEでファイル書き出しができない？
pickelでの対応としていましたが、書き出しのところで読み取り専用というようなエラー。当初、ローカル環境で項目名だけ入った空のpickelファイルを作成しGAEにアップロードしていたので、GAEで作成するようにしてもエラー。
GAEではファイルの書き出しができないのか？と調べてみると、[一時ファイルであれば/tmpで使える](https://cloud.google.com/appengine/docs/standard/using-temp-files?hl=ja&tab=python)とのこと。あまり深く考えずルートに作成しようとしていたのがNGだったようです。
インスタンスが消えれば消えるみたいですが、最近は安定しているのであまり影響もなさそうなのと、googleドライブなど他のストレージサービスより気軽に使えそうなので/tmp/scrap.pklを利用するようにしています。

### 実装での残念ポイント
いろいろ苦労して実装したのですが、結局私だけしかDMという出力結果を目の当たりにしないので、なんだかなー、とは思います。
経験値がたまったので、ヨシ！

## ８．GAEのインスタンスが消える対応（以下20240225追記）
差分は正しく動作していてよかったのですが、GAEのインスタンスが消えると /tmp もリセットされて全て消えてしまうことに気づきました。インスタンスは1日2度くらい切り替えが行われるので、その都度、全ての投稿がDiscordにDMされてきてしまう、という状態になっていました。
履歴を固定の決まったデータとしてどこかに保存して、そのデータをインスタンス切り替え時も読み込むべきだろう、という考えでコードの修正に取り掛かりました。

取得したデータの書き出し先ですが、google Firestore（Datastore モード）、google cloud storageを候補に。
* Datastore であればjsonのような形でデータを残しておけそうで今回のウェブスクライピングには合ってそう！データの差分抽出はPANDASのマージで実現できているので問題ではなかったのですが、Datastoreのデータを消したり、データを書き出したりが、直感的によくわからず・・・
* cloud storageなら、現在の一時ファイルの保存先をcloud storageにすればいいだけー、簡単！
と思いきや、恐らく書き出し読み出しの処理がそれなりに重いようで、その前にBOTが動こうとしてエラー、みたいな状態に。

あれこれ試行錯誤して、
* cloud storage を使う（Datastoreがよくわからなかった）
* 現在のメモリを利用している/tmp/ の一時ファイルも利用する
* BOTの動作への影響が少なくなるように、BOT動作の前後にファイルの読み込み、書き出しを行う

新たにgcs_file.pyというものを作って、cloud storageにアップロード、ダウンロードする関数を作成しました。
* dl_scrap は、GCSからscrap.pklをダウンロードして、/tmp/scrap.pkl に保存。
BOTを起動させる前に直前のインスタンスで使っていたであろう履歴ファイルを自分の一時ファイルとしてダウンロードします。
* up_scrap は、/tmp/scrap.pkl をGCSにアップロード。
一時ファイルに保存する動きの後に、こちらを呼び出しし、GCSにアップロードしています。

なので、BOTはメモリ上にある一時ファイル /tmp/scrap.pklを利用するのですが、裏でこそこそGCSにアップロードしていて、新規にインスタンスが作られた時には物理ファイルを一時ファイルに持ってくる、みたいな形になりました。

```python:gcs_file.py
import os
from google.cloud import storage

def dl_scrap():
    BUCKET_NAME = os.getenv('BUCKET_NAME')
    BLOB_NAME= os.getenv('BLOB_NAME')

    storage_client = storage.Client()
    bucket = storage_client.bucket(BUCKET_NAME)
    blob = bucket.blob(BLOB_NAME)
    blob.download_to_filename('/tmp/scrap.pkl')

def up_scrap():
    BUCKET_NAME = os.getenv('BUCKET_NAME')
    BLOB_NAME= os.getenv('BLOB_NAME')

    storage_client = storage.Client()
    bucket = storage_client.bucket(BUCKET_NAME)
    blob = bucket.blob(BLOB_NAME)
    blob.upload_from_filename('/tmp/scrap.pkl')
```