画像を検索して貼れるDiscord Botを作る

画像検索できるbotを作ったのでその時の知見を書こうと思います。

#作った物
![findimage](https://raw.githubusercontent.com/wiki/akomekagome/FindImage/images/findImage.gif)

このような感じで、与えられたキーワードから画像を検索し、貼り付けてくれます。
導入は[こちら](https://discord.com/api/oauth2/authorize?client_id=766774601904029726&permissions=8&scope=bot)からできるので、よかったらよろしくお願いします！[使用方法](https://github.com/akomekagome/FindImage/blob/main/README.md)
[ソースコード](https://github.com/akomekagome/FindImage)(github)

#環境
* python 3.7.9
* discord.py 1.5.1
* bs4 0.0.1
* psycopg2 2.8.6
* urllib3           1.25.11

#Discord.pyの使い方

[こちらの記事](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB)を参考に作成しました。Discord.pyを触ったことない方は、先にこちらを読むことをおすすめします。

#画像を検索してurlを取得

##画像検索結果の取得

urllibを使用して画像を検索し、htmlを取得します。
この際、htmlの内容が変わってしまうので、User-Agentを必ず指定してください。

```python:find_image.py
from urllib import request as req
from urllib import parse

def find_image(keyword):
	urlKeyword = parse.quote(keyword)
	url = 'https://www.google.com/search?hl=jp&q=' + urlKeyword + '&btnG=Google+Search&tbs=0&safe=off&tbm=isch'
	headers = {"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:47.0) Gecko/20100101 Firefox/47.0",}
	request = req.Request(url=url, headers=headers)
	page = req.urlopen(request)
	html = page.read()
	page.close()

	return html
```

##画像urlの取得

通常、スクレイピングで画像を取得する場合、imgタグから取得できますが、Google画像検索の場合それだと圧縮された画像しか取得できないです。
なので、オリジナルの画像を取得するためには、Seleniumなどで[こちらの記事](https://qiita.com/maruman029/items/8dc7f8190d0e3f892d99)のように、クリックして取得しなければいけないのですが、負荷や速度を考慮して今回は別の方法を取っています。
宣伝ですが、BeautifulSoupを高速化するコツについて記事を書いたので、よかったら[こちら](https://qiita.com/Akome/items/19f68e78fade841263bb)を参考にしてください

User-Agentをブラウザで指定した場合、下のようにsctiptタグにメソッド呼び出しが実装されているのでそれを使用します。

```html:
<script nonce>
AF_initDataCallback({
			key: 'ds:1',
			isError: false,
			hash: '2',
			data: [null, [
						[
							["g_1", [
									["生 クリーム", ["https://encrypted-tbn0.gstatic.com/images?q\u003dtbn%3AANd9GcR_QK2ghJ5WWcj-Tcf9znnP6_rZwe7f2MCwWUERoVqVLNRFsj4D\u0026usqp\u003dCAU", null, null, true, [null, 0], false, false], "/search?q\u003d%E3%83%97%E3%83%AA%E3%83%B3\u0026tbm\u003disch\u0026chips\u003dq:%E3%83%97%E3%83%AA%E3%83%B3,g_1:%E7%94%9F+%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%A0:FuBfrMHhliU%3D", null, null, [null, null, null, null, "q:プリン,g_1:生 クリーム:FuBfrMHhliU\u003d"], 0],
									["コンビニ", ["https://encrypted-tbn0.gstatic.com/images?q\u003dtbn%3AANd9GcThveHaG9uvSFj6QwXIVDoJPs9P3KjNdnl-I35Wf0WzAKNffK_m\u0026usqp\u003dCAU", null, null, true, [null, 0], false, false], "/search?q\u003d%E3%83%97%E3%83%AA%E3%83%B3\u0026tbm\u003disch\u0026chips\u003dq:%E3%83%97%E3%83%AA%E3%83%B3,g_1:%E3%82%B3%E3%83%B3%E3%83%93%E3%83%8B:tHwRIJyFAco%3D", null, null, [null, null, null, null, "q:プリン,g_1:コンビニ:tHwRIJyFAco\u003d"], 1],
.......
```

ただ、これが曲者で、検索した時の場所がキーワードごとによって違うため、xpathやcssセレクターでの取得ができず、属性の指定のもないので絞り込みもできず、しかも、変数を展開した上でのjson形式になっているため、簡単にjsonデータに変換することができないです。
なので、**ゴリ押し**しました。
まず、scriptタグを全て取得し、AF_initDataCallbackから始まる内容を探してそれを無理やり整形してjsonが読み込める形にします。ただ、構造が辞書型ではなく配列のため、indexを決め打ちして取得しています。
正直、スレクイピングのやり方としては下の下ですが、今回のbotのように速度が求められているため、この実装で妥協しました。

```python:find_image.py
import bs4

def scrap_image_urls(html, start = 0, stop = 1)):
	soup = bs4.BeautifulSoup(html, 'html.parser', from_encoding='utf8')
	soup = soup.find_all('script')
	data = [c for s in soup for c in s.contents if c.startswith('AF_initDataCallback')][1]
	data = data[data.find('data:') + 5:data.find('sideChannel') - 2]
	data = json.loads(data)
	data = data[31][0][12][2]
	image_urls= [x[1][3][0] for x in data if x[1]]
	image_urls= [url for url in image_urls if not is_exception_url(url)][start:stop]

	return image_urls
```

ただ、インスタなどのスクレイピング対策を行っているサイトは、urlを取得できないため弾いています。

```python:find_image.py
exception_urls = [
	'.cdninstagram.com',
	'www.instagram.com'
]

def is_exception_url(str):
	return any([x in str for x in exception_urls])
```

#Prefixを動的に変化させる

prefix(コマンドの最初につける接頭語)を変換させる機能を実装しました。
いろいろ方法はありますが、今回はHeroku Postgresを使用しています。
最初、jsonファイルで管理していましたが、herokuは毎日リセットが入るのでデータが吹っ飛びました。。。
herokuへのデプロイの流れは[最初に紹介した記事](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB)に詳しく載っているので、そちらを参照してください。
**psqlコマンドの導入が必要になります。**

##Heroku Postgresアドオンの追加

無料プランのhobby-devでアドオンを追加します。最大レコード行数は10Kです。

```
$ heroku addons:create heroku-postgresql:hobby-dev -a [APP_NAME]
```

##データベースへのアクセス
まず作成したデータベース名を確認します。
Add-onの行を参照してください

```
heroku pg:info -a [APP_NAME]
```

次に、データベースにアクセスします。
先ほど取得したデータベース名を使用してください

```
heroku pg:psql [DATABASE_NAME] -a [APP_NAME]
```

これでSQLを実行できますので、テーブルを作成してください。

```sql
create table guilds (
id varchar(255) not null,
prefix varchar(255) not null,
PRIMARY KEY (id)
);
```

##PythonからPostgreSQLにアクセスする

今回は、psycopg2を使用しています。
データベースのurlは作成した段階で環境変数に定義されているので、それを使用します。

```python:find_image.py
import psycopg2

db_url = os.environ['DATABASE_URL']
conn = psycopg2.connect(db_url)
```

##Discordサーバーごとに動的にprefixを適用する
discord.Clientはコンストラクタでメッソドを渡すことができるので、インスタンス生成の際に渡します。
例では、discord.Clientの子クラスである**discord.ext.commands.Bot**を使用しています。

```python:find_image.py
from discord.ext import commands
import psycopg2

defalut_prefix = '!'
table_name = 'guilds'

async def get_prefix(bot, message):
	return get_prefix_sql(str(message.guild.id))

def get_prefix_sql(key):
	with conn.cursor() as cur:
		cur.execute(f'SELECT * FROM {table_name} WHERE id=%s', (key, ))
		d = cur.fetchone()
		return d[1] if d else defalut_prefix

bot = commands.Bot(command_prefix=get_prefix)
```

##DiscordサーバーごとにPrefixを設定する

今回の場合set_prefixコマンドを実行すると、UPSERTクエリが走るようにしています。

```python:find_image.py
from discord.ext import commands
import psycopg2

table_name = 'guilds'

def set_prefix_sql(key, prefix):
	with conn.cursor() as cur:
		cur.execute(f'INSERT INTO {table_name} VALUES (%s,%s) ON CONFLICT ON CONSTRAINT guilds_pkey DO UPDATE SET prefix=%s', (key, prefix, prefix))
	conn.commit()

@bot.command()
async def set_prefix(ctx, prefix):
	set_prefix_sql(str(ctx.guild.id), prefix)

	await ctx.send(f'The prefix has been changed from {ctx.prefix} to {prefix}')
```

#最後に
ここまで読んでいただきありがとうございました！
Discord Botの作成やスクレイピングの際に参考になれば幸いです！