新着記事

右記投稿者による投稿を見る 剛 四柳

メディアクエリを入れた Style タグつきの HTML メールを各種メーラーで見てみる

Gmail が、2016年末に HTMLメールの style のタグに対応してたということを知ったので、いよいよ style つきの HTMLメールを実用化できると思い、メディアクエリと Flex を含んだ HTML メールを送信してみました。

2017年11月現在の結果として、メディアクエリと Flex は危なそうですが style タグはメジャーな HTML メールで対応していたため、充分に実用的といえます。

HTMLソース

<head>
<meta charset="utf-8" />
<style>
*{
box-sizing: border-box;
}
.wrapper {
width: 100%;
}
.flex-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}

.flex-item {
border: 1px solid;
flex-grow: 1;
height: 200px;
flex-basis: 200px;
background-color: #ffd5a7;
}

.book-cover {
width: 100px;
height: 150px;
background-color: red;
}

.row {
overflow: hidden;
}

.pc2col {
width: 100%;
border: 1px solid;
background-color: #cae7ff;
float: left;
height: 100px;
}

@media (max-width: 768px) {
.visible-pc {
display: none;
}

.pc2col {
width: 100%;
}
}

@media (min-width: 769px) {
.visible-sp {
display: none;
}

.pc2col {
width: 50%;
}
}
</style>
</head>
<body>
<div class="wrapper">
<h1>Style テストメール</h1>
<h2>フレックスボックス</h2>
<div class="flex-container">
<div class="flex-item">
<div class="book-cover">A</div>
</div>
<div class="flex-item">
<div class="book-cover">B</div>
</div>
<div class="flex-item">
<div class="book-cover">C</div>
</div>
<div class="flex-item">
<div class="book-cover">D</div>
</div>
</div>

<h2>メディアクエリーブレイクポイント</h2>

<div class="visible-sp">
SPでのみ表示
</div>
<div class="visible-pc">
PCでのみ表示
</div>
<div class="row">
<div class="pc2col">
col1
</div>
<div class="pc2col">
col2
</div>
</div>
</div>

</body>

ソースコードをブラウザでそのまま表示

PCサイズ

SPサイズ

Gmail Web

PCサイズ

SPサイズ

Yahooメール Web

Outlook オンライン

iPhone の「メール」

iPhone の Gmail

iPhone Inbox

iphone Yahooメール

iPhone Yahooアプリ

Android Docomoメール

Android auメール

HTMLメルマガで避けるべき項目

メディアクエリ、Flex

本文記事で書いてある通り、メーラーによって動作まちまちなので使わない。

CSSセレクタでクラスを複数指定

style で、.header.btn のような複数クラスの指定をすると outlook オンラインで機能しない。

マイナスのマージン

margin-top: -20px; など。Gmail で機能しない。

position: relative/ position: absolute

Gmail で機能しない。

style タグ内の CSSのパースエラー

style タグ内に &quot; が混じった時、Gmail でスタイルが全て無効になった。焦るので注意。

box-shadow

Gmail で機能しない。

Django の CRUD ジェネリックビュー (ListView, DetailView, CreateView, UpdateView, DeleteView) の簡単な使い方

Django は、Python プログラミング言語で動作するウェブアプリケーションフレームワークです。

この記事は、Django の使い方を説明するものです。

Djangoの解説: The Web framework for perfectionists with deadlines | Django


Django に最初から用意されているビュー(ジェネリックビュー) を使えば、

  • オブジェクトのリスト表示
  • オブジェクトの作成
  • オブジェクトの詳細表示
  • オブジェクトの更新
  • オブジェクトの削除

のWebアプリが簡単に作成できます。

(オブジェクト=DBのレコード=モデルインスタンス=アクティブレコードオブジェクト という意味です)

Create, Read, Update, Delete をまとめて CRUD と呼んだりしますが、Django では CRUD に対応した汎用的な基底ビューコントローラが用意されているため、それを継承してビューを作ることで、安全で、読みやすいコードを書けます。

それぞれ、以下のビューコントローラに対応しています。

Create … CreateView
Read (リスト表示) … ListView
Read (詳細表示) … DetailView
Update … UpdateView
Delete … DeleteView

django.views.generic の中にいます。

Django では、これらのさらに基底となる TemplateView, FormView もありますが、やっている仕事として「モデルインスタンスを1つ作成/更新」「モデルインスタンス1つを詳しく表示」など、今回の CRUD の基底ビューに含まれる場合は、TemplateView, FormView を使うよりこれらのジェネリックビューを継承したほうがコードを読む際に書き手の意図を理解しやすいため、これらのクラスを使うべきです。

今回は、シンプルなテキストメモを作成・更新するWeb アプリのサンプルを作ってみました。
GitHubにコードを上げてありますので、同時にご参照ください。

リポジトリ

https://github.com/ytyng/django-crud-generic-view-tutorial

核心は、views.py なのでそれを見るとよくわかります。お急ぎの方はこれだけ読んでみてください。

https://github.com/ytyng/django-crud-generic-view-tutorial/blob/master/memo/memo/views.py

環境

Django 1.11, Python 3.6

ディレクトリ構造

memo/
+ manage.py
+ db.sqlite3
+ memo /
+ settings.py (Django設定(自動生成))
+ wsgi.py (WSGIランナー(自動生成))
+ urls.py (URLマップ(自動生成))
+ models.py (Memoモデルの定義)
+ views.py (ビューコントローラ)
+ forms.py (HTMLフォームクラス)
+ migrations (マイグレーションスクリプト(自動生成))
+ templates (HTMLテンプレート)
+ memo
+ base.html
+ memo_confirm_delete.html (削除確認用テンプレート)
+ memo_detail.html (詳細表示用テンプレート)
+ memo_form.html (作成、更新フォーム用テンプレート)
+ base.html (基底テンプレート)
+ memo_list.html (リスト表示用テンプレート)

コード解説

解説はコード中のコメントに書きました

models.py

from django.db import models


class Memo(models.Model):
"""
メモモデル。実質、件名と本文のみ。
"""
subject = models.CharField(
verbose_name='件名',
max_length=100,
default='',
blank=True
)

