kat 1.0.0 → 2.0.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/.gitignore +1 -0
- data/Gemfile +1 -0
- data/README.md +45 -2
- data/Rakefile +1 -1
- data/bin/kat +2 -124
- data/lib/kat.rb +2 -247
- data/lib/kat/app.rb +276 -0
- data/lib/kat/colour.rb +77 -0
- data/lib/kat/field_map.rb +122 -0
- data/lib/kat/options.rb +59 -0
- data/lib/kat/search.rb +307 -0
- data/lib/kat/version.rb +5 -3
- data/test/kat/test_app.rb +121 -0
- data/test/kat/test_colour.rb +136 -0
- data/test/kat/test_field_map.rb +46 -0
- data/test/kat/test_options.rb +77 -0
- data/test/{kat_test.rb → kat/test_search.rb} +22 -24
- metadata +17 -4
data/lib/kat/options.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/field_map'
|
2
|
+
require File.dirname(__FILE__) + '/version'
|
3
|
+
require 'trollop'
|
4
|
+
|
5
|
+
module Kat
|
6
|
+
|
7
|
+
class << self
|
8
|
+
#
|
9
|
+
# Convenience method for the Options class
|
10
|
+
#
|
11
|
+
def options(args)
|
12
|
+
Options.parse args
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Options
|
17
|
+
|
18
|
+
class << self
|
19
|
+
#
|
20
|
+
# Pick out the invocation options from the field map
|
21
|
+
#
|
22
|
+
def options_map
|
23
|
+
fields = %i(desc type multi select short)
|
24
|
+
|
25
|
+
FIELD_MAP.inject({}) { |hash, (k, v)|
|
26
|
+
hash.tap { |h| h[k] = v.select { |f| fields.include? f } if v[:desc] }
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse(args)
|
31
|
+
Trollop::options(args) {
|
32
|
+
version VERSION_STR
|
33
|
+
banner <<-USAGE.gsub /^\s+\|/, ''
|
34
|
+
|#{ VERSION_STR }
|
35
|
+
|
|
36
|
+
|Usage: #{ File.basename __FILE__ } [options] <query>+
|
37
|
+
|
|
38
|
+
| Options:
|
39
|
+
USAGE
|
40
|
+
|
41
|
+
Options.options_map.each { |k, v|
|
42
|
+
opt k,
|
43
|
+
v[:desc],
|
44
|
+
{ type: v[:type] || :boolean,
|
45
|
+
multi: v[:multi],
|
46
|
+
short: v[:short] }
|
47
|
+
}
|
48
|
+
|
49
|
+
Options.options_map.each { |k, v|
|
50
|
+
opt v[:select],
|
51
|
+
"List the #{ v[:select] } that may be used with --#{ k }",
|
52
|
+
short: :none if v[:select]
|
53
|
+
}
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
data/lib/kat/search.rb
ADDED
@@ -0,0 +1,307 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/field_map'
|
2
|
+
require 'nokogiri'
|
3
|
+
require 'net/http'
|
4
|
+
|
5
|
+
module Kat
|
6
|
+
|
7
|
+
BASE_URL = 'http://kickass.to'
|
8
|
+
RECENT_PATH = 'new'
|
9
|
+
SEARCH_PATH = 'usearch'
|
10
|
+
ADVANCED_URL = "#{ BASE_URL }/torrents/search/advanced/"
|
11
|
+
|
12
|
+
class << self
|
13
|
+
#
|
14
|
+
# Convenience methods for the Search class
|
15
|
+
#
|
16
|
+
def search(search_term = nil, opts = {})
|
17
|
+
Search.new search_term, opts
|
18
|
+
end
|
19
|
+
|
20
|
+
def quick_search(search_term = nil)
|
21
|
+
Search.quick_search search_term
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Search
|
26
|
+
# The number of pages of results
|
27
|
+
attr_reader :pages
|
28
|
+
|
29
|
+
# Any error in searching is stored here
|
30
|
+
attr_reader :error
|
31
|
+
|
32
|
+
@@doc = nil
|
33
|
+
|
34
|
+
class << self
|
35
|
+
|
36
|
+
#
|
37
|
+
# Kat.quick_search will do a quick search and return the results
|
38
|
+
#
|
39
|
+
def quick_search(search_term = nil)
|
40
|
+
new(search_term).search
|
41
|
+
end
|
42
|
+
|
43
|
+
def field_map(type = nil)
|
44
|
+
return FIELD_MAP.dup unless type
|
45
|
+
|
46
|
+
FIELD_MAP.inject({}) { |hash, (k, v)|
|
47
|
+
hash.tap { |h|
|
48
|
+
case type
|
49
|
+
when :select then h[k] = { select: v[:select], id: v[:id] || k }
|
50
|
+
when :sort then h[k] = v[:sort] and h[v[:sort]] = v[:sort]
|
51
|
+
h[v[:id]] = v[:sort] if v[:id]
|
52
|
+
else h[k] = v[type]
|
53
|
+
end if v[type]
|
54
|
+
}
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
def checks; field_map :check end
|
59
|
+
def inputs; field_map :input end
|
60
|
+
def selects; field_map :select end
|
61
|
+
def sorts; field_map :sort end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
#
|
66
|
+
# Get a list of options for a particular selection field from the advanced search form
|
67
|
+
#
|
68
|
+
def field_options(label)
|
69
|
+
fail 'Unknown search field' unless selects.find { |k, v| k == label.intern }
|
70
|
+
|
71
|
+
url = URI(ADVANCED_URL)
|
72
|
+
|
73
|
+
@@doc ||= Nokogiri::HTML(Net::HTTP.start(url.host) { |http| http.get url }.body)
|
74
|
+
|
75
|
+
opts = @@doc.css('table.formtable td').find { |e|
|
76
|
+
e.text[/#{ label }/i]
|
77
|
+
}.next_element.first_element_child.children
|
78
|
+
|
79
|
+
unless (group = opts.css('optgroup')).empty?
|
80
|
+
# Categories
|
81
|
+
group.inject({}) { |cat, og|
|
82
|
+
cat.tap { |c|
|
83
|
+
c[og.attributes['label'].value] = og.children.map { |o|
|
84
|
+
o.attributes['value'].value
|
85
|
+
}
|
86
|
+
}
|
87
|
+
}
|
88
|
+
else
|
89
|
+
# Times, languages, platforms
|
90
|
+
opts.reject { |o| o.attributes.empty? }.inject({}) { |p, o|
|
91
|
+
p.tap { |p| p[o.text] = o.attributes['value'].value }
|
92
|
+
}
|
93
|
+
end
|
94
|
+
rescue => e
|
95
|
+
{ error: e }
|
96
|
+
end
|
97
|
+
|
98
|
+
#
|
99
|
+
# If method is a field name in SELECT_FIELDS, fetch the list of values.
|
100
|
+
#
|
101
|
+
def method_missing(method, *args, &block)
|
102
|
+
return super unless respond_to? method
|
103
|
+
field_options selects.find { |k, v| v[:select] == method }.first
|
104
|
+
end
|
105
|
+
|
106
|
+
#
|
107
|
+
# If method is a field name in SELECT_FIELDS, we can fetch the list of values
|
108
|
+
#
|
109
|
+
def respond_to_missing?(method, include_private = false)
|
110
|
+
!!selects.find { |k, v| v[:select] == method } || super
|
111
|
+
end
|
112
|
+
|
113
|
+
end # class methods
|
114
|
+
|
115
|
+
#
|
116
|
+
# Create a new +Kat::Search+ object to search Kickass Torrents.
|
117
|
+
# The search_term can be nil, a string/symbol, or an array of strings/symbols.
|
118
|
+
# Valid options are in STRING_FIELDS, SELECT_FIELDS or SWITCH_FIELDS.
|
119
|
+
#
|
120
|
+
def initialize(search_term = nil, opts = {})
|
121
|
+
@search_term = []
|
122
|
+
@options = {}
|
123
|
+
@error = nil
|
124
|
+
|
125
|
+
self.query = search_term
|
126
|
+
self.options = opts
|
127
|
+
end
|
128
|
+
|
129
|
+
#
|
130
|
+
# Generate a query string from the stored options, supplying an optional page number
|
131
|
+
#
|
132
|
+
def query_str(page = 0)
|
133
|
+
str = [SEARCH_PATH, @query.join(' ').gsub(/[^a-z0-9: _-]/i, '')]
|
134
|
+
str = [RECENT_PATH] if str[1].empty?
|
135
|
+
str << page + 1 if page > 0
|
136
|
+
|
137
|
+
sorts.find { |k, v| @options[:sort] && k == @options[:sort].intern }.tap { |k, v|
|
138
|
+
str << (k ? "?field=#{ v }&sorder=#{ options[:asc] ? 'asc' : 'desc' }" : '')
|
139
|
+
}
|
140
|
+
|
141
|
+
str.join '/'
|
142
|
+
end
|
143
|
+
|
144
|
+
#
|
145
|
+
# Change the search term, triggering a query rebuild and clearing past results.
|
146
|
+
#
|
147
|
+
# Raises ArgumentError if search_term is not a String, Symbol or Array
|
148
|
+
#
|
149
|
+
def query=(search_term)
|
150
|
+
@search_term = case search_term
|
151
|
+
when nil then []
|
152
|
+
when String, Symbol then [search_term]
|
153
|
+
when Array then search_term.flatten.select { |e| [String, Symbol].include? e.class }
|
154
|
+
else fail ArgumentError, "search_term must be a String, Symbol or Array. " <<
|
155
|
+
"#{ search_term.inspect } given."
|
156
|
+
end
|
157
|
+
|
158
|
+
build_query
|
159
|
+
end
|
160
|
+
|
161
|
+
#
|
162
|
+
# Get a copy of the search options hash
|
163
|
+
#
|
164
|
+
def options
|
165
|
+
Marshal.load(Marshal.dump(@options))
|
166
|
+
end
|
167
|
+
|
168
|
+
#
|
169
|
+
# Change search options with a hash, triggering a query string rebuild and
|
170
|
+
# clearing past results.
|
171
|
+
#
|
172
|
+
# Raises ArgumentError if options is not a Hash
|
173
|
+
#
|
174
|
+
def options=(options)
|
175
|
+
fail ArgumentError, "options must be a Hash. " <<
|
176
|
+
"#{ options.inspect } given." unless Hash === options
|
177
|
+
|
178
|
+
@options.merge! options
|
179
|
+
|
180
|
+
build_query
|
181
|
+
end
|
182
|
+
|
183
|
+
#
|
184
|
+
# Perform the search, supplying an optional page number to search on. Returns
|
185
|
+
# a result set limited to the 25 results Kickass Torrents returns itself. Will
|
186
|
+
# cache results for subsequent calls of search with the same query string.
|
187
|
+
#
|
188
|
+
def search(page = 0)
|
189
|
+
@error = nil
|
190
|
+
|
191
|
+
search_proc = -> page {
|
192
|
+
begin
|
193
|
+
uri = URI(URI::encode(to_s page))
|
194
|
+
res = Net::HTTP.start(uri.host) { |http| http.get uri }
|
195
|
+
@pages = 0 and return if res.code == '404'
|
196
|
+
|
197
|
+
doc = Nokogiri::HTML(res.body)
|
198
|
+
|
199
|
+
@results[page] = doc.css('td.torrentnameCell').map { |node|
|
200
|
+
{ path: node.css('a.normalgrey').first.attributes['href'].value,
|
201
|
+
title: node.css('a.normalgrey').text,
|
202
|
+
magnet: node.css('a.imagnet').first.attributes['href'].value,
|
203
|
+
download: node.css('a.idownload').last.attributes['href'].value,
|
204
|
+
size: (node = node.next_element).text,
|
205
|
+
files: (node = node.next_element).text.to_i,
|
206
|
+
age: (node = node.next_element).text,
|
207
|
+
seeds: (node = node.next_element).text.to_i,
|
208
|
+
leeches: (node = node.next_element).text.to_i }
|
209
|
+
}
|
210
|
+
|
211
|
+
# If we haven't previously performed a search with this query string, get the
|
212
|
+
# number of pages from the pagination bar at the bottom of the results page.
|
213
|
+
# If there's no pagination bar there's only 1 page of results.
|
214
|
+
if @pages == -1
|
215
|
+
p = doc.css('div.pages > a').last
|
216
|
+
@pages = p ? [1, p.text.to_i].max : 1
|
217
|
+
end
|
218
|
+
rescue => e
|
219
|
+
@error = { error: e }
|
220
|
+
end unless @results[page] || (@pages > -1 && page >= @pages)
|
221
|
+
}
|
222
|
+
|
223
|
+
# Make sure we do a query for the first page of results before getting
|
224
|
+
# subsequent pages in order to correctly figure out the total number of
|
225
|
+
# pages of results.
|
226
|
+
search_proc.call 0 if @pages == -1
|
227
|
+
search_proc.call page
|
228
|
+
|
229
|
+
results[page]
|
230
|
+
end
|
231
|
+
|
232
|
+
#
|
233
|
+
# For message chaining
|
234
|
+
#
|
235
|
+
def go(page = 0)
|
236
|
+
search page
|
237
|
+
self
|
238
|
+
end
|
239
|
+
|
240
|
+
# Was called do_search in v1, keeping it for compatibility
|
241
|
+
alias_method :do_search, :go
|
242
|
+
|
243
|
+
#
|
244
|
+
# Get a copy of the results
|
245
|
+
#
|
246
|
+
def results
|
247
|
+
Marshal.load(Marshal.dump(@results))
|
248
|
+
end
|
249
|
+
|
250
|
+
def checks; Search.checks end
|
251
|
+
def inputs; Search.inputs end
|
252
|
+
def selects; Search.selects end
|
253
|
+
def sorts; Search.sorts end
|
254
|
+
|
255
|
+
#
|
256
|
+
# Use the search url as the string representation of the object
|
257
|
+
#
|
258
|
+
def to_s(page = 0)
|
259
|
+
"#{ BASE_URL }/#{ query_str page }"
|
260
|
+
end
|
261
|
+
|
262
|
+
private
|
263
|
+
|
264
|
+
#
|
265
|
+
# Clear out the query and rebuild it from the various stored options. Also clears out the
|
266
|
+
# results set and sets pages back to -1
|
267
|
+
#
|
268
|
+
def build_query
|
269
|
+
@query = @search_term.dup
|
270
|
+
@pages = -1
|
271
|
+
@results = []
|
272
|
+
|
273
|
+
@query << "\"#{ @options[:exact] }\"" if @options[:exact]
|
274
|
+
@query << @options[:or].join(' OR ') unless @options[:or].nil? or @options[:or].empty?
|
275
|
+
@query += @options[:without].map { |s| "-#{ s }" } if @options[:without]
|
276
|
+
|
277
|
+
@query += inputs.select { |k, v| @options[k] }.map { |k, v| "#{ k }:#{ @options[k] }" }
|
278
|
+
@query += checks.select { |k, v| @options[k] }.map { |k, v| "#{ k }:1" }
|
279
|
+
@query += selects.select { |k, v|
|
280
|
+
(v[:id].to_s[/^.*_id$/] && @options[k].to_s.to_i > 0) ||
|
281
|
+
(v[:id].to_s[/^[^_]+$/] && @options[k])
|
282
|
+
}.map { |k, v| "#{ v[:id] }:#{ @options[k] }" }
|
283
|
+
end
|
284
|
+
|
285
|
+
#
|
286
|
+
# Fetch a list of values from the results set given by name
|
287
|
+
#
|
288
|
+
def results_column(name)
|
289
|
+
@results.compact.map { |rs| rs.map { |r| r[name] || r[name[0...-1].intern] } }.flatten
|
290
|
+
end
|
291
|
+
|
292
|
+
#
|
293
|
+
# If method or its plural form is a field name in the results list, fetch the list of values.
|
294
|
+
# Can only happen after a successful search.
|
295
|
+
#
|
296
|
+
def method_missing(method, *args, &block)
|
297
|
+
respond_to?(method) ? results_column(method) : super
|
298
|
+
end
|
299
|
+
|
300
|
+
def respond_to_missing?(method, include_private)
|
301
|
+
!(@results.empty? || @results.first.empty?) &&
|
302
|
+
(@results.first.first[method] || @results.first.first[method[0..-2].intern]) || super
|
303
|
+
end
|
304
|
+
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
data/lib/kat/version.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
|
-
|
1
|
+
module Kat
|
2
2
|
NAME = 'Kickass Torrents Search'
|
3
|
-
VERSION = '
|
4
|
-
|
3
|
+
VERSION = '2.0.0'
|
4
|
+
MALEVOLENT_DICTATOR_FOR_LIFE = 'Fission Xuiptz'
|
5
|
+
AUTHOR = MALEVOLENT_DICTATOR_FOR_LIFE
|
6
|
+
VERSION_STR = "#{NAME} #{VERSION} (c) 2013 #{MALEVOLENT_DICTATOR_FOR_LIFE}"
|
5
7
|
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require File.dirname(__FILE__) + '/../../lib/kat/app'
|
3
|
+
|
4
|
+
app = Kat::App.new %w(predator -c movies -o .)
|
5
|
+
app.kat.go(1).go(app.kat.pages - 1)
|
6
|
+
|
7
|
+
describe Kat::App do
|
8
|
+
describe 'app' do
|
9
|
+
it 'initialises options' do
|
10
|
+
app.kat.must_be_instance_of Kat::Search
|
11
|
+
app.options.must_be_instance_of Hash
|
12
|
+
app.options[:category].must_equal 'movies'
|
13
|
+
app.options[:category_given].must_equal true
|
14
|
+
end
|
15
|
+
|
16
|
+
it 're-initialises options' do
|
17
|
+
k = Kat::App.new %w(predator)
|
18
|
+
k.init_options %w(bible -c books)
|
19
|
+
k.options.must_be_instance_of Hash
|
20
|
+
k.options[:category].must_equal 'books'
|
21
|
+
k.options[:category_given].must_equal true
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'creates a validation regex' do
|
25
|
+
app.page.must_equal 0
|
26
|
+
app.instance_exec {
|
27
|
+
@window_width = 80
|
28
|
+
|
29
|
+
prev?.wont_equal true
|
30
|
+
next?.must_equal true
|
31
|
+
validation_regex.must_equal(/^([inq]|[1-9]|1[0-9]|2[0-5])$/)
|
32
|
+
|
33
|
+
@window_width = 81
|
34
|
+
@page = 1
|
35
|
+
|
36
|
+
prev?.must_equal true
|
37
|
+
next?.must_equal true
|
38
|
+
validation_regex.must_equal(/^([npq]|[1-9]|1[0-9]|2[0-5])$/)
|
39
|
+
|
40
|
+
@page = kat.pages - 1
|
41
|
+
n = kat.results[@page].size
|
42
|
+
|
43
|
+
prev?.must_equal true
|
44
|
+
next?.wont_equal true
|
45
|
+
validation_regex.must_equal(
|
46
|
+
/^([pq]|[1-#{ [9, n].min }]#{
|
47
|
+
"|1[0-#{ [9, n - 10].min }]" if n > 9
|
48
|
+
}#{ "|2[0-#{ n - 20 }]" if n > 19 })$/
|
49
|
+
)
|
50
|
+
|
51
|
+
@page = 0
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'deals with terminal width' do
|
56
|
+
app.instance_exec {
|
57
|
+
set_window_width
|
58
|
+
hide_info?.must_equal (@window_width < 81)
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'formats a list of options' do
|
63
|
+
app.instance_exec {
|
64
|
+
%i(category added platform language).each { |s|
|
65
|
+
list = format_lists(s => Kat::Search.selects[s])
|
66
|
+
|
67
|
+
list.must_be_instance_of Array
|
68
|
+
list.wont_be_empty
|
69
|
+
|
70
|
+
[0, 2, list.size - 1].each { |i| list[i].must_be_nil }
|
71
|
+
|
72
|
+
list[1].must_equal case s
|
73
|
+
when :added then 'Times'
|
74
|
+
when :category then 'Categories'
|
75
|
+
else s.to_s.capitalize << 's'
|
76
|
+
end
|
77
|
+
|
78
|
+
3.upto(list.size - 2) { |i| list[i].must_be_instance_of String } unless s == :category
|
79
|
+
3.upto(list.size - 2) { |i| list[i].must_match(/^\s*([A-Z]+ => )?[a-z0-9-]+/) if list[i] } if s == :category
|
80
|
+
}
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'formats a list of torrents' do
|
85
|
+
Kat::Colour.colour = false
|
86
|
+
app.instance_exec {
|
87
|
+
set_window_width
|
88
|
+
list = format_results
|
89
|
+
|
90
|
+
list.must_be_instance_of Array
|
91
|
+
list.wont_be_empty
|
92
|
+
|
93
|
+
list.size.must_equal kat.results[0].size + 3
|
94
|
+
2.upto(list.size - 2) { |i|
|
95
|
+
list[i].must_match /^(\s[1-9]|[12][0-9])\. .*/
|
96
|
+
}
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'downloads data from a URL' do
|
101
|
+
Kat::Colour.colour = false
|
102
|
+
app.instance_exec {
|
103
|
+
s = 'foobar'
|
104
|
+
result = download({ download: 'http://google.com', title: s })
|
105
|
+
result.must_equal :done
|
106
|
+
File.exists?(File.expand_path "./#{ s }.torrent").must_equal true
|
107
|
+
File.delete(File.expand_path "./#{ s }.torrent")
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
it 'returns an error message when a download fails' do
|
112
|
+
Kat::Colour.colour = false
|
113
|
+
app.instance_exec {
|
114
|
+
result = download({ download: 'http://foo.bar', title: 'foobar' })
|
115
|
+
result.must_be_instance_of Array
|
116
|
+
result.first.must_equal :failed
|
117
|
+
result.last.must_match /^getaddrinfo/
|
118
|
+
}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|