オフラインで現れる恐竜をプロ生ちゃんにする
この記事は、プロ生ちゃん Advent Calendar 2015 の 22日めです。
オフラインで現れるアイツ
「オフラインで現れる恐竜ゲーム」をご存知でしょうか。Google Chrome を、ネットワークに接続できない状態でページを開くと、恐竜が表示され、キーボードを押すとゲームが始まる、という隠し機能のことです。
これはこれで楽しいのですが、実は、PCを機内モードにすると、オフラインと判定される際にちょっとしたタイムラグがあることを発見してしまいました。つまり、
① 機内モードにする
② どこかページを開く(アクセスできない、と出る)
③ 数秒、時間がかかる
④ オフラインモードになる(恐竜が表示される)
これはチャンスです!
このスキに、恐竜をプロ生ちゃんに変えてしまいましょう!
プロ生ちゃんスプライトを用意する
恐竜モードの画面を調べると、恐竜ゲームのスプライトが base64 にエンコードされた画像として埋め込まれていることが分かります。
画像のソースが src="data:image/png;base64,iVBOR..." となっていますね。
実際にこの画像データを取り出してみると、下のようなPNG画像になります。
この画像をベースに、プロ生ちゃんのドット絵を使って同じような画像を作ってみましょう。
こんなかんじ。
それを、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);
実行手順
では、実行手順です。
【事前】 ブックマークレットを仕込んでおく
① 機内モードにする
② どこかページを開く(アクセスできない、と出る)
③ 数秒、時間がかかる ので、このスキにブックマークレットをクリック!
④ オフラインモードになる(プロ生ちゃんが表示される)
さあ、何かキーを押してください。プロ生ちゃんゲームの始まりです!
デモ動画
こちらからどうぞ
オフラインで走るプロ生ちゃん デモ動画https://t.co/I3XS9Bqhe1 pic.twitter.com/jBONXvCtWW
— たけまる (@felis_catus_) 2015年12月22日
さいごに
この動作確認は、Windows 10 にアップデートされてしまった PC で、機内モード使用という、とても狭い範囲でしか行っていません。 普通にオフラインで行おうとしても、タイムラグがなく難しいです。**LANケーブルを抜き、Wi-Fiを無効にした環境ではダメでした。
なお、私のPCでは機内モードにするとネットワークに不具合が出ることが多く、再起動を頻繁にせねばならず面倒です。誰か、機内モード以外でこれを実行できる方法をご存知でしたら教えてください。
ではでは。
プロ生ちゃんクソT時計
これは プロ生ちゃん Advent Calendar 2015 20日めの記事です。
プロ生ちゃんクソT時計を作りました。
プロ生ちゃんクソT時計とは、プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計のことです。下は、スクリーンショットです。
こちらからダウンロード可能です。
マスコットアプリ文化祭2015 にも作品登録させて頂いていおります。
mascot-apps-contest.azurewebsites.net
【前置き】そもそも「プロ生ちゃんクソT」とは何か
プロ生ちゃんにクソTを着せるムーブメントのことです。
nさんの以下のような投稿をきっかけに、2015年9月頃に、突発的に盛り上がりました。
クソTプロ生ちゃん pic.twitter.com/XXwZbTOhfb
— n (@NKDTR) 2015年9月4日
ハッシュタグ「#プロ生ちゃんクソT選手権」で検索すると、雰囲気を味わっていただけます。
詳しくは、こちらの記事を見てください。
ジェネレーターもありますので、お手軽に作れます。今からでも参加、遅くありませんよ!!
【前置き】そして「プロ生ちゃんクソT時計」とは何か
「#プロ生ちゃんクソT選手権」を眺めながら、オノッチさんがぽつりと呟きました。
#プロ生ちゃんクソT選手権 の画像がたくさん増えてて満足。
— オノッチ (@onotchi_) 2015年9月7日
多分これから、#プロ生ちゃんクソT選手権 で投稿された画像を拾い上げて定期的に画像を切り替え表示する「プロ生ちゃんクソT時計」とかが作られていくに違いないんだ...! #他力本願
たぶん、そのタイトルからイメージされるのって、かつてブームになった「美人時計」のような感じの何か、なのですよね。
さすがに、そんな豪華なものは手間もコストもかかるうえに技術的にも大変で、簡単には作れそうにないので皆、躊躇したのかもしれませんしそもそも見ていなかったかスルーしていたのかもしれません。
待てど暮らせど、誰かが空気を読んで「プロ生ちゃんクソT時計」なるものを作る気配がありません。
ですが、私はなんとなく頭の片隅にひっかかっていました。
【前置き】なぜ、自分でつくろうと思ったか
気になっていた、というのもひとつの理由ではあります。
ですが、実はもうひとつのきっかけがありました。それが、プロ生ちゃんのこのツイートです。
React.js みんな使ってる? 今からでも遅くない! React事始め http://t.co/2ogbXqWQa3 @SlideShareさんから
— プロ生ちゃん(暮井 慧)🍍 (@pronama) 2015年6月16日
で、これに対して「React興味ある」と軽い気持ちで呟いてしまったのですね。そしたらプロ生ちゃんから!
@felis_catus_ いつやるの!
— プロ生ちゃん(暮井 慧)🍍 (@pronama) 2015年6月16日
@felis_catus_ 結構長い!
— プロ生ちゃん(暮井 慧)🍍 (@pronama) 2015年6月16日
@felis_catus_ これは着手しないパターン!
— プロ生ちゃん(暮井 慧)🍍 (@pronama) 2015年6月16日
これは着手しないパターン と言われてしまったではありませんか。
もう、これは遅くとも年内には React に着手するぞ、と心に誓ったのでした。
そこに、誰も手を付けない「プロ生ちゃんクソT時計」案件が転がっている・・・
そうだ、これを React 入門の材料にしてみてはいかがだろう、などと考えだすのは時間の問題でした。
【技術的,全体】アプリケーションの構成
要件と仕様
さて、やっと技術的な話です。クソT時計とは何でしょうか?(技術的に)
まず、要件、というと大げさですが。何を達成すればよいのか。それは一言で「プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計」であることです。
満たすための仕様として、今回のアプリケーションでは以下のようなことを行っています。
バックエンド側(サーバーサイド)
- #プロ生ちゃんクソT選手権 に投稿された画像とIDなどの情報を定期的に取得する
- 取得した画像/ID情報をDBに保存する
- クライアント側からリクエストがあった場合、DBに保存したID情報のリストを返却する
- クライアント側からリクエストがあった場合、DBに保存したクソT画像を、サイズの最適化・背景の透明化処理をしたうえで返却する
フロントエンド側(クライアントアプリケーション)
- 時計を表示し、時刻に合わせて針を表示する
- サーバー側にクソTツイートのIDリストを要求し、メモリ上に保存しておく
- 定期的に、リストからランダムにひとつを選び、それに応じた画像をサーバー側に要求する
- サーバー側から返却された画像を表示する
全体の構成
アプリケーションの構成は以下のようになっています。
素材
- #プロ生ちゃんクソT選手権 に投稿されたクソT画像
- OpenClipArtの時計SVG素材
- ロゴ - ココナラで 無添加豆腐様 に作って頂きました
【技術的,バックエンド】Twitter 画像を MongoDB に保存する
Python で Twitter からツイートの内容を取得し、画像を 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リクエストが来た場合、バックエンドでは以下のようなレスポンスを返すようにしています。
- DBに保存されたツイートの全リスト(IDを含んでいる)
- IDに対応した、DBに保存された当該のツイート(画像以外)
- IDに対応した画像
1と2は MongoDBのデータを加工して、JSONとして返せばいいだけなので難しくないのですが、画像を返す処理がなかなか大変でした。一番の難点は、プロ生ちゃん部分をはみ出して文字や絵が描かれている ケースで、この部分を画像に残しつつ、背景を透過にする ことをしています。
↓こういう画像を、プロ生ちゃんと外の絵 以外 を透明にしなくてはいけない。
で、どうしたかといいますと、プロ生ちゃんをくり抜いた形のマスク画像を作り、
画像処理エンジン 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 に変換します。
必要なものは、
gulp, babelify browserify vinyl-source-stream
Electron(electron-prebuilt, electron-packager
React(react, react-dom)
お好みのライブラリ(moment, superagent など)
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 に依ることができるのです。
SVGの path タグには 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)" }
この方法によって、いかにもあっさりと、しかも直感的に、時計の針のアニメーションが完成してしまいました。
【あとがき】React と アニメーションについて
React を実際に触るまで、アニメーションとは相性が悪いのだろうな、という先入観がありました。実際、仮想DOMと jQuery など実際にDOMを弄る方法を用いたアニメーションとは相性が悪いのだろうといまでも思っています。
ですが、現在、アニメーションのあり方によっては、逆に React とアニメーションはとても相性が良いのではないかという気がしています。その可能性の端緒を垣間見たのが、今回のプロ生ちゃんクソT時計だったのです。今回、時計部分に用いた SVG を state に応じて描画する方法などがその例です。
かつての Flash に変わって、ウェブ上でのアニメーションは、SVGとCanvasが中心となり始めています(CSSなどもありますが)。Canvashは完全にAPIで描画していくタイプなのでステートをもたせるのは難しいかもしれません。ですが、SVGはそれ自体が状態を持つ、画像のフォーマットとも言えるものです。このような存在は、React の考え方とよく馴染むのではないでしょうか。
React も SVG も、まだまだ勉強不足ではありますが、今後もなにか機会を見つけては、この組み合わせを試してみたいと思っています。
長文、読んでいただき、ありがとうございました。
Uncaught Syntaxerror: Unexpected token u
概要
このエラーメッセージが出たのが、 localStorage に undefined を保存しちゃった ことが原因だったよ、、というお話。
さらに、undefined を JSON.parse したら、再現できます。
経緯
このあいだ、Javascriptでコードを組んでいて、以下のようなエラーメッセージに遭遇しました。
処理がここで止まって、突如、総ての動作が停止する事態に焦ったのですが、何しろエラーメッセージの情報が少なすぎる 。
なんだ u って。
しかも、gulpで Electron + React 環境をビルドしていたためか、正確なエラーの位置も分からない状態でした。 コードの中をあちこち「u」で検索しましたが、そんな変数は見つからず。 うっかり入力ミスで「u」を書き込んでいた、ということもありませんでした。
仕方なく「Uncaught Syntaxerror: Unexpected token u」で検索すると、次のような情報に出会いました。
- "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」が再現できるはずです。
このケースの場合は明らかですが、localStorage に undefined が入ってしまっています。
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日目の記事です。
プライバシーの護り方
プロ生のサイトで以前、Microsoft の Project Oxford Face API が紹介されていましたね。
この顔認識の機能を使って、人物の写っている写真に、プロ生ちゃんの顔(ステッカーとは微妙に変えてます)を重ねて、人物のプライバシーを護ってあげましょう。これで、公安9課も捜査に手こずるはずです。
使用するプロ生ちゃん画像
使用方法
http://api.felis-catus.net/pronama/face_api/?url=(プライバシーを護りたい画像)
※ あまりサイズが大きすぎたり、小さすぎたりすると、顔認識に失敗するようです。人数にも制限がある模様。
実際にやってみた例
今回、サンプルとして、このようなプライベートな家族写真を使用しました。これはいけません。顔がバレては困りますね。
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
結果は、以下のようになりました。これで安心ですね!
追記
ほんとは動画GIFにして、くるくる回したいのです〜。それはいずれまた・・・
追記2
Guyモードを追加しました。クエリ文字列に mask=guy
を追加すると・・・これはっ!?
(政治的に問題のない範囲で遊んでください)
Linux 開発環境で Windows 用の Electron アプリを作る場合のアイコンについて
はじめに
この記事では、Electronアプリケーションの作成方法を最初からは説明しません。下記の記事などが大変役に立ちますので、入門の方はこちらをお勧めします。
30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまで - Qiita
もう少し詳しく、ひと通りの過程をなぞっておられる記事でしたら、次のようなものをお勧めします。
Electronでデスクトップウィジェットを作るまで - Qiita
結論を急ぎたい方は
こちらへどうぞ
ビルドした .exe ファイルにアイコンを設定したい!
本題です。 アプリケーションをそれなりに作成したら、アイコンを設定したくなるのが人間の性というものです。 Windows だったら .exe ファイルに好きな画像を表示させたいですよね?
ところが、私の開発環境が Ubuntu であったばっかりに、これが意外に手間でした。そして、これに関する情報がとても少なく、あちらこちらの記事にバラけていたため、まとめようと考えました。
まず、こちらのブログ。アイコンについて記載があるのですが、
さて、恐らく皆さんが一番カスタマイズしたいのがこれでしょう。 アプリ自体のアイコンの変更です。デフォルトでは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 の場合のオプション指定方法を記載してくださっています。
なんだか、これでうまく行きそうですね?
残念。実は、これでうまく行くのは 開発環境がWindowsの場合 のようです。
あきらめて、公式ドキュメントを読みましょう。
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 ファイルが最適のようですね。 画像編集ソフトなどを用いてお好きなアイコンファイルを用意してください。
例
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アプリケーションがアイコン付きで作成できます。
Python3 + Bottle + Pillow でレスポンスとして画像を返す
とりあえず結論
Python3 で画像を取り扱うには、Pillow(PILの派生版, Python3対応)を使うと良いようです。
サーバー側で作成・処理した 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 を使用しています。
クライアント側では、以下のようにして呼び出すことができます。
<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 で有効であった StingIO や cStyingIO は、Python 3 では撤廃されて、代わりに io.StringIO と io.BytesIO になったことが原因です。 そこで 上記のStackOverflow の回答のように、
try: from StringIO import StringIO except ImportError: from io import StringIO
StringIO.StringIO の代わりに io.StringIO で処理しようとしても、今回のケースではやはりうまく行きません。
公式サイトのドキュメントを読むとわかるのですが、画像などバイナリファイルを扱うには、bytes オブジェクトに対応した io.BytesIO でなければいけないのですね。 StringIO.StringIO と io.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 の導入方法は、他のパッケージ同様に 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)。
MongoDB に保存されたデータを読み込むのは、Tweet.object.all() を利用して実行することができます。
for tw in Tweet.objects.all(): # 何かの処理
さらに詳しいことは、公式ドキュメントや、
MongoEngine User Documentation — MongoEngine 0.24.0 documentation
こちらの記事などを参考にしてください。
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 の場合はこれを使うことになります。
Pillow — Pillow (PIL Fork) 9.0.1 documentation
なるほど、と納得しつつ、早速
$ pip install pillow
を実行しようとしたら、途中でエラーが出てきて失敗しました。どうやら、libjpeg-dev というライブラリが入っていないことが原因だったようです。
あらためて libjpeg-dev と Pillow を導入します。
$ sudo apt-get install libjpeg-dev $ pip install pillow
これで無事に Pillow が使えるようになりました。
さて、この Pillow で URL から画像を取得して、MongoDB に保存できればいいわけです。
ところが、意外とここが苦労するポイントでした。というのも、Python 2 のときに使われていたライブラリや手順が Python 3 では微妙に異なり、新旧の情報が入り混じって存在している状況です。
https://librabuch.jp/2013/05/python_pillow_pil/librabuch.jp
結局、上記のサイトなどを参考にしつつ、自分で色々試してみたところ、次の方法でうまく行くことがわかりました。
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()
ポイントは、
- 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 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")
♪ いつからだろう JavaScript を 追いかける私がいた
— たけまる (@felis_catus_) 2015年11月8日
どうかお願い置いてかないで 使ってよ私の このスキルを pic.twitter.com/XQUFucZ8H1
↓
画像が MongoDB に保存される
↓
MongoDB から画像を取得