No Programming, No Life

プログラミング関連の話題や雑記

HatenaHaikuAPI for Groovy

ふもぼ(h:id:fumobot)が使っている
はてなハイクAPIをGroovyから操るAPIをせっかくなので公開してみます。
#ただし、自分用に書いているので、使いにくいかもしれません。エッセンスだけでもどうぞ。

HatenaHaikuAPI.groovy

import java.text.*

class HatenaHaikuAPI {
	String username = null
	String password = null
	String client   = 'Web'

	/**
	 * ステータスを取得しマップして返却
	 * マップに指定できる値
	 *   ・id         エントリID
	 *   ・keyword    キーワード
	 *   ・user       ユーザID
	 *   ・friends    フレンドタイムラインを探すユーザID
	 *   ・page       指定のページを探す
	 *   ・count      取得件数(最大200件, 省略時20件)
	 *   ・sinceDate  指定の日付より新しい投稿のみ探す
	 *   ・sinceId    指定のエントリIDの日付より新しい投稿のみ探す
	 */
	def getStatus(Map param) {
		def reqParams = [:]
		if (param?.sinceDate) {
			reqParams['since'] = toDateTimeTZ(param.sinceDate)
		} else if (param?.sinceId) {
			def idStatus = getStatus(id: param.sinceId)
			if (idStatus) reqParams['since'] = toDateTimeTZ(idStatus.first().createdAt)
		}
		if (param?.page)  reqParams['page']  = param.page
		if (param?.count) reqParams['count'] = param.count

		def paramStr = reqParams.collect{ "${it.key}=${URLEncoder.encode(it.value.toString(), 'UTF-8')}" }.join('&')

		switch(param) {
			// id指定ありの場合
			case {param?.containsKey('id')} :
				def id     = URLEncoder.encode(param.id, 'UTF-8')
				def url    = "http://h.hatena.ne.jp/api/statuses/show/${id}.xml"
				println "GET ${url}"
				def result = []
				try {
					def status = new XmlParser().parse(url)
					if (status) {
						result = toStatusMap(status)
					} else {
						String msg = "指定したID ${param.id} が不正。"
						LogUtil.error(msg)
					}
				} catch(FileNotFoundException fnfe) {
					String msg = "指定したID ${param.id} が不正。"
					LogUtil.error(msg)
				}
				return result

			// keyword指定ありの場合
			case {param?.containsKey('keyword')} :
				def keyword  = URLEncoder.encode(param.keyword, 'UTF-8')
				def url      = "http://h.hatena.ne.jp/api/statuses/keyword_timeline/${keyword}.xml?${paramStr}"
				println "GET ${url}"
				def statuses = new XmlParser().parse(url)
				def result   = []
				statuses.each { result << toStatusMap(it) }
				return result

			// user指定ありの場合
			case {param?.containsKey('user')} :
				def user     = URLEncoder.encode(param.user, 'UTF-8')
				def url      = "http://h.hatena.ne.jp/api/statuses/user_timeline/${user}.xml?${paramStr}"
				println "GET ${url}"
				def statuses = new XmlParser().parse(url)
				def result   = []
				statuses.each { result << toStatusMap(it) }
				return result

			// friends指定ありの場合
			case {param?.containsKey('friends')} :
				def friends  = URLEncoder.encode(param.friends, 'UTF-8')
				def url      = "http://h.hatena.ne.jp/api/statuses/friends_timeline/${friends}.xml?${paramStr}"
				println "GET ${url}"
				def statuses = new XmlParser().parse(url)
				def result   = []
				statuses.each { result << toStatusMap(it) }
				return result

			// パブリックタイムラインから取得
			default :
				def url      = "http://h.hatena.ne.jp/api/statuses/public_timeline.xml?${paramStr}"
				println "GET ${url}"
				def statuses = new XmlParser().parse(url)
				def result   = []
				statuses.each { result << toStatusMap(it) }
				return result
		}
	}

	/**
	 * Hot Keywordsのリストを取得する
	 * @param depth related_
	 */
	def showHotKeywords(depth = 1) {
		int count = depth - 1
		def list = []
		def relatedList = []

		// Hot全体を取ってくる
		def url = 'http://h.hatena.ne.jp/api/keywords/hot.xml'
		println "GET ${url}"
		def keywords = new XmlParser().parse(url)
		list.addAll(keywords.keyword*.title*.text())
		relatedList.addAll(keywords.'**'.related_keywords*.text())
		relatedList = relatedList.unique()

		// depthが2以上なら、relatedListの内容を追加
		if(count >= 1){
			list.addAll(relatedList)
			count--
		}

		// depthが3以上ならなくなるまで繰り返し探す
		while(count) {
			list.addAll(relatedList)
			def nextRelatedList = []
			def size = relatedList.size()
			relatedList.eachWithIndex{ item, idx ->
				print "${idx+1}/${size} "
				def kw = this.showKeyword(item)
				if(kw) nextRelatedList.addAll(kw.relatedKeywords)
			}
			nextRelatedList = nextRelatedList.unique()
			list.addAll(nextRelatedList)
			relatedList = nextRelatedList
			count--
		}

		return list.unique() - [null]
	}

