webri 1.0.5 → 2.1.0
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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/bin/scrape +12 -0
- data/data/3.2.json +30958 -0
- data/data/3.4.json +33900 -0
- data/data/4.0.json +28533 -0
- data/exe/webri +80 -0
- data/lib/scraper.rb +285 -0
- data/lib/webri/version.rb +1 -1
- data/lib/webri.rb +287 -372
- metadata +8 -3
- data/bin/webri +0 -65
data/exe/webri
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# A console application to display Ruby HTML documentation.
|
|
4
|
+
|
|
5
|
+
require 'optparse'
|
|
6
|
+
require_relative '../lib/webri/version'
|
|
7
|
+
require_relative '../lib/webri'
|
|
8
|
+
require_relative '../lib/scraper'
|
|
9
|
+
|
|
10
|
+
NAME = <<EOT
|
|
11
|
+
|
|
12
|
+
Name:
|
|
13
|
+
|
|
14
|
+
There are four types of names, as determined by prefixes:
|
|
15
|
+
|
|
16
|
+
- Class or module: starts with a capital letter; e.g., 'Array'.
|
|
17
|
+
- Singleton method: starts with '::'; e.g., '::new'.
|
|
18
|
+
- Instance method: starts with '#' (escaped as '\\#', if your shell requires it);
|
|
19
|
+
e.g., '#inspect'.
|
|
20
|
+
- Ruby file: starts with 'ruby:'; e.g., 'ruby:syntax_rdoc'.
|
|
21
|
+
|
|
22
|
+
Name handling:
|
|
23
|
+
|
|
24
|
+
- If name is a complete name of its type (and not also the start of other such names),
|
|
25
|
+
webri opens the page for that name.
|
|
26
|
+
- If name is incomplete, but is the start of only one name of its type,
|
|
27
|
+
webri asks whether to open the page for that name.
|
|
28
|
+
- If name is incomplete, but is the start of multiple names of its type,
|
|
29
|
+
webri displays those names and lets you choose.
|
|
30
|
+
- If name is not a valid name of its type, webri asks whether show such names.
|
|
31
|
+
- If name is not valid at all (i.e., does not start with any of the prefixes above),
|
|
32
|
+
webri prints an error message.
|
|
33
|
+
|
|
34
|
+
EOT
|
|
35
|
+
options = {}
|
|
36
|
+
|
|
37
|
+
parser = OptionParser.new
|
|
38
|
+
|
|
39
|
+
parser.version = WebRI::VERSION
|
|
40
|
+
parser.banner = <<-BANNER
|
|
41
|
+
webri is a console application for displaying Ruby online HTML documentation.
|
|
42
|
+
Documentation pages are opened in the default web browser.
|
|
43
|
+
|
|
44
|
+
Usage: #{parser.program_name} [options] name
|
|
45
|
+
|
|
46
|
+
BANNER
|
|
47
|
+
|
|
48
|
+
RELEASES = Scraper.scrapers.keys.inspect
|
|
49
|
+
|
|
50
|
+
parser.separator("Options:")
|
|
51
|
+
release_description = <<-EOT
|
|
52
|
+
|
|
53
|
+
EOT
|
|
54
|
+
parser.on('-r', '--release RELEASE',
|
|
55
|
+
"Specify documentation release (one of #{RELEASES}).") do |value|
|
|
56
|
+
options[:release_name] = value
|
|
57
|
+
end
|
|
58
|
+
parser.on('-i', '--info', 'Print information about webri.') do
|
|
59
|
+
options[:info] = true
|
|
60
|
+
end
|
|
61
|
+
parser.on('--noreline', 'Do not use Reline (useful for testing).') do |value|
|
|
62
|
+
options[:noreline] = true
|
|
63
|
+
end
|
|
64
|
+
parser.on('-n', '--noop', 'Do not actually open web pages.') do |value|
|
|
65
|
+
options[:noop] = true
|
|
66
|
+
end
|
|
67
|
+
parser.on('-h', '--help', 'Print this help.') do
|
|
68
|
+
puts parser
|
|
69
|
+
exit
|
|
70
|
+
end
|
|
71
|
+
parser.on('-v', '--version', 'Print the version of webri.') do
|
|
72
|
+
puts WebRI::VERSION
|
|
73
|
+
exit
|
|
74
|
+
end
|
|
75
|
+
parser.separator(NAME)
|
|
76
|
+
|
|
77
|
+
parser.parse!
|
|
78
|
+
|
|
79
|
+
name = ARGV[0]
|
|
80
|
+
WebRI.new(name, options)
|
data/lib/scraper.rb
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'rexml'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'json/add/core'
|
|
6
|
+
|
|
7
|
+
# Class to scrape Ruby info from the documentation site and store it as JSON.
|
|
8
|
+
|
|
9
|
+
class Scraper
|
|
10
|
+
|
|
11
|
+
# The official documentation site for English.
|
|
12
|
+
BASE_URL = 'https://docs.ruby-lang.org/en'
|
|
13
|
+
|
|
14
|
+
# Translations for hex characters.
|
|
15
|
+
HEX_CHARS = {
|
|
16
|
+
'-21' => '!',
|
|
17
|
+
'-25' => '%',
|
|
18
|
+
'-26' => '&',
|
|
19
|
+
'-2A' => '*',
|
|
20
|
+
'-2B' => '+',
|
|
21
|
+
'-2D' => '-',
|
|
22
|
+
'-2F' => '/',
|
|
23
|
+
'-3C' => '<',
|
|
24
|
+
'-40' => '@',
|
|
25
|
+
'-3D' => '=',
|
|
26
|
+
'-3E' => '>',
|
|
27
|
+
'-3F' => '?',
|
|
28
|
+
'-5B' => '[',
|
|
29
|
+
'-5D' => ']',
|
|
30
|
+
'-5E' => '^',
|
|
31
|
+
'-60' => '`',
|
|
32
|
+
'-7C' => '|',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Hash: relative URL paths for a name.
|
|
36
|
+
attr_accessor :hrefs_for_name
|
|
37
|
+
|
|
38
|
+
# Hash: the names of the classes that have a method.
|
|
39
|
+
attr_accessor :classes_for_method
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
self.hrefs_for_name = {}
|
|
43
|
+
self.classes_for_method = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.scrapers
|
|
47
|
+
{
|
|
48
|
+
'3.2' => Scraper34,
|
|
49
|
+
'3.4' => Scraper32,
|
|
50
|
+
'4.0' => Scraper40,
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.release_names
|
|
55
|
+
self.scrapers.keys
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_home_page(release_name, suffix)
|
|
59
|
+
url = File.join(BASE_URL, release_name, suffix)
|
|
60
|
+
uri = URI(url)
|
|
61
|
+
response = Net::HTTP.get_response(uri)
|
|
62
|
+
unless response.code == '200'
|
|
63
|
+
message = "Page #{url} for release #{url} not found; code #{response.code}."
|
|
64
|
+
raise RuntimeError.new(message)
|
|
65
|
+
end
|
|
66
|
+
response.body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Argument is an REXML::Element representing a link to a file.
|
|
70
|
+
# Returns the parsed name and href.
|
|
71
|
+
def add_file(element)
|
|
72
|
+
unless element.name == 'a'
|
|
73
|
+
message = "Expecting 'a', not '#{element.name}'"
|
|
74
|
+
raise ArgumentError.new(message)
|
|
75
|
+
end
|
|
76
|
+
file_href = element.attributes['href']
|
|
77
|
+
file_href.sub!(%r[^./], '')
|
|
78
|
+
file_name = 'ruby:' + file_href.sub(/\.html$/, '')
|
|
79
|
+
puts "Adding file #{file_name}"
|
|
80
|
+
hrefs_for_name[file_name] = [file_href]
|
|
81
|
+
[file_name, file_href]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Argument is an REXML::Element representing a link to a class.
|
|
85
|
+
# Returns the parsed name and href.
|
|
86
|
+
def add_class(element)
|
|
87
|
+
unless element.name == 'a'
|
|
88
|
+
message = "Expecting 'a', not '#{element.name}'"
|
|
89
|
+
raise ArgumentError.new(message)
|
|
90
|
+
end
|
|
91
|
+
class_href = element.attributes['href'].sub(%r[^./], '')
|
|
92
|
+
class_name = class_href.gsub('/', '::').sub(%r[\.html$], '')
|
|
93
|
+
puts "Adding class #{class_name}"
|
|
94
|
+
hrefs_for_name[class_name] = [class_href]
|
|
95
|
+
[class_name, class_href]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Arguments are an REXML::Element representing a link to a class, and the class name.
|
|
99
|
+
# Returns the parsed name and href.
|
|
100
|
+
def add_method(element, class_name)
|
|
101
|
+
method_href = element.attributes['href']
|
|
102
|
+
# Translate hex characters.
|
|
103
|
+
method_name = method_href.dup
|
|
104
|
+
hex_chars = method_name.scan(/-[0-9A-F]{2}/)
|
|
105
|
+
hex_chars.each do |hex_char|
|
|
106
|
+
char = HEX_CHARS[hex_char]
|
|
107
|
+
raise hex_char unless char
|
|
108
|
+
method_name.sub!(hex_char, char)
|
|
109
|
+
end
|
|
110
|
+
method_name = case
|
|
111
|
+
# when method_href == '#method-i-2D'
|
|
112
|
+
# # Special case; not worth the trouble of handling below.
|
|
113
|
+
# '#-'
|
|
114
|
+
when method_href.match(/[#]?method-i-2D/)
|
|
115
|
+
# Special case; not worth the trouble of handling below.
|
|
116
|
+
'#-'
|
|
117
|
+
# when method_href == '#method-i-2D-40'
|
|
118
|
+
# # Special case; not worth the trouble of handling below.
|
|
119
|
+
# '#-@'
|
|
120
|
+
when method_href.match(/[#]?method-i-2D-40/)
|
|
121
|
+
# Special case; not worth the trouble of handling below.
|
|
122
|
+
'#-@'
|
|
123
|
+
when method_href.match('-i-')
|
|
124
|
+
# Instance method.
|
|
125
|
+
'#' + method_name.sub(/[#]?method-i[-]?/, '')
|
|
126
|
+
when method_href.match('-c-')
|
|
127
|
+
# Singleton method.
|
|
128
|
+
'::' + method_name.sub(/[#]?method-c[-]?/, '')
|
|
129
|
+
end
|
|
130
|
+
puts " Adding method #{method_name}"
|
|
131
|
+
hrefs_for_name[method_name] = "#{method_href}"
|
|
132
|
+
classes_for_method[method_name] ||= []
|
|
133
|
+
classes_for_method[method_name] << "#{class_name}"
|
|
134
|
+
[method_name, method_href]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def write_json(release_name)
|
|
138
|
+
# This JSON is bulky, but readable by humans.
|
|
139
|
+
data = {
|
|
140
|
+
:timestamp => Time.now,
|
|
141
|
+
:hrefs_for_name => hrefs_for_name.sort.to_h,
|
|
142
|
+
:classes_for_method => classes_for_method.sort.to_h,
|
|
143
|
+
}
|
|
144
|
+
json = JSON.generate(
|
|
145
|
+
data.sort.to_h,
|
|
146
|
+
indent: " ", # 4 spaces per level
|
|
147
|
+
space: " ", # space after :
|
|
148
|
+
array_nl: "\n", # newline after each array element
|
|
149
|
+
object_nl: "\n" # newline after each object member
|
|
150
|
+
)
|
|
151
|
+
filepath = "data/#{release_name}.json"
|
|
152
|
+
File.write(filepath, json)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class Scraper40 < Scraper
|
|
158
|
+
|
|
159
|
+
RELEASE_NAME = '4.0'
|
|
160
|
+
|
|
161
|
+
def scrape
|
|
162
|
+
home_page = get_home_page(RELEASE_NAME, '')
|
|
163
|
+
home_page.lines.each do |line|
|
|
164
|
+
line.chomp!
|
|
165
|
+
next unless line.match(%r[<a href="])
|
|
166
|
+
line += '</a>' unless line.match(%r[</a>]) # Add end-tag if needed.
|
|
167
|
+
# Parse the line as XML.
|
|
168
|
+
doc = REXML::Document.new(line)
|
|
169
|
+
root = doc.root
|
|
170
|
+
case root.name
|
|
171
|
+
when 'a' # Each file URL is the href attribute in an anchor element.
|
|
172
|
+
add_file(root)
|
|
173
|
+
when 'ul' # All class/module URLs are in a single ul element.
|
|
174
|
+
add_classes(root)
|
|
175
|
+
break # We don't need anything farther down.
|
|
176
|
+
else
|
|
177
|
+
# Ignore.
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
write_json(RELEASE_NAME)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def add_classes(element)
|
|
184
|
+
REXML::XPath.each(element, "//*[@href]") do |element|
|
|
185
|
+
class_name, class_href = add_class(element)
|
|
186
|
+
url = File.join(BASE_URL, RELEASE_NAME, class_href)
|
|
187
|
+
uri = URI(url)
|
|
188
|
+
response = Net::HTTP.get_response(uri)
|
|
189
|
+
raise response.code unless response.code == '200'
|
|
190
|
+
response.body.lines.each do |line|
|
|
191
|
+
next unless line.match(%r[<li ><a href="#method-])
|
|
192
|
+
line.chomp!
|
|
193
|
+
doc = REXML::Document.new(line)
|
|
194
|
+
REXML::XPath.each(doc.root, "//*[@href]") do |element|
|
|
195
|
+
add_method(element, class_name)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class Scraper34 < Scraper
|
|
203
|
+
|
|
204
|
+
RELEASE_NAME = '3.4'
|
|
205
|
+
|
|
206
|
+
def add_method(element)
|
|
207
|
+
href = element.attributes['href']
|
|
208
|
+
m = href.match(%r[(#)])
|
|
209
|
+
class_name = m.pre_match.gsub('/', '::').sub(%r[\.html$], '')
|
|
210
|
+
new_href = m.post_match
|
|
211
|
+
element.attributes['href'] = new_href
|
|
212
|
+
super(element, class_name)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def scrape
|
|
216
|
+
home_page = get_home_page(RELEASE_NAME, 'table_of_contents.html')
|
|
217
|
+
enum = home_page.lines.to_enum
|
|
218
|
+
while true do
|
|
219
|
+
begin
|
|
220
|
+
line = enum.next
|
|
221
|
+
next unless line.match(%r[<li class="(\w+)">])
|
|
222
|
+
type = $1
|
|
223
|
+
next_line = enum.peek
|
|
224
|
+
element = REXML::Document.new(next_line).root
|
|
225
|
+
case type
|
|
226
|
+
when 'file'
|
|
227
|
+
add_file(element)
|
|
228
|
+
when 'class', 'module'
|
|
229
|
+
add_class(element)
|
|
230
|
+
when 'method'
|
|
231
|
+
add_method(element)
|
|
232
|
+
else
|
|
233
|
+
raise type
|
|
234
|
+
end
|
|
235
|
+
rescue StopIteration
|
|
236
|
+
break
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
write_json(RELEASE_NAME)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class Scraper32 < Scraper
|
|
245
|
+
|
|
246
|
+
RELEASE_NAME = '3.2'
|
|
247
|
+
|
|
248
|
+
def add_method(element)
|
|
249
|
+
href = element.attributes['href']
|
|
250
|
+
m = href.match(%r[(#)])
|
|
251
|
+
class_name = m.pre_match.gsub('/', '::').sub(%r[\.html$], '')
|
|
252
|
+
new_href = m.post_match
|
|
253
|
+
element.attributes['href'] = new_href
|
|
254
|
+
super(element, class_name)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def scrape
|
|
258
|
+
home_page = get_home_page(RELEASE_NAME, 'table_of_contents.html')
|
|
259
|
+
enum = home_page.lines.to_enum
|
|
260
|
+
while true do
|
|
261
|
+
begin
|
|
262
|
+
line = enum.next
|
|
263
|
+
next unless line.match(%r[<li class="(\w+)">])
|
|
264
|
+
type = $1
|
|
265
|
+
next_line = enum.peek
|
|
266
|
+
element = REXML::Document.new(next_line).root
|
|
267
|
+
case type
|
|
268
|
+
when 'file'
|
|
269
|
+
add_file(element)
|
|
270
|
+
when 'class', 'module'
|
|
271
|
+
add_class(element)
|
|
272
|
+
when 'method'
|
|
273
|
+
add_method(element)
|
|
274
|
+
else
|
|
275
|
+
raise type
|
|
276
|
+
end
|
|
277
|
+
rescue StopIteration
|
|
278
|
+
break
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
write_json(RELEASE_NAME)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
end
|
|
285
|
+
|
data/lib/webri/version.rb
CHANGED