ついったかうんた

ついったのポスト数カウントツールを書いたので、置いてみました。
タンブラーにちょこちょこメモりつつ書いてた物です。

TwitterCounter.zip

解凍したフォルダ直下にあるTwitterCounter.bat(windowsの場合)かTwitterCounter.sh(unixとかの場合)を叩くと起動します。

実行すると

こんな感じです。
グラフは読み込みが進むにつれてちょっとずつ出て来ます。

グラフはGoogleGraphで出してるだけなので、URLをコピペすれば

とグラフを貼る事も出来ます。

ていうかこれがしたくて作ったツールです^^

groovyで書いたスクリプトで、まあそのまま「TwitterCounter.groovy」もついてるんですが、せっかくなんでここに全文晒します。
コメントが全く入ってなんで、ちょこちょことメモ書きも挟みつつ…

import java.text.*
import java.net.*
import org.apache.commons.lang.time.DateUtils

この辺はAPIからポスト時間を引っ張ってくるのに使ってます。

import java.awt.BorderLayout
import java.awt.EventQueue
import javax.swing.*
import groovy.swing.SwingBuilder

この辺はGUI書くなら絶対入ってくる類ですね。
SwingBuilderというのは、マークアップ風にswingのUIを作るためのクラスです。

def eachStatus = {user, pages, append, flush ->
    def slurper = new XmlSlurper()
    for (int page: 1..pages) {
        def url="http://twitter.com/statuses/user_timeline/${user}.xml" +
            "?count=200&page=${page}"
        def status = slurper.parse(url).status
        status.each(append)
        flush()
        if (status.size() < 200) {
            break;
        }
    }
}

頭から胆の部分です。TwitterのRestAPIからユーザタイムラインをどんどん遡って取って来ています。まぁ、取ってる最中に発言が有るとその分飛んじゃうんですが、大差はないので良いかと…

user, pagesは取得用のパラメータで、ユーザIDと取得するページ数を指定します。
appendとflushはコールバックで、それぞれ取得したstatusを渡す口と1ページ読み終わるごとのコールバック、となってます。

最後の「if (status.size() < 200)」は「最古のページまで取ったら」というチェックを意図してました。200件ずつとって、最後は半端になるから、と…
ですが、実際にはたいてい取得可能なページ数(今は16ページみたいです)を超えてしまって0件でここに来そうです。3200件以上発言してる人なら…

public class Summary {
    def map = new TreeMap()
    public void add(Date date) {
        def day = DateUtils.truncate(date, Calendar.DATE)
        if (!(map.containsKey(day))){
            map[day] = new ArrayList<Date>()
        }
        map[day].add(date)
    }
    
    public List<Date> getDays() {
        (map.keySet().min())..(map.keySet().max())
    }
    
    public getCounts() {
        days.collect{day->map.containsKey(day)?map[day].size():0}
    }
    
    public clear() {
        map.clear()
    }
}
def summary = new Summary()

で、こいつが集計用クラスです。Date型にしたポスト時間をaddして行くと、日付ごとにまとめてくれます。
getDaysは全くポストしなかった日が飛んでしまわないように、集計した日付の一番古い日付〜一番新しい日付までのRengeを作り直して返しています。
で、それに合わせてgetCountsもgetDaysが返す各日付に対しての件数を集め直しています。

String simpleEncode(
    values,
    max = values.collect({it == null || Double.isNaN(it) || it < 0 ? null : it}).max()
) {
    def format = ('A'..'Z') + ('a'..'z') + (0..9)
    values.collect(["chxr=1,0,${max}&chd=s:"]) {
        (it == null || Double.isNaN(it) || it < 0
            ? "_" : format[(int)(it * (format.size()-1) / max)])
    }.join()
}
def thinOut(list, condition) {
    def result = []
    list.each{it->
        if (condition(it))result += it
        else result += ""
    }
    return result
}
def makeDateLabels = {
    def list = thinOut(summary.days) {
        it.getAt(Calendar.DAY_OF_WEEK) == Calendar.MONDAY
    }
    list.collect{it instanceof Date?it.format('M/d'):it}
}
def makeColorBar = {
    def days = summary.days;
    def dateWidth = (int)(10000.0 / days.size() + 0.5)
    def count = 0;
    def result = []
    (days[0]..(days.size < 7 ? days[-1] : days[6])).each {day->
        def color = Calendar.with{
            ([SATURDAY, SUNDAY].contains(day.getAt(DAY_OF_WEEK))
                    ? "cccccc" : "ffffff")}
        if (result.isEmpty()) {
            result += color;
        }
        if (result[-1] == color) {
            count++
        } else {
            result += "${dateWidth * count / 10000.0}"
            result += color;
            count = 1
        }
    }
    result += "${dateWidth * count / 10000.0}"
    return result.join(",")
}

