]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/utils.rb
safe_exec fixes
[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     # TODO: find a way to expose some common errors (e.g. Errno::NOENT)
279     # to the caller
280     def Utils.safe_exec(command, *args)
281       output = IO.popen("-") { |p|
282         if p
283           break p.readlines.join("\n")
284         else
285           begin
286             $stderr.reopen($stdout)
287             exec(command, *args)
288           rescue Exception => e
289             puts "exception #{e.pretty_inspect} trying to run #{command}"
290             Kernel::exit! 1
291           end
292           puts "exec of #{command} failed"
293           Kernel::exit! 1
294         end
295       }
296       raise "safe execution of #{command} returned #{$?}" unless $?.success?
297       return output
298     end
299
300     # Try executing an external program, returning true if the run was successful
301     # and false otherwise
302     def Utils.try_exec(command, *args)
303       IO.popen("-") { |p|
304         if p.nil?
305           begin
306             $stderr.reopen($stdout)
307             exec(command, *args)
308           rescue Exception => e
309             Kernel::exit! 1
310           end
311           Kernel::exit! 1
312         else
313           debug p.readlines
314         end
315       }
316       debug $?
317       return $?.success?
318     end
319
320     # Safely (atomically) save to _file_, by passing a tempfile to the block
321     # and then moving the tempfile to its final location when done.
322     #
323     # call-seq: Utils.safe_save(file, &block)
324     #
325     def Utils.safe_save(file)
326       raise 'No safe save directory defined!' if @@safe_save_dir.nil?
327       basename = File.basename(file)
328       temp = Tempfile.new(basename,@@safe_save_dir)
329       temp.binmode
330       yield temp if block_given?
331       temp.close
332       File.rename(temp.path, file)
333     end
334
335
336     # Decode HTML entities in the String _str_, using HTMLEntities if the
337     # package was found, or UNESCAPE_TABLE otherwise.
338     #
339     def Utils.decode_html_entities(str)
340       if defined? ::HTMLEntities
341         return HTMLEntities.decode_entities(str)
342       else
343         str.gsub(/(&(.+?);)/) {
344           symbol = $2
345           # remove the 0-paddng from unicode integers
346           if symbol =~ /^#(\d+)$/
347             symbol = $1.to_i.to_s
348           end
349
350           # output the symbol's irc-translated character, or a * if it's unknown
351           UNESCAPE_TABLE[symbol] || (symbol.match(/^\d+$/) ? [symbol.to_i].pack("U") : '*')
352         }
353       end
354     end
355
356     # Try to grab and IRCify the first HTML par (<p> tag) in the given string.
357     # If possible, grab the one after the first heading
358     #
359     # It is possible to pass some options to determine how the stripping
360     # occurs. Currently supported options are
361     # strip:: Regex or String to strip at the beginning of the obtained
362     #         text
363     # min_spaces:: minimum number of spaces a paragraph should have
364     #
365     def Utils.ircify_first_html_par(xml_org, opts={})
366       if defined? ::Hpricot
367         Utils.ircify_first_html_par_wh(xml_org, opts)
368       else
369         Utils.ircify_first_html_par_woh(xml_org, opts)
370       end
371     end
372
373     # HTML first par grabber using hpricot
374     def Utils.ircify_first_html_par_wh(xml_org, opts={})
375       doc = Hpricot(xml_org)
376
377       # Strip styles and scripts
378       (doc/"style|script").remove
379
380       debug doc
381
382       strip = opts[:strip]
383       strip = Regexp.new(/^#{Regexp.escape(strip)}/) if strip.kind_of?(String)
384
385       min_spaces = opts[:min_spaces] || 8
386       min_spaces = 0 if min_spaces < 0
387
388       txt = String.new
389
390       pre_h = pars = by_span = nil
391
392       while true
393         debug "Minimum number of spaces: #{min_spaces}"
394
395         # Initial attempt: <p> that follows <h\d>
396         if pre_h.nil?
397           pre_h = Hpricot::Elements[]
398           found_h = false
399           doc.search("*") { |e|
400             next if e.bogusetag?
401             case e.pathname
402             when /^h\d/
403               found_h = true
404             when 'p'
405               pre_h << e if found_h
406             end
407           }
408           debug "Hx: found: #{pre_h.pretty_inspect}"
409         end
410
411         pre_h.each { |p|
412           debug p
413           txt = p.to_html.ircify_html
414           txt.sub!(strip, '') if strip
415           debug "(Hx attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
416           break unless txt.empty? or txt.count(" ") < min_spaces
417         }
418
419         return txt unless txt.empty? or txt.count(" ") < min_spaces
420
421         # Second natural attempt: just get any <p>
422         pars = doc/"p" if pars.nil?
423         debug "par: found: #{pars.pretty_inspect}"
424         pars.each { |p|
425           debug p
426           txt = p.to_html.ircify_html
427           txt.sub!(strip, '') if strip
428           debug "(par attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
429           break unless txt.empty? or txt.count(" ") < min_spaces
430         }
431
432         return txt unless txt.empty? or txt.count(" ") < min_spaces
433
434         # Nothing yet ... let's get drastic: we look for non-par elements too,
435         # but only for those that match something that we know is likely to
436         # contain text
437
438         # Some blogging and forum platforms use spans or divs with a 'body' or
439         # 'message' or 'text' in their class to mark actual text. Since we want
440         # the class match to be partial and case insensitive, we collect
441         # the common elements that may have this class and then filter out those
442         # we don't need. If no divs or spans are found, we'll accept additional
443         # elements too (td, tr, tbody, table).
444         if by_span.nil?
445           by_span = Hpricot::Elements[]
446           extra = Hpricot::Elements[]
447           doc.search("*") { |el|
448             next if el.bogusetag?
449             case el.pathname
450             when AFTER_PAR_PATH
451               by_span.push el if el[:class] =~ AFTER_PAR_CLASS or el[:id] =~ AFTER_PAR_CLASS
452             when AFTER_PAR_EX
453               extra.push el if el[:class] =~ AFTER_PAR_CLASS or el[:id] =~ AFTER_PAR_CLASS
454             end
455           }
456           if by_span.empty? and not extra.empty?
457             by_span.concat extra
458           end
459           debug "other \#1: found: #{by_span.pretty_inspect}"
460         end
461
462         by_span.each { |p|
463           debug p
464           txt = p.to_html.ircify_html
465           txt.sub!(strip, '') if strip
466           debug "(other attempt \#1) #{txt.inspect} has #{txt.count(" ")} spaces"
467           break unless txt.empty? or txt.count(" ") < min_spaces
468         }
469
470         return txt unless txt.empty? or txt.count(" ") < min_spaces
471
472         # At worst, we can try stuff which is comprised between two <br>
473         # TODO
474
475         debug "Last candidate #{txt.inspect} has #{txt.count(" ")} spaces"
476         return txt unless txt.count(" ") < min_spaces
477         break if min_spaces == 0
478         min_spaces /= 2
479       end
480     end
481
482     # HTML first par grabber without hpricot
483     def Utils.ircify_first_html_par_woh(xml_org, opts={})
484       xml = xml_org.gsub(/<!--.*?-->/m, '').gsub(/<script(?:\s+[^>]*)?>.*?<\/script>/im, "").gsub(/<style(?:\s+[^>]*)?>.*?<\/style>/im, "")
485
486       strip = opts[:strip]
487       strip = Regexp.new(/^#{Regexp.escape(strip)}/) if strip.kind_of?(String)
488
489       min_spaces = opts[:min_spaces] || 8
490       min_spaces = 0 if min_spaces < 0
491
492       txt = String.new
493
494       while true
495         debug "Minimum number of spaces: #{min_spaces}"
496         header_found = xml.match(HX_REGEX)
497         if header_found
498           header_found = $'
499           while txt.empty? or txt.count(" ") < min_spaces
500             candidate = header_found[PAR_REGEX]
501             break unless candidate
502             txt = candidate.ircify_html
503             header_found = $'
504             txt.sub!(strip, '') if strip
505             debug "(Hx attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
506           end
507         end
508
509         return txt unless txt.empty? or txt.count(" ") < min_spaces
510
511         # If we haven't found a first par yet, try to get it from the whole
512         # document
513         header_found = xml
514         while txt.empty? or txt.count(" ") < min_spaces
515           candidate = header_found[PAR_REGEX]
516           break unless candidate
517           txt = candidate.ircify_html
518           header_found = $'
519           txt.sub!(strip, '') if strip
520           debug "(par attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
521         end
522
523         return txt unless txt.empty? or txt.count(" ") < min_spaces
524
525         # Nothing yet ... let's get drastic: we look for non-par elements too,
526         # but only for those that match something that we know is likely to
527         # contain text
528
529         # Attempt #1
530         header_found = xml
531         while txt.empty? or txt.count(" ") < min_spaces
532           candidate = header_found[AFTER_PAR1_REGEX]
533           break unless candidate
534           txt = candidate.ircify_html
535           header_found = $'
536           txt.sub!(strip, '') if strip
537           debug "(other attempt \#1) #{txt.inspect} has #{txt.count(" ")} spaces"
538         end
539
540         return txt unless txt.empty? or txt.count(" ") < min_spaces
541
542         # Attempt #2
543         header_found = xml
544         while txt.empty? or txt.count(" ") < min_spaces
545           candidate = header_found[AFTER_PAR2_REGEX]
546           break unless candidate
547           txt = candidate.ircify_html
548           header_found = $'
549           txt.sub!(strip, '') if strip
550           debug "(other attempt \#2) #{txt.inspect} has #{txt.count(" ")} spaces"
551         end
552
553         debug "Last candidate #{txt.inspect} has #{txt.count(" ")} spaces"
554         return txt unless txt.count(" ") < min_spaces
555         break if min_spaces == 0
556         min_spaces /= 2
557       end
558     end
559
560     # This method extracts title, content (first par) and extra
561     # information from the given document _doc_.
562     #
563     # _doc_ can be an URI, a Net::HTTPResponse or a String.
564     #
565     # If _doc_ is a String, only title and content information
566     # are retrieved (if possible), using standard methods.
567     #
568     # If _doc_ is an URI or a Net::HTTPResponse, additional
569     # information is retrieved, and special title/summary
570     # extraction routines are used if possible.
571     #
572     def Utils.get_html_info(doc, opts={})
573       case doc
574       when String
575         Utils.get_string_html_info(doc, opts)
576       when Net::HTTPResponse
577         Utils.get_resp_html_info(doc, opts)
578       when URI
579         ret = DataStream.new
580         @@bot.httputil.get_response(doc) { |resp|
581           ret.replace Utils.get_resp_html_info(resp, opts)
582         }
583         return ret
584       else
585         raise
586       end
587     end
588
589     class ::UrlLinkError < RuntimeError
590     end
591
592     # This method extracts title, content (first par) and extra
593     # information from the given Net::HTTPResponse _resp_.
594     #
595     # Currently, the only accepted options (in _opts_) are
596     # uri_fragment:: the URI fragment of the original request
597     # full_body::    get the whole body instead of
598     #                @@bot.config['http.info_bytes'] bytes only
599     #
600     # Returns a DataStream with the following keys:
601     # text:: the (partial) body
602     # title:: the title of the document (if any)
603     # content:: the first paragraph of the document (if any)
604     # headers::
605     #   the headers of the Net::HTTPResponse. The value is
606     #   a Hash whose keys are lowercase forms of the HTTP
607     #   header fields, and whose values are Arrays.
608     #
609     def Utils.get_resp_html_info(resp, opts={})
610       case resp
611       when Net::HTTPSuccess
612         loc = URI.parse(resp['x-rbot-location'] || resp['location']) rescue nil
613         if loc and loc.fragment and not loc.fragment.empty?
614           opts[:uri_fragment] ||= loc.fragment
615         end
616         ret = DataStream.new(opts.dup)
617         ret[:headers] = resp.to_hash
618         ret[:text] = partial = opts[:full_body] ? resp.body : resp.partial_body(@@bot.config['http.info_bytes'])
619
620         filtered = Utils.try_htmlinfo_filters(ret)
621
622         if filtered
623           return filtered
624         elsif resp['content-type'] =~ /^text\/|(?:x|ht)ml/
625           ret.merge!(Utils.get_string_html_info(partial, opts))
626         end
627         return ret
628       else
629         raise UrlLinkError, "getting link (#{resp.code} - #{resp.message})"
630       end
631     end
632
633     # This method runs an appropriately-crafted DataStream _ds_ through the
634     # filters in the :htmlinfo filter group, in order. If one of the filters
635     # returns non-nil, its results are merged in _ds_ and returned. Otherwise
636     # nil is returned.
637     #
638     # The input DataStream should have the downloaded HTML as primary key
639     # (:text) and possibly a :headers key holding the resonse headers.
640     #
641     def Utils.try_htmlinfo_filters(ds)
642       filters = @@bot.filter_names(:htmlinfo)
643       return nil if filters.empty?
644       cur = nil
645       # TODO filter priority
646       filters.each { |n|
647         debug "testing htmlinfo filter #{n}"
648         cur = @@bot.filter(@@bot.global_filter_name(n, :htmlinfo), ds)
649         debug "returned #{cur.pretty_inspect}"
650         break if cur
651       }
652       return ds.merge(cur) if cur
653     end
654
655     # HTML info filters often need to check if the webpage location
656     # of a passed DataStream _ds_ matches a given Regexp.
657     def Utils.check_location(ds, rx)
658       debug ds[:headers]
659       if h = ds[:headers]
660         loc = [h['x-rbot-location'],h['location']].flatten.grep(rx)
661       end
662       loc ||= []
663       debug loc
664       return loc.empty? ? nil : loc
665     end
666
667     # This method extracts title and content (first par)
668     # from the given HTML or XML document _text_, using
669     # standard methods (String#ircify_html_title,
670     # Utils.ircify_first_html_par)
671     #
672     # Currently, the only accepted option (in _opts_) is
673     # uri_fragment:: the URI fragment of the original request
674     #
675     def Utils.get_string_html_info(text, opts={})
676       debug "getting string html info"
677       txt = text.dup
678       title = txt.ircify_html_title
679       debug opts
680       if frag = opts[:uri_fragment] and not frag.empty?
681         fragreg = /<a\s+(?:[^>]+\s+)?(?:name|id)=["']?#{frag}["']?[^>]*>/im
682         debug fragreg
683         debug txt
684         if txt.match(fragreg)
685           # grab the post-match
686           txt = $'
687         end
688         debug txt
689       end
690       c_opts = opts.dup
691       c_opts[:strip] ||= title
692       content = Utils.ircify_first_html_par(txt, c_opts)
693       content = nil if content.empty?
694       return {:title => title, :content => content}
695     end
696
697     # Get the first pars of the first _count_ _urls_.
698     # The pages are downloaded using the bot httputil service.
699     # Returns an array of the first paragraphs fetched.
700     # If (optional) _opts_ :message is specified, those paragraphs are
701     # echoed as replies to the IRC message passed as _opts_ :message
702     #
703     def Utils.get_first_pars(urls, count, opts={})
704       idx = 0
705       msg = opts[:message]
706       retval = Array.new
707       while count > 0 and urls.length > 0
708         url = urls.shift
709         idx += 1
710
711         begin
712           info = Utils.get_html_info(URI.parse(url), opts)
713
714           par = info[:content]
715           retval.push(par)
716
717           if par
718             msg.reply "[#{idx}] #{par}", :overlong => :truncate if msg
719             count -=1
720           end
721         rescue
722           debug "Unable to retrieve #{url}: #{$!}"
723           next
724         end
725       end
726       return retval
727     end
728
729     # Returns a comma separated list except for the last element
730     # which is joined in with specified conjunction
731     #
732     def Utils.comma_list(words, options={})
733       defaults = { :join_with => ", ", :join_last_with => _(" and ") }
734       opts = defaults.merge(options)
735
736       if words.size < 2
737         words.last
738       else
739         [words[0..-2].join(opts[:join_with]), words.last].join(opts[:join_last_with])
740       end
741     end
742
743   end
744 end
745
746 Irc::Utils.bot = Irc::Bot::Plugins.manager.bot