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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.adoc +44 -0
- data/CODE_OF_CONDUCT.adoc +73 -0
- data/Gemfile +14 -2
- data/LICENSE +1 -1
- data/README.adoc +180 -0
- data/bin/kindle +2 -19
- data/features/highlights.feature +117 -0
- data/features/support/aruba.rb +1 -0
- data/features/support/highlight_steps.rb +13 -0
- data/kindle.gemspec +11 -7
- data/lib/kindle.rb +18 -17
- data/lib/kindle/cli.rb +115 -0
- data/lib/kindle/exports/csv.rb +15 -0
- data/lib/kindle/exports/json.rb +12 -0
- data/lib/kindle/exports/markdown.rb +18 -0
- data/lib/kindle/migrations/base_migration.rb +22 -0
- data/lib/kindle/migrations/initializer.rb +13 -0
- data/lib/kindle/models/book.rb +26 -0
- data/lib/kindle/models/highlight.rb +24 -0
- data/lib/kindle/parser/agent.rb +13 -0
- data/lib/kindle/parser/annotations.rb +130 -0
- data/lib/kindle/remote/book.rb +14 -0
- data/lib/kindle/remote/highlight.rb +12 -0
- data/lib/kindle/settings.rb +51 -0
- data/templates/database.yml +11 -0
- data/test/cli_test.rb +0 -0
- data/test/highlights_test.rb +13 -0
- data/test/kindle_test.rb +17 -0
- metadata +98 -17
- data/.rvmrc +0 -1
- data/Changelog +0 -24
- data/README.md +0 -19
- data/lib/kindle/highlight.rb +0 -13
- data/lib/kindle/highlights_parser.rb +0 -105
- data/lib/kindle/version.rb +0 -3
@@ -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
|
data/kindle.gemspec
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
2
2
|
$:.push File.expand_path("../lib", __FILE__)
|
3
|
-
|
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
|
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.
|
22
|
-
s.
|
23
|
-
s.
|
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
|
data/lib/kindle.rb
CHANGED
@@ -1,20 +1,21 @@
|
|
1
|
-
require
|
2
|
-
|
3
|
-
require_relative
|
4
|
-
require_relative
|
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
|
-
|
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
|
data/lib/kindle/cli.rb
ADDED
@@ -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,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,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}¤t_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
|