snooby 0.0.1 → 0.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.
data/Gemfile CHANGED
@@ -1,4 +1,2 @@
1
1
  source "http://rubygems.org"
2
-
3
- # Specify your gem's dependencies in snooby.gemspec
4
- gemspec
2
+ gemspec
@@ -0,0 +1,42 @@
1
+ # What is Snooby?
2
+ Snooby is a wrapper around the reddit API written in Ruby. It aims to make automating any part of the reddit experience as simple and clear-cut as possible, while still providing ample functionality.
3
+
4
+ ## Install
5
+ gem install snooby
6
+
7
+ ## Example
8
+
9
+ Here's one way you might go about implementing a very simple bot that constantly monitors new comments to scold users of crass language.
10
+
11
+ ```ruby
12
+ require 'snooby'
13
+
14
+ probot = Snooby::Client.new('ProfanityBot, v1.0')
15
+ probot.authorize!('ProfanityBot', 'hunter2')
16
+ while true
17
+ new_comments = probot.r('all').comments
18
+ sleep 2 # Respecting the API is currently manual, will be fixed in future.
19
+ new_comments.each do |com|
20
+ if com.body =~ /(vile|rotten|words)/
21
+ com.reply("#{$&.capitalize} is a terrible word, #{com.author}!")
22
+ sleep 2
23
+ end
24
+ end
25
+ sleep 2
26
+ end
27
+ ```
28
+ ## Features
29
+
30
+ Snooby is in the early stages of active development. Most of the code is structure, but there is *some* functionality in place. At the moment, Snooby can:
31
+
32
+ * grab the first page of comments/posts for a user/subreddit
33
+ * grab about data for users and subreddits
34
+ * grab trophy data
35
+ * reply to comments and posts
36
+
37
+ ## TODO
38
+
39
+ * Pagination
40
+ * Flesh out errors
41
+ * Much more thorough configuration file
42
+ * Granular caching
data/Rakefile CHANGED
@@ -1 +1 @@
1
- require "bundler/gem_tasks"
1
+ require "bundler/gem_tasks"
@@ -1,14 +1,80 @@
1
- $: << File.expand_path('../lib', $0)
2
- %w[net/http cgi json].each { |d| require d }
3
- require 'snooby/user'
1
+ %w[net/http/persistent json].each { |d| require d }
4
2
 
5
3
  module Snooby
6
- # connection to be shared by all instances
7
- Reddit = Net::HTTP.new('www.reddit.com')
4
+ # Opens a persistent connection that provides a significant speed improvement
5
+ # during repeated calls; reddit's rate limit nullifies this for the most part,
6
+ # but it's still a nice library and persistent connections are a Good Thing.
7
+ Conn = Net::HTTP::Persistent.new('snooby')
8
8
 
9
- # load previously stored user data if it exists
10
- class << self; attr_accessor :config; end
11
- Snooby.config = JSON.parse(File.read('.snooby')) rescue {}
9
+ # Provides a mapping of things and actions to their respective URL fragments.
10
+ # A path is eventually used as a complete URI, thus the merge.
11
+ paths = {
12
+ :comment => 'api/comment',
13
+ :login => 'api/login/%s',
14
+ :me => 'api/me.json',
15
+ :subreddit_about => 'r/%s/about.json',
16
+ :subreddit_comments => 'r/%s/comments.json',
17
+ :subreddit_posts => 'r/%s.json',
18
+ :user => 'user/%s',
19
+ :user_about => 'user/%s/about.json',
20
+ :user_comments => 'user/%s/comments.json',
21
+ :user_posts => 'user/%s/submitted.json',
22
+ }
23
+ Paths = paths.merge(paths) { |k, v| 'http://www.reddit.com/' + v }
12
24
 
