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