2010年4月29日木曜日

Google App EngineとSafe Browsing APIを利用して短縮URLチェッカーを作ってみた

このブログ記事をはてなブックマークに追加

最近、Twitterやはてなブックマークなどを使うことが多くなり、字数制限付きのコメントを書くことが増えた。コメント中にURLを入れたいこともままあるのだが、長いURLだと字数をオーバーしてしまう。そのため短縮URLを使っているが、コメント内に直接短縮URLを入れるのがやっかいだった。そこで、Google App Engine (GAE)を使ってシンプルで高速な短縮URL変換ツールを作成してみた。

しかし、短縮URLは悪意のあるサイトの判別が難しいという欠点がある。見た目で判断できないので開くのを躊躇してしまう。そこで、今回作成したツールでは、短縮URLに変換するだけではなく、それを元のURLに戻す機能も追加した。さらに、Google Safe Browsing APIを利用してフィッシングサイトなどの悪意あるサイトの判別も試みている。

短縮URLチェッカー

使い方はシンプルで、テキストボックスに変換したいURLを入力すればbit.lyの短縮URLがテキストボックス表示され、bit.lyの短縮URLを入力すればもとのURLが表示される。テキストボックスをクリックすれば表示されているURLが選択されるのでクリップボードへのコピーも簡単だ。テキストボックスの下には展開されたURLがリンク付きで表示されるが、もしフィッシングサイトだと疑われる場合は、以下のように警告してくれる。


さて、今回のコード作成だが、bit.lyによる短縮URLの変換は簡単だった。というのも以前にPythonスクリプトで作ったことがあるからだ。それをそのままGAEに載せればいい。しかし、Safe Browsing APIによる悪意あるサイトの検出は思ったよりも面倒だった。まず、APIの使い方がよく分からない。使う前は任意のURLをAPIで確認するのかと思っていたのだが、そうではなく、MD5でハッシュ化したリストをダウンロードして、調べたいURLをハッシュ化してそれと照らし合わせなくてはならない。今回問題になったのはそのダウンロード容量で、フィッシングサイト用データが700KB程、マルウェア用データが10MBを超えていた。GAEでは一回のダウンロード容量の上限が1MBと決まっており、フィッシングサイト用データは何とかダウンロードできても、マルウェア用データは途中で切れてしまった。仕方がないので今回はフィッシングサイト用のみを使うことにした。ただし現在、Safe Browsing APIの新しいバージョンが開発中であり、それを使えば何とかなるかもしれない。自分がちょっと試したときには何故か新しいAPIが使えなかったので、今回は見送ることにした。気が向いたら試してみるかも。

APIによりダウンロードされるリストはMD5でハッシュ化されているが、ハッシュ化される前のURLは標準化された参照URLだ。そこで、調べるURLについても標準化して参照URLを作成しなくてはならない。あまり時間を掛けたくなかったので、今回は標準化については実装しなかった。参照URLの作成についてはThejaswi Puthraya氏が作成したコードを利用させてもらった。URLの標準化についてはGoogleのサイトにも参考例が載っているので、時間ができたら作ってみたい。

ダウンロードされたリストはデータストアに格納している。ハッシュ化されたURLを個別にデータストアに入れるとデータ数が非常に多くなってしまうので、ハッシュ化リストの先頭の2桁(16進数)をインデックスにし、256分割のデータとして格納した。また、リスト更新中にデータストア上のURLの参照が行われても問題が発生しないように、データモデルを2つ用意して更新は交互に行うことにした。加えて、データストアへのアクセスを減らすためMemcacheを利用している。

フィッシングサイト用リストはcronを使って30分毎に更新している。しかし、リスト更新の際に処理が30秒を超えることがあり、そのため更新に失敗してしまうことがある。これはGAEの制限なのだが、前述のデータ取得の容量制限と合わせて、このあたりが改善されるとより素晴らしくなるのだけどなぁ。無料で使わせてもらっているのに贅沢かもしれないけど。

