2011/03/03


こんばんわ。node.jsやってないとjavascriptのもぐりだと言われている昨今、いかがお過ごしでしょうか。
個人的には node.js は2回くらい飽きてしまって、3周目くらいです。

よく考えたらこのブログでは一度も node.js に触れてなかったなーと思ったのと、最近触ってないから忘れてそうだな...という思いからエントリにしてみました。
node.js が面白いと言われている理由の一つに非同期処理があります。そして非同期を面白くする題材として websocket があります。今日はその websocket を使って、サンプルを作ります。
物としては、twitter の filter stream から、instagr.ampicplz.com の画像URLを収集し、それをクライアントにブロードキャストします。クライアントはそれを受けて Growl 風にポップアップ表示するという物です。
まずサイトに仕上げるには、静的ファイルサーバが必要になります。
どのサイトでも紹介してそうですが、適当ながら動くレベルの物を作ります。
var sys = require('sys'),
    fs = require('fs'),
    url = require('url'),
    http = require('http');

// static file server
var extmap = {'.htm''text/html''.css''text/css'};
var server = http.createServer(function(req, res){
  var uri = url.parse(req.url).pathname;  
  if (uri.match('/$')) uri = '/index.html'
  try {
    var filename = __dirname + '/static' + uri;
    var contenttype = extmap[filename.substr(filename.lastIndexOf("."), 4)] || 'application/octet-stream';
    var rs = fs.createReadStream(filename);
    res.writeHead(200, {'Content-Type': contenttype});
    sys.pump(rs, res);
  } catch(e) {
    console.log(e);
    res.sendHeader(404, {"Content-Type""text/plain"});  
    res.write("Not Found\n");  
    res.close();  
  }
});
server.listen(8080);
これを server.js というファイル名で保存して # node server.js と実行すると、static フォルダ配下のファイルが http://localhost:8080/ でサーブされる仕組みです。
次に、websocket を扱える様にします。既存のサーバ上に websocket を乗せるには以下の様にサーバを指定すればokです。(おそらくこれは sugyan さんへのヒントになるのかもしれませんが)
var ws = websocket.createServer({server: server});
クライアント側の実装だとこんな感じですね。
var connection = new WebSocket('ws://' + location.host);
connection.onopen = function(event) {
}
connection.onmessage = function(event) {
}
さて、下準備は出来たので twitter stream を扱いましょう。twitter stream API はまだ basic 認証で扱えます。http-basic-auth モジュールを使いましょう。なお、Ubuntu 向けに launchpad から提供されている nodejs には node-waf が入っていませんので、依存で入る base64 モジュールのビルドに失敗します。どうしても試したい人は nodejs をソースからビルドしましょう。
twitter filter stream を扱うコードは以下の様になります。
var basicauth = require('http-basic-auth');

var basicauthclient = basicauth.createClient(80'stream.twitter.com'falsefalse, account)
var req = basicauthclient.request('GET''/1/statuses/filter.json?track=picplz,instagr', {'host''stream.twitter.com'})
req.end();
req.on('response'function (res) {
  res.on('data'function(chunk) {
  })
})
account は username と password をキーに持つオブジェクトです。ご自分のアカウントを設定しましょう。
twitter stream を受信したら、tweet の entities から URL 一覧を取得し、instagr.am と picplz.com の画像URLを取得しましょう。picplz.com はURLの後ろに /thumb/400 を足すだけですが、instagr.am は API にアクセスしなければなりません。
tweet 受信時の処理は以下の様になりました。
req.on('response'function (res) {
  res.on('data'function(chunk) {
    try {
      var tweet = JSON.parse(chunk);
      // parse entities urls
      [].forEach.call(tweet.entities.urls, function(item) {
         // pick images of instagr.am or picplz.com
         if (item.url.match('^http://instagr.am/p/')) {
           var client = http.createClient(80'instagr.am');
           var req = client.request('GET''/api/v1/oembed/?format=json&maxheight=330&url=' + item.url, {'host''instagr.am'});
           req.on('response'function(res){
             res.on('data'function(chunk){
               var url = JSON.parse(chunk).url
               console.log(url)
               ws.broadcast(url)
             });
           });
           req.end();
         }
         if (item.url.match('^http://picplz.com/')) {
           var url = item.url + '/thumb/400'
           console.log(url)
           ws.broadcast(url)
         }
      });
    } catch(e) { console.log(e) }
  });
});
サーバ部は出来上がりですね。最後にクライアント部を作りましょう。
$(function() {
  var connection = new WebSocket('ws://' + location.host);
  connection.onopen = function(event) {
    $('#fotoflo').empty();
  }
  connection.onmessage = function(event) {
    var x = (Math.random() * ($(document).width() - 330)).toFixed();
    var y = (Math.random() * ($(document).height() - 330)).toFixed();
    var item = $('<div/>')
      .addClass('popup-image')
      .css({'left': x + 'px''top': y + 'px''position''absolute''display''none'})
      .appendTo('body')
    $('<img/>')
      .attr('src'event.data)
      .bind('load'function() {
        $(item).fadeIn(500).delay(10000).fadeOut(500function() {
          $(this).remove()
        })
      }).appendTo(item)
  }
})
こんな感じでしょうか。受信する度にランダムな位置へ画像をフェイドイン表示し、数秒経過したら消すというオーソドックスな物です。
以下、リポジトリの場所は示しますが、サーバの全体ソースを載せておきます。
var sys = require('sys'),
    fs = require('fs'),
    url = require('url'),
    http = require('http'),
    websocket = require('websocket-server'),
    basicauth = require('http-basic-auth');

