kindle 0.1.3 → 0.7.0.beta2

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