body = models.TextField(
verbose_name='本文',
default='',
blank=True
)

# created: auto_now_add を指定すると、作成日時を自動保存する
created = models.DateTimeField(
auto_now_add=True
)

# updated: auto_now を指定すると、更新日時を自動保存する
updated = models.DateTimeField(
auto_now=True
)

def __str__(self):
return self.subject

forms.py

from django import forms

from .models import Memo


class MemoForm(forms.ModelForm):
"""
Memo モデルの作成、更新に使われる Django フォーム。
ModelForm を継承して作れば、HTMLで表示したいフィールドを
指定するだけで HTML フォームを作ってくれる。
"""

class Meta:
model = Memo
fields = ['subject', 'body']

views.py

今回の核心です。Django の ListView, DetailView, CreateView, UpdateView, DeleteView をそれぞれ継承し、CRUDのビューコントローラを作成しています。

フォームクラスは、先程の MemoForm を使います。

それぞれのビューで書かなければならない内容は少なく、実務的な処理は Django に任せることで、エンジニアは UI の開発に集中できます。

from django.contrib import messages
from django.urls import reverse_lazy
from django.views.generic import \
ListView, DetailView, CreateView, UpdateView, DeleteView

from .models import Memo
from .forms import MemoForm


class MemoListView(ListView):
"""
メモを一覧表示
テンプレートは、何も指定しないと モデル名_list.html が使われる
ListView は、パジネーションもやってくれる
"""
model = Memo
paginate_by = 10 # 1ページに表示する件数


class MemoDetailView(DetailView):
"""
1つのメモを詳細表示
テンプレートは、何も指定しないと モデル名_detail.html が使われる
"""
model = Memo


class MemoCreateView(CreateView):
"""
メモ 新規作成
完了ページを作成し、success_url で指定して表示してもいいが、
django.contrib.messages の機能で、メッセージを保存して
リストビューなんかに戻した時に表示するのも簡潔で良い。
"""
model = Memo
form_class = MemoForm
success_url = reverse_lazy('memo_list')

def form_valid(self, form):
result = super().form_valid(form)
messages.success(
self.request, '「{}」を作成しました'.format(form.instance))
return result


class MemoUpdateView(UpdateView):
"""
メモ 更新
"""
model = Memo
form_class = MemoForm

success_url = reverse_lazy('memo_list')

def form_valid(self, form):
result = super().form_valid(form)
messages.success(
self.request, '「{}」を更新しました'.format(form.instance))
return result


class MemoDeleteView(DeleteView):
"""
メモ 削除
デフォルトでは、get でリクエストすると確認ページ、
post でリクエストすると削除を実行する、という動作。
実際は、レコードを削除するのではなく有効フラグを消す(いわゆる論理削除)
のケースが多いと思うので、そんな時はdeleteをオーバーライドしてその中で処理を書く。
"""
model = Memo
form_class = MemoForm

success_url = reverse_lazy('memo_list')

def delete(self, request, *args, **kwargs):
result = super().delete(request, *args, **kwargs)
messages.success(
self.request, '「{}」を削除しました'.format(self.object))
return result

urls.py

from django.conf.urls import url
from django.contrib import admin

from . import views

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

url(r'^$',
views.MemoListView.as_view(),
name='memo_list'),

url(r'^detail/(?P<pk>\d+)/$',
views.MemoDetailView.as_view(),
name='memo_detail'),

url(r'^create/$',
views.MemoCreateView.as_view(),
name='memo_create'),

url(r'^update/(?P<pk>\d+)/$',
views.MemoUpdateView.as_view(),
name='memo_update'),

url(r'^delete/(?P<pk>\d+)/$',
views.MemoDeleteView.as_view(),
name='memo_delete'),
]

テンプレート

Bootstrap4でさっくり作ってあります。

base.html

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{# Bootstrap4 で。#}
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb"
crossorigin="anonymous">
<title>{% block meta_title %}{% endblock %}</title>
<body>

{% if messages %}
{# Django のメッセージに記録している内容があればここで表示 #}
<div class="container">
<div class="row">
<div class="col-12">
<div class="messages mt-3">
{% for message in messages %}
<div class="alert alert-dismissable alert-{{ message.tags }}" data-alert="alert">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ message }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}

<div class="container">
<div class="row">
<div class="col-12">
<h1 class="mt-5">{% block page_title %}Memo{% endblock %}</h1>
</div>
</div>
</div>
{% block content %}
{% endblock %}
{% block foot_scripts %}
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js"
integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh"
crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js"
integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ"
crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>

memo_list.html

シンプルなメモ一覧テンプレート。簡易的なパジネーションつき

{% extends 'memo/base.html' %}
{% block content %}

<div class="container">
<div class="row">
<div class="col-12">
<div class="card card-default">
<ul class="list-group list-group-flush">
{% for o in object_list %}
<li class="list-group-item">
<a href="{% url 'memo_detail' o.pk %}">{{ o.subject }}</a>
</li>
{% empty %}
<li class="list-group-item">
メモはありません
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-12">
<div class="mt-3 text-center">
{% if page_obj.has_previous %}
<a class="btn btn-secondary" href="/?page={{ page_obj.previous_page_number }}">前へ</a>
{% endif %}
<span>{{ page_obj.number }}/{{ paginator.num_pages }}ページ</span>
{% if page_obj.has_next %}
<a class="btn btn-secondary" href="/?page={{ page_obj.next_page_number }}">次へ</a>
{% endif %}
</div>
</div>
</div>
</div>

<div class="container">
<div class="row">
<div class="col-12 mt-3">
<a href="{% url 'memo_create' %}" class="btn btn-primary">新規作成</a>
</div>
</div>
</div>
{% endblock %}

memo_detail.html

詳細表示。MemoDetailView で使われる。