// load twitter account
var account = JSON.parse(fs.readFileSync(__dirname + '/config.json''utf8'))

// static file server
var extmap = {'.htm''text/html''.css''text/css'};
var server = http.createServer(function(req, res){
  var uri = url.parse(req.url).pathname;  
  if (uri.match('/$')) uri = '/index.html'
  try {
    var filename = __dirname + '/static' + uri;
    var contenttype = extmap[filename.substr(filename.lastIndexOf("."), 4)] || 'application/octet-stream';
    var rs = fs.createReadStream(filename);
    res.writeHead(200, {'Content-Type': contenttype});
    sys.pump(rs, res);
  } catch(e) {
    console.log(e);
    res.sendHeader(404, {"Content-Type""text/plain"});  
    res.write("Not Found\n");  
    res.close();  
  }
});
server.listen(80);

// listen websocket server on the server
var ws = websocket.createServer({server: server});

// twitter filter stream
var basicauthclient = basicauth.createClient(80'stream.twitter.com'falsefalse, account)
var req = basicauthclient.request('GET''/1/statuses/filter.json?track=picplz,instagr', {'host''stream.twitter.com'})
req.end();
req.on('response'function (res) {
  res.on('data'function(chunk) {
    try {
      var tweet = JSON.parse(chunk);
      // parse entities urls
      [].forEach.call(tweet.entities.urls, function(item) {
         // pick images of instagr.am or picplz.com
         if (item.url.match('^http://instagr.am/p/')) {
           var client = http.createClient(80'instagr.am');
           var req = client.request('GET''/api/v1/oembed/?format=json&maxheight=330&url=' + item.url, {'host''instagr.am'});
           req.on('response'function(res){
             res.on('data'function(chunk){
               var url = JSON.parse(chunk).url
               console.log(url)
               ws.broadcast(url)
             });
           });
           req.end();
         }
         if (item.url.match('^http://picplz.com/')) {
           var url = item.url + '/thumb/400'
           console.log(url)
           ws.broadcast(url)
         }
      });
    } catch(e) { console.log(e) }
  });
});
簡単ですね!
imagestream
server.js と同じフォルダに config.json というファイルがあるので、そのファイル内を twitter アカウントで修正して頂ければ動きます。
mattn/node-image-stream - GitHub

display images from twitter stream

https://github.com/mattn/node-image-stream
みなさんもぜひ面白い物作ってみて下さい。
Posted at by



2011/02/24


新しい言語を始めて慣れてくると、必ず作りたくなるのがblosxomですね。(ですよね?)
て事でGoでblosxomライクなのを作った。cgiも出来なくないけど、GoはWebに強かったりもするので、web.goを使ってサーバとして書いた。
名前は「blogo」(ブロゴー!えっ)。blosxomライクにテキストファイルを読み込んで、タイトルと本文をこしらえます。
今回はHTMLのテンプレートエンジンとしてmustache(マツタケじゃないよ)を選びました。
結構ハマってしまったけど、一通り動いた。設定ファイル(JSON)でフォルダ位置やタイトル、サブタイトル、リンク一覧なんかを変更出来ます。見た目はこんな感じ。
blogo
短いのでソース全部のっける。
package main

