オフラインで現れる恐竜をプロ生ちゃんにする



この記事は、プロ生ちゃん Advent Calendar 2015 の 22日めです。

qiita.com

オフラインで現れるアイツ

「オフラインで現れる恐竜ゲーム」をご存知でしょうか。Google Chrome を、ネットワークに接続できない状態でページを開くと、恐竜が表示され、キーボードを押すとゲームが始まる、という隠し機能のことです。

f:id:takemaruhirai:20151222222338p:plain

これはこれで楽しいのですが、実は、PCを機内モードにすると、オフラインと判定される際にちょっとしたタイムラグがあることを発見してしまいました。つまり、

機内モードにする

f:id:takemaruhirai:20151222224437p:plain:w500

② どこかページを開く(アクセスできない、と出る)

f:id:takemaruhirai:20151223050934p:plain:w500

数秒、時間がかかる

④ オフラインモードになる(恐竜が表示される)

f:id:takemaruhirai:20151223050905p:plain:w500




これはチャンスです!

このスキに、恐竜をプロ生ちゃんに変えてしまいましょう

f:id:takemaruhirai:20151223063042p:plain

プロ生ちゃんスプライトを用意する

恐竜モードの画面を調べると、恐竜ゲームのスプライトが base64エンコードされた画像として埋め込まれていることが分かります。

f:id:takemaruhirai:20151222223025p:plain

画像のソースが src="data:image/png;base64,iVBOR..." となっていますね。

実際にこの画像データを取り出してみると、下のようなPNG画像になります。

f:id:takemaruhirai:20151222223217p:plain

この画像をベースに、プロ生ちゃんのドット絵を使って同じような画像を作ってみましょう。

こんなかんじ。

f:id:takemaruhirai:20151223063115p:plain

それを、base64 方式でエンコードします。 Ubuntu では、とくになにもインストールせず、base64 コマンドが使えます。

$ base64 probamachan-offline.png > base64.txt

テキストファイルの中身に、エンコードされた画像が文字列として入ってきますので、これを IMG タグの src属性に、

<img src="data:image/png;base64,(エンコードされた文字列)">

と入れてやればOKです。

あとは、JavaScript で DOM を操作し、恐竜のスプライトを指定している IMG タグを消して、同じID のプロ生ちゃんスプライトのIMGタグを挿入してやりましょう。 これを、ブックマークレットにします。ブックマークレットの中身は、以下のとおりです。

javascript:var p=document.getElementById('offline-resources');var d=p.children[0];p.removeChild(d);var a=document.createElement('img');a.class='added';a.id='offline-resources-1x';a.jstcache='0';a.src='data:image/png;base64,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';p.appendChild(a);

実行手順

では、実行手順です。

【事前】 ブックマークレットを仕込んでおく

f:id:takemaruhirai:20151223051113p:plain:w500

機内モードにする

f:id:takemaruhirai:20151222224437p:plain:w500

② どこかページを開く(アクセスできない、と出る)

f:id:takemaruhirai:20151223050934p:plain:w500

数秒、時間がかかる ので、このスキにブックマークレットをクリック

f:id:takemaruhirai:20151223051129p:plain:w500

④ オフラインモードになる(プロ生ちゃんが表示される)

f:id:takemaruhirai:20151223062411p:plain:w500

さあ、何かキーを押してください。プロ生ちゃんゲームの始まりです!

f:id:takemaruhirai:20151222225338p:plain:w500

f:id:takemaruhirai:20151222225345p:plain:w500

f:id:takemaruhirai:20151222225349p:plain:w500

デモ動画

こちらからどうぞ

さいごに

この動作確認は、Windows 10 にアップデートされてしまった PC で、機内モード使用という、とても狭い範囲でしか行っていません。 普通にオフラインで行おうとしても、タイムラグがなく難しいです。**LANケーブルを抜き、Wi-Fiを無効にした環境ではダメでした。

なお、私のPCでは機内モードにするとネットワークに不具合が出ることが多く、再起動を頻繁にせねばならず面倒です。誰か、機内モード以外でこれを実行できる方法をご存知でしたら教えてください。

ではでは。

プロ生ちゃんクソT時計



これは プロ生ちゃん Advent Calendar 2015 20日めの記事です。

qiita.com

プロ生ちゃんクソT時計を作りました。

プロ生ちゃんクソT時計とは、プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計のことです。下は、スクリーンショットです。

f:id:takemaruhirai:20151216231101p:plain

こちらからダウンロード可能です。

マスコットアプリ文化祭2015 にも作品登録させて頂いていおります。

mascot-apps-contest.azurewebsites.net

【前置き】そもそも「プロ生ちゃんクソT」とは何か

プロ生ちゃんにクソTを着せるムーブメントのことです。

nさんの以下のような投稿をきっかけに、2015年9月頃に、突発的に盛り上がりました。

ハッシュタグ#プロ生ちゃんクソT選手権」で検索すると、雰囲気を味わっていただけます。

