ErlangでTwitterのUserStreamを受け取る

以前もErlangでTwitter Streaming APIを使うといったエントリを書いたのですが、いかんせん情報が古すぎます。UserStreamではなく、素のStreaming APIなのはともかく、認証がベーシック認証だったりします。

その割にはどうやら最近参照されているらしい。http://naoyat.hatenablog.jp/entry/2012/01/04/220639http://d.hatena.ne.jp/siritori/20120312/1331503357には以前のエントリのURLが貼られているようで。いや、なんかすみません。

ということで、ちゃんと動くかつOTPで書き直してみました。erlang-oauthに依存しています。

-module(userstream).
-author("Takahiro Kondo <heartery@gmail.com>").

-export([start/5, start/6, start_link/5, start_link/6, stop/1]).

-behavior(gen_server).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

-record(state, {id, processor}).

start(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret) ->
    start(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, []).

start(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, Options) ->
    Args = [Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret],
    gen_server:start(?MODULE, Args, Options).

start_link(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret) ->
    start_link(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, []).

start_link(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, Options) ->
    Args = [Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret],
    gen_server:start_link(?MODULE, Args, Options).

stop(Server) ->
    gen_server:cast(Server, stop).

%% callback functions

init([Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret]) ->
    Url = "https://userstream.twitter.com/2/user.json",
    Consumer = {ConsumerKey, ConsumerSecret, hmac_sha1},
    Options = [{sync, false}, {stream, self}],
    case oauth:post(Url, [], Consumer, AccessToken, AccessTokenSecret, Options) of
        {ok, Id}        -> {ok, #state{id = Id, processor = Processor}};
        {error, Reason} -> {stop, {http_error, Reason}}
    end.

handle_call(_, _, State) ->
    {noreply, State}.

handle_cast(stop, State) ->
    {stop, normal, State}.

handle_info({http, {Id, stream_start, Headers}}, #state{id = Id, processor = Processor} = State) ->
    send(Processor, {start, Headers}),
    {noreply, State};

handle_info({http, {Id, stream, <<"\r\n">>}}, #state{id = Id} = State) ->
    {noreply, State};

handle_info({http, {Id, stream, Part}}, #state{id = Id, processor = Processor} = State) ->
    send(Processor, {stream, Part}),
    {noreply, State};

handle_info({http, {Id, {error, Reason}}}, #state{id = Id, processor = Processor} = State) ->
    send(Processor, {error, Reason}),
    {stop, {http_error, Reason}, State}.

terminate(_, #state{id = Id, processor = Processor}) ->
    send(Processor, stop),
    httpc:cancel_request(Id).

code_change(_, State, _) ->
    {ok, State}.

%% private functions

send(To, Message) ->
    To ! Message.

ちゃんと動くかは確認しましたが、process_flagとかは呼んでいないのでそこらへんは適当に。gen_serverですので、ちゃんと設定すればそこまで手こずることなくsupervisor treeに組込めるかと思います。

本当はuserstreamモジュールをさらにビヘイビアにして、handle_status/2, handle_favorite/2とかで各イベントをハンドリングできるようにするといいんですが、それをやるとちょっと複雑になるので、そこまではやりません。

ちなみに使い方はこんな感じで。

-module(example).
-author("Takahiro Kondo <heartery@gmail.com>").

-export([start/0, stop/1]).

start() ->
    Processor = spawn(fun() -> process() end),
    ConsumerKey = "Your consumer key",
    ConsumerSecret = "Your consumer secret",
    AccessToken = "Your access token",
    AccessTokenSecret = "Your access token secret",
    userstream:start(Processor, ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret).

stop(Pid) ->
    userstream:stop(Pid).

process() ->
    receive
        {start, Headers} ->
            io:format("Start: ~p~n", [Headers]),
            process();
        {stream, Part} ->
            io:format("Stream: ~p~n", [Part]),
            process();
        {error, Reason} ->
            io:format("Error: ~p~n", [Reason]),
            process();
        stop ->
            io:format("Stop~n")
    end.
$ erl -s inets -s ssl
> {ok, Pid} = example:start().
ここにUserStreamからの応答が表示される(example:process/0で標準出力に吐き出してるため)
> example:stop(Pid).
Stop
>

OTPを使いつつ複雑すぎない書き方をしてみました。必ずしもOTPを使う必要はないですし、メリットばかりでもないんですが、アプリケーションがある程度複雑になってきたら使った方が良いかなと思います。それこそ書き捨てのコードでは不要でしょうが、あのプロセスが動いて、こっちであーでどーで、とかで頭のリソース割かれるならOTPを学ぶ価値はアリかなと。

余談ではありますが、余力があれば自作のtwitterモジュールをGitHubにでもあげておきたいもんですね。それにはTwitterREST APIはもちろん、先述したUserStream用ビヘイビアも書いてはあるんですよ。ただ随分前からメンテナンスをサボってるので、REST APIが古過ぎるという感じで…… なんか一から書いた方が早そう。

autotest-twitterでブヒる

最近とあるgemを書きながら付随するgemを書いてて優先すべきそれが中々進まない昨今ですこんばんは。

で、恥ずかしながらテストファーストってあんまりやったことなくて、今それを実践しながらの開発をしています。使っているツールはRSpecなんですが、コマンドひとつでテストできるとは言っても、今度はそのコマンドを実行するのがめんどくさい。ひたすら怠惰な生き物ですね。

そういう生き物たちにうってつけなのが当然あって、それのひとつにautotestってのがあります。しばらくはautotest + autotest-growlで開発してたんですが、家で使ってるマシンはMac、職場で使ってるマシンはUbuntuなんですね。Macには当然Growlをインストールしてるんですが、UbuntuとなるとGrowl以前の問題です。なので「Twitterにテストの結果をツイートして、あとは各OS向けのTwitterクライアントから通知すればいいんじゃね」と思い至りました。というわけでautotest-twitterです。まあ後からよく調べたらautotest-growlLinuxにも対応してましたけどね。ちくしょう。

使い方

README読めと言いたいところですが、GitHubに置いてあるのがいい加減なのでアレです。何がアレってテストを書いていないところですよね。まあとりあえずテストの対象となるアプリケーションなりライブラリが置いてあるディレクトリに.rspecを作り:

--format nested
--color

を、.autotestに:

require 'autotest-twitter'

Autotest::Twitter.configure do |config|
  # ツイートするアカウントのアクセストークンを設定
  config.consumer_key = 'your consumer key'
  config.consumer_secret = 'your consumer secret'
  config.oauth_token = 'your access token'
  config.oauth_token_secret = 'your access token secret'

  # ラベル。アプリケーションの名前とか
  config.label = 'any application'

  # テストの結果に応じてアイコンを変えられるので、そのアイコンが
  # 置いてあるディレクトリ
  #   - missing.png: テスト自体がない場合のアイコン
  #   - failed.png: テストに失敗した場合のアイコン
  #   - pending.png: ペンディングが存在する場合のアイコン
  #   - passed.png: テストに成功した場合のアイコン
  config.image_dir = 'path/to/icons'

  # テストの結果に応じたツイートの内容。$で始まるのは変数
  #   - $label: config.labelで設定した内容
  #   - $all: テストの全件数
  #   - $failed: 失敗したテストの件数
  #   - $pending: ペンディングしてるテストの件数
  config.missing_messages = ['$labelのテストが存在しないよ']
  config.failed_messages = ['$labelのテストに失敗したよ。$all件中$failed件がダメみたい']
  config.pending_messages = ['$labelのテストに$pending件のペンディングがあるよ']
  config.passed_messages = ['$labelのテストに成功したよ! $all件あったみたいだね']
end

を、Gemfileに:

source 'https://rubygems.org'

gem 'autotest'
gem 'autotest-fsevent'
gem 'autotest-twitter', :git => 'git://github.com/takkkun/autotest-twitter.git'

こう。で:

$ bundle --path vendor/bundle

でもしてautotest諸々をインストール。後は:

$ bundle exec autotest

でテストを開始。後はファイルに変更があるたびにテストが走り、結果に応じてツイートされるはずです。config.image_dirを設定してればアイコンも変わります。ちなみにRSpecでしか試していませんし、とりあえず動いてるっぽいってことしか確認してないのであしからず。

ちなみに僕は @Shinobu_DD で試していました。まるでアイコンセットのような画像(TVアニメ偽物語の一部でしょうが)があったので。でもまあ「$labelで$pending件ペンディングがあるようじゃな。お前様の生き様が垣間見えるの。かか」とか打ってると頭抱えたくなりますし、いざブヒろうにも全然テンション上がらないのであんま向いてなかったようです。ていうか元々そういうのじゃないし!

まあでもブヒろうと思えばブヒれるので、テストがコケたらツンツンされたり、テストが通ったらデレデレされたりして、「今日も開発がんばりましゅううう」とか言ってればいいんじゃないですかね。

あとさっき思い付いたんですけど、ツイートするアカウントを自分のアカウントにし、passed.pngをいつも使ってるアイコン、failed.pngをとてつもなく恥ずかしいアイコンにすると面白いんじゃないかと思います。はやくテスト通さないとエラい思いをするハメになるというマゾい開発が出来ていいんじゃないかナーーー。

Exporterでエクスポートされる関数の挙動を変更する

ひょんなところにこんなモジュールがあRuby

package Hoge;

use strict;
use warnings;
use base qw/Exporter/;

our @EXPORT = qw/hoge/;

sub hoge {
    print "Hoge::hoge called\n";
}

1;

このHogeモジュールのhoge関数を呼び出すためにこんなコードを書Chrome

use strict;
use warnings;

use Hoge;

hoge; # Hoge::hoge called

いろんな事情が絡んで、Hoge::hoge関数の挙動を変更したEthernet

use strict;
use warnings;

use Hoge;

{
    no warnings 'redefine';
    local *Hoge::hoge = sub { print "anon called" };
    hoge;
}

hoge; # Hoge::hoge called

一度目のhogeでanon calledと出力したいのだけど、これはHoge::hoge calledと出力されちゃWindows*1

Exporterを使うと、エクスポート対象となるパッケージ(main)に指定された関数がコピーされているので、元の関数(Hoge::hoge)を書き換えてももはや手遅Rails

だから*hogeを書き換えてやれば問題なかっTumblr

でもどの名前空間にあるhogeが呼ばれるか分からない時もある訳Debianコンパイル時に名前を解決するためにエクスポートしているけど、実際呼び出す時は必ず書き換えるようにしたりする訳Delphi

ということでこうすれば良いんじゃないNode.js。

package Hoge;

use strict;
use warnings;
use base qw/Exporter/;

our @EXPORT = qw/hoge/;

sub hoge { _hoge(@_) }

sub _hoge {
    print "Hoge::hoge called\n";
}

1;
use strict;
use warnings;

use Hoge;

{
    no warnings 'redefine';
    local *Hoge::_hoge = sub { print "anon called" };
    hoge; # anon called
}

hoge; # Hoge::hoge called

間接的に呼べば問題ないようDarwin。関数呼び出しのオーバヘッド増えるけど見ないふLisp

おっPython

*1:悔しい

単数形/複数形の変換ルールを独自に定義する

必要に迫られたので探してました. 案の定用意されたメソッドで好き勝手できるようになってました.

Rails御用達のActiveSupportの場合.

require 'active_support'
require 'active_support/inflector' # Railsは自動で取り込んでくれるだろうけど, ActiveSupportを単体で使う場合は取り込んでくれないみたいです

ActiveSupport::Inflector.inflections do |inflect|
  inflect.irregular 'octopus', 'octopi'
end

ActiveSupport::Inflector.inflections.irregular 'octopus', 'octopi' # コレでも一緒

イレギュラーなケース以外にもいろいろと定義できるので, ActiveSupport::Inflector::Inflectionsのドキュメントなり読みましょう.

Sequelもモデル名とテーブル名の変換に同様の仕組みを用いてる.

require 'sequel'

Sequel.inflections do |inflect|
  inflect.irregular 'octopus', 'octopi'
end

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見ればよかったなぁ, と思います