	/** キーワードリストを取得 */
	def showKeywordsList(Map param) {
		def reqParams = [:]
		if (param?.page)  reqParams['page']  = param.page
		if (param?.word)  reqParams['word']  = param.word

		def paramStr = reqParams.collect{ "${it.key}=${URLEncoder.encode(it.value.toString(), 'UTF-8')}" }.join('&')

		try {
			def url = "http://h.hatena.ne.jp/api/keywords/list.xml?${paramStr}"
			println "GET ${url}"
			def list = new XmlParser().parse(url)
			list ? list.keyword.collect{ toKeywordMap(it) } : []
		} catch(Exception e) {
			println "取得失敗 ${e}"
			[]
		}
	}

	/**
	 * keywordノードを取得してくる。
	 */
	def showKeyword(searchKeyword) {
		def url = "http://h.hatena.ne.jp/api/keywords/show/${URLEncoder.encode(searchKeyword, 'UTF-8')}.xml"
		println "GET ${url}"
		try {
			return toKeywordMap(new XmlParser().parse(url))
		} catch(Exception e) {
			return [:]
		}
	}

	/**
	 * エントリする。
	 * @param message 投稿するメッセージ [必須]
	 * @param keyword キーワード         [省略された場合idページへ投稿]
	 */
	def entry(message, keyword = null) {
		post(message, keyword)
	}

	/**
	 * 特定のエントリに返信する。
	 * @param message 投稿するメッセージ [必須]
	 * @param replyId 返信先メッセージID [必須]
	 */
	def reply(message, replyId) {
		post(message, null, replyId)
	}

	/**
	 * ポストする。
	 * @param message 投稿するメッセージ [必須]
	 * @param keyword 投稿するメッセージ [省略された場合idページへ投稿]
	 * @param replyId 投稿するメッセージ [省略された場合通常エントリ]
	 */
	def post(message, keyword = null, replyId = null) {
		// コネクション接続
		URL url = new URL('http://h.hatena.ne.jp/api/statuses/update.xml')
		def authenticator = new HttpAuthenticator('username':username, 'password':password)
		Authenticator.default = authenticator
		def urlconn = url.openConnection() as HttpURLConnection
		urlconn.requestMethod = 'POST'
		urlconn.doOutput = true

		// ポスト内容の表示
		println "keyword=[${keyword}]"
		println "message=[${message}]"
		println "client =[${client}]"
		println "replyId=[${replyId}]"

		// ポストする文字
		def postStr = [
		    'source'  : client,   // クライアント名
		    'status'  : message,  // 本文
		]
		if (keyword) postStr['keyword'] = keyword               // キーワード
		if (replyId) postStr['in_reply_to_status_id'] = replyId // 返信先ID

		try {
			def writer = new PrintWriter(urlconn.outputStream)
			writer.write(postStr.collect{ "${it.key}=${URLEncoder.encode(it.value, 'UTF-8')}" }.join('&'))
			writer.flush()
			writer.close()

			// レスポンス
			Map headers = urlconn.headerFields
			headers.each { println " ${it.key}: ${it.value}" }
			// ボディ
			String responseBody = new BufferedReader(new InputStreamReader(urlconn.inputStream, 'UTF-8')).text
			println """\
----------------
レスポンスコード    [${urlconn.responseCode}]
レスポンスメッセージ[${urlconn.responseMessage}]
プロンプト(realm)   [${authenticator.myGetRequestingPrompt()}]
----------------\
"""
			println '●●●●● ポスト成功 ●●●●●'
			// レスポンスボディをStatusMapにして返却
			return toStatusMap(new XmlParser().parseText(responseBody))

		} catch( IOException e ) {
			LogUtil.error(e)
			println '●●●●● ポスト失敗 ●●●●●'
			return null
		
		} finally {
			// コネクション切断
			urlconn.disconnect()
		}
	}

	/** スターを消す */
	def addStar(id) {
		_updateStar(id, true)
	}

	/** スターを消す */
	def deleteStar(id) {
		_updateStar(id, false)
	}