詳しくは、こちらの記事を見てください。

pronama.azurewebsites.net

ジェネレーターもありますので、お手軽に作れます。今からでも参加、遅くありませんよ!!

クソTプロ生ちゃんジェネレーター

【前置き】そして「プロ生ちゃんクソT時計」とは何か

#プロ生ちゃんクソT選手権」を眺めながら、オノッチさんがぽつりと呟きました。

たぶん、そのタイトルからイメージされるのって、かつてブームになった「美人時計」のような感じの何か、なのですよね。

美人時計

bijin-tokei(美人時計) 公式ウェブサイト

さすがに、そんな豪華なものは手間もコストもかかるうえに技術的にも大変で、簡単には作れそうにないので皆、躊躇したのかもしれませんしそもそも見ていなかったかスルーしていたのかもしれません。

待てど暮らせど、誰かが空気を読んで「プロ生ちゃんクソT時計」なるものを作る気配がありません。

ですが、私はなんとなく頭の片隅にひっかかっていました。

【前置き】なぜ、自分でつくろうと思ったか

気になっていた、というのもひとつの理由ではあります。

ですが、実はもうひとつのきっかけがありました。それが、プロ生ちゃんのこのツイートです。

で、これに対して「React興味ある」と軽い気持ちで呟いてしまったのですね。そしたらプロ生ちゃんから!

これは着手しないパターン と言われてしまったではありませんか。

もう、これは遅くとも年内には React に着手するぞ、と心に誓ったのでした。

そこに、誰も手を付けない「プロ生ちゃんクソT時計」案件が転がっている・・・

そうだ、これを React 入門の材料にしてみてはいかがだろう、などと考えだすのは時間の問題でした。

【技術的,全体】アプリケーションの構成

要件と仕様

さて、やっと技術的な話です。クソT時計とは何でしょうか?(技術的に)

まず、要件、というと大げさですが。何を達成すればよいのか。それは一言で「プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計」であることです。

満たすための仕様として、今回のアプリケーションでは以下のようなことを行っています。

バックエンド側(サーバーサイド)
  1. #プロ生ちゃんクソT選手権 に投稿された画像とIDなどの情報を定期的に取得する
  2. 取得した画像/ID情報をDBに保存する
  3. クライアント側からリクエストがあった場合、DBに保存したID情報のリストを返却する
  4. クライアント側からリクエストがあった場合、DBに保存したクソT画像を、サイズの最適化・背景の透明化処理をしたうえで返却する
フロントエンド側(クライアントアプリケーション)
  1. 時計を表示し、時刻に合わせて針を表示する
  2. サーバー側にクソTツイートのIDリストを要求し、メモリ上に保存しておく
  3. 定期的に、リストからランダムにひとつを選び、それに応じた画像をサーバー側に要求する
  4. サーバー側から返却された画像を表示する

全体の構成

アプリケーションの構成は以下のようになっています。

f:id:takemaruhirai:20151214082347p:plain

素材

【技術的,バックエンド】Twitter 画像を MongoDB に保存する

PythonTwitter からツイートの内容を取得し、画像を MongoDB に保存する方法は、過去の記事に書いていますので、興味のある方はそちらを参照してください。

ここでは、Twitter Search API を叩く部分だけ少し簡略化して書いておきます。

def search(params):
    session  = OAuth1Session("ConsumerKey", "ConsumerSecret", "AccessToken", "AccessTokenSecret")
    response = session.get("https://api.twitter.com/1.1/search/tweets.json", params = params)
    if response.status_code == 200:
        data = json.loads(response.text)
        return data["statuses"]
    return None

params = {
    "q" :  "%23プロ生ちゃんクソT選手権+exclude:retweets+filter:images",
    "count" : 100,
    "result_type" : "recent"
}

for tweet in search(params):
    # DB に Tweet ID が存在するかを確認し、
    # なければ、ツイートの内容をDB に格納する

これを使って、「#プロ生ちゃんクソT選手権 の投稿を取得し、MongoDBに保存する」動作を cron で10分ごとに実行する bot 化してあります。

【技術的,バックエンド】リクエストされた画像をレスポンスとして返す

フロントエンドのアプリケーションからHTTPリクエストが来た場合、バックエンドでは以下のようなレスポンスを返すようにしています。

  1. DBに保存されたツイートの全リスト(IDを含んでいる)
  2. IDに対応した、DBに保存された当該のツイート(画像以外)
  3. IDに対応した画像

1と2は MongoDBのデータを加工して、JSONとして返せばいいだけなので難しくないのですが、画像を返す処理がなかなか大変でした。一番の難点は、プロ生ちゃん部分をはみ出して文字や絵が描かれている ケースで、この部分を画像に残しつつ、背景を透過にする ことをしています。

↓こういう画像を、プロ生ちゃんと外の絵 以外 を透明にしなくてはいけない。

f:id:takemaruhirai:20151214063415p:plain:h200

で、どうしたかといいますと、プロ生ちゃんをくり抜いた形のマスク画像を作り、

