rubot-base 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/lib/rubot/base.rb +1182 -0
- data/lib/rubot/base/utils.rb +141 -0
- data/lib/rubot/mime.rb +453 -0
- data/test/rubot/base.rb +179 -0
- data/test/rubot_config.rb +15 -0
- data/test/tools.rb +12 -0
- metadata +73 -0
data/lib/rubot/base.rb
ADDED
@@ -0,0 +1,1182 @@
|
|
1
|
+
# :main:rubot/base.rb
|
2
|
+
# :title:Rubot Documentation
|
3
|
+
#
|
4
|
+
# = Rubot Base Library for Ruby
|
5
|
+
# Author:: Konstantin Haase
|
6
|
+
# Requires:: Ruby, Hpricot (>= 0.5), eruby, HTML Entities for Ruby (>= 4.0), Rubygems (optional)
|
7
|
+
# License:: MIT-License
|
8
|
+
#
|
9
|
+
# This is a library for creating bots for MediaWiki projects (i.e. Wikipedia).
|
10
|
+
# It can be either used directly or through an adapter library (for handling monitoring, caching and so on).
|
11
|
+
#
|
12
|
+
# == Status Quo
|
13
|
+
# This libary is working quite smooth now but it's not as feature-rich as I want it to be.
|
14
|
+
#
|
15
|
+
# Heavy improvements to be expected, stay in touch. Some more documentation will follow.
|
16
|
+
#
|
17
|
+
# == Ruby 1.9
|
18
|
+
# Rubot now works with Ruby 1.9. However, hpricot for 1.9 seems to be broken somehow (at least the
|
19
|
+
# version from the Ubuntu repo). I'll keep an eye on that one.
|
20
|
+
#
|
21
|
+
# == Installation
|
22
|
+
# At the moment, there is no official release, so you have to use the development version.
|
23
|
+
# You can download it using bazaar (http://bazaar-vcs.org)
|
24
|
+
#
|
25
|
+
# bzr branch http://freifunk-halle.de/~konstantin/rubot
|
26
|
+
#
|
27
|
+
# <b>First offical version will be released, when uploads are fixed. Enjoy!</b>
|
28
|
+
#
|
29
|
+
# == Known Bugs
|
30
|
+
# * Uploads don't work, yet
|
31
|
+
#
|
32
|
+
# == TODO
|
33
|
+
# * Download files
|
34
|
+
#
|
35
|
+
# == License
|
36
|
+
# Copyright (c) 2008 Konstantin Haase
|
37
|
+
#
|
38
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
39
|
+
# of this software and associated documentation files (the "Software"), to
|
40
|
+
# deal in the Software without restriction, including without limitation the
|
41
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
42
|
+
# sell copies of the Software, and to permit persons to whom the Software is
|
43
|
+
# furnished to do so, subject to the following conditions:
|
44
|
+
#
|
45
|
+
# The above copyright notice and this permission notice shall be included in
|
46
|
+
# all copies or substantial portions of the Software.
|
47
|
+
#
|
48
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
49
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
50
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
51
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
52
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
53
|
+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
54
|
+
# IN THE SOFTWARE.
|
55
|
+
|
56
|
+
# Tries to execute the given block and returns true if no exception is raised,
|
57
|
+
# returns false otherwise. Takes as optional argument the exception to rescue.
|
58
|
+
def try(exception = Exception)
|
59
|
+
begin
|
60
|
+
yield
|
61
|
+
true
|
62
|
+
rescue exception
|
63
|
+
false
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# :stopdoc:
|
68
|
+
try(LoadError) { require 'rubygems' }
|
69
|
+
# :startdoc:
|
70
|
+
|
71
|
+
require 'rubot/mime.rb'
|
72
|
+
require 'net/http'
|
73
|
+
require 'net/https'
|
74
|
+
require 'uri'
|
75
|
+
require 'hpricot'
|
76
|
+
require 'erb'
|
77
|
+
require 'logger'
|
78
|
+
require 'htmlentities'
|
79
|
+
require 'yaml'
|
80
|
+
|
81
|
+
module Rubot
|
82
|
+
|
83
|
+
# Base for all Rubot errors
|
84
|
+
class Error < StandardError; end
|
85
|
+
|
86
|
+
# Mixin for all post relatedt errors
|
87
|
+
module PostError; end
|
88
|
+
|
89
|
+
# Raised if not logged in but has to do some register user only stuff.
|
90
|
+
# (Mainly changing interface language / time format)
|
91
|
+
class NotLoggedInError < Error; end
|
92
|
+
|
93
|
+
# Error raised if login fails
|
94
|
+
class LoginError < Error; include PostError; end
|
95
|
+
|
96
|
+
# Error raised, when some page related errors occur.
|
97
|
+
class PageError < Error; end
|
98
|
+
|
99
|
+
# Raised if writing to a page failed.
|
100
|
+
class WritePageError < PageError; include PostError; end
|
101
|
+
|
102
|
+
# Raised if moving a page failed.
|
103
|
+
class MovePageError < PageError; include PostError; end
|
104
|
+
|
105
|
+
# Error directly from MediaWiki API
|
106
|
+
class ApiError < Error; end
|
107
|
+
|
108
|
+
# Server access
|
109
|
+
#
|
110
|
+
# Examples:
|
111
|
+
# Rubot[:en] # same as Rubot[:en, :default] unless Rubot.default_family was changed
|
112
|
+
# Rubot[:de, :wikipedia]
|
113
|
+
def Rubot.[](iw, family = Rubot.default_family)
|
114
|
+
Server.server[family.to_sym][iw.to_sym] rescue nil
|
115
|
+
end
|
116
|
+
|
117
|
+
# Default family for wikis. See Rubot.default_family=
|
118
|
+
def Rubot.default_family
|
119
|
+
@@default_family ||= :default
|
120
|
+
@@default_family
|
121
|
+
end
|
122
|
+
|
123
|
+
# Sets default family
|
124
|
+
# Comes in very handy when handling a lot of wikis.
|
125
|
+
# Example:
|
126
|
+
#
|
127
|
+
# Rubot :en, :host => 'localhost'
|
128
|
+
# Rubot :en, :host => 'en.somewiki.tld', :wiki_family => :somewiki
|
129
|
+
# Rubot[:en] # should be local wiki
|
130
|
+
# Rubot.new(:de, :host => 'localhost').family # should be :default
|
131
|
+
# Rubot.default_family = :somewiki
|
132
|
+
# Rubot[:en] # should be wiki at somewiki.tld
|
133
|
+
# Rubot.new(:de, :host => 'de.somewiki.tld').family # should be :somewiki
|
134
|
+
# Rubot.default_family = :default
|
135
|
+
# Rubot[:de] # should be local wiki
|
136
|
+
#
|
137
|
+
# If you want to connect to multiple wikis Rubot.mass_connect should be very useful, too.
|
138
|
+
def Rubot.default_family=(family)
|
139
|
+
@@default_family = family
|
140
|
+
end
|
141
|
+
|
142
|
+
# Loops through server.
|
143
|
+
def Rubot.each_server
|
144
|
+
Server.server.each { |f,s| s.each { |iw, server| yield server } }
|
145
|
+
end
|
146
|
+
|
147
|
+
# Hash-like each_key
|
148
|
+
def Rubot.each_key
|
149
|
+
Server.server.each { |family,server| server.each_key { |interwiki| yield interwiki, family } }
|
150
|
+
end
|
151
|
+
|
152
|
+
# Loops through wiki families.
|
153
|
+
def Rubot.each_family
|
154
|
+
Server.server.each { |family, server| yield family, server }
|
155
|
+
end
|
156
|
+
|
157
|
+
# Array containing all servers.
|
158
|
+
def Rubot.server
|
159
|
+
(Server.server.collect { |k,v| v.values }).flatten
|
160
|
+
end
|
161
|
+
|
162
|
+
# Same as Rubot::Server.new
|
163
|
+
def Rubot.new(interwiki, options = {})
|
164
|
+
Server.new(interwiki, options)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Used for "magic" values like in Rubot.mass_connect
|
168
|
+
#
|
169
|
+
# Examples:
|
170
|
+
#
|
171
|
+
# # 'a string'
|
172
|
+
# Rubot.value 'a string', :en
|
173
|
+
# Rubot.value { :en => 'a string', :fr => 'another string' }, :en
|
174
|
+
# Rubot.value lambda { |v| 'a string' if v == :en }, :en
|
175
|
+
def Rubot.value(var, *keys)
|
176
|
+
case var
|
177
|
+
when Proc
|
178
|
+
var.call *keys
|
179
|
+
when Hash
|
180
|
+
var[*keys]
|
181
|
+
when Array
|
182
|
+
keys.length == 1 and keys[0].is_a?(Integer) ? var[] : var
|
183
|
+
else
|
184
|
+
var
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# The server class. It handels all the communication with the website.
|
189
|
+
# See Server.new
|
190
|
+
class Server
|
191
|
+
|
192
|
+
@@server ||= {}
|
193
|
+
@@default_adapter ||= nil
|
194
|
+
@@coder ||= HTMLEntities.new
|
195
|
+
@@english_namespaces ||= [ :main, :talk,
|
196
|
+
:user, :user_talk,
|
197
|
+
:project, :project_talk,
|
198
|
+
:image, :image_talk,
|
199
|
+
:mediawiki, :mediawiki_talk,
|
200
|
+
:template, :template_talk,
|
201
|
+
:help, :help_talk,
|
202
|
+
:category, :category_talk ]
|
203
|
+
|
204
|
+
# Returns hash of wikis
|
205
|
+
def self.server
|
206
|
+
@@server
|
207
|
+
end
|
208
|
+
|
209
|
+
attr_accessor :cookies, :preferences, :adapter; :use_api
|
210
|
+
attr_reader :interwiki, :log, :family
|
211
|
+
|
212
|
+
|
213
|
+
# Creats new Wiki object.
|
214
|
+
# Takes Interwiki name (for handling multiple Wikis) and an optional hash of options.
|
215
|
+
#
|
216
|
+
# Note: You can even use Rubot.new
|
217
|
+
#
|
218
|
+
# Examples:
|
219
|
+
#
|
220
|
+
# # MediaWiki on http://localhost/index.php
|
221
|
+
# wiki = Rubot::Server.new :en, :host => 'localhost'
|
222
|
+
#
|
223
|
+
# # Wikipedia - en and de
|
224
|
+
# wiki_en = Rubot::Server.new :en, :host => 'en.wikipedia.org',
|
225
|
+
# :path => '/w/index.php'
|
226
|
+
# wiki_de = Rubot::Server.new :de, :host => 'de.wikipedia.org',
|
227
|
+
# :path => '/w/index.php'
|
228
|
+
#
|
229
|
+
# # Wikipedia, smooth way for loading a couple of wikis
|
230
|
+
# # You can access those via Rubot[:en] etc.
|
231
|
+
# ['en', 'de', 'fr', 'se'].each do |iw|
|
232
|
+
# Rubot.new iw, :host => iw+'.wikipedia.org', :path => '/w/index.php'
|
233
|
+
# end
|
234
|
+
#
|
235
|
+
# # With login
|
236
|
+
# Rubot::Server.new :xy, :host => 'somehost',
|
237
|
+
# :username => 'WikiAdmin',
|
238
|
+
# :password => 'qwerty'
|
239
|
+
# Options:
|
240
|
+
# :host wiki host, has to be set!
|
241
|
+
# :port port (default is 80 or 443, depending on ssl)
|
242
|
+
# :ssl wheter to use ssl (default is false)
|
243
|
+
# :path path to index.php (default is '/index.php')
|
244
|
+
# :http_user set http user if wiki is protectet by basic authentication
|
245
|
+
# :http_pass set http password if wiki is protectet by basic authentication
|
246
|
+
# :proxy_host proxy host, will only be used if set (has to be http proxy!)
|
247
|
+
# :proxy_port proxy port, will only be used if set, :proxy_host has to be set
|
248
|
+
# in order to use this!
|
249
|
+
# :proxy_user proxy user, will only be used if set, :proxy_host has to be set
|
250
|
+
# in order to use this!
|
251
|
+
# :proxy_pass proxy password, will only be used if set, :proxy_host and
|
252
|
+
# :proxy_user has to be set in order to use this!
|
253
|
+
# :cookies Hash of preset cookies, I don't think you'll need that one!
|
254
|
+
# :username Name of your bot. Has to be an existing wiki user. If not set bot
|
255
|
+
# won't try to login and work anonymous. (It's recommended to have a
|
256
|
+
# dedicated account for the bot)
|
257
|
+
# :password Guess what! Only to be used when :username is set.
|
258
|
+
# :identify_as_bot Whether the Bot should identdfy himself as such or should pretent
|
259
|
+
# to be a web browser. (default is true)
|
260
|
+
# :auto_login Login when calling new (only if :username is set, default is true)
|
261
|
+
# :user_agent User agent to be sent to server (default depends on :identify_as_bot)
|
262
|
+
# :logger Logger to be used
|
263
|
+
# :delay Seconds to wait between to edits. When working on Wikimedia projects
|
264
|
+
# this must not be less than 12. It should be at least 1, else MediaWiki
|
265
|
+
# flood control or something will prevent edits. Default is 1.
|
266
|
+
# :wiki_family groups wikis in families (like pywiki does) for handling multiple
|
267
|
+
# "interwiki clouds", if not given, wiki will be added to Rubot.default_family
|
268
|
+
# :use_api Whether to use api.php instead of index.php. EXPERIMENTEL, but could increase
|
269
|
+
# both speed and stability. (default is false)
|
270
|
+
# :gzip Whether or not to use gzip compression for HTTP. (default is true)
|
271
|
+
def initialize(interwiki, options = {})
|
272
|
+
|
273
|
+
# Basic settings
|
274
|
+
@interwiki = interwiki.to_sym
|
275
|
+
@options = { :host => nil, :port => nil,
|
276
|
+
:ssl => false, :path => nil,
|
277
|
+
:http_user => nil, :http_pass => nil,
|
278
|
+
:proxy_host => nil, :proxy_port => nil,
|
279
|
+
:proxy_user => nil, :proxy_pass => nil,
|
280
|
+
:logger => nil, :cookies => {},
|
281
|
+
:username => nil, :password => nil,
|
282
|
+
:identify_as_bot => true, :auto_login => true,
|
283
|
+
:user_agent => nil, :delay => 1,
|
284
|
+
:wiki_family => Rubot.default_family, :dont_mutter => false,
|
285
|
+
:use_api => false, :gzip => true }.merge options.keys_to_sym
|
286
|
+
@preferences = { 'wpUserLanguage' => 'en', 'wpDate' => "ISO 8601",
|
287
|
+
'wpOpexternaleditor' => nil, 'wpOppreviewonfirs' => nil }
|
288
|
+
@cookies = @options[:cookies].dup
|
289
|
+
@logged_in = false
|
290
|
+
@checked = false
|
291
|
+
@family = @options[:wiki_family].to_sym
|
292
|
+
@adapter = @@default_adapter
|
293
|
+
|
294
|
+
# Default path depends on :use_api
|
295
|
+
@options[:path] ||= use_api? ? '/api.php' : '/index.php'
|
296
|
+
|
297
|
+
# Logging
|
298
|
+
if @adapter
|
299
|
+
@log = @adapter.log
|
300
|
+
else
|
301
|
+
@log = @options.delete :logger
|
302
|
+
unless @log
|
303
|
+
@log = Logger.new(STDOUT)
|
304
|
+
@log.level = Logger::INFO
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
# Default port
|
309
|
+
unless @options[:port]
|
310
|
+
@options[:port] = 80 unless @options[:ssl]
|
311
|
+
@options[:port] = 443 if @options[:ssl]
|
312
|
+
end
|
313
|
+
|
314
|
+
# HTTP connection (via proxy if :proxy_host given)
|
315
|
+
@log.debug iw.to_s+': Creating HTTP connection'
|
316
|
+
@http = Net::HTTP::Proxy(@options[:proxy_host], @options[:proxy_port], @options[:proxy_user],
|
317
|
+
@options[:proxy_pass]).new(@options[:host], @options[:port])
|
318
|
+
@http.basic_auth(@options[:http_user], @options[:http_pass]) if @options[:http_user]
|
319
|
+
|
320
|
+
# identify as bot? sets user agent
|
321
|
+
unless @options[:user_agent]
|
322
|
+
if @options[:identify_as_bot]
|
323
|
+
@options[:user_agent] = "Mozilla/5.0 (compatible; Rubot; #{RUBY_PLATFORM}; en)"
|
324
|
+
else
|
325
|
+
@options[:user_agent] = case rand(4)
|
326
|
+
# Firefox on Ubuntu
|
327
|
+
when 0
|
328
|
+
"Mozilla/5.0 (X11; U; Linux i686; en;"+
|
329
|
+
" rv:1.8.1.12)Gecko/20080207 Ubuntu/7"+
|
330
|
+
".10 (gutsy) Firefox/2.0.0.12"
|
331
|
+
# Safari on OSX
|
332
|
+
when 1
|
333
|
+
"Mozilla/5.0 (Macintosh; U; PPC Mac O"+
|
334
|
+
"S X; en-en) AppleWebKit/523.12.2 (KH"+
|
335
|
+
"TML, like Gecko) Version/3.0.4 Safar"+
|
336
|
+
"i/523.12.2"
|
337
|
+
# Opera on Windows
|
338
|
+
when 2
|
339
|
+
"Opera/9.10 (Windows NT 5.0; U; en)"
|
340
|
+
# Lynx
|
341
|
+
when 3
|
342
|
+
"Lynx/2.8.4rel.1 libwww-FM/2.14 SSL-M"+
|
343
|
+
"M/1.4.1 OpenSSL/0.9.6c"
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
@log.debug iw.to_s+': Initialized'
|
349
|
+
|
350
|
+
# login attemp, only if :username given (handled by login)
|
351
|
+
login if @options[:auto_login]
|
352
|
+
|
353
|
+
# For Rubot::Server.server
|
354
|
+
@@server[@family] ||= {}
|
355
|
+
@@server[@family][@interwiki] = self
|
356
|
+
|
357
|
+
end
|
358
|
+
|
359
|
+
# Whether or not api.php is used.
|
360
|
+
def use_api?
|
361
|
+
@options[:use_api]
|
362
|
+
end
|
363
|
+
|
364
|
+
# Send HTTP request.
|
365
|
+
# This is mainly for internal useage.
|
366
|
+
# Does all the cookie handling.
|
367
|
+
# Takes a hash with options.
|
368
|
+
#
|
369
|
+
# Options:
|
370
|
+
# :method :get, :post or :head (default is :get)
|
371
|
+
# :path Path to be requestet (default is set when calling new)
|
372
|
+
# :path_values Takes a hash of Data that will be added to the Path
|
373
|
+
# :data Data that will be transmitted
|
374
|
+
# :headers some additional header (might be overwritten, though)
|
375
|
+
# :follow_redirects whether to follow redirects (default is true)
|
376
|
+
# :follow_limit max. redirects to follow (default is 10)
|
377
|
+
# :body body to be sent (post only, if given :data will be ignored)
|
378
|
+
# :retries number of retries if request fails (NOT YET IMPLEMENTED)
|
379
|
+
# :gzip whether or not to use gzip compression for this request
|
380
|
+
|
381
|
+
def request(params = {})
|
382
|
+
|
383
|
+
# basic settings
|
384
|
+
params = { :method => :get, :path => @options[:path],
|
385
|
+
:path_values => {}, :data => {},
|
386
|
+
:headers => {}, :follow_redirects => true,
|
387
|
+
:follow_limit => 10, :body => nil,
|
388
|
+
:retries => 2, :gzip => @options[:gzip] }.merge(params.keys_to_sym!)
|
389
|
+
|
390
|
+
# catching if something went wrong with the path
|
391
|
+
params[:path] = @options[:path] unless params[:path]
|
392
|
+
|
393
|
+
# redirect handling
|
394
|
+
raise Error, 'HTTP redirect too deep' if params[:follow_limit] == 0
|
395
|
+
|
396
|
+
# headers, cookie handling
|
397
|
+
params[:headers].merge!({ 'Cookie' => (@cookies.collect do |key,value|
|
398
|
+
key.for_url+'='+value.to_s.for_url+';'
|
399
|
+
end).join(' '),
|
400
|
+
'User-Agent' => @options[:user_agent] })
|
401
|
+
params[:headers]['Accept-Encoding'] = "gzip,deflate" if params[:gzip]
|
402
|
+
if params[:method] == :post
|
403
|
+
params[:headers]['Content-Type'] ||= 'application/x-www-form-urlencoded'
|
404
|
+
else
|
405
|
+
params[:path_values].merge! params[:data]
|
406
|
+
params[:data] = {}
|
407
|
+
end
|
408
|
+
|
409
|
+
# adding parameters to path
|
410
|
+
params[:path] += (case
|
411
|
+
when (params[:path_values].empty? or params[:path] =~ /(\?|&)$/)
|
412
|
+
''
|
413
|
+
when params[:path] =~ /\?.+$/
|
414
|
+
'&'
|
415
|
+
else
|
416
|
+
'?'
|
417
|
+
end) + params[:path_values].to_url_data
|
418
|
+
params[:path_values] = {}
|
419
|
+
|
420
|
+
# handling data
|
421
|
+
data = (params[:body] or params[:data].to_url_data) if params[:method] == :post
|
422
|
+
|
423
|
+
# now do the request
|
424
|
+
@log.debug iw.to_s+": Sending request (#{params[:method]}, #{params[:path]})"
|
425
|
+
case params[:method]
|
426
|
+
when :get
|
427
|
+
response = @http.request_get params[:path], params[:headers]
|
428
|
+
when :post
|
429
|
+
response = @http.request_post params[:path], data, params[:headers]
|
430
|
+
when :head
|
431
|
+
response = @http.request_head params[:path], params[:headers]
|
432
|
+
else
|
433
|
+
raise ArgumentError, ':method must be :get, :post or :head'
|
434
|
+
end
|
435
|
+
|
436
|
+
# cookie handling, again
|
437
|
+
if response['set-cookie']
|
438
|
+
response.get_fields('set-cookie').each do |field|
|
439
|
+
field.scan(/([^; ]*)=([^;]*)/).each do |cookie|
|
440
|
+
unless ['path', 'expires'].include? cookie[0]
|
441
|
+
@log.debug iw.to_s+": Setting cookie #{cookie[0]} = #{cookie[1]}"
|
442
|
+
@cookies[cookie[0]] = cookie[1]
|
443
|
+
end
|
444
|
+
end
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
# Handle redirects / errors / gzip
|
449
|
+
case response
|
450
|
+
when Net::HTTPSuccess
|
451
|
+
body = response.body
|
452
|
+
if body and response['Content-Encoding'] == "gzip"
|
453
|
+
zstream = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
|
454
|
+
body = zstream.inflate body
|
455
|
+
zstream.finish
|
456
|
+
zstream.close
|
457
|
+
end
|
458
|
+
{:response => response, :request => params, :body => body }
|
459
|
+
when Net::HTTPRedirection
|
460
|
+
uri = URI.parse(response['location'])
|
461
|
+
path = uri.path
|
462
|
+
path += '?'+uri.query if uri.query
|
463
|
+
@log.debug iw.to_s+": Following redirect #{params[:path]} to #{path}"
|
464
|
+
request params.merge(:path => path, :follow_limit => params[:follow_limit]-1)
|
465
|
+
else
|
466
|
+
response.error!
|
467
|
+
end
|
468
|
+
|
469
|
+
end
|
470
|
+
|
471
|
+
# Does a request via api.php
|
472
|
+
#
|
473
|
+
# First parameter takes path_values, second parameter takes a hash like Server.request.
|
474
|
+
# If the third parameter (force) is true no error will be raised, even if use_api?.
|
475
|
+
#
|
476
|
+
# Note: If use_api? is false, force is true and no path is given Rubot tries to figure
|
477
|
+
# out the api path on it's own (simply replacing 'index.php' by 'api.php')
|
478
|
+
#
|
479
|
+
# The value for :action is by default set to "query"
|
480
|
+
#
|
481
|
+
# Thus
|
482
|
+
# Rubot[:en].api_request :action => 'help'
|
483
|
+
# is the same as
|
484
|
+
# Rubot[:en].api_request {}, :path_values => { :action => 'help' }
|
485
|
+
def api_request(path_values = {}, params = {}, force = false)
|
486
|
+
params.keys_to_sym!
|
487
|
+
unless use_api?
|
488
|
+
raise Error, "Trying to do some MediaWiki API request without using MediaWik API. Try :use_api => true" unless force
|
489
|
+
params[:path] ||= @options[:path].sub 'index.php', 'api.php'
|
490
|
+
end
|
491
|
+
params[:path_values].keys_to_sym!
|
492
|
+
params[:path_values] ||= {}
|
493
|
+
params[:path_values].merge!(path_values.keys_to_sym.merge({:format=>'yaml'}))
|
494
|
+
params[:path_values][:action] ||= 'query'
|
495
|
+
result = request(params)
|
496
|
+
begin
|
497
|
+
data = YAML::load result[:body]
|
498
|
+
rescue ArgumentError
|
499
|
+
begin
|
500
|
+
data = YAML::load result[:body][/---.*\n \*: >/m][0..-8]
|
501
|
+
rescue NoMethodError
|
502
|
+
if result[:body] =~ /<error code="(.*)" info="(.*)"/
|
503
|
+
data = {'error' => { 'code' => $1, 'info' => $2 } }
|
504
|
+
else
|
505
|
+
raise NoMethodError, $!
|
506
|
+
end
|
507
|
+
end
|
508
|
+
end
|
509
|
+
data ||= {}
|
510
|
+
raise ApiError, "#{data["error"]["code"]}: #{data["error"]["info"] or 'no message'} (#{result[:request][:path]})" if data["error"]
|
511
|
+
data
|
512
|
+
end
|
513
|
+
|
514
|
+
# Does a request and returns Hpricot object of body (DRY)
|
515
|
+
def hpricot(params = {})
|
516
|
+
Hpricot(request(params)[:body])
|
517
|
+
end
|
518
|
+
|
519
|
+
# Loads form data, see request and Rubot::Form.new
|
520
|
+
def get_form(handle = :form, params = {})
|
521
|
+
@log.debug iw.to_s+': Loading form data'
|
522
|
+
page = request(params)
|
523
|
+
Form.new page[:body], handle, page[:request][:path]
|
524
|
+
end
|
525
|
+
|
526
|
+
# Submits a form, takes optional request parameters if not given by request_data
|
527
|
+
# (i.e. :follow_redirects), for options see request
|
528
|
+
def submit_form(form, default_params = {})
|
529
|
+
@log.debug iw.to_s+': Sending form data'
|
530
|
+
request form.request_data.merge(default_params)
|
531
|
+
end
|
532
|
+
|
533
|
+
# MediaWiki Version. If parameter is true version number will be transformed to be comparable.
|
534
|
+
def mw_version(transform = false)
|
535
|
+
unless @version
|
536
|
+
if use_api?
|
537
|
+
@version = api(:meta => 'siteinfo')['query']['general']['generator'][/\d+\.\d+.\d(alpha|beta)?/]
|
538
|
+
else
|
539
|
+
@version = 'unknown'
|
540
|
+
doc = hpricot :path_values => {:title => 'Special:Version' }
|
541
|
+
(doc / :script).each { |script| version = $1 if script.inner_html =~ /var wgVersion = "(\w+)";/ }
|
542
|
+
if @version == 'unknown'
|
543
|
+
(doc / "#bodyContent tr" ).each do |tr|
|
544
|
+
try NoMethodError do
|
545
|
+
if (tr / :td)[0].at('a').inner_html == 'MediaWiki' and (tr / :td)[1].inner_html =~ /(\d+\.\d+.\d(alpha|beta)?)/
|
546
|
+
@version = $1
|
547
|
+
break
|
548
|
+
end
|
549
|
+
end
|
550
|
+
end
|
551
|
+
end
|
552
|
+
if @version == 'unknown'
|
553
|
+
(doc / "#bodyContent li" ).each do |li|
|
554
|
+
try NoMethodError do
|
555
|
+
if li.inner_html.gsub(/<[^>]*>/, '') =~ /MediaWiki: (\d+\.\d+.\d(alpha|beta)?)/
|
556
|
+
@version = $1
|
557
|
+
break
|
558
|
+
end
|
559
|
+
end
|
560
|
+
end
|
561
|
+
end
|
562
|
+
end
|
563
|
+
end
|
564
|
+
transform ? @version.gsub(/\.(\d)\./, '.0\1.').gsub(/\.(\d)\D*$/, '.0\1') : @version
|
565
|
+
end
|
566
|
+
|
567
|
+
# Try to login. False will prevent login from calling check_preferences.
|
568
|
+
def login(check = true)
|
569
|
+
@logged_in = false
|
570
|
+
if @options[:username]
|
571
|
+
@log.info iw.to_s+': Trying to login as '+@options[:username]
|
572
|
+
if use_api?
|
573
|
+
data = api :action => :login, :lgname => @options[:username], :lgpassword => @options[:password]
|
574
|
+
if data['login']['result'] != 'Success'
|
575
|
+
@log.warn "#{iw}: Login failed: #{data['result']['login']}"
|
576
|
+
raise LoginError, "failed to login (#{data['result']['login']})"
|
577
|
+
else
|
578
|
+
@logged_in == true
|
579
|
+
end
|
580
|
+
else
|
581
|
+
form = get_form "form[@name='userlogin']", :path_values => {:title => 'Special:Userlogin' }
|
582
|
+
form['wpName'] = @options[:username]
|
583
|
+
form['wpPassword'] = @options[:password]
|
584
|
+
form['wpRemember'] = '1'
|
585
|
+
form.submit = 'wpLoginattempt'
|
586
|
+
page = submit_form form
|
587
|
+
doc = Hpricot page[:body]
|
588
|
+
if doc.at('.errorbox')
|
589
|
+
@log.warn iw.to_s+': Login failed:'+doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
|
590
|
+
raise LoginError, "failed to login"
|
591
|
+
else
|
592
|
+
@logged_in = true
|
593
|
+
check_preferences if check
|
594
|
+
end
|
595
|
+
end
|
596
|
+
end
|
597
|
+
@log.info iw.to_s+': Logged in!' if @logged_in
|
598
|
+
end
|
599
|
+
|
600
|
+
# Returns true if logged in, else false.
|
601
|
+
def logged_in?
|
602
|
+
try { @logged_in = (not [0, nil].include? api(:meta => 'userinfo')['query']['userinfo']['id']) }
|
603
|
+
@logged_in
|
604
|
+
end
|
605
|
+
|
606
|
+
# Checks preferences.
|
607
|
+
def check_preferences(force = false)
|
608
|
+
if use_api?
|
609
|
+
@log.debug iw.to_s+': Not checking preferences (using API).'
|
610
|
+
@checked = true
|
611
|
+
else
|
612
|
+
@log.warn iw.to_s+': Not checking preferences, need to login first!' unless logged_in?
|
613
|
+
if logged_in? and (!@checked or force)
|
614
|
+
@log.debug iw.to_s+': Checking preferences'
|
615
|
+
changes = []
|
616
|
+
form = get_form "form", :path_values => {:title => 'Special:Preferences' }
|
617
|
+
@preferences.each do |key,value|
|
618
|
+
if form[key] != value
|
619
|
+
form[key] = value
|
620
|
+
if value
|
621
|
+
changes.push "'#{key}' = '#{value}'"
|
622
|
+
else
|
623
|
+
changes.push "remove '#{key}'"
|
624
|
+
end
|
625
|
+
end
|
626
|
+
end
|
627
|
+
unless changes.empty?
|
628
|
+
@log.info iw.to_s+": Changing some preferences (#{changes.join(', ')})"
|
629
|
+
form.submit = 'wpSaveprefs'
|
630
|
+
submit_form form
|
631
|
+
end
|
632
|
+
@checked = true
|
633
|
+
end
|
634
|
+
end
|
635
|
+
end
|
636
|
+
|
637
|
+
# Reads contents of a page
|
638
|
+
def read_page(page)
|
639
|
+
@log.info iw.to_s+": Reading page '#{page}'"
|
640
|
+
if use_api?
|
641
|
+
result = api :prop => 'revisions', :titles => page, :rvprop => 'content'
|
642
|
+
try(NoMethodError) { return result['query']['pages'][0]['revisions'][0]['*'] }
|
643
|
+
"\n"
|
644
|
+
else
|
645
|
+
@@coder.decode(
|
646
|
+
hpricot(:path_values => {:title => page, :action => :edit, :internaledit => true}).at('#wpTextbox1').inner_html )
|
647
|
+
end
|
648
|
+
end
|
649
|
+
|
650
|
+
# Writes to a page
|
651
|
+
def write_page(page, text, summary, minor = false)
|
652
|
+
login unless logged_in?
|
653
|
+
if (Time.now - last_edit) < @options[:delay]
|
654
|
+
@log.debug iw.to_s+": Delay - editing another page is not yet permitted."
|
655
|
+
sleep((@options[:delay] - (Time.now - last_edit)).ceil)
|
656
|
+
end
|
657
|
+
if use_api?
|
658
|
+
raise NotImplementedError, "Writing pages with MediaWiki's build-in API is not yet possible."
|
659
|
+
else
|
660
|
+
@log.warn iw.to_s+": Writing to page '#{page}' without being logged in!" unless logged_in? or @options[:dont_mutter]
|
661
|
+
@log.debug iw.to_s+": Reading page '#{page}'"
|
662
|
+
form = get_form '#editform', :path_values => {:title => page, :action => :edit, :internaledit => true}
|
663
|
+
@log.info iw.to_s+": Writing page '#{page}'"
|
664
|
+
form['wpTextbox1'] = text
|
665
|
+
form['wpSummary'] = summary
|
666
|
+
form['wpMinoredit'] = "1" if minor
|
667
|
+
form.submit = 'wpSave'
|
668
|
+
page = submit_form form
|
669
|
+
doc = Hpricot page[:body]
|
670
|
+
if doc.at('.errorbox')
|
671
|
+
@log.error iw.to_s+": Failed to write to page '#{page}':"+
|
672
|
+
doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
|
673
|
+
raise WritePageError, "Failed to write to page '#{page}'."
|
674
|
+
end
|
675
|
+
end
|
676
|
+
last_edit = Time.now
|
677
|
+
page
|
678
|
+
end
|
679
|
+
|
680
|
+
# Gets last edit time.
|
681
|
+
def last_edit
|
682
|
+
if @adapter and @adapter.respond_to? :last_edit
|
683
|
+
@adapter.last_edit self
|
684
|
+
else
|
685
|
+
@last_edit ||= Time.at 0
|
686
|
+
@last_edit
|
687
|
+
end
|
688
|
+
end
|
689
|
+
|
690
|
+
# Sets last edit time.
|
691
|
+
def last_edit=(time)
|
692
|
+
if @adapter and @adapter.respond_to? :set_last_edit
|
693
|
+
@adapter.set_last_edit self, time
|
694
|
+
else
|
695
|
+
@last_edit = time
|
696
|
+
end
|
697
|
+
end
|
698
|
+
|
699
|
+
# Gets page history. Offset is for recursion.
|
700
|
+
def page_history(page, offset = false)
|
701
|
+
raise NotImplementedError, "Reading page history with MediaWiki's build-in API is not yet possible." if use_api?
|
702
|
+
@log.info iw.to_s+": Reading page history for #{page}" unless offset
|
703
|
+
history = {}
|
704
|
+
if logged_in?
|
705
|
+
check_preferences
|
706
|
+
path_values = { :title => page, :action => :history }
|
707
|
+
path_values[:offset] = offset if offset
|
708
|
+
doc = hpricot :path_values => path_values
|
709
|
+
(doc / '#pagehistory li').each do |element|
|
710
|
+
id = element.at("input[@name='oldid']")[:value].to_i if element.at("input[@name='oldid']")
|
711
|
+
history[id] = {:user => element.at('.history-user').at('a').inner_html,
|
712
|
+
:oldid => id,
|
713
|
+
:comment => element.at('.comment'),
|
714
|
+
:time => Time.local(*(ParseDate.parsedate element.inner_html[/20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d/])) }
|
715
|
+
end
|
716
|
+
(doc / 'a[@href]').each do |a|
|
717
|
+
if a.inner_html['next'] and a[:href] =~ /offset=(\d*)&action=history/
|
718
|
+
history.merge! page_history(page, $1)
|
719
|
+
break
|
720
|
+
end
|
721
|
+
end
|
722
|
+
else
|
723
|
+
@log.error iw.to_s+": Will only read history, if logged in (cannot parse time otherwise)"
|
724
|
+
raise NotLoggedInError, "cannot read time format, need to log in."
|
725
|
+
end
|
726
|
+
offset ? history : history.values.sort_by { |a| a[:time] }
|
727
|
+
end
|
728
|
+
|
729
|
+
# Moves a page
|
730
|
+
def move(from, to, reason, keep_redirect = true)
|
731
|
+
raise NotImplementedError, "Moving pages with MediaWiki's build-in API is not yet possible." if use_api?
|
732
|
+
@log.info iw.to_s+": Moving page '#{from}' to '#{to}'"
|
733
|
+
login unless logged_in?
|
734
|
+
if (Time.now - last_edit) < @options[:delay]
|
735
|
+
@log.debug iw.to_s+": Delay - editing another page is not yet permitted."
|
736
|
+
sleep((@options[:delay] - (Time.now - last_edit)).ceil)
|
737
|
+
end
|
738
|
+
form = get_form "#movepage", :path_values => { :title => "Special:Movepage/#{from}"}
|
739
|
+
form['wpNewTitle'] = to
|
740
|
+
form['wpReason'] = reason
|
741
|
+
form.submit = 'wpMove'
|
742
|
+
page = submit_form form
|
743
|
+
doc = Hpricot page[:body]
|
744
|
+
if doc.at('.errorbox')
|
745
|
+
@log.error iw.to_s+": Failed to move page '#{from}' to '#{to}':"+
|
746
|
+
doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
|
747
|
+
raise MovePageError, "Failed to move page '#{from}' to '#{to}'."
|
748
|
+
end
|
749
|
+
last_edit = Time.now
|
750
|
+
page
|
751
|
+
end
|
752
|
+
|
753
|
+
# Lists recent changes. If called first time, will list the last 500 changes, when called again will only
|
754
|
+
# list new changes (up to 500). The options given will be added directly to the path, so you can pass all
|
755
|
+
# you can pass to Special:Recentchanges in MediaWiki (from, limit, hideminor, days, etc). Note that limit
|
756
|
+
# cannot be more than 500 and days cannot be more than 30. All hide parameters take 0 or 1, from will be
|
757
|
+
# set automaticly depending on last request of recent_changes. However, if you overwrite it, it has to be
|
758
|
+
# in the format of YYMMDDhhmmss.
|
759
|
+
#
|
760
|
+
# Returns Hash with Arrays :edits, :deletes, :moves, :uploads, :protections and :user_rights.
|
761
|
+
#
|
762
|
+
# For more advanced handling of recent changes, use base/utils or Adapter.
|
763
|
+
def recent_changes(path_values = {})
|
764
|
+
raise NotImplementedError, "Reading recent changes with MediaWiki's build-in API is not yet possible." if use_api?
|
765
|
+
@log.info iw.to_s+": Reading recent changes"
|
766
|
+
check_preferences
|
767
|
+
@rc_from ||= ''
|
768
|
+
doc = hpricot :path_values => { :title => 'Special:Recentchanges', :from => @rc_from,
|
769
|
+
:limit => 500, :hidebots => 0,
|
770
|
+
:hideminor => 0, :hideanons => 0,
|
771
|
+
:hideliu => 0, :hidepatrolled => 0,
|
772
|
+
:hidemyself => 0, :days => 30 }.merge(path_values.keys_to_sym)
|
773
|
+
(doc / :script).each do |script|
|
774
|
+
if script.inner_html =~ /var wgUserLanguage = "(\w+)";/ and $1 != 'en'
|
775
|
+
@log.error iw.to_s+": Will only read recent changes, if interface language is english (can be solved be simply logging in!)"
|
776
|
+
raise NotLoggedInError, "cannot read language '#{$1}', need to log in."
|
777
|
+
return nil
|
778
|
+
end
|
779
|
+
end
|
780
|
+
(doc / 'a[@href]').each do |a|
|
781
|
+
if a[:href] =~ /from=(\d\d\d\d\d\d\d\d\d\d\d\d\d\d)/
|
782
|
+
@rc_from = $1
|
783
|
+
break
|
784
|
+
end
|
785
|
+
end
|
786
|
+
edits, deletes, moves, uploads, protections, user_rights = [], [], [], [], [], []
|
787
|
+
(doc / '.special li').each do |element|
|
788
|
+
case element.at('a').inner_html.downcase
|
789
|
+
when "diff", "hist"
|
790
|
+
edits.push( { :page => element.at('a')[:title],
|
791
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
|
792
|
+
:user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
793
|
+
:new => (element.inner_html[0..5] == "(diff)"),
|
794
|
+
:minor => (element / '.minor' ).empty?,
|
795
|
+
:bot => (element / '.bot' ).empty?,
|
796
|
+
:oldid => ($1.to_i if element.at('a')[:href] =~ /oldid=(\d*)$/) })
|
797
|
+
when "deletion log"
|
798
|
+
deletes.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
799
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
|
800
|
+
:page => element.at('.comment a').inner_html })
|
801
|
+
when "move log"
|
802
|
+
moves.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
803
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
|
804
|
+
:from => (element / '.comment a')[0].inner_html,
|
805
|
+
:to => (element / '.comment a')[1].inner_html })
|
806
|
+
when "upload log"
|
807
|
+
uploads.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
808
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
|
809
|
+
:file => element.at('.comment a').inner_html })
|
810
|
+
when "protection log"
|
811
|
+
protections.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
812
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
|
813
|
+
:page => element.at('.comment a').inner_html,
|
814
|
+
:rules => element.at('.comment').inner_html[/\[.*\]\)/][1..-3].split(':').collect_hash do |rule|
|
815
|
+
{$1 => $2} if rule =~ /^(.*)=(.*)$/
|
816
|
+
end })
|
817
|
+
when "user rights log"
|
818
|
+
user_rights.push( { :user_1 => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
|
819
|
+
:user_2 => ((element.at('.comment a').inner_html[/[^:]*$/] if element.at('.comment a')) or ""),
|
820
|
+
:comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or "") })
|
821
|
+
end
|
822
|
+
end
|
823
|
+
{ :edits => edits, :deletes => deletes,
|
824
|
+
:moves => moves, :uploads => uploads,
|
825
|
+
:protections => protections, :user_rights => user_rights }
|
826
|
+
end
|
827
|
+
|
828
|
+
# Uploads data
|
829
|
+
def upload(file_name, summary, data)
|
830
|
+
raise NotImplementedError, "Uploading files with MediaWiki's build-in API is not yet possible." if use_api?
|
831
|
+
@log.info iw.to_s+": Uploading file '#{file_name}'"
|
832
|
+
@log.warn "UPLOADS DON'T WORK RIGHT NOW! But have a try..."
|
833
|
+
form = get_form '#upload', :path_values => { :title => 'Special:Upload' }
|
834
|
+
form['wpDestFile'] = file_name
|
835
|
+
form['wpUploadFile'] = data
|
836
|
+
form['wpUploadDescription'] = summary
|
837
|
+
form['wpIgnoreWarning'] = 'true'
|
838
|
+
form.submit = 'wpUpload'
|
839
|
+
form.set_file_name 'wpUploadFile', file_name
|
840
|
+
form.set_mime_type 'wpUploadFile', MIME_TYPES[file_name[/[^\.]*$/]]
|
841
|
+
submit_form form
|
842
|
+
end
|
843
|
+
|
844
|
+
# Array of pages in a given Category
|
845
|
+
def category_pages(category, from = nil)
|
846
|
+
raise NotImplementedError, "Reading categories with MediaWiki's build-in API is not yet possible." if use_api?
|
847
|
+
@log.info iw.to_s+": Reading page list '#{category}'."
|
848
|
+
doc = hpricot :path_values => { :title => "Category:#{category}", :from => from }
|
849
|
+
(doc / :script).each do |script|
|
850
|
+
if script.inner_html =~ /var wgUserLanguage = "(\w+)";/ and $1 != 'en'
|
851
|
+
@log.error iw.to_s+": Will only read recent changes, if interface language is english (can be solved be simply logging in!)"
|
852
|
+
raise NotLoggedInError, "cannot read language '#{$1}', need to log in."
|
853
|
+
return nil
|
854
|
+
end
|
855
|
+
end
|
856
|
+
from = nil
|
857
|
+
from = @@coder.decode($1) if (doc / "a[@href]").detect { |a| a.inner_html['next'] and a[:href] =~ /from=(.*)$/ }
|
858
|
+
((doc / 'li a').collect { |e| e[:title] if e[:title] == e.inner_html } +
|
859
|
+
(from ? [URI::decode(from).gsub(/[\+_]/, ' ')]+category_pages(category, from) : [])).compact.uniq.sort
|
860
|
+
end
|
861
|
+
|
862
|
+
# Localized namespaces.
|
863
|
+
#
|
864
|
+
# Examples:
|
865
|
+
# Rubot[:de].local_namespace(:Talk) # Could be something like "Diskussion"
|
866
|
+
# Rubot[:en].local_namespace(:Main) # Should be "(Main)"
|
867
|
+
def local_namespace(ns = :all, force_reload = false)
|
868
|
+
raise NotImplementedError, "Loading namespaces with MediaWiki's build-in API is not yet possible." if use_api?
|
869
|
+
if force_reload or not defined?(@ns)
|
870
|
+
@log.debug iw.to_s+": Loading localized namespaces."
|
871
|
+
elements = (hpricot(:path_values => { :title => 'Special:Search' }) / '#powersearch span')
|
872
|
+
elements = (hpricot(:path_values => { :title => 'Special:Search' }) / '#powersearch label') if elements.empty?
|
873
|
+
@ns = elements.collect_hash do |element|
|
874
|
+
{ element.at('input')[:name][2..-1].to_i => @@coder.decode(element.inner_html.gsub(/<[^>]*>/, '')).gsub(/\302|\240/, " ").strip }
|
875
|
+
end
|
876
|
+
end
|
877
|
+
ns == :all ? @ns : @ns[ns_index(ns)].to_s
|
878
|
+
end
|
879
|
+
|
880
|
+
# Returns prefix like it's to be used in links
|
881
|
+
#
|
882
|
+
# Examples:
|
883
|
+
# Rubot[:en].namespace(:main) # => ''
|
884
|
+
# Rubot[:en].namespace(:talk) # => 'Talk:'
|
885
|
+
# Rubot[:de].namespace(:image) # => 'Bild:'
|
886
|
+
def namespace(ns)
|
887
|
+
lns(ns) =~ /^\(.*\)$/ ? '' : "#{lns(ns)}:"
|
888
|
+
end
|
889
|
+
|
890
|
+
# Returns namespace number
|
891
|
+
def namespace_index(ns)
|
892
|
+
case
|
893
|
+
when ns.is_a?(Integer)
|
894
|
+
ns
|
895
|
+
when ns =~ /^ns(\d*)$/
|
896
|
+
$1.to_i
|
897
|
+
when @@english_namespaces.include?(ns)
|
898
|
+
@@english_namespaces.index ns
|
899
|
+
when lns.include?(ns)
|
900
|
+
lns.index ns
|
901
|
+
when @@english_namespaces.include?(ns.to_s.downcase.to_sym)
|
902
|
+
@@english_namespaces.index ns.to_s.downcase.to_sym
|
903
|
+
else
|
904
|
+
(lns.detect { |key,value| value.to_s.downcase == ns.to_s.downcase })[0]
|
905
|
+
end
|
906
|
+
end
|
907
|
+
|
908
|
+
# Returns all pages in the given namespace.
|
909
|
+
def pages_in_namespace(ns = :main, from = '!')
|
910
|
+
raise NotImplementedError, "Listing pages in a namespace with MediaWiki's build-in API is not yet possible." if use_api?
|
911
|
+
doc = hpricot :path_values => { :title => "Special:Allpages", :namespace => ns_index(ns), :from => from }
|
912
|
+
new_from = nil
|
913
|
+
ary = ((doc / "td a").collect do |a|
|
914
|
+
if a[:title] == n(ns)+a.inner_html
|
915
|
+
new_from = a.inner_html
|
916
|
+
@@coder.decode a[:title]
|
917
|
+
end
|
918
|
+
end).compact.sort
|
919
|
+
ary += pages_in_namespace(ns, new_from) if new_from and new_from != from
|
920
|
+
ary.uniq.sort
|
921
|
+
end
|
922
|
+
|
923
|
+
# Returns all pages in all namespaces
|
924
|
+
def all_pages
|
925
|
+
(lns(:all).values.collect { |ns| pages_in_namespace ns }).flatten
|
926
|
+
end
|
927
|
+
|
928
|
+
# Sets log (should be a logger object).
|
929
|
+
def log=(logger)
|
930
|
+
if @adapter
|
931
|
+
@log = @adapter.log
|
932
|
+
log.warn("Could not set log for #{interwiki.to_s}, since logging is controlled by adapter.") unless logger == @log
|
933
|
+
else
|
934
|
+
@log = logger
|
935
|
+
end
|
936
|
+
end
|
937
|
+
|
938
|
+
# shortform
|
939
|
+
alias iw interwiki
|
940
|
+
alias ns_index namespace_index
|
941
|
+
alias lns local_namespace
|
942
|
+
alias n namespace
|
943
|
+
alias pages_in_category category_pages
|
944
|
+
alias api api_request
|
945
|
+
|
946
|
+
end
|
947
|
+
|
948
|
+
|
949
|
+
|
950
|
+
# Class for handling html forms, does not realy depend on Rubot stuff, so if you can use the result of
|
951
|
+
# request_data (which is a hash to be understood by Server.request), feel free to do so. Make sure
|
952
|
+
# to load hpricot, uri and mime/types.
|
953
|
+
class Form
|
954
|
+
|
955
|
+
include Enumerable
|
956
|
+
|
957
|
+
attr_accessor :method, :path, :force, :multipart, :boundary
|
958
|
+
attr_reader :submit
|
959
|
+
|
960
|
+
# Greps Formular for given source (handle should be css-like or XPath)
|
961
|
+
def initialize(source, handle, default_path)
|
962
|
+
@doc = Hpricot(source)
|
963
|
+
@form = @doc.at(handle)
|
964
|
+
@method = (@form[:method] || 'get').downcase.to_sym
|
965
|
+
@submit = nil
|
966
|
+
@force = false
|
967
|
+
@elements = {}
|
968
|
+
@@coder ||= HTMLEntities.new
|
969
|
+
@path = @@coder.decode((@form[:action] || default_path))
|
970
|
+
@multipart = false
|
971
|
+
@boundary = '349832898984244898448024464570528145'
|
972
|
+
(@form / :input).each do |element|
|
973
|
+
ignore = false
|
974
|
+
case (element[:type] || 'text').downcase.to_sym
|
975
|
+
when :text, :password, :hidden
|
976
|
+
@elements[element[:name]] = { :mode => :text, :type => element[:type], :value => element[:value] }
|
977
|
+
when :checkbox
|
978
|
+
@elements[element[:name]] = { :mode => :select, :type => element[:type],
|
979
|
+
:possible => [nil, element[:value]], :value => nil }
|
980
|
+
@elements[element[:name]][:value] = element[:value] if element[:checked]
|
981
|
+
when :radio
|
982
|
+
@elements[element[:name]] = { :possible => [] } unless @elements[element[:name]]
|
983
|
+
@elements[element[:name]][:mode] = :select
|
984
|
+
@elements[element[:name]][:type] = element[:type]
|
985
|
+
@elements[element[:name]][:value] = element[:value] if element[:checked]
|
986
|
+
@elements[element[:name]][:possible].push(element[:value])
|
987
|
+
when :submit, :image
|
988
|
+
@submit = element[:name] unless @submit
|
989
|
+
@elements[element[:name]] = { :mode => :submit, :type => element[:type],
|
990
|
+
:value => (element[:value] or element[:src]) }
|
991
|
+
when :file
|
992
|
+
@multipart = true
|
993
|
+
@method = :post
|
994
|
+
@elements[element[:name]] = { :mode => :file, :type => element[:type],
|
995
|
+
:value => '', :mime_type => 'text/plain',
|
996
|
+
:file_name => element[:name] }
|
997
|
+
when :reset, :button
|
998
|
+
ignore = true
|
999
|
+
end
|
1000
|
+
@elements[element[:name]][:disabled] = (element[:readonly] or element[:disabled]) != nil unless ignore
|
1001
|
+
end
|
1002
|
+
(@form / :textarea).each do |element|
|
1003
|
+
@elements[element[:name]] = { :mode => :text, :type => 'textarea', :value => element.inner_html,
|
1004
|
+
:disabled => (element[:readonly] or element[:disabled]) != nil }
|
1005
|
+
end
|
1006
|
+
(@form / :select).each do |element|
|
1007
|
+
@elements[element[:name]] = { :mode => :select, :type => 'select', :value => nil, :possible => [],
|
1008
|
+
:disabled => (element[:readonly] or element[:disabled]) != nil }
|
1009
|
+
(element / :option).each do |option|
|
1010
|
+
@elements[element[:name]][:possible].push((option[:value] or option.inner_html))
|
1011
|
+
@elements[element[:name]][:value] = (option[:value] or option.inner_html) if option[:selected]
|
1012
|
+
end
|
1013
|
+
end
|
1014
|
+
end
|
1015
|
+
|
1016
|
+
# sets submit field to be used
|
1017
|
+
def submit=(name)
|
1018
|
+
@submit = name if @elements[name] and @elements[name][:mode] == :submit
|
1019
|
+
end
|
1020
|
+
|
1021
|
+
# sets value of field +element+, if force is true even set if it wouldn't be possible
|
1022
|
+
def set_value(element, value, force = @force)
|
1023
|
+
@elements[element] = { :mode => :text, :type => nil } if !@elements[element] and force
|
1024
|
+
@elements[element][:value] = value if force or (!@elements[element][:disabled] and
|
1025
|
+
(@elements[element][:mode] == :text or
|
1026
|
+
@elements[element][:mode] == :file or
|
1027
|
+
(@elements[element][:mode] == :select and
|
1028
|
+
@elements[element][:possible].include? value)))
|
1029
|
+
end
|
1030
|
+
|
1031
|
+
# returns mime type of field
|
1032
|
+
def get_mime_type(element)
|
1033
|
+
@elements[element] ? @elements[element][:mime_type] : nil
|
1034
|
+
end
|
1035
|
+
|
1036
|
+
# sets mime type of field
|
1037
|
+
def set_mime_type(element, mime_type)
|
1038
|
+
@elements[element][:mime_type] = mime_type if @elements[element] and @elements[element][:mode] == :file
|
1039
|
+
end
|
1040
|
+
|
1041
|
+
# returns file name of field
|
1042
|
+
def get_file_name(element)
|
1043
|
+
@elements[element] ? @elements[element][:file_name] : nil
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
# sets mime type of field
|
1047
|
+
def set_file_name(element, file_name)
|
1048
|
+
@elements[element][:file_name] = file_name if @elements[element] and @elements[element][:mode] == :file
|
1049
|
+
end
|
1050
|
+
|
1051
|
+
# returns value of given field
|
1052
|
+
def [](element)
|
1053
|
+
@elements[element] ? @elements[element][:value] : nil
|
1054
|
+
end
|
1055
|
+
|
1056
|
+
# alias for set_value
|
1057
|
+
def []=(element, value)
|
1058
|
+
set_value(element, value, @force)
|
1059
|
+
end
|
1060
|
+
|
1061
|
+
# loops through elements and passes name and value of fields
|
1062
|
+
def each(&block) # :yield: name, value
|
1063
|
+
@elements.each { |key, value| block.call key, value[:value] }
|
1064
|
+
end
|
1065
|
+
|
1066
|
+
# Hash-like
|
1067
|
+
def each_value(&block)
|
1068
|
+
@elements.each_value { |value| block.call value }
|
1069
|
+
end
|
1070
|
+
|
1071
|
+
# Hash-like
|
1072
|
+
def each_key(&block)
|
1073
|
+
@elements.each_key { |key| block.call key }
|
1074
|
+
end
|
1075
|
+
|
1076
|
+
# returns true if element is readonly
|
1077
|
+
def readonly? element
|
1078
|
+
@elements[element][:disabled]
|
1079
|
+
end
|
1080
|
+
|
1081
|
+
# returns false if element is readonly
|
1082
|
+
def writable? element
|
1083
|
+
not readonly? element
|
1084
|
+
end
|
1085
|
+
|
1086
|
+
# Data for request
|
1087
|
+
def request_data
|
1088
|
+
if @multipart
|
1089
|
+
data = []
|
1090
|
+
@elements.each do |name,element|
|
1091
|
+
unless element[:mode] == :select and element[:value] == nil
|
1092
|
+
if element[:mode] == :file
|
1093
|
+
data.push "Content-Disposition: form-data; name=\"#{name.for_url}\"; filename=\"#{element[:file_name]}\"\r\n" +
|
1094
|
+
"Content-Transfer-Encoding: binary\r\n" + "Content-Type: #{element[:mime_type]}\r\n" +
|
1095
|
+
"\r\n#{element[:value]}\r\n"
|
1096
|
+
elsif element[:mode] != :submit or @submit == name
|
1097
|
+
data.push "Content-Disposition: form-data; name=\"#{name.for_url}\"\r\n" +
|
1098
|
+
"\r\n#{element[:value]}\r\n"
|
1099
|
+
end
|
1100
|
+
end
|
1101
|
+
end
|
1102
|
+
data.push "Content-Disposition: form-data; name=\"submit\"\r\n\r\n#{@submit}\r\n"
|
1103
|
+
{ :body => data.collect {|e| "--#{@boundary}\r\n#{e}"}.join('') + "--#{@boundary}--\r\n",
|
1104
|
+
:headers => {"Content-Type" => "multipart/form-data; boundary=" + @boundary},
|
1105
|
+
:method => @method,
|
1106
|
+
:path => @path }
|
1107
|
+
else
|
1108
|
+
data = {}
|
1109
|
+
@elements.each do |name,element|
|
1110
|
+
data[name] = element[:value] if (element[:mode] == :submit and @submit == name) or
|
1111
|
+
(element[:mode] == :select and element[:value] != nil) or
|
1112
|
+
element[:mode] == :text
|
1113
|
+
end
|
1114
|
+
data[:submit] = @submit
|
1115
|
+
{ :method => @method, :path => @path, :data => data }
|
1116
|
+
end
|
1117
|
+
end
|
1118
|
+
|
1119
|
+
end
|
1120
|
+
|
1121
|
+
end
|
1122
|
+
|
1123
|
+
|
1124
|
+
|
1125
|
+
# Monkey patching
|
1126
|
+
class Object
|
1127
|
+
def for_url
|
1128
|
+
ERB::Util.url_encode(self.to_s)
|
1129
|
+
end
|
1130
|
+
end
|
1131
|
+
|
1132
|
+
|
1133
|
+
# Monkey patching
|
1134
|
+
class Array
|
1135
|
+
|
1136
|
+
def collect_hash
|
1137
|
+
result = {}
|
1138
|
+
self.each do |value|
|
1139
|
+
subhash = yield value
|
1140
|
+
result.merge!(subhash) if subhash.is_a? Hash
|
1141
|
+
end
|
1142
|
+
result
|
1143
|
+
end
|
1144
|
+
|
1145
|
+
def for_all?(&block)
|
1146
|
+
result = true
|
1147
|
+
self.each { |value| result = (result and block.call value) }
|
1148
|
+
result
|
1149
|
+
end
|
1150
|
+
|
1151
|
+
def exists?(&block)
|
1152
|
+
result = false
|
1153
|
+
self.each { |value| result = (result or block.call value) }
|
1154
|
+
result
|
1155
|
+
end
|
1156
|
+
|
1157
|
+
end
|
1158
|
+
|
1159
|
+
|
1160
|
+
# Monkey patching
|
1161
|
+
class Hash
|
1162
|
+
def to_url_data
|
1163
|
+
(self.collect { |key,value| key.to_s.for_url+'='+value.to_s.for_url }).join('&')
|
1164
|
+
end
|
1165
|
+
|
1166
|
+
def keys_to_sym
|
1167
|
+
self.to_a.collect_hash { |a| { a[0].to_sym => a[1] } }
|
1168
|
+
end
|
1169
|
+
|
1170
|
+
def keys_to_sym!
|
1171
|
+
self.each_key { |key| self[key.to_sym] = self.delete key }
|
1172
|
+
end
|
1173
|
+
|
1174
|
+
def collect_hash
|
1175
|
+
result = {}
|
1176
|
+
self.each do |key,value|
|
1177
|
+
subhash = yield key, value
|
1178
|
+
result.merge!(subhash) if subhash.is_a? Hash
|
1179
|
+
end
|
1180
|
+
result
|
1181
|
+
end
|
1182
|
+
end
|