import "bytes"
import "html"
import "io"
import "io/ioutil"
import "mustache"
import "os"
import pathutil "path"
import "regexp"
import "strings"
import "time"
import "web"
import "json"

type Tag struct {
    Name string
}

type Entry struct {
    Id       string
    Filename string
    Title    string
    Body     string
    Created  *time.Time
    Category string
    Author   string
    Tags     []Tag
}

func toTextChild(w io.Writer, n *html.Node) os.Error {
    switch n.Type {
    case html.ErrorNode:
        return os.NewError("unexpected ErrorNode")
    case html.DocumentNode:
        return os.NewError("unexpected DocumentNode")
    case html.ElementNode:
    case html.TextNode:
        w.Write([]byte(n.Data))
    case html.CommentNode:
        return os.NewError("COMMENT")
    default:
        return os.NewError("unknown node type")
    }
    for _, c := range n.Child {
        if err := toTextChild(w, c); err != nil {
            return err
        }
    }
    return nil
}

func toText(n *html.Node) (stringos.Error) {
    if n == nil || len(n.Child) == 0 {
        return ""nil
    }
    b := bytes.NewBuffer(nil)
    for _, child := range n.Child {
        if err := toTextChild(b, child); err != nil {
            return "", err
        }
    }
    return b.String(), nil
}

func GetEntry(filename string) (entry *Entry, err os.Error) {
    fi, err := os.Stat(filename)
    if err != nil {
        return nil, err
    }
    f, err := os.Open(filename, os.O_RDONLY, 0)
    if err != nil {
        return nil, err
    }
    b, err := ioutil.ReadAll(f)
    if err != nil {
        return nil, err
    }

    in_body := false
    re, err := regexp.Compile("^meta-([a-zA-Z]+):[:space:]*(.*)$")
    if err != nil {
        return nil, err
    }
    for n, line := range strings.Split(string(b), "\n", -1) {
        line = strings.TrimSpace(line)
        if n == 0 {
            entry = new(Entry)
            entry.Title = line
            entry.Filename = pathutil.Clean(filename)
            entry.Tags = []Tag{}
            entry.Created = time.SecondsToUTC(fi.Ctime_ns / 1e9)
            continue
        }
        if n > 0 && len(line) == 0 {
            in_body = true
            continue
        }
        if in_body == false && re.MatchString(line) {
            submatch := re.FindStringSubmatch(line)
            if submatch[1] == "tags" {
                tags := strings.Split(submatch[2], ",", -1)
                entry.Tags = make([]Tag, len(tags))
                for i, t := range tags {
                    entry.Tags[i].Name = strings.TrimSpace(t)
                }
            }
            if submatch[1] == "author" {
                entry.Author = submatch[2]
            }
        } else {
            entry.Body += strings.Trim(line, "\r") + "\n"
        }
    }
    if entry == nil {
        err = os.NewError("invalid entry file")
    }
    return
}

type Entries []*Entry

func (p *Entries) VisitDir(path string, f *os.FileInfo) bool { return true }
func (p *Entries) VisitFile(path string, f *os.FileInfo) {
    if strings.ToLower(pathutil.Ext(path)) != ".txt" {
        return
    }
    if entry, err := GetEntry(path); err == nil {
        *p = append(*p, entry)
    }
}

func GetEntries(path string, useSummary bool) (entries *Entries, err os.Error) {
    entries = new(Entries)
    e := make(chan os.Error)
    pathutil.Walk(path, entries, e)
    for _, entry := range *entries {
        if useSummary {
            doc, err := html.Parse(strings.NewReader(entry.Body))
            if err == nil {
                if text, err := toText(doc); err == nil {
                    if len(text) > 500 {
                        text = text[0:500] + "..."
                    }
                    entry.Body = text
                }
            }
        }
        entry.Id = entry.Filename[len(path):len(entry.Filename)-3] + "html"
    }
    if len(*entries) == 0 {
        entries = nil
    }
    return
}

type Config map[string]interface{}

func (c *Config) Set(key string, val interface{}) {
    (*c)[key] = val
}

