新着記事

PyCharm + Docker で Django 開発環境を構築する

PyCharm 2016 の Docker サポートの使い勝手が良いです。

Docker で開発環境を作るのは初めてだったのですが、

  • ローカル ( mac ) は Docker Toolbox を使って開発環境を作る。PyCharm のデバッガでステップ実行できるようにする。
  • ソースコード、およびログディレクトリは Docker イメージの中に入れず、ホストのディレクトリをマウントして使う
  • 公開サーバを見越して uwsgi, nginx の設定も作っておく


という所までできたので、手順を書きます。

コード置いてあります。 https://github.com/ytyng/docker-django-skeleton

こちらを参考にしました。https://github.com/dockerfiles/django-uwsgi-nginx

必要アプリのインストール

インストーラに従ってインストールしておきます。

プロジェクトディレクトリを作成し、PyCharm で開く

$ mkdir docker-django-skeleton

$ open -a PyCharm docker-django-skeleton

PyCharm用の Bash プラグインのインストール

メニューバーの PyCharm → Preferences → 検索: Plugins → Plugins の検索で「BashSupport」を検索 → 該当したものをインストールしておきます。

PyCharmの再起動を求められるので、案内通り再起動します。

Docker 用のファイルを作る

プロジェクトルートに Dockerfile そのまま置くと煩雑になってしまうので、docker ディレクトリを作って関連ファイルを入れます。

docker/Dockerfile

FROM ubuntu:16.04

MAINTAINER ytyng

