root/lang/ruby/enokidu_antenna/enokidu.rb @ 24822

Revision 24822, 6.8 kB (checked in by trashsuite, 4 years ago)

Enokidu::Antenna

Line 
1require 'uri'
2require 'net/http'
3require 'time'
4require '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#
16module 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
264end # Enokidu
Note: See TracBrowser for help on using the browser.