追記(2010/5/2): リスト更新の半分以上が30秒を超えてエラーになったので、TaskQueueによる処理に変更した。

最後に今回作成したソースコード(実装したクラスのみ)を示しておく。

class Bitly(): def __init__(self): self.apiurl = "http://api.bit.ly/%s?version=2.0.1&%s=%s&login=BITLY_ID&apiKey=BITLY_API_KEY" self.api_index = { "shorten": "longUrl", "expand": "shortUrl" } self.info_index = { "shorten": "shortUrl", "expand": "longUrl" } def is_error(self, url, url_info): return url_info["statusCode"] != "OK" or (url_info["results"][url].has_key("statusCode") and url_info["results"][url]["statusCode"] != "OK") def bitly(self, url, api): url_data = urllib2.urlopen(self.apiurl % (api, self.api_index[api], urllib.quote(url))).read() url_info = simplejson.loads(url_data) if api == "expand": url = url.replace("http://bit.ly/", "") if self.is_error(url, url_info): return "" return url_info["results"][url][self.info_index[api]] def get(self, url): if url.lower().split(":")[0] not in ("http", "https", "ftp"): url = "http://" + url bitly_url = "" if re.match(r"http://bit.ly/", url) and not re.search("[^a-z^A-Z^0-9]", url.replace("http://bit.ly/", "")): self.shorten_url = url bitly_url = self.bitly(url, "expand") self.long_url = bitly_url if not bitly_url: self.long_url = url bitly_url = self.bitly(url, "shorten") self.shorten_url = bitly_url return bitly_url class DataProperty(db.Model): phishing = db.IntegerProperty() malware = db.IntegerProperty() class PhishingDataA(db.Model): index = db.IntegerProperty() hash_urls = db.TextProperty() class PhishingDataB(db.Model): index = db.IntegerProperty() hash_urls = db.TextProperty() class MalwareDataA(db.Model): index = db.IntegerProperty() hash_urls = db.TextProperty() class MalwareDataB(db.Model): index = db.IntegerProperty() hash_urls = db.TextProperty() class ShortUrl(webapp.RequestHandler): def __init__(self): self.html = u"""<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" lang="ja"> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta http-equiv="content-script-type" content="text/javascript" /> <meta http-equiv="content-style-type" content="text/css" /> <link type="text/css" rel="stylesheet" href="/stylesheets/main.css" /> <title>短縮URLチェッカー</title> </head> <body> <form action="/short_url" method="post"> <div>URL: <input type="text" name="content" size="31" maxlength="1024" value="%s" onclick="this.select()" /> <input type="submit" value="変換" /></div> </form> %s <div style="font-size:xx-small;"><p>短縮URLの変換およびフィッシングサイトの判別を行います。[ver.20100502]<br /><a href="http://code.google.com/apis/safebrowsing/safebrowsing_faq.html#whyAdvisory">Advisory provided by Google.</a></p></div> </body> </html>""" def lookup_by_url(self, url): """from http://github.com/theju/safebrowsing-python""" url_re = re.compile("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?") url = url.lower() url_components = url_re.match(url).groups() lookup_list = set() hostname = url_components[3] hostname_comp = hostname.split(".") if not hostname_comp: raise AttributeError("Invalid URL.") for i in xrange(len(hostname_comp) - 1): filtered_hostname_comp = ".".join(hostname_comp[i:]) lookup_list.add(filtered_hostname_comp + "/") if url_components[4]: path = url_components[4].split('/') for j in xrange(len(path) + 1): filtered_paths = '/'.join(path[:j]) if not '.' in filtered_paths: lookup_list.add(filtered_hostname_comp + "%s/" % filtered_paths) lookup_list.add(filtered_hostname_comp + url_components[4]) if url_components[5]: lookup_list.add(filtered_hostname_comp + ''.join(url_components[4:6])) if url_components[7]: lookup_list.add(filtered_hostname_comp + ''.join(url_components[4:6]) + url_components[7]) return lookup_list def get(self): self.response.out.write(self.html % ("", "")) def post(self): content = self.request.get('content') bitly = Bitly() bitly_url = bitly.get(content) dp = memcache.get("dp") if not dp: dp = DataProperty().all().get() memcache.add("dp", dp) phishing, malware = dp.phishing, dp.malware P = PhishingDataA if phishing == 0 else PhishingDataB M = MalwareDataA if malware == 0 else MalwareDataB urls = self.lookup_by_url(bitly.long_url) hash_urls = [hashlib.md5(x).hexdigest() for x in urls] is_phishing, is_malware = False, False for hash_url in hash_urls: idx = int(hash_url[:2], 16) phishing_list = memcache.get("phishing_%d" % idx) if not phishing_list: phishing_list = P().all().filter("index =", idx).get().hash_urls memcache.add("phishing_%d" % idx, phishing_list) if re.search(hash_url, phishing_list): is_phishing = True """ malware_list = memcache.get("malware_%d" % idx) if not malware_list: malware_list = M().all().filter("index =", idx).get().hash_urls memcache.add("malware_%d" % idx, malware_list) if re.search(hash_url, malware_list): is_malware = True """ link = '<a href="%s">%s</a>' % (bitly.long_url, bitly.long_url) if is_phishing: link += u'<br />フィッシングサイトの疑いがあります。<span style="font-size:xx-small;color:gray;">詳しくは<a href="http://www.antiphishing.org">AntiPhishing.org</a>を参照してください。</span>' if bitly_url: self.response.out.write(self.html % (bitly_url, "<div><p>%s</p></div>" % link)) else: self.response.out.write(self.html % ("", u"<div><p>変換できませんでした。</p></div>")) class SafeBrowsing(webapp.RequestHandler): def __init__(self): self.urls = { "phishing": "http://sb.google.com/safebrowsing/update?client=api&apikey=SAFE_BROWSING_API_KEY&version=goog-black-hash:1:-1", "malware": "http://sb.google.com/safebrowsing/update?client=api&apikey=SAFE_BROWSING_API_KEY&version=goog-malware-hash:1:-1" } def get_data(self, target, selection): idx = 0 txt = "" all_data = urllib2.urlopen(self.urls[target]).read().split() for l in all_data: l = l.strip() if len(l) == 33 and l[0] == '+': current_idx = int(l[1:3], 16) if idx != current_idx: taskqueue.add(url="/short_url/safe_browsing/process", params={"target": target, "selection": selection, "idx": idx, "txt": txt}) txt = "" idx = current_idx txt += l taskqueue.add(url="/short_url/safe_browsing/process", params={"target": target, "selection": selection, "idx": idx, "txt": txt}) def get(self): target = self.request.get("target") if target in ("phishing", "malware"): dp = DataProperty().all().get() if not dp: dp = DataProperty(phishing=0, malware=0) dp.put() dp = dp.all().get() if target == "phishing": selection = abs(dp.phishing - 1) self.get_data(target, selection) dp.phishing = selection else: selection = abs(dp.malware - 1) self.get_data(target, selection) dp.malware = selection self.response.out.write("Done: %s, %d" % (target, selection)) dp.put() memcache.delete("dp") else: self.response.out.write("Incorrect: %s" % target) class SafeBrowsingProcess(webapp.RequestHandler): def __init__(self): self.data = { "phishing": lambda (idx): PhishingDataA if idx == 0 else PhishingDataB, "malware": lambda (idx): MalwareDataA if idx == 0 else MalwareDataB } def post(self): target = self.request.get("target") selection = int(self.request.get("selection")) idx = int(self.request.get("idx")) txt = self.request.get("txt") M = self.data[target](selection) model = M().all().filter("index =", idx).get() if not model: model = M(index=idx, hash_urls="") model.hash_urls = txt model.put() memcache.delete("phishing_%d" % idx)

