過去のはてなブックマークからランダムで1件選んでツイートするBot

「あとで読む」タグを付けて放置しっ放しのブックマークが数多く溜まっている現状を打破したいと思い、Twitterbotを実装しました。
具体的には、はてなブックマークの全ブックマークの中からランダムで1件を選択し、その情報をTwitterにツイートします。
過去のはてなブックマークからランダムで1件選んでツイートするBot · GitHub

ブックマークの情報をどこから取得するか

フィードが提供されているのでこれを使います。
はてなブックマークフィード仕様 - Hatena Developer Center

全ブックマーク数の取得

フィードをRSS形式で取得した場合、 "opensearch:totalResults" 要素に全ブックマーク数が含まれています。
RubyRSSライブラリではこの値は取得出来ないため、Nokogiriを使ってゴリゴリXMLとして読み込みます。

  ...
  <channel rdf:about="http://b.hatena.ne.jp/aquarla/">
    <title>あくあーらのブックマーク</title>
    <link>http://b.hatena.ne.jp/aquarla/</link>
    <description>あくあーらのブックマーク</description>
    <opensearch:startIndex>1</opensearch:startIndex>
    <opensearch:itemsPerPage>20</opensearch:itemsPerPage>
    <opensearch:totalResults>3295</opensearch:totalResults>
    <items>
    ...

ブックマークの情報をランダムで1個だけ取得

全ブックマーク数をもとに、「n番目のブックマークを取得する」のnをランダムで決定し、当該ブックマークの情報をピンポイントで取得します。

はてなブックマークフィードのドキュメントには、 "of"パラメータを付けることでページングが可能と書いています。

http://b.hatena.ne.jp/aquarla/rss?of=20

ちなみに、ドキュメントに明記されていない(実際に動かしてみるとわかる)仕様がいくつかあって…。

  • 1ページで取得出来る件数は20件固定。減らすことも増やすことも出来ない
  • "of"パラメータは自動的に20の倍数に切り詰めて扱われる。(たとえば"of=30"を指定しても、20に切り詰めて扱われる)

title、link、description要素はRSSライブラリでも取得出来るのですが、ブックマーク数の取得にNokogiriを使っていてここで別のライブラリを持ち出すのもアレなので、そのままNokogiriでゴリゴリ取得してしまっています。

ブックマーク情報をツイート

取得したブックマーク情報(タイトル、URL、コメントなど)をTwitterにツイートします。

  • ライブラリはなんでも良いと思うのですが、ここではRuby OAuth Gemを使っています。
  • 文字列内に"@"が含まれていたりすると迷惑になるかもなので、ツイート前に予め取り除きます。
  • タイトルやコメントが長すぎる場合は切り詰めます。あまりゴツいライブラリはrequireしたくなかったので、文字列切り詰めの処理部分だけをActiveSupportから無理やりパクってきました。それが嫌な人はActiveSupportをそのまま使いましょう。

ソースコード

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-

require 'rubygems'
require 'open-uri'
require 'nokogiri'
require 'oauth'

# ActiveSupportのString#truncateを参考にする
# String#mb_charsは、Ruby1.9前提であればそのままselfを返してよい
class String
  def truncate(length, options = { })
    text = self.dup
    options[:omission] ||= "..."

    length_with_room_for_omission = length - options[:omission].mb_chars.length
    chars = text.mb_chars
    stop = options[:separator] ?
      (chars.rindex(options[:separator].mb_chars, length_with_room_for_omission) || length_with_room_for_omission) : length_with_room_for_omission

    (chars.length > length ? chars[0...stop] + options[:omission] : text).to_s
  end

  def mb_chars
    self
  end
end

# 過去のブックマークからランダムに1個を選びツイートするbotクラス
module HatenaBookmarkBot
  class Base
    # Twitterアクセスのための各種情報
    CONSUMER_KEY = "XXXXXXXXXXXXXXXXX"
    CONSUMER_SECRET = "XXXXXXXXXXXXXXXXX"
    ACCESS_TOKEN = "XXXXXXXXXXXXXXXXX"
    ACCESS_TOKEN_SECRET = "XXXXXXXXXXXXXXXXX"

    # はてなブックマークのユーザー名
    HATENA_BOOKMARK_USERNAME = "aquarla"

    # はてブRSSをXMLとして取得
    # offsetを指定した場合は、offset件目以降を取得
    def open_hatena_bookmark_rss(username, offset=nil)
      rss_url = "http://b.hatena.ne.jp/#{username}/rss"
      rss_url += "?of=#{offset}" if offset

      open(rss_url) do |file|
        yield file.read
      end
    end

    # XMLから現在の総ブックマーク数を取得
    def bookmark_count(xml)
      namespaces = {
        'opensearch' => 'http://a9.com/-/spec/opensearchrss/1.0/',
      }
      Nokogiri::XML.parse(xml).at('.//opensearch:totalResults', namespaces).text.to_i rescue -1
    end

    # 文字列をツイート
    def tweet(status)
      consumer = OAuth::Consumer.new(CONSUMER_KEY, CONSUMER_SECRET, :site => 'https://api.twitter.com')
      access_token = OAuth::AccessToken.new(consumer, ACCESS_TOKEN, ACCESS_TOKEN_SECRET)
      access_token.post('/1.1/statuses/update.json', 'status' => status)
    end

    # 処理本体
    def run
      # 1. はてブのRSSフィードから、ブックマークの全件数を取得
      open_hatena_bookmark_rss(HATENA_BOOKMARK_USERNAME) do |xml|
        count = bookmark_count(xml)

        # 2. 全件のうち何件めをツイートするかをランダムに決定し、
        index = rand(count)
        open_hatena_bookmark_rss(HATENA_BOOKMARK_USERNAME, index/20*20) do |xml2|
          namespaces = {"rss" => "http://purl.org/rss/1.0/"}
          item = Nokogiri::XML.parse(xml2).xpath("//rss:item[#{(index%20)}]", namespaces)
          title = item.xpath(".//rss:title", namespaces).text
          link = item.xpath(".//rss:link", namespaces).text
          description = item.xpath(".//rss:description", namespaces).text

          # 3. ブックマーク情報をツイートする
          #    ツイートに@が含まれていると迷惑なので外すのと、
          #    ツイートが長すぎる場合はタイトル及びコメントを切り詰める
          status = if description.nil? || description == ""
                     "#{title.truncate(100)} #{link}"
                   else
                     "#{description.truncate(50)} / #{title.truncate(50)} #{link}"
                   end
          status.gsub!(/@/, "")
          tweet(status)
        end
      end
    end
  end
end

if $0 == __FILE__
  HatenaBookmarkBot::Base.new.run
end

おわりに

上記のプログラムを、どこかのサーバで定期的にcron実行するようにすればOK。
「あとで読む」を消化するために実装したbotですが、今まですっかり忘れていた事項をプッシュで配信してくれるので、意外と便利です。
遠い昔のブックマークをツイートすることも多く、色々と紛らわしいので、protectedでの運用を推奨します。