rubot-base 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|