この辺はGoogleChartを叩くための書くオプションを組み立てる関数群です。
simpleEncodeがデータ系列を簡易エンコードに変換する関数、
thinOutはラベルを間引くための物で、リスト中の条件に一致しない項目を空文字列で置き換える関数、
makeDatesLabelはthinOutしたリストをラベル指定オプションに変換する関数、
makeColorBarは上手い事土日だけ背景色がグレーになる感じに背景指定を作る関数、
て感じです。

def twitterFormat = new SimpleDateFormat(
        "EEE MMM dd HH:mm:ss Z yyyy", Locale.US)

def update = {button, userId, pages, graph, out ->
    button.enabled = false
    new Thread({
        try {
            summary.clear()
            eachStatus(userId, pages,
                {summary.add(twitterFormat.parse(it.created_at.text()))},
                {
                    def url = new URL(
                        "http://chart.apis.google.com/chart?" + 
                       ["cht=bvs",
                        "chco=0099ff",
                        "chf=c,ls,0,${makeColorBar()}", 
                        "chbh=5,1,1",
                        "chs=${30+6*summary.days.size()}x200",
                        "chxt=x,y",
                        "chxl=0:|${makeDateLabels().join('|')}|",
                        simpleEncode(summary.counts)
                       ].join("&")
                    )
                    EventQueue.invokeLater({
                        graph.icon = new ImageIcon(url)
                        out.text = url.toString()
                    })
                }
            )
        } catch (IOException e) {
             EventQueue.invokeAndWait({JOptionPane.showMessageDialog(button,
"""タイムラインが取得できませんでした。IDを確認してみてください。

API制限、あるいはtwitterがダウン中の可能性もあります。
(ログイン無しのAPI使用は、同一IPについて1時間で100回までに制限されているようです。)

詳細:""" + e,
                 "タイムラインを取得できませんでした。",
                 JOptionPane.ERROR_MESSAGE)})
        } finally {
            EventQueue.invokeLater({button.enabled = true})
        }
    }).start()
}

この辺が本体です。
なんだかちゃんと色がついてませんが、"""〜"""はhereドキュメントで、catch節にある

"""タイムラインが取得できませんでした。IDを確認してみてください。

API制限、あるいはtwitterがダウン中の可能性もあります。
(ログイン無しのAPI使用は、同一IPについて1時間で100回までに制限されているようです。)

詳細:"""

は全部文字列です。

しかし、こうして見てみると、catchは外だしにしといた方が良かったかもですねー。
変にでかくて醜い。ダイアログ出してるだけなんですけど…

  1. ボタンを無効にして、
  2. スレッド立てて

+eachStatusでタイムラインを取ってはグラフを描画して、
+エラーが出たらダイアログ、
+なんにせよ最後はボタンを有効に戻す

て感じです。
ちょっとしたイベントリスナが、気がついたらMediator的な役割も持ってしまって
引数が大きくなってます…

ここはちゃんとViewのファサードを作ってもうちょっとこぎれいにしたい所ですね…

def fetch

def frame = new SwingBuilder().frame(
        title: "ついったかうんた",
        size: [500, 300],
        defaultCloseOperation: JFrame.DISPOSE_ON_CLOSE) {

    def graph;
    def out;

    box(constraints: BorderLayout.NORTH) {
        label("ユーザID:")
        def userId = textField(columns:10)
        widget(Box.createHorizontalStrut(20))
        label("取得ページ数:")
        def pages = widget(new JFormattedTextField(new DecimalFormat("##0")))
        pages.columns = 2
        pages.value = 16
        widget(Box.createHorizontalStrut(20))
        fetch = button("取得",
                actionPerformed:{update(fetch, userId.text, pages.value, graph, out)})
    }
    
    scrollPane(constraints: BorderLayout.CENTER) {
        graph = label();
    }
    box (constraints: BorderLayout.SOUTH) {
        label("グラフ画像URL:")
        out = textField(editable:false)
    }
}
frame.rootPane.defaultButton = fetch
frame.visible = true

で、最後はSwingBuilderを使ってGUIを組み立てて、表示です。
SwingBuilderは結構使ってるんですけど、今ひとつありがたみがわかんないんですよねー…
というか、なんかもうちょっときれいに書けそうなのに使いこなせてない感が有って…

ま、思い通りに組み立てられてるので、今は良しとします。