]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/translator.rb
chucknorris: typo
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / translator.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Translator plugin for rbot
5 #
6 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
7 # Copyright:: (C) 2007 Yaohan Chen
8 # License:: GPLv2
9 #
10 # This plugin allows using rbot to translate text on a few translation services
11 #
12 # TODO
13 #
14 # * Configuration for whether to show translation engine
15 # * Optionally sync default translators with karma.rb ranking
16
17 require 'set'
18 require 'timeout'
19
20 # base class for implementing a translation service
21 # = Attributes
22 # direction:: supported translation directions, a hash where each key is a source
23 #             language name, and each value is Set of target language names. The
24 #             methods in the Direction module are convenient for initializing this
25 #             attribute
26 class Translator
27   INFO = 'Some translation service'
28
29   class UnsupportedDirectionError < ArgumentError
30   end
31
32   class NoTranslationError < RuntimeError
33   end
34
35   attr_reader :directions, :cache
36
37   def initialize(directions, cache={})
38     @directions = directions
39     @cache = cache
40   end
41
42   # Many translators use Mechanize, which changed namespace around version 1.0
43   # To support both pre-1.0 and post-1.0 namespaces, we use these auxiliary
44   # method. The translator still needs to require 'mechanize' on initialization
45   # if it needs it.
46   def mechanize
47     return Mechanize if defined? Mechanize
48     return WWW::Mechanize
49   end
50
51   # whether the translator supports this direction
52   def support?(from, to)
53     from != to && @directions[from].include?(to)
54   end
55
56   # this implements argument checking and caching. subclasses should define the
57   # do_translate method to implement actual translation
58   def translate(text, from, to)
59     raise UnsupportedDirectionError unless support?(from, to)
60     raise ArgumentError, _("Cannot translate empty string") if text.empty?
61     request = [text, from, to]
62     unless @cache.has_key? request
63       translation = do_translate(text, from, to)
64       raise NoTranslationError if translation.empty?
65       @cache[request] = translation
66     else
67       @cache[request]
68     end
69   end
70
71   module Direction
72     # given the set of supported languages, return a hash suitable for the directions
73     # attribute which includes any language to any other language
74     def self.all_to_all(languages)
75       directions = all_to_none(languages)
76       languages.each {|l| directions[l] = languages.to_set}
77       directions
78     end
79
80     # a hash suitable for the directions attribute which includes any language from/to
81     # the given set of languages (center_languages)
82     def self.all_from_to(languages, center_languages)
83       directions = all_to_none(languages)
84       center_languages.each {|l| directions[l] = languages - [l]}
85       (languages - center_languages).each {|l| directions[l] = center_languages.to_set}
86       directions
87     end
88
89     # get a hash from a list of pairs
90     def self.pairs(list_of_pairs)
91       languages = list_of_pairs.flatten.to_set
92       directions = all_to_none(languages)
93       list_of_pairs.each do |(from, to)|
94         directions[from] << to
95       end
96       directions
97     end
98
99     # an empty hash with empty sets as default values
100     def self.all_to_none(languages)
101       Hash.new do |h, k|
102         # always return empty set when the key is non-existent, but put empty set in the
103         # hash only if the key is one of the languages
104         if languages.include? k
105           h[k] = Set.new
106         else
107           Set.new
108         end
109       end
110     end
111   end
112 end
113
114
115 class NiftyTranslator < Translator
116   INFO = '@nifty Translation <http://nifty.amikai.com/amitext/indexUTF8.jsp>'
117
118   def initialize(cache={})
119    require 'mechanize'
120    super(Translator::Direction.all_from_to(%w[ja en zh_CN ko], %w[ja]), cache)
121   end
122
123   def do_translate(text, from, to)
124     @form ||= mechanize.new.
125               get('http://nifty.amikai.com/amitext/indexUTF8.jsp').
126               forms_with(:name => 'translateForm').last
127     @radio = @form.radiobuttons_with(:name => 'langpair').first
128     @radio.value = "#{from},#{to}".upcase
129     @radio.check
130     @form.fields_with(:name => 'sourceText').last.value = text
131
132     @form.submit(@form.buttons_with(:name => 'translate').last).
133           forms_with(:name => 'translateForm').last.fields_with(:name => 'translatedText').last.value
134   end
135 end
136
137
138 class ExciteTranslator < Translator
139   INFO = 'Excite.jp Translation <http://www.excite.co.jp/world/>'
140
141   def initialize(cache={})
142     require 'mechanize'
143     require 'iconv'
144
145     super(Translator::Direction.all_from_to(%w[ja en zh_CN zh_TW ko], %w[ja]), cache)
146
147     @forms = Hash.new do |h, k|
148       case k
149       when 'en'
150         h[k] = open_form('english')
151       when 'zh_CN', 'zh_TW'
152         # this way we don't need to fetch the same page twice
153         h['zh_CN'] = h['zh_TW'] = open_form('chinese')
154       when 'ko'
155         h[k] = open_form('korean')
156       end
157     end
158   end
159
160   def open_form(name)
161     mechanize.new.get("http://www.excite.co.jp/world/#{name}").
162                    forms_with(:name => 'world').first
163   end
164
165   def do_translate(text, from, to)
166     non_ja_language = from != 'ja' ? from : to
167     form = @forms[non_ja_language]
168
169     if non_ja_language =~ /zh_(CN|TW)/
170       form_with_fields(:name => 'wb_lp').first.value = "#{from}#{to}".sub(/_(?:CN|TW)/, '').upcase
171       form_with_fields(:name => 'big5').first.value = ($1 == 'TW' ? 'yes' : 'no')
172     else
173       # the en<->ja page is in Shift_JIS while other pages are UTF-8
174       text = Iconv.iconv('Shift_JIS', 'UTF-8', text) if non_ja_language == 'en'
175       form.fields_with(:name => 'wb_lp').first.value = "#{from}#{to}".upcase
176     end
177     form.fields_with(:name => 'before').first.value = text
178     result = form.submit.forms_with(:name => 'world').first.fields_with(:name => 'after').first.value
179     # the en<->ja page is in Shift_JIS while other pages are UTF-8
180     if non_ja_language == 'en'
181       Iconv.iconv('UTF-8', 'Shift_JIS', result)
182     else
183       result
184     end
185
186   end
187 end
188
189
190 class GoogleTranslator < Translator
191   INFO = 'Google Translate <http://www.google.com/translate_t>'
192
193   LANGUAGES =
194     %w[af sq am ar hy az eu be bn bh bg my ca chr zh zh_CN zh_TW hr
195     cs da dv en eo et tl fi fr gl ka de el gn gu iw hi hu is id iu
196     ga it ja kn kk km ko lv lt mk ms ml mt mr mn ne no or ps fa pl
197     pt_PT pa ro ru sa sr sd si sk sl es sw sv tg ta tl te th bo tr
198     uk ur uz ug vi cy yi auto]
199   def initialize(cache={})
200     require "uri"
201     require "json"
202     super(Translator::Direction.all_to_all(LANGUAGES), cache)
203   end
204
205   def do_translate(text, from, to)
206     langpair = [from == 'auto' ? '' : from, to].map { |e| e.tr('_', '-') }.join("|")
207     raw_json = Irc::Utils.bot.httputil.get_response(URI.escape(
208                "http://ajax.googleapis.com/ajax/services/language/translate?v=1.0&q=#{text}&langpair=#{langpair}")).body
209     response = JSON.parse(raw_json)
210
211     if response["responseStatus"] != 200
212       raise Translator::NoTranslationError, response["responseDetails"]
213     else
214       translation = response["responseData"]["translatedText"]
215       return Utils.decode_html_entities(translation)
216     end
217   end
218 end
219
220
221 class BabelfishTranslator < Translator
222   INFO = 'AltaVista Babel Fish Translation <http://babelfish.altavista.com/babelfish/>'
223
224   def initialize(cache)
225     require 'mechanize'
226     (_, lang_list) = parse_page
227     language_pairs = lang_list.options.map {|o| o.value.split('_')}.
228                                            reject {|p| p.empty?}
229     super(Translator::Direction.pairs(language_pairs), cache)
230   end
231
232   def parse_page
233     form = mechanize.new.get('http://babelfish.altavista.com/babelfish/').
234            forms_with(:name => 'frmTrText').first
235     lang_list = form.fields_with(:name => 'lp').first
236     [form, lang_list]
237   end
238
239   def do_translate(text, from, to)
240     unless @form && @lang_list
241       @form, @lang_list = parse_page
242     end
243     
244     if @form.fields_with(:name => 'trtext').empty?
245       @form.add_field!('trtext', text)
246     else
247       @form.fields_with(:name => 'trtext').first.value = text
248     end
249     @lang_list.value = "#{from}_#{to}"
250     @form.submit.parser.search("div[@id='result']/div[@style]").inner_html
251   end
252 end
253
254 class WorldlingoTranslator < Translator
255   INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
256
257   LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
258   def initialize(cache)
259     require 'uri'
260     super(Translator::Direction.all_to_all(LANGUAGES), cache)
261   end
262
263   def translate(text, from, to)
264     response = Irc::Utils.bot.httputil.get_response(URI.escape(
265                "http://www.worldlingo.com/SEfpX0LV2xIxsIIELJ,2E5nOlz5RArCY,/texttranslate?wl_srcenc=utf-8&wl_trgenc=utf-8&wl_text=#{text}&wl_srclang=#{from.upcase}&wl_trglang=#{to.upcase}"))
266     # WorldLingo seems to respond an XML when error occurs
267     case response['Content-Type']
268     when %r'text/plain'
269       response.body
270     else
271       raise Translator::NoTranslationError
272     end
273   end
274 end
275
276 class TranslatorPlugin < Plugin
277   Config.register Config::IntegerValue.new('translator.timeout',
278     :default => 30, :validate => Proc.new{|v| v > 0},
279     :desc => _("Number of seconds to wait for the translation service before timeout"))
280   Config.register Config::StringValue.new('translator.destination',
281     :default => "en",
282     :desc => _("Default destination language to be used with translate command"))
283
284   TRANSLATORS = {
285     'nifty' => NiftyTranslator,
286     'excite' => ExciteTranslator,
287     'google_translate' => GoogleTranslator,
288     'babelfish' => BabelfishTranslator,
289     'worldlingo' => WorldlingoTranslator,
290   }
291
292   def initialize
293     super
294     @failed_translators = []
295     @translators = {}
296     TRANSLATORS.each_pair do |name, c|
297       watch_for_fail(name) do
298         @translators[name] = c.new(@registry.sub_registry(name))
299         map "#{name} :from :to *phrase",
300           :action => :cmd_translate, :thread => true
301       end
302     end
303
304     Config.register Config::ArrayValue.new('translator.default_list',
305       :default => TRANSLATORS.keys,
306       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
307       :desc => _("List of translators to try in order when translator name not specified"),
308       :on_change => Proc.new {|bot, v| update_default})
309     update_default
310   end
311
312   def watch_for_fail(name, &block)
313     begin
314       yield
315     rescue Exception
316       @failed_translators << { :name => name, :reason => $!.to_s }
317
318       warning _("Translator %{name} cannot be used: %{reason}") %
319              {:name => name, :reason => $!}
320       map "#{name} [*args]", :action => :failed_translator,
321                              :defaults => {:name => name, :reason => $!}
322     end
323   end
324
325   def failed_translator(m, params)
326     m.reply _("Translator %{name} cannot be used: %{reason}") %
327             {:name => params[:name], :reason => params[:reason]}
328   end
329
330   def help(plugin, topic=nil)
331     case (topic.intern rescue nil)
332     when :failed
333       unless @failed_translators.empty?
334         failed_list = @failed_translators.map { |t| _("%{bold}%{translator}%{bold}: %{reason}") % {
335           :translator => t[:name],
336           :reason => t[:reason],
337           :bold => Bold
338         }}
339
340         _("Failed translators: %{list}") % { :list => failed_list.join(", ") }
341       else
342         _("None of the translators failed")
343       end
344     else
345       if @translators.has_key?(plugin)
346         translator = @translators[plugin]
347         _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
348           :translator => plugin,
349           :info => translator.class::INFO,
350           :directions => translator.directions.map do |source, targets|
351                            _('%{source} -> %{targets}') %
352                            {:source => source, :targets => targets.to_a.join(', ')}
353                          end.join(' | ')
354         }
355       else
356         help_str = _('Command: <translator> <from> <to> <phrase>, where <translator> is one of: %{translators}. If "translator" is used in place of the translator name, the first translator in translator.default_list which supports the specified direction will be picked automatically. Use "help <translator>" to look up supported from and to languages') %
357                      {:translators => @translators.keys.join(', ')}
358
359         help_str << "\n" + _("%{bold}Note%{bold}: %{failed_amt} translators failed, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
360           :failed_amt => @failed_translators.size,
361           :bold => Bold,
362           :reverse => Reverse,
363           :prefix => @bot.config['core.address_prefix'].first
364         }
365
366         help_str
367       end
368     end
369   end
370
371   def languages
372     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
373   end
374
375   def update_default
376     @default_translators = bot.config['translator.default_list'] & @translators.keys
377   end
378
379   def cmd_translator(m, params)
380     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
381     params[:from] ||= 'auto'
382     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
383
384     if translator
385       cmd_translate m, params.merge({:translator => translator, :show_provider => false})
386     else
387       # When translate command is used without source language, "auto" as source
388       # language is assumed. It means that google translator is used and we let google
389       # figure out what the source language is.
390       #
391       # Problem is that the google translator will fail if the system that the bot is
392       # running on does not have the json gem installed.
393       if params[:from] == 'auto'
394         m.reply _("Unable to auto-detect source language due to broken google translator, see %{reverse}%{prefix}help translate failed%{reverse} for details") % {
395           :reverse => Reverse,
396           :prefix => @bot.config['core.address_prefix'].first
397         }
398       else
399         m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
400       end
401     end
402   end
403
404   def cmd_translate(m, params)
405     # get the first word of the command
406     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
407     translator = @translators[tname]
408     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
409     if translator
410       watch_for_fail(tname) do
411         begin
412           translation = Timeout.timeout(@bot.config['translator.timeout']) do
413             translator.translate(phrase, from, to)
414           end
415           m.reply(if params[:show_provider]
416                     _('%{translation} (provided by %{translator})') %
417                       {:translation => translation, :translator => tname.gsub("_", " ")}
418                   else
419                     translation
420                   end)
421
422         rescue Translator::UnsupportedDirectionError
423           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
424                   {:translator => tname, :source => from, :target => to}
425         rescue Translator::NoTranslationError
426           m.reply _('%{translator} failed to provide a translation') %
427                   {:translator => tname}
428         rescue Timeout::Error
429           m.reply _('The translator timed out')
430         end
431       end
432     else
433       m.reply _('No translator called %{name}') % {:name => tname}
434     end
435   end
436
437   # URL translation has nothing to do with Translators so let's make it
438   # separate, and Google exclusive for now
439   def cmd_translate_url(m, params)
440     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
441     params[:from] ||= 'auto'
442
443     translate_url = "http://translate.google.com/translate?sl=%{from}&tl=%{to}&u=%{url}" % {
444       :from => params[:from],
445       :to   => params[:to],
446       :url  => CGI.escape(params[:url].to_s)
447     }
448
449     m.reply(translate_url)
450   end
451 end
452
453 plugin = TranslatorPlugin.new
454 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
455
456 plugin.map 'translate [:from] [:to] :url',
457            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
458 plugin.map 'translator [:from] [:to] :url',
459            :action => :cmd_translate_url, :requirements => req.merge(:url => %r{^https?://[^\s]*})
460 plugin.map 'translate [:from] [:to] *phrase',
461            :action => :cmd_translator, :thread => true, :requirements => req
462 plugin.map 'translator [:from] [:to] *phrase',
463            :action => :cmd_translator, :thread => true, :requirements => req