ITANDI TECH BLOG

イタンジのスタッフブログです。イベントや技術情報などを発信しています。

全エンジニアに機械学習のスキルを

IMG_9481

こんにちは、エンジニアの建三です。

イタンジでは先週MLTP(Machine Learning engineer Training Program)なるものを始めました。これは何かというと、イタンジの全エンジニアに機械学習のスキルを身に付けてもらおうという試みです。

これには主に3つの理由ががあります。

1 - イタンジのエンジニアリング力の強化

Googleは全エンジニアが機械学習をある程度知ってる状態にしたいと宣言しています。どんなエンジニアでもRailsなどでWeb開発がある程度出来るのが必須なのと同じで、どんなエンジニアでも機械学習をそれなりに知っているのが普通という時代があと数年で来ると思います。

2 - 機械学習研究者/エンジニアは中々見つからない

最近こそエンジニアのための機械学習入門的なブログ/オンラインコース/本が出てき始めましたが、まだまだWeb Developerに比べて機械学習エンジニアは圧倒的に少なく採用が大変です。

更に、機械学習を効果的に使うにはその会社のビジネスと既存のシステムを理解している必要があり、機械学習エンジニアを雇ってから会社に貢献出来るようになるまである程度時間がかかります。

既存のエンジニアは既にそれらを理解しているため、後は機械学習を学べばすぐ会社に貢献出来ると考えています。

3 - 自分を置き換えられる存在にする

強い組織というのは誰が突然抜けても問題なくやっていける組織だと思います。その為には自分にしか知らないこと・出来ないことを徹底的になくす必要があります。

自分にしか出来ないことはある意味会社での自分の存在価値のようなものなので、それを守ろうとする人がいるかもしれません。しかし知識や情報は与えて減るものではないので、必死にそれを守るメリットはありません。

プログラムの内容

僕はnumpyで機械学習アルゴリズムを一から書くことのが一番機械学習の勉強になりました。なのでこのプログラムでも実際にアルゴリズムを書くことによって学ぶことを重点に置いています。 更に、イタンジで実際に使われてる機械学習のモデルを使い、実践で機械学習を使うためのスキルやノウハウを勉強します。

まだ始まったばかりなので今後変わるとは思いますが、以下の内容を網羅する予定です。

1 Traditional Machine Learning

numpyを使ってmini scikit-learn libraryを作ります。 色んなアルゴリズムを勉強してく中で同時に機械学習の基本的なコンセプトを身に付けます。

  1. Supervised Learning
  • K nearest Neighbors
  • Naive Bayes
  1. Unsupervised Learning
  • K means clustering

2 Deep Learningを学ぶ為の基礎

numpyを使ってmini kears libraryを作ります。

  1. Linear Regression
  2. Logistic Regression
  3. Feed Forward Neural Network(Back Propagation)

3 Deep Learning

PyTorchを使います。

  1. Recurrent Neural Network
  2. Convolutional Neural Network

第一回目の様子

先週に第一回が行われ、3種類の機械学習の説明(教師あり学習、教師なし学習、強化学習)とK-Nearest Neighbors(k-NN)の説明をしました。

レクチャーではk-NNのアルゴリズムを手で計算しました。

IMG_9490

IMG_9491

宿題では実際にk-NNをnumpyで書きます。Python機械学習の知識は人により差はありますが、全員コンプリートしたようです。

今後の展望

第一回目のレクチャーはライブで行いましたが、2回目以降はスクリーンキャストを作って各自のペースで進めてもらう予定です。エンジニアの中でも営業を兼任してる者が何人かいるので、勤務時間内と言えども全員揃うのは難しいです。更に今後入社するエンジニアにも使ってほしいので、使い回しが出来るものの方が良いと思っています。

上記のプログラムをこなすのに大体3ヶ月くらいかかりそうですが、もしこの取り組みが上手くいったら外部にも公開する予定です。

Deep LearningをやるならTensorflowよりもPyTorch!

Screenshot 2017-03-08 11.53.00

こんにちは、エンジニアの建三です。

Deep Learningのライブラリと言えばTensorflowが有名ですよね。1年半前にリリースされて以来、一瞬にして知名度を手にしました。僕はその頃Deep Learningを勉強していたので、Hacker NewsでTensorflowがバズってるのを見て何となく僕も興奮していたのを覚えています。しかし早速使ってみようと思いチュートリアルを進めたものの、LSTMやCNNの作り方が分からず断念しました。

僕はStanfordのコースDeep Learningを勉強したんですが(無料でクオリティ超高いです)インストラクターのAndrej KarpathyはTensorflowよりもTorchを勧めていました。ホームワークはnumpyを使うんですが、アーキテクチャが正にTorchのPython版という感じで、すごくしっくりくるんですよね。それまで色んなリソースを使ってDeep Learningを勉強して中々理解出来なかったのがやっと理解出来たんです。

しかしTorchの言語はLuaで、Python大好きの僕にはちょっと辛かったのでKerasを使うことにしました。KerasはTorchに影響されてるので、レイヤーを重ねていくというところがすごく似ています。でもTorchよりももっと簡単に書けるのでscikit-learnのDeep Learning版といった感じです。イタンジでもAIチャットやクローラーにKerasを使っています。

しかし最近やっと強化学習を勉強し始め、Kerasよりもっとフレキシブルなライブラリを使った方がいいなと思い他のライブラリを探し始めました。すると何とTorchのPython版が出てるじゃありませんか!