続きを読む...

2010年4月14日水曜日

一つのGoogle App Engineアプリケーションで複数のWaveボットを作成する

このブログ記事をはてなブックマークに追加

Google App Engine (GAE)では現在10個までのアプリケーションを登録することができる。そして、GAEを利用してGoogle Waveのボットを作成することができる。しかし、多種多様な利用方法が存在するWaveボットをGAEアプリケーションとして登録していてはすぐに上限に達してしまうだろう。そこで、一つのGAEアプリケーションで複数のWaveボットを作成するためのいくつかの方法を利用することになる。サブドメインを利用する方法が一般的だろうか。

しばらく前からサブドメインを利用して複数のWaveボットを作成しようと思っていたのだが、ちょっと面倒に思えて手を付けていなかった。そうしたら、technohippy氏によるappengine_multi_robot_runnerというライブラリが公開され、それがとても便利そうだったので簡単なWaveボットを作成してみた。ただし、Google Wave Robots API v2が3月30日にアップデートされて実装の一部が変更されたことにより、appengine_multi_robot_runner.pyがそのままでは動作しなかったので該当箇所を修正した。それについては最後に記述しておく。

追記(2010/4/15): 現在、appengine_multi_robot_runnerは最新版に更新されているので、後述の修正は必要無くなっている。

