]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/utils.rb
22fcee37a5972c1bc6c8dc6efe235ed9a6d21dcf
[user/henk/code/ruby/rbot.git] / lib / rbot / core / utils / utils.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot utilities provider
5 #
6 # Author:: Tom Gilbert <tom@linuxbrit.co.uk>
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 #
9 # Copyright:: (C) 2002-2006 Tom Gilbert
10 # Copyright:: (C) 2007 Giuseppe Bilotta
11 #
12 # TODO some of these Utils should be rewritten as extensions to the approriate
13 # standard Ruby classes and accordingly be moved to extends.rb
14
15 require 'tempfile'
16 require 'set'
17
18 # Try to load htmlentities, fall back to an HTML escape table.
19 begin
20   require 'htmlentities'
21 rescue LoadError
22   gems = nil
23   begin
24     gems = require 'rubygems'
25   rescue LoadError
26     gems = false
27   end
28   if gems
29     retry
30   else
31     module ::Irc
32       module Utils
33         UNESCAPE_TABLE = {
34     'laquo' => '«',
35     'raquo' => '»',
36     'quot' => '"',
37     'apos' => '\'',
38     'micro' => 'µ',
39     'copy' => '©',
40     'trade' => '™',
41     'reg' => '®',
42     'amp' => '&',
43     'lt' => '<',
44     'gt' => '>',
45     'hellip' => '…',
46     'nbsp' => ' ',
47     'Agrave' => 'À',
48     'Aacute' => 'Á',
49     'Acirc' => 'Â',
50     'Atilde' => 'Ã',
51     'Auml' => 'Ä',
52     'Aring' => 'Å',
53     'AElig' => 'Æ',
54     'OElig' => 'Œ',
55     'Ccedil' => 'Ç',
56     'Egrave' => 'È',
57     'Eacute' => 'É',
58     'Ecirc' => 'Ê',
59     'Euml' => 'Ë',
60     'Igrave' => 'Ì',
61     'Iacute' => 'Í',
62     'Icirc' => 'Î',
63     'Iuml' => 'Ï',
64     'ETH' => 'Ð',
65     'Ntilde' => 'Ñ',
66     'Ograve' => 'Ò',
67     'Oacute' => 'Ó',
68     'Ocirc' => 'Ô',
69     'Otilde' => 'Õ',
70     'Ouml' => 'Ö',
71     'Oslash' => 'Ø',
72     'Ugrave' => 'Ù',
73     'Uacute' => 'Ú',
74     'Ucirc' => 'Û',
75     'Uuml' => 'Ü',
76     'Yacute' => 'Ý',
77     'THORN' => 'Þ',
78     'szlig' => 'ß',
79     'agrave' => 'à',
80     'aacute' => 'á',
81     'acirc' => 'â',
82     'atilde' => 'ã',
83     'auml' => 'ä',
84     'aring' => 'å',
85     'aelig' => 'æ',
86     'oelig' => 'œ',
87     'ccedil' => 'ç',
88     'egrave' => 'è',
89     'eacute' => 'é',
90     'ecirc' => 'ê',
91     'euml' => 'ë',
92     'igrave' => 'ì',
93     'iacute' => 'í',
94     'icirc' => 'î',
95     'iuml' => 'ï',
96     'eth' => 'ð',
97     'ntilde' => 'ñ',
98     'ograve' => 'ò',
99     'oacute' => 'ó',
100     'ocirc' => 'ô',
101     'otilde' => 'õ',
102     'ouml' => 'ö',
103     'oslash' => 'ø',
104     'ugrave' => 'ù',
105     'uacute' => 'ú',
106     'ucirc' => 'û',
107     'uuml' => 'ü',
108     'yacute' => 'ý',
109     'thorn' => 'þ',
110     'yuml' => 'ÿ'
111         }
112       end
113     end
114   end
115 end
116
117 begin
118   require 'hpricot'
119   module ::Irc
120     module Utils
121       AFTER_PAR_PATH = /^(?:div|span)$/
122       AFTER_PAR_EX = /^(?:td|tr|tbody|table)$/
123       AFTER_PAR_CLASS = /body|message|text/i
124     end
125   end
126 rescue LoadError
127   gems = nil
128   begin
129     gems = require 'rubygems'
130   rescue LoadError
131     gems = false
132   end
133   if gems
134     retry
135   else
136     module ::Irc
137       module Utils
138         # Some regular expressions to manage HTML data
139
140         # Title
141         TITLE_REGEX = /<\s*?title\s*?>(.+?)<\s*?\/title\s*?>/im
142
143         # H1, H2, etc
144         HX_REGEX = /<h(\d)(?:\s+[^>]*)?>(.*?)<\/h\1>/im
145         # A paragraph
146         PAR_REGEX = /<p(?:\s+[^>]*)?>.*?<\/?(?:p|div|html|body|table|td|tr)(?:\s+[^>]*)?>/im
147
148         # Some blogging and forum platforms use spans or divs with a 'body' or 'message' or 'text' in their class
149         # to mark actual text
150         AFTER_PAR1_REGEX = /<\w+\s+[^>]*(?:body|message|text)[^>]*>.*?<\/?(?:p|div|html|body|table|td|tr)(?:\s+[^>]*)?>/im
151
152         # At worst, we can try stuff which is comprised between two <br>
153         AFTER_PAR2_REGEX = /<br(?:\s+[^>]*)?\/?>.*?<\/?(?:br|p|div|html|body|table|td|tr)(?:\s+[^>]*)?\/?>/im
154       end
155     end
156   end
157 end
158
159 module ::Irc
160
161   # Miscellaneous useful functions
162   module Utils
163     @@bot = nil unless defined? @@bot
164     @@safe_save_dir = nil unless defined?(@@safe_save_dir)
165
166     # The bot instance
167     def Utils.bot
168       @@bot
169     end
170
171     # Set up some Utils routines which depend on the associated bot.
172     def Utils.bot=(b)
173       debug "initializing utils"
174       @@bot = b
175       @@safe_save_dir = "#{@@bot.botclass}/safe_save"
176     end
177
178
179     # Seconds per minute
180     SEC_PER_MIN = 60
181     # Seconds per hour
182     SEC_PER_HR = SEC_PER_MIN * 60
183     # Seconds per day
184     SEC_PER_DAY = SEC_PER_HR * 24
185     # Seconds per (30-day) month
186     SEC_PER_MNTH = SEC_PER_DAY * 30
187     # Second per (30*12 = 360 day) year
188     SEC_PER_YR = SEC_PER_MNTH * 12
189
190     # Auxiliary method needed by Utils.secs_to_string
191     def Utils.secs_to_string_case(array, var, string, plural)
192       case var
193       when 1
194         array << "1 #{string}"
195       else
196         array << "#{var} #{plural}"
197       end
198     end
199
200     # Turn a number of seconds into a human readable string, e.g
201     # 2 days, 3 hours, 18 minutes and 10 seconds
202     def Utils.secs_to_string(secs)
203       ret = []
204       years, secs = secs.divmod SEC_PER_YR
205       secs_to_string_case(ret, years, _("year"), _("years")) if years > 0
206       months, secs = secs.divmod SEC_PER_MNTH
207       secs_to_string_case(ret, months, _("month"), _("months")) if months > 0
208       days, secs = secs.divmod SEC_PER_DAY
209       secs_to_string_case(ret, days, _("day"), _("days")) if days > 0
210       hours, secs = secs.divmod SEC_PER_HR
211       secs_to_string_case(ret, hours, _("hour"), _("hours")) if hours > 0
212       mins, secs = secs.divmod SEC_PER_MIN
213       secs_to_string_case(ret, mins, _("minute"), _("minutes")) if mins > 0
214       secs = secs.to_i
215       secs_to_string_case(ret, secs, _("second"), _("seconds")) if secs > 0 or ret.empty?
216       case ret.length
217       when 0
218         raise "Empty ret array!"
219       when 1
220         return ret.to_s
221       else
222         return [ret[0, ret.length-1].join(", ") , ret[-1]].join(_(" and "))
223       end
224     end
225
226     # Turn a number of seconds into a hours:minutes:seconds e.g.
227     # 3:18:10 or 5'12" or 7s
228     #
229     def Utils.secs_to_short(seconds)
230       secs = seconds.to_i # make sure it's an integer
231       mins, secs = secs.divmod 60
232       hours, mins = mins.divmod 60
233       if hours > 0
234         return ("%s:%s:%s" % [hours, mins, secs])
235       elsif mins > 0
236         return ("%s'%s\"" % [mins, secs])
237       else
238         return ("%ss" % [secs])
239       end
240     end
241
242
243     # Execute an external program, returning a String obtained by redirecting
244     # the program's standards errors and output 
245     #
246     def Utils.safe_exec(command, *args)
247       IO.popen("-") { |p|
248         if p
249           return p.readlines.join("\n")
250         else
251           begin
252             $stderr.reopen($stdout)
253             exec(command, *args)
254           rescue Exception => e
255             puts "exec of #{command} led to exception: #{e.pretty_inspect}"
256             Kernel::exit! 0
257           end
258           puts "exec of #{command} failed"
259           Kernel::exit! 0
260         end
261       }
262     end
263
264
265     # Safely (atomically) save to _file_, by passing a tempfile to the block
266     # and then moving the tempfile to its final location when done.
267     #
268     # call-seq: Utils.safe_save(file, &block)
269     #
270     def Utils.safe_save(file)
271       raise 'No safe save directory defined!' if @@safe_save_dir.nil?
272       basename = File.basename(file)
273       temp = Tempfile.new(basename,@@safe_save_dir)
274       temp.binmode
275       yield temp if block_given?
276       temp.close
277       File.rename(temp.path, file)
278     end
279
280
281     # Decode HTML entities in the String _str_, using HTMLEntities if the
282     # package was found, or UNESCAPE_TABLE otherwise.
283     #
284     def Utils.decode_html_entities(str)
285       if defined? ::HTMLEntities
286         return HTMLEntities.decode_entities(str)
287       else
288         str.gsub(/(&(.+?);)/) {
289           symbol = $2
290           # remove the 0-paddng from unicode integers
291           if symbol =~ /^#(\d+)$/
292             symbol = $1.to_i.to_s
293           end
294
295           # output the symbol's irc-translated character, or a * if it's unknown
296           UNESCAPE_TABLE[symbol] || (symbol.match(/^\d+$/) ? [symbol.to_i].pack("U") : '*')
297         }
298       end
299     end
300
301     # Try to grab and IRCify the first HTML par (<p> tag) in the given string.
302     # If possible, grab the one after the first heading
303     #
304     # It is possible to pass some options to determine how the stripping
305     # occurs. Currently supported options are
306     # strip:: Regex or String to strip at the beginning of the obtained
307     #         text
308     # min_spaces:: minimum number of spaces a paragraph should have
309     #
310     def Utils.ircify_first_html_par(xml_org, opts={})
311       if defined? ::Hpricot
312         Utils.ircify_first_html_par_wh(xml_org, opts)
313       else
314         Utils.ircify_first_html_par_woh(xml_org, opts)
315       end
316     end
317
318     # HTML first par grabber using hpricot
319     def Utils.ircify_first_html_par_wh(xml_org, opts={})
320       doc = Hpricot(xml_org)
321
322       # Strip styles and scripts
323       (doc/"style|script").remove
324
325       debug doc
326
327       strip = opts[:strip]
328       strip = Regexp.new(/^#{Regexp.escape(strip)}/) if strip.kind_of?(String)
329
330       min_spaces = opts[:min_spaces] || 8
331       min_spaces = 0 if min_spaces < 0
332
333       txt = String.new
334
335       pre_h = pars = by_span = nil
336
337       while true
338         debug "Minimum number of spaces: #{min_spaces}"
339
340         # Initial attempt: <p> that follows <h\d>
341         if pre_h.nil?
342           pre_h = Hpricot::Elements[]
343           found_h = false
344           doc.search("*") { |e|
345             next if e.bogusetag?
346             case e.pathname
347             when /^h\d/
348               found_h = true
349             when 'p'
350               pre_h << e if found_h
351             end
352           }
353           debug "Hx: found: #{pre_h.pretty_inspect}"
354         end
355
356         pre_h.each { |p|
357           debug p
358           txt = p.to_html.ircify_html
359           txt.sub!(strip, '') if strip
360           debug "(Hx attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
361           break unless txt.empty? or txt.count(" ") < min_spaces
362         }
363
364         return txt unless txt.empty? or txt.count(" ") < min_spaces
365
366         # Second natural attempt: just get any <p>
367         pars = doc/"p" if pars.nil?
368         debug "par: found: #{pars.pretty_inspect}"
369         pars.each { |p|
370           debug p
371           txt = p.to_html.ircify_html
372           txt.sub!(strip, '') if strip
373           debug "(par attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
374           break unless txt.empty? or txt.count(" ") < min_spaces
375         }
376
377         return txt unless txt.empty? or txt.count(" ") < min_spaces
378
379         # Nothing yet ... let's get drastic: we look for non-par elements too,
380         # but only for those that match something that we know is likely to
381         # contain text
382
383         # Some blogging and forum platforms use spans or divs with a 'body' or
384         # 'message' or 'text' in their class to mark actual text. Since we want
385         # the class match to be partial and case insensitive, we collect
386         # the common elements that may have this class and then filter out those
387         # we don't need. If no divs or spans are found, we'll accept additional
388         # elements too (td, tr, tbody, table).
389         if by_span.nil?
390           by_span = Hpricot::Elements[]
391           extra = Hpricot::Elements[]
392           doc.search("*") { |el|
393             next if el.bogusetag?
394             case el.pathname
395             when AFTER_PAR_PATH
396               by_span.push el if el[:class] =~ AFTER_PAR_CLASS or el[:id] =~ AFTER_PAR_CLASS
397             when AFTER_PAR_EX
398               extra.push el if el[:class] =~ AFTER_PAR_CLASS or el[:id] =~ AFTER_PAR_CLASS
399             end
400           }
401           if by_span.empty? and not extra.empty?
402             by_span.concat extra
403           end
404           debug "other \#1: found: #{by_span.pretty_inspect}"
405         end
406
407         by_span.each { |p|
408           debug p
409           txt = p.to_html.ircify_html
410           txt.sub!(strip, '') if strip
411           debug "(other attempt \#1) #{txt.inspect} has #{txt.count(" ")} spaces"
412           break unless txt.empty? or txt.count(" ") < min_spaces
413         }
414
415         return txt unless txt.empty? or txt.count(" ") < min_spaces
416
417         # At worst, we can try stuff which is comprised between two <br>
418         # TODO
419
420         debug "Last candidate #{txt.inspect} has #{txt.count(" ")} spaces"
421         return txt unless txt.count(" ") < min_spaces
422         break if min_spaces == 0
423         min_spaces /= 2
424       end
425     end
426
427     # HTML first par grabber without hpricot
428     def Utils.ircify_first_html_par_woh(xml_org, opts={})
429       xml = xml_org.gsub(/<!--.*?-->/m, '').gsub(/<script(?:\s+[^>]*)?>.*?<\/script>/im, "").gsub(/<style(?:\s+[^>]*)?>.*?<\/style>/im, "")
430
431       strip = opts[:strip]
432       strip = Regexp.new(/^#{Regexp.escape(strip)}/) if strip.kind_of?(String)
433
434       min_spaces = opts[:min_spaces] || 8
435       min_spaces = 0 if min_spaces < 0
436
437       txt = String.new
438
439       while true
440         debug "Minimum number of spaces: #{min_spaces}"
441         header_found = xml.match(HX_REGEX)
442         if header_found
443           header_found = $'
444           while txt.empty? or txt.count(" ") < min_spaces
445             candidate = header_found[PAR_REGEX]
446             break unless candidate
447             txt = candidate.ircify_html
448             header_found = $'
449             txt.sub!(strip, '') if strip
450             debug "(Hx attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
451           end
452         end
453
454         return txt unless txt.empty? or txt.count(" ") < min_spaces
455
456         # If we haven't found a first par yet, try to get it from the whole
457         # document
458         header_found = xml
459         while txt.empty? or txt.count(" ") < min_spaces
460           candidate = header_found[PAR_REGEX]
461           break unless candidate
462           txt = candidate.ircify_html
463           header_found = $'
464           txt.sub!(strip, '') if strip
465           debug "(par attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
466         end
467
468         return txt unless txt.empty? or txt.count(" ") < min_spaces
469
470         # Nothing yet ... let's get drastic: we look for non-par elements too,
471         # but only for those that match something that we know is likely to
472         # contain text
473
474         # Attempt #1
475         header_found = xml
476         while txt.empty? or txt.count(" ") < min_spaces
477           candidate = header_found[AFTER_PAR1_REGEX]
478           break unless candidate
479           txt = candidate.ircify_html
480           header_found = $'
481           txt.sub!(strip, '') if strip
482           debug "(other attempt \#1) #{txt.inspect} has #{txt.count(" ")} spaces"
483         end
484
485         return txt unless txt.empty? or txt.count(" ") < min_spaces
486
487         # Attempt #2
488         header_found = xml
489         while txt.empty? or txt.count(" ") < min_spaces
490           candidate = header_found[AFTER_PAR2_REGEX]
491           break unless candidate
492           txt = candidate.ircify_html
493           header_found = $'
494           txt.sub!(strip, '') if strip
495           debug "(other attempt \#2) #{txt.inspect} has #{txt.count(" ")} spaces"
496         end
497
498         debug "Last candidate #{txt.inspect} has #{txt.count(" ")} spaces"
499         return txt unless txt.count(" ") < min_spaces
500         break if min_spaces == 0
501         min_spaces /= 2
502       end
503     end
504
505     # This method extracts title, content (first par) and extra
506     # information from the given document _doc_.
507     #
508     # _doc_ can be an URI, a Net::HTTPResponse or a String.
509     #
510     # If _doc_ is a String, only title and content information
511     # are retrieved (if possible), using standard methods.
512     #
513     # If _doc_ is an URI or a Net::HTTPResponse, additional
514     # information is retrieved, and special title/summary
515     # extraction routines are used if possible.
516     #
517     def Utils.get_html_info(doc, opts={})
518       case doc
519       when String
520         Utils.get_string_html_info(doc, opts)
521       when Net::HTTPResponse
522         Utils.get_resp_html_info(doc, opts)
523       when URI
524         ret = DataStream.new
525         @@bot.httputil.get_response(doc) { |resp|
526           ret.replace Utils.get_resp_html_info(resp, opts)
527         }
528         return ret
529       else
530         raise
531       end
532     end
533
534     class ::UrlLinkError < RuntimeError
535     end
536
537     # This method extracts title, content (first par) and extra
538     # information from the given Net::HTTPResponse _resp_.
539     #
540     # Currently, the only accepted options (in _opts_) are
541     # uri_fragment:: the URI fragment of the original request
542     # full_body::    get the whole body instead of
543     #                @@bot.config['http.info_bytes'] bytes only
544     #
545     # Returns a DataStream with the following keys:
546     # text:: the (partial) body
547     # title:: the title of the document (if any)
548     # content:: the first paragraph of the document (if any)
549     # headers::
550     #   the headers of the Net::HTTPResponse. The value is
551     #   a Hash whose keys are lowercase forms of the HTTP
552     #   header fields, and whose values are Arrays.
553     #
554     def Utils.get_resp_html_info(resp, opts={})
555       case resp
556       when Net::HTTPSuccess
557         loc = URI.parse(resp['x-rbot-location'] || resp['location']) rescue nil
558         if loc and loc.fragment and not loc.fragment.empty?
559           opts[:uri_fragment] ||= loc.fragment
560         end
561         ret = DataStream.new(opts.dup)
562         ret[:headers] = resp.to_hash
563         ret[:text] = partial = opts[:full_body] ? resp.body : resp.partial_body(@@bot.config['http.info_bytes'])
564
565         filtered = Utils.try_htmlinfo_filters(ret)
566
567         if filtered
568           return filtered
569         elsif resp['content-type'] =~ /^text\/|(?:x|ht)ml/
570           ret.merge!(Utils.get_string_html_info(partial, opts))
571         end
572         return ret
573       else
574         raise UrlLinkError, "getting link (#{resp.code} - #{resp.message})"
575       end
576     end
577
578     # This method runs an appropriately-crafted DataStream _ds_ through the
579     # filters in the :htmlinfo filter group, in order. If one of the filters
580     # returns non-nil, its results are merged in _ds_ and returned. Otherwise
581     # nil is returned.
582     #
583     # The input DataStream shuold have the downloaded HTML as primary key
584     # (:text) and possibly a :headers key holding the resonse headers.
585     #
586     def Utils.try_htmlinfo_filters(ds)
587       filters = @@bot.filter_names(:htmlinfo)
588       return nil if filters.empty?
589       cur = nil
590       # TODO filter priority
591       filters.each { |n|
592         debug "testing filter #{n}"
593         cur = @@bot.filter(@@bot.global_filter_name(n, :htmlinfo), ds)
594         debug "returned #{cur.pretty_inspect}"
595         break if cur
596       }
597       return ds.merge(cur) if cur
598     end
599
600     # HTML info filters often need to check if the webpage location
601     # of a passed DataStream _ds_ matches a given Regexp.
602     def Utils.check_location(ds, rx)
603       debug ds[:headers]
604       if h = ds[:headers]
605         loc = [h['x-rbot-location'],h['location']].flatten.grep(rx)
606       end
607       loc ||= []
608       debug loc
609       return loc.empty? ? nil : loc
610     end
611
612     # This method extracts title and content (first par)
613     # from the given HTML or XML document _text_, using
614     # standard methods (String#ircify_html_title,
615     # Utils.ircify_first_html_par)
616     #
617     # Currently, the only accepted option (in _opts_) is
618     # uri_fragment:: the URI fragment of the original request
619     #
620     def Utils.get_string_html_info(text, opts={})
621       debug "getting string html info"
622       txt = text.dup
623       title = txt.ircify_html_title
624       debug opts
625       if frag = opts[:uri_fragment] and not frag.empty?
626         fragreg = /<a\s+(?:[^>]+\s+)?(?:name|id)=["']?#{frag}["']?[^>]*>/im
627         debug fragreg
628         debug txt
629         if txt.match(fragreg)
630           # grab the post-match
631           txt = $'
632         end
633         debug txt
634       end
635       c_opts = opts.dup
636       c_opts[:strip] ||= title
637       content = Utils.ircify_first_html_par(txt, c_opts)
638       content = nil if content.empty?
639       return {:title => title, :content => content}
640     end
641
642     # Get the first pars of the first _count_ _urls_.
643     # The pages are downloaded using the bot httputil service.
644     # Returns an array of the first paragraphs fetched.
645     # If (optional) _opts_ :message is specified, those paragraphs are
646     # echoed as replies to the IRC message passed as _opts_ :message
647     #
648     def Utils.get_first_pars(urls, count, opts={})
649       idx = 0
650       msg = opts[:message]
651       retval = Array.new
652       while count > 0 and urls.length > 0
653         url = urls.shift
654         idx += 1
655
656         begin
657           info = Utils.get_html_info(URI.parse(url), opts)
658
659           par = info[:content]
660           retval.push(par)
661
662           if par
663             msg.reply "[#{idx}] #{par}", :overlong => :truncate if msg
664             count -=1
665           end
666         rescue
667           debug "Unable to retrieve #{url}: #{$!}"
668           next
669         end
670       end
671       return retval
672     end
673
674   end
675 end
676
677 Irc::Utils.bot = Irc::Bot::Plugins.manager.bot