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