+ added a translator plugin
[rbot] / 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 require 'set'
13 require 'timeout'
14
15 # base class for implementing a translation service
16 # = Attributes
17 # direction:: supported translation directions, a hash where each key is a source
18 #             language name, and each value is Set of target language names. The
19 #             methods in the Direction module are convenient for initializing this
20 #             attribute
21 class Translator
22
23   class UnsupportedDirectionError < ArgumentError
24   end
25
26   attr_reader :directions, :cache
27
28   def initialize(directions, cache={})
29     @directions = directions
30     @cache = cache
31   end
32
33  
34   # whether the translator supports this direction
35   def support?(from, to)
36     from != to && @directions[from].include?(to)
37   end
38
39   # this implements checking of languages and caching. subclasses should define the
40   # do_translate method to implement actual translation
41   def translate(text, from, to)
42     raise UnsupportedDirectionError unless support?(from, to)
43     @cache[[text, from, to]] ||= do_translate(text, from, to)
44   end
45
46   module Direction
47     # given the set of supported languages, return a hash suitable for the directions
48     # attribute which includes any language to any other language
49     def self.all_to_all(languages)
50       directions = all_to_all(languages)
51       languages.each {|l| directions[l] = languages.to_set}
52       directions
53     end
54
55     # a hash suitable for the directions attribute which includes any language from/to
56     # the given set of languages (center_languages)
57     def self.all_from_to(languages, center_languages)
58       directions = all_to_none(languages)
59       center_languages.each {|l| directions[l] = languages - [l]}
60       (languages - center_languages).each {|l| directions[l] = center_languages.to_set}
61       directions
62     end
63
64     # get a hash from a list of pairs
65     def self.pairs(list_of_pairs)
66       languages = list_of_pairs.flatten.to_set
67       directions = all_to_none(languages)
68       list_of_pairs.each do |(from, to)|
69         directions[from] << to
70       end
71       directions
72     end
73
74     # an empty hash with empty sets as default values
75     def self.all_to_none(languages)
76       Hash.new do |h, k|
77         # always return empty set when the key is non-existent, but put empty set in the
78         # hash only if the key is one of the languages
79         if languages.include? k
80           h[k] = Set.new
81         else
82           Set.new
83         end
84       end
85     end
86   end
87 end
88
89
90 class NiftyTranslator < Translator
91   def initialize(cache={})
92    require 'mechanize'
93    super(Translator::Direction.all_from_to(%w[ja en zh_CN ko], %w[ja]), cache)
94     @form = WWW::Mechanize.new.
95             get('http://nifty.amikai.com/amitext/indexUTF8.jsp').
96             forms.name('translateForm').first
97   end
98
99   def do_translate(text, from, to)
100     @form.radiobuttons.name('langpair').value = "#{from},#{to}".upcase
101     @form.fields.name('sourceText').value = text
102
103     @form.submit(@form.buttons.name('translate')).
104           forms.name('translateForm').fields.name('translatedText').value
105   end
106 end
107
108
109 class ExciteTranslator < Translator
110
111   def initialize(cache={})
112     require 'mechanize'
113     require 'iconv'
114
115     super(Translator::Direction.all_from_to(%w[ja en zh_CN zh_TW ko], %w[ja]), cache)
116
117     @forms = Hash.new do |h, k|
118       case k
119       when 'en'
120         h[k] = open_form('english')
121       when 'zh_CN', 'zh_TW'
122         # this way we don't need to fetch the same page twice
123         h['zh_CN'] = h['zh_TW'] = open_form('chinese')
124       when 'ko'
125         h[k] = open_form('korean')
126       end
127     end
128   end
129
130   def open_form(name)
131     WWW::Mechanize.new.get("http://www.excite.co.jp/world/#{name}").
132                    forms.name('world').first
133   end
134
135   def do_translate(text, from, to)
136     non_ja_language = from != 'ja' ? from : to
137     form = @forms[non_ja_language]
138
139     if non_ja_language =~ /zh_(CN|TW)/
140       form.fields.name('wb_lp').value = "#{from}#{to}".sub(/_(?:CN|TW)/, '').upcase
141       form.fields.name('big5').value = ($1 == 'TW' ? 'yes' : 'no')
142     else
143       # the en<->ja page is in Shift_JIS while other pages are UTF-8
144       text = Iconv.iconv('Shift_JIS', 'UTF-8', text) if non_ja_language == 'en'
145       form.fields.name('wb_lp').value = "#{from}#{to}".upcase
146     end
147     form.fields.name('before').value = text
148     result = form.submit.forms.name('world').fields.name('after').value
149     # the en<->ja page is in Shift_JIS while other pages are UTF-8
150     if non_ja_language == 'en'
151       Iconv.iconv('UTF-8', 'Shift_JIS', result)
152     else
153       result
154     end
155
156   end
157 end
158
159
160 class GoogleTranslator < Translator
161   def initialize(cache={})
162     require 'mechanize'
163     load_form!
164     language_pairs = @lang_list.options.map do |o|
165       # these options have values like "en|zh-CN"; map to things like ['en', 'zh_CN'].
166       o.value.split('|').map {|l| l.sub('-', '_')}
167     end
168     super(Translator::Direction.pairs(language_pairs), cache)
169   end
170
171   def load_form!
172     agent = WWW::Mechanize.new
173     # without faking the user agent, Google Translate will serve non-UTF-8 text
174     agent.user_agent_alias = 'Linux Konqueror'
175     @form = agent.get('http://www.google.com/translate_t').
176             forms.action('/translate_t').first
177     @lang_list = @form.fields.name('langpair')
178   end
179
180   def do_translate(text, from, to)
181     load_form!
182
183     @lang_list.value = "#{from}|#{to}".sub('_', '-')
184     @form.fields.name('text').value = text
185     @form.submit.parser.search('div#result_box').inner_html
186   end
187 end
188
189
190 class BabelfishTranslator < Translator
191   def initialize(cache)
192     require 'mechanize'
193     
194     @form = WWW::Mechanize.new.get('http://babelfish.altavista.com/babelfish/').
195             forms.name('frmTrText').first
196     @lang_list = @form.fields.name('lp')
197     language_pairs = @lang_list.options.map {|o| o.value.split('_')}.
198                                             reject {|p| p.empty?}
199     super(Translator::Direction.pairs(language_pairs), cache)
200   end
201
202   def translate(text, from, to)
203     super
204     if @form.fields.name('trtext').empty?
205       @form.add_field!('trtext', text)
206     else
207       @form.fields.name('trtext').value = text
208     end
209     @lang_list.value = "#{from}_#{to}"
210     @form.submit.parser.search("td.s/div[@style]").inner_html
211   end
212 end
213
214 class TranslatorPlugin < Plugin
215   BotConfig.register BotConfigIntegerValue.new('translate.timeout',
216     :default => 30, :validate => Proc.new{|v| v > 0},
217     :desc => _("Number of seconds to wait for the translation service before timeout"))
218
219   def initialize
220     super
221     translator_classes = {
222       'nifty' => NiftyTranslator,
223       'excite' => ExciteTranslator,
224       'google_translate' => GoogleTranslator,
225       'babelfish' => BabelfishTranslator
226     }
227
228     @translators = {}
229
230     translator_classes.each_pair do |name, c|
231       begin
232         @translators[name] = c.new(@registry.sub_registry(name))
233         map "#{name} :from :to *phrase", :action => :cmd_translate
234       rescue
235         warning _("Translator %{name} cannot be used: %{reason}") %
236                {:name => name, :reason => $!}
237       end
238     end
239   end
240
241   def help(plugin, topic=nil)
242     if @translators.has_key?(topic)
243       _('Supported directions of translation for %{translator}: %{directions}') % {
244         :translator => topic,
245         :directions => @translators[topic].directions.map do |source, targets|
246                          _('%{source} -> %{targets}') %
247                          {:source => source, :targets => targets.to_a.join(', ')}
248                        end.join(' | ')
249       }
250     else
251       _('Command: <translator> <from> <to> <phrase>, where <translator> is one of: %{translators}. Use help <translator> to look up supported from and to languages') %
252         {:translators => @translators.keys.join(', ')}
253     end
254   end
255
256   def cmd_translate(m, params)
257     # get the first word of the command
258     translator = @translators[m.message[/\A(\w+)\s/, 1]]
259     from, to, phrase = params[:from], params[:to], params[:phrase].to_s
260     if translator
261       begin
262         if translator.support?(from, to)
263           translation = Timeout.timeout(@bot.config['translate.timeout']) do
264             translator.translate(phrase, from, to)
265           end
266           if translation.empty?
267             m.reply _('No translation returned')
268           else
269             m.reply translation
270           end
271         else
272           m.reply _("%{translator} doesn't support translating from %{source} to %{target}") %
273                   {:source => from, :target => to}
274         end
275       rescue Timeout::Error
276         m.reply _('The translator timed out')
277       end
278     else
279       m.reply _('No translator called %{name}') % {:name => translator}
280     end
281   end
282 end
283
284 plugin = TranslatorPlugin.new
285