13
- class RedditError < StandardError; end
25
+ # Provides a mapping of things to a list of all the attributes present in the
26
+ # relevant JSON object. A lot of these probably won't get used too often, but
27
+ # might as well expose all available data.
28
+ Fields = {
29
+ :comment => %w[author author_flair_css_class author_flair_text body body_html created created_utc downs id likes link_id link_title name parent_id replies subreddit subreddit_id ups],
30
+ :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 selftext_html subreddit subreddit_id thumbnail title ups url]
31
+ }
32
+
33
+ # The crux of Snooby. Generates an array of structs from the Paths and Fields
34
+ # hashes defined above. In addition to just being a very neat container, this
35
+ # allows accessing the returned JSON values using thing.attribute, as opposed
36
+ # to thing['data']['attribute']. Only used for listings of posts and comments
37
+ # at the moment, but I imagine it'll be used for moderation down the road.
38
+ def self.build(object, path, which)
39
+ # A bit of string manipulation to determine which fields to populate the
40
+ # generated struct with. There might be a less fragile way to go about it,
41
+ # but it shouldn't be a problem as long as naming remains consistent.
42
+ kind = object.to_s.split('::')[1].downcase.to_sym
43
+
44
+ # Having to explicitly pass the path symbol isn't exactly DRY, but deriving
45
+ # it from the object parameter (say, Snooby::Comment) doesn't expose which
46
+ # kind of comment it is, either User or Post.
47
+ uri = URI(Paths[path] % which)
48
+
49
+ # This'll likely have to be tweaked to handle other types of listings, but
50
+ # it's sufficient for comments and posts.
51
+ JSON.parse(Conn.request(uri).body)['data']['children'].map do |child|
52
+ # Maps each of the listing's children to the relevant struct based on the
53
+ # object type passed in. The symbols in a struct definition are ordered,
54
+ # but Python dictionaries are not, so #values isn't sufficient.
55
+ object.new(*child['data'].values_at(*Fields[kind]))
56
+ end
57
+ end
58
+
59
+ class << self
60
+ attr_accessor :config, :auth
61
+ end
62
+
63
+ # Used for permanent storage of preferences and authorization data.
64
+ # Each client should have its own directory to prevent pollution.
65
+ @config = JSON.parse(File.read('.snooby')) rescue {'auth' => {}}
66
+
67
+ # Raised with a pretty print of the relevant JSON object whenever an API call
68
+ # returns a non-empty "errors" field. Where "pretty" is inapplicable, such as
69
+ # when the returned JSON object is a series of jQuery calls, a manual message
70
+ # is displayed instead, typically to inform the user either that they've been
71
+ # rate-limited or that they lack the necessary authorization.
72
+ class RedditError < StandardError; end
73
+ end
74
+
75
+ # Snooby's parts are required down here, after its initial declaration, because
76
+ # Post and Comment are structs whose definitions are taken from the Fields hash
77
+ # above, and related bits might as well be kept together.
78
+ %w[client actions objects].each do |d|
79
+ require "snooby/#{d}"
14
80
  end