作成したのはインチキ日本語ボット(fake-japanese.robotic-wave@appspot.com)とインチキ英語ボット(fake-english.robotic-wave@appspot.com)の二つのWaveボットだ。インチキ日本語ボットでは、書き込んだ日本語を一度Googleにより英語に翻訳し、それを再度日本語に翻訳し直して表示する。インチキ英語ボットは、日本語を英語に翻訳したものを表示する。インチキと書いたが、特にでたらめにするために細工しているわけではない。現在の翻訳技術ではインチキっぽく見えてしまうというだけである。因みに、これらのボットのアイコン画像はAtnet Japan!を利用させてもらった。

どのように動作するか、以下の例文を実際に入力して動作を見てみよう。

「花便り」
「ここ数日は暖かな日が続いていますが、その後いかがお過ごしでしょうか。」
「待ちに待った桜の花もそろそろ咲き始めております。近いうちにお花見にお誘いしようと思っておりますが、ご都合はいかがでしょうか。」

インチキ日本語ボット(fake-japanese.robotic-wave@appspot.com)では以下の通り。


インチキ英語ボット(fake-english.robotic-wave@appspot.com)では以下の通り。


後述のソースコードを見てもらっても分かるように、この程度の内容であれば非常に短いコードで済んでしまう。Waveボットは一般的なネットボットよりもインタラクティブに利用でき、効果も大きいので、このように簡単に作成できることは非常に重要だと思う。簡単に作成できると言えば、technohippy氏によるas-a-robot@appspot.comを利用することで、GAEを意識せずにGoogle Wave上で簡単にWaveボットを作成することができる。

以下にWaveボットのソースコードを示す。

robotic-wave.py

#!/usr/bin/env python # -*- coding: utf-8 -*- """robotic-wave.py by nox, 2010.4.14""" import urllib, urllib2 from waveapi import robot from waveapi import events from waveapi import simplejson import appengine_multi_robot_runner def Translate(text, from_lang, to_lang): url = "http://ajax.googleapis.com/ajax/services/language/translate?v=1.0&q=%s&langpair=%s%%7C%s" % (urllib.quote(text.encode("utf-8")), from_lang, to_lang) data = simplejson.loads(urllib2.urlopen(url).read()) return data["responseData"]["translatedText"] class FakeJapaneseRobot(robot.Robot): def __init__(self): robot.Robot.__init__(self, "FakeJapanese", image_url="http://robotic-wave.appspot.com/assets/edodaikan03i.gif", profile_url="http://robotic-wave.appspot.com/") self.register_handler(events.BlipSubmitted, self.OnBlipSubmitted) def OnBlipSubmitted(self, event, wavelet): blp = event.blip text = blp.text.strip() text = Translate(Translate(text, "ja", "en"), "en", "ja") blp.range(1, len(blp.text)).replace(text.encode("utf-8")) class FakeEnglishRobot(robot.Robot): def __init__(self): robot.Robot.__init__(self, "FakeEnglish", image_url="http://robotic-wave.appspot.com/assets/202aschwarzenegger.gif", profile_url="http://robotic-wave.appspot.com/") self.register_handler(events.BlipSubmitted, self.OnBlipSubmitted) def OnBlipSubmitted(self, event, wavelet): blp = event.blip text = blp.text.strip() text = Translate(text, "ja", "en") blp.range(1, len(blp.text)).replace(text.encode("utf-8")) def main(): appengine_multi_robot_runner.compound_and_run([ ("fake-japanese", FakeJapaneseRobot()), ("fake-english", FakeEnglishRobot()) ]) if __name__ == "__main__": main()

