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