{% extends 'memo/base.html' %}
{% block meta_title %}{{ object.subject }}{% endblock %}
{% block page_title %}{{ object.subject }}{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card card-default">
<div class="card-body">
{{ object.body|linebreaksbr }}
</div>
<div class="card-footer">
<a class="btn btn-primary" href="{% url 'memo_update' object.pk %}">修正</a>
<a class="btn btn-danger" href="{% url 'memo_delete' object.pk %}">削除</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

memo_form.html

新規作成/更新フォーム。 MemoCreateView, MemoUpdateView で使われる。
HTMLフォームは、MemoForm のインスタンスを bootstrap フィルタにかければ自動的にいい感じで作ってくれる。バリデーション〜エラー対応もやってくれる。

{% extends 'memo/base.html' %}
{% load bootstrap %}
{% block meta_title %}{% if object.pk %}{{ object.subject }}{% else %}新規作成{% endif %}{% endblock %}
{% block page_title %}{% if object.pk %}{{ object.subject }}{% else %}新規作成{% endif %}{% endblock %}
{% block content %}
<form method="post">
<div class="container">
<div class="row">
<div class="col-12">
{{ form|bootstrap }}
</div>
</div>
<div class="row">
<div class="col-12">
<button class="btn btn-primary">保存</button>
</div>
</div>
</div>
{% csrf_token %}
</form>
{% endblock %}

memo_confirm_delete.html

削除確認フォーム

{% extends 'memo/base.html' %}
{% load bootstrap %}
{% block meta_title %}削除確認{% endblock %}
{% block page_title %}削除確認{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12">
<p>{{ object }} を削除していいですか?</p>
<form method="post">
<button class="btn btn-danger">削除する</button>
{% csrf_token %}
</form>
</div>
</div>
</div>
{% endblock %}

パスワードを設定する時の注意とパスワード管理アプリの紹介

社内MTGで話した内容の共有です。

パスワードを新しく作る時の注意

辞書に載っている語句は使わない

辞書に載っている単語は誰でも知っている単語であり、簡単に組み合わせパターンを生成できるため使ってはいけません。

思いついたパスワードは使わない

パスワードを考える時、パスワードを思いつくことがあります。
私の姓は yotsuyanagi ですが、例えばこれを 逆にして、 o と i を数字にして、一部頭文字にして
1GanayustOy

というパスワードを思いついたとしますが、使ってはいけません。
自分が思いつくようなパスワードは、プログラムでも簡単に作れます。
他にも、PCキーボードやスマホのキーボードで打ちやすい文字を思いついた、とかもダメです。

ダメなパスワードの例

ilovetokyo2408

The-Quick-Brown-Fox

asdfzxcvjkl;m,./

このようなパスワードは、簡単に推測されます。

12345678, password, admin, 会社名なんかは論外です。

良いパスワードの例

ja3~hAb?eW%a

pAPr!p2u'u<a

これらのパスワードは、簡単に推測されることはありません。

どうやって作るのか?

パスワード管理ツールについている生成器(パスワードジェネレータ)で作りましょう。

例外を認めない

加えて、パスワードを作る時は例外を認めないようにしましょう。
・仮のパスワードだから
・検証環境だから
・試しで使ってみるだけだから
等、理由をつけて推測しやすいパスワードにしてはいけません。
例外かどうか、を判定するのにもコストがかかります。「パスワード」という単語が出てきたら、ジェネレータで作る以外に方法は無いと習慣づけましょう。

サイト毎にパスワードは変える

(他のサイトで使っているパスワードは使わない)

複数のサイトでパスワードを使いまわしていると、ある1つのサイトのパスワードが漏洩してしまった場合、他の同じパスワードのサイトがすべて乗っ取られてしまいます。

パスワードの漏洩は、フィッシングなどに引っかかって漏らしてしまう場合もあれば、サービスが保持しているパスワードがハッシュ化されておらず、悪意ある従業員が見たり、セキュリティホールからデータが漏洩してしまう危険性もあります。そのような時に被害を最小限にするため、サイトごとにパスワードは必ず変えましょう

さらに安全に使うには

サービスで2段階認証を使える場合、有効にしましょう。
少し手間は増えますが、受けられるメリットのほうがはるかに大きいです。

2段階認証を使いましょう。繰り返しお伝えします。2段階認証を使いましょう

パスワードを保存する時

パスワードツールを使いましょう

メモ帳や表計算ソフトはやめて、パスワード管理ツールを使いましょう。
マスターパスワードがつけられて、内容が暗号化される必要があります。

パスワードツールの紹介

無料

無料アプリはサポートが充実しておらず、アップデートも頻繁ではないためできれば有料アプリを使いましょう。

無料/Windows

Keepass

完全無料でいくならこれ。Windows, Android で使えます。
Mac, iPhone は 一応使えますが、クライアントアプリのアップデートがあまりされておらず、おそらく keepass ver.1 しか対応アプリは無さそうなので Windows と連携する時は注意。
あまり便利な機能などありませんが、一応使えます。

データの保存先を Dropbox など信頼できるクラウドストレージにすれば、機種間のパスワード同期も可能です。

Buttercup

比較的新しいアプリです。使ったこと無いのですが、けっこう良さそうです。(Electronかな?)

ID manager

古くからあるパスワード管理ツールです。シンプルながら十分使えます。

データの保存先を Dropbox など信頼できるクラウドストレージにすれば、機種間のパスワード同期も可能です。

無料/mac

キーチェーンアクセス

mac でお金をかけたくないなら、macにインストールされているキーチェーンアクセスアプリを使いましょう。
Windows や Android との連携はできないので、Windows でも読みたいなどあれば他のアプリにしましょう。

無料でもなんとか使える (フリーミアム)

keeper

なにかと有料プランへの告知が出ますが、無料でも十分に使えます。マルチデバイスでの対応状況も良いです。
有料の場合、¥3,600/年

Lastpass

使ったことありませんが有名、サイトを見るかぎりけっこういけそう。
有料の場合、$2/月 の年間払いで $24/年

データ漏洩がニュースになったこともありました。

有料 (サブスクリプション課金)

1Password

僕はこれです。マルチデバイスは一応対応ですが、OSごとに違うライセンス(アプリ代金)が必要なので少し高額になります。
→最近は買い切りではなくストレージつきサブスクリプションプランになってました。$2.99/月 か、$4.99/月 のファミリープランがありました。
年だと $35.88/年

True Key

Intel/マカフィーブランド。無課金だと15件しか保存できず、使い続けるのは難しそうなので有料扱いにしています。昔は5件だったような。機能十分。
有料だと2,678円/年 ($19.99)。

トレンドマイクロ パスワードマネージャー

無課金だと5件しか保存できず、使い物にならないので有料扱いにしています。
月額150円ぐらい、有料にしては安いほう。
年だと 1,800円ぐらい

有料 (買い切り)

買い切りのパスワード管理アプリは今のところ有力なものが無さそうです。

昔は 1Password が買い切りだったが今はやってない?
あと mac 用では、Forklift を作ってる会社の Locko というアプリもありましたが、(日本のストアでは?)非公開になってました。


おすすめは?

正直、どのアプリでも十分な成果は期待できそうです。

あえて言うなら家族で使うなら、1Password ファミリープラン。mac の人にすすめるなら 1Password。
Windows 使ってる人に勧めるなら、Truekey でしょうかね…

私は 1Password を過去に買い切りで買っているのでそれをずっと使っています。
当時は良いアプリでしたが、現在は競合も多くそれほどの優位性は無いと感じています。

やむおえず人にパスワードを伝える時

基本的に、アカウントの使い回しは無いに越したことありませんが、業務上まれにあります。

・メールに書かない

経路によっては回線が暗号化されておらず、内容が盗聴される可能性があります。

また、メールサーバが暗号化せず保存している場合も多いので、比較的簡単に中身が読まれる可能性があります。

どうする?

メール添付する場合は、暗号化してファイルを添付する。 gnupg とか使う

メールを使わず、別の安全な経路で伝える。お互いSSHで入れるサーバがあるならそこに書くとか。

フィッシングメールに注意

たまに、メールでフィッシングが来ます。Gmail だと見たことないのですが、Outlook online だと見かけたります。

メールの内容としては、「Apple ID がリセットされました。すぐログインして確認してください」等の文言が書いてあり、ログインを促すボタンが表示されています。その他、巧妙な文面で不安を煽る内容が書かれているはずです。

あなたは絶対にボタンを押してはいけません。

ログインボタンを押した時点で、あなたのメールアドレスは「フィッシングメールを踏む情弱カモ」と認定されリストに登録されます。その後、情弱なあなたにフィッシング攻撃のメールやらスパムやらが大量に送られてくることになります。

ログインボタンを押すと、おそらく偽物のログインフォームが設置されたページ表示されます。見た目は、本物のサイトと区別がつかないはずです。おそらくSSL対応され、ブラウザ上は安全な鍵マークが表示されていることでしょうが、ブラウザ上のドメイン表示は偽ることはできないため、ドメインをしっかり見れば偽物のサイトだと気づくかもしれません。

セキュリティ対策ソフトがインストールされている場合、ここで警告が出るはずです。最近のブラウザであれば、ブラウザ自体が警告を出すかもしれません。(出さないかもしれません。)

それでも気付かずにメールアドレスとパスワードを入力した場合、おそらくは正規のログインページに飛ぶ気がします。あなたは、「パスワードを間違ったのかな?」と思うだけかもしれませんが、攻撃者は既にパスワードを入手しています。

この次の攻撃はおそらく2つのパターンに別れます。

A) 攻撃者がパスワードを変更しない (静かな攻撃)

攻撃者はあなたのパスワードを入手しましたが、活動的な攻撃は行いません。そのメールアドレスとパスワードを使い、他のサイトにログインできるかを試します(プログラムが自動的に行います)。そして、ログインできた全てのサイトを記録し、あなたの活動を監視します。それで攻撃者の目的が達成される場合もありますし、もしくはどこかのタイミングで大きな攻撃をするために待っているだけかもしれません。

B) 攻撃者がパスワードを変更する (活発な攻撃)