@@ -0,0 +1,69 @@
1
+ module Snooby
2
+ # Mixin to provide functionality to all of the objects in one fell swoop,
3
+ # both relevant and irrelevant. Errors are thrown where nonsensical methods
4
+ # are called, but hopefully the intent is to use Snooby sensibly. I realize
5
+ # this is probably bad design, but it's just so damned clean.
6
+ module Actions
7
+ # Returns a hash containing the values supplied by about.json, so long as
8
+ # the calling object is a User or Subreddit.
9
+ def about
10
+ if !['user', 'subreddit'].include?(@kind)
11
+ raise RedditError, 'Only users and subreddits have about pages.'
12
+ end
13
+
14
+ uri = URI(Paths[:"#{@kind}_about"] % @name)
15
+ JSON.parse(Conn.request(uri).body)['data']
16
+ end
17
+
18
+ # Returns an array of structs containing the object's posts.
19
+ def posts
20
+ if !['user', 'subreddit'].include?(@kind)
21
+ raise RedditError, 'Only users and subreddits have posts.'
22
+ end
23
+
24
+ Snooby.build(Post, :"#{@kind}_posts", @name)
25
+ end
26
+
27
+ # Returns an array of structs containing the object's comments.
28
+ def comments
29
+ if !['user', 'subreddit'].include?(@kind)
30
+ raise RedditError, 'Only users and subreddits have comments.'
31
+ end
32
+
33
+ Snooby.build(Comment, :"#{@kind}_comments", @name)
34
+ end
35
+
36
+ # Returns an array of 2-tuples containing the user's trophy information in
37
+ # the form of [name, description], the latter containing the empty string
38
+ # if inapplicable.
39
+ def trophies
40
+ raise RedditError, 'Only users have trophies.' if @kind != 'user'
41
+
42
+ # Only interested in trophies; request the minimum amount of content.
43
+ html = Conn.request(URI(Paths[:user] % @name) + '?limit=1').body
44
+ # Entry-level black magic.
45
+ html.scan(/"trophy-name">(.+?)<.+?"\s?>([^<]*)</)
46
+ end
47
+
48
+ # Posts a reply to the caller as the currently authorized user, so long as
49
+ # that caller is a Post or Comment.
50
+ def reply(text)
51
+ raise RedditError, 'You must be authorized to comment.' if !Snooby.auth
52
+
53
+ if !['post', 'comment'].include?(@kind)
54
+ raise RedditError, "Replying to a #{@kind} doesn't make much sense."
55
+ end
56
+
57
+ uri = URI(Paths[:comment])
58
+ post = Net::HTTP::Post.new(uri.path)
59
+ data = {:parent => self.name, :text => text, :uh => Snooby.auth}
60
+ post.set_form_data(data)
61
+ json = JSON.parse(Conn.request(uri, post).body)['jquery']
62
+
63
+ # Bad magic numbers, I know, but getting rate-limited during a reply
64
+ # returns a bunch of serialized jQuery rather than a straightforward
65
+ # and meaningful message. Fleshing out errors is on the to-do list.
66
+ raise RedditError, json[14][3] if json.size == 17
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,65 @@
1
+ module Snooby
2
+ # Interface through which Snooby does all of its interacting with the API.
3
+ class Client
4
+ def initialize(user_agent = 'Snooby')
5
+ # Net::HTTP's default User-Agent, "Ruby", is banned on reddit due to its
6
+ # frequent improper use; cheers to Eric Hodel (drbrain) for implementing
7
+ # this workaround for net-http-persistent.
8
+ Conn.override_headers['User-Agent'] = user_agent
9
+ end
10
+
11
+ # Causes the client to be recognized as the given user during API calls.
12
+ # GET operations do not need to be authorized, so if your intent is simply
13
+ # to gather data, feel free to disregard this method entirely.
14
+ def authorize!(user, passwd, force_update = false)
15
+ if Snooby.config['auth'][user] && !force_update
16
+ # Authorization data exists, skip login and potential rate-limiting.
17
+ @modhash, @cookie = Snooby.config['auth'][user]
18
+ else
19
+ uri = URI(Paths[:login] % user)
20
+ post = Net::HTTP::Post.new(uri.path)
21
+ data = {:user => user, :passwd => CGI.escape(passwd), :api_type => 'json'}
22
+ post.set_form_data(data)
23
+ json = JSON.parse(Conn.request(uri, post).body)['json']
24
+
25
+ # Will fire for incorrect login credentials and when rate-limited.
26
+ raise RedditError, jj(json) if !json['errors'].empty?
27
+
28
+ # Parse authorization data and store it both in the current config, as
29
+ # well as in the configuration file for future use. This works because
30
+ # authorization data is immortal unless the password has changed. The
31
+ # force_update parameter should be enabled if such is the case.
32
+ @modhash, @cookie = json['data'].values
33
+ Snooby.config['auth'][user] = [@modhash, @cookie]
34
+ File.open('.snooby', 'w') { |f| f << Snooby.config.to_json }
35
+ end
36
+
37
+ # Sets the reddit_session cookie required for API calls to be recognized
38
+ # as coming from the intended user; uses override_headers to allow for
39
+ # switching the current user mid-execution, if so desired.
40
+ Conn.headers['Cookie'] = "reddit_session=#{@cookie}"
41
+
42
+ # Allows Snooby's classes to access the currently authorized client.
43
+ Snooby.auth = @modhash
44
+ end
45
+
46
+ # Returns a hash containing the values supplied by me.json.
47
+ def me
48
+ uri = URI(Paths[:me])
49
+ JSON.parse(Conn.request(uri).body)['data']
50
+ end
51
+
52
+ # Returns a User object from which posts, comments, etc. can be accessed.
53
+ def user(name)
54
+ User.new(name)
55
+ end
56
+
57
+ # As above, so below.
58
+ def subreddit(name)
59
+ Subreddit.new(name)
60
+ end
61
+
62
+ alias :u :user
63
+ alias :r :subreddit
64
+ end
65
+ end
@@ -0,0 +1,37 @@
1
+ module Snooby
2
+ class User
3
+ include Actions
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ @kind = 'user'
8
+ end
9
+ end
10
+
11
+ class Subreddit
12
+ include Actions
13
+
14
+ def initialize(name)
15
+ @name = name
16
+ @kind = 'subreddit'
17
+ end
18
+ end
19
+
20
+ class Post < Struct.new(*Fields[:post].map(&:to_sym))
21
+ include Actions
22
+
23
+ def initialize(*)
24
+ super
25
+ @kind = 'post'
26
+ end
27
+ end
28
+
29
+ class Comment < Struct.new(*Fields[:comment].map(&:to_sym))
30
+ include Actions
31
+
32
+ def initialize(*)
33
+ super
34
+ @kind = 'comment'
35
+ end
36
+ end
37
+ end
metadata CHANGED
@@ -1,29 +1,52 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: snooby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
8
- - andkerosine
8
+ - Donnie Akers
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-02-06 00:00:00.000000000 Z
13
- dependencies: []
14
- description: Snooby wraps the reddit API in convenient Ruby.
12
+ date: 2012-02-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: json
16
+ requirement: &79734590 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *79734590
25
+ - !ruby/object:Gem::Dependency
26
+ name: net-http-persistent
27
+ requirement: &79734280 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *79734280
36
+ description:
15
37
  email:
16
38
  - andkerosine@gmail.com
17
39
  executables: []
18
40
  extensions: []
19
41
  extra_rdoc_files: []
20
42
  files:
21
- - .gitignore
22
43
  - Gemfile
44
+ - README.md
23
45
  - Rakefile
24
46
  - lib/snooby.rb
25
- - lib/snooby/version.rb
26
- - snooby.gemspec
47
+ - lib/snooby/actions.rb
48
+ - lib/snooby/client.rb
49
+ - lib/snooby/objects.rb
27
50
  homepage: https://github.com/andkerosine/snooby
28
51
  licenses: []
29
52
  post_install_message:
@@ -43,9 +66,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
43
66
  - !ruby/object:Gem::Version
44
67
  version: '0'
45
68
  requirements: []
46
- rubyforge_project: snooby
69
+ rubyforge_project:
47
70
  rubygems_version: 1.8.15
48
71
  signing_key:
49
72
  specification_version: 3
50
- summary: Ruby wrapper for the reddit API.
73
+ summary: Snooby wraps the reddit API in happy, convenient Ruby.
51
74
  test_files: []
data/.gitignore DELETED
@@ -1,4 +0,0 @@
1
- *.gem
2
- .bundle
3
- Gemfile.lock
4
- pkg/*
@@ -1,3 +0,0 @@
1
- module Snooby
2
- VERSION = "0.0.1"
3
- end
@@ -1,20 +0,0 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require 'snooby/version'
4
-
5
- Gem::Specification.new do |s|
6
- s.name = 'snooby'
7
- s.version = Snooby::VERSION
8
- s.authors = ['andkerosine']
9
- s.email = ['andkerosine@gmail.com']
10
- s.homepage = 'https://github.com/andkerosine/snooby'
11
- s.summary = 'Ruby wrapper for the reddit API.'
12
- s.description = 'Snooby wraps the reddit API in convenient Ruby.'
13
-
14
- s.rubyforge_project = 'snooby'
15
-
16
- s.files = `git ls-files`.split("\n")
17
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
- s.require_paths = ["lib"]
20
- end