小説家になろうの更新を通知するDiscord botを作った

#はじめに
[小説家になろう](https://syosetu.com/)って、あるじゃないですか。
しょっちゅう[形態素解析](https://qiita.com/tabu_ichi2/items/984e6b5dfbce6727778b)されたり[スクレイピング](https://qiita.com/kokokocococo555/items/8a750cc588c45782d2c2)されたり[タイトルの文字数平均を求め](https://qiita.com/dely13/items/5e949a384161c961d8ce)られたりしてる、アレですよ。
私の好きな「[シャングリラ・フロンティア](https://ncode.syosetu.com/n6169dz/)」は、不定期更新であり、いつ更新されるかわからないため2分に一度は目次をリロードして更新が来ていないか確認する必要があります。
これは非常にめんどくさいので、Discordを開いていれば勝手に知らせてくれるbotを作ることにしました。

#環境

* Python 3.8.1
* pip 20.0.2
* Discord.py 1.3.2

#準備
[ここ](https://discordapp.com/developers/applications)に行ってNew ApplicationでBot -> Build-A-Botしてトークンをゲットし、OAuth2でbotにチェックを入れて本拠地となるDiscordのサーバーに招待しましょう。

#作る
##基本
とりあえず基本部分を作ります

```Python:DiscordBot.py
import discord
TOKEN = '****************'
CHANNEL_ID = ***************** #本拠地となるチャンネルのIDを入れる
client = discord.Client()

@client.event
async def on_ready():
    await channel.send("on ready")

client.run(TOKEN)
```

起動したら「on ready」と発言するだけの簡素なbotができました。
##ループ
次にループ部分を作りましょう。`tasks.loop`でループさせ、10秒に一度~~requestsで~~aiohttpでAPIを叩きます。[^1]

```Python:DiscordBot.py
import discord
from discord.ext import tasks
import aiohttp
TOKEN = '****************'
CHANNEL_ID = ***************** #本拠地となるチャンネルのIDを入れる
client = discord.Client()

@client.event
async def on_ready():
    loop.start()
    await channel.send("on ready")

@tasks.loop(seconds=10)
async def loop():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:

client.run(TOKEN)
```

##API処理
「返ってきたAPIの最終掲載日」と「それまで(10秒前)に叩いていたAPIの最終掲載日」を比較し、異なった場合は更新されたと判断してチャンネルにメッセージを送ります。

```Python:DiscordBot.py
import discord
from discord.ext import tasks
import aiohttp
import yaml
TOKEN = '****************'
CHANNEL_ID = ***************** #本拠地となるチャンネルのIDを入れる
client = discord.Client()
lastup = "null"

@client.event
async def on_ready():
    loop.start()
    await channel.send("on ready")

@tasks.loop(seconds=10)
async def loop():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:
            new_lastup = yaml.safe_load(await r.text())[1]["general_lastup"]
            channel = client.get_channel(CHANNEL_ID)
            global lastup
            if lastup != new_lastup:
                lastup = new_lastup
                await channel.send('シャンフロ更新来た')
client.run(TOKEN)
```
とりあえずこれで、更新時に通知するbotはできました。
しかし、これでは「**起動時に変数**`lastup`**はnullに設定されるため、起動直後のループ1周目で「更新が来た」と錯覚してしまう**」という問題点があります。

##起動時処理の補強
なので、起動時に一度APIを叩いて`lastup`に代入することで、それを解決します。

```Python:DiscordBot.py
import discord
from discord.ext import tasks
import aiohttp
import yaml
import datetime
TOKEN = '****************'
CHANNEL_ID = ***************** #本拠地となるチャンネルのIDを入れる
client = discord.Client()
lastup = "null"

@client.event
async def on_ready():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:
            global lastup
            lastup = yaml.safe_load(await r.text())[1]["general_lastup"]
    loop.start()
    channel = client.get_channel(CHANNEL_ID)
    await channel.send("on ready")
    await channel.send("最終更新：" + str(lastup)) #ついでに起動時に最終更新日時を伝えるようにしました

@tasks.loop(seconds=10)
async def loop():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:
            new_lastup = yaml.safe_load(await r.text())[1]["general_lastup"]
            channel = client.get_channel(CHANNEL_ID)
            global lastup
            if lastup != new_lastup:
                lastup = new_lastup
                await channel.send('シャンフロ更新来た')
client.run(TOKEN)
```
##おまけ
これでもう完成といってもいいんですが、チャットボットなのに一方向に情報を伝える機能しかなくて、こちらから送れるコマンドが一つもないというのもアレなので、一つコマンドを追加します。
`[最終更新からどれくらい？]`と聞くと、timedeltaオブジェクトで答えてくれるようにします。

```Python:DiscordBot.py
import discord
from discord.ext import tasks
import aiohttp
import yaml
import datetime
TOKEN = '****************'
CHANNEL_ID = ***************** #本拠地となるチャンネルのIDを入れる
client = discord.Client()
lastup = "null"

@client.event
async def on_ready():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:
            global lastup
            lastup = yaml.safe_load(await r.text())[1]["general_lastup"]
    loop.start()
    channel = client.get_channel(CHANNEL_ID)
    await channel.send("on ready")
    await channel.send("最終更新：" + str(lastup)) #ついでに起動時に最終更新日時を伝えるようにしました

@client.event
async def on_message(message):
    if message.author.bot:
        return
    if message.content == '[最終更新からどれくらい？]':
        dif = datetime.datetime.now() - lastup
        await message.channel.send(dif)

@tasks.loop(seconds=10)
async def loop():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://api.syosetu.com/novelapi/api/?ncode=n6169dz&of=gl') as r:
            new_lastup = yaml.safe_load(await r.text())[1]["general_lastup"]
            channel = client.get_channel(CHANNEL_ID)
            global lastup
            if lastup != new_lastup:
                lastup = new_lastup
                await channel.send('シャンフロ更新来た')
client.run(TOKEN)
```
これで完成です。
#参考

* [【最新版】discord.pyでbotに一定時間ごとに発言させる](https://qiita.com/rareshana/items/2917b42016557e27229b)
* [Discord Bot 最速チュートリアル【Python&Heroku&GitHub】](https://qiita.com/1ntegrale9/items/aa4b373e8895273875a8)
* [Pythonで実用Discord Bot(discordpy解説)](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)

[^1]: ~~非同期処理可能なaiohttpの方がいいらしいんですけど、よくわからないしちょっとくらいの遅延は許容範囲だろうと考えてrequestsにしました~~aiohttpを使用するように変更しました。なおほぼDiscord.pyのドキュメントの丸写しです。