app.yaml

application: robotic-wave version: 1 runtime: python api_version: 1 handlers: - url: /_wave/(.*) script: robotic-wave.py - url: /assets static_dir: assets - url: /(.*) script: index.py

Google Wave Robots API v2が3月30日にアップデートされ実装が一部変更された。そのため、appengine_multi_robot_runner.pyが動作しなくなったのでそれを修正するために加えた変更を以下に示しておく。

変更箇所その1。

class GetHandler(appengine_robot_runner.GetHandler): def __init__(self, method, contenttype): appengine_robot_runner.GetHandler.__init__(self, method, contenttype) def get(self): self.response.headers['Content-Type'] = self._contenttype self.response.out.write(self._method(host=self.request.host))

上記のコードを以下のように変更する。

class CapabilitiesHandler(appengine_robot_runner.CapabilitiesHandler): def __init__(self, method, contenttype): appengine_robot_runner.CapabilitiesHandler.__init__(self, method, contenttype) def get(self): self.response.headers['Content-Type'] = self._contenttype self.response.out.write(self._method(host=self.request.host)) class ProfileHandler(appengine_robot_runner.ProfileHandler): def __init__(self, method, contenttype): appengine_robot_runner.ProfileHandler.__init__(self, method, contenttype) def get(self): self.response.headers['Content-Type'] = self._contenttype if self.request.get('name'): self.response.out.write(self._method(self.request.get('name'), host=self.request.host)) else: self.response.out.write(self._method(host=self.request.host))

変更箇所その2。