f:id:takemaruhirai:20151214064258p:plain:h200

画像処理エンジン Pillow で、自作のマスク処理 を行いました。単純にマスク処理をすると、プロ生ちゃん以外全部透明になってしまうのですが、これを回避するために次のようなことをしています。

  • マスクがかかっていない部分は、そのまま透過処理をする
  • マスクがかかっている部分は、
    • 元画像の色が #FFFFFF (すべてがFになってる)ならば、透過処理をする
    • 元画像の色がそれ以外ならば、透過処理をしない

という、ちょっとややこしい処理を経ることで、目的の画像が得られます。

この部分のコードです。

from PIL import Image

# base が元画像, mask がマスク画像(それぞれ Pillow の Imageオブジェクト)
def kusot_mask(base, mask):
    pixels_in  = base.load()
    w ,h       = base.size
    pixels_msk = mask.load()
    output     = Image.new("RGBA", (w,h), (255,255,255,0))
    pixels_out = output.load()
    for x, y in product(range(w), range(h)):
        r, g, b, a = pixels_in[x,y]
        if pixels_msk[x,y] != 255 and r > 250 and g > 250 and b > 250: #pixels_in[x,y] == (255,255,255,255)
            pixels_out[x,y] = (r, g, b, pixels_msk[x,y])
        else:
            pixels_out[x,y] = pixels_in[x,y]
    return output

MongoDBから画像を取り出してレスポンスとして返す処理自体については、過去の記事がありますのでよろしければ参照してください。

【技術的,フロントエンド】React + Electron + Babel

デスクトップアプリケーションを作成しつつ、Webと技術を共有できるということで、Electronアプリを作ることにしました。アプリケーションの中身自体は、前述のとおり、React を使って書いていくのですが、どうせならば、と ES6 でコーディングすることにしました。ES6 のすべての機能をブラウザが対応しているとは限りませんので、Babel を用いて ES5 に変換します。

必要なものは、

package.json の dependencies はこんなかんじ。

{
  "devDependencies": {
    "babelify": "^6.1.3",
    "browserify": "^11.0.1",
    "electron-packager": "^5.1.1",
    "electron-prebuilt": "^0.34.3",
    "gulp": "^3.9.0",
    "gulp-pleeease": "^2.0.1",
    "gulp-sass": "^2.1.0",
    "rcedit": "^0.3.0",
    "vinyl-source-stream": "^1.1.0"
  },
  "dependencies": {
    "moment": "^2.10.6",
    "react": "^0.14.1",
    "react-dom": "^0.14.1",
    "superagent": "^1.4.0"
  },
}

また、エントリーポイントとして index.js を指定しています。

{
  "main": "index.js",
}

script は Electron に合わせて以下のようになっています。

{
  "scripts": {
    "start": "node_modules/.bin/electron --enable-transparent-visuals . ",
    "pack" : "electron-packager . pronama-kuso-t  --out=exe --arch=x64 --platform=win32 --version=0.35.0 --overwrite --ignore=exe --prune --ignore=node_modules/.bin  --icon=img/icon-win.ico"
  },
}

※ この --icon=img/icon-win.ico に苦労したのですが、その話はこちら

gulpfile.js の build の部分はこうです。Babel は React の jsx ファイルを js に変換しつつ、ES6/7 のコードを ES5 に変換してくれちゃうので凄いのです。

gulp.task('build', function () {
    browserify({
        entries: 'index.jsx',
        extensions: ['.jsx'],
        debug: true
    })
        .transform(babelify)
        .bundle()
        .pipe(source('bundle.js'))
        .pipe(gulp.dest('dist/js'));
});

ルートディレクトリの構成はこんなかんじです。

  • src - React コンポーネントソースコード(ES6/7, jsx)
  • dist - 変換されたファイルの配置先(ES5, js)
  • img - 画像ファイル
  • exe - Electron によってビルドされたアプリケーションの配置先

(index.js と index.jsx と index.html ができちゃってますが、気にしない。)

実行の流れですが、

  • package.json に指定してあるとおり、まず Electron 用の index.js が呼ばれます。
  • Electron が UI として index.html を実行します。
  • index.html から open.js が呼ばれて、Electron のウインドウの位置を保存/回復したりしています。
  • index.html から React のindex.jsx が呼ばれます。
  • index.jsx から、React の子コンポーネントが呼ばれます。

以下にこのあたりのソースを書いておきます。

index.js (Electron)

var app  = require('app');
var Menu = require('menu');
var Tray = require('tray');
var BrowserWindow = require('browser-window');
var currentURL = 'file://' + __dirname + '/index.html';

require('crash-reporter').start();

var mainWindow = null;

app.on('window-all-closed', function() {
    if (process.platform != 'darwin') {
        app.quit();
    }
});

