]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/translator.rb
8617c321e8196a2f86bebb69d3eba93893f62ede
[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       response["responseData"]["translatedText"]
207     end
208   end
209 end
210
211
212 class BabelfishTranslator < Translator
213   INFO = 'AltaVista Babel Fish Translation <http://babelfish.altavista.com/babelfish/>'
214
215   def initialize(cache)
216     require 'mechanize'
217
218     @form = WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
219             forms_with(:name => 'frmTrText').first
220     @lang_list = @form.fields_with(:name => 'lp').first
221     language_pairs = @lang_list.options.map {|o| o.value.split('_')}.
222                                             reject {|p| p.empty?}
223     super(Translator::Direction.pairs(language_pairs), cache)
224   end
225
226   def do_translate(text, from, to)
227     if @form.fields_with(:name => 'trtext').empty?
228       @form.add_field!('trtext', text)
229     else
230       @form.fields_with(:name => 'trtext').first.value = text
231     end
232     @lang_list.value = "#{from}_#{to}"
233     @form.submit.parser.search("div[@id='result']/div[@style]").inner_html
234   end
235 end
236
237 class WorldlingoTranslator < Translator
238   INFO = 'WorldLingo Free Online Translator <http://www.worldlingo.com/en/products_services/worldlingo_translator.html>'
239
240   LANGUAGES = %w[en fr de it pt es ru nl el sv ar ja ko zh_CN zh_TW]
241   def initialize(cache)
242     require 'uri'
243     super(Translator::Direction.all_to_all(LANGUAGES), cache)
244   end
245
246   def translate(text, from, to)
247     response = Irc::Utils.bot.httputil.get_response(URI.escape(
248                "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}"))
249     # WorldLingo seems to respond an XML when error occurs
250     case response['Content-Type']
251     when %r'text/plain'
252       response.body
253     else
254       raise Translator::NoTranslationError
255     end
256   end
257 end
258
259 class TranslatorPlugin < Plugin
260   Config.register Config::IntegerValue.new('translator.timeout',
261     :default => 30, :validate => Proc.new{|v| v > 0},
262     :desc => _("Number of seconds to wait for the translation service before timeout"))
263   Config.register Config::StringValue.new('translator.destination',
264     :default => "en",
265     :desc => _("Default destination language to be used with translate command"))
266
267   TRANSLATORS = {
268     'nifty' => NiftyTranslator,
269     'excite' => ExciteTranslator,
270     'google_translate' => GoogleTranslator,
271     'babelfish' => BabelfishTranslator,
272     'worldlingo' => WorldlingoTranslator,
273   }
274
275   def initialize
276     super
277
278     @translators = {}
279     TRANSLATORS.each_pair do |name, c|
280       begin
281         @translators[name] = c.new(@registry.sub_registry(name))
282         map "#{name} :from :to *phrase",
283           :action => :cmd_translate, :thread => true
284       rescue Exception
285         warning _("Translator %{name} cannot be used: %{reason}") %
286                {:name => name, :reason => $!}
287         map "#{name} [*args]", :action => :failed_translator,
288                                :defaults => {:name => name, :reason => $!}
289       end
290     end
291
292     Config.register Config::ArrayValue.new('translator.default_list',
293       :default => TRANSLATORS.keys,
294       :validate => Proc.new {|l| l.all? {|t| TRANSLATORS.has_key?(t)}},
295       :desc => _("List of translators to try in order when translator name not specified"),
296       :on_change => Proc.new {|bot, v| update_default})
297     update_default
298   end
299
300   def failed_translator(m, params)
301     m.reply _("Translator %{name} cannot be used: %{reason}") %
302             {:name => params[:name], :reason => params[:reason]}
303   end
304
305   def help(plugin, topic=nil)
306     if @translators.has_key?(plugin)
307       translator = @translators[plugin]
308       _('%{translator} <from> <to> <phrase> => Look up phrase using %{info}, supported from -> to languages: %{directions}') % {
309         :translator => plugin,
310         :info => translator.class::INFO,
311         :directions => translator.directions.map do |source, targets|
312                          _('%{source} -> %{targets}') %
313                          {:source => source, :targets => targets.to_a.join(', ')}
314                        end.join(' | ')
315       }
316     else
317       _('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') %
318         {:translators => @translators.keys.join(', ')}
319     end
320   end
321
322   def languages
323     @languages ||= @translators.map { |t| t.last.directions.keys }.flatten.uniq
324   end
325
326   def update_default
327     @default_translators = bot.config['translator.default_list'] & @translators.keys
328   end
329
330   def cmd_translator(m, params)
331     params[:to] = @bot.config['translator.destination'] if params[:to].nil?
332     params[:from] ||= 'auto'
333     translator = @default_translators.find {|t| @translators[t].support?(params[:from], params[:to])}
334
335     if translator
336       cmd_translate m, params.merge({:translator => translator, :show_provider => true})
337     else
338       m.reply _('None of the default translators (translator.default_list) supports translating from %{source} to %{target}') % {:source => params[:from], :target => params[:to]}
339     end
340   end
341
342   def cmd_translate(m, params)
343     # get the first word of the command
344     tname = params[:translator] || m.message[/\A(\w+)\s/, 1]
345     translator = @translators[tname]
346     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
347     if translator
348       begin
349         translation = Timeout.timeout(@bot.config['translator.timeout']) do
350           translator.translate(phrase, from, to)
351         end
352         m.reply(if params[:show_provider]
353                   _('%{translation} (provided by %{translator})') %
354                     {:translation => translation, :translator => tname.gsub("_", " ")}
355                 else
356                   translation
357                 end)
358
359       rescue Translator::UnsupportedDirectionError
360         m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
361                 {:translator => tname, :source => from, :target => to}
362       rescue Translator::NoTranslationError
363         m.reply _('%{translator} failed to provide a translation') %
364                 {:translator => tname}
365       rescue Timeout::Error
366         m.reply _('The translator timed out')
367       end
368     else
369       m.reply _('No translator called %{name}') % {:name => tname}
370     end
371   end
372 end
373
374 plugin = TranslatorPlugin.new
375 req = Hash[*%w(from to).map { |e| [e.to_sym, /#{plugin.languages.join("|")}/] }.flatten]
376
377 plugin.map 'translate [:from] [:to] *phrase',
378            :action => :cmd_translator, :thread => true, :requirements => req
379 plugin.map 'translator [:from] [:to] *phrase',
380            :action => :cmd_translator, :thread => true, :requirements => req