その名もPyTorch(そのまんま)。

PyTorchを賞賛する声

TensorflowよりもPyTorchを好むのは僕だけではありません。

Redditでは、Kaggleの優勝者のJeremy HowardがPyTorchの方が使い易いと言っています。

Hacker NewsではSalesforceのエンジニアがChainerからPyTorchに移行する予定と言っています。

そして僕にTorchの素晴らしさを教えてくれたAndrej KarpathyはTwitterでもはやTensorflowは古いという爆弾発言...

DeepMindはTorchからTensorflowに移行しましたが、もうPyTorchを使ってるだろうと予想しています。facebookもTorchを使ってる代表的な会社の一つで、PyTorchの開発にがっつり携わっています。

Angular(Google)からReact(facebook)の流れを思い出します。一時期AngularはJS frameworkの頂点に立ち誰もAngularを超えられないと思いきや、Reactがあっさりと超えてしまいましたよね。

JSにしろDeep Learningにしろ僕はfacebookの作るものの方が好きみたいです...

Tensorflowを使う一番のメリットは、研究と実装で同じライブラリを使えるというところにあります。Deep Learningの学び易さを重視するならPyTorchの方が良いでしょう。numpyからtensorへのコンバートが出来るのですごく楽です。

PyTorchでGRUを作ろう!

単純なGRUのClassifierを作りました。 まだチュートリアルが全然ないので、こんな簡単なモデルですらかなり苦労しました... 何度も諦め「Kerasでいいや...」と思ったんですが、踏ん張った甲斐がありました。

完成版のGistはこちらにあります。

Pytorchではモデルをクラスで定義します。initで必要なレイヤーをinitializeします。この場合だとnn.GRUnn.Linearの2つです。 そしてforwardでどうそれらのレイヤーを繋げるかを書きます。Input -> GRU -> Linearというシンプルな構造です。

import torch
import torch.nn as nn
from torch.autograd import Variable

class GRU(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(GRU, self).__init__()

        self.hidden_size = hidden_size

        self.gru = nn.GRU(input_size, hidden_size)
        self.linear = nn.Linear(hidden_size, output_size)

    def forward(self, input, hidden):
        _, hn = self.gru(input, hidden)
        ## from (1, N, hidden) to (N, hidden)
        rearranged = hn.view(hn.size()[1], hn.size(2))
        out1 = self.linear(rearranged)
        return out1

    def initHidden(self, N):
        return Variable(torch.randn(1, N, self.hidden_size))

PyTorchはKerasやscikit-learnのようにmodel.fit(X,y)で勝手に学習してくれるわけではないので、ボイラープレートを書く必要があります。

import numpy as np
np.random.seed(1337)

def batch(tensor, batch_size):
    tensor_list = []
    length = tensor.shape[0]
    i = 0
    while True:
        if (i+1) * batch_size >= length:
            tensor_list.append(tensor[i * batch_size: length])
            return tensor_list
        tensor_list.append(tensor[i * batch_size: (i+1) * batch_size])
        i += 1

class Estimator(object):

    def __init__(self, model):
        self.model = model

    def compile(self, optimizer, loss):
        self.optimizer = optimizer
        self.loss_f = loss

    def _fit(self, X_list, y_list):
        """
        train one epoch
        """
        loss_list = []
        acc_list = []
        for X, y in zip(X_list, y_list):
            X_v = Variable(torch.from_numpy(np.swapaxes(X,0,1)).float())
            y_v = Variable(torch.from_numpy(y).long(), requires_grad=False)

            self.optimizer.zero_grad()
            y_pred = self.model(X_v, self.model.initHidden(X_v.size()[1]))
            loss = self.loss_f(y_pred, y_v)
            loss.backward()
            self.optimizer.step()

            ## for log
            loss_list.append(loss.data[0])
            classes = torch.topk(y_pred, 1)[1].data.numpy().flatten()
            acc = self._accuracy(classes, y)
            acc_list.append(acc)

        return sum(loss_list) / len(loss_list), sum(acc_list) / len(acc_list)

    def fit(self, X, y, batch_size=32, nb_epoch=10, validation_data=()):
        X_list = batch(X, batch_size)
        y_list = batch(y, batch_size)

        for t in range(1, nb_epoch + 1):
            loss, acc = self._fit(X_list, y_list)
            val_log = ''
            if validation_data:
                val_loss, val_acc = self.evaluate(validation_data[0], validation_data[1], batch_size)
                val_log = "- val_loss: %06.4f - val_acc: %06.4f" % (val_loss, val_acc)
            print("Epoch %s/%s loss: %06.4f - acc: %06.4f %s" % (t, nb_epoch, loss, acc, val_log))

    def evaluate(self, X, y, batch_size=32):
        y_pred = self.predict(X)

        y_v = Variable(torch.from_numpy(y).long(), requires_grad=False)
        loss = self.loss_f(y_pred, y_v)

        classes = torch.topk(y_pred, 1)[1].data.numpy().flatten()
        acc = self._accuracy(classes, y)
        return loss.data[0], acc

    def _accuracy(self, y_pred, y):
        return sum(y_pred == y) / y.shape[0]

    def predict(self, X):
        X = Variable(torch.from_numpy(np.swapaxes(X,0,1)).float())      
        y_pred = self.model(X, self.model.initHidden(X.size()[1]))
        return y_pred       

    def predict_classes(self, X):
        return torch.topk(self.predict(X), 1)[1].data.numpy().flatten()


これでKerasと同じように使えます。イタンジでは住所、物件名、最寄り駅など7種類のテキストを判別するClassifierにこのモデルを使っています。データだけダミーに置き換えました。

from sklearn.model_selection import train_test_split

MAX_LEN = 30
EMBEDDING_SIZE = 64
BATCH_SIZE = 32
EPOCH = 40
DATA_SIZE = 1000
INPUT_SIZE = 300

def main():
    class_size = 7

    ## Fake data
    X = np.random.randn(DATA_SIZE * class_size, MAX_LEN, INPUT_SIZE)
    y = np.array([i for i in range(class_size) for _ in range(DATA_SIZE)])

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.2)

    model = GRU(INPUT_SIZE, EMBEDDING_SIZE, class_size)
    clf = Estimator(model)
    clf.compile(optimizer=torch.optim.Adam(model.parameters(), lr=1e-4),
                loss=nn.CrossEntropyLoss())
    clf.fit(X_train, y_train, batch_size=BATCH_SIZE, nb_epoch=EPOCH,
            validation_data=(X_test, y_test))
    score, acc = clf.evaluate(X_test, y_test)
    print('Test score:', score)
    print('Test accuracy:', acc)

    torch.save(model, 'model.pt')