func (c *Config) Get(key stringstring {
    val, ok := (*c)[key].(string)
    if !ok {
        return ""
    }
    return val
}

func LoadConfig() (config *Config) {
    root, _ := pathutil.Split(pathutil.Clean(os.Args[0]))
    b, err := ioutil.ReadFile(pathutil.Join(root, "config.json"))
    if err != nil {
        println(err.String())
        return &Config{}
    }
    err = json.Unmarshal(b, &config)
    return
}

func main() {
    config := LoadConfig()
    web.Get("/(.*)"func(ctx *web.Context, path string) {
        config = LoadConfig()
        datadir := config.Get("datadir")
        if path == "" || path[len(path)-1] == '/' {
            dir := pathutil.Join(datadir, path)
            stat, err := os.Stat(dir)
            if err != nil || !stat.IsDirectory() {
                ctx.NotFound("File Not Found")
                return
            }
            var useSummary = false
            if config.Get("useSummary") != "" {
                useSummary = true
            }
            entries, err := GetEntries(dir, useSummary)
            if err == nil {
                ctx.WriteString(mustache.RenderFile("entries.mustache",
                    map[string]interface{}{
                        "config":  config,
                        "entries": entries}))
                return
            }
        } else if len(path) > 5 && path[len(path)-5:] == ".html" {
            file := pathutil.Join(datadir, path[:len(path)-5] + ".txt")
            _, err := os.Stat(file)
            if err != nil {
                ctx.NotFound("File Not Found")
                return
            }
            entry, err := GetEntry(file)
            if err == nil {
                ctx.WriteString(mustache.RenderFile("entry.mustache",
                    map[string]interface{}{
                        "config": config,
                        "entry":  entry}))
                return
            }
        }
        ctx.Abort(500"Server Error")
    })
    web.Config.RecoverPanic = false
    web.Config.StaticDir = config.Get("staticdir")
    web.Run(config.Get("host"))
}
それと、今回初めてCSS FrameworkであるBlueprintを使った。まぁ確かに慣れてると使いやすいのかもしれないけど、CSSそんなに知らない人が使い始められる物じゃないなと思った。Goのコード書くよりこのレイアウト作る方に時間がかかったという...
mattn/blogo - GitHub

blogo : blosxom like blog engine for golang

http://github.com/mattn/blogo
Posted at by



2011/02/03


最近phantomjsなんて物が出てきて結構便利そうなのでいろいろと遊んでます。
PhantomJS: 「最小限なheadlessのWebKitベースのJavaScriptツール」 - karasuyamatenguの日記

headless=スクリーンがない=コマンドと考えればいい。要はブラウザから画面と取り除いてJavaScriptによるスクリプティングを可能にしたコマンドツール。逆に言うとDOM+JavaScript+Networkingをコマンドにしたもの。...

http://d.hatena.ne.jp/karasuyamatengu/20110126/1296066287
phantomjs - Project Hosting on Google Code

PhantomJS is a minimalistic, headless, WebKit-based, JavaScript-driven tool. It has native support f...

http://code.google.com/p/phantomjs/
ただちょっと改造したいとか、それだけの為にQt入れたくないよーとか、staticビルドすんのに4時間もかかるのかよ!とかお嘆きの方もいらっしゃると思ったので...

phantomjs を webkitgtk+ でうごかしたらいいんじゃね、とおもってやってたけど、コンパイルに必要なモジュールおおすぎてあきらめたless than a minute ago via Echofon



QtWebKitでなく、webkitgtk+で実装してみました。
mattn/specterjs - GitHub

SpecterJS is a minimalistic, headless, WebKit-based, JavaScript-driven tool.

https://github.com/mattn/specterjs
ほとんどphantomjs互換です。phantomjsのphantomは幽霊って意味だったので、妖怪という意味のspecterを使いspecterjsと名付けました。
ただまだ制限があって、renderで扱えるのはpdfのみです。png出力にはまだ対応出来ていません。これはいずれやります。おそらくcairoとGtkPrintContext使えばいけるかと思います。
次にrenderで使用する際のviewportSizeが正しく機能していません。
それ以外は動くのでphantomjsについているサンプルの殆どが動きます。
ちなみに
if (specter.state.length === 0) {
    specter.state = 'mcdonalds';
    specter.open('http://www.mcdonalds.co.jp/menu/regular/index.html');
else {
    [].forEach.call(document.querySelectorAll('ul.food-set>li img'),
        function(n) { console.log(n.getAttribute('alt')); });
    specter.exit();
}
で、マクドナルドのメニュー一覧を出すことも出来ちゃいます!
よろしかったら遊んで下さい。
Posted at by