パスワードを入手した攻撃者は、そのパスワードを使ってサイトにログインし、すぐパスワードを変更します。場合によってはパスワードリマインダーが使えないよう、メールアドレスも変更するかもしれません。おそらくプログラムが自動的に行います。

そして、そのアカウントを使い、SNSに広告を投稿したり、友達リストの友達に「コンビニでプリペイドカード買って」など連絡を取るかもしれません。

あなたはそのアカウントにログインする方法も、パスワードをリセットする方法も持たないため、アカウントをあきらめるしかありません。すぐさま、知人に「乗っ取られたので変なこと言われても無視して」と連絡しましょう。

Amazon マーケットプレイスWebサービス (MWS) APIから注文情報を取得する方法

当社では、Amazon のマーケットプレイスに出店していたり、FBA (フルフィルメントByアマゾン: Amazon社の倉庫に商品を納品し、販売を代行してもらう販売方法) を行っています。

マーケットプレイスにはAPIが用意されており、リクエストすることで受注情報など多くの情報を取得できるのですが、署名の計算に少し躓いたので書いておきます。

Amazon MWS 公式ガイド

アクセスに必要な情報を集める

認証情報 (クレデンシャル)

アクセスに必要な認証情報は、

  1. 出品者ID (マーチャントID, セラーIDと呼ばれることもある)
  2. AWSアクセスキーID
  3. 秘密キー(シークレットキー)

の3つです。もし、アクセスするアカウントがセラーセントラルのアカウントではなく、派生して作られた子アカウントである場合、別途「MWS認証トークン」が必要になります。

これらの情報は、すべてセラーセントラルの「設定」→「ユーザー権限」のページで取得できます。AWSアクセスキーID,秘密キー は、ページ下部の「Amazon MWS 開発者権限」の、「認証情報を表示」をクリックすると表示されます。

マーケットプレイスID

別途、マーケットプレイスID という文字列が必要になる場合があります。

マーケットプレイスIDの一覧はページから確認できます。

Amazon マーケットプレイス Web サービスエンドポイント

例えば、日本なら A1VC38T7YXB528 です。

APIアクセスのテストをする