app.on('ready', function() {

    Menu.setApplicationMenu(menu);

    var appIcon = new Tray(__dirname + '/img/icon.png');
    appIcon.setToolTip('プロ生ちゃんクソT時計');

    mainWindow = new BrowserWindow({
        width : 320,
        height: 380,
        transparent: true,
        frame      : false,
        resizable  : false,
        'always-on-top': true,
        "skip-taskbar": true,
        "show": false
    });
    mainWindow.loadUrl(currentURL);
    mainWindow.on('closed', function() {
        mainWindow = null;
    });
});

open.js (Electron)

var remote = require('remote');
var win    = remote.getCurrentWindow();
if (localStorage.getItem("windowPosition")) {
    var pos = JSON.parse(localStorage.getItem("windowPosition"));
    win.setPosition(pos[0], pos[1]);
}
win.on("close", function() {
    localStorage.setItem("windowPosition", JSON.stringify(win.getPosition()));
});
win.show();

index.html (React)

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>プロ生ちゃんクソT時計</title>
    <link rel="stylesheet" type="text/css" href="dist/css/bundle.min.css">
</head>
<body>
    <div id="content"></div>
    <script src="open.js" type="application/javascript"></script>
    <script src="dist/js/bundle.js" type="text/javascript"></script>
</body>
</html>

index.jsx (React)

import React       from 'react';
import ReactDOM    from 'react-dom';
import Application from './src/jsx/app';

ReactDOM.render(
    <Application />,
    document.getElementById("content")
);

【技術的,フロントエンド】プロ生ちゃんの画像を切り替える

アプリケーションは、いまのところ4つのコンポーネントで構成されています。

export default class Application extends React.Component {
    render() {
        return (
            <div>
                <TransparentPanel />
                <KusoTLogo />
                <PronamaChan />
                <AnalogClock />
            </div>
        )
    }
}

それぞれの説明は、以下のようになっています。

  • TransparentPanel ・・・ 透明な背景
  • KusoTLogo ・・・ タイトルロゴ
  • PronamaChan ・・・ クソTプロ生ちゃん
  • AnalogClock ・・・ アナログ時計

肝心のプロ生ちゃんを表示しているコンポーネント PronamaChan では以下のようなことをしています。IDのリストの取得は初回に1回のみ行われ、1分毎に発生するバックエンドとの通信は画像の取得のみとなっているのがポイントです。

pronama-chan.jsx

import React from 'react';
import moment from "moment";
import request from 'superagent';
import BaseComponent from './base-component';

export default class PronamaChan extends BaseComponent {
    init(){
        // このコンポーネントのステートです。
        this.state = {
            timer : null, twid  : "639820515272605697"
        };
        // 非同期に呼び出されるメソッドで this を有効にするための処理です
        // 実際の動作は BaseComponent(後述)に書かれています
        this._bind('tick','fetchKusoTData', 'openLink');
        // ツイートのリストを保存しておくための変数
        this.stock = null; 
    }
    // コンポーネント開始時に1秒毎のタイマーを起動、tick を実行します
    componentDidMount(){
        let timer = setInterval(this.tick, 1000);
        this.setState({ timer : timer });
    }
    // コンポーネント終了時にタイマーを破棄します
    componentWillUnmount(){
        clearInterval(this.state.timer);
        this.setState({ timer : undefined });
    }
    // 1秒毎に実行されます
    tick(){
        let m = moment();
        // 時刻が xx:59 だったら(1分毎に)、ツイートのリストを取得する処理(fetchKusoTData)を実行します
        if (m.seconds() == 59){
            // ツイートのリストが 変数stock に保存済みの場合、バックエンドへのリクエストは行わない
            if (!!this.stock){
                this.fetchKusoTData(0, { body : this.stock });
            }
            // ツイートのリストが未保存(つまり初回)の場合
            else {
                request
                    .get('http://api.felis-catus.net/pronama/kuso_t') // バックエンドからツイートのリストを取得
                    .end(this.fetchKusoTData);
            }
        }
    }
    // ツイートデータを取得する
    fetchKusoTData(err, res){
        if (err) throw err;
        let data = res.body;
        this.stock = data;
        let choice = data[ Math.floor( Math.random() * data.length ) ]; // ツイートのリストからランダムにひとつ選ぶ
        this.setState({ twid  : choice.twid + '' }); // 選ばれたツイートの ID をステートに保存
    }
    // ステートにある ID からURLを構成し、バックエンドに画像を要求
    get imageURL(){
        return "http://api.felis-catus.net/pronama/kuso_t/" + this.state.twid + "/image";
    }

    render(){
        return (
            <div className="pronama-chan">
                <img src={ this.imageURL } />
            </div>
        )
    }
}

base-component.jsx

export default class BaseComponent extends React.Component {
    constructor(props){
        super(props);
        this.init();
    }
    _bind(...methods) {
        methods.forEach( (method) => this[method] = this[method].bind(this) );
    }
}

【技術的,フロントエンド】React で 時計の SVG をアニメーションさせる

つぎは、時計です。

analog-clock.jsx(抜粋)

import React  from 'react';
import moment from "moment";
import BaseComponent from './base-component';

