snooby 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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