| 1 | require 'uri' |
|---|
| 2 | require 'net/http' |
|---|
| 3 | require 'time' |
|---|
| 4 | require 'kconv' |
|---|
| 5 | |
|---|
| 6 | $KCODE = 'UTF-8' |
|---|
| 7 | |
|---|
| 8 | # |
|---|
| 9 | # = enokidu.rb |
|---|
| 10 | # |
|---|
| 11 | # Copyright; 2008 ODA Kaname [trashsuite@gmail.com] |
|---|
| 12 | # See also ; http://d.hatena.ne.jp/trashsuite/ |
|---|
| 13 | # |
|---|
| 14 | # ウェブページ更新確認スクリプト |
|---|
| 15 | # |
|---|
| 16 | module Enokidu |
|---|
| 17 | class UserAgent |
|---|
| 18 | NAME = 'Enokidu::Antenna' |
|---|
| 19 | VERSION = '0.1.5' |
|---|
| 20 | end |
|---|
| 21 | |
|---|
| 22 | class Antenna |
|---|
| 23 | ERROR_CODE = { |
|---|
| 24 | :connerr => 900, |
|---|
| 25 | :noroute => 901, |
|---|
| 26 | :sockerr => 902, |
|---|
| 27 | :timeout => 903, |
|---|
| 28 | :unknown => 999 |
|---|
| 29 | } |
|---|
| 30 | |
|---|
| 31 | def initialize(options = {}) |
|---|
| 32 | @timeout = options[:timeout] || 30 |
|---|
| 33 | @debug = options[:debug] || false |
|---|
| 34 | @request = Request.new(:timeout => @timeout, :debug => @debug) |
|---|
| 35 | end |
|---|
| 36 | |
|---|
| 37 | def detective(options = {}) |
|---|
| 38 | page = options[:page] |
|---|
| 39 | |
|---|
| 40 | page.body ||= '' |
|---|
| 41 | |
|---|
| 42 | # タイトルを取得 |
|---|
| 43 | if page.title.empty? |
|---|
| 44 | @request.get(page) |
|---|
| 45 | page.last_modified_at = nil |
|---|
| 46 | end |
|---|
| 47 | |
|---|
| 48 | # 初回は HEAD が使えるかどうか確認する |
|---|
| 49 | page.method = 'HEAD' if page.last_modified_at.nil? |
|---|
| 50 | |
|---|
| 51 | page.updated = false |
|---|
| 52 | case page.method |
|---|
| 53 | when 'GET' then @request.get(page) |
|---|
| 54 | when 'HEAD' then @request.head(page) |
|---|
| 55 | else raise Request::InvalidMethod |
|---|
| 56 | end |
|---|
| 57 | rescue Request::Redirect |
|---|
| 58 | retry |
|---|
| 59 | rescue Request::InvalidHeadResponse |
|---|
| 60 | page.method = 'GET' |
|---|
| 61 | retry |
|---|
| 62 | rescue Exception => exception |
|---|
| 63 | case exception |
|---|
| 64 | when Errno::ECONNREFUSED then page.code = ERROR_CODE[:connerr] |
|---|
| 65 | when Errno::EHOSTUNREACH then page.code = ERROR_CODE[:noroute] |
|---|
| 66 | when Timeout::Error then page.code = ERROR_CODE[:timeout] |
|---|
| 67 | when SocketError then page.code = ERROR_CODE[:sockerr] |
|---|
| 68 | else |
|---|
| 69 | puts exception.class |
|---|
| 70 | page.code = ERROR_CODE[:unknown] |
|---|
| 71 | end |
|---|
| 72 | page |
|---|
| 73 | end |
|---|
| 74 | end # Antenna |
|---|
| 75 | |
|---|
| 76 | class Request |
|---|
| 77 | def initialize(options = {}) |
|---|
| 78 | @http_header = {'User-Agent' => Enokidu::UserAgent::NAME, 'Connection' => 'close'} |
|---|
| 79 | @timeout = options[:timeout] || 30 |
|---|
| 80 | @debug = options[:debug] || false |
|---|
| 81 | end |
|---|
| 82 | |
|---|
| 83 | def get(page) |
|---|
| 84 | page.method = 'GET' |
|---|
| 85 | |
|---|
| 86 | debug_print "sync by GET method" |
|---|
| 87 | debug_print "initial sync" if page.body.empty? |
|---|
| 88 | debug_print "title #{page.title}" unless page.body.empty? |
|---|
| 89 | |
|---|
| 90 | uri = URI.parse(page.uri) |
|---|
| 91 | http = http_instance(uri) |
|---|
| 92 | res = nil |
|---|
| 93 | |
|---|
| 94 | # 更新状況を聞いてみる |
|---|
| 95 | %w[If-Modified-Since, If-None-Match].each {|header|@http_header.delete header} |
|---|
| 96 | if !page.title.empty? and !page.has_range? |
|---|
| 97 | debug_print 'set If-Modified-Since' |
|---|
| 98 | @http_header['If-Modified-Since'] = page.last_modified_at.httpdate if page.last_modified_at |
|---|
| 99 | @http_header['If-None-Match'] = page.etag unless page.etag.empty? |
|---|
| 100 | end |
|---|
| 101 | |
|---|
| 102 | timeout(@timeout) do |
|---|
| 103 | res = http.get(mkpath(uri), @http_header) |
|---|
| 104 | end |
|---|
| 105 | |
|---|
| 106 | page.code = res.code |
|---|
| 107 | debug_print "Return code #{res.code}" |
|---|
| 108 | |
|---|
| 109 | # Redirect |
|---|
| 110 | if res.code.match(/^30[12]$/) |
|---|
| 111 | location = res['location'] || '' |
|---|
| 112 | old_uri = page.uri |
|---|
| 113 | page.uri = location |
|---|
| 114 | |
|---|
| 115 | # ロケーションが空または不完全な場合 |
|---|
| 116 | if !location.empty? and !location.match(%r[^http://]) |
|---|
| 117 | uri = URI.parse(old_uri) |
|---|
| 118 | path, query = location.split('?') |
|---|
| 119 | uri.path = path |
|---|
| 120 | uri.query = query || '' |
|---|
| 121 | page.uri = uri.to_s |
|---|
| 122 | end |
|---|
| 123 | |
|---|
| 124 | raise Redirect |
|---|
| 125 | end |
|---|
| 126 | |
|---|
| 127 | # 親切な御仁に感謝しつつ終了 |
|---|
| 128 | if res.instance_of? Net::HTTPNotModified |
|---|
| 129 | debug_print 'use If-Modified-Since' |
|---|
| 130 | page.updated = false |
|---|
| 131 | return page |
|---|
| 132 | end |
|---|
| 133 | |
|---|
| 134 | # タイトルを抜き取る |
|---|
| 135 | body = res.body.toutf8 |
|---|
| 136 | if page.title.empty? |
|---|
| 137 | debug_print "get page title" |
|---|
| 138 | page.title = body.scan(/<title>([^<]*)/im).to_s.strip |
|---|
| 139 | page.title = 'no title' if page.title.empty? |
|---|
| 140 | end |
|---|
| 141 | |
|---|
| 142 | body = if page.has_range? |
|---|
| 143 | get_range(body, page.start_range, page.end_range) |
|---|
| 144 | else |
|---|
| 145 | body |
|---|
| 146 | end.gsub(/<[^>]*>|\s|\n/, '') |
|---|
| 147 | |
|---|
| 148 | # 更新チェック |
|---|
| 149 | debug_print "body size #{page.body.size} => #{body.size}" |
|---|
| 150 | unless body.size == page.body.size |
|---|
| 151 | page.last_modified_at = Time.now |
|---|
| 152 | page.updated = true |
|---|
| 153 | end |
|---|
| 154 | |
|---|
| 155 | page.body = body |
|---|
| 156 | |
|---|
| 157 | page |
|---|
| 158 | end |
|---|
| 159 | |
|---|
| 160 | def head(page) |
|---|
| 161 | page.method = 'HEAD' |
|---|
| 162 | |
|---|
| 163 | debug_print "sync by HEAD method" |
|---|
| 164 | |
|---|
| 165 | uri = URI.parse(page.uri) |
|---|
| 166 | http = http_instance(uri) |
|---|
| 167 | res = nil |
|---|
| 168 | |
|---|
| 169 | timeout(@timeout) do |
|---|
| 170 | res = http.head(mkpath(uri), @http_header) |
|---|
| 171 | end |
|---|
| 172 | |
|---|
| 173 | page.code = res.code |
|---|
| 174 | debug_print "Return code #{res.code}" |
|---|
| 175 | |
|---|
| 176 | date = res['date'] |
|---|
| 177 | lm = res['last-modified'] |
|---|
| 178 | etag = res['etag'] |
|---|
| 179 | |
|---|
| 180 | # 初回は現在時刻をセット |
|---|
| 181 | page.last_modified_at = Time.now if page.last_modified_at.nil? |
|---|
| 182 | |
|---|
| 183 | # Last-Modified で更新チェック |
|---|
| 184 | raise InvalidHeadResponse if date == lm |
|---|
| 185 | lm = Time.httpdate(lm).localtime if lm |
|---|
| 186 | if lm and lm != page.last_modified_at.localtime |
|---|
| 187 | page.last_modified_at = lm |
|---|
| 188 | page.updated = true |
|---|
| 189 | end |
|---|
| 190 | |
|---|
| 191 | # Etag で更新チェック |
|---|
| 192 | if !lm and etag and page.etag != etag |
|---|
| 193 | page.etag = etag |
|---|
| 194 | page.last_modified_at = Time.now.localtime |
|---|
| 195 | page.updated = true |
|---|
| 196 | end |
|---|
| 197 | |
|---|
| 198 | # Last-Modified も Etag も使えない |
|---|
| 199 | raise InvalidHeadResponse unless lm or etag |
|---|
| 200 | |
|---|
| 201 | page |
|---|
| 202 | end |
|---|
| 203 | |
|---|
| 204 | private |
|---|
| 205 | def mkpath(uri) |
|---|
| 206 | uri.query ? [uri.path, uri.query].join('?') : uri.path |
|---|
| 207 | end |
|---|
| 208 | |
|---|
| 209 | def http_instance(uri) |
|---|
| 210 | http = Net::HTTP.new(uri.host, uri.port) |
|---|
| 211 | http.use_ssl = true if uri.to_s.match(/^https/) |
|---|
| 212 | http |
|---|
| 213 | end |
|---|
| 214 | |
|---|
| 215 | def get_range(body, start_range, end_range) |
|---|
| 216 | debug_print "use range" |
|---|
| 217 | body.scan(/#{start_range}(.*)#{end_range}/im).to_s |
|---|
| 218 | end |
|---|
| 219 | |
|---|
| 220 | def debug_print(content) |
|---|
| 221 | puts content if @debug |
|---|
| 222 | end |
|---|
| 223 | |
|---|
| 224 | class Redirect < StandardError; end |
|---|
| 225 | class InvalidHeadResponse < StandardError; end |
|---|
| 226 | class InvalidMethod < StandardError; end |
|---|
| 227 | end # Request |
|---|
| 228 | |
|---|
| 229 | class Page |
|---|
| 230 | PERMIT_OPTIONS = [:uri, :title, :method, :start_range, :end_range, :last_modified_at, :etag, :code, :body] |
|---|
| 231 | PERMIT_OPTIONS.each {|opt|attr_accessor opt} |
|---|
| 232 | attr_accessor :updated |
|---|
| 233 | |
|---|
| 234 | def initialize(options = {}) |
|---|
| 235 | @uri = options[:uri] || '' |
|---|
| 236 | @title = options[:title] || '' |
|---|
| 237 | @method = options[:method] || 'GET' |
|---|
| 238 | @start_range = options[:start_range] || '' |
|---|
| 239 | @end_range = options[:end_range] || '' |
|---|
| 240 | @last_modified_at = options[:last_modified_at] |
|---|
| 241 | @etag = options[:etag] || '' |
|---|
| 242 | @code = options[:code] || 200 |
|---|
| 243 | @body = options[:body] || '' |
|---|
| 244 | |
|---|
| 245 | raise ArgumentError if @uri.empty? |
|---|
| 246 | end |
|---|
| 247 | |
|---|
| 248 | def code |
|---|
| 249 | @code.to_i |
|---|
| 250 | end |
|---|
| 251 | |
|---|
| 252 | def has_range? |
|---|
| 253 | !start_range.empty? or !end_range.empty? |
|---|
| 254 | end |
|---|
| 255 | |
|---|
| 256 | def updated? |
|---|
| 257 | @updated || false |
|---|
| 258 | end |
|---|
| 259 | |
|---|
| 260 | def valid? |
|---|
| 261 | code == 200 |
|---|
| 262 | end |
|---|
| 263 | end # Page |
|---|
| 264 | end # Enokidu |
|---|