Python3 + MongoEngine + Pillow でTwitter の画像をMongoDBに保存する

Webから拾ってきた画像、たとえば Twitter に投稿された画像などをMondoDBに保存したいと考えました。言語は Python を使っているとすると MongoEngine というライブラリが“よさそう”な候補として挙がってくることになります。ところが、ネットを検索しても MongoEngine の情報が多くないようです。「画像を保存する方法」を検索しても、なかなか適切な情報がありません。そこで、ネットの情報と自分で試行錯誤した結果を方法を記しておきます。環境は Ubuntu 14.04, Python 3.4, MongoDB 3.0.3 です。

MongoEngine とは?

MongoEngine は NoSQLである MongoDB を扱うための Python用 のライブラリです。いわゆる ORM(Object-Relational Mapper)、つまり DB のデータをオブジェクトとして扱うためのものと似ています。MongoDB はリレーショナル・データベースではなく NoSQL に属するドキュメント・データベースの一種ですので、厳密には ODM (Object-Document Mapper)になります。

Python + MongoDB というと PyMongo や Django に備わっている ORM がスタンダードとして使われており、情報量も多いようです。しかし、ネットで見る評価では、MongoEngine も使いやすいという印象を受けます。MongoEngine は内部で PyMongo を使用しつつ、より直感的にデータを扱うためのライブラリになります。

mongoengine.org

MongoEngine の導入方法は、他のパッケージ同様に pip で行います。検索するとすぐに見つかると思いますので、ここでは割愛します。MongoDB の導入方法も同様に割愛します。

MongoEngine の使い方

MongoEngine を導入すると、mongoengine.onnect を使用してすぐに MongoDB に接続することができるようになります。データベース名を「twitter_data」とすると、以下のようになります。

from mongoengine import connect

connect('twitter_data', host='localhost',port=27017)

この「twitter_data」に tweet というコレクションを保存することにします。 tweet コレクションは、MongoEngine では Document クラスを継承した Tweet クラスとして定義します。とりあえずは、ツイートのIDとユーザーのアカウント名、表示名だけ保存するように定義します。

from mongoengine.document import Document
from mongoengine import fields

class Tweet(Document):
    twid    = fields.IntField(unique=True)
    account = fields.StringField()
    name    = fields.StringField()

この Tweet クラスのインスタンスを適当に作成し、save メソッドを実行することでデータとしてMongoDBに保存します。実際には、Twitter API で取得した内容などを使用してインスタンスを作成するイメージです。

tw = Tweet()
tw.twid    = 663314203642454016
tw.account = "felis_catus_"
tw.name    = "たけまる"
tw.save()

以下のように、ドキュメントが保存されていることがわかります(画面は PyCharm)。

f:id:takemaruhirai:20151109215536p:plain

MongoDB に保存されたデータを読み込むのは、Tweet.object.all() を利用して実行することができます。

for tw in Tweet.objects.all():
    # 何かの処理

さらに詳しいことは、公式ドキュメントや、

MongoEngine User Documentation — MongoEngine 0.24.0 documentation

こちらの記事などを参考にしてください。

kitanokumo.hatenablog.com

MongoDBに画像を保存するには

ここからが本記事の本番です。

ここまでの内容は、ネット検索ですぐに調べられると思いますし、特別に難しいことはありません。

ですが、たとえば、ある URL から取得した画像を MongoDB に保存するにはどうすればいいのでしょうか。

MongoEngine のAPIリファレンスを見ると、ImageField というものがあります。これを使えば、MongoDBに画像を保存することができそうです。

3. API Reference — MongoEngine 0.24.0 documentation

というわけで、おもむろに Tweet クラスに image というプロパティを追加してみます。

from mongoengine.document import Document
from mongoengine import fields

class Tweet(Document):
    twid    = fields.IntField(unique=True)
    account = fields.StringField()
    name    = fields.StringField()
    image   = fields.ImageField()

そして、imageプロパティに画像を追加してみましょう(まだ良くわからないので適当に画像の URL を指定しています)。

tw = Tweet()
tw.twid    = 663314203642454016
tw.account = "felis_catus_"
tw.name    = "たけまる"
tw.image   = "/path/to/image.png"
tw.save()

これは、(当然ながら)実行してもエラーとなってしまいます。

mongoengine.fields.ImproperlyConfigured: PIL library was not found

StackOverflow で調べると、Pillow を導入しろ と言われているのがわかります。Pillow というのは、Python の画像処理ライブラリ PIL の派生版で、Python 3 の場合はこれを使うことになります。

stackoverflow.com

Pillow — Pillow (PIL Fork) 9.0.1 documentation

android-memo.hatenablog.jp

なるほど、と納得しつつ、早速

$ pip install pillow

を実行しようとしたら、途中でエラーが出てきて失敗しました。どうやら、libjpeg-dev というライブラリが入っていないことが原因だったようです。

