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