if __name__ == '__main__':
    main()

こんな感じでKerasっぽいログが出ます。

Epoch 1/40 loss: 1.9836 - acc: 0.1379 - val_loss: 1.9776 - val_acc: 0.1557
Epoch 2/40 loss: 1.9634 - acc: 0.1550 - val_loss: 1.9748 - val_acc: 0.1521
Epoch 3/40 loss: 1.9454 - acc: 0.1711 - val_loss: 1.9727 - val_acc: 0.1579
Epoch 4/40 loss: 1.9285 - acc: 0.1904 - val_loss: 1.9712 - val_acc: 0.1579
Epoch 5/40 loss: 1.9121 - acc: 0.2061 - val_loss: 1.9701 - val_acc: 0.1614

PyTorchの不満

一つPyTorchの不満があるとすれば、Tnesorのタイプが間違っているが為にエラーになることが多々あり苦戦しました。 上記の_fitのところでX.float()でfloatにしy.long()にしています。何故かそうしないと「Typeが違うよ!」というエラーが出ます。何故そのタイプじゃなきゃいけないのか分からないし、それくらい自動でやってくれと思いました。 しかもエラーメッセージがすごく分かり辛く、どこが間違ってるか理解するまでに時間がかかりました。

Documentationでもあまり触れられてないので、ここは改善してほしいと思っています。

PyTorchの勉強の仕方

正直オフィシャルのチュートリアルは微妙です。PyTorchのリソースをまとめたRepoにもっとクオリティの高いチュートリアルがあるので、こっちがオススメです。

まとめ

Tensorflowや他のライブラリを使ってる人は是非PyTorchを一度試してみることをオススメします。僕もまだまだ学びたてですが、もっと多くの人にPyTorchの素晴らしさを知ってほしいと思っています!

汎用的クローラーの構築

こんにちは、エンジニアの建三です。

クローラーを作る際に必要なものは大きく分けて3つあります。

1. URLを取得するスパイダー - 不動産の例で言うと、サイトに掲載されている全物件の詳細ページのURLを取得するスパイダーが必要です。

2. XPath(あるいはCSS Selector)の特定 - 物件名や家賃などの物件情報を取得する為のXPathが必要です。

3. テキストの整形 - XPathで得られるのはあるDOMのテキストなので、更にそのテキストから求めてる情報を取得する必要があります。

これ以外の機能(定期的にクローリングする、エラーの対処など)は、サイトに関わらず共通するものですので、サイト毎に必要なのは上記の3つになります。

アプローチ

汎用的なクローラーには主に2つのアプローチがあります。

GUIXpathを特定する方法

1つ目はPortiaのように、GUIで取得したい情報のDOMを指定する方法です。これは上記の2を簡単にしたものと言えます。この方法だと確実に必要な情報が取得出来ますが、幾つか欠点があります。

それでも1000サイトやるのは大変 早ければ1サイト30分くらいで出来るかもしれませんが、慣れるまで時間がかかります。

DOM構造が変わったらやり直さなければならない 将来DOM構造が変わった時にまたXPathの特定をしなければいけないのでメンテナンスが大変です。

結局整形はしなければならない 3は自動化出来てないので、1サイト毎に作らなければなりません。

自動でXpathを特定&整形する方法

もう1つの方法は、自動的にXpathを特定する方法です。当然これが出来たらこっちの方が楽なので、精度次第ということになります。

この方法は前例があまり見つからず、どこまで難しいのかあまり予測出来ませんでした。社内でどちらのアプローチを取るか意見が分かれましたが、一ヶ月後者を試してみて、ダメだったら諦めて前者のアプローチを取るということになりました。

あれから2ヶ月後...

結論から言うと、1ヶ月の開発でそこそこのものが出来、自動でいけるなというのを確信しました。更に1ヶ月開発を続け、10サイトで9割の情報が自動で取れるまでになりました。

元々完全自動は無理だと分かっていて9割取れれば良い方だと思っていたので、期待以上のものが出来ました。勿論開発に使ってる10サイトで9割というのは機械学習で言う教師用データの精度と同じなので、実際はもう少し低くなります。

大まかな流れ

以下がアルゴリズムの大まかな流れです。