stackoverflow.com

あらためて libjpeg-dev と Pillow を導入します。

$ sudo apt-get install libjpeg-dev
$ pip install pillow

これで無事に Pillow が使えるようになりました。

さて、この Pillow で URL から画像を取得して、MongoDB に保存できればいいわけです。

ところが、意外とここが苦労するポイントでした。というのも、Python 2 のときに使われていたライブラリや手順が Python 3 では微妙に異なり、新旧の情報が入り混じって存在している状況です。

stackoverflow.com

https://librabuch.jp/2013/05/python_pillow_pil/librabuch.jp

qiita.com

stackoverflow.com

結局、上記のサイトなどを参考にしつつ、自分で色々試してみたところ、次の方法でうまく行くことがわかりました。

from io import BytesIO
import requests

tw = Tweet()
tw.twid    = 663314203642454016
tw.account = "felis_catus_"
tw.name    = "たけまる"

response   = requests.get("path/to/image.png")
img        = BytesIO(response.content)
tw.image.put(tmg)

tw.save()

f:id:takemaruhirai:20151109232228p:plain

ポイントは、

  • Requests モジュールを使う
  • Pillow(PIL)を使う(といいつつ使ってない)
  • StringIO(Python2) や io.StringIOではなく、io.BytesIO を使う
  • PILモジュールの Image.open で取得した画像オブジェクトではなく ByteIOのオブジェクトそのものをMongoEngineに渡す
  • tw.image に代入するのではなく、tw.image.put の引数として img を渡す

といったところです。

追記

ちなみに、ネット記事の記載通りに Pillow の Image.open を使ってやると、何故か ValidationErrorが発生する。これを調べてもなかなか良い情報に出会わない。

response   = requests.get("path/to/image.png")
img        = Image.open(BytesIO(response.content))
Traceback (most recent call last):
  File "***.py", line 25, in <module>
    tw.image.put(img)
  File "***", line 1490, in put
    raise ValidationError('Invalid image: %s' % e)
mongoengine.errors.ValidationError: Invalid image: read

同じ現象で悩んでいる人はいるもよう。

python - How to Create a MongoDB/mongoengine ImageField from POSTed base64-encoded image? - Stack Overflow

このへんがヒントになるかもしれない。Python 2 と Python 3 の互換性関連のような気がするなあ……。

Managed Cloud, Cloud Migration and Adoption – Carbon60

MondoDBから画像を取得するには

保存しただけでは中途半端ですので、保存した画像を取得する方法です。こちらは素直に記述すれば大丈夫でした。

from PIL import Image
from mongoengine import connect

connect('twitter_data', host='localhost',port=27017)

for tw in Tweet.objects.all():
    img = Image.open(tw.image)
    img.save(str(tw.twid) + ".png")

これを実行すると、カレントディレクトリにMongoDBから取得した画像が保存されます。

Twitterから取得した画像を保存する

最後に、いままでの流れを応用して Twitter の検索結果からツイートを取得し、画像をMongoDBに保存する ことをやってみたのが以下のコードです。

from mongoengine import connect

class MongoDB:
    @classmethod
    def connect(cls):
        connect('twitter_data', host='localhost',port=27017, tz_aware=True)


from mongoengine.document import Document
from mongoengine import fields

class Tweet(Document):
    twid    = fields.IntField(unique=True)
    account = fields.StringField()
    name    = fields.StringField()
    created = fields.DateTimeField()
    image   = fields.ImageField()


from requests_oauthlib import OAuth1Session
import json

class Timeline:

    @classmethod
    def get_session(cls):
        return OAuth1Session("...", "...", "...", "...")

    @classmethod
    def search(cls, params):
        session  = cls.get_session()
        response = session.get("https://api.twitter.com/1.1/search/tweets.json", params = params)
        if response.status_code == 200:
            data = json.loads(response.text)
            return data["statuses"]
        return None


from io  import BytesIO
import requests

def upload():
    params = {
        "q" :  "JavaScript+を+追いかける私がいた+exclude:retweets+filter:images", #画像があるものだけ
        "count" : 1,
        "result_type" : "recent"
    }

    MongoDB.connect()
    for tl in Timeline.search(params):
    if Tweet.objects(twid=tl["id"]).count() < 1:
        tw = Tweet()
        tw.twid    = tl["id"]
        tw.account = tl["user"]["screen_name"]
        tw.name    = tl["user"]["name"]
        tw.created = tl["created_at"]
        response = requests.get(tl["entities"]["media"][0]["media_url_https"])        
        img = BytesIO(response.content)
        tw.image.put(img)        
        tw.save()


from PIL import Image

def download():
    MongoDB.connect()
    for tw in Tweet.objects.all():
        img = Image.open(tw.image)
        img.save(str(tw.twid) + ".png")

画像が MongoDB に保存される

MongoDB から画像を取得

f:id:takemaruhirai:20151110000714p:plain