([('/_wave/capabilities.xml', lambda: GetHandler(robot.capabilities_xml, 'application/xml')), ('/_wave/robot/profile', lambda: GetHandler(robot.profile_json, 'application/json')),

上記のコードを以下のように変更する。

([('/_wave/capabilities.xml', lambda: CapabilitiesHandler(robot.capabilities_xml, 'application/xml')), ('/_wave/robot/profile', lambda: ProfileHandler(robot.profile_json, 'application/json')),

続きを読む...

2010年4月6日火曜日

Android Scripting Environment (ASE) Python API簡易リファレンス

このブログ記事をはてなブックマークに追加

Android Scripting Environment (ASE)について、「Android上でPython、Lua、JavaScriptなどを実行するスクリプティング環境が凄い」で紹介した。今回はAndroid端末の機能を利用するためのPython APIの使い方をリファレンスとしてまとめてみた。Python 2.6の標準モジュールは最初から利用できる。また、twitterモジュールなど、いくつかの標準外のモジュールがデフォルトでインストールされている。詳細については利用する環境で確認して欲しい。

ASE上のPythonスクリプトの編集画面でメニューボタンを押し、そこから"API Browser"で簡単なリファレンスを読むことができる。また、ASEのWikiとしてWiki pages - android-scripting、サンプルプログラムとしてtest.pyが参考になる。

以下にASE Python APIの簡易リファレンスを示すが、APIのすべてを記しているわけではない。足りない部分については随時追加していく予定だ。

Androidモジュールを使用する

import android droid = android.Android()

現在のクリップボード取得と貼り付け

clip = droid.getClipboard()["result"] droid.setClipboard("Hello, world!")

GDataの使用

import gdata.docs.service client = gdata.docs.service.DocsService() # クライアント. client.ClientLogin(username, password) # 接続. feed = client.GetDocumentListFeed() # ドキュメントリストのAtomをフィード.

GPSで現在の位置情報を取得

droid.startLocating() # 開始. location = droid.readLocation()["result"] lat = location["network"]["latitude"] # 緯度. lng = location["network"]["longitude"] # 経度. droid.stopLocating() # 停止.

位置情報取得などのイベントを受け取るタイプのAPIでは、開始直後にイベントを取得できない場合がある。そこで以下のように数回リトライできるようにする。

for i in range(10): location = droid.readLocation()["result"] if location: break time.sleep(1)

Android端末のセンサー情報を取得

droid.startSensing() # 開始. sensors = droid.readSensors()["result"] droid.stopSensing() # 停止.

センサー情報はディクショナリとして保存されている。

sensors accuracy 精度. azimuth 方位(0-360) pitch 縦の傾き. roll 横の傾き. xforce X方向の加速度. yforce Y方向の加速度. zforce Z方向の加速度. xmag X方向の磁場. ymag Y方向の磁場. zmag Z方向の磁場.

Android端末で音声出力

droid.speak("Hello, world!")

Android端末で音声認識

result = droid.recognizeSpeech()["result"] droid.makeToast(result) # 認識した語句を表示.

指定したURLでウェブブラウザを開く

droid.view("http://www.google.com/")

指定した語句をネット検索

result = droid.recognizeSpeech()["result"] # 音声認識で語句を指定. droid.webSearch(result)

通話状況の取得

droid.startTrackingPhoneState() # 開始. result = droid.readPhoneState()["result"] droid.stopTrackingPhoneState() # 停止.

サイレントモードのトグル

droid.toggleRingerSilentMode()

電話呼び出し音の音量情の取得と変更

vol = droid.getRingerVolume() droid.setRingerVolume(0) # 音量を0にする. droid.setRingerVolume(vol["result"]) # 元に戻す.

最後に取得した位置情報の取得

result = droid.getLastKnownLocation()

ジオコード情報を取得

result = droid.geocode(35.698, 139.774) # 緯度, 経度.

Wi-Fiのトグル

droid.toggleWifiState()

Android端末に文字を表示

droid.makeToast("Hello, world!")

Android端末を振動させる

droid.vibrate()

Android端末にメッセージを送る

droid.notify("Hello, world!")

実行中のAndroidパッケージの取得(com.android.phoneなど)

result = droid.getRunningPackages()

テキスト入力ダイアログの表示

result = droid.getInput("title", "message")["result"]

ボタン付きダイアログの表示

droid.dialogCreateAlert("title", "message") droid.dialogSetPositiveButtonText("Yes") droid.dialogSetNegativeButtonText("No") droid.dialogSetNeutralButtonText("Cancel") droid.dialogShow() response = droid.dialogGetResponse()["result"]

response["which"]には以下の値が入る: Yesボタン: "positive" Noボタン: "negative" Cancelボタン: "neutral"

指定したボタンが1つ(例えばdroid.dialogSetPositiveButtonText("Yes"))なら1つのボタンのみを表示する。

スピナープログレス(回転型進捗アイコン)付きダイアログ

droid.dialogCreateSpinnerProgress("title", "message") droid.dialogShow() time.sleep(2) droid.dialogDismiss()

プログレスバー付きダイアログ

droid.dialogCreateHorizontalProgress("title", "message", 50) # 50分割. droid.dialogShow() for i in range(50): time.sleep(0.1) droid.dialogSetCurrentProgress(i) droid.dialogDismiss()

リスト付きダイアログの表示

droid.dialogCreateAlert("title") droid.dialogSetItems(["foo", "bar", "baz"]) droid.dialogShow() response = droid.dialogGetResponse()["result"]

response["item"]にはリストの順に0からの数値が入る。

追記(2010/6/27):

ASE r22からreadLocation()のデータ構造が変更されたようなので、それに合わせて上述のコードを修正した。

続きを読む...