export default class AnalogClock extends BaseComponent {
    init(){
        let m = moment();
        this.state = {
            h     : m.hours(),
            m     : m.minutes(),
            s     : m.seconds(),
            timer : null
        };
        this._bind('tick');
    }

    // コンポーネント開始時に1秒毎のタイマーを起動、tick を実行します
    componentDidMount(){
        let timer = setInterval(this.tick, 1000);
        this.setState({ timer : timer });
    }
    // コンポーネント終了時にタイマーを破棄します
    componentWillUnmount(){
        clearInterval(this.state.timer);
        this.setState({ timer : undefined });
    }
    // 1秒毎に実行されます。ステートに現在時刻の時・分・秒をセットします
    tick(){
        let m = moment();
        this.setState({ h : m.hours(), m : m.minutes(), s : m.seconds() });
    }
    // 時計の針の角度を計算します。val は現在の時or分or秒、maxは最大値(時=24,分=60,秒=60)
    // 元の時計SVGの針の向きが12時方向ではないばあい、start_degree を指定します(3時方向=90)
    toDegree(val, max, start_degree){
        let degree = (val * 360 / max) - start_degree;
        if (degree < 0)
            degree = 360 + degree;
        else if (degree > 360)
            degree = degree - 360;
        return degree;
    }

    render(){
        return (
            <div className="analog-clock">
                <svg version="1.0" width="100%" height="100%" viewBox="0 0 400 400" id="svg2">
                    <defs id="defs4">
                        <filter id="filter3945">
                            <feGaussianBlur stdDeviation="2.4871406"/>
                        </filter>
             (中略)
                    </defs>
                    <g transform="translate(-278.34,-412.89)" id="layer1">
                       (中略)
                        <path
                        d="m 477.97798,471.84594 -5.55309,139.24123 2.24695,-4.9e-4 -2.41394,24.2058 11.1021,-0.002 -2.41574,-24.20333 2.58796,0.001 -5.55424,-139.24198 z"
                        id="rect3080"
                        style={{"fill":"#000000","fillRule":"evenodd"}}
                        transform={ "rotate(" + this.toDegree(this.state.m + (this.state.s / 60), 60,  0) + ",479,608)" }/>

                        <path
                        d="m 578.0952,609.05293 -104.00977,-5.66807 0.008,2.4317 -18.08625,-2.55309 0.0408,12.0361 18.06893,-2.68067 0.009,2.80207 103.96898,-6.36804 z"
                        id="path3087"
                        style={{"fill":"#000000","fillRule":"evenodd"}}
                        transform={ "rotate(" + this.toDegree(this.state.h + (this.state.m / 60), 12, 90) + ",479,608)" }/>

                        <line x1="477" y1="480" x2="477" y2="650" style={{"stroke":"rgb(149,21,44)","strokeWidth":2}}
                        transform={ "rotate(" + this.toDegree(this.state.s                      , 60,  0) + ",479,608)" }/>
                      (中略)
                    </g>
                </svg>
            </div>
        )
    }
}

何をやっているかというと、SVG で描かれた時計 の 長針・短針・秒針を、React Component のステートの値(現在時刻)によって、rotate させているのです。 React では、SVGも仮想DOMとして扱うことが可能なのですね!そして、その描画を state に依ることができるのです。

SVGpath タグには transform という属性を設定することができるのですが、これに rotate(回転)という値を設定することができるのです。これをたとえば下記のように、state と絡めてやれば、state の値が即SVGの描画に反映されるという訳です。

// 座標(479,608)を起点に 長針を this.state.m(分) に従って(this.state.s(秒)も加味)、正しい角度(toDegreeで計算)で描画する
transform={ "rotate(" + this.toDegree(this.state.m + (this.state.s / 60), 60,  0) + ",479,608)" }

この方法によって、いかにもあっさりと、しかも直感的に、時計の針のアニメーションが完成してしまいました。

ちなみに、時計のSVGは、こちらからダウンロードできます。

f:id:takemaruhirai:20151214095851p:plain:h200

【あとがき】React と アニメーションについて

React を実際に触るまで、アニメーションとは相性が悪いのだろうな、という先入観がありました。実際、仮想DOMと jQuery など実際にDOMを弄る方法を用いたアニメーションとは相性が悪いのだろうといまでも思っています。

ですが、現在、アニメーションのあり方によっては、逆に React とアニメーションはとても相性が良いのではないかという気がしています。その可能性の端緒を垣間見たのが、今回のプロ生ちゃんクソT時計だったのです。今回、時計部分に用いた SVG を state に応じて描画する方法などがその例です。

かつての Flash に変わって、ウェブ上でのアニメーションは、SVGCanvasが中心となり始めています(CSSなどもありますが)。Canvashは完全にAPIで描画していくタイプなのでステートをもたせるのは難しいかもしれません。ですが、SVGはそれ自体が状態を持つ、画像のフォーマットとも言えるものです。このような存在は、React の考え方とよく馴染むのではないでしょうか。