1 - ラベルと値を特定する 項目によって機械学習正規表現、または両方を使いラベルと値を特定します。ラベルが必要な項目とそうでない項目があります。

2 - レイヤーを重ねて候補を絞っていく 機械学習正規表現だけでは完全に値を特定出来ないので、様々な手法を使い正しい値を探します。

3 - 特定したXpathを保存する Xpathが特定出来たら、それをサイト毎に保存します。特定出来なかった項目は手動で書きます。

4 - 整形する Xpathを使って取ってきたものを整形します。

HTML elementのXpathを取得するプログラム

Pythonのlxmlを使ってHTMLのマニュピレーションをしているんですが、elementからXPathを自動的に取る関数がないので自分で書きました。(gistはこちら) XPathを使ったことがある人はご存知だと思いますが、XPathは何通りもの書き方があり、質の良いXPathと質の悪いXPathがあります。indexだと壊れる可能性が高いので、なるべくidやclassを使うようにします。

from urllib import request
from lxml import etree
import re

def get_index(e):
    tag = e.tag
    prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag]
    next_list = [i for i in e.itersiblings() if i.tag == tag]
    if len(prev_list + next_list) == 0:
        return None
    return len(prev_list) + 1

def is_valid_class(c, siblings):    
    if re.search(r'[0-9]', c):
        return False
    c = c.strip()
    for sibling in siblings:
        if c in sibling:
            return False
    return True

def get_one_path(e):
    index = get_index(e)
    index = "[%s]" % (index) if index else ""
    this_attrib = e.attrib
    if 'id' in this_attrib:
        val = this_attrib['id']
        if not re.search(r'[0-9]', val):
            return e.tag + "[@id='%s']" % (val)
    if 'class' in this_attrib:
        ## 同じタグで同じクラスのものがsiblingにない場合のみclassを使用
        tag = e.tag
        prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag and 'class' in i.attrib]
        next_list = [i for i in e.itersiblings() if i.tag == tag and 'class' in i.attrib]
        siblings = [e.attrib['class'].split(' ') for e in prev_list + next_list]
        class_list = this_attrib['class'].split(' ')
        for c in class_list:
            if is_valid_class(c, siblings):
                return e.tag + "[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]" % (c)
    return e.tag + index

def get_xpath(e):
    my_xpath = ''
    while True:
        path = get_one_path(e)
        my_xpath = "/%s%s" % (path, my_xpath)
        e = e.getparent()
        # root tagまでたどり着いた
        if e is None:
            return my_xpath

url = 'https://rent.tokyu-housing-lease.co.jp/rent/8016671/6337'
def main():
    with request.urlopen(url) as f:
        data = f.read().decode('utf-8')     
        tree = etree.HTML(data)
        given_path = "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/th[1]"
        p = tree.xpath(given_path)[0]
        print(get_xpath(p) == given_path)

if __name__ == '__main__':
    main()

1. ラベルと値を特定する。

第1ステップは、機械学習正規表現を使ってラベルと値を特定します。その際に1サイトにつき200物件ほどのデータを使います。

特定したら、そのelementのXPathを先ほどのget_xpathで取得し、テキストと同時に下記のようなフォーマットで保存します。

以下は賃料の値の例です。2物件だけ表示しています。1つ目のリストに4つリストが入っています。これは1つ目の物件で賃料と思われるテキストを含むHTML elementが4つあることを示しています。

[
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[contains(concat(' ', normalize-space(@class), ' '), '  ')]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "7.5万円"
    ]
  ],
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "8.2万円〜8.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[1]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[2]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[3]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.3万円"
    ]
  ]
]

ご覧の通り1つに絞れていないので、他の手法を使う必要があります。

2. レイヤーを重ねて候補を絞っていく

第2ステップがメインのアルゴリズムになります。第1ステップで特定したものを更に絞っていくのですが、手法が沢山あり、それらをレイヤーと呼んでいます。

項目によって色んなレイヤーを使い分けます。全て説明しようとすると一つのレイヤーで記事が一つ書けてしまうほどなので、よく使うレイヤーの概要だけ説明します。

全物件に共通してるものを除外する

ヘッダー、フッターなど、サイトで共通してるものは物件の情報でないので除外します。

項目毎にスレッショルドを決めています。例えばスレッショルドが.9で200物件使っていたら、180物件に存在していれば共通のものと判断します。何故こんなことをするかというと、共通情報がたまに一部のページにだけなかったりするので、少し余裕を持たせています。

リピートしてるものを除外する

物件詳細ページには、その物件以外にも、似ている物件や同じ建物の空室一覧が載っています。それらの物件は大抵複数載っているので、リピートしてるかどうかを見ます。

以下は住所の例です。また2物件だけ表示しています。ご覧の通り、2〜4つ目のXPathdivのindex以外同じなのが分かります。これらはメイン物件の情報ではないので除外します。

[
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ],
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ]
]

ラベルに近いものを採用する

特定したラベルを使い、そのラベルに一番近い値を採用します。この「近さ」を計るには、ラベルのXPathと値のXPathがどれだけ違うというのを計算します。

敷金や礼金など、ラベルがないと区別がつかないものには必須です。

ポピュラーなものを採用する

最後の方の工程で使われます。それぞれのXpathがどれくらいのページに使われてるかを計算し、一番使われているものを採用します。ノイズを削除するのに使います。

最初に出てきたものを採用する

