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