SnoobyPlus 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/snooby.rb ADDED
@@ -0,0 +1,178 @@
1
+ %w[json net/http/persistent].each { |dep| require dep }
2
+
3
+ CONFIG_FILE = '.snooby/config.json'
4
+
5
+ # Creates and initializes configuration and caching on a per-directory basis.
6
+ # Doesn't update if they already exist, but this might need to be fleshed out
7
+ # a bit in future to merge values to be kept with new defaults in upgrades.
8
+ unless File.exists? CONFIG_FILE
9
+ DEFAULT_CONFIG = File.join Gem.datadir('snooby'), 'config.json'
10
+ %w[.snooby .snooby/cache].each { |dir| Dir.mkdir dir }
11
+ File.open(CONFIG_FILE, 'w') { |file| file << File.read(DEFAULT_CONFIG) }
12
+ end
13
+
14
+ # reddit API (Snoo) + happy programming (Ruby) = Snooby
15
+ module Snooby
16
+
17
+ class << self
18
+ attr_accessor :config, :active
19
+ end
20
+
21
+ # Raised with a pretty print of the relevant JSON object whenever an API call
22
+ # returns a non-empty "errors" array, typically in cases of rate limiting and
23
+ # missing or insufficient authorization. Also used as a general container for
24
+ # miscellaneous errors related to site functionality.
25
+ class RedditError < StandardError
26
+ end
27
+
28
+ # Changes to configuration should persist across multiple uses. This class is
29
+ # a simple modification to the standard hash's setter that updates the config
30
+ # file whenever a value changes.
31
+ class Config < Hash
32
+ def []=(key, value)
33
+ super
34
+ return unless Snooby.config # nil during initialization inside JSON#parse
35
+ File.open(CONFIG_FILE, 'w') do |file|
36
+ file << JSON.pretty_generate(Snooby.config)
37
+ end
38
+ end
39
+ end
40
+
41
+ @config = JSON.parse(File.read(CONFIG_FILE), object_class: Config)
42
+ raise RedditError, 'Insufficiently patient delay.' if @config['delay'] < 2
43
+
44
+ # Opens a persistent connection that provides a significant speed improvement
45
+ # during repeated calls; reddit's two-second rule pretty much nullifies this,
46
+ # but it's still a great library and persistent connections are a Good Thing.
47
+ Conn = Net::HTTP::Persistent.new 'Snooby'
48
+
49
+ paths = {
50
+ :comment => 'api/comment',
51
+ :compose => 'api/compose',
52
+ :delete => 'api/del',
53
+ :disliked => 'user/%s/disliked.json',
54
+ :domain_posts => 'domain/%s.json',
55
+ :friend => 'api/friend',
56
+ :hidden => 'user/%s/hidden.json',
57
+ :hide => 'api/hide',
58
+ :liked => 'user/%s/liked.json',
59
+ :login => 'api/login/%s',
60
+ :mark => 'api/marknsfw',
61
+ :me => 'api/me.json',
62
+ :post_comments => 'comments/%s.json',
63
+ :reddit => '.json',
64
+ :save => 'api/save',
65
+ :saved => 'saved.json',
66
+ :submit => 'api/submit',
67
+ :subreddit_about => 'r/%s/about.json',
68
+ :subreddit_comments => 'r/%s/comments.json',
69
+ :subreddit_posts => 'r/%s.json',
70
+ # Snooby-Plus adds Top/Controversial/New grabbing to Snooby
71
+ :subreddit_top => 'r/%s/top.json',
72
+ :subreddit_new => 'r/%s/new.json',
73
+ :subreddit_rising => 'r/%s/rising.json',
74
+ :subreddit_contro => 'r/%s/controversial.json',
75
+ # End Snooby-Plus additions
76
+ :subscribe => 'api/subscribe',
77
+ :unfriend => 'api/unfriend',
78
+ :unhide => 'api/unhide',
79
+ :unmark => 'api/unmarknsfw',
80
+ :unsave => 'api/unsave',
81
+ :user => 'user/%s',
82
+ :user_about => 'user/%s/about.json',
83
+ :user_comments => 'user/%s/comments.json',
84
+ :user_posts => 'user/%s/submitted.json',
85
+ :vote => 'api/vote',
86
+ }
87
+
88
+ # Provides a mapping of things and actions to their respective URL fragments.
89
+ Paths = paths.merge(paths) { |act, path| "http://www.reddit.com/#{path}" }
90
+
91
+ # Provides a mapping of things to a list of all the attributes present in the
92
+ # relevant JSON object. A lot of these probably won't get used too often, but
93
+ # might as well grab all available data (except body_html and selftext_html).
94
+ Fields = {
95
+ :comment => %w[author author_flair_css_class author_flair_text body created created_utc downs id likes link_id link_title name parent_id replies subreddit subreddit_id ups],
96
+ :post => %w[author author_flair_css_class author_flair_text clicked created created_utc domain downs hidden id is_self likes media media_embed name num_comments over_18 permalink saved score selftext subreddit subreddit_id thumbnail title ups url]
97
+ }
98
+
99
+ # Wraps the connection for all requests to ensure that the two-second rule is
100
+ # adhered to. The uri parameter comes in as a string because it may have been
101
+ # susceptible to variables, so it gets turned into an actual URI here instead
102
+ # of all over the place. Since it's required for all POST requests other than
103
+ # logging in, the client's modhash is sent along by default.
104
+ def self.request(uri, data = nil)
105
+ uri = URI uri
106
+ if data && data != 'html'
107
+ unless active.uh || data[:passwd]
108
+ raise RedditError, 'You must be logged in to make POST requests.'
109
+ end
110
+ post = Net::HTTP::Post.new uri.path
111
+ post.set_form_data data.merge!(:uh => active.uh, :api_type => 'json')
112
+ end
113
+ wait if @last_request && Time.now - @last_request < @config['delay']
114
+ @last_request = Time.now
115
+
116
+ resp = Conn.request uri, post
117
+ raise ArgumentError, resp.code_type unless resp.code == '200'
118
+
119
+ # Raw HTML is parsed to obtain the user's trophy data and karma breakdown.
120
+ return resp.body if data == 'html'
121
+
122
+ json = JSON.parse resp.body, :max_nesting => 100
123
+ if (resp = json['json']) && (errors = resp['errors'])
124
+ raise RedditError, jj(json) unless errors.empty?
125
+ end
126
+ json
127
+ end
128
+
129
+ # The crux of Snooby. Generates an array of structs from the Paths and Fields
130
+ # hashes defined above. In addition to just being a very neat container, this
131
+ # allows accessing the returned JSON values using thing.attribute, as opposed
132
+ # to thing['data']['attribute']. Only used for listings of posts and comments
133
+ # at the moment, but I imagine it'll be used for moderation down the road.
134
+ # Having to explicitly pass the path isn't very DRY, but deriving it from the
135
+ # object (say, Snooby::Comment) doesn't expose which kind of comment it is.
136
+ def self.build(object, path, which, count)
137
+ # A bit of string manipulation to determine which fields to populate the
138
+ # generated structs with. There might be a less fragile way to go about it,
139
+ # but it shouldn't be a problem as long as naming remains consistent.
140
+ kind = object.to_s.split('::')[1].downcase.to_sym
141
+
142
+ # Set limit to the maximum of 100 if we're grabbing more than that, give
143
+ # after a truthy value since we stop when it's no longer so, and initialize
144
+ # an empty result set that the generated structs will be pushed into.
145
+ limit, after, results = [count, 100].min, '', []
146
+
147
+ while results.size < count && after
148
+ uri = Paths[path] % which + "?limit=#{limit}&after=#{after}"
149
+ json = request uri
150
+ json = json[1] if path == :post_comments # skip over the post's data
151
+ json['data']['children'].each do |child|
152
+ # Converts each child's JSON data into the relevant struct based on the
153
+ # kind of object being built. The symbols of a struct definition are
154
+ # ordered, but Python dictionaries are not, so #values is insufficient.
155
+ # Preliminary testing showed that appending one at a time is slightly
156
+ # faster than concatenating the entire page of results and then taking
157
+ # a slice at the end. This also allows for premature stopping if the
158
+ # count is reached before all results have been processed.
159
+ results << object.new(*child['data'].values_at(*Fields[kind]))
160
+ return results if results.size == count
161
+ end
162
+ after = json['data']['after']
163
+ end
164
+ results
165
+ end
166
+
167
+ # Called whenever respecting the API is required.
168
+ def self.wait
169
+ sleep @config['delay']
170
+ end
171
+ end
172
+
173
+ # Snooby's parts are required down here, after its initial declaration, because
174
+ # Post and Comment are structs whose definitions are taken from the Fields hash
175
+ # and related bits might as well be kept together.
176
+ %w[client actions user subreddit domain post comment].each do |dep|
177
+ require "snooby/#{dep}"
178
+ end
@@ -0,0 +1,73 @@
1
+ module Snooby
2
+
3
+ module About
4
+ # Returns a hash containing the calling object's about.json data.
5
+ def about
6
+ Snooby.request(Paths[:"#{@kind}_about"] % @name)['data']
7
+ end
8
+ end
9
+
10
+ module Posts
11
+ # Returns an array of structs containing the calling object's posts.
12
+ # Snooby-Plus adds the `srlist' parameter. This can be any one of `:top', `:new', `:rising', or `:contro'.
13
+ def posts(count = 25, srlist = :posts)
14
+ path = @name ? :"#{@kind}_#{@kind == :subreddit ? srlist : :posts}" : :reddit
15
+ Snooby.build Post, path, @name, count
16
+ end
17
+ alias :submissions :posts
18
+ end
19
+
20
+ module Comments
21
+ # Returns an array of structs containing the calling object's comments.
22
+ # TODO: return more than just top-level comments for posts.
23
+ def comments(count = @kind == 'post' ? 500 : 25)
24
+ # @name suffices for users and subreddits, but a post's name is obtained
25
+ # from its struct; the "t3_" must be removed before making the API call.
26
+ @name ||= self.name[3..-1]
27
+ Snooby.build Comment, :"#{@kind}_comments", @name, count
28
+ end
29
+ end
30
+
31
+ module Reply
32
+ # Posts a reply to the calling object, which is either a post or a comment.
33
+ def reply(text)
34
+ Snooby.request Paths[:comment], :parent => self.name, :text => text
35
+ end
36
+ end
37
+
38
+ module Delete
39
+ # Deletes the calling object, which is either a post or a comment.
40
+ def delete
41
+ Snooby.request Paths[:delete], :id => self.name
42
+ end
43
+ end
44
+
45
+ module Compose
46
+ # Sends a message to the calling object, which is either a subreddit or a
47
+ # user; in the case of the former, this behaves like moderator mail.
48
+ def compose(subject, text)
49
+ to = (@kind == 'user' ? '' : '#') + @name
50
+ data = {:to => to, :subject => subject, :text => text}
51
+ Snooby.request Paths[:compose], data
52
+ end
53
+ alias :message :compose
54
+ end
55
+
56
+ module Voting
57
+ def vote(dir)
58
+ Snooby.request Paths[:vote], :id => self.name, :dir => dir
59
+ end
60
+
61
+ def upvote
62
+ vote 1
63
+ end
64
+
65
+ def rescind
66
+ vote 0
67
+ end
68
+
69
+ def downvote
70
+ vote -1
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,105 @@
1
+ module Snooby
2
+
3
+ # Interface through which Snooby does all of its interacting with the API.
4
+ class Client
5
+ attr_reader :uh, :id
6
+
7
+ def initialize(user_agent = "Snooby, #{rand}")
8
+ # Net::HTTP's default User-Agent, "Ruby", is banned on reddit due to its
9
+ # frequent improper use; cheers to Eric Hodel (drbrain) for implementing
10
+ # this workaround for net-http-persistent.
11
+ Conn.override_headers['User-Agent'] = user_agent
12
+
13
+ # Allows Snooby's classes to access the currently active client, even if
14
+ # it has not been authorized, allowing the user to log in mid-execution.
15
+ Snooby.active = self
16
+ end
17
+
18
+ # Causes the client to be recognized as the given user during API calls.
19
+ # GET operations do not need to be authorized, so if your intent is simply
20
+ # to gather data, feel free to disregard this method entirely.
21
+ def authorize!(user, passwd, force_update = false)
22
+ if Snooby.config['auth'][user] && !force_update
23
+ # Authorization data exists, skip login and potential rate-limiting.
24
+ @uh, @cookie, @id = Snooby.config['auth'][user]
25
+ else
26
+ data = {:user => user, :passwd => passwd}
27
+ json = Snooby.request(Paths[:login] % user, data)['json']
28
+ @uh, @cookie = json['data'].values
29
+ end
30
+
31
+ # Sets the reddit_session cookie required for API calls to be recognized
32
+ # as coming from the intended user. Uses override_headers to allow for
33
+ # switching the current user mid-execution, if so desired.
34
+ Conn.override_headers['Cookie'] = "reddit_session=#{@cookie}"
35
+
36
+ # A second call is made, if required, to grab the client's id, which is
37
+ # necessary for (un)friending.
38
+ @id ||= "t2_#{me['id']}"
39
+
40
+ # Updates the config file to faciliate one-time authorization. This works
41
+ # because the authorization data is immortal unless the password has been
42
+ # changed; enable the force_update parameter if such is the case.
43
+ Snooby.config['auth'][user] = [@uh, @cookie, @id]
44
+ end
45
+
46
+ # Returns a User object through which all relevant data is accessed.
47
+ def user(name)
48
+ User.new name
49
+ end
50
+
51
+ # Returns a Subreddit object through which all relevant data is accessed.
52
+ # Supplying no name provides access to the client's front page.
53
+ def subreddit(name = nil)
54
+ Subreddit.new name
55
+ end
56
+
57
+ def domain(name)
58
+ Domain.new name
59
+ end
60
+
61
+ # Returns a hash containing the values given by me.json, used internally to
62
+ # obtain the client's id, but also the most efficient way to check whether
63
+ # or not the client has mail.
64
+ def me
65
+ Snooby.request(Paths[:me])['data']
66
+ end
67
+
68
+ # Returns an array of structs containing the current client's saved posts.
69
+ def saved(count = 25)
70
+ Snooby.build Post, :saved, nil, count
71
+ end
72
+
73
+ def submit(name, title, content)
74
+ Subreddit.new(name).submit title, content
75
+ end
76
+
77
+ def subscribe(name)
78
+ Subreddit.new(name).subscribe
79
+ end
80
+
81
+ def unsubscribe(name)
82
+ Subreddit.new(name).unsubscribe
83
+ end
84
+
85
+ def friend(name)
86
+ User.new(name).friend
87
+ end
88
+
89
+ def unfriend(name)
90
+ User.new(name).unfriend
91
+ end
92
+
93
+ def compose(to, subject, text)
94
+ data = {:to => to, :subject => subject, :text => text}
95
+ Snooby.request Paths[:compose], data
96
+ end
97
+
98
+ # Aliases all in one place for purely aesthetic reasons.
99
+ alias :u :user
100
+ alias :r :subreddit
101
+ alias :sub :subscribe
102
+ alias :unsub :unsubscribe
103
+ alias :message :compose
104
+ end
105
+ end
@@ -0,0 +1,11 @@
1
+ module Snooby
2
+
3
+ class Comment < Struct.new(*Fields[:comment].map(&:to_sym))
4
+ include Reply, Delete, Voting
5
+
6
+ def initialize(*)
7
+ super
8
+ @kind = 'comment'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Snooby
2
+
3
+ class Domain
4
+ include Posts
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ @kind = 'domain'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,35 @@
1
+ module Snooby
2
+
3
+ class Post < Struct.new(*Fields[:post].map(&:to_sym))
4
+ include Comments, Reply, Delete, Voting
5
+
6
+ def initialize(*)
7
+ super
8
+ @kind = 'post'
9
+ end
10
+
11
+ def save(un = '')
12
+ Snooby.request Paths[:"#{un}save"], :id => self.name
13
+ end
14
+
15
+ def unsave
16
+ save 'un'
17
+ end
18
+
19
+ def hide(un = '')
20
+ Snooby.request Paths[:"#{un}hide"], :id => self.name
21
+ end
22
+
23
+ def unhide
24
+ hide 'un'
25
+ end
26
+
27
+ def mark(un = '')
28
+ Snooby.request Paths[:"#{un}mark"], :id => self.name
29
+ end
30
+
31
+ def unmark
32
+ mark 'un'
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,48 @@
1
+ module Snooby
2
+
3
+ class User
4
+ include About, Posts, Comments, Compose
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ @kind = 'user'
9
+ end
10
+
11
+ # Returns an array of arrays containing the user's trophy information in
12
+ # the form of [name, description], the latter containing the empty string
13
+ # if inapplicable.
14
+ def trophies
15
+ # Only interested in trophies; request the minimum amount of content.
16
+ html = Snooby.request(Paths[:user] % @name + '?limit=1', 'html')
17
+ # Entry-level black magic.
18
+ html.scan /"trophy-name">(.+?)<.+?"\s?>([^<]*)</
19
+ end
20
+
21
+ def karma_breakdown
22
+ html = Snooby.request(Paths[:user] % @name + '?limit=1', 'html')
23
+ rx = /h>(.+?)<.+?(\d+).+?(\d+)/
24
+ Hash[html.split('y>')[2].scan(rx).map { |r| [r.shift, r.map(&:to_i)] }]
25
+ end
26
+
27
+ def liked(count = 25)
28
+ Snooby.build Post, :liked, @name, count
29
+ end
30
+
31
+ def disliked(count = 25)
32
+ Snooby.build Post, :disliked, @name, count
33
+ end
34
+
35
+ def hidden(count = 25)
36
+ Snooby.build Post, :hidden, @name, count
37
+ end
38
+
39
+ def friend(un = '')
40
+ data = {:name => @name, :type => 'friend', :container => Snooby.active.id}
41
+ Snooby.request Paths[:"#{un}friend"], data
42
+ end
43
+
44
+ def unfriend
45
+ friend 'un'
46
+ end
47
+ end
48
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: SnoobyPlus
3
+ version: !ruby/object:Gem::Version
4
+ hash: 9
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 1
9
+ version: "0.1"
10
+ platform: ruby
11
+ authors:
12
+ - King Bowser
13
+ - andkerosine
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2013-08-05 00:00:00 Z
19
+ dependencies: []
20
+
21
+ description: All the features of Snooby, along with TopRisingNewControversial list grabbing.
22
+ email: backseat.rapist@gmail.com
23
+ executables: []
24
+
25
+ extensions: []
26
+
27
+ extra_rdoc_files: []
28
+
29
+ files:
30
+ - lib/snooby.rb
31
+ - lib/snooby/actions.rb
32
+ - lib/snooby/client.rb
33
+ - lib/snooby/comment.rb
34
+ - lib/snooby/domain.rb
35
+ - lib/snooby/post.rb
36
+ - lib/snooby/user.rb
37
+ homepage: https://github.com/KingBowser/snooby
38
+ licenses: []
39
+
40
+ post_install_message:
41
+ rdoc_options: []
42
+
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ none: false
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ hash: 3
51
+ segments:
52
+ - 0
53
+ version: "0"
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 3
60
+ segments:
61
+ - 0
62
+ version: "0"
63
+ requirements: []
64
+
65
+ rubyforge_project:
66
+ rubygems_version: 1.8.15
67
+ signing_key:
68
+ specification_version: 3
69
+ summary: All the features of Snooby, along with TopRisingNewControversial list grabbing.
70
+ test_files: []
71
+