React も SVG も、まだまだ勉強不足ではありますが、今後もなにか機会を見つけては、この組み合わせを試してみたいと思っています。

長文、読んでいただき、ありがとうございました。

Uncaught Syntaxerror: Unexpected token u

概要

このエラーメッセージが出たのが、 localStorage に undefined を保存しちゃった ことが原因だったよ、、というお話。

さらに、undefined を JSON.parse したら、再現できます。

経緯

このあいだ、Javascriptでコードを組んでいて、以下のようなエラーメッセージに遭遇しました。

f:id:takemaruhirai:20151216111842p:plain

処理がここで止まって、突如、総ての動作が停止する事態に焦ったのですが、何しろエラーメッセージの情報が少なすぎる 。

なんだ u って

しかも、gulpで Electron + React 環境をビルドしていたためか、正確なエラーの位置も分からない状態でした。 コードの中をあちこち「u」で検索しましたが、そんな変数は見つからず。 うっかり入力ミスで「u」を書き込んでいた、ということもありませんでした。

仕方なく「Uncaught Syntaxerror: Unexpected token u」で検索すると、次のような情報に出会いました。

2013-04-21

  • "uncaught syntaxerror unexpected token u"と出る
    • コールバックと引数が一致していない。succcesssなのにcomplateで使う引数を使っているとか変数名が間違っている

コールバック?? と思いつつも、コールバック関数を利用している箇所を探しましたが、エラーを起こすような箇所はすぐに見つかりませんでした。 ただ、「引数がおかしい」という見当をつけることにはなりました。

そこで、最近追加した「引数を扱っている」コードをコメントアウトするなどして場所を絞り込んでいくと、見つかりましたよ、原因

以下に、それを再現したコードを掲載します。

obj= {};

localStorage.setItem("Something", JSON.stringify(obj.notCreated));

if (localStorage.getItem("Something")){
    var loadedItem = JSON.parse(localStorage.getItem("Something"));
    console.log(loadedItem);
}

これを HTML に埋め込んで実行すると「Uncaught Syntaxerror: Unexpected token u」が再現できるはずです。

このケースの場合は明らかですが、localStorageundefined が入ってしまっています。

f:id:takemaruhirai:20151216114534p:plain

Somerhing という変数自体が undefined ならばもっと分かりやすいエラーメッセージがでて、localStorageへの格納自体ができないのですが、 Something というオブジェクトのキーが udefined の場合、localStorage に undefined が保存できてしまう ということが分かりました。

localStroage にデータを保存/読込するときは、JSON.stringify/JSON.parse するのがお作法となっていますが、 getItem のほうで読み込んだデータが undefined だと、当然、JSON.parse のところで不都合が生じるわけですね。 これが今回のケースの原因でした。

つまり、もっとも端的に「Uncaught Syntaxerror: Unexpected token u」を再現するならば、

console.log(JSON.parse(undefined));

これで十分です。このままのコードを書くケースはまず無いと思いますが、localStorageにオブジェクトを保存する際にはご注意ください。

プライバシーはプロ生ちゃんが護る!

この記事は、プロ生ちゃん Advent Calendar 2015 の13日目の記事です。

www.adventar.org

プライバシーの護り方

プロ生のサイトで以前、Microsoft の Project Oxford Face API が紹介されていましたね。

pronama.azurewebsites.net

この顔認識の機能を使って、人物の写っている写真に、プロ生ちゃんの顔(ステッカーとは微妙に変えてます)を重ねて、人物のプライバシーを護ってあげましょう。これで、公安9課も捜査に手こずるはずです。

使用するプロ生ちゃん画像

f:id:takemaruhirai:20151213200928p:plain

使用方法

http://api.felis-catus.net/pronama/face_api/?url=(プライバシーを護りたい画像)

※ あまりサイズが大きすぎたり、小さすぎたりすると、顔認識に失敗するようです。人数にも制限がある模様。

実際にやってみた例

今回、サンプルとして、このようなプライベートな家族写真を使用しました。これはいけません。顔がバレては困りますね。

https://www.whitehouse.gov/sites/default/files/image/12152011-family-portrait-high-res.jpg

https://www.whitehouse.gov/sites/default/files/image/12152011-family-portrait-high-res.jpg

そこで、下記のアドレスの url= の後に、画像のURLを補完し、ブラウザのアドレス欄に入れてみましょう。

http://api.felis-catus.net/pronama/face_api/?url=https://www.whitehouse.gov/sites/default/files/image/12152011-family-portrait-high-res.jpg

結果は、以下のようになりました。これで安心ですね!

f:id:takemaruhirai:20151213211828j:plain

追記

ほんとは動画GIFにして、くるくる回したいのです〜。それはいずれまた・・・

f:id:takemaruhirai:20151213203153g:plain

追記2

Guyモードを追加しました。クエリ文字列に mask=guy を追加すると・・・これはっ!?

f:id:takemaruhirai:20151213220031j:plain