	/**
	 * スターを操作
	 * @param id スター操作対象のステータスID
	 * @param isAdd true:スターを付ける, false:スターを消す
	 */
	def _updateStar(id, isAdd) {
		// コネクション接続
		URL url = isAdd ?
			new URL("http://h.hatena.ne.jp/api/favorites/create/${id}.xml") :
			new URL("http://h.hatena.ne.jp/api/favorites/destroy/${id}.xml")
		def authenticator = new HttpAuthenticator('username':username, 'password':password)
		Authenticator.default = authenticator
		def urlconn = url.openConnection() as HttpURLConnection
		urlconn.requestMethod = 'POST'
		urlconn.doOutput = true

		// ポスト内容の表示
		println "id=[${id}]"

		try {
			// レスポンス
			Map headers = urlconn.headerFields
			headers.each { println " ${it.key}: ${it.value}" }
			println """\
----------------
レスポンスコード    [${urlconn.responseCode}]
レスポンスメッセージ[${urlconn.responseMessage}]
プロンプト(realm)   [${authenticator.myGetRequestingPrompt()}]
---- ボディ ----
${new BufferedReader(new InputStreamReader(urlconn.inputStream, 'UTF-8')).text}
----------------\
"""
			println "●●●●● スター${isAdd ? '付加' : '削除'}成功 ●●●●●"
		} catch( IOException e ) {
			LogUtil.error(e)
			println "●●●●● スター${isAdd ? '付加' : '削除'}失敗 ●●●●●"
		} finally {
			// コネクション切断
			urlconn.disconnect()
		}
	}

	/** ステータスノードをマップして返却 */
	def toStatusMap(status) {
		def text = (status.keyword.text() ==~ /^${Util.ID_PATTERN}$/) ?
			status.text.text() : status.text.text().split('=').toList().tail().join()
		[
			'id'              : status.id.text(),
			'createdAt'       : toDefaultDate(status.created_at.text()),
			'star'            : status.favorited.text().toInteger(),
			'replyToStatusId' : status.in_reply_to_status_id.text(),
			'replyToUserId'   : status.in_reply_to_user_id.text(),
			'keyword'         : status.keyword.text(),
			'link'            : status.link.text(),
			'source'          : status.source.text(),
			'text'            : text,
			'userId'          : status.user.id.text(),
			'userName'        : status.user.name.text(),
		]
	}

	/** キーワードノートをマップして返却 */
	def toKeywordMap(keyword) {
		[
			'entries'         : keyword.entry_count.text(),
			'fans'            : keyword.followers_count.text(),
			'entriesAndFans'  : "${NumberFormat.instance.format(keyword.entry_count.text().toInteger())} entries /${NumberFormat.instance.format(keyword.followers_count.text().toInteger())} fans",
			'relatedKeywords' : keyword.related_keywords*.text(),
			'title'           : keyword.title.text(),
		]
	}

	/**
	 * datetimeTZ形式からDateに変換
	 * 
	 * @param datetimeTZ datetimeTZ形式の文字列
	 * @return 日本標準時のDate
	 */
	Date toDefaultDate(String datetimeTZ) {
	    DateFormat fm = new SimpleDateFormat('yyyy-MM-dd HH:mm:ss')
	    fm.timeZone   = TimeZone.getTimeZone('GMT+0')
	    fm.parse(datetimeTZ.replace('T', ' ').replace('Z', ''))
	}

	/**
	 * DateをdatetimeTZ形式に変換
	 * 
	 * @param defaultDate 日本標準時のDate
	 * @return datetimeTZ形式の文字列
	 */
	String toDateTimeTZ(Date defaultDate) {
	    DateFormat fmYMD    = new SimpleDateFormat('yyyy-MM-dd')
	    DateFormat fmHHMMSS = new SimpleDateFormat('HH:mm:ss')
	    fmYMD.timeZone      = TimeZone.getTimeZone('GMT+0')
	    fmHHMMSS.timeZone   = TimeZone.getTimeZone('GMT+0')
	    "${fmYMD.format(defaultDate)}T${fmHHMMSS.format(defaultDate)}Z"
	}
}

/**
 * Basic認証のためのクラス
 */
class HttpAuthenticator extends Authenticator {
    def username
    def password
    PasswordAuthentication getPasswordAuthentication() {
        new PasswordAuthentication(this.username, this.password as char[])
    }
    String myGetRequestingPrompt() {
        super.getRequestingPrompt()
    }
}

使い方

def api = new HatenaHaikuAPI(username: 'ユーザ名', password: '投稿用パスワード', client: 'クライアント名')
def result = api.getStatus('keyword': 'ひとりごと')

resultにはひとりごとキーワードの投稿内容のMapのリストが返ってきます

例えば先頭1件目のユーザIDを取得する場合は
result.first()['userId']となります。

ちなみに、更新系のメソッドを使用しないのであれば、コンストラクタの引数はすべて省略してもOKです。