これも最後の方の工程で使います。 それぞれのページで一番最初に出てきたXpathを採用するというシンプルな手法ですが、中々使えます。他のレイヤーである程度絞っていれば、後は先に出てきたものがメイン物件の情報である可能性が高いです。

3 - 特定したXpathを保存する

第2ステップでレイヤーを特定したら、サイト毎にjsonファイルにまとめます。その際に必要なメタデータを自動で取得します。

以下はWinsproというサイトの例です。設備・条件は読点で区切られてるので、それを保存しておきます。敷金、礼金、保証金の場合は同じelementに複数の項目が入ってるので、ポジションを保存しておきます。

{
  "option": {
    "label": "設備・条件",
    "delimiter": "、",
    "path": [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[text()='設備・条件']/following-sibling::td"
    ]
  },
  "shikikin": {
    "label": "敷金/礼金",
    "position": 0,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "reikin": {
    "label": "敷金/礼金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "hosyokin": {
    "label": "敷金/保証金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/保証金']/following-sibling::td[1]"
  }
}

4. 整形する

第3ステップで作ったデータを使ってスクレイピングと整形をします。Scrapyを使っていますが、デフォルトのParserではなくlxmlを使っています。

まとめ

今回ご紹介した方法は、不動産サイトのように項目が主にテーブルの中に入ってるようなものに向いています。ブログ記事のような長いテキストの中から情報を抽出するのであれば自然言語処理を使った方が良いでしょう。

もし汎用的クローラーを作ってる方がいらっしゃったら是非情報交換しましょう!

エンジニアの採用も強化しておりますので気軽にオフィスに遊びに来て下さい!

プロダクトロードマップとは巨大なフレームワークの表層部分

この記事はProduct Manager Advent Calendar 2016の23日目のエントリーです

横澤です、本年はお世話になりました。来年もよろしくお願い致します。

プロダクトマネジメントの勃興】 本日はプロダクトマネジメントにおけるプロダクトロードマップの位置付けについて思うところがあったので書いてみました。ここ二、三年くらいでしょうか、プロダクトマネジメントという単語、というか概念が流行ってきている感があります。プロダクトマネジメントとは何か?プロダクトマネージャーは何をする人か?まだプロダクトマネジメントで消耗してるの? 的な事が各所で語られているのを見かけます。

プロダクトマネジメントにおけるプロダクトロードマップという業務】 私の理解ではプロダクトマネージャーの重要な役割の一つにプロダクトロードマップ(PRD:ProductRequirementDocと呼ばれる事もあるようですが以下PRMと略します)をアウトプットしてメンバーの指標とする役割があると認識しております。ですが、プロダクトロードマップのお手本というのはあまり見た記憶がありません。冷静に考えてみればその会社のPRMが見られるという事は、サービスの方向性やセグメントとかフェーズ認識とか会社や事業として重要な思念が色々と見えてしまうのでカジュアルに公開する訳にはいかない様な気がします。

とは言え作ろうと思ったら試しに見てみたいので色々とググったのですがこんな資料を発見しました。シンプルisベストという事でしょうか。他にもガントチャート型の例なども見かけましたが、個人的には「マイルストーンを大きめな粒度で描いたスケジュール表」という受け取り方をしています。分かりやすさという意味ではこのシンプルさは良いのでしょうけど、これだけ見せられて納得感があるのか?というのがどうも腑に落ちないところでした。

【プロダクトロードマップの背後に潜むコンテキストの重要性】 丁度昨年度の始め辺りに上に書いたようなモヤモヤした気持ちを持ちつつもググって調べたフォーマットや独自の考えを用いてPRMを作成したのですが、モノの見事に失敗しました。結構な時間と気合を入れてそれっぽいアウトプットをしたのですが、結果としては現在のプロダクトと乖離しているので仕事としては失敗したと考えています。失敗した理由を改めて考えてみると大きく分けて3つの理由があったと振り返っています。

  • 提供する機能は明確に記載されているが、それがもたらす価値についての記載が希薄だった

  • 最終的には文章と画でアウトプットされたが、そこに至るまでの分解されたKPIや数字の関係性が抜け落ちていた

  • 細かく書きすぎ且つ、一つ上のレイヤーで抽象的にまとめられておらずスッと記憶されない

おおざっぱに言うとメンバーに公開する際の圧縮率が足りてないのと、最終アウトプットに至る途中資料の作り込みが足りないという結論を得ました。良いPRMというのは高度に抽象化されたMVCフレームワークみたいなイメージで、普通にWEBアプリを作る上では必要最低限の規約を理解すれば充分で、より深掘りしたくなった際には、複雑ながらも全体を通して一貫性のあるクラス構造によって支えられていて、読み進めるウチに入口であった規約の背後に潜む思想がより強靭に理解されるようなモノだと考えました。つまりRuby on RailsみたいなPRMは良いPRMだろうという事です。

【事業計画とPRMは表裏一体】 そんな事を考えている中、今年もPRMを作る機会がありました。反省を活かして凝縮されたシンプルな作りを目指すのですが、どうも納得する水準に達さない出来のままに、途中で会社全体の事業計画を作る業務に移りました。事業計画にはマイルストーンとして「この事業(≒プロダクト)はこのタイミングでこんな事が実現されている」みたいな事を書きこんでいたのですが、それを見て「PRMの一枚裏にあるコンテキスト資料とは事業計画なのではないか?」という考えが頭をよぎりました。その考えを辿った結果、会社のミッション>事業を通して実現したいビジョン>サービスやプロダクト個別の事業計画(ビジネスモデルやKPIツリーが含まれる)>PRMというレイヤー構造として理解でき、それぞれの資料の繫がりを自分の中で整理する事ができるようになりました。

【何が言いたいのか】 開発メンバー等に共有するPRMのフォーマットはやはりシンプルに凝縮されたフォーマットが望ましいと思います。ただ、PRMが産み出されたコンテキストについては、メンバーが知りたいと考えた時にどこまでも深掘り出来て、最終的にはそれが会社のミッションやビジョンという最上位レイヤーから繋がった概念である、と理解できる設計になっている事も重要だと考えています。プロダクトマネジメントをする上ではUXデザインであったりビジネスデザインであったりと考慮すべき領域が多岐に渡るのですが、それぞれがどういうレイヤー構造で企業思念に結びついているのかをもやもやしながらも少しは理解を進歩出来たと思う一年でした。

ちなみにイタンジでは共にプロダクトの成長を楽しんでいけるメンバーを絶賛募集しております。

来年もどうぞよろしくお願い申し上げます。

crawleraを使ったクローリングの最適化

これは日本情報クリエイト Engineers Advent Calendar 2016の24日目の記事です。 元日本情報クリエイトのエンジニアなのでOB枠として参加させてもらいます!

日本情報クリエイトは宮崎で不動産系の自社プロダクトを開発し、日本全国に販売している会社です。 九州にUターンを考えている方、九州の南のほうにお住まいのエンジニアの方、転職先としておすすめです。

はじめに

皆さんクローラー作ってますか? 以前こちらにも書きましたがscrapy + scrapy cloudを使うと簡単に安定稼働するクローラーを構築できます。 今日はscrapy cloudを提供しているscraping hubが展開しているもう一つのサービスcrawleraをご紹介します

crawleraとは

英語ですがここのページに記載されています。 https://crawlera.com/

簡単にまとめると以下の様なことを実現出来るクローラー専用のproxyサービスです

  • 数千のIPプールを介してのリクエス
  • 50カ国以上のIPが利用可能
  • 130種類以上のステータスコード、またはキャプチャを含むバンの自動検出
  • 自動再実行及び、遅延処理でリクエストを抑制してバンを防ぐ
  • HTTPとHTTPSをサポート

便利ですね、ipの付け替えやバンの検出及び自動再実行を自前でやろうとするとそれなりにコストがかかりますがその辺をまるっとやってくれます。

使ってみよう

以下の物が必要です。

  • scrapinghubのアカウント
  • クレジットカード(crawleraは無料版がないため料金の支払いが必要です)

https://scrapinghub.com/にアクセスしてアカウントを作成し、crawleraを契約してください。

crawleraのアカウントを作る

Create Accountをクリックします

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-12-24-14-53-46

アカウント名を入力してリージョンを選択します。 ここで選択したリージョンのIPアドレスが使用されます。 allを選択すると各国のIPプールの中でIPを付け替えてくれます。 今回は日本を選択します。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-12-24-14-58-49

作成するとOverviewが表示されます、ここではcrawleraでリクエストしたものがどれくらいバンされたり失敗したのかやどこのサイトにどれくらいアクセスしたのかをグラフで確認出来ます。 また残り何回リクエスト出来るかもここで確認出来ます。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-12-24-15-03-16

使ってみる

作成したcrawleraアカウントの設定画面にcurlを使ったコマンド例があります、これをコマンドラインに貼り付けて実行してみます。

curl -U [API KEY]: -x proxy.crawlera.com:8010 http://httpbin.org/ip

するとリクエスト元のIPアドレスjsonで返ってきます。 試しに何回か実行すると毎回違うIPが返ってくることがわかります、crawleraさんがちゃんとプールされたIPの中から毎回違うIPをチョイスしてくれてますね。

実行後実行したアカウントをcrawleraの管理画面から選択するとリクエストの履歴を見ることが出来ます。 client ipから実際にリクエストで使ったipレスポンスタイム等必要な情報を全て見ることが出来ます。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-12-24-15-16-35

また各行のResp. Timeにマウスオーバーするとどれくらい遅延を入れたかが表示されます。 crawleraさんが自動で遅延処理を入れてくれていることが分かります。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-12-24-15-21-35

後は今使っているクローラーのproxyにcrawleraを指定すれば完了、簡単ですね。 scrapyをお使いの場合は専用のライブラリが用意されているのでpip installして設定するだけです。 詳細はこちらに記載されています。

https://doc.scrapinghub.com/crawlera.html

終わりに

いかがでしたか、crawleraを使うと拍子抜けするほど簡単にクローリングを最適実行する環境とダッシュボードを手に入れることが出来ます。 ただ実際に稼働しているクローラーに入れたところパフォーマンスが1/3まで低下してしまいました。 これはcrawleraが自動で遅延処理を入れてくれるのはいいんですが15秒とか平気で遅延させるためです。 パフォーマンスが求められるクローラーの場合はただ入れるだけだと大変なことになるので同時リクエスト数を増やす等のチューニングが必要です。

ReduxとReact Routerの相性が悪いのでrouterを一から書いてみた

こんにちは、エンジニアの建三です。

Reactと一緒に使うRouterと言えばReact Routerですよね。しかし僕のReact+ReduxアプリにReact Routerを導入してみると、どうも思うようにいきませんでした。

Reduxは"Single Source of Truth"をモットーにしており、Reduxのstoreがアプリのstateを全て管理しています。しかしReact Routerを使うと、React Routerがrouteを管理しReduxがそれ以外を管理するというぎこちない感じになります。

React Routerの代わりを探す旅に出た

そんなぎこちなさをGoogleにぶつけてみると、同じような考えを持ってる人が沢山いました。React Router v4が出た時のHacker NewsではReact Routerの批判のコメントが多く見られ、代替案が多数提示されました。

React Router以外のRouterを調べてみると沢山出てきますが、どれも使い方がイマイチ分からなく断念...

するとこのブログで「そもそもroutingにlibraryなんているの?」というのが書かれており、読んでみました。早速試してみると少しのコードでrouterが自分で作れ、「これでいいじゃん」と思いました。

ただ先ほどのブログでは色んな詳細が省かれており、Reduxの話が全くされていません。Reduxの話はこちらのブログが参考になりました。一つ一つのrouteをreduxのアクションにしてしまおうという非常にシンプルな方法です。RouteをJSXで定義するReact Routerに比べこちらの方がフレキシブルだししっくりくるなと思いました。

Routerを作ってみよう!

必要なlibraryはhistoryだけです。これはブラウザーWindow.historyとほぼ同じAPIですが、ブラウザーによる違いをなくすために、Window.historyを直接使わずこっちを使います。React Routerもhistoryを使っています。

まずはインストールします。

$ npm install --save history

次にメインのrouterを作ります。基本的な使い方は上記のリンクを参照して下さい。 URLが変わる度にhistory.listenが呼ばれます。 location.pathnameがその名の通りpathになるので、それを好きなように処理してReduxにdispatchします。

import createHistory from 'history/createBrowserHistory'
import store from '../store.js'

export const history = createHistory()

function handleNavigation(location, action) {
  // e.g., 'examples/123/' => ["examples", "123"]
  const pathList = location.pathname.split('/').filter(o => o !== '')
  // examplesをrootに指定
  const path = pathList[0] || 'examples'

  else if (path === 'examples') {
    store.dispatch({type: 'ROUTE_EXAMPLES'})
  }
  else if (path === 'guide') {
    store.dispatch({type: 'ROUTE_GUIDE'})
  }
  else if (path === 'projects') {
    store.dispatch({type: 'ROUTE_PROJECTS'})
  }
}

handleNavigation(history.location)
history.listen(handleNavigation)

reducerでそれぞれのアクションを処理します。ただのstatic pageの移動だったら以下のようにrouteを変えるだけで十分ですが、勿論ここで他のstateを変えることが出来ます。

function reducer(state, action) {
  switch (action.type) {
    case 'ROUTE_EXAMPLES': {
      return {
        ...state,
        route: 'examples',
      }
    }
    case 'ROUTE_GUIDE': {
      return {
        ...state,
        route: 'guide'
      }
    }
    case 'ROUTE_PROJECTS': {
      return {
        ...state,
        route: 'projects'
      }
    }
    // ...
  }
}

そしてrouteによってコンポーネントを切り替えます。

import React from 'react'
import store from '../store.js'
import { Link } from './my-router'

import Examples from './Examples'
import Projects from './Projects'
import Guide from './Guide'

function getChildren(route) {
  if (route === 'projects') {
    return <Projects/>
  }
  if (route === 'examples') {
    return <Examples/>
  }
  if (route === 'guide') {
    return <Guide />
  }
}

const App = React.createClass({
  componentDidMount: function() {
    store.subscribe(() => this.forceUpdate())
  },
  render: function() {
    const state = store.getState()
    const route = state.route
    const children = getChildren(route)

    return (
      <div>
        <nav className="navbar navbar-default">
          <div className="container-fluid">
            <div className="navbar-header">
              <Link to='/' className="navbar-brand">Geeklish</Link>
            </div>

            <div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
              <ul className="nav navbar-nav">
                <li><Link to='/examples'>Examples</Link></li>
              </ul>
              <ul className="nav navbar-nav">
                <li><Link to='/projects'>My Projects</Link></li>
              </ul>
              <ul className="nav navbar-nav">
                <li><Link to='/guide'>Guide</Link></li>
              </ul>
            </div>
          </div>
        </nav>

        {children}

        <footer>
        </footer>
      </div>
    )
  }
})

上記にLinkコンポーネントがありますが、これも基本的なものであれば簡単に作れます。React Routerと同じAPIにしています。

import React from 'react'

const isLeftClickEvent = (e) => e.button === 0
const isModifiedEvent = (e) => !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)

