]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/imdb.rb
imdb: update to latest html
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / imdb.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: IMDB plugin for rbot
5 #
6 # Author:: Arnaud Cornet <arnaud.cornet@gmail.com>
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 #
9 # Copyright:: (C) 2005 Arnaud Cornet
10 # Copyright:: (C) 2007 Giuseppe Bilotta
11 #
12 # License:: MIT license
13
14 class Imdb
15   IMDB = "http://www.imdb.com"
16   TITLE_OR_NAME_MATCH = /<a\s+href="(\/(?:title|name)\/(?:tt|nm)[0-9]+\/?)[^"]*"(?:[^>]*)>([^<]*)<\/a>/
17   TITLE_MATCH = /<a\s+href="(\/title\/tt[0-9]+\/?)[^"]*"(?:[^>]*)>([^<]*)<\/a>/
18   NAME_MATCH = /<a\s+href="(\/name\/nm[0-9]+\/?)[^"]*"(?:[^>]*)>([^<]*)<\/a>/
19   CREDIT_NAME_MATCH = /#{NAME_MATCH}\s*<\/td>\s*<td[^>]+>\s*\.\.\.\s*<\/td>\s*<td[^>]+>\s*(.+?)\s*<\/td>/m
20   FINAL_ARTICLE_MATCH = /, ([A-Z]\S{0,2})$/
21   DESC_MATCH = /<meta name="description" content="(.*?)\. (.*?)\. (.*?)\."\s*\/>/
22
23   MATCHER = {
24     :title => TITLE_MATCH,
25     :name => NAME_MATCH,
26     :both => TITLE_OR_NAME_MATCH
27   }
28
29   def initialize(bot)
30     @bot = bot
31   end
32
33   def search(rawstr, rawopts={})
34     str = CGI.escape(rawstr)
35     str << ";site=aka" if @bot.config['imdb.aka']
36     opts = rawopts.dup
37     opts[:type] = :both unless opts[:type]
38     return do_search(str, opts)
39   end
40
41   def do_search(str, opts={})
42     resp = nil
43     begin
44       resp = @bot.httputil.get_response(IMDB + "/find?q=#{str}",
45                                         :max_redir => -1)
46     rescue Exception => e
47       error e.message
48       warning e.backtrace.join("\n")
49       return nil
50     end
51
52
53     matcher = MATCHER[opts[:type]]
54
55     if resp.code == "200"
56       m = []
57       m << matcher.match(resp.body) if @bot.config['imdb.popular']
58       if resp.body.match(/\(Exact Matches\)<\/b>/) and @bot.config['imdb.exact']
59         m << matcher.match($')
60       end
61       m.compact!
62       unless m.empty?
63         return m.map { |mm|
64           mm[1]
65         }.uniq
66       end
67     elsif resp.code == "302"
68       debug "automatic redirection"
69       new_loc = resp['location'].gsub(IMDB, "")
70       if new_loc.match(/\/find\?q=(.*)/)
71         return do_search($1, opts)
72       else
73         return [new_loc.gsub(/\?.*/, "")]
74       end
75     end
76     return nil
77   end
78
79   def info(rawstr, opts={})
80     debug opts.inspect
81     urls = search(rawstr, opts)
82     debug urls
83     if urls.nil_or_empty?
84       debug "IMDB: search returned NIL"
85       return nil
86     end
87     results = []
88     urls.each { |sr|
89       type = sr.match(/^\/([^\/]+)\//)[1].downcase.intern rescue nil
90       case type
91       when :title
92         results << info_title(sr, opts)
93       when :name
94         results << info_name(sr, opts)
95       else
96         results << "#{sr}"
97       end
98     }
99     return results
100   end
101
102   def grab_info(info, body)
103     /<div (?:id="\S+-info" )?class="(?:txt-block|see-more inline canwrap)">\s*<h[45](?: class="inline")?>\s*#{info}:\s*<\/h[45]>\s*(.*?)<\/div>/mi.match(body)[1] rescue nil
104   end
105
106   def fix_article(org_tit)
107     title = org_tit.dup
108     debug title.inspect
109     if title.match(/^"(.*)"$/)
110       return "\"#{fix_article($1)}\""
111     end
112     if @bot.config['imdb.fix_article'] and title.gsub!(FINAL_ARTICLE_MATCH, '')
113       art = $1.dup
114       debug art.inspect
115       if art[-1,1].match(/[A-Za-z]/)
116         art << " "
117       end
118       return art + title
119     end
120     return title
121   end
122
123   def info_title(sr, opts={})
124     resp = nil
125     begin
126       resp = @bot.httputil.get_response(IMDB + sr, :max_redir => -1)
127     rescue Exception => e
128       error e.message
129       warning e.backtrace.join("\n")
130       return nil
131     end
132
133     info = []
134
135     if resp.code == "200"
136       m = /<title>([^<]*)<\/title>/.match(resp.body)
137       return nil if !m
138       title_date = m[1].ircify_html
139       debug title_date
140       # note that the date dash for series is a - (ndash), not a - (minus sign)
141       pre_title, extra, date, junk = title_date.scan(/^(.*)\((.+?\s+)?(\d\d\d\d(?:–(?:\d\d\d\d)?)?(?:\/[IV]+)?)\)\s*(.+)?$/).first
142       extra.strip! if extra
143       pre_title.strip!
144       title = fix_article(pre_title)
145
146       dir = nil
147       data = grab_info(/(?:Director|Creator)s?/, resp.body)
148       if data
149         dir = data.scan(NAME_MATCH).map { |url, name|
150           name.ircify_html
151         }.join(', ')
152       end
153
154       country = nil
155       data = grab_info(/Country/, resp.body)
156       if data
157         country = data.ircify_html.gsub(' / ','/')
158       end
159
160       info << [title, "(#{country}, #{date})", extra, dir ? "[#{dir}]" : nil, opts[:nourl] ? nil : ": http://www.imdb.com#{sr}"].compact.join(" ")
161
162       return info if opts[:title_only]
163
164       if opts[:characters]
165         info << resp.body.scan(CREDIT_NAME_MATCH).map { |url, name, role|
166           "%s: %s" % [name, role.ircify_html]
167         }.join('; ')
168         return info
169       end
170
171       ratings = "no votes"
172       m = resp.body.match(/<b>([0-9.]+)<\/b><span [^>]+>\/10<\/span><\/span>\s*[^<]+<a\s+[^>]*href="ratings"[^>]+>([0-9,]+) votes?<\/a>/m)
173       if m
174         ratings = "#{m[1]}/10 (#{m[2]} voters)"
175       end
176
177       genre = Array.new
178       resp.body.scan(/<a href="\/genre\/[^"]+">([^<]+)<\/a>/) do |gnr|
179         genre << gnr
180       end
181
182       plot = resp.body.match(DESC_MATCH)[3] rescue nil
183       # TODO option to extract the long storyline
184       # data = resp.body.match(/<h2>Storyline<\/h2>\s+/m).post_match.match(/<\/p>/).pre_match rescue nil
185       # if data
186       #   data.sub!(/<em class="nobr">Written by.*$/m, '')
187       #   plot = data.ircify_html.gsub(/\s+more\s*$/,'').gsub(/\s+Full summary » \| Full synopsis »\s*$/,'')
188       # end
189       plot = "Plot: #{plot}" if plot
190
191       info << ["Ratings: " << ratings, "Genre: " << genre.join('/') , plot].compact.join(". ")
192
193       return info
194     end
195     return nil
196   end
197
198   def info_name(sr, opts={})
199     resp = nil
200     begin
201       resp = @bot.httputil.get_response(IMDB + sr, :max_redir => -1)
202     rescue Exception => e
203       error e.message
204       warning e.backtrace.join("\n")
205       return nil
206     end
207
208     info = []
209
210     if resp.code == "200"
211       m = /<title>([^<]*)<\/title>/.match(resp.body)
212       return nil if !m
213       name = m[1].sub(/ - IMDb/, '')
214
215       info << name
216       info.last << " : http://www.imdb.com#{sr}" unless opts[:nourl]
217
218       return info if opts[:name_only]
219
220       if opts[:movies_by_year]
221         filmoyear = @bot.httputil.get(IMDB + sr + "filmoyear")
222         if filmoyear
223           info << filmoyear.scan(/#{TITLE_MATCH} \((\d\d\d\d)\)[^\[\n]*((?:\s+\[[^\]]+\](?:\s+\([^\[<]+\))*)+)\s+</)
224         end
225         return info
226       end
227
228       birth = nil
229       data = grab_info("Date of Birth", resp.body)
230       if data
231         birth = "Birth: #{data.ircify_html.gsub(/\s+more$/,'')}"
232       end
233
234       death = nil
235       data = grab_info("Date of Death", resp.body)
236       if data
237         death = "Death: #{data.ircify_html.gsub(/\s+more$/,'')}"
238       end
239
240       info << [birth, death].compact.join('. ') if birth or death
241
242       movies = {}
243
244       filmorate = nil
245       begin
246         filmorate = @bot.httputil.get(IMDB + sr + "filmorate")
247       rescue Exception
248       end
249
250       if filmorate
251         filmorate.scan(/<div class="filmo">.*?<a href="\/title.*?<\/div>/m) { |str|
252           what = str.match(/<a name="[^"]+">([^<]+)<\/a>/)[1] rescue nil
253           next unless what
254           movies[what] = str.scan(TITLE_MATCH)[0..2].map { |url, tit|
255             fix_article(tit.ircify_html)
256           }
257         }
258       end
259
260       preferred = ['Actor', 'Director']
261       if resp.body.match(/Jump to filmography as:&nbsp;(.*?)<\/div>/)
262         txt = $1
263         preferred = txt.scan(/<a[^>]+>([^<]+)<\/a>/)[0..2].map { |pref|
264           pref.first
265         }
266       end
267
268       unless movies.empty?
269         all_keys = movies.keys.sort
270         debug all_keys.inspect
271         keys = []
272         preferred.each { |key|
273           keys << key if all_keys.include? key
274         }
275         keys = all_keys if keys.empty?
276         ar = []
277         keys.each { |key|
278           ar << key.dup
279           ar.last << ": " + movies[key].join('; ')
280         }
281         info << ar.join('. ')
282       end
283       return info
284
285     end
286     return nil
287   end
288
289   def year_movies(urls, years_txt_org, role_req)
290     years_txt = years_txt_org.dup
291     years_txt.sub!(/^'/,'')
292     years_txt = "9#{years_txt}" if years_txt.match(/^\d\ds?$/)
293     years_txt = "1#{years_txt}" if years_txt.match(/^\d\d\ds?$/)
294
295     years = []
296     case years_txt
297     when /^\d\d\d\d$/
298       years << years_txt
299     when /^(\d\d\d\d)s$/
300       base = $1.to_i
301       base.upto(base+9) { |year|
302         years << year.to_s
303       }
304     end
305
306     urls.map { |url|
307       info = info_name(url, :movies_by_year => true)
308
309       debug info.inspect
310
311       name_url = info.first
312       data = info[1]
313
314       movies = []
315       # Sort by pre-title putting movies before TV series
316       data.sort { |a, b|
317         aclip = a[1][0,5]
318         bclip = b[1][0,5]
319         quot = '&#34;'
320         (aclip == quot ? 1 : -1) <=> (bclip == quot ? 1 : -1)
321       }.each { |url, pre_title, year, pre_roles|
322         next unless years.include?(year)
323         title = fix_article(pre_title.ircify_html)
324         if title[0] == ?" and not @bot.config['imdb.tv_series_in_movies']
325           next
326         end
327         title << " (#{year})" unless years.length == 1
328         role_array = []
329         pre_roles.strip.scan(/\[([^\]]+)\]((?:\s+\([^\[]+\))+)?/) { |txt, comm|
330           role = nil
331           extra = nil
332           if txt.match(/^(.*)\s+\.\.\.\.\s+(.*)$/)
333             role = $1
334             extra = "(#{$2.ircify_html})"
335           else
336             role = txt
337           end
338           next if role_req and not role.match(/^#{role_req}/i)
339           if comm
340             extra ||= ""
341             extra += comm.ircify_html if comm
342           end
343           role_array << [role, extra]
344         }
345         next if role_req and role_array.empty?
346
347         roles = role_array.map { |ar|
348           if role_req
349             ar[1] # works for us both if it's nil and if it's something
350           else
351             ar.compact.join(" ")
352           end
353         }.compact.join(', ')
354         roles = nil if roles.empty?
355         movies << [roles, title].compact.join(": ")
356       }
357
358       if movies.empty?
359         [name_url, nil]
360       else
361         [name_url, movies.join(" | ")]
362       end
363     }
364   end
365
366   def name_in_movie(name_urls, movie_urls)
367     info = []
368     movie_urls.each { |movie|
369       title_info = info_title(movie, :title_only => true)
370       valid = []
371
372       data = @bot.httputil.get(IMDB + movie + "fullcredits")
373       data.scan(CREDIT_NAME_MATCH).each { |url, name, role|
374         valid << [url, name.ircify_html, role.ircify_html] if name_urls.include?(url)
375       }
376       valid.each { |url, name, role|
377         info << "%s : %s was %s in %s" % [name, IMDB + url, role, title_info]
378       }
379     }
380     return info
381   end
382
383
384 end
385
386 class ImdbPlugin < Plugin
387   Config.register Config::BooleanValue.new('imdb.aka',
388     :default => true,
389     :desc => "Look for IMDB matches also in translated titles and other 'also known as' information")
390   Config.register Config::BooleanValue.new('imdb.popular',
391     :default => true,
392     :desc => "Display info on popular IMDB entries matching the request closely")
393   Config.register Config::BooleanValue.new('imdb.exact',
394     :default => true,
395     :desc => "Display info on IMDB entries matching the request exactly")
396   Config.register Config::BooleanValue.new('imdb.fix_article',
397     :default => false,
398     :desc => "Try to detect an article placed at the end and move it in front of the title")
399   Config.register Config::BooleanValue.new('imdb.tv_series_in_movies',
400     :default => false,
401     :desc => "Whether searching movies by person/year should also return TV series")
402
403   def help(plugin, topic="")
404     case plugin
405     when "movies"
406       "movies by <who> in <years> [as <role>] => display the movies in the <years> where which <who> was <role>; <role> can be one of actor, actress, director or anything: if it's omitted, the role is defined by the prefix: \"movies by ...\" implies director, \"movies with ...\" implies actor or actress; the years can be specified as \"in the 60s\" or as \"in 1953\""
407     when /characters?/
408       "character played by <who> in <movie> => show the character played by <who> in movie <movie>. characters in <movie> => show the actors and characters in movie <movie>"
409     else
410       "imdb <string> => search http://www.imdb.org for <string>: prefix <string> with 'name' or 'title' if you only want to search for people or films respectively, e.g.: imdb name ed wood. see also movies and characters"
411     end
412   end
413
414   attr_reader :i
415
416   TITLE_URL = %r{^http://(?:[^.]+\.)?imdb.com(/title/tt\d+/)}
417   NAME_URL = %r{^http://(?:[^.]+\.)?imdb.com(/name/nm\d+/)}
418   def imdb_filter(s)
419     loc = Utils.check_location(s, TITLE_URL)
420     if loc
421       sr = loc.first.match(TITLE_URL)[1]
422       extra = $2 # nothign for the time being, could be fullcredits or whatever
423       res = i.info_title(sr, :nourl => true, :characters => (extra == 'fullcredits'))
424       debug res
425       if res
426         return {:title => res.first, :content => res.last}
427       else
428         return nil
429       end
430     end
431     loc = Utils.check_location(s, NAME_URL)
432     if loc
433       sr = loc.first.match(NAME_URL)[1]
434       extra = $2 # nothing for the time being, could be filmoyear or whatever
435       res = i.info_name(sr, :nourl => true, :movies_by_year => (extra == 'filmoyear'))
436       debug res
437       if res
438         name = res.shift
439         return {:title => name, :content => res.join(". ")}
440       else
441         return nil
442       end
443     end
444     return nil
445   end
446
447   def initialize
448     super
449     @i = Imdb.new(@bot)
450     @bot.register_filter(:imdb, :htmlinfo) { |s| imdb_filter(s) }
451   end
452
453   # Find a person or movie on IMDB. A :type (name/title, default both) can be
454   # specified to limit the search to either.
455   #
456   def imdb(m, params)
457     if params[:movie]
458       movie = params[:movie].to_s
459       info = i.info(movie, :type => :title, :characters => true)
460     else
461       what = params[:what].to_s
462       type = params[:type].intern
463       info = i.info(what, :type => type)
464       if !info
465         m.reply "nothing found for #{what}"
466         return nil
467       end
468     end
469     if info.length == 1
470       m.reply Utils.decode_html_entities(info.first.join("\n"))
471     else
472       m.reply info.map { |si|
473         Utils.decode_html_entities si.join(" | ")
474       }.join("\n")
475     end
476   end
477
478   # Find the movies with a participation of :who in the year :year
479   # TODO: allow year to be either a year or a decade ('[in the] 1960s')
480   #
481   def movies(m, params)
482     who = params[:who].to_s
483     years = params[:years]
484     role = params[:role]
485     if role and role.downcase == 'anything'
486       role = nil
487     elsif not role
488       case params[:prefix].intern
489       when :with
490         role = /actor|actress/i
491       when :by
492         role = 'director'
493       end
494     end
495
496     name_urls = i.search(who, :type => :name)
497     unless name_urls
498       m.reply "nothing found about #{who}, sorry"
499       return
500     end
501
502     movie_urls = i.year_movies(name_urls, years, role)
503     debug movie_urls.inspect
504     debug movie_urls[0][1]
505
506     if movie_urls.length == 1 and movie_urls[0][1]
507       m.reply movie_urls.join("\n")
508     else
509       m.reply movie_urls.map { |si|
510         si[1] = "no movies in #{years}" unless si[1]
511         Utils.decode_html_entities si.join(" | ")
512       }.join("\n")
513     end
514   end
515
516   # Find the character played by :who in :movie
517   #
518   def character(m, params)
519     who = params[:who].to_s
520     movie = params[:movie].to_s
521
522     name_urls = i.search(who, :type => :name)
523     unless name_urls
524       m.reply "nothing found about #{who}, sorry"
525       return
526     end
527
528     movie_urls = i.search(movie, :type => :title)
529     unless movie_urls
530       m.reply "nothing found about #{who}, sorry"
531       return
532     end
533
534     info = i.name_in_movie(name_urls, movie_urls)
535     if info.empty?
536       m.reply "nothing found about #{who} in #{movie}, sorry"
537     else
538       m.reply info.join("\n")
539     end
540   end
541
542   # Report the characters in movie :movie
543   #
544   def characters(m, params)
545     movie = params[:movie].to_s
546
547     urls = i.search(movie, :type => :title)
548     unless urls
549       m.reply "nothing found about #{movie}"
550     end
551
552   end
553
554 end
555
556 plugin = ImdbPlugin.new
557
558 plugin.map "imdb [:type] *what", :requirements => { :type => /name|title/ }, :defaults => { :type => 'both' }
559 plugin.map "movies :prefix *who in [the] :years [as :role]", :requirements => { :prefix => /with|by|from/, :years => /'?\d+s?/ }
560 plugin.map "character [played] by *who in *movie"
561 plugin.map "character of *who in *movie"
562 plugin.map "characters in *movie", :action => :imdb
563