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 +1 -3
- data/README.md +42 -0
- data/Rakefile +1 -1
- data/lib/snooby.rb +75 -9
- data/lib/snooby/actions.rb +69 -0
- data/lib/snooby/client.rb +65 -0
- data/lib/snooby/objects.rb +37 -0
- metadata +33 -10
- data/.gitignore +0 -4
- data/lib/snooby/version.rb +0 -3
- data/snooby.gemspec +0 -20
data/Gemfile
CHANGED
data/README.md
ADDED
@@ -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"
|
data/lib/snooby.rb
CHANGED
@@ -1,14 +1,80 @@
|
|
1
|
-
|
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
|
-
|
7
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
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
|
4
|
+
version: 0.1.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
8
|
-
-
|
8
|
+
- Donnie Akers
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-02-
|
13
|
-
dependencies:
|
14
|
-
|
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/
|
26
|
-
- snooby.
|
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:
|
69
|
+
rubyforge_project:
|
47
70
|
rubygems_version: 1.8.15
|
48
71
|
signing_key:
|
49
72
|
specification_version: 3
|
50
|
-
summary:
|
73
|
+
summary: Snooby wraps the reddit API in happy, convenient Ruby.
|
51
74
|
test_files: []
|
data/.gitignore
DELETED
data/lib/snooby/version.rb
DELETED
data/snooby.gemspec
DELETED
@@ -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
|