(政治的に問題のない範囲で遊んでください)

Linux 開発環境で Windows 用の Electron アプリを作る場合のアイコンについて

はじめに

この記事では、Electronアプリケーションの作成方法を最初からは説明しません。下記の記事などが大変役に立ちますので、入門の方はこちらをお勧めします。

30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまで - Qiita

もう少し詳しく、ひと通りの過程をなぞっておられる記事でしたら、次のようなものをお勧めします。

Electronでデスクトップウィジェットを作るまで - Qiita

結論を急ぎたい方は

こちらへどうぞ

ビルドした .exe ファイルにアイコンを設定したい!

本題です。 アプリケーションをそれなりに作成したら、アイコンを設定したくなるのが人間の性というものです。 Windows だったら .exe ファイルに好きな画像を表示させたいですよね?

ところが、私の開発環境が Ubuntu であったばっかりに、これが意外に手間でした。そして、これに関する情報がとても少なく、あちらこちらの記事にバラけていたため、まとめようと考えました。

まず、こちらのブログ。アイコンについて記載があるのですが、

qiita.com

さて、恐らく皆さんが一番カスタマイズしたいのがこれでしょう。 アプリ自体のアイコンの変更です。デフォルトではElectronになっています。 これを変更するのは、実はとても簡単です。

と書かれていて、夢がひろがりんぐです。なるほど、Macでは icon.icns というファイルを用意して electron-packager にオプションを指定すればいいのか。なるなる。

$ electron-packager . sample --platform=darwin--arch=x64 --version=0.30.0 --icon=images/icon.icns

なら、Windowsも似たようにすれば、・・・とはいきませんでした。同記事にも、

ただ、Windows版アプリだとそのままでは上手くいかないようです。

と書かれています・・・簡単なのはMac用アプリの話だったのですね。

さて、別の記事には、丁寧に Windows の場合のオプション指定方法を記載してくださっています。

nulab-inc.com

f:id:takemaruhirai:20151212104956p:plain

なんだか、これでうまく行きそうですね?

残念。実は、これでうまく行くのは 開発環境がWindowsの場合 のようです。

あきらめて、公式ドキュメントを読みましょう

www.npmjs.com

icon - String
Currently you must look for conversion tools in order to supply an icon in the format required by the platform:
OS X: .icns
Windows: .ico (See below for details on on-Windows platforms)
Linux: this option is not required, as the dock/window list icon is set via the icon option in the BrowserWindow contructor. Setting the icon in the file manager is not currently supported.

on-Windows は non-Windows のミスでしょう。細かいことは気にせず、下を見ろ、というので下を見ましょう。なになに。

Building Windows apps from non-Windows platforms
Building an Electron app for the Windows platform with a custom icon requires editing the Electron.exe file. Currently, electron-packager uses node-rcedit to accomplish this. A Windows executable is bundled in that node package and needs to be run in order for this functionality to work, so on non-Windows platforms, Wine needs to be installed. On OS X, it is installable via Homebrew.

つまり、カスタムアイコンを .exe ファイルに設定するには、今のところ、Electron は node-rcedit というパッケージを必要としているということのようです。この node-rcedit が Windows の実行ファイルをもっているため、必然的に Linux環境に Wine が入っている必要がある、ということのようです。

node-rcedit のサイトへ行ってみると、npmでインストールできるようですね。ただし、パッケージ名は node-rcedit ではなく rcedit のようですのでご注意を。 github.com

これで、ひと通りの前知識が揃ったようです。

実行手順のまとめ

前提

node や npm、Electron はすでに導入済みで、アプリも作成中であるものとします。

Wine をインストール

たとえば、Ubuntu の場合は apt を使用して以下のようなコマンドを実行します。

$ sudo apt-get install wine

かなり時間がかかると思います。余裕を見て実行してください。

node-rcedit をインストール

Electronアプリケーションのルートディレクトリに移動し、以下のコマンドを実行します。

$ npm install --save-dev rcedit

アイコンを作成

256x256 の .ico ファイルが最適のようですね。 画像編集ソフトなどを用いてお好きなアイコンファイルを用意してください。

f:id:takemaruhirai:20151212113555p:plain:w256

electron-packager を実行

作成したアイコンファイルを icon.ico とし、Electronアプリケーションの images ディレクトリに格納してあるとします。 sample というパッケージ名でビルドするとすると、下記のようなコマンドを実行することになります。 パッケージ化の際の細かいオプション等は、他の方の記事を参照してください。

$ electron-packager . sample --platform=win32 --arch=x64 --version=0.30.0 --icon=images/icon.ico

これで、Linux環境でWindows用のElectronアプリケーションがアイコン付きで作成できます。

f:id:takemaruhirai:20151212115354p:plain

Python3 + Bottle + Pillow でレスポンスとして画像を返す

とりあえず結論

Python3 で画像を取り扱うには、Pillow(PILの派生版, Python3対応)を使うと良いようです。

python-pillow.github.io

