kindle 0.1.3 → 0.7.0.beta2

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.
@@ -0,0 +1 @@
1
+ require 'aruba/cucumber'
@@ -0,0 +1,13 @@
1
+ require "kindle"
2
+ require 'cucumber/timecop'
3
+
4
+ Given(/^a database exists$/) do
5
+ Kindle::Models::Book.delete_all
6
+ Kindle::Models::Highlight.delete_all
7
+ book = Kindle::Models::Book.create! title: "Zen", author: "Monk", highlight_count: 1
8
+ highlight = book.highlights.create! highlight: "Reach for enlightenment"
9
+ end
10
+
11
+ Given(/^time is frozen$/) do
12
+ Timecop.freeze(Time.local(1990))
13
+ end
@@ -1,6 +1,6 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  $:.push File.expand_path("../lib", __FILE__)
3
- require "kindle/version"
3
+ require_relative "lib/kindle"
4
4
 
5
5
  Gem::Specification.new do |s|
6
6
  s.name = "kindle"
@@ -8,9 +8,9 @@ Gem::Specification.new do |s|
8
8
  s.platform = Gem::Platform::RUBY
9
9
  s.authors = ["Matt Petty"]
10
10
  s.email = ["matt@kizmeta.com"]
11
- s.homepage = ""
12
- s.summary = %q{Manage your kindle highlights with ruby}
13
- s.description = %q{Manage your kindle highlights with ruby}
11
+ s.homepage = "https://github.com/lodestone/kindle"
12
+ s.summary = %q{Manage your kindle highlights with ruby, output in JSON, Markdown, and CSV formats}
13
+ s.description = %q{Manage your Amazon Kindle highlights: Sync and cache to an ActiveRecord database and output in various formats}
14
14
 
15
15
  s.rubyforge_project = "kindle"
16
16
 
@@ -18,7 +18,11 @@ Gem::Specification.new do |s|
18
18
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
19
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
20
  s.require_paths = ["lib"]
21
- s.add_dependency "nokogiri"
22
- s.add_dependency "highline"
23
- s.add_dependency "mechanize"
21
+ s.add_runtime_dependency "gli"
22
+ s.add_runtime_dependency "sqlite3"
23
+ s.add_runtime_dependency "activerecord", "~>5.0.0"
24
+ s.add_runtime_dependency "nokogiri"
25
+ s.add_runtime_dependency "mechanize"
26
+ s.add_runtime_dependency "rainbow"
27
+ s.add_runtime_dependency "pry"
24
28
  end
@@ -1,20 +1,21 @@
1
- require 'nokogiri'
2
- require 'mechanize'
3
- require_relative 'kindle/highlight'
4
- require_relative 'kindle/highlights_parser'
1
+ require "active_record"
2
+ require_relative "kindle/settings"
3
+ require_relative "kindle/migrations/initializer"
4
+ require_relative "kindle/migrations/base_migration"
5
+ require_relative "kindle/models/highlight"
6
+ require_relative "kindle/models/book"
7
+ require_relative "kindle/parser/agent"
8
+ require_relative "kindle/parser/annotations"
9
+ require_relative "kindle/remote/book"
10
+ require_relative "kindle/remote/highlight"
11
+ require_relative "kindle/exports/markdown"
12
+ require_relative "kindle/exports/json"
13
+ require_relative "kindle/exports/csv"
5
14
 
6
- module Kindle
7
-
8
- class Highlights
9
-
10
- def initialize(options = {})
11
- options.each { |k,v| instance_variable_set("@#{k}", v) }
12
- end
13
-
14
- def fetch_highlights
15
- HighlightsParser.new(login: @login, password: @password).get_highlights
16
- end
17
-
18
- end
15
+ # TODO: Handle multiple environments
16
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: Kindle::Settings::KINDLE_DATABASE_FILENAME)
19
17
 
18
+ module Kindle
19
+ VERSION = "0.7.0.beta2"
20
+ include Models
20
21
  end
