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

Revision 35250, 6.9 kB (checked in by trashsuite, 5 years ago)

* ライセンスを表記した

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