export const Link = React.createClass({
  handleClick: function(event) {
    if (event.defaultPrevented || isModifiedEvent(event) || !isLeftClickEvent(event)) {
      return
    }
    event.preventDefault()
    history.push(this.props.to)
  },

  render: function() {
    let props = Object.assign({}, this.props)
    props.onClick = this.handleClick
    props.href = this.props.to

    return (
      <a {...props}>{this.props.children}</a>
    )
  }
})

たったこれだけでrouterの完成です!

大規模なアプリになるともっとちゃんとしたコードを書かなければいけないですが、最初のうちはこれで十分です。

上記に書いた通りhistory APIブラウザーに入っており、それを使うだけで誰でもrouterが作れます。やはりフロントエンドエンジニアはブラウザーを知ることが重要だなというのを今回学びました。

僕が作ってるアプリはまだrouteが複雑じゃないし、ほぼSingle Page Applicationなので、このまま自家製routerを使っていきます。

Reactを使ってる方には是非一度routerを自分で作ることをオススメします。数時間で書けますし、React Routerや他のroutingのlibraryをより理解出来るようになります。

参考資料

https://news.ycombinator.com/item?id=12511419 http://beautifulcode.1stdibs.com/2016/09/20/redux-url-router/ http://jamesknelson.com/even-need-routing-library/ https://github.com/mjackson/history

