Emacsからrakeを呼び出してみた

最近「プログラミングしてないなぁ……」とか思っちゃったので, 試しにTwitterのUser Streamsを使ったプログラムを書こうとしたのですが, ErlangからUser Streamsが使えなかったので*1別のプログラムに逃避した次第です, こんにちは. id:siritoriごめん.

で, 何作ろうかと考えてたら, 「そういえばEmacsからrake呼び出したいなぁ. あるのかなぁ. ありそうだよねぇ」とふと思い立った. 調べると, testタスクを実行するのはあっても, その他タスクを実行するのがなかったので, ちょっとだけ作ってみた.

(defvar rake:output-buffer "*rake output*")

(defun rake:invoke ()
  (interactive)
  (let ((task-suggestions (rake:task-suggestions)))
    (if task-suggestions
      (rake:run (completing-read "rake " task-suggestions))
      (message "Not found target"))))

(defmacro rake:deftask (task)
  (list 'defun (prog1 (intern (concat "rake:run-" task))) '()
        '(interactive)
        (list 'rake:run task)))

(defmacro rake:task (task)
  (list 'lambda '()
        '(interactive)
        (list 'rake:run task)))

(defun rake:task-suggestions ()
  (reverse (reduce #'(lambda (suggestions line)
                       (if (string-match "rake \\([^ ]+\\)" line)
                           (cons (match-string 1 line) suggestions)
                         suggestions))
                   (split-string (shell-command-to-string "rake -T") "[\r\n]+")
                   :initial-value nil)))

(defun rake:run (task)
  (call-process-shell-command (format "rake %s" task) nil rake:output-buffer))

使い方は.emacsとかに:

(require 'rake)

(global-set-key "\C-cr" 'rake:invoke)

(global-set-key "\C-cRd" (rake:task "default")) ;; C-c R dでdefaultタスクを実行する

(rake:deftask "test") ;; testタスクを実行する関数rake:run-testを定義し,
(global-set-key "\C-cRt" 'rake:run-test) ;; C-c R tでrake:run-testを呼び出す

な感じで. 一応タスク名を補完するようにしてあるけども, それをちょっとがんばったくらいで他がガタガタ. 後々ちゃんとさせる.

Lispは不慣れなので, 「この書き方よりもこっちの方がいいよ」とかあったら突っ込んでいただけると嬉しいです.

自分用メモ

  • タスク名の一覧をキャッシュさせる(rake -Tの呼び出しに微妙に時間がかかるため). Rakefile(*.rake)のパスと更新時刻, タスク名一覧を持っておけばよさげ
  • となると, Rakefile(*.rake)の探索をrakeと合わせておかなければならない
  • *rake output*バッファにどんどん書き込まれるので, rake:runするたびにクリア
  • キーバインドの設定を簡潔に記述できるようにする
  • 直前に実行したタスクを記憶しておく. コレはRakefile(*.rake)ごとに記憶した方がいい気がする

あとなんかあるかなぁ……

*1:「使えたよ」という方がいたら教えていただけると嬉しいです

ファイルの非同期アップロードを実装してたら, IEでいっぱい怒られたよ

すげー怒られたので, 逆ギレのごとくここにご報告いたします. 気分は先生に怒られた小学生.

「同一ドメインなのにiframeのdocumentを取得出来ませんよ!?」「うっせーなー」

非同期アップロードはform要素のtarget属性を任意のインラインフレームにして実装するのが定石だと思います. なので結果は指定したインラインフレームの中にあるため, documentを取得しないと結果がわかりません. ちなみに取得方法は:

var iframe = document.getElementById('iframe-id');

iframe.onreadystatechange = function() { // IEはiframe要素でloadイベントが発生しないらしい(IE8だと普通に発生してたけど)
  if (iframe.readyState == 'complete') {
    var doc = iframe.contentWindow.document; // contentWindowを経由して取得
  }
};

でしょうか. IE8で試してたので, contentDocumentもありましたが.

当然異なるドメインであれば, Same origin policyが適用され, アクセスが拒否されるのですが, 同じドメインでもアクセスが拒否されました.

で, どうやらレスポンスのステータスコードが200以外だと, documentプロパティを参照したときに「アクセスが拒否されました」と言われるようです. エラーならステータスコードを適切な内容にしたいのですが, それは許されないようですね. 先生厳しすぎ.

とりあえず回避するにはステータスコードを200にしなければいけないので:

status 200

って書いておきました. あ, サーバー側にはSinatra使ってたので.

「アップロードするファイルが選択されていませんよ!?」「あー, だりぃ」

アップロードするファイルを選択するためには, <input type="file" />を設けなければいけません. で, 問題なのはコイツのvalue属性. どうやらJavaScriptから設定するコトが出来ず, 閲覧者の操作によってでしか設定出来ないようです. 先生頭固すぎ.

実装としては元々あるフォームをコピーしてたのですが, そんなコトする必要もないと思い, 元々あるそのフォームのtarget属性をインラインフレームにして対処しました*1.

「アップロードしたファイルがJPEGじゃありませんよ!?」「帰りたーい」

アップロードできるファイルはJPEG, GIF, PNGのいずれかだったのですが, なぜかIEJPEGをアップロードしても不正な形式扱い.

実装としてはMIMEタイプで見てたので, image/jpeg, image/gif, image/pngのいずれかだったら正しい形式と判断してたのですが, IEからJPEGをアップロードすると, MIMEタイプはimage/pjpeg…… pjpeg(・∀・`)?

なんか独自実装らしいです. 先生自由すぎ.

とりあえず許可するMIMEタイプにimage/pjpegを追加して事なきを得ました.

以上, 小学生のご報告でした.

*1:なぜわざわざフォームのコピーを作成してたかは謎

hetemlにRubyGemsをインストールする

"いざhetemlにRubyGemsをインストールしようとしたら, すんなりとインストールされず, なんてこったい"を2回繰り返してしまったので, 3度目がないようにメモ.

RubyGemsのインストール先ディレクトリを作成

当然/usr/localとかには入れられないので, ホーム配下にインストール先のディレクトリを作っておく.

$ cd
$ mkdir .gem

RubyGemsのソースをダウンロード

とりあえず適当なディレクトリを作ってそこにダウンロードし, 伸張. RubyGemsのバージョンに合わせて, URLは適当に変える.

$ mkdir src
$ cd src
$ wget http://rubyforge.org/frs/download.php/69365/rubygems-1.3.6.tgz
$ tar zxf rubygems-1.3.6.tgz
$ cd rubygems-1.3.6

ソースを修正

後はインストールすればいいんだけど, このままではgemコマンドがコケるので, ソースを直しておく. ちなみにコケる理由は, rubyコマンドのパスが違うから. RubyGems自体はRbConfig::CONFIG[:bindir]からrubyコマンドのありかを見ているんだけど, なぜかそこにはないのでそれを直す.

でも食い違ってるっておかしくないですか(・∀・`)?

$ vi lib/rubygems.rb

127行目あたり.

:bindir            => RbConfig::CONFIG["bindir"],

コレを:

:bindir            => '/usr/bin',

って直に書いちゃう.

インストール

$ ruby setup.rb --prefix=~/.gem

環境変数の定義

.bashrcを新規作成して書けばいいんだけど, .bash_profileもないので, .bashrcが読込まれない. 一応.bashrcに書くのなら以下のようにでもしとけばいいのかな?

$ cd
$ vi .bash_profile
source .bashrc
$ vi .bashrc
export GEM_HOME=~/.gem
export PATH=~$GEM_HOME/bin:$PATH
export RUBYLIB=$GEM_HOME/lib

次からログインしたときは, .bashrcを読込んで環境変数を定義してくれるけど, 今は.bashrcが読込まれてないので, sourceで読込んでおく. 次回ログインからは不要.

$ source .bashrc

インストールされてるか確認

$ gem -v
1.3.6

ちなみに

RubyGemsをhetemlに入れる理由なんて, Sinatra + CGIでサーバーサイドの処理を書きたい, ぐらいだと思うのだけれど, CGIを実行するユーザーがログインユーザーとは違うので, 環境変数GEM_HOMEとRUBYLIBが定義されておらず, rubygemsを取り込むと盛大に血反吐を吐く.

ので, *.cgiの先頭に:

ENV['GEM_HOME'] = 'ログインユーザーの環境変数GEM_HOMEと同じ値("~"ってやっても無理なので絶対パスで)'
$: << File.join(ENV['GEM_HOME'], 'lib')

って書いておくといいかなーと.

コレはhetemlに限らず, 他のレンタルサーバー(さくらとか)でも一緒かな?

スパムアカウントの排斥と許容

スパムアカウントの大量虐殺サービスを作っておきながら言うのもなんだが, スパムアカウントに対して攻撃的な人が目につく. それも生半可な攻撃性ではなく, その攻撃性は現実世界で言う殺害に等しいのでは, と思えるほど. 赤の他人, ましてやbotに欲してもいない絡みをされたら, うっとうしく感じるのは理解できる. しかし何故そこまで嫌う? 正直見ていてかなり不快に感じる.

嫌なコトだっていうのはわかる. ボクだって嫌だ. しかし嫌だからといって, その嫌悪感むき出しの言動はどうなのだろう? そこまでして排斥したいのだろうか? そこまでして自分のアカウントをクリーンに保ちたいのだろうか?

我慢すればいいのに, とか心ないコトを言うつもりはないが, 少しぐらいの許容はできないのだろうか.

TLを見ながらそんなコトを思う. そしてボクはスパムアカウントに対するキャパと, 過剰な攻撃性に対するキャパを広げようと考える. 改めて書いて思うけど, キャパ低いね, 自分.

ちなみにふぇらほいを作ったコトに関しては現状善悪半々だとボク自身は思います. ボク自身アレを作ったコトによってスキルや経験が身に付いたし, ご利用いただいた方に喜んでいただけるコトもありました.

ただ気に入らない者を排斥する行為を助長した象徴であるならば, 反省せざるをえません. あと度が過ぎていたとは思うが, ブリトニーごめんね.

Twitterの発言中に現れるハッシュタグを抜き出す関数

Twitter側がどのようなカタチをハッシュタグと認識するのかよくわからないけど, とりあえず書いてみた. ちなみにめんどくさいので正規表現とかは使ってません.

使い方は:

$ erl
1> twitter_status:hashtags("ほげほげ #banana_#mango#highschool").
["banana"]
2> twitter_status:hashtags("ふがふが #banana_ #mango #highschool").
["banana", "mango", "highschool"]

です. 最初の戻り値にmangoとhighschoolが含まれないのはそういう仕様です*1.

んで注意するのは, twitter_status:hashtags/1に与える引数をバイナリまたはUnicode表現のリスト*2にしなければいけない点.

当然と言えば当然なのかもしれませんが, 文字単位で処理を行いたかったので扱いはこっちの方が楽です. 正規表現使ってないし.

ただシェルから与えたリストはあらかじめUnicode表現である*3のに対して, ソース中に書かれたリストはUnicode表現になっていないので:

hoge() ->
    twitter_status:hashtags("#hoge").

とやっても["hoge"]は返ってきません. list_to_binary/1を使って:

hoge() ->
    twitter_status:hashtags(list_to_binary("#hoge")).

とすれば, ["hoge"]が返ってきます. バイナリを与えると, twitter_status:hashtags/1がunicode:characters_to_list/1を呼び出すからね!

基本的にバイナリのまま扱っていればいいので, こういうのは稀. とりあえず有事に備えてこの記事を備忘録としよう.

でソースですが, Gistに上がってるのでよければどうぞ.

http://gist.github.com/271074

一応ココにも書いておきます.

-module(twitter_status).
-author("KONDO Takahiro <heartery@gmail.com>").
 
-export([hashtags/1]).
 
-define(ALPHANUMERIC(C), ($a =< C andalso C =< $z orelse
                          $A =< C andalso C =< $Z orelse
                          $0 =< C andalso C =< $9)).
 
-define(HASHTAG_PREFIX(C), (C =:= $# orelse C =:= 65283)).
-define(HASHTAG_CHARS(C), (?ALPHANUMERIC(C) orelse C =:= $_)).
 
hashtags(Text) when is_binary(Text) ->
    hashtags(unicode:characters_to_list(Text));
 
hashtags(Text) ->
    lists:reverse(hashtags(Text, -1, "", [])).
 
hashtags([C|Text], -1, [], Hashtags) when ?HASHTAG_PREFIX(C) ->
    hashtags(Text, 0, [C], Hashtags);
 
hashtags([C|Text], -1, [LC|_] = Read, Hashtags) when ?HASHTAG_PREFIX(C), not ?ALPHANUMERIC(LC) ->
    hashtags(Text, 0, [C|Read], Hashtags);
 
hashtags([C|Text], Size, Read, Hashtags) when ?HASHTAG_CHARS(C), Size > -1 ->
    hashtags(Text, Size + 1, [C|Read], Hashtags);
 
hashtags([C|Text], Size, Read, Hashtags) when Size > 0 ->
    hashtags(Text, -1, [C|Read], [hashtag(Read, Size)|Hashtags]);
 
hashtags([C|Text], Size, Read, Hashtags) ->
    hashtags(Text, Size, [C|Read], Hashtags);
 
hashtags([], Size, Read, Hashtags) when Size > 0 ->
    [hashtag(Read, Size)|Hashtags];
 
hashtags([], _, _, Hashtags) ->
    Hashtags.
 
hashtag(Read, Size) -> hashtag(Read, Size, []).
hashtag(_, 0, Acc) -> Acc;
hashtag([C|Read], Size, Acc) -> hashtag(Read, Size - 1, [C|Acc]).

*1:Twitter側で試してみればわかります. ただ#banana_#mango#highschoolと書いて, フォロワーに変な目で見られても, 責任は負いかねます><

*2:"あ"が[227, 129, 130]ではなく[12354]となる状態

*3:端末のエンコーディングにもよると思いますが. erlとの関係何かあるのかな……? 少なくともボクの環境(Mac OS X 10.5.8, Erlang R13B03)ではそうだった, と書いておきます

ぼくたちとブリトニーの70日戦争

今日もTwitterで元気よくさえずっているたっくんですこんにちは.

元気は元気なんですが, 最近何か物足りないんですよ. ……そう, ブリトニーが来ない. あんなに疎んでいたのにいざ来ないとなるとこんなに寂しいなんて…… なにこれ恋?

そんなワケはないんですが, ホント音沙汰が無いです. 諦めたのか知りませんが, ひとつの節目として彼女らがどう進化してきたかをまとめてみます. ついでにふぇらほいもその進化に対してどう対処してきたかを併せて書いてみます. ちなみに日付は大体の目安です.

ふぇらほい誕生以前: 〜 9/26

いつから流行りだしたかはよく知りませんが, ブリトニーの熱烈フォローに困っている人がボクのTLだけでもかなりの人数居たと思います. ボク自身あまり気にしない人なので, ブロックもせずひたすら放置プレイ*1をキメていましたが, たしかに量はすごい, と思っていました.

このとき頭の中では「彼女らのアイコン一緒なんだから, 自動でブロックできそうだよね」と言う考えがありました. ただデフォルトアイコンのブリトニーがまれにいたので, 結局ふぇらほい自体はアイコンで判断していません. Britney Fuckedという名前が共通だったので, 名前だけで判断するようにしました.

ちなみに進化と称していますが, 実際は段階を経て, ふぇらほいなどのプログラムに補足されにくくするよう, 名前やアイコンに小細工を施していたようです. この段階でブリトニーは名前に半角ドット(.)を適当な位置に混ぜ込んでいました.

ふぇらほい誕生: 9/27 〜 10/10

とりあえず名前に含まれている半角ドットを除去した上でBritney Fuckedとなるアカウントをブリトニーとみなし, 自動でブロックを行うふぇらふぇらほいほいを公開しました. TwitterでURLを発言したらいろいろ広まったのか, 予想以上の登録者数の伸びにあたふたしたり*2. いまさらながらありがとうございます!

自動ブロックプログラムを更新しつつ, Web側のデザインもちくちく変えていました. 基本安定していたかなぁ, と思います.

ファミリー化と文字の置き換え: 10/11 〜 10/24

しかしココにきてブリトニーがBritney Fucked以外に, Britney Suck Cock, Britney Fuck, Britney XXXなどと様々な名前で攻めてくるようになりました. ボクはこのまとまりをブリトニーファミリーと呼んでいます. ちなみに他にはHorny Blackなどもいましたが, 彼女らもホーニィファミリーと呼んでいました*3.

さらには名前に含まれるiを1に, oを0に, という感じに似ている数字に置き換えてくるようにもなりました.

当然対処するために, スパムアカウントとみなす名前を列挙したブラックリストを更新していたのですが, ファミリー化と文字の置き換えによって, 名前のパターンが多種多様になり, 更新が間に合いません. そこで任意の文字列同士の相違度を表すレーベンシュタイン距離というのを使いました. 具体的にはブラックリストに含まれる名前と取得したアカウントの名前のレーベンシュタイン距離を求めて, それがしきい値以下であるならばスパムアカウントとみなす, という風にしていました. コレならBr1tney Suck C0ckが来ても, Britney Suck Cockとのレーベンシュタイン距離は2となり, 当時のしきい値である3以下のため, ブロックされていました.

全角文字による回避: 10/25 〜 11/13

レーベンシュタイン距離を実装してしばらくすると, 今度はBritney Fuckedという名前で攻めてきました. わかりにくいかもしれませんが, 3文字目のiが全角になっています. 実はこの時点では何も対処しなくとも, 現状の状態で対処できておりました*4.

しかし全角文字 + 文字の置き換え, のように複合的な手法を取られると, スパムアカウントとして検出されなくなりました. そこでしきい値を大幅に8にあげたところ, 今度はスパムでない人をスパムとしてブロックするという誤爆が発生したので, あえなく断念. その節は申し訳ございませんでした……

結局半角で表せる文字が全角であった場合(すなわちiなどのアルファベット), 半角に直してからブラックリストに含まれるか検査する, という方式にしました. ついでに大文字は小文字にしていました. またbritneyに着目して, britney(またはbritny, briteny*5 )が含まれている, かつbritneyを除外した残りの単語がfucked, suckcock, fuck, xxxなどの単語で構成されていたらアウト, という風にしました. ファミリー指向の検出に切り替えたワケです. なのでレーベンシュタイン距離はこのあたりで外しました. 実は処理が遅く, ファンが唸りっぱなしだったので.

ちなみにこのあたりで1日あたりのブロック回数が7,000 〜 10,000という値を連日記録しました. おそろしいですね!

またTwitterが実装したスパム報告もこの時点で行うようにしています.

リプライによる積極的攻撃: 11/14 〜 11/20

しばらくするとTLで「ブリトニーから@飛んできた!」と発言されてるのを見かけました. なにそれこわいと思ったら, 自分のところにも飛んできました. ただフォローしてきた時に比べて, 数はあまり多くなかったように思います*6.

ふぇらほいもそれに対応させようとしていたのですが, いかんせんフォロワーではなくMentionsを見なければいけないので, API実行回数の都合上あまり短い間隔で見れず, 漏れが発生するかも…… とか懸念していました. 実際はAPI実行回数がまったく増えるコトなくできたので杞憂に終わり, 無事に対処できました*7.

どのようにしてブリトニーかどうかを判断しているかですが, それはふぇらほいの方に書いてあります. なぜ名前で判断していないかというと, 名前が"誰かのTwitter ID + 適当な桁数の数字"(後に"適当な英単語 + 適当な桁数の数字"に変化)という一見するとブリトニーと思えないものだったからです. どうしちゃったんだよブリトニー……!

ちなみにこのころが一番激しかったと思います. 数的に言えばそれほどでもないのですが, リプライ飛ばしてくるわ, フォローしてくるわの波状攻撃にふぇらほい稼働させっぱなしでした.

過剰な没個性: 11/21 〜 12/4

さきほども名前がブリトニーっぽくないと書きましたが, このあたりからアイコンが例のフェ○アイコンではなく, デフォルトアイコンになったりと, 捨て身の攻撃を仕掛けてきました. ただ哀しいかな, 全部見当違いの方向だったり. あと発言にも全角などが混じっていたようですが, 前例があったのでどうせ仕掛けてくるだろうと, こちらもあらかじめ対処しておりました. 言うなれば落とし穴に全力で突っ込んできてくれた感じです.

後は卑猥なページへのURLを発言に含めず, プロフィールのWebに書いておくだけにしたり, URLではなくホスト名だけにしたりと, いろいろしていましたがいかんせん空振り感が強くて覚えていないです. ごめんよブリトニー. 気分的には恋人の些細な変化を見落としていて, 後から言及され, 凹む感じです. 凹んでませんけど.

Twitterの本気: 12/5 〜

そしてついにTwitterが本気を出しました. Twitter側によってブリトニーが粛清され始めたようです. 具体的に言うと10分ぐらいでブリトニーのアカウントが凍結されていました. なのでこのあたりからブリトニー含めスパムアカウントを見る数が激減しました(ボクだけかもしれませんが).

そして今に至る, という感じです. 正直どうしてそこで諦めるんだそこで! 昔のお前思い出してみろ! せっかくアイコンベースの検出作ってくれている最中だっt, あ, いや, コレでよかったなぁと思います.

ただこの静寂は一時のコトかもしれないので, それはあしからず……

ちなみに過去のTwitterでの発言を元にこの文章を書いたため, ヌケがあるかもしれません. なにか気づきましたら適当にご連絡いただけると嬉しいです.

*1:ある程度の時間が経過するとTwitter側でスパムアカウントを凍結してくれていたようです

*2:予想外すぎて自動ブロックプログラムが対処しきれなくなりました. その時の内容はこんな感じ Twitterのしつこいフェ○アイコンを自動でブロックするサービスを作ったよ

*3:ちなみにブリトニーは本家という位置づけで, ブリトニーと同じような動作をするスパムアカウントを亜種という位置づけにしていました. まぁスパムには違いないのですが

*4:詳しく言うと, UTF-8における全角のiは3バイトで, 半角のiとのレーベンシュタイン距離は3となります. しきい値である3にギリギリ収まるので, スパムアカウントとみなされます

*5:britenyは誤字だと思いますがw 現れたコトあるので一応

*6:リプライしてきたブリトニーを見ると発言数が130前後で止まっていました. おそらく一定時間内に発言しすぎて, 制限にひっかかっていたから, 数が少なかったのかと

*7:正確には当然増えましたが, 影響しないようになりました(というよりもともと影響していない). Streaming APIとか試みるよりもさっさとMentions見ればよかったなぁ, と思います

シェルライクなTwitterクライアントをざっくり作ってみた

昨日Twitterでシェルっぽい感じのTwitterクライアントあったらおもしろくない? とか言っていて, なんか楽しそうだったのでざっくり作ってみました.

使い方はこんな感じ.

$ ruby twsh.rb -u your_twitter_id -p password
twsh% echo "はろー twsh.rb"
twsh% ls
screen_name1: tweet1
screen_name2: tweet2
screen_name3: tweet3
screen_name4: tweet4
screen_name5: tweet5
twsh% exit
$

下記コードをtwsh.rbとかで保存して, Rubyとして実行してあげれば起動します. -pオプションはパスワードで必須ですが, -uによるTwitter IDは省略すると環境変数USERが使われます.

後はechoで発言, lsでTL取得です. exit(quit, logout)でシェルを抜けます*1.

ちなみにTwsh.rbとして実装されていないコマンドを用いると, 普段使っているシェルに実行を委ねます. そしてバッククォートによるコマンドの実行を備えているので:

twsh% echo "ボクの$ unameは" `uname` です

とかやると, "ボクの$ unameDarwin です"って出せたり.

適当で抜けとかありますが, 興味がある方は適当にいじっちゃってくだしあ.

あ, あとボクがTwitterでぶつくさ言っていたら@omasanoriさんが興味を持ったようでして, PythonでTwsh.pyを作っているみたいです! たぶんTwsh.rbよりクールなやつができあがると思いますよ!

require 'optparse'
require 'stringio'
require 'readline'
require 'rubygems'
require 'twitter'

module Twsh
  class Logout < StandardError; end

  class Shell
    class << self
      def login(argv, env)
        new argv, env
      end
    end

    def initialize(argv, env)
      username = env['USER']
      password = nil

      OptionParser.new do |opt|
        opt.on('-u VAL') {|v| username = v}
        opt.on('-p VAL') {|v| password = v}
        opt.parse! argv
      end

      fail if username.nil? || password.nil?

      @client = Twitter::Base.new Twitter::HTTPAuth.new username, password
      input = Input.new self
      logged_in = true

      while logged_in
        begin
          output = execute input.read
          puts output if output
        rescue Logout
          logged_in = false
        rescue Interrupt
          puts
          logged_in = false
        end
      end
    end

    attr_reader :client

    def execute(argv)
      return if argv.empty?
      command = argv.shift
      Command.new(self).execute command, argv
    end
  end

  class Command
    @@commands = {}

    class << self
      def setup
        fail unless block_given?
        yield new
      end
    end

    def initialize(twsh = nil)
      @twsh = twsh
    end

    def on(*commands, &process)
      fail unless block_given?
      commands.each {|command| @@commands[command] = process}
    end

    def execute(command, argv)
      process = @@commands[command]

      if process
        args = []
        args << @twsh if process.arity >= 1
        args << argv if process.arity >= 2
        output = nil

        StringIO.open '', 'w' do |io|
          orig = $stdout
          $stdout = io
          process.call *args
          $stdout = orig
          output = io.string unless io.string.empty?
        end

        output
      else
        `#{command} #{argv.join ' '}`.chomp
      end
    end
  end

  class Input
    def initialize(twsh)
      @twsh = twsh
    end

    def read
      read_with_context
    end

    private

    def read_with_context(context = nil, argv = [])
      prompt = context ? "#{context}> " : argv.empty? ? "twsh% " : '> '
      input = Readline.readline prompt, true
      context, argv, continue = parse "#{input}\n", context, argv
      continue ? read_with_context(context, argv) : argv
    end

    def parse(input, context = nil, argv = [])
      escape = false
      continue = false
      v = !argv.empty? ? argv.pop : ''

      new = lambda do
        argv << v unless v.empty?
        v = ''
      end

      input.each_byte do |c|
        if escape
          escape = false
          case c
          when LINE_FEED
            continue = true
            v << c if context
          when ?n
            v << "\n" if quoted? context
          when ?t
            v << "\t" if quoted? context
          when ?f
            v << "\f" if quoted? context
          when ?v
            v << "\v" if quoted? context
          else
            v << c
          end
        else
          case c
          when ?\n
            v << c if context
          when ?\s
            if context
              v << c
            else
              new.call
            end
          when ?'
            if context == :quote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :quote
            end
          when ?"
            if context == :dquote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :dquote
            end
          when ?`
            if context == :bquote
              v = @twsh.execute(parse(v)[1])
              context = nil
            elsif context
              v << c
            else
              context = :bquote
            end
          when ?\\
            escape = true
          else
            v << c
          end
        end
      end

      new.call
      [context, argv, continue || !context.nil?]
    end

    def quoted?(context)
      context == :quote || context == :dquote
    end
  end
end

Twsh::Command.setup do |c|
  c.on('exit', 'quit', 'logout') { raise Twsh::Logout }

  c.on 'echo' do |twsh, argv|
    twsh.client.update argv.join ' '
  end

  c.on 'ls' do |twsh, argv|
    n = 5

    OptionParser.new do |opt|
      opt.on('-n VAL', Integer) {|v| n = v}
      opt.parse! argv
    end

    twsh.client.home_timeline(:count => n).each do |status|
      puts "#{status.user.screen_name}: #{status.text}"
    end
  end

  c.on 'cd' do |twsh, argv|
    if argv.empty?
      Dir.chdir
    else
      Dir.chdir File.expand_path argv.first
    end
  end
end

Twsh::Shell.login ARGV, ENV

*1:Ctrl + cでもいいんですが, Readlineが捉えちゃっているのかInterruptがうまく投げられない……

名前空間つきのモジュールを使う

いつからこうなっているか知らないんですけど, Erlangって名前空間があるらしいです.

-module(ffhh.followers.director).

こんな感じでモジュールを任意の名前空間に属させるコトができます. アンダースコアで長ったらしい名前をつけなくてもいいワケですね! 例えば呼び出しとかも, 同一の名前空間に属していれば同一部分は省略できます. こんな感じ.

-module(ns.mod1).

-export([fun1/0]).

fun1() ->
    Result = mod2:fun1(),   % 実際にはns.mod2:fun1/0がコールされている
    .lists:reverse(Result). % ただ存在しないモジュールはルートから検索してくれるワケではないので,
                            % 先頭にドットをつけて, 明示的にルートから検索するようにしないとダメ
                            % (ちょっとめんどくさい)

ステキ! 少しめんどうなところもあるし, 実際OTP使ったりすると, どこから呼ばれるのかわからないので, 先頭にドットつけてフルパスで指定した方が結局無難じゃん! とかありますけども, まぁそれは些細な話というコトで.

んで名前空間を使うと, ディレクトリの構造も変えなくちゃいけなくて, CPANよろしくな構造にしないといけないんですよね.

コレを:

src
├── ns.erl
└── ns_mod1.erl

こんな風に:

src
├── ns.erl
├── ns
     └── mod1.erl(モジュール名はns.mod1)

あと*.beamファイルの出力先も同じような構造になってなくちゃいけません. *.erlと同じところに入れるなら簡単ですが, ebinなど別のディレクトリに出力するならディレクトリを作りつつコンパイルしなくちゃいけないはず.

というコトでそれを行うRakefileを書いてみた*1. MakefileじゃないのはただボクがMakefileの書き方をよく知らないってだけです.

require 'rake/clean'

BEAM_DIR = 'ebin'

def dirname(files)
  (files.map {|f| File.dirname f}.uniq - [BEAM_DIR]).sort_by {|f| f.length}
end

BEAM_OBJ = FileList['src/**/*.erl'].sub(/^src/, BEAM_DIR).ext 'beam'
BEAM_DIRS = dirname BEAM_OBJ

CLOBBER.include BEAM_OBJ + BEAM_DIRS

BEAM_DIRS.each {|dir| directory dir}

def modules(*mods)
  files = mods.flatten.map do |mod|
    segs = mod.split '.'
    segs[-1] = "#{segs.last}.beam"
    File.join BEAM_DIR, segs
  end

  dirname(files) + files
end

def resolve(ext)
  lambda {|obj| obj.sub(/^#{BEAM_DIR}/, 'src').sub(/\.[^.]+$/, ".#{ext}")}
end

ERLC = 'erlc'
ERLC_FLAGS = '-W -Iinclude'

task :default => modules('ns', 'ns.mod1')

rule /\.beam$/ => resolve('erl') do |t|
  sh "#{ERLC} #{ERLC_FLAGS} -o #{File.dirname t.to_s} #{t.source}"
end

楽ちんだぜー!

*1:ebinは最初からある想定です. *.appを置かない場合はebinも作るようにした方がいいと思います

新しいふぇらほい forefrontの構造

単なる走り書きです.

(やべぇ, 透過しっぱなしだ)

灰色の四角形は全部モジュールで, 太めの字はモジュール名, その下に書かれた小さな字は使用しているビヘイビアの名前です(存在しないのはビヘイビアを使っていない). 水色で囲われたところはsupervision tree. ffhh_supの下に並ぶモジュールはsupervisorの子となります.

ffhh

各プロセスへのインタフェースを備えているだけ. このモジュールから各プロセスに指示を出す.

ffhh_app

アプリケーション. ただsupervision treeを作るよう指示するだけ. まじ中身空っぽ.

ffhh_sup

スーパバイザ. どんなsupervision treeを作るかを定義しているだけなので, ffhh_app並みに内容がない.

ffhh_logger

ロガー. さまざまなプロセスからのログ出力要求に応えてログを吐くだけ.

ffhh_director

ユーザーを保持しているディレクタ. こいつがタイミングを見計らってクローラの生成/回収を行う.

ffhh_crawler

指定されたユーザーのフォロワーを捜査するクローラ. スーパバイザの管理下ではないので注意. ただクローラの死亡はディレクタがきっちり面倒みてる.

ffhh_judgement

ユーザーがスパムかそうでないかを判断する. クローラはこいつによってフォロワーをスパム or スパムでない, に振り分ける.

ffhh_sweeper

アカウントをスパムとして報告またはブロックする. クローラによって集めたスパムアカウントをこいつによってほいほいする. ほいほいした情報も蓄えている.

Twitter Streaming APIをErlangから使ってみる

TwitterさんはたくさんAPIを公開しており, 開発者からしたら頭が下がる想いですが, その中でもちょっと特殊なStreaming API.

Twitter API Wiki / Streaming API Documentation

Public Timeline上を流れるつぶやきを次々と受け取るAPIですね. 種類があるのでとりあえず書いておきます.

メソッドの種類

sampleメソッド

Public Timeline上のつぶやきをランダムにピックアップし, 応答として返してくれるメソッドです. 「とりあえずどんな感じなのか知りたいんだけど」って人にもってこいです.

URL
http://stream.twitter.com/1/statuses/sample.format
リクエストメソッド
GET
firehoseメソッド

Public Timeline上の全つぶやきを応答として返してくれるメソッド. おそらく消化ホース(firehose)の中を流れる水のよう, として名付けられたんじゃないでしょうか? 全つぶやきなのでそれはそれは激流なんだろうなぁ, と勝手に想像してました.

しかしこのメソッド, 利用時のアカウントに特定のロール(権限)が与えられていないと使えません. なので普段はあまり使(わ|え)ないでしょう.

URL
http://stream.twitter.com/1/statuses/firehose.format
リクエストメソッド
GET
filterメソッド

使うならこっちのfilterメソッド. Public Timeline上から条件に該当するつぶやきを応答として返してくれるメソッドです. 指定できる条件はユーザーID(follow)または任意のキーワード(track), です. 双方ともカンマ区切りで複数指定できます. 片方, または両方指定するコトもできますが, 両方省略するコトはできません. firehoseメソッドと同じになっちゃいますからね.

注意するコトはfollowにはScreen nameではなく, ユーザーIDしか指定できません. ボクだったらtakkkunではなく, 14976270です. またtrackにはマルチバイト文字を指定するコトができないようです. またデフォルトではfollowに200個, trackに400個までしか指定できません.

URL
http://stream.twitter.com/1/statuses/filter.format
リクエストメソッド
POST
retweetメソッド

コレだけ試していないのでなんともですが, Public Timeline上を流れるRetweetsを応答として返すメソッドでしょう. そんな感じ.

URL
http://stream.twitter.com/1/statuses/retweet.format
リクエストメソッド
GET

気になるところ

@tsupo さんによるTwitter APIの日本語訳があります.

twitterAPI.txt at master from tsupo's Twitter-API-Specification--written-in-Japanese- - GitHub

ここのStreaming APIの項にgardenhose, birddog, shadowというのがあります. しかし先のStreaming API Documentにはありません. コレらは代わりにロールという位置づけになっており, そのロールが付与されると, filterメソッドのfollowまたはtrackに指定できるIDまたはキーワードの個数が増える, というものです.

どちらにせよロールは設けるのでしょうが, filterメソッドの制限を拡張するのか, 新たなメソッドが使えるようになるのか, どうなるか気になるところです.

Erlangから実際に使ってみる

とりあえず現状で動くコードを書いてみます. 試すだけならcurlを使えばいいんですが, コードから扱ってみたかったのでErlangで試してみました.

-module(twitter_stream).
-author("KONDO Takahiro <heartery@gmail.com>").

-export([filter/3, stop/1]).

filter(User, Password, Data) ->
    Url = lists:append(["http://", User, ":", Password, "@stream.twitter.com/1/statuses/filter.json"]),
    Request = {Url, [], "application/x-www-form-urlencoded", Data},
    stream(post, Request, processor()).

stop(Streamer) ->
    send(Streamer, stop),
    receive {Streamer, stopped} -> stopped end.

stream(RequestMethod, Request, Processor) ->
    spawn(fun() ->
                  Options = [{sync, false}, {stream, self}],
                  case http:request(RequestMethod, Request, [], Options) of
                      {ok, RequestId} -> loop(RequestId, Processor);
                      {error, Reason} -> send(Processor, {error, Reason})
                  end
          end).

loop(RequestId, Processor) ->
    receive
        {http, {RequestId, stream_start, Headers}} ->
            send(Processor, {start, Headers}),
            loop(RequestId, Processor);
        {http, {RequestId, stream, Part}} ->
            case Part of
                <<"\r\n">> -> noop;
                _          -> send(Processor, {stream, Part})
            end,
            loop(RequestId, Processor);
        {http, {RequestId, {error, Reason}}} ->
            send(Processor, {error, Reason});
        {From, stop} ->
            http:cancel_request(RequestId),
            send(From, stopped)
    end.

processor() ->
    spawn(fun() -> process_loop() end).

process_loop() ->
    receive
        {_Streamer, {start, Headers}} ->
            io:format("Start: ~p~n", [Headers]),
            process_loop();
        {_Streamer, {stream, Part}} ->
            io:format("Stream: ~p~n", [Part]),
            process_loop();
        {_Streamer, {error, Reason}} ->
            io:format("Error: ~p~n", [Reason])
    end.

send(To, Message) ->
    To ! {self(), Message}.

つぶやきひとつひとつは改行で区切られるので, それを元にして切り出しています.

http:request/4のオプションとして{stream, self}を用いると, http:request/4を呼び出したプロセスに次々とレスポンスが流れ込んでくるので, それを改行区切りで別プロセスに渡すだけです. 実用するならその別プロセス(コード上ではprocessor)を外から渡せるようにした方がいいでしょうね.

とりあえずこんな感じに使います. erlang(大文字小文字は関係ない)を含むつぶやきを受け取ります.

% erl
Erlang R13B01 (erts-5.7.2) [source] [smp:2:2] [rq:2] [async-threads:0] [hipe] [kernel-poll:false]
Eshell V5.7.2  (abort with ^G)
1> inets:start().
ok
2> Streamer = twitter_stream:filter("Twitter ID", "Password", "track=erlang").
<0.48.0>
% 標準出力に結果を出力しているので, ここに応答が次々と表示される
3> twitter_stream:stop(Streamer).
stopped

コレでStreaming APIからつぶやきを受け取りつついろんなコトができそうです.