require 'digest/sha1' require 'base64' require 'net/http' require 'open-uri' require 'kconv' require 'rubygems' require 'hpricot' # # = hatena/api/bookmark.rb # # Copyright; 2008 ODA Kaname [trashsuite@gmail.com] # See also ; http://d.hatena.ne.jp/trashsuite/ # # Hatena Bookmark API の Ruby バインディング # # module Net class HTTP class WSSE def self.create_header(username, password) self.new(username, password).x_wsse end def initialize(username, password) @username = username @password = password reset_all end def reset_all @nonce = nil @date = Time.now end def x_wsse [ %Q[UsernameToken Username="#{@username}"], %Q[PasswordDigest="#{Base64.encode64(password_digest).chomp}"], %Q[Nonce="#{Base64.encode64(nonce).chomp}"], %Q[Created="#{@date.iso8601}"], ].join ', ' end private def nonce @nonce ||= Digest::SHA1.hexdigest([@date.to_f, rand(), $$].join) end def password_digest Digest::SHA1.digest [nonce, @date.iso8601, @password].join end end end end module Hatena module API class Bookmark VERSION = '0.0.1' ROOT_ENDPOINT_URI = 'http://b.hatena.ne.jp/atom' REQUIRED_SERVICES = [:post, :feed, :edit] PageInfo = Struct.new(:uri, :title, :tags, :comment, :edit_endpoint) def initialize(username, password) raise ArgumentError, 'invalid username or password' unless username and password @username = username @password = password @x_wsse = Net::HTTP::WSSE.create_header(@username, @password) @endpoints = get_endpoints end # options => {:uri => 'http://b.hatena.ne.jp/', :tags => ['web', 'hatena'], :comment => 'HatenaBookmark'} def add(options = {}) request(:post, @endpoints[:post], Net::HTTPCreated, lambda {|body|build_page_info(body)}, build_add_request(options).toutf8 ) end def show(page_info) request(:get, page_info.edit_endpoint, Net::HTTPOK, lambda {|body|build_page_info(body)} ) end # options => {:title => 'new title', :tags => ['new'], :comment => 'new comment'} def update(page_info, options = {}) request(:put, page_info.edit_endpoint, Net::HTTPOK, lambda {true}, build_update_request(options).toutf8 ) end def remove(page_info) request(:delete, page_info.edit_endpoint, Net::HTTPOK, lambda {true} ) end def feed request(:get, @endpoints[:feed], Net::HTTPOK, lambda {|body|Hpricot.parse(body)} ) end private def get_endpoints endpoints = {} doc = Hpricot.parse(open(ROOT_ENDPOINT_URI, "X-WSSE" => @x_wsse)) doc.search( %Q|//link[@rel='service.post', @rel='service.feed', @rel='service.edit']| ).each do |element| service = element[:rel].split('.', 2).last.to_sym endpoints[service] = URI.parse(element[:href]) end # unless endpoints.has_key? :edit # endpoints[:edit] = URI.parse(URI::HTTP.build( # :host => URI.parse(ROOT_ENDPOINT_URI).host, # :path => '/atom/edit' # ).to_s) # end endpoints[:edit] = nil if REQUIRED_SERVICES.detect{|service|!endpoints.has_key?(service)} raise RootEndPointError, "can't get required service uri(s)" end endpoints end def build_add_request(options = {}) check_uri options[:uri] options[:tags] ||= {} comment = options[:tags].map{|tag|"[#{tag}]"}.join comment += options[:comment] || '' [ %Q||, %Q| |, %Q| #{comment}|, %Q|| ].join("\n") end def build_update_request(options = {}) title = options[:title] || '' comment = cat_tags_and_comment(options[:tags], options[:comment]) [ %Q||, %Q| #{title}|, %Q| #{comment}|, %Q||, ].join("\n") end def build_page_info(response_body) doc = Hpricot.parse(response_body) PageInfo.new( doc.search(%Q|/entry/link[@rel='related']|).first[:href], doc.search(%Q|/entry/title|).inner_html, doc.search(%Q|//dc:subject|).map{|tag|tag.inner_html}, comment = doc.search(%Q|//summary|).inner_html, URI.parse(doc.search(%Q|/entry/link[@rel='service.edit']|).first[:href]) ) end def cat_tags_and_comment(tags, comment) tags ||= {} comment ||= '' tags.map{|tag|"[#{tag}]"}.join + comment end def check_uri(uri) uri = URI.parse(uri) rescue nil unless uri.instance_of?(URI::HTTP) or uri.instance_of?(URI::HTTPS) raise ArgumentError, 'invalid uri' end end def request(method, endpoint, valid_response_class, result_proc, body = nil) options = [ method, endpoint.path, body, { 'Content-Type' => 'application/x.atom+xml; charset=utf-8', 'X-WSSE' => @x_wsse } ].compact Net::HTTP.start(endpoint.host, endpoint.port) do |http| response = http.__send__(*options) case response when valid_response_class then result_proc.call(response.body) when Net::HTTPForbidden then raise(RequestError, '403 Forbidden') else raise RequestError, "#{response.class.to_s.split('::').last}\n#{response.body}" end end end class RootEndPointError < StandardError; end class RequestError < StandardError; end end end end