RUN apt-get update && apt-get install -y \
    python3 \
    python3-pip \
    python3-dev \
    python3-setuptools \
    libjpeg-dev \
    nginx \
    supervisor \
  && rm -rf /var/lib/apt/lists/*

# ipython, gnureadline が必要なら
# lib32ncurses5-dev

RUN ln -s /usr/lib/x86_64-linux-gnu/libjpeg.so /usr/lib/libjpeg.so

# RUN easy_install3 pip  # aptで入れた方が安定

COPY requirements/base.txt /tmp/requirements/base.txt
RUN pip3 install -r /tmp/requirements/base.txt

# setup all the configfiles
RUN echo "daemon off;" >> /etc/nginx/nginx.conf
COPY nginx-app.conf /etc/nginx/sites-available/default
COPY supervisor-app.conf /etc/supervisor/conf.d/

RUN mkdir -p /etc/nginx/certs
# COPY certs /etc/nginx/certs/

# set locale
RUN locale-gen en_US.UTF-8
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

EXPOSE 80
#EXPOSE 443

↑ Pillow をインストールする可能性を考え、libjpeg などインストールしてます。

docker/requirements/base.txt

Django==1.10.1
PyMySQL
pytz

↑ 今回のサンプルは、DBは SQLite3 なので PyMySQL 不要ですがサンプルとして書いておきます。



docker/nginx-app.conf

server {
    listen 80;
    # listen 443 ssl;

    server_name docker-django-skeleton.example.com;

    charset utf-8;

    # ssl_certificate /etc/nginx/certs/xxx.crt;
    # ssl_certificate_key /etc/nginx/certs/xxx.key;

    location /static/ {
        alias /var/django/docker-django-skeleton/staticfiles/;
        break;
    }

    location / {
        include uwsgi_params;
        uwsgi_pass 127.0.0.1:3031;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker/supervisor-app.conf

[program:app-uwsgi]
command = /usr/local/bin/uwsgi --ini /var/django/docker-django-skeleton/conf/uwsgi.ini

[program:nginx-app]
command = /usr/sbin/nginx

Docker 用の設定ファイルの作成

環境変数など設定する Bash スクリプトを用意しておきます。
mac で実行した場合と公開サーバで起動した場合に読み込まれる設定を変えるようにしています。

docker/_settings.sh

#!/usr/bin/env bash

APP_NAME=docker-django-skeleton
DJANGO_APP_NAME=docker_django_skeleton

PROJECT_DIR=$(cd $(dirname ${BASH_SOURCE:-$0})/..;pwd)

if [ $(which docker-machine) ]; then
    # mac
    eval "$(docker-machine env default)"
    DOCKER_COMMAND=docker
    DOCKER_MACHINE_COMMAND=docker-machine
    LOG_DIR=/tmp/log/${APP_NAME}
    export UWSGI_PROCESSES=1
    export UWSGI_THREADS=1
    export DJANGO_SETTINGS_MODULE=${DJANGO_APP_NAME}.settings.local
else
    # ubuntu
    DOCKER_COMMAND="sudo docker"
    LOG_DIR=/var/log/${APP_NAME}
    OPTIONAL_PARAMS="--link mysql:mysql"
    RUN_COMMAND="supervisord -n"
    export UWSGI_PROCESSES=2
    export UWSGI_THREADS=2
    export DJANGO_SETTINGS_MODULE=${DJANGO_APP_NAME}.settings.production
fi


Dockerイメージ作成用スクリプト

docker/docker-01-build.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

${DOCKER_COMMAND} build -t ${APP_NAME} .

docker/docker-02-run.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

mkdir -p -m 777 ${LOG_DIR}

${DOCKER_COMMAND} run -d -P -p 80:80 -p 443:443 \
  -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
  -v ${LOG_DIR}:/var/log/django \
  -v /etc/localtime:/etc/localtime:ro \
  -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
  -e UWSGI_PROCESSES=${UWSGI_PROCESSES} \
  -e UWSGI_THREADS=${UWSGI_THREADS} \
  ${OPTIONAL_PARAMS} \
  --name ${APP_NAME} ${APP_NAME} ${RUN_COMMAND}

if [ "${DOCKER_MACHINE_COMMAND}" ]; then
    docker-machine ip default
fi

${DOCKER_COMMAND} ps

docker/docker-03-bash.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

EXISTS=$(${DOCKER_COMMAND} ps |grep ${APP_NAME})

if [ "${EXISTS}" ]; then
    ${DOCKER_COMMAND} exec -it ${APP_NAME} /bin/bash $@
else
    ${DOCKER_COMMAND} run --rm -it \
    -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
    -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
    --name ${APP_NAME} ${APP_NAME} /bin/bash $@
fi

↑ 分岐がちょっと雑かな…

docker/docker-09-rm.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

${DOCKER_COMMAND} rm --force ${APP_NAME}

docker/manage.sh

#!/usr/bin/env bash
cd $(dirname ${BASH_SOURCE:-$0})
. _settings.sh

EXISTS=$(${DOCKER_COMMAND} ps |grep ${APP_NAME})

if [ "${EXISTS}" ]; then
    ${DOCKER_COMMAND} exec -it ${APP_NAME} \
    /bin/bash -c "cd /var/django/${APP_NAME}/${APP_NAME}/; ./manage.py $@"

else
    ${DOCKER_COMMAND} run --rm -it \
    -e DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} \
    -v ${PROJECT_DIR}:/var/django/${APP_NAME} \
    --name ${APP_NAME} ${APP_NAME} /bin/bash -c \
    "cd /var/django/${APP_NAME}/${APP_NAME}/; ./manage.py $@"
fi


*.sh には、実行パーミッションを付与しておきます。

$ chmod +x docker-*.sh manage.sh

docker-django-skeleton
└── docker
    ├── Dockerfile
    ├── _settings.sh
    ├── docker-01-build.sh
    ├── docker-02-run.sh
    ├── docker-03-bash.sh
    ├── docker-09-rm.sh
    ├── manage.sh
    ├── nginx-app.conf
    ├── requirements
    │   └── base.txt
    └── supervisor-app.conf


Docker イメージの作成

先ほど作成した、docker-01-build.sh を PyCharm 上で右クリック → Run 'docker-01-build.sh'


Dockerイメージができます。

Django プロジェクトディレクトリの作成

既に他の件で Django が mac にインストールされている場合は、それを使って `django-admin.py startproject` すれば良いと思いますが、今回は先ほど Docker イメージにインストールした Django を使って Django プロジェクトを作成します。

プロジェクトディレクトリをまるごと Docker にマウントし、Docker内で startproject します。

$ docker run -it -v `pwd`:/var/django/docker-django-skeleton docker-django-skeleton /bin/bash

※ pwd は、docker-django-skeleton ディレクトリ。docker ディレクトリの親ディレクトリ

$ docker run -it -v `pwd`:/var/django/docker-django-skeleton docker-django-skeleton /bin/bash
root@8d2ba7a54287:/# cd /var/django/docker-django-skeleton/
root@8d2ba7a54287:/var/django/docker-django-skeleton# django-admin.py startproject docker_django_skeleton
root@8d2ba7a54287:/var/django/docker-django-skeleton# mv docker_django_skeleton docker-django-skeleton
root@8d2ba7a54287:/var/django/docker-django-skeleton#


好みの問題ですが、Django プロジェクトのディレクトリ名は アンダーバーではなく ハイフンに変更しています。

PyCharm を見てみると、docker-django-skeleton ディレクトリが作成されているのが確認できます。

settings を環境ごとに分ける

Django でよくやるプラクティスで、settings を ローカル環境と公開環境で分けるというものがあります。

docker_django_skeleton ディレクトリ内で、

1. settings ディレクトリを作成
2. settings.py は、 settings/base.py に改名
3. settings/local.py settings/production.py を作成。


settings/local.py, settings/production.py の内容は以下のようになります。

settings/local.py

from .base import *  # NOQA


内容はありませんが、今回はこれで良しとします。

manage.py のインタプリタ (シェバン shebang ) を python3 にしておく


作成された manage.py を見てみると、1行目が

manage.py

#!/usr/bin/env python

となっていますが、Docker イメージ内には Python2 が残っているため不具合が起こることがあります。

#!/usr/bin/env python3

に変更しておきます。

PyCharm のインタプリタで Docker の Python を指定する

PyCharm → Preferences → 検索: interpreter

Project Interpreter が出てきます。

ここで、右上の「...」ボタンをクリックし、Add Remote を選択。

Configure Remote Python Interpreter になるので、
「Docker」を選択、Image name は docker-django-skeleton:latest を選択してください。

それと、Python interpreter path: は python となっていますが、python3 に変更します。

すると、Docker 内の Python 環境を認識します。すごい!

Djangoサポートを有効にする

Preferences で、検索: django

Language & Frameworks の Django が出て来るので選択し、Enable Django Support にチェック。

Django project root: manage.py のある、docker-django-skeleton
Settings: docker_django_skeleton/settings/local.py

OKをクリック

Project Structure を設定する

Preferences で、検索: structure

manage.py のある、docker-django-skeleton ディレクトリを選択し、Source をクリック → OK

migrate

次は、./manage.py migrate をします。
docker コンテナ内なので、./manage.py を実行するのも少し面倒ですが、docker ディレクトリの中に manage.sh を作ってあるのでこれを使います。

$ cd docker
$ ./manage.sh

Type 'manage.py help <subcommand>' for help on a specific subcommand.

Available subcommands:

[auth]
    changepassword
    createsuperuser

[django]
    check
    compilemessages
...

$ ./manage.sh migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
...

Docker の Django環境を使ってマイグレーションしてくれました。

テストサーバの起動

PyCharm の右上、Run/Debug の選択をクリックし、Edit Configurations...

Run/Debug Configurations ダイアログで、左上の + をクリック

Django server を選択

Host: には、0.0.0.0 を入力(重要)
Name: は適当に (例: runserver )
→ OK

出来た runserver 実行環境が選択されている状態で、虫(デバッグ) ボタンを押下

System check identified no issues (0 silenced).
September 26, 2016 - 09:54:15
Django version 1.10.1, using settings 'docker_django_skeleton.settings.local'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.

となりますので、0.0.0.0:8000 をクリックするとブラウザが起動し、Docker マシーンにリダイレクトされてページが表示されます。
デバッガも有効な状態ですので、ブレイクポイントが使えます。すごい!

公開環境で起動する

uwsgi.ini

[uwsgi]

app = $(APP_NAME)
django_app = $(DJANGO_APP_NAME)

base = /var/django/%(app)/%(app)/
pythonpath = %(base)
chdir = %(base)

socket = 127.0.0.1:3031

module = %(django_app).wsgi:application

processes = $(UWSGI_PROCESSES)
threads = $(UWSGI_THREADS)

master = true

pidfile = /var/run/django/%(app).pid

touch-reload = %(base)/%(django_app)/wsgi.py
lazy-apps = true
thunder-lock = true

buffer-size = 32768

logto = /var/log/django/%(app)/%n.log

uwsgi の設定ファイルはこんな感じですかね。

ソースコードを公開環境のサーバに持っていって、

$ ./docker-01-build.sh

でコンテナを作り (ローカルで作ったのを Docker hub に push -> 本番で Pull でもいいですが)、

$ docker-02-run.sh

で起動すると、公開環境 (というか docker-machine がインストールされてない環境 ) では本番用設定で nginx → uwsgi → Django で起動します。

Docker コンテナを削除する時は

$ docker-09-rm.sh

で消えます。

Djangoプロジェクト間を OAuth2 連携する

ローカル環境に Django プロジェクトを2つ作り、OAuth2で(ダミーの)プロフィール情報を取得するまで書きます。

2つの Django プロジェクトを作ります。test_provider, test_consumer です。

OAuth2の認証フローは、サーバ間通信のため「Authorization Code」形式で行います。

OAuth2についての情報は IPAのページ などにあります

ソースコードを github に上げました。

Django プロジェクト 概要

test_provider

起動ポート 8000
ユーザーのIDとハッシュ化パスワードを保持します。
OAuth2 プロバイダを提供します。
ユーザーに test_provider への情報提供の認可を問います。
django-oauth-toolkit を使います

test_consumer

起動ポート 8001
ユーザーが最初にアクセスする Webページ
ユーザーに test_provider への認可ページへ誘導します。
ユーザー認可後は、このプログラムが test_provider へリクエストを行い、情報をもらいます。
django-allauth を使います。

※なお、django-allauth と同じくらい使いやすいのが python-social-auth です。Django のライブラリも含んでいます。

django-social-auth というライブラリも見かけると思いますが、Python3に対応していないのでやめておきましょう。

OAuth2プロバイダの開発

django-oauth-toolkit の素晴らしいドキュメントがここにあります。
「Make a Provider in a Minute」という挑発的なタイトルですが、
認証モデルにこだわりがなければほんとにすぐ作れます。
がんばっても1分では作れないと思いますが…。

Django プロジェクトの作成

$ mkdir test-oauth
$ cd test-oauth

$ django-admin.py startproject test_provider
$ pip install django-oauth-toolkit django-cors-headers

$ cd test_provider

test_provider/settings.py の編集

INSTALLED_APPS に追加

    'oauth2_provider',
    'corsheaders',

MIDDLEWARE_CLASSES に追加

'corsheaders.middleware.CorsMiddleware',

末尾に追加

CORS_ORIGIN_ALLOW_ALL = True

test_provider/urls.py の編集

from django.conf.urls import url, include
from django.contrib import admin
from .views import profile_view

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
    url(r'^api/profile/$', profile_view, name='profile'),
    url(r'^accounts/login/', 'django.contrib.auth.views.login', name='login',
        kwargs={'template_name': 'admin/login.html'}),
]

test_provider/views.py の作成

from django.http import JsonResponse
from oauth2_provider.views.generic import ProtectedResourceView

class ProfileView(ProtectedResourceView):
    def get(self, request, **kwargs):
        user = request.resource_owner

        return JsonResponse({
            'user_id': user.id,
            'email': user.email,
            'date_joined': user.date_joined,
            'secret_message': 'The quick brown fox',
        })

profile_view = ProfileView.as_view()

DB に反映

$ ./manage.py migrate

Adminユーザーの作成

OAuthプロバイダを追加設定するためには、Adminユーザーが必要なため作っておきます。

$ ./manage.py createsuperuser

テストサーバの起動

$ ./manage.py runserver 8000

OAuthプロバイダの設定

プロバイダの設定ページにアクセスするために、一度 Admin サイトにログインしておきます。

http://127.0.0.1:8000/admin/

Admin サイトにログイン後、アプリ追加ページを開きます。

http://127.0.0.1:8000/o/applications/

Click here をクリックします。

Register a new application ページとなるので、

Name: test-consumer (何でも良い)
Client type: Confidential
Authorization grant type: Authorization code
Redirect urls:
http://localhost:8001/accounts/testprovider/login/callback/
http://127.0.0.1:8001/accounts/testprovider/login/callback/

このように入力し、saveします。

作成した OAuthプロバイダは、このツールで簡単なテストができます。

client_id に先ほどの client_id をコピペし、
Authorization url は http://localhost:8000/o/authorize/ を入れればなんとなくのテストができます。

テストユーザーの作成

Admin から、テストユーザーを作っておきます。

http://127.0.0.1:8000/admin/auth/user/

ADD USER をクリックし、適当なユーザーを作っておいてください。
OAuth2 でのログイン時、このユーザーID/パスワードでログインを行うことになります。

引き続き、OAuthコンシューマの開発を行います。

起動中の Django テストサーバは、そのまま起動させっぱなしにしておいてください。

OAuth2コンシューマテストアプリの作成

Django プロジェクトの作成

$ cd test-oauth

$ django-admin.py startproject test_consumer
$ pip install django-allauth

$ cd test_consumer

ここでは、OAuth クライアントライブラリ django-allauth をインストールしています。

django-allauth は多彩はクライアント接続に対応した、多機能なライブラリで、django1.9 + Python 3.5 でも動きますので使い勝手が良いです。

django-allauth のドキュメントはここ

設定

http://django-allauth.readthedocs.io/en/latest/installation.html

test_consumer/settings.py

TEMPLATES の OPTIONS の context_processors に追加

'django.template.context_processors.request',

Django 1.9 なら、デフォルトで入ってると思います。

INSTALLED_APPS に追加

    'django.contrib.sites',
    'test_consumer',
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.google',
    'allauth.socialaccount.providers.twitter',
    'testprovider',  # これから作る

allauth.socialaccount.providers.* は任意に選んで追加してください。
テストのために google と twitter を追加しています。

どんなプロバイダが準備されているかは公式ドキュメントに書いてあります。

http://django-allauth.readthedocs.io/en/latest/installation.html

末尾に追加

AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'allauth.account.auth_backends.AuthenticationBackend',
)

SITE_ID = 1
SESSION_COOKIE_NAME = 'test-consumer-session-id'

test_consumer/urls.py

from django.conf.urls import url, include
from django.contrib import admin
from django.views.generic import TemplateView

urlpatterns = [
    url(r'^admin/', admin.site.urls),

    url(r'^$', TemplateView.as_view(template_name='index.html')),
    url(r'^accounts/', include('allauth.urls')),

]

テンプレートの作成

$ mkdir test_consumer/templates
test_consumer/templates/index.html を作成

{% load socialaccount %}
{% load account %}

<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<a href="{% provider_login_url "twitter" %}">Twitterでログイン</a><br />
<a href="{% provider_login_url "google" %}">Googleでログイン</a><br />
<a href="{% provider_login_url "testprovider" %}">Test Provider でログイン</a><br />

<hr />
{% if user.is_authenticated %}
  ログインユーザー: {% user_display user %}<br />
  {% for sa in user.socialaccount_set.all %}
    {{ sa.extra_data }}<br />
  {% endfor %}
{% endif %}
</body>
</html>

testprovider アダプタの作成

allauth に入っている、google のアダプタを改修して作るのが最も手軽でしょう。

allauth/socialaccount/providers/google このディレクトリをまるごとコピーし、
testprovider としてペーストします。 ( manage.py がいるディレクトリに)

内容は以下のようになります。

testprovider/provider.py

from allauth.socialaccount import providers
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider

class TestAccount(ProviderAccount):

    def to_str(self):
        dflt = super(TestAccount, self).to_str()
        return self.account.extra_data.get('name', dflt)

class TestProvider(OAuth2Provider):
    id = 'testprovider'
    name = 'Test Provider'
    account_class = TestAccount

    def get_default_scope(self):
        return ['read', 'write']

    def get_site(self):
        settings = self.get_settings()
        return settings.get('SITE', 'testprovider')

    def extract_uid(self, data):
        uid = str(data['user_id'])
        return uid

    def extract_common_fields(self, data):
        return dict(username=data.get('email', 'no name'))

providers.registry.register(TestProvider)

testprovider/urls.py

from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
from .provider import TestProvider

urlpatterns = default_urlpatterns(TestProvider)

testprovider/views.py

import requests

from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
                                                          OAuth2LoginView,
                                                          OAuth2CallbackView)
from allauth.socialaccount.providers import registry

from .provider import TestProvider

from django.conf import settings

server_url_prefix = getattr(
    settings, 'TEST_PROVIDER_URL_PREFIX',
    'http://127.0.0.1:8000')

class TestOAuth2Adapter(OAuth2Adapter):
    provider_id = TestProvider.id
    access_token_url = server_url_prefix + '/o/token/'
    authorize_url = server_url_prefix + '/o/authorize/'
    profile_url = server_url_prefix + '/api/profile/'

    def complete_login(self, request, app, token, **kwargs):
        provider = registry.by_id(app.provider)
        resp = requests.get(self.profile_url,
                            params={'access_token': token.token})

        extra_data = resp.json()
        return self.get_provider().sociallogin_from_response(
            request, extra_data)

oauth2_login = OAuth2LoginView.adapter_view(TestOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(TestOAuth2Adapter)

DBのマイグレーション

$ ./manage.py migrate

Adminユーザーの作成

$ ./manage.py createsuperuser

テストサーバの起動

$ ./manage.py runserver 8001

アプリケーションの登録

http://127.0.0.1:8001/admin/

Admin サイトにログイン後、SOCIAL ACCOUNTS の Social applications の +Add をクリックします。

Provider: Test Provider
Name: Test Provider
Client id: 先ほどの 8000 admin で作った Client id
Secret key: 先ほどの 8000 admin で作った Secret key
Key: 空
Sites: example.com を選択

これで、Save します。

Google, Twitter など他サービスへのログインも必要であれば、ここから追加できます。

クライアントとしての動作テスト

先ほど、8000ポートにログインしたブラウザとは別のブラウザを使います。(ログアウト状態になっているため)

http://127.0.0.1:8001/

ここで、「Test Provider でログイン」をクリックします。

( AllAuth が自動的にログインフォームを提供してますので、このURLからでもいけます。/accounts/login/ )

そうすると、8000 ポートへ転送され、ログインフォームが出てきます。
今回は時間節約のため、Django Admin のログインフォームを流用していますが、
通常は自作のログインフォームをここで表示させることになると思います。

8000 (test_provider) で作ったテストユーザーでログインしてみましょう。

ログインが成功すると、認可を求めるページとなりますので、「Authorize」をクリック。
ちなみに、この認可を求めるページは、8000 (test_provider) の Admin の アプリ設定の、「Skip authorization」にチェックを入れると省略できます。

ログインに成功すると、8001 ポートに戻ってきます。

http://127.0.0.1:8001/accounts/profile/
が表示されますが、このページはまだ作っていませんので Not found になります。

情報取得は成功していますので、トップページのURLを手で入力して情報を見てみます。

http://127.0.0.1:8001/

トップページ下部に、API取得した extra_data を表示する箇所があります。
そこに、OAuth2 のプロバイダから取得した情報が表示されるのが確認できます。

OAuthプロバイダの /api/profile/ から取得した 'secret_message': 'The quick brown fox' が、OAuthコンシューマで取得できているのが確認できます。

ソースコードは Github に上げました。

Django Mezzanine で、複数サイトを共存させ同時に運用する

はじめに

Mezzanine とは、Django (Python のWeb フレームワーク )上で動く、CMS フレームワークです。使い勝手としては Wordpress に似ています。

(英語) http://mezzanine.jupo.org/docs/index.html

最初から入っている機能としては

  • WYSIWYG エディタ ( Tiny MCE ) が入っているのでリッチテキストコンテンツが簡単に投稿できる
  • インラインページ編集 … 管理者ログインしていれば、公開済みページの各セクションに編集ボタンが出て編集可能
  • Twitter Bootstrap
  • Disqus連携
  • Gravatar連携
  • Twitter連携

などがあり、そもそも Django なのでテーマの切り替えが簡単だったり、プラグインの入れて機能を拡張したりなどが容易にできます。プラグイン( Django モジュール) の開発者は世界中に多く存在します。

その Mezzanine の機能の中で、少し難解ですが便利な機能に「Multi Tenancy」というものがあります。1つの Mezzanine の中で、複数サイトを運営できる機能です。

このブログも Mezzanine の マルチテナンシーで運営しています。例えば、株式会社TORICO のサイト , TORICOの技術部ブログまんが展公式サイトホーリンラブブックスブログ などは、一つの Gunicorn プロセスから出力しています。

公式サイトの説明は ここ(英語) にあるのですが、テンプレートの説明しかなくてわかりにくいですね。

このマルチテナンシーは Django 標準の Site フレームワークを使いやすく拡張した機能です。Site フレームワークについては Djangoドキュメントに説明があります。(英語)

一昔前はあまり詳しい説明が書いてなかった記憶があるのですが、執筆時現行バージョンの Django 1.9 のドキュメンテーションでは丁寧に書かれていますね。

とはいえ、Django は使っていても Site は使ったことがない方が多いと思います。私もそうで、Django を業務で使っていた今まで5年間、まったく活用したことがありませんでした。というか、使い方を知りませんでした。Mezzanine は、実用的な応用例として参考になると思います。

Mezzanineのセットアップ (省略)

Mezzanine の セットアップについてはこの記事では言及しません。

公式サイト部右ブロックに、「QUICK START」という欄があるので、これに沿ってコマンドを実行すればデモページはすぐ動きますし、Qiita に良記事があります。
Mezzanineをはじめよう - Qiita

nginx の設定

公開環境で動かすには、gunicorn (や uwsgi ) 上で django を起動し、nginx で80ポートを受けて gunicorn がリッスンしている 8000 ポートあたりに転送する感じでしょう。マルチテナンシー運用では、複数ドメインを宛先としたリクエストをすべて nginx から同じ gunicorn に転送します。

リクエストのドメイン名からサイトを判定する都合上、Web サーバから Django アプリケーションへのリクエストの際に、ユーザーエージェントがリクエストする HOST ヘッダを失うことなく Django アプリケーションに伝える必要があります。

Django でリクエストホストを取得する処理は django.http.request.get_host にあるのですが、

1. X_FORWARDED_HOST ヘッダがあればそれを使う
2. 無ければ、HOST ヘッダを使う

という処理になっていますので、nginx の設定は

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

このように書くと良いでしょう。

全体的な nginx の設定はこのようになります。

server {
    listen 80 default;
    listen 443 ssl;

    ssl_certificate ...........;
    ssl_certificate_key ...........;

    access_log  ...........;
    error_log  ...........;

    # ローカルの gunicorn にプロキシ
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
    # Django の static_url 転送
    location ~ ^/static(/(.+))?$ {
        alias ...........$1;
        break;
    }
    # Django の media_url 転送
    location ~ ^/media(/(.+))?$ {
        alias ...........$1;
        break;
    }
}

# ネイキッドドメインリダイレクト
server {
    server_name torico-corp.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.torico-corp.com$request_uri;
}
server {
    server_name manga10.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.manga10.com$request_uri;
}

サイト切り替えの仕組みの基礎

Mezzanine 内でサイトIDを判定するために、1リクエスト内で何度も評価されるメソッドがあります。

mezzanine.utils.sites の current_site_id がそれです。

読んでみると、

1. スレッドローカルに保存されている request をキャッシュとして使い、その中に site_id があるか調べる。あれば使う。
2. 無ければ、セッション変数に site_id があるか調べる。あれば使う。
3. Django のキャッシュフレームワークにあるか調べる。あれば使う。
4. 無ければ、リクエストのドメインから Site モデルを検索し、一致すればその id を使う。
※ Site モデルは、DBから取得した後はローカルメモリにキャッシュする。
5. 無ければ、環境変数 MEZZANINE_SITE_ID
6. 無ければ、Django settings の SITE_ID
site_id を見つけたら、適宜キャッシュに保存する。

という順番で評価していくのがわかると思います。このコードの評価順は重要なので、何度も読み返すことになるでしょう。

複数の Site の登録

まず、Mezzanine が動きましたら、管理サイト (デフォルトでは /admin/ ) にログインします。

管理サイトの左ナビ部に、「サイト」がありますのでそれをクリックし、サイトを追加します。


すると、管理サイト上部にサイト切り替えプルダウンが表示されます。

これを切り替えると、編集対象とするサイトを変えることができます。
これは、選択した site_id をセッション内に記録します。

サイトごとに内容を切り替えるモデルの仕組み

BlogPost モデル (ブログ投稿) を見るとわかりますが、Mezzanine に含まれるモデルはほぼすべて、SiteRelated モデルを継承しています。

SiteRelated ← Slugged ← Displayable ← BlogPost

この SiteRelated モデルは、独自のモデルマネージャ CurrentSiteManager を持っており、これを継承したモデル(例: BlogPost ) でクエリセットを操作すると、自動的に WHERE site_id = ? の SQL が付与されます。また、新規保存する際にも、自動的に site_id = ? の SQL を加えて保存します。

site_id は、上記の「mezzanine.utils.sites.current_site_id」から取得します。ここで、セッション変数やリクエストのドメイン名から mezzanine が自動的に site_id を判定してくれるので、意識せずとも複数サイトを切り替えることができるわけです。

そのため、自分で Django モデルを追加した場合は、この SiteRelated を継承して作る必要があります。
単純に SiteRelated を継承するだけで、管理サイトのサイト切り替えなどに対応したモデルを簡単に作ることができます。

サイト別に、固有設定をもっと定義したい

Site モデルには、ドメインと名前のフィールドしかありません。実際にはもっと多くのサイト別情報を管理する必要があるでしょう。

Site に紐付いた付加情報を扱うモデルも、Mezzanine に用意されています。

mezzanine.conf モジュールがそれで、Site 別に、キーバリューストアがあるイメージです。

ドキュメンテーションは ここ にあり、使用例は
mezzanine/accounts/defaults.py を見ると良いでしょう。

from mezzanine.conf import register_setting

register_setting(
    name="ACCOUNTS_MIN_PASSWORD_LENGTH",
    description=_("Minimum length for passwords"),
    editable=False,
    default=6,
)

このように、各 Django アプリの defaults.py で、register_setting を呼ぶことで キー (name)を設定します。

ここで定義したものは、Admin サイトの「設定」にフォームフィールドが表示されます。

オートグルーピング

Mezzanine の管理サイトの、「設定」を見てみると、デフォルトでは「コメント」「SSL」「Twitter」などがグループ化されています。

自分で register_setting した場合、そのままだと設定ページの「その他」(Miscellaneous) グループに含まれてしまいます。自分でグルーピングする手段がわかりにくいですが、グルーピングは name の値によって自動的に行われます。1つめの _ (アンダーバー) の前がグループ名となり、複数あるようなら自動的にグループ化されます。

例えば

from mezzanine.conf import register_setting

register_setting(
    name="TEMPLATE_SITES_HOGE",
    label="テンプレートの…",
    editable=True,
    default="",
)
register_setting(
    name="TEMPLATE_SITES_FUGA",
    label="テンプレートの…",
    editable=True,
    default="",
)

このように register_setting した場合、Admin サイト内では

この画像のように、Template という名前でグループされます。便利ですね。

mezzanine.conf.settings ( conf_settingsテーブル) の読み出し方

上記管理サイトで設定した値は、

from mezzanine.conf import settings
settings.TEMPLATE_SITES_HOGE

でアクセスできます。

django.conf.settings と名前が似ていて混乱しそうですが、mezzanine プロジェクトでは django.conf.settings は使う必要がありません。mezzanine.conf.settings は、django.conf.settings の完全な代わりとして使えます。

このコードの場合、まず TEMPLATE_SITES_HOGE キーが conf_settings テーブルにあるか(現在の site_id で絞り込んで)調べ、無ければ django.conf.settings の値を使う、という動作になります。

なお、DB の検索結果はメモリにキャッシュされますので、頻繁にアクセスする場合も安心です。

テンプレートから使う

mezzanine.conf.settings をテンプレートから使う場合は、コンテキストプロセッサ ( mezzanine.conf.context_processors ) が用意されていますので、特に設定不要で

{{ settings.XXXXXX }}

で設定値にアクセスできます。

ただし、このコンテキストプロセッサを読むと、「ホワイトリストで許可された設定値のみ読み出せる」という処理になっているのがわかると思います。

そのホワイトリストは、mezzanine/core/defaults.py を TEMPLATE_ACCESSIBLE_SETTINGS で検索すると出てきますが、

register_setting(
    name="TEMPLATE_ACCESSIBLE_SETTINGS",
    description=_("Sequence of setting names available within templates."),
    editable=False,
    default=(
        "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
        "ADMIN_MENU_COLLAPSED",
        "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
        "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
        "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
        "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
        "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
        "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
        "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    ),
)

こうなっています。

そのため、独自に register_settings した値は、このホワイトリストに追加しないとコンテクストプロセッサ経由でアクセスできません。

このホワイトリスト TEMPLATE_ACCESSIBLE_SETTINGS に値を追加するには、自分の App 内の defaults.py に、上記の register_setting をコピーして貼り付け、default= に値を追加しても良いですし、Django の settings.py に

TEMPLATE_ACCESSIBLE_SETTINGS = (
    "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
    "ADMIN_MENU_COLLAPSED",
    "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
    "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
    "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
    "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
    "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
    "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
    "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    # =================================
    # 追加分
    "TEMPLATE_SITES_HOGE", "TEMPLATE_SITES_FUGA
)

このように書いておいても読み出されます。

(デフォルト値をベタッと書いているのがメンテナンス性低そうで嫌ですが、良い方法が思いつきませんでした)

これで、テンプレートで

{{ settings.TEMPLATE_SITES_HOGE }}

ができるようになります。

全サイトを回すバッチの作り方

Sites に複数のサイトを登録してある環境で、Django のマネジメントコマンドでバッチを動作させたいとします。

普通にマネジメントコマンドを書いて実行すると、現在の site_id は settings.SITE_ID に書いた値のみ使われることになります。この場合、全サイトを対象にバッチを動作させることができません。

そこで、環境変数 MEZZANINE_SITE_ID を使います。

私は、このようなメソッドを用意してやっています。

def set_site_id(site_id):
    os.environ["MEZZANINE_SITE_ID"] = str(site_id)

def clear_current_request():
    """
    Mezzanine のスレッドローカルリクエストを消去
    """
    from mezzanine.core.request import _thread_local
    if hasattr(_thread_local, "request"):
        setattr(_thread_local, "request", None)

def all_sites():
    """
    全site を、有効なサイトを設定しながらループする
    :return:
    """
    for site in Site.object.all().order_by("id"):
        clear_current_request()
        set_site_id(site.id)
        yield site

この場合、使い方としては

for site in all_sites():
    1つのサイトに対するバッチ処理...

このようになり、ループ毎に環境変数の MEZZANINE_SITE_ID をセットしながら回すことができます。

スレッドローカルの "request" を消去するのは、ユニットテスト用です。

同じスレッドで過去にビュー関数を呼ぶテストが行われていた場合、スレッドローカルに request が残り誤動作をしてしまうので、消してます。

Admin 管理サイトから、特定のサイトだけ左メニューからリンクを消す

モデルを作り、admin.py を作るなどして管理サイトから編集できるようにすると、マルチテナンシーの場合はすべてのサイトで編集リンクが出ますが、特定のサイトのみ管理サイト項目を有効化(もしくは無効化) させたいケースはあると思います。

管理サイトに項目を表示するかどうかは、Admin モデルの in_menu メソッドで評価するようになってますので、例えば Contact というモデルを作り、ContactAdmin という Admin モデルをひも付けた場合は

from django.contrib import admin
from mezzanine.utils.sites import current_site_id

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return current_site_id() in [2, ]


admin.site.register(models.Contact, ContactAdmin)

(ちょっと上の例は泥臭い例ですが)このように、in_menu メソッドを作り、その中で current_site_id() を判断するとか、

register_setting(
    name="CONTACT_ENABLED",
    editable=True,
    default=True,
)

defaults.py なんかで、上記のように設定フラグを定義して、

from django.contrib import admin
from mezzanine.conf import settings

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return settings.CONTACT_ENABLED


admin.site.register(models.Contact, ContactAdmin)

その設定値を使って項目を出し分けることもできます。

mezzanine のビルトインモデルを Admin で出し分けるには

モンキーパッチでできます。他に良い方法ありそうですが知りません。

def patch_blog_post_admin():
    """
    BlogPost site_id = 7 では消す
    """
    from mezzanine.blog.admin import BlogPostAdmin

    def _in_menu(self):
        return _current_site_id() not in [7, ]

    BlogPostAdmin.in_menu = _in_menu

patch_blog_post_admin()

Admin 表示前に、上記関数が評価されていれば、site_id == 7 の場合、BlogPost が表示されなくなります。

Re:dash ダッシュボードツールを使って、運営しているあらゆるサービスの数値を1ページで見る

Re:dash とは

リダッシュ。公式サイトはこちらになります。

http://redash.io/


公式サイトには動作可能デモもあるので、試しに触っていただくとわかりやすいと思うのですが、簡潔に言うと

  • Webアプリ上で SQL などクエリー文を登録
  • 結果を表やグラフで、そのまま表示
  • その表やグラフのけっかをまとめて1つのページに組み合わせることができる

というツールです。「ダッシュボード」を作るWebアプリですね。


TORICO では、2016年に Redash を使い始めましたが、使い勝手が良いので継続して使っていきたいです。

オープンソースです。

https://github.com/getredash/redash/

クエリーは定期的に実行してくれます。設定も、「10分ごと」「12時間に1回」「1週間に1回」のようなざっくりした指定ができるのがとても便利です。時刻指定で毎日実行させることもできます。

クエリーはページを表示する毎に実行されるわけではないので、少し重いクエリーでも気兼ねなく実行できます。今までは自前でダッシュボード作って、クエリーをいちいちキャッシュするようなコードを書いてましたが、Redash ではプルダウンから選ぶだけなので簡単で良いです。

グラフ(ビジュアライゼーション) いくつか


棒グラフ、線グラフの他にも、使い勝手の良いビジュアライゼーションがいくつか入っています。

ピボットテーブル


二次元でのクエリ結果… 例えば、日付 ✕ アイテム ✕ 販売数  ですとか、四半期間 ✕ サービス ✕ 売上 のようなクエリ結果を、整形せずともきれいな二次元表として表示できます。

カウンター



ただ数字を表示します。ダッシュボードの上部に、達成率などを表示すると使い勝手が良さそうですね。

コホート表

お客様継続率の表示に使うやつですね。

https://demo.redash.io/queries/67

※デモを見ていただくとわかるのですが、コホートの分析結果 までを自前で作る必要があります。
Redash がやってくれるのは、分析結果から表を生成する所だけです。

ユーザーID と デイリーアクセスのログ だけでは、この表は作れずに、一度分析した結果を DB に格納するとか、それなりの解析ができるクエリーを自分で作らないといけないです。

その他、株価チャートなどに使う箱ひげ図、地図上プロット、パイチャート、XY相関表のようなものを表示できます。

インストール方法

参考: http://docs.redash.io/en/latest/setup.html

AWS、Google Compute Engine でイメージが公開されていますので、これを使うのが一番手っ取り早いでしょう。

上記参考サイトの特定リージョン ( 日本なら ap-northeast-1 ) の、AMI のリンクをクリックして、ボタンをクリックしていけばすぐに出来ます。

Ubuntu 用のプロビジョニングスクリプトが用意されているようなので、Ubuntu であれば自分でインストールも出来なくはなさそうです。

SSL 設定


イメージから立ち上げた場合、nginx が HTTP サーバとなります。
SSL証明書をサーバにコピーし、/etc/nginx/sites-available/redash を修正してその SSL 証明書を使うようにすれば、redash は問題なく HTTPS で動きます。

ドキュメントはこちらです http://docs.redash.io/en/latest/misc/ssl.html

離れたネットワークの DB に接続する

インフラが AWS で完結している場合は、セキュリティグループなんかで権限管理すれば RDS など DB への接続は問題無いと思いますが、AWS の外にあるサーバなどにも繋ぎたい所です。

MySQL over SSL で接続する、などの手も考えられますが、セキュリティ周りの調整がシビアになりそうです。

平文通信を SSHトンネルの中を通すのが簡単でしょう。

常時 SSH トンネルをキープするツールとして、autossh というものがあり便利です。

インストール

$ sudo apt-get install autossh

このように起動します。

$ autossh -M 0 -f -N -L 127.0.0.1:13306:127.0.0.1:3306 user@example.com


-M 0 … 接続確認ポートを使わない
-f … デーモンモード
-N … SSH接続設定

参考: autossh - MQTT and …

この設定の場合、

1. まず、SSH で user ユーザーで example.com ホストへ接続する
2. example.com ホストから見た、127.0.0.1:3306 ポートを、
3. authossh 起動ホストの、127.0.0.1:13306 にバインドする

となります。

この autossh が起動している状態で、redash サーバ上で

$ mysql --host=127.0.0.1 --port=13306 --user=hoge --password


とすれば、接続先の example.com の中で起動している MySQL サーバに、SSH 越しに接続できるというわけです。

※ redash イメージは MySQL クライアントコマンドは入ってないので、実際には上のコマンドは事前に
$ sudo apt-get install mysql-client-core なんかでインストールしておく必要があります。

SSH 秘密鍵の作成

SSH で redashサーバからリモートサーバに接続する際は、SSH キーペアの作成が必要です。
定番ですが一応書いておきます。

redashサーバ上で

$ ssh-keygen -C redash-server@example.com

.ssh/id_rsa , .ssh/id_rsa.pub
が出来ます。

-C 以下は公開鍵に書き込まれるコメント


id_rsa を上書きしたくない場合は -f でパスを指定

$ ssh-keygen -f ~/secret -C redash-server@example.com


id_rsa.pub の内容を、接続先サーバの ~/.ssh/authorized_keys に追記 (ファイルが無ければ作成)

これで、redash から接続先サーバに SSH で接続できるようになります。

アップデート方法


Redash は頻繁にアップデートされていますので、追従してサーバをアップデートするとバグが直ってたり新しい機能が使えたりするので幸せです。

ドキュメント http://docs.redash.io/en/latest/upgrade.html

アップデートの前に、EC2インスタンスのイメージをバックアップのために作っておくと良いでしょう。

アップデートは、mac など手元のPC から fabric を使って行います。fabric は、Python のデプロイツールです。capistrano みたいなやつです。TORICOでも多くのケースで使っています。

$ pip install fabric requests


で fabric をインストールし、

https://gist.github.com/arikfr/440d1403b4aeb76ebaf8

ここから fabfile.py をダウンロードします。

fabfile.py があるディレクトリ (もしくはそれ以下のディレクトリ) で、

fab -H{your re:dash host} -u{the ssh user for this host} -i{path to key file for passwordless login} deploy_latest_release


このように、deploy_latest_release を実行すると簡単にアップデートできます。

この fabfile には deploy_latest_release 以外にもいくつかのタスクが登録されています。

タスク一覧を表示するには

$ fab -l

Redash を使ってみての感想


非常に良いです。TORICO では、複数のサービスを様々な環境下でリリースしていますが、それらの数値をまとめて1つのページに簡単に出来るのは便利です。IAM が作られているので環境構築が簡単だというのも良いですね。

会社、もしくは部署で1つ作っておいて、autossh で各サーバにつなぎ、数値は全部そこで見る。みたいな運用が理想的だと思います。

Python + Selenium で、簡単にブラウザの自動操作をする

mac 上の Python から、Selenium を使って簡単に Firefox を自動操作できます。

Firefox がインストールされている必要があります。Python は、2 でも 3 でも大丈夫です。

単純な Google 検索

コマンド1発で「Hello, world!」で google 検索をするところまでを書きます。

1. selenium をインストール

$ sudo pip install selenium

2. pythonスクリプトを作成

hello_selenium.py

#!/usr/bin/env python

from selenium import webdriver

if __name__ == '__main__':
    driver = webdriver.Firefox()
    driver.get('http://google.com')
    driver.find_element_by_css_selector(
        'input[name="q"]').send_keys("Hello, world!")
    driver.find_element_by_css_selector('input[type="submit"]').click()

3. 実行

$ chmod +x hello_selenium.py
$ ./hello_selenium.py

Firefoxが起動し、Hello, world! で Google 検索されたと思います。簡単ですね。

このパターンは、フォームに入力し submit するだけですが、応用すると認証ページに自動ログインしたりなどはすぐに書けると思います。

いつも使っている Firefox ではなく、クッキーや履歴などがまっさらな状態の Firefox を簡単につくれるので、テストには重宝します。

何度も実行すると、Dock が Firefox だらけになります。以下のコマンドで一気に kill できます。

$ killall firefox-bin

スマートフォンの User-Agent でアクセスする

#!/usr/bin/env python

from selenium import webdriver

if __name__ == '__main__':
    user_agent = "Mozilla/5.0 (Linux; Android 4.1.1; Nexus 7 Build/JRO03D) " \
                 "AppleWebKit/535.19 (KHTML, like Gecko) " \
                 "Chrome/18.0.1025.166 Safari/535.19"

    profile = webdriver.FirefoxProfile()
    profile.set_preference("general.useragent.override", user_agent)
    driver = webdriver.Firefox(firefox_profile=profile)
    driver.get('http://www.mangazenkan.com')

webdriver.Firefox の引数に、FirefoxProfile を与えると設定を変更できます。 これは、Android のUA を設定しているので、UA で表示を分けているタイプのサイトでは、SP 表示のテストができます。

レスポンシブデザインが主流だと思うので、あまり UA で分岐するようなサイトは最近見ませんけどね。

プロキシ設定をする (socks)

#!/usr/bin/env python

from selenium import webdriver

if __name__ == '__main__':
    profile = webdriver.FirefoxProfile()
    profile.set_preference('network.proxy.type', 1)
    profile.set_preference('network.proxy.socks', '127.0.0.1')
    profile.set_preference('network.proxy.socks_port', 10080)
    driver = webdriver.Firefox(firefox_profile=profile)
    driver.get('http://example.com')

これで、プロキシの socks 設定を上書きできますので、

$ ssh -N -D 10080 ubuntu@example.com

予め、このようなコマンドで socks トンネルを作っておき、( デーモンモードで起動するには -f を付与します。& してもいいかも)

このコマンドを実行すれば、socks 先の踏み台サーバを経由して Firefox を使えるため、アクセス元を考慮した確認に大変便利です。例えば、外国からのアクセスを試してみたい場合など。

EPUBファイルから画像を抽出する

電子書籍フォーマットとして広く使われている EPUB ファイルから、連番で画像を抽出する方法です。

ツール作りました! pip でインストールできます。https://github.com/ytyng/epub-extract-jpeg

EPUB ファイルの概要

EPUB ファイルとは、平たく言えば ZIP圧縮された XHTML です。 コミックで一般的に使われる形式では、1ページが1つの XHTML ファイルになっており、その中に 1 つの img タグが あり、画像ファイルにリンクされています。

そのため、手順としては

  • EPUB ファイルを解凍
  • 構成情報の XML ファイルを解析し、ページ画像の URL (パス) を取得
  • ページ画像を連番で改名コピー(移動)

となります。

1. EPUBファイルを解凍

unzip で一発です。

$ mkdir /tmp/epub-extract
$ unzip sample.epub -d /tmp/epub-extract

2. 構成情報の XML ファイルを解析し、ページ画像の URL (パス) を取得

まず、展開後のディレクトリにある META-INF/container.xml を開きます。 ここに、rootfile というタグがあるので、その full-path 属性を見ます。 full-path の XML ファイルが、各ページの目次のようなものになります。

full-path が示す XML ファイルで、manifest タグの中に item タグが複数あります。 これらは、EPUB 中の XHTML から使われているファイルです。

3. ページ画像を連番で改名コピー(移動)

この、item タグの中はおそらくページ順になっているので、このファイルをスクリプトで連番で改名コピーしながら収集すれば、ページ画像を抽出できます。

本来であれば、直接画像のパスを読むのではなく、ページの XHTML ファイルを開き、そこからリンクされている画像を収集していくのが正しいのですが、ページの XHTML の順と画像の item タグの順が一致しないケースは稀だと思いますので(EPUB 作成者が意図的に XHTML ファイルと画像ファイルの順番を一致させなかった場合などは、ページ数が正しく取得できません)、item タグの順で処理して基本的には問題無いでしょう。

これで、EPUB ファイルから画像ファイルを抽出する方法は終わりです。 最後に、Python スクリプトにした例を掲載しておきます。

from __future__ import print_function, unicode_literals

import os
import time
import sys
import subprocess
import shutil
from xml.etree import ElementTree

TEMP_DIR = '/tmp/epub-extract-{}'.format(int(time.time()))


def procedure(file_path):
    if not os.path.exists(file_path):
        print("{} is not exist.".format(file_path), file=sys.stderr)
        return

    output_dir, ext = os.path.splitext(file_path)

    if ext != '.epub':
        print("{} is not epub.".format(file_path), file=sys.stderr)
        return

    if os.path.exists(output_dir):
        print("{} is already exists.".format(output_dir), file=sys.stderr)
        return

    os.mkdir(TEMP_DIR)

    subprocess.Popen(
        ('unzip', file_path, "-d", TEMP_DIR),
        stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()

    os.mkdir(output_dir)

    container_xml_path = os.path.join(TEMP_DIR, 'META-INF', 'container.xml')
    etree = ElementTree.parse(container_xml_path)
    rootfile_node = etree.find(
        ".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
    content_opf_path = rootfile_node.attrib['full-path']

    content_xml_path = os.path.join(TEMP_DIR, content_opf_path)
    etree = ElementTree.parse(content_xml_path)
    manifest = etree.find('.//{http://www.idpf.org/2007/opf}manifest')
    items = manifest.findall('.//{http://www.idpf.org/2007/opf}item')

    image_paths = []
    for item in items:
        if item.attrib['media-type'] == 'image/jpeg':
            image_paths.append(item.attrib['href'])

    root_dir = os.path.dirname(content_xml_path)

    for i, image_path in enumerate(image_paths, start=1):
        destination_image_name = '{:03d}.jpg'.format(i)
        source_image_path = os.path.join(root_dir, image_path)
        destination_image_path = os.path.join(
            output_dir, destination_image_name)
        shutil.move(source_image_path, destination_image_path)
        print('{} -> {}'.format(image_path, destination_image_name))

    shutil.rmtree(TEMP_DIR)


def main():
    for arg in sys.argv[1:]:
        procedure(arg)


if __name__ == '__main__':
    main()
追記: Github に上げて、pip でインストールできるようにしました。
検索

Recent Tweets

  • ytyng

    ytyng @ytyng

    Core OS でデプロイしたかったけど意味ないじゃん、という。仕方なくシンガポール使うなら DigitalOcean かなー
    6 days, 17 hours ago

  • ytyng

    ytyng @ytyng

    Vultr で日本リージョンのサーバが作れない。 https://t.co/LFzvnzhPOz ここで availability 見れるけど日本少ない。日本リージョン品切れ中ってことなのかな?
    6 days, 17 hours ago

  • ytyng

    ytyng @ytyng

    Jetbrains All Products Pack をサブスクリプションした。最初は2年で$249で Jetbrains 使い放題。PyCharm で Docker 使いたかったりなど。
    6 days, 17 hours ago

  • ytyng

    ytyng @ytyng

    テスト https://t.co/wuWUEzwcd9
    2 weeks, 1 day ago

  • ytyng

    ytyng @ytyng

    仕事メールの gmail 転送が拒否されるようになった。メールサーバがブラックリストに載った?
    1 month, 4 weeks ago