SnoobyPlus 0.1

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.
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
+