Hamburger Menu VS Tab - 有名なアプリ15個のUIを調査!

icons_no_borders

こんにちは、エンジニアの建三です。最近はデザインの仕事が主になってきています。新規事業のモバイルアプリのプロトタイプをSketchInvisionでせっせと作る日々です。それに伴いUIデザインを最近勉強し始めたんですが、デザインってすごく面白いですね。

このモバイルアプリですが、素早く顧客のフィードバックを得る為にWebviewを使ってくれという指示がありました。Webviewというのは、アプリ上でWebページを表示するものです。FacebookとかTwitterのリンクをアプリでクリックすると、ブラウザーに飛ばずに、FacebookTwitterのアプリ内でWebページが見れますよね。要するにモバイルウェブサイトをWebviewで表示してアプリ化しようというものです。これだとアップデートする度にAppleGoogleに申請する必要がなくなります。

このアプローチを取る場合、iOSAndroid用に別々のUIを作るのが難しくなります。OSによってCSSを切り替える事は出来ますが、大幅な変更は面倒そうだし、それで開発期間が延びたら本末転倒です。

iOSAndroidの一番顕著な違いは、ナビゲーションの方法です。iOSはタブを使うのが一般的ですが、Androidだと「ハンバーガーメニュー」と呼ばれる左上の3本線のボタンを押してメニュー画面を開くのが一般的です。どちらかに絞りたいんですが、iOSハンバーガーは不自然だしAndroidでタブも不自然なのではという気がします。ただ普段僕はiPhoneしか使いませんし、普段使ってるアプリでもそんなにデザインに気を使った事がないのでどっちにすればいいか分かりません。

