robi 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 67c9217164cb562bda4b90c2330e8ed7f8a3dc70
4
+ data.tar.gz: eddff5c9f49640c852d3992f22464d27a95a98d3
5
+ SHA512:
6
+ metadata.gz: 2e441b7ea0deb38e1b07e5352081cfeebbe5a71b9b9b32612d2ed7725854ce5a9ff0b7c80e6656edb2692365d8a080fa43e5ad716b61d889632486b228d68d53
7
+ data.tar.gz: c05727623b778fa1a16f1e78ae0b585bde44cc473c0a3fab29762852f98c407c3d66f310e67d63af2d72b6e1cc32b9a528e724ad52864e3b72e97f45f4139d66
data/bin/robi ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'robi'
4
+ require 'optparser'
5
+
6
+ opts = OptParser.parse
7
+
8
+ Robi.new(
9
+ opts[:subreddit],
10
+ opts[:type]
11
+ ).bundle(
12
+ opts[:count]
13
+ )
data/lib/optparser.rb ADDED
@@ -0,0 +1,37 @@
1
+ require 'optparse'
2
+
3
+ module OptParser
4
+ TYPES = %i(hot new rising controversial top gilded).freeze
5
+
6
+ def self.parse
7
+ opt = {}
8
+
9
+ OptionParser.new { |options|
10
+ options.banner = 'Usage: robi SUBREDDIT [-t TYPE] [-c COUNT]'
11
+
12
+ opt[:subreddit] = ARGV.first
13
+ opt[:type] = :hot
14
+ opt[:count] = 25
15
+
16
+ options.on('-t', '--type TYPE', 'top, new, etc. (default hot)') { |type|
17
+ opt[:type] = type.downcase.to_sym
18
+ }
19
+
20
+ options.on('-c', '--count COUNT', 'Max number of posts (default 25)') { |count|
21
+ opt[:count] = count.to_i
22
+ }
23
+
24
+ options.on('-h', '--help', 'Display this screen') {
25
+ puts options
26
+ exit
27
+ }
28
+ }.parse!
29
+
30
+ raise 'No subreddit specified' if opt[:subreddit].nil?
31
+ raise "Subreddit name must be raw ('funny' or 'pics')" if opt[:subreddit].include?('/')
32
+ raise "Type must be one of {#{TYPES.join(', ')}}" unless TYPES.include?(opt[:type])
33
+ raise 'Count must be at least 1' if opt[:count] < 1
34
+
35
+ opt
36
+ end
37
+ end
@@ -0,0 +1,179 @@
1
+ require 'nokogiri'
2
+ require 'fileutils'
3
+
4
+ class Robi
5
+ class Compiler
6
+ def initialize(dest_dir, stylesheet_source)
7
+ @dest_dir = dest_dir
8
+
9
+ @html_file = 'index.html'
10
+ @stylesheet_source = stylesheet_source
11
+ @stylesheet_file = File.basename(@stylesheet_source)
12
+ @table_of_contents_file = 'tableofcontents.ncx'
13
+ @metadata_file = 'metadata.opf'
14
+ end
15
+
16
+ def compile(title, posts)
17
+ puts ' Building HTML...'
18
+ html = build_html(title, posts)
19
+
20
+ puts ' Building metadata...'
21
+ metadata = build_metadata(title)
22
+
23
+ puts ' Building Table of Contents...'
24
+ table_of_contents = build_table_of_contents(title, posts)
25
+
26
+ puts ' Assembling files...'
27
+ assemble(metadata, html, table_of_contents)
28
+
29
+ "#{@dest_dir}/#{@metadata_file}"
30
+ end
31
+
32
+ def build_html(title, posts)
33
+ Nokogiri::HTML::Builder.new(encoding: 'utf-8') { |doc|
34
+ doc.html {
35
+ doc.head {
36
+ doc.title { doc.text title }
37
+ doc.link(
38
+ rel: 'stylesheet',
39
+ href: @stylesheet_file,
40
+ type: 'text/css'
41
+ )
42
+ }
43
+ doc.body {
44
+ doc.div(id: 'titlepage') {
45
+ doc.h1 { doc.text title }
46
+ doc.h2 {
47
+ unit = simple_plural('post', posts.size)
48
+ doc.text "#{posts.size} #{unit}"
49
+ }
50
+ }
51
+ doc.div(id: 'tableofcontents') {
52
+ doc.h1 { doc.text 'Posts' }
53
+ doc.ol {
54
+ posts.each do |post|
55
+ doc.li {
56
+ doc.a(href: "##{post.uid}") {
57
+ doc.text post.title
58
+ }
59
+ }
60
+ end
61
+ }
62
+ }
63
+ doc.div(id: 'postwrapper') {
64
+ posts.each do |post|
65
+ doc.div(class: 'post', id: post.uid) {
66
+ doc.h1 { doc.text post.title }
67
+ doc.h2 { doc.text "by #{post.author}" }
68
+ doc.div {
69
+ body = Nokogiri::HTML::DocumentFragment.parse(post.body)
70
+ doc << body.content
71
+ }
72
+ }
73
+ end
74
+ }
75
+ }
76
+ }
77
+ }.to_html
78
+ end
79
+
80
+ def build_metadata(title)
81
+ Nokogiri::XML::Builder.new { |xml|
82
+ xml.root {
83
+ xml.package(
84
+ :'unique-identifier' => title,
85
+ :'xmlns:opf' => 'http://www.idpf.org/2007/opf',
86
+ :'xmlns:asd' => 'http://www.idpf.org/asdfaf'
87
+ ) {
88
+ xml.metadata {
89
+ xml.send(
90
+ :'dc-metadata',
91
+ :'xmlns:dc' => 'http://purl.org/metadata/dublin_core',
92
+ :'xmlns:oebpackage' => 'http://openebook.org/namespaces/oeb-package/1.0/'
93
+ ) {
94
+ xml.send(:'dc:Title', title)
95
+ xml.send(:'dc:Language', 'en')
96
+ xml.send(:'dc:Creator', 'Reddit')
97
+ xml.send(:'x-metadata')
98
+ }
99
+ }
100
+ xml.manifest {
101
+ # xml.item(
102
+ # :id => 'content',
103
+ # :'media-type' => 'text/x-oeb1-document',
104
+ # :href => "#{HTML}#tableofcontents"
105
+ # )
106
+ xml.item(
107
+ :id => 'ncx',
108
+ :'media-type' => 'application/x-dtbncx+xml',
109
+ :href => @table_of_contents_file
110
+ )
111
+ xml.item(
112
+ :id => 'text',
113
+ :'media-type' => 'text/x-oeb1-document',
114
+ :href => "#{@html_file}"
115
+ )
116
+ }
117
+ xml.spine(toc: 'ncx') {
118
+ # xml.itemref(idref: 'content')
119
+ xml.itemref(idref: 'text')
120
+ }
121
+ xml.guide {
122
+ xml.reference(type: 'toc', title: 'Table of Contents', href: @table_of_contents_file)
123
+ xml.reference(type: 'text', title: title, href: @html_file)
124
+ }
125
+ }
126
+ }
127
+ }.to_xml
128
+ end
129
+
130
+ def build_table_of_contents(title, posts)
131
+ Nokogiri::XML::Builder.new { |xml|
132
+ xml.doc.create_internal_subset(
133
+ 'ncx',
134
+ '-//NISO//DTD ncx 2005-1//EN',
135
+ 'http://www.daisy.org/z3986/2005/ncx-2005-1.dtd'
136
+ )
137
+ xml.ncx(xmlns: 'http://www.daisy.org/z3986/2005/ncx/', version: '2005-1') {
138
+ xml.docTitle {
139
+ xml.text_ title
140
+ }
141
+ xml.navMap {
142
+ # xml.navPoint(id: 'toc', playOrder: 1) {
143
+ # xml.navLabel {
144
+ # xml.text_ 'Table of Contents'
145
+ # }
146
+ # xml.content(src: @html_file)
147
+ # }
148
+ posts.zip(1..Float::INFINITY).each do |post, index|
149
+ xml.navPoint(id: post.uid, playOrder: index) {
150
+ xml.navLabel {
151
+ xml.text_ post.title
152
+ }
153
+ xml.content(src: "#{@html_file}##{post.uid}")
154
+ }
155
+ end
156
+ }
157
+ }
158
+ }.to_xml
159
+ end
160
+
161
+ def assemble(metadata, html, table_of_contents)
162
+ Dir.mkdir(@dest_dir) unless Dir.exist?(@dest_dir)
163
+
164
+ File.open("#{@dest_dir}/#{@html_file}", 'w') { |file| file.write(html) }
165
+ File.open("#{@dest_dir}/#{@metadata_file}", 'w') { |file| file.write(metadata) }
166
+ File.open("#{@dest_dir}/#{@table_of_contents_file}", 'w') { |file| file.write(table_of_contents) }
167
+
168
+ FileUtils.cp(@stylesheet_source, "#{@dest_dir}/#{@stylesheet_file}")
169
+ end
170
+
171
+ def simple_plural(word, count)
172
+ if count.abs > 1
173
+ "#{word}s"
174
+ else
175
+ word
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,46 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'pp'
4
+
5
+ require 'robi/post'
6
+
7
+ class Robi
8
+ class Fetcher
9
+ HTTP_SUCCESS = %w(200).map(&:freeze).freeze
10
+ HTTP_REDIRECT = %w(301 302).map(&:freeze).freeze
11
+
12
+ def initialize(subreddit, type)
13
+ @uri = URI("https://reddit.com/r/#{subreddit}/#{type}/.json")
14
+ end
15
+
16
+ def fetch(count)
17
+ puts " Connecting to #{@uri}..."
18
+ response = Net::HTTP.get_response(@uri)
19
+
20
+ while HTTP_REDIRECT.include?(response.code)
21
+ @uri = URI(response.header['location'])
22
+ puts " Redirecting to #{@uri}..."
23
+ response = Net::HTTP.get_response(@uri)
24
+ end
25
+
26
+ unless HTTP_SUCCESS.include?(response.code)
27
+ raise "Received response code #{response.code}: #{response.msg}"
28
+ end
29
+
30
+ json = response.body
31
+
32
+ puts ' Extracting content...'
33
+ obj = JSON.parse(json)
34
+ extract(count, obj)
35
+ end
36
+
37
+ def extract(count, obj)
38
+ obj['data']['children']
39
+ .map { |post| post['data'] }
40
+ .reject { |post| post['stickied'] }
41
+ .reject { |post| post['selftext'].empty? }
42
+ .first(count)
43
+ .map { |post| Post.from_json_hash(post) }
44
+ end
45
+ end
46
+ end
data/lib/robi/post.rb ADDED
@@ -0,0 +1,49 @@
1
+ require 'time'
2
+ class Robi
3
+ class Post
4
+ attr_reader(
5
+ *%i(
6
+ uid
7
+ title
8
+ author
9
+ date body
10
+ comment_count
11
+ points
12
+ )
13
+ )
14
+
15
+ def initialize(
16
+ uid,
17
+ title,
18
+ author,
19
+ timestamp,
20
+ body,
21
+ comment_count,
22
+ points
23
+ )
24
+ @uid = uid.to_s
25
+ @title = title.to_s
26
+ @author = author.to_s
27
+ @date = Time.at(timestamp.to_i)
28
+ @body = body.to_s
29
+ @comment_count = comment_count.to_i
30
+ @points = points
31
+ end
32
+
33
+ def self.from_json_hash(hash)
34
+ new(
35
+ *hash.values_at(
36
+ *%w(
37
+ name
38
+ title
39
+ author
40
+ created
41
+ selftext_html
42
+ num_comments
43
+ score
44
+ )
45
+ )
46
+ )
47
+ end
48
+ end
49
+ end
data/lib/robi.rb ADDED
@@ -0,0 +1,41 @@
1
+ require 'time'
2
+
3
+ require 'robi/fetcher'
4
+ require 'robi/compiler'
5
+
6
+ class Robi
7
+ def initialize(subreddit, type)
8
+ @subreddit = subreddit
9
+ @type = type
10
+ end
11
+
12
+ def bundle(count)
13
+ id = [@subreddit, @type, Time.now.strftime('%Y-%m-%d-%H%M')]
14
+
15
+ title_string = id.join(' ')
16
+ title_slug = id.join('_')
17
+ dest_dir = "./#{title_slug}"
18
+
19
+ if Dir.exist?(dest_dir) && (Dir.entries(dest_dir) - %w(. ..)).any?
20
+ raise "#{dest_dir} not empty"
21
+ end
22
+
23
+ puts 'Locating kindlegen'
24
+ kindlegen_found = system('which kindlegen')
25
+ abort unless kindlegen_found
26
+
27
+ puts "\nFetching posts"
28
+ posts = Fetcher.new(@subreddit, @type).fetch(count)
29
+
30
+ puts "\nCompiling to eBook source"
31
+ stylesheet = File.expand_path('stylesheet.css', "#{File.dirname(__FILE__)}/static")
32
+ metadata_file = Compiler.new(dest_dir, stylesheet)
33
+ .compile(title_string, posts)
34
+
35
+ puts "\nInvoking kindlegen"
36
+ outfile = "#{File.dirname(metadata_file)}/output.mobi"
37
+ system("kindlegen #{metadata_file} -o output.mobi")
38
+ raise 'kindlegen failed to output eBook' unless File.exist?(outfile)
39
+ FileUtils.mv(outfile, "./#{title_slug}.mobi")
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ h1 { text-align: center; }
2
+ h2 { text-align: center; }
3
+ h3 { text-align: center; }
4
+
5
+
6
+ #titlepage {
7
+ page-break-after: always;
8
+ padding-top: 40%;
9
+ }
10
+
11
+
12
+ #tableofcontents {
13
+ page-break-after: always;
14
+ }
15
+
16
+ #tableofcontents ol {
17
+ margin: 0 10%;
18
+ }
19
+
20
+
21
+ .post {
22
+ page-break-after: always;
23
+ }
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: robi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Tolvstad
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-12-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.6.8
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '1.6'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.6.8
33
+ description: Compile Reddit to Kindle eBooks
34
+ email: tolvstaa@oregonstate.edu
35
+ executables:
36
+ - robi
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - bin/robi
41
+ - lib/optparser.rb
42
+ - lib/robi.rb
43
+ - lib/robi/compiler.rb
44
+ - lib/robi/fetcher.rb
45
+ - lib/robi/post.rb
46
+ - lib/static/stylesheet.css
47
+ homepage: https://github.com/Inityx/robi
48
+ licenses:
49
+ - Apache-2.0
50
+ metadata: {}
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubyforge_project:
67
+ rubygems_version: 2.5.2
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: Robi
71
+ test_files: []