Amazon社がAPIのテストツール「Scratchpad」を公開しているので、それを使います。後述する HMAC の計算が正しいか確認する意味でも、このツールは必ず使ってみたほうが良いです。

  1. Amazon MWS Scratchpad を開く
  2. 左上「API Selection」を適当に選択。今回は、API セクション: 注文、Operation: ListOrders
  3. Authentication 欄に認証情報を入力、SellerId: には 出品者ID, MWSAuthToken は元アカウントなら空、払い出された子アカウントならトークン文字列を入れる。AWSAccessKeyId、Secret Kye は先ほど取得した文字列を取得。
  4. API必須パラメータの「MarketplaceId.Id.1」には、A1VC38T7YXB528 を入力
  5. API任意パラメータの「LastUpdatedAfter」のみ、2017-05-05 のように入力
  6. 「送信」をクリックすると、結果が表示されます。

HMAC の値を確認しておく

結果が正常に表示された場合、「リクエスト」タブをクリックして開いてみると「署名対象の文字列」というセクションと、その下に計算した HMAC署名 が表示されています。APIを開発する時は、この情報を元に開発するとやりやすいです。「証明対象の文字列」に対して SHA 256 HMAC で計算を行い、その下に書かれている文字列が結果で得られるよう開発をしていきます。

APIライブラリを開発する

Pythonで作ります。

署名方法は、Amazonで「署名バージョン2」と言われる方法です。

公式ドキュメントの署名のロジック説明 (Java のサンプルコードあり)

HMACの計算ロジックの作成

Python には hmac ライブラリがあるので、それを使えばすぐにできます。

Python3での例

import hmac
import hashlib
import base64

secret_key = b"取得した秘密キー"

canonical = b"""
POST
… 「証明対象の文字列」をここにコピペ …
"""

h = hmac.new(secret_key, canonical.strip(), hashlib.sha256)

print(h.hexdigest())
print(base64.b64encode(h.digest()))

これを実行すると、Scratchpad に表示されている「SHA 256 HMAC」「Base64 HMAC」と同じ値が取得できるはずです。

署名対象の文字列の作成

署名対象の文字列は、HTTPメソッド(POST)、ドメイン名(mws.amazonservices.jp)、パス(/Orders/2013-09-01)、それとクエリ文字列を、改行(\n)で連結して作ります。

クエリ文字列の作成

クエリ文字列は、検索パラメータ名をソートさせ、&= で連結して作ります。値は URL エンコードします。

ディクショナリで値を用意してたとすると、

import datetime
import urllib.parse

AMAZON_CREDENTIAL = {
    'SELLER_ID': 'セラーID',
    'ACCESS_KEY_ID': 'AWSアクセスキーID',
    'ACCESS_SECRET': 'アクセスシークレット',
}

data = {
    'AWSAccessKeyId': AMAZON_CREDENTIAL['ACCESS_KEY_ID'],
    'Action': 'ListOrders',
    'MarketplaceId.Id.1': 'A1VC38T7YXB528',
    'SellerId': AMAZON_CREDENTIAL['SELLER_ID'],
    'SignatureMethod': 'HmacSHA256',
    'SignatureVersion': '2',
    'Timestamp': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    'Version': '2013-09-01',
}

query_string = '&'.join('{}={}'.format(
    n, urllib.parse.quote(v, safe='')) for n, v in sorted(data.items()))

print(query_string)

このようなロジックで作成できます。

sorted メソッドでキーで並び替えを行い、値は urllib.parse.quote で URLエンコードします。safe='' を入れないと / がエンコードされないので、入れます。

後は、改行で連結すれば署名対象文字列になります。

canonical = "{}\n{}\n{}\n{}".format(
    'POST', 'mws.amazonservices.jp', '/Orders/2013-09-01', query_string
)

print(canonical)

署名をつけてリクエストする方法

リクエストメソッドは POST です。ですが、POSTのデータは空で、パラメータはURLのクエリストリングに入れます。

署名は、クエリストリングの末尾に &Signature=署名 という形で付与します。

requests でリクエストしてみる

実際にリクエストするコードを書いてみます。

import base64
import datetime
import hashlib
import hmac
import urllib.parse

import requests
import six

AMAZON_CREDENTIAL = {
    'SELLER_ID': 'セラーID',
    'ACCESS_KEY_ID': 'AWSアクセスキーID',
    'ACCESS_SECRET': 'アクセスシークレット',
}

DOMAIN = 'mws.amazonservices.jp'
ENDPOINT = '/Orders/2013-09-01'


def datetime_encode(dt):
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')


timestamp = datetime_encode(datetime.datetime.utcnow())

last_update_after = datetime_encode(
datetime.datetime.utcnow() - datetime.timedelta(days=1))

data = {
'AWSAccessKeyId': AMAZON_CREDENTIAL['ACCESS_KEY_ID'],
'Action': 'ListOrders',
'MarketplaceId.Id.1': 'A1VC38T7YXB528',
'SellerId': AMAZON_CREDENTIAL['SELLER_ID'],
'SignatureMethod': 'HmacSHA256',
'SignatureVersion': '2',
'Timestamp': timestamp,
'Version': '2013-09-01',
'LastUpdatedAfter': last_update_after,
}

query_string = '&'.join('{}={}'.format(
n, urllib.parse.quote(v, safe='')) for n, v in sorted(data.items()))

canonical = "{}\n{}\n{}\n{}".format(
'POST', DOMAIN, ENDPOINT, query_string
)

h = hmac.new(
six.b(AMAZON_CREDENTIAL['ACCESS_SECRET']),
six.b(canonical), hashlib.sha256)

signature = urllib.parse.quote(base64.b64encode(h.digest()), safe='')

url = 'https://{}{}?{}&Signature={}'.format(
DOMAIN, ENDPOINT, query_string, signature)

response = requests.post(url)

print(response.content.decode())

下品にベターっと書いてますが、これで動きます。

実際にはこれをライブラリ化して肉付けしていくと良いでしょう。

Chrome58で、HTTPSの自己証明書が NET::ERR_CERT_COMMON_NAME_INVALID になる場合の対応