そこで、巷の有名なアプリはiOSAndroidでどっちのナビゲーションを採用してるか調べる事にしました!

対象のアプリは僕のiPhoneに入ってるアプリの中で有名なもの15個。

僕のNexus7にそれらを全部インストールしてAndroidも確かめました。

ナビゲーションのバリエーションは大きく分けて4つあったので、それぞれに分けて見てみましょう。

iOS - タブ、Android - ハンバーガ

対象アプリ: Dropbox, Trello, Evernote

これは一番僕が予想していたパターンです。

Dropbox

dropbox

Trello

iOS

trello_ios

Android

trello_android

Evernote

iOS

これはタブと呼ぶのかどうか微妙なラインですが、どちらかというとタブなのでこのカテゴリに入れました。

evernote_iphone

Android

evernote_android

iOS - ハンバーガー、Android - ハンバーガ

対象アプリ: Inbox, Google Calendar, Google Map, Slack, Uber, Kindle

これはGoogleのMaterial Designを使ったアプリが中心になります。

Inbox

inbox

Google Calendar

iOSだけになりますが、Androidもほぼ同じです。

google_calender

Google Map

Androidもほぼ同じです。

google_map_ios

Slack

slack

Uber

Androidもほぼ同じです。

uber

Kindle

iOS

kindle_ios

Android

OLYMPUS DIGITAL CAMERA

iOS - タブ、Android - 上タブ

対象アプリ: messenger, facebook, Twitter, Youtube

これはAndroidの場合タブが上に来るパターンです。

messenger

iOS

messenger_ios

Android

messenger_android

facebook

iOS

facebook_ios

Android

facebook_android

Twitter

iOS

twitter_ios

Android

twitte_android

YouTube

iOS

youtube_ios

Android

youtube_android

iOS - タブ、Android - タブ

対象アプリ: Airbnb, Instagram

僕の予想に反して、AndroidでもiOSと同じようにタブを使っているアプリがありました。

Airbnb

iOS

airbnb_ios

Android

airbnb_android

Instagram

instagram

まとめ

色んなバリエーションがあり、答えは一つじゃないというのを実感しました。タブは1タップで画面を切り替えられるので便利です。一方でハンバーガーメニューは、SlackのチャンネルやKindleのチャプターの様に多くのものを入れるのに向いてます。またGoogle MapやUberの様に地図がメインのアプリでもハンバーガーメニューが好まれるようです。

新規事業のアプリでは両方のプロトタイプを作ってみた結果タブの方が良いという意見が上がっています。最終的にどちらになるかは出来上がってからのお楽しみですね!

Androidの戻るボタン

おまけにAndroidの戻るボタンがどう処理されるかも調べました。ほとんどのアプリは戻るボタンが予想通り動いたのですが、幾つかのアプリが予想と違う動きをしました。以下のアプリの動作で戻るボタンが効かずにアプリが終了しました。

  • Google Calendar - 1日、1週間、1ヶ月などの表示の切り替え
  • Slack - チャンネルの切り替え
  • Dropbox - FilesからPhotosへなど、メニューの切り替え
  • YouTube - 上タブの切り替え

Google CalendarとSlackに関してはホーム画面を切り替えてるイメージなので何となく分かりますが、DropboxYoutubeは意外でした。アプリが終了しても開き直せばいいだけなので大した問題ではないのですが、戻れるに越したことはないと思います。

最後に

イタンジではプロトタイピングからコーディングまで何でもやりたいデザイナー・エンジニアを募集しています!