サーバー側で作成・処理した Pillow のオブジェクトを、HTTPレスポンスで画像としてクライアントに表示させるには、次のようにします。 ポイントは、Pillow オブジェクトの保存先に BytesIO オブジェクトを指定している点です。

from bottle import default_app, route, HTTPResponse
from io     import BytesIO
from PIL    import Image
import requests

application = default_app()

@route('/route/to/your/image/shown')
def show_image():

    # URLから画像を開く
    image_url     = "path/to/target/image"
    response      = requests.get(image_url)
    pillow_object = Image.Open(BytesIO(response.content))
    
    # -- ここで pillow_object に対して何らかの処理 --
    
    # HTTPレスポンスを作成する
    
    output = BytesIO()
    pillow_object.save(output, format='png')
    
    res = HTTPResponse(status=200, body=output.getvalue())
    res.set_header('Content-Type', 'image/png')
    return res

サーバーでは wsgi 対応フレームワークとしてBottle を使用しています。

bottlepy.org

クライアント側では、以下のようにして呼び出すことができます。

<img src="/route/to/your/image/shown"/>

Python3 と StringIO, BytesIO

ところで、ネットで上記の方法を調べようとして適当なキーワードで検索すると、以下のような回答にぶつかります。

See Image.save(). It can take a file object in which case you can write it to a StringIO instance. Thus something like:

output = StringIO.StringIO()
base.save(output, format='PNG')
return [output.getvalue()]

python imaging library - How to return in-memory PIL image from WSGI application - Stack Overflow

画像イメージを StringIO に保存すると良い、ということですね。ところが、これは Python 3.x ではうまくいきません。なぜかというと、

The StringIO and cStringIO modules are gone. Instead, import the io module and use io.StringIO or io.BytesIO for text and data respectively.

python - How to use StringIO in Python3? - Stack Overflow

What’s New In Python 3.0 — Python v3.0.1 documentation

つまり、Python 2 で有効であった StingIOcStyingIO は、Python 3 では撤廃されて、代わりに io.StringIOio.BytesIO になったことが原因です。 そこで 上記のStackOverflow の回答のように、

try:
    from StringIO import StringIO
except ImportError:
    from io import StringIO

StringIO.StringIO の代わりに io.StringIO で処理しようとしても、今回のケースではやはりうまく行きません。

公式サイトのドキュメントを読むとわかるのですが、画像などバイナリファイルを扱うには、bytes オブジェクトに対応した io.BytesIO でなければいけないのですね。 StringIO.StringIOio.StringIO の名前が似ているのでややこしいのですが、Python3 ではより適切に役割が分離されたということでしょう。

テキスト I/O は、 str オブジェクトを受け取り、生成します。すなわち、背後にあるストレージがバイト列 (例えばファイルなど) を格納するときは常に、透過的にデータのエンコード・デコードを行ない、オプションでプラットフォーム依存の改行文字変換を行います。

バイナリー I/O (buffered I/O とも呼ばれます) は bytes オブジェクトを受け取り、生成します。エンコード、デコード、改行文字変換は一切行いません。このカテゴリのストリームは全ての非テキストデータや、テキストデータの扱いを手動で管理したい場合に利用することができます。

io --- ストリームを扱うコアツール — Python 3.10.0b2 ドキュメント

MongoDB に画像を保存するときに BytesIO を使用するのも同様の理由と思われます。

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

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

MongoEngine とは?

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

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

mongoengine.org

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

MongoEngine の使い方

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

from mongoengine import connect

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

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

from mongoengine.document import Document
from mongoengine import fields

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

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

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

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

f:id:takemaruhirai:20151109215536p:plain

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

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

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

MongoEngine User Documentation — MongoEngine 0.24.0 documentation

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

kitanokumo.hatenablog.com

MongoDBに画像を保存するには

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

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

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

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

3. API Reference — MongoEngine 0.24.0 documentation

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

from mongoengine.document import Document
from mongoengine import fields

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

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

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

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

mongoengine.fields.ImproperlyConfigured: PIL library was not found

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

stackoverflow.com

Pillow — Pillow (PIL Fork) 9.0.1 documentation

android-memo.hatenablog.jp

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

$ pip install pillow

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

stackoverflow.com

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

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

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

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

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

stackoverflow.com

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

qiita.com

stackoverflow.com

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

from io import BytesIO
import requests

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

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

tw.save()

f:id:takemaruhirai:20151109232228p:plain

ポイントは、

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

といったところです。

追記

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

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

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

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

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

Managed Cloud, Cloud Migration and Adoption – Carbon60

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

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

from PIL import Image
from mongoengine import connect

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

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

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

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

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

from mongoengine import connect

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


from mongoengine.document import Document
from mongoengine import fields

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


from requests_oauthlib import OAuth1Session
import json

class Timeline:

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

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


from io  import BytesIO
import requests

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

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


from PIL import Image

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

画像が MongoDB に保存される

MongoDB から画像を取得

f:id:takemaruhirai:20151110000714p:plain