Google Chrome をバージョン58 にアップデートすると、SSL自己証明書を使っているサイトが見れなくなる場合があります。

自己証明書(オレオレ証明書)を使っているサイトに HTTPS でアクセスすると、

この接続ではプライバシーが保護されません

攻撃者が、tech.torico-corp.com 上のあなたの情報(パスワード、メッセージ、クレジット カード情報など)を
不正に取得しようとしている可能性があります。 NET::ERR_CERT_COMMON_NAME_INVALID

セキュリティに関する事象についての詳細を Google に自動送信する。プライバシー ポリシー
セキュリティで保護されたページに戻る詳細情報を表示しない
このサーバーが tech.torico-corp.com であることを確認できませんでした。
このサーバーのセキュリティ証明書は [missing_subjectAltName] から発行されています。
原因として、設定が不適切であるか、悪意のあるユーザーが接続を妨害していることが考えられます。
詳細

tech.torico-corp.com にアクセスする(安全ではありません)

このような感じのエラーになります。

原因

SSL証明書のエントリをテキスト形式で見ると

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 35 (0x23)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=JP, ST=Tokyo, L=Chiyodaku, O=TORICO, OU=CA, CN=tech.torico-corp.com/...
        Validity
            Not Before: Apr 27 05:54:47 2017 GMT
            Not After : Apr 25 05:54:47 2027 GMT
        Subject: C=JP, ST=Tokyo, O=torico, CN=*.torico-corp.com ←※
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
...

このような感じになっていると思います。大抵、証明書を設置するドメインを「←※」の箇所の CN= に書きますが、Chrome 58 以降、この CN= を評価しなくなったようです。

そのため、閲覧しているドメインが CN= に一致しても、証明書が検証できないとしてエラーになります。

対策

ドメイン名は CN= では判定しなくなったため、どこで判定するかというと

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 35 (0x23)
        Signature Algorithm: sha256WithRSAEncryption
...
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            Netscape Comment:
                OpenSSL Generated Certificate
            X509v3 Subject Key Identifier:
...
            X509v3 Authority Key Identifier:
...

            X509v3 Subject Alternative Name:
                DNS:torico-corp.com, DNS:*.torico-corp.com ←※

    Signature Algorithm: sha256WithRSAEncryption

この、X509v3 Subject Alternative Name: DNS: で判定します。略して SAN と言われるやつです。

DNS: という名前がめちゃくちゃわかりにくいですが、ここに CN= に書いてたドメインと同じものを記入することで Chrome58+ で認識します。

この値は Chrome58 より古いバージョンでも読めるようになっていますが、Chrome58 は CN= が読み込まれなくなったため、SANに書いてます。

SAN の DNS: は複数書けるので、手持ちのドメインをすべて列挙しておくと便利でしょう。

証明書の作り方

認証局証明書 (CA証明書)

認証局証明書 (CA の PEM から作る公開証明書) の更新は不要です。今までの証明書を使いまわせます。

CSR (認証リクエスト)

openssl req -new -newkey rsa:2048 -nodes \
    -out ${CSRファイル名}.csr \
    -keyout ${鍵ファイル名}.key \
    -sha256 \
    -config ca.conf \
    -subj "/C=JP/ST=Tokyo/L=Chiyoda/O=torico/CN=${CN}"

おそらくこのような感じのコマンドで CSR を作られていると思いますが、この -config のファイルで次のように指定します。

ca.conf

[ ca ]
default_ca = CA_default

[ CA_default ]
...
x509_extensions = usr_cert
...

[ req ]
default_bits = 2048
default_keyfile = privkey.pem
distinguished_name = req_distinguished_name
attributes = req_attributes
x509_extensions = v3_ca
req_extensions = v3_req

...

[ usr_cert ]
basicConstraints=CA:FALSE
nsComment = "OpenSSL Generated Certificate"

subjectKeyIdentifier = hash
authorityKeyIdentifier=keyid,issuer:always
subjectAltName = @alt_names

