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.
- 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
|