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.
@@ -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
@@ -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
@@ -1,5 +1,7 @@
1
- class Kat
1
+ module Kat
2
2
  NAME = 'Kickass Torrents Search'
3
- VERSION = '1.0.0'
4
- AUTHOR = 'Fission Xuiptz'
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