@@ -0,0 +1,115 @@
1
+ require_relative "../kindle"
2
+ require "pry"
3
+ require "gli"
4
+ require "rainbow"
5
+
6
+ # TODO: Create a test and pull request for this monkey patch
7
+ # that handles an empty file without blowing up.
8
+ module GLI
9
+ module AppSupport
10
+ def parse_config # :nodoc:
11
+ config = { 'commands' => {} }
12
+ if @config_file && File.exist?(@config_file)
13
+ require 'yaml'
14
+ yaml = YAML.load(File.open(@config_file).read)
15
+ config.merge!(yaml||{})
16
+ end
17
+ config
18
+ end
19
+ end
20
+ end
21
+
22
+ module Kindle
23
+ class CLI
24
+ extend GLI::App
25
+
26
+ sort_help :manually
27
+ version Kindle::VERSION
28
+ hide_commands_without_desc true
29
+ config_file ".kindle/kindlerc.yml"
30
+ program_desc Rainbow("Fetch and query your Amazon Kindle Highlights").cyan
31
+
32
+ flag [:username, :u]
33
+ flag [:password, :p]
34
+ flag [:domain, :d], default_value: "amazon.com"
35
+
36
+ # NOTE: Commenting out these descriptions to *hide* the option from help
37
+ # desc "Initialize the highlights database"
38
+ # long_desc "Creates a SQLITE3 database to store/cache your highlights"
39
+ command :initdb do |c|
40
+ c.action do |global_options, options, args|
41
+ begin
42
+ puts Rainbow("\nInitializing the database...").green
43
+ Kindle::Migrations::Initializer.new
44
+ rescue ActiveRecord::StatementInvalid
45
+ puts Rainbow("Looks like the database is already created, skipping...").red
46
+ end
47
+ end
48
+ end
49
+
50
+ desc Rainbow("Initialize the database and config files").yellow
51
+ long_desc <<~EOD
52
+ Creates the ~/.kindle/kindle.db SQLITE file
53
+ Creates the ~/.kindle directory and a default config file
54
+ EOD
55
+ command :init do |c|
56
+ c.action do |global_options, options, args|
57
+ commands[:initdb].execute(global_options, options, args)
58
+ commands[:initconfig].execute(global_options, options, args)
59
+ end
60
+ end
61
+
62
+ desc Rainbow("Export Highlights as JSON, CSV, or Markdown").yellow
63
+ long_desc "Output your highlights database in JSON, CSV, or Markdown format"
64
+ command :highlights do |highlights|
65
+
66
+ highlights.desc Rainbow("Refresh the database of highlights").yellow
67
+ highlights.long_desc "Scrape your current Kindle highlights and update the local database"
68
+ highlights.command :update do |update|
69
+ update.action do
70
+ Kindle::Parser::Annotations.new
71
+ end
72
+ end
73
+
74
+ highlights.desc Rainbow("Export highlights and books as JSON").yellow
75
+ highlights.command :json do |json|
76
+ json.action do
77
+ puts Kindle::Exports::Json.new(Kindle::Models::Highlight.includes(:book).all)
78
+ end
79
+ end
80
+
81
+ highlights.desc Rainbow("Export highlights and books as CSV").yellow
82
+ highlights.command :csv do |csv|
83
+ csv.action do
84
+ puts Kindle::Exports::Csv.new(Kindle::Models::Highlight.includes(:book).all)
85
+ end
86
+ end
87
+
88
+ highlights.desc Rainbow("Export highlights and books as Markdown").yellow
89
+ highlights.command :markdown do |markdown|
90
+ markdown.action do
91
+ puts Kindle::Exports::Markdown.new(Kindle::Models::Book.order("title ASC").all)
92
+ end
93
+ end
94
+ end
95
+
96
+ desc Rainbow("Open an interactive console").yellow
97
+ long_desc %{
98
+ Open an interactive console with both Book and Highlight objects available.
99
+ }
100
+ command :console do |c|
101
+ c.action do |global_options,options,args|
102
+ Pry.start Kindle, prompt: [proc { "Kindle :) " }]
103
+ end
104
+ end
105
+
106
+ # NOTE: Fuckery to hide the auto generated actions:
107
+ helpcmd = commands[:help]
108
+ helpcmd.instance_variable_set("@description", Rainbow(helpcmd.instance_variable_get("@description")).yellow)
109
+ commands[:initconfig].instance_variable_set("@description", nil)
110
+ commands[:initconfig].instance_variable_set("@hide_commands_without_desc", true)
111
+
112
+ exit run(ARGV)
113
+
114
+ end
115
+ end
@@ -0,0 +1,15 @@
1
+ module Kindle
2
+ module Exports
3
+ class Csv
4
+ def initialize(highlights)
5
+ @document = "highlight,book title,book author,book asin\n"
6
+ highlights.each do |h|
7
+ @document << "\"#{h.highlight}\",\"#{h.book.title}\",\"#{h.book.author}\",#{h.book.asin}\n"
8
+ end
9
+ end
10
+ def to_s
11
+ @document
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ module Kindle
2
+ module Exports
3
+ class Json
4
+ def initialize(highlights)
5
+ @document = highlights.to_json(include: :book)
6
+ end
7
+ def to_s
8
+ @document
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ module Kindle
2
+ module Exports
3
+ class Markdown
4
+ def initialize(books)
5
+ @document = "## Kindle Highlights\n\n"
6
+ books.each do |book|
7
+ @document << "\n### #{book.title} by #{book.author}\n\n"
8
+ book.highlights.each do |highlight|
9
+ @document << "> #{highlight.highlight}\n\n"
10
+ end
11
+ end
12
+ end
13
+ def to_s
14
+ @document
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ class CreateBaseStructure < ActiveRecord::Migration[5.0]
2
+
3
+ def self.up
4
+ create_table :books do |t|
5
+ t.string :asin, :title, :author
6
+ t.integer :highlight_count
7
+ t.timestamps
8
+ end
9
+ create_table :highlights do |t|
10
+ t.text :highlight
11
+ t.string :amazon_id
12
+ t.integer :book_id
13
+ t.timestamps
14
+ end
15
+ end
16
+
17
+ def self.down
18
+ drop_table :books
19
+ drop_table :highlights
20
+ end
21
+
22
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "base_migration"
2
+
3
+ module Kindle
4
+ module Migrations
5
+ class Initializer
6
+ def initialize
7
+ # ActiveRecord::Base.configurations = YAML::load(IO.read(ENV['HOME']+'/.kindle/database.yml'))
8
+ # ActiveRecord::Base.establish_connection("development")
9
+ CreateBaseStructure.migrate(:up)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ module Kindle
2
+ module Models
3
+ class Book < ActiveRecord::Base
4
+
5
+ attribute :asin, :string
6
+ attribute :highlight_count, :integer
7
+ attribute :title, :string
8
+ attribute :author, :string
9
+
10
+ has_many :highlights
11
+
12
+ # def initialize(asin, options = {})
13
+ # @asin = asin
14
+ # @title = options[:title]
15
+ # @author = options[:author]
16
+ # @highlight_count = options[:highlight_count]
17
+ # @highlights = []
18
+ # end
19
+
20
+ # def to_csv
21
+ # "#{id},#{asin},#{author},#{title},#{highlight},highlight_count"
22
+ # end
23
+
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module Kindle
2
+ module Models
3
+ class Highlight < ActiveRecord::Base
4
+
5
+ attribute :amazon_id, :integer
6
+ attribute :highlight, :text
7
+
8
+ belongs_to :book
9
+
10
+ # def to_csv
11
+ # "#{id},#{asin},#{author},#{title},#{highlight},highlight_count"
12
+ # end
13
+ #
14
+ # def to_json
15
+ # {id: id, highlight: highlight, asin: asin, title: title, author: author, highlight_count: highlight_count}.to_json
16
+ # end
17
+
18
+ # def as_json
19
+ # {id: id, highlight: highlight, asin: book.asin, title: book.title, author: book.author}
20
+ # end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ module Kindle
2
+ module Parser
3
+ class Agent
4
+ def agent
5
+ return @agent if @agent
6
+ @agent = Mechanize.new
7
+ @agent.redirect_ok = true
8
+ @agent.user_agent_alias = 'Mac Mozilla'
9
+ @agent
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,130 @@
1
+ require "mechanize"
2
+ require "nokogiri"
3
+ require "json"
4
+ require_relative "agent"
5
+ require_relative "../remote/highlight"
6
+ require_relative "../remote/book"
7
+ require_relative "../models/highlight"
8
+ require_relative "../models/book"
9
+
10
+ module Kindle
11
+ module Parser
12
+ class Annotations
13
+
14
+ SETTINGS = Kindle::Settings.new
15
+
16
+ attr_accessor :page, :highlights
17
+ attr_reader :books
18
+
19
+ def initialize(options={})
20
+ @username = SETTINGS.username || options[:username]
21
+ @password = SETTINGS.password || options[:password]
22
+ @books = []
23
+ @highlights = []
24
+ load_highlights
25
+ end
26
+
27
+ def load_highlights
28
+ login
29
+ fetch_highlights
30
+ collect_authoritative_highlights
31
+ end
32
+
33
+ private
34
+
35
+ def agent
36
+ @agent ||= Kindle::Parser::Agent.new.agent
37
+ end
38
+
39
+ def login
40
+ @page = agent.get(SETTINGS.url + "/login")
41
+ login_form = page.forms.first
42
+ login_form.email = @username
43
+ login_form.password = @password
44
+ @page = login_form.submit
45
+ end
46
+
47
+ # TODO: Handle CAPTCHA
48
+ # def handle_captcha
49
+ # if page.css("#ap_captcha_img")
50
+ # puts "CAPTCHA LOGIN ERROR"
51
+ # save_and_open_page
52
+ # p page.link_with(text: /See a new challenge/).resolved_uri.to_s
53
+ # agent.cookie_jar.save("cookies.txt", format: :cookiestxt)
54
+ # end
55
+ # save_and_open_page
56
+ # rescue => exception
57
+ # end
58
+ # end
59
+
60
+ def collect_authoritative_highlights
61
+ # NOTE: This fetch may fail if the highlight count is realy large.
62
+ books.each do |book|
63
+ kb = Kindle::Book.find_or_create_by(asin: book.asin, title: book.title, author: book.author)
64
+ if kb.highlight_count != book.highlight_count
65
+ kb.highlight_count = book.highlight_count
66
+ kb.save!
67
+ end
68
+ url = "#{SETTINGS.url}/kcw/highlights?asin=#{book.asin}&cursor=0&count=#{book.highlight_count}"
69
+ bpage = agent.get url
70
+ items = JSON.parse(bpage.body).fetch("items", [])
71
+ book.highlights = items.map do |item|
72
+ kb.highlights.find_or_create_by(highlight: item["highlight"])
73
+ # TODO: FIXME: amazon_id: item["embeddedId"]
74
+ end
75
+ end
76
+ end
77
+
78
+ def fetch_highlights(state={current_upcoming: [], asins: []})
79
+ page = agent.get("#{SETTINGS.url}/your_highlights")
80
+ initialize_state_with_page state, page
81
+ highlights = extract_highlights_from_page(page, state)
82
+ begin
83
+ page = get_the_next_page(state, highlights.flatten)
84
+ new_highlights = extract_highlights_from_page(page, state)
85
+ highlights << new_highlights
86
+ end until new_highlights.length == 0
87
+ highlights.flatten
88
+ end
89
+
90
+ def extract_highlights_from_page(page, state)
91
+ return [] if page.css(".yourHighlight").length == 0
92
+ page.css(".yourHighlight").map do |hl|
93
+ parse_highlight(hl, state)
94
+ end
95
+ end
96
+
97
+ def initialize_state_with_page(state, page)
98
+ return if (page/".yourHighlight").length == 0
99
+ state[:current_asin] = (page/".yourHighlightsHeader").first.attributes["id"].value.split("_").first
100
+ state[:current_upcoming] = (page/".upcoming").first.text.split(',') rescue []
101
+ state[:title] = (page/".yourHighlightsHeader .title").text.to_s.strip
102
+ state[:author] = (page/".yourHighlightsHeader .author").text.to_s.strip
103
+ highlights_on_page = (page/".yourHighlightsHeader")
104
+ state[:current_offset] = (highlights_on_page.collect{|h| h.attributes['id'].value }).first.split('_').last
105
+ highlight_count = (highlights_on_page/".highlightCount#{state[:current_asin]}").text.to_i
106
+ state[:current_number_of_highlights] = highlight_count
107
+ @books << Kindle::Remote::Book.new(state[:current_asin], {author: state[:author], title: state[:title], highlight_count: highlight_count})
108
+ end
109
+
110
+ def get_the_next_page(state, previously_extracted_highlights = [])
111
+ asins = previously_extracted_highlights.map(&:asin).uniq
112
+ asins_string = asins.collect { |asin| "used_asins[]=#{asin}" } * '&'
113
+ upcoming_string = state[:current_upcoming].map { |l| "upcoming_asins[]=#{l}" } * '&'
114
+ url = "#{SETTINGS.url}/your_highlights/next_book?#{asins_string}&current_offset=#{state[:current_offset]}&#{upcoming_string}"
115
+ ajax_headers = { 'X-Requested-With' => 'XMLHttpRequest', 'Host' => "kindle.#{SETTINGS.domain}" }
116
+ page = agent.get(url,[],"#{SETTINGS.url}/your_highlight", ajax_headers)
117
+ initialize_state_with_page state, page
118
+ page
119
+ end
120
+
121
+ def parse_highlight(hl, state)
122
+ highlight_id = hl.xpath('//*[@id="annotation_id"]').first["value"]
123
+ highlight_text = (hl/".highlight").text
124
+ asin = (hl/".asin").text
125
+ highlight = Kindle::Remote::Highlight.new(amazon_id: highlight_id, highlight: highlight_text, asin: asin)
126
+ highlight
127
+ end
128
+ end
129
+ end
130
+ end