田園補完計画をスクレイピングする

<!-- 田園補完計画をスクレイピングする -->

※この記事は[Udon Advent Calendar 2024 - Adventar](https://adventar.org/calendars/10764)の11日目の記事です。

## はじめに

こんにちは。Udonです。

自分はUDCというサークルに所属しており、そこではデュエル・マスターズというカードゲームをしています。

デュエルマスターズをする上で欠かせないのは、現在の環境でどのようなデッキが活躍しているのか、そして新カードがどのような性能をしているのかをいち早く知ることです。

これらの情報をまとめている[田園補完計画](https://supersolenoid.jp/)というサイトがあります。

新カードについての情報は公式がYouTubeにアップしたりするのですが、それらの情報とともに、各地で行われた大会で活躍したデッキ、デッキの入賞数ランキングなどの情報がまとめられています。

というわけで、このサイトの情報をスクレイピングしてその結果をDiscordに通知するBotを作成しました。

## リポジトリ

リポジトリは以下の通りです。

https://github.com/Gitudon/UDC/tree/master/UDC_Information

上位ディレクトリにはほかのBotのディレクトリもありますが、とりあえずは``UDC_Information``のみに絞って話をしようと思います。ほかのBotについてはまた別の機会に話ができればいいなと思います。

## やったこと

まずは田園補完計画のサイトがどうなっているのかを「ページのソース表示」で確認しました。

このサイトは様々なカードゲームを扱っているので、[デュエマ関係のカテゴリのページ](https://supersolenoid.jp/blog-category-12.html)のみを扱うこととしました。

なんかたくさん書いてありましたが、どうやら記事のタイトルとそこへのリンクが``EntryTitle``というクラスの``div``タグの中にあるようでした。それを取得すれば、記事のタイトルとリンクが取得でき、どんな記事かくらいはわかると思いました。

次に、実際の記事の中身を見てみました。各カテゴリごとに、欲しい情報は以下のように格納されていました。

- 新カードの画像：``card_image``というクラスの``div``タグの中にある``img``要素の``src``属性
- 入賞数ランキング：````EntryBody``というクラスの``div``タグの中にある``a``要素の``href``属性
- 大会結果概要：``caption_white``というクラスの一つ目の``div``タグの中にある``text``要素
- 入賞者一覧：``caption_white``というクラスの二つ目の``div``タグの中にある``text``要素、または``dm_deck_name``というクラスの``p``タグの中にある``text``要素
- 入賞者のデッキ：``dm_deck_image``というクラスの``div``タグの中にある``img``要素の``src``属性

というわけで、こいつらをいい感じに取得できれば良いわけです。

また、実行の流れを以下のように規定しました。

1. 起動時、エントリーされた記事のリストを取得し、「が公開」「が優勝」「入賞数ランキング」のうちどれかが含まれている記事のみを取得し、リンクを送信済みリストに入れる
2. 60秒ごとにまたエントリーされた記事のリストを取得し、まだ送信済みでなく、かつ上記3つの文字列のいずれかを含んでいる記事を取得し、データを抜き取る
3. 抜き取ったデータをもとにメッセージを作成し、Discordに送信する
4. 送信した記事のみを送信済みリストに入れる
5. 2に戻る

という感じです。なぜ「が公開」「が優勝」「入賞数ランキング」のうちどれかが含まれている記事のみを送信済みリストに追加するのかというと、それ以外の記事まで送信済みに含めてしまうと、記事のタイトルが更新され条件を満たしたときに見逃してしまうからです。そして、記事が一気に複数投稿されることもあるので、60秒ごとにエントリーされた記事のリスト(1ページだけなので15個くらい)を取得し、条件を満たすものが複数あっても大丈夫なようにしています。

まず1.に関してですが、以下のようなプログラムで実装しました。

```python
async def ready():
    global latest_articles
    response = requests.get(denen_url)
    soup = BeautifulSoup(response.text, "html.parser")
    articles = soup.find_all("div",class_="EntryTitle")
    for a in articles:
        title = a.text
        if ("入賞数ランキング" in title) or ("が優勝" in title) or ("が公開" in title):
            latest_articles.append(a.find("a").get("href"))
```

2.～4.については、それぞれ以下の関数たちで実装しています。

```python
async def get_new_articles():
    response = requests.get(denen_url)
    soup = BeautifulSoup(response.text, "html.parser")
    article = soup.find_all("div",class_="EntryTitle")
    articles = []
    for a in article:
        articles.append(a.find("a").get("href"))
    article_title = soup.find_all("div",class_="EntryTitle")
    article_titles = []
    for t in article_title:
        article_titles.append(t.find("a").text)
    return articles, article_titles
```

トップページ(``denen_url``)から記事のリンクとタイトルを取得する関数です。

```python
async def ranking_check(new_article):
    response = requests.get(new_article)
    soup = BeautifulSoup(response.text, "html.parser")
    ranking_img = soup.find("div",class_="EntryBody").find("a").get("href")
    return ranking_img
```

受け取ったリンクから入賞数ランキングの画像を取得する関数です。

```python
async def result_check(new_article):
    response = requests.get(new_article)
    soup = BeautifulSoup(response.text, "html.parser")
    result_div = soup.find("div", class_="caption_white")
    # <br> タグを \n に置き換え
    for br in result_div.find_all("br"):
        br.replace_with("\n")
    # テキストを取得
    result_sentence = result_div.text
    result_names = soup.find_all("p", class_="dm_deck_name")
    names = [name.text for name in result_names]
    # 複数の画像を含む <img> 要素を取得
    result_imgs = soup.find_all("div", class_="dm_deck_image")
    imgs = [img.find("img").get("src") for img in result_imgs if img.find("img") is not None]
    return result_sentence, names, imgs
```

受け取ったリンクから大会概要、入賞者一覧、入賞者のデッキの画像を取得する関数です。入賞数や画像は複数あるので、リストにします。

```python
async def newcard_check(new_article):
    response = requests.get(new_article)
    soup = BeautifulSoup(response.text, "html.parser")
    newcard= soup.find_all("div",class_="card_image")
    newcard_img = [img.find("img").get("src") for img in newcard if img.find("img") is not None]
    return newcard_img
```

受け取ったリンクから新カードの画像を取得する関数です。画像は複数あることがあるので、リストにします。

```python
async def hacchi_result(new_article):
    response = requests.get(new_article)
    soup = BeautifulSoup(response.text, "html.parser")
    result_div = soup.find("div", class_="caption_white").find_next("div")
    for br in result_div.find_all("br"):
        br.replace_with("\n")
    # テキストを取得
    names = result_div.text
    result_url=soup.find("blockquote",class_="twitter-tweet").find("a").get("href")
    return names,result_url
```

大会によっては(はっちCS)、田園補完計画のサイトに入賞者一覧だけしか書いていないときがあります。そういう時は、外部リンクを取得します。``result_check``では入賞者デッキ画像のキャプションから入賞者一覧を取得していまずが、この場合は``caption_white``の中の2番目の``div``タグに入賞者一覧が書かれているので、それを取得します。

```python
async def check_new_article():
    global latest_articles
    new_articles, article_titles = await get_new_articles()
    page_length=len(new_articles)
    for i in range(page_length):
        new_article=new_articles[i]
        article_title=article_titles[i]
        if new_article not in latest_articles:
            if "入賞数ランキング" in article_title:
                channel = client.get_channel(DISCORD_CHANNEL_ID)
                await channel.send(await ranking_check(new_article))
                latest_articles = [new_article]+latest_articles
            elif "が優勝" in article_title:
                channel = client.get_channel(DISCORD_CHANNEL_ID_3)
                result_sentence, names, imgs = await result_check(new_article)
                txt=result_sentence+"\n"
                if "はっち" in article_title:
                    names,result_url=await hacchi_result(new_article)
                    txt+="\n"
                    txt+=names
                    txt+=result_url
                    await channel.send(txt)
                else:
                    for name in names:
                        txt+=("\n"+name)
                    await channel.send(txt)
                    for img in imgs:
                        await channel.send(img)
                latest_articles = [new_article]+latest_articles
            elif "が公開" in article_title:
                channel = client.get_channel(DISCORD_CHANNEL_ID_2)
                newcard_img = await newcard_check(new_article)
                if newcard_img !=[]:
                    latest_articles = [new_article]+latest_articles
                for img in newcard_img:
                    await channel.send(img)
    return
```

メインプログラムが60秒ごとに呼び出す関数です。新しい記事があるかどうかを確認し、それらを一つ一つ見ていって、まだ送信済みではなく、かつ特定の文字列を含んでいる記事があれば、それに応じた処理を行います。サークルの人の要望で、記事ごとに送信先のチャンネルを分けています。

```python
@client.event
async def on_ready():
    print("Bot is ready!")
    await ready()
    while True:
        await check_new_article()
        await asyncio.sleep(60)
```

これがBot本体となります。起動時に``ready``関数を呼び出し、60秒ごとに``check_new_article``関数を呼び出します。

最終的にDockerでホストするわけですが、そのあたりは[ここ](https://qiita.com/Q_Udon/items/35cf098bdec0a6a2204a)に書いてあることを行いました。

稼働させると、以下のような感じで情報が取得されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468660/7e14e8fa-9601-631c-5a20-f3b9f67065c3.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468660/82f456c7-fab9-110b-0c1a-95bfa1778429.png)

## 今後の課題点

まだ本格的に稼働してから時間が経っていないので、稼働の過程で何か問題が発生する可能性があります。そうなったときは逐一対応する必要があります。

また、一部の大会結果は画像がなく外部サイトへのリンクのみ貼ってあることがあります。現在はこのリンクのみ取得していますが、今後はこの外部サイトをスクレイピングし、画像を取得できるようにできたらいいなと思っています。

加えて、プログラム内にはYouTubeからの情報取得をする関数も実装しています。しかし、話し合いの結果あまり必要がないということで現在は使っていません。もし必要があれば、この関数を使って情報を取得するようにしたいと思います。

## おわりに

Discord Botは何個か開発してきたのですが、今回はかなり実用的なものが作れたかなと思います。

田園補完計画のサイトが割と静的に書かれていて助かりました。動的に書かれていた場合Seleniumを使う必要があり、実装が難しくなっていたと思います。

サークル内でも割と評判がいいので、今後も改善していきたいと思います。

ではまた、明日の記事でお会いしましょう。