[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = torico-corp.com
DNS.2 = *.torico-corp.com
DNS.3 = mangazenkan.com
DNS.4 = *.mangazenkan.com
DNS.5 = sukima.me
DNS.6 = *.sukima.me

かいつまんで書いてますが、こんな感じです。

CSRを作る際

[req] req_extensions = v3_req

[v3_req] subjectAltName = @alt_names

[alt_names] DNS.1 = サーバのドメイン名

といった流れで参照します。この設定ファイルを -config で指定して CSR を作ると、CSR に X509v3 Subject Alternative Name:エントリが入ります。

作ったCSR をテキストで確認するには

openssl req -noout -text -in ${CSRファイル名}.csr

で見れます。

Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=JP, ST=Tokyo, L=Chiyoda, O=torico, CN=*.torico-corp.com
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
        Attributes:
        Requested Extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            X509v3 Key Usage:
                Digital Signature, Non Repudiation, Key Encipherment
            X509v3 Subject Alternative Name:
                DNS:torico-corp.com, DNS:*.torico-corp.com, DNS:mangazenkan.com, DNS:*.mangazenkan.com, DNS:sukima.me, DNS:*.sukima.me

このように、複数のDNS: が出力されているはずです。

CSRをCAで認可しサーバ証明書を作る

openssl ca -batch -passin pass:${CAパスワード} -config ca.conf \
    -in ${CSRファイル名}.csr \
    -keyfile ${CA鍵ファイル}.key \
    -cert ${CA鍵証明書}.pem \
    -out ${出力するサーバ証明書}.crt -days 3650

このようなコマンドでサーバ証明書を作られると思いますが、今回も -config で先ほどと同じファイルを指定しています。

先ほどは [req] のディレクティブを読み込んで使いましたが、今回は

[ CA_default ] x509_extensions = usr_cert
             ↓
[ usr_cert ] subjectAltName = @alt_names
             ↓
[alt_names] DNS.1 = ...

とたどって alt_names ディレクティブを読み込みます。

(読む項目が違うので、CSRを作る時に使った設定ファイルと設定ファイルを別にしても良いです)

(もしかして、このCSRのCA認証だけ SANを出力するよう設定しておけば、CSR を作る際の SAN出力は不要かもしれません。未確認)

これで証明書を作ると、

Certificate Details:
        Serial Number: 35 (0x23)
        Validity
...
        Subject:
            countryName               = JP
            stateOrProvinceName       = Tokyo
            organizationName          = torico
            commonName                = *.torico-corp.com
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            Netscape Comment:
                OpenSSL Generated Certificate
            X509v3 Subject Key Identifier:
...
            X509v3 Authority Key Identifier:
...

            X509v3 Subject Alternative Name:
                DNS:torico-corp.com, DNS:*.torico-corp.com, DNS:mangazenkan.com, DNS:*.mangazenkan.com, DNS:sukima.me, DNS:*.sukima.me

このように、X509v3 Subject Alternative Name:  (SAN) が出力されます。

この PEM を Nginx やら Apache やらの SSL 証明書に指定し、サーバを再起動すれば Chrome58 でも証明書のエラーが出なくなるはずです。

ブラウザ上で簡単なスクリプトをブックマークから動かす方法(ブックマークレット)

この記事は、非プログラマ向けの技術記事です。非プログラマが、プログラミングを始める足がかりとなることを目的としています。

WEBブラウザと日常業務

日常業務でウェブブラウザを使う機会はけっこうあると思います。いろいろなツールが Webアプリ化されるにつれ、ブラウザを業務で使う機会は昔と比べて増えました。

ただし、多くの項目があるフォームを毎日書く業務があったり、あるページを開いて項目を1つ1つスプレッドシートや他のWebアプリ、もしくはメールにコピペしたりといった業務があったりもします。もし、その業務が面倒だと感じているならば、自動化できる余地は十分あります。

プログラマは、面倒だと思った作業はすぐに自動化できますが、プログラミングに明るくない方でも、範囲は限定的ですが Webアプリであれば面倒が改善できるかもしれません。

WEBブラウザでプログラムを動かせる

お使いのブラウザで、今すぐに自分でプログラムを書いて実行できます。そういう機能がブラウザに入っています。

Windows の Chrome, Firefox であれば、Ctrl + Shift + i を押してみてください。Edge をお使いであれば F12 です。Mac であれば Command + Option + i です。

すると、画面が分割され別のペインが出てきます。もし、警告文が出てきたら読み、同意/認可ができるようであればブラウザの案内にそって対応してください。

おそらく最初は、Firefoxの場合は「インスペクタ」、Chromeの場合は「Elements」タブが有効になっていると思います。その隣に、「コンソール」「Console」タブがありますので、それをクリックするとコンソールモードになります。

↑ Firefox

↑ Google Chrome

Firefox では、一番下に >> となっています。ここをクリックするとプログラムが入力できます。Chrome の場合は一番上に > があり、ここにプログラムが入力できます。

試しに、簡単な計算式を書くと、計算ができます。

↑消費税計算と81の平方根の算出をした例

Math.sqrt は、ブラウザの Javascript エンジンに最初から組み込まれている平方根計算の関数です。どんなことができるかはJavascript の入門書を読むと良いと思いますが、「何かをウェブページに表示する」であれば大概できます。(ただし、表示しているサイトとは別のサイトから持ってきた情報を表示するのは少し難しい場合があります)

別の例を試します。コンソールに alert('今日は' + new Date()) と入力します。そうすると、現在の日時がポップアップで表示されます。

また、history.back() を入力すれば、ブラウザの「戻る」ボタンを押した時と同じように、前のページへ戻ることができます。

このように、ウェブブラウザには非常に簡単にプログラムを動かす仕組みが入っています。

アドレスバーからでもプログラムを実行できる

先程は開発コンソールからプログラムを実行しましたが、ブラウザ上部にあるアドレスバーからでもプログラムの実行ができます。

…できたはずなんですが、最近のブラウザは、アドレスバーに入力した文字列が URL ではないと判断した場合、検索エンジンで検索するようになっているので、アドレスバーにスクリプトを入力しての実行する方法は最近のブラウザでは難しくなっています。

昔のブラウザでは、アドレスバーに

javascript:alert('今日は' + new Date());void(0);

と入力すると、先程コンソールでプログラムを動かしたのと同様に alert を動作させることができました。

アドレスバーに直接入力しての動作はできなくなりましたが、リンクのタグとして Webページに記録すれば動作させることができます。

プログラムを動作させるリンクタグ(クリックで上記プログラムを実行)

ブックマークからでもプログラムを実行できる

アドレスバーに入力したプログラムが動作するということは、そのプログラムをブックマークとして保存しておけば、そのブックマークを呼び出した時にプログラムが動作することになります。

試しに、上記「プログラムを動作させるリンクタグ」をブックマークしてください。リンクをブックマークツールバーにドラッグアンドドロップすると良いでしょう。

ブラウザによっては、「ブックマークの新規作成」のようなメニューから、さきほどの

javascript:alert('今日は' + new Date());void(0);

このスクリプトをURLとして登録すればブックマークできます。

ブックマークができたら、クリックしてください。同様にプログラムが動いて、ポップアップでメッセージが表示されたと思います。

このように、ブックマークの中にプログラム(Javascript)を記録して、クリックして実行させる方法を「ブックマークレット」といいます。

ブックマークレットのサンプル

試しに作ってみました。

ページの表示を崩すブックマークレット

ページの表示を崩す

これをブックマークして、好きなページで実行することでページの表示を崩すことができます。

元に戻すにはページをリロードしてください。

スクリプトの内容を書いておきます

var divs = document.getElementsByTagName('div');
var mode = Math.floor(Math.random() * 2);
for (var i = 0; i < divs.length; i++){
  var delay = Math.random() * 1000;
  var d = divs[i];
  setTimeout(function(d) {
   if (d){
    var t = Math.floor(Math.random() * 2000 + 1000);
    d.style.transition = "all " + t +"ms ease-in";
    var deg = Math.random() * 30 - 15;
    d.style.transform = "translate(0, 800px) rotate(" + deg + "deg)";
  }
  }, delay, d);
}

フォームの全 Input の value に name を入れるブックマークレット

inputのvalueにnameを入れる

function d(inputs){
  for(var i =0; i<inputs.length; i++){
    inputs[i].value =inputs[i].name;
  }
}
d(document.getElementsByTagName("input"));
d(document.getElementsByTagName("textarea"));

「フォーム内容を復元するブックマークレット」を作るブックマークレット

フォーム内容を保存

実行すると、ページ上にリンクが1つできます。そのリンクがブックマークレットになっているので、ブックマークしてください。

次回、同じフォームを表示した時にそのブックマークレットを実行することで内容を再度入力することができます。

var out = "javascript:function sC(n,v,c){var elms=document.getElementsByTagName('input');for(ind in elms){o=elms[ind];if(o.name==n && o.value==v){o.checked=c;}}}function sI(n,v,c){var elms=document.getElementsByTagName('input');for(ind in elms){o=elms[ind];if(o.name==n){o.value=v;o.checked=c;}}}function sO(v){var elms=document.getElementsByTagName('option');for(ind in elms){o=elms[ind];if(o.value==v){o.selected='selected'}}}function sT(n,v){var elms=document.getElementsByTagName('textarea');for(ind in elms){o=elms[ind];if(o.name==n){o.value=v;}}}";

var elms, ind, o;
elms = document.getElementsByTagName('input');
for (ind in elms) {
    o = elms[ind];
    if (o.type == "hidden") {
        continue;
    } else if ((o.type == "checkbox" || o.type == "radio") && o.name && o.value) {
        out += "sC('" + o.name + "','" + encodeURIComponent(o.value) + "'," + o.checked + ");";
    } else if (o.name && o.value) {
        out += "sI('" + o.name + "','" + encodeURIComponent(o.value) + "');"
    }
}
elms = document.getElementsByTagName('option');
for (ind in elms) {
    o = elms[ind];
    if (o.selected) {
        out += "sO('" + o.value + "');"
    }
}
elms = document.getElementsByTagName('textarea');
for (ind in elms) {
    o = elms[ind];
    if (o.value) {
        out += "sT('" + o.name + "','" + encodeURIComponent(o.value.replace(/\s+/g, ' ')) + "');"
    }
}
out += "void(0);";
console.log(out);
var a = document.createElement('a');
a.href = out;
a.textContent = 'フォーム自動入力';
a.style.display = 'block';
a.style.position = 'fixed';
a.style.backgroundColor = '#eee';
a.style.padding = "40px";
a.style.top = "100px";
a.style.left = "100px";
a.style.zIndex = 1000;
a.style.border = "1px solid black";
document.body.appendChild(a);

ブックマークレットでできること

表示しているページの改変や操作が得意です。

ブラウザで表示しているページは HTML で出来ていて、ブックマークレットではその HTML のかなり多くの箇所を操作できるため、「表示しているページに対して何かを行う」ことに関しては得意です。

例えば、入力フォームが表示されている時、その入力フォームに対して何かを行う(自動入力など)ことについては他にないほど便利です。

複数ページにまたがった処理や、別のサイトのページに対しての処理は苦手です。

ページの遷移を行うと処理が止まってしまうため、複数ページの処理はあまり得意ではありません。特に、複数のサイトを同時に操る操作はブラウザの制約があるため難しいです。がんばればなんとかなりますが、他の手段を用いたほうが良いでしょう。Selenium とか Windows Scripting Host とか。

Javascript をブックマークレットに変換する方法

ページに対して何かを行う Javascript が書けた場合(思い浮かんだ場合)、それをブックマークレットの形式にするには少しの変換が必要です。

  • 改行や余計なスペースを除去する
  • 記号を URL エンコードする
  • 先頭に「javascript:」をつける
  • 最後に void(0) をつける、もしくは全体を void() で囲む、など

(一番最後のは、処理が undefined で評価されないとページ遷移が発生して、真っ白いページが表示されるなど変な結果になってしまうため入れてます)。

短いスクリプトであれば手で修正できますが、長いスクリプト、特にインデントが入っているものなんかは手で変換するのは大変です。

変換スクリプトを書いて github pages で上げておきましたので、よかったら使ってください。

Bookmarklet スクリプト変換

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/

RUN mkdir -p /var/run/django/

# 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

↑ libjpeg などインストールしてるのは Pillow 用

docker/requirements/base.txt

Django==1.10.1
pytz
uwsgi
# MySQL使う場合
PyMySQL

↑ 今回のサンプルは、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 --quiet --filter name=${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 --quiet --filter name=${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 でインストールできるようにしました。
検索

最近のツイート

  • ytyng

    ytyng @ytyng

    やばい、機能がリリースされない! タイムゾーンか? サマータイム設定になってるのか? とか焦ってたら、リリース日になってなかっただけだった。今日月末じゃん。月初じゃないじゃん。1人で焦ってたけど結果1日得した気分
    2 ヶ月, 3 週間 前

  • ytyng

    ytyng @ytyng

    class SerialCode って書こうとして、class Serialcode (非キャメルの1単語) の方がいいのではと思ってしまう。Datasturcture, Javascript みたいに非キャメル1単語で書きそうになるのは症状名があるのだろうか
    4 ヶ月 前

  • おてふ

    おてふ @otef

    ytyng

    ほんとうに頭がおかしいコマしかないのに読後感は非常に爽やかという奇跡のマンガ、男日本海が今なら無料なのでみなさん読んでおいたほうがいいですよほんとに https://t.co/AsIZsNuwiP https://t.co/kKl77lJIlH
    4 ヶ月, 2 週間 前

  • ytyng

    ytyng @ytyng

    昼間なのに影が多方向に出るのってちょっと面白い https://t.co/x1scFI2Uk2
    4 ヶ月, 2 週間 前

  • ytyng

    ytyng @ytyng

    https://t.co/FUChcVIVpm になって、パッケージの登録ができないんですけど、Python有識者の方にどうしたらいいか教えてほしい。 https://t.co/AEkCfbml6Z このガイドの通り twine 使っても HTTPError 410 っていう
    4 ヶ月, 3 週間 前