good_audible_story_sync 0.0.5
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/bin/good-audible-story-sync +6 -0
- data/lib/good_audible_story_sync/audible/auth.rb +262 -0
- data/lib/good_audible_story_sync/audible/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/audible/client.rb +220 -0
- data/lib/good_audible_story_sync/audible/library.rb +318 -0
- data/lib/good_audible_story_sync/audible/library_item.rb +213 -0
- data/lib/good_audible_story_sync/audible/user_profile.rb +39 -0
- data/lib/good_audible_story_sync/audible.rb +13 -0
- data/lib/good_audible_story_sync/database/audible_books.rb +66 -0
- data/lib/good_audible_story_sync/database/client.rb +70 -0
- data/lib/good_audible_story_sync/database/credentials.rb +48 -0
- data/lib/good_audible_story_sync/database/goodreads_books.rb +60 -0
- data/lib/good_audible_story_sync/database/storygraph_books.rb +74 -0
- data/lib/good_audible_story_sync/database/sync_times.rb +52 -0
- data/lib/good_audible_story_sync/database.rb +16 -0
- data/lib/good_audible_story_sync/goodreads/auth.rb +137 -0
- data/lib/good_audible_story_sync/goodreads/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/goodreads/book.rb +171 -0
- data/lib/good_audible_story_sync/goodreads/client.rb +98 -0
- data/lib/good_audible_story_sync/goodreads/library.rb +149 -0
- data/lib/good_audible_story_sync/goodreads.rb +12 -0
- data/lib/good_audible_story_sync/input_loop.rb +214 -0
- data/lib/good_audible_story_sync/options.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/auth.rb +91 -0
- data/lib/good_audible_story_sync/storygraph/auth_flow.rb +70 -0
- data/lib/good_audible_story_sync/storygraph/book.rb +261 -0
- data/lib/good_audible_story_sync/storygraph/client.rb +247 -0
- data/lib/good_audible_story_sync/storygraph/library.rb +183 -0
- data/lib/good_audible_story_sync/storygraph/look_up_book_flow.rb +172 -0
- data/lib/good_audible_story_sync/storygraph/mark_finished_flow.rb +201 -0
- data/lib/good_audible_story_sync/storygraph.rb +14 -0
- data/lib/good_audible_story_sync/util/cipher.rb +43 -0
- data/lib/good_audible_story_sync/util/keychain.rb +32 -0
- data/lib/good_audible_story_sync/util.rb +92 -0
- data/lib/good_audible_story_sync/version.rb +6 -0
- data/lib/good_audible_story_sync.rb +14 -0
- metadata +80 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "mechanize"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
module Goodreads
|
|
8
|
+
class Auth
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://www.goodreads.com"
|
|
14
|
+
CREDENTIALS_DB_KEY = "goodreads"
|
|
15
|
+
|
|
16
|
+
sig { params(email: String, password: String).returns(Auth) }
|
|
17
|
+
def self.sign_in(email:, password:)
|
|
18
|
+
auth = new(data: { "email" => email, "password" => password })
|
|
19
|
+
auth.sign_in
|
|
20
|
+
auth
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(page: Mechanize::Page).returns(T::Boolean) }
|
|
24
|
+
def self.sign_in_page?(page)
|
|
25
|
+
url = page.uri.to_s
|
|
26
|
+
url.end_with?("/user/sign_in") || url.end_with?("/ap/signin")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns T.nilable(String) }
|
|
30
|
+
attr_reader :email, :profile_name, :user_id, :slug
|
|
31
|
+
|
|
32
|
+
sig { returns Mechanize }
|
|
33
|
+
attr_reader :agent
|
|
34
|
+
|
|
35
|
+
sig { params(agent: T.nilable(Mechanize), data: T::Hash[String, T.nilable(String)]).void }
|
|
36
|
+
def initialize(agent: nil, data: {})
|
|
37
|
+
@agent = agent || Mechanize.new
|
|
38
|
+
@agent.user_agent_alias = "iPad"
|
|
39
|
+
@email = T.let(data["email"], T.nilable(String))
|
|
40
|
+
@password = T.let(data["password"], T.nilable(String))
|
|
41
|
+
@profile_name = T.let(data["profile_name"], T.nilable(String))
|
|
42
|
+
@user_id = T.let(data["user_id"], T.nilable(String))
|
|
43
|
+
@slug = T.let(data["slug"], T.nilable(String))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(T.untyped) }
|
|
47
|
+
def sign_in
|
|
48
|
+
raise Error.new("Cannot sign in without credentials") if @email.nil? || @password.nil?
|
|
49
|
+
|
|
50
|
+
puts "#{Util::INFO_EMOJI} Signing into Goodreads as #{@email}..."
|
|
51
|
+
select_sign_in_page = begin
|
|
52
|
+
get("/user/sign_in")
|
|
53
|
+
rescue Errno::ETIMEDOUT => err
|
|
54
|
+
raise Error.new("Failed to load Goodreads: #{err}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
email_sign_in_link = T.let(select_sign_in_page.link_with(text: /Sign in with email/),
|
|
58
|
+
T.nilable(Mechanize::Page::Link))
|
|
59
|
+
raise Error.new("Failed to find sign-in link on #{select_sign_in_page.uri}") unless email_sign_in_link
|
|
60
|
+
|
|
61
|
+
email_sign_in_page = email_sign_in_link.click
|
|
62
|
+
sign_in_form = T.let(email_sign_in_page.form_with(name: "signIn"), T.nilable(Mechanize::Form))
|
|
63
|
+
raise Error.new("Could not find sign-in form on #{email_sign_in_page.uri}") unless sign_in_form
|
|
64
|
+
|
|
65
|
+
email_field = sign_in_form.field_with(name: "email")
|
|
66
|
+
raise Error.new("Could not find email field in sign-in form") unless email_field
|
|
67
|
+
password_field = sign_in_form.field_with(name: "password")
|
|
68
|
+
raise Error.new("Could not find password field in sign-in form") unless password_field
|
|
69
|
+
|
|
70
|
+
email_field.value = @email
|
|
71
|
+
password_field.value = @password
|
|
72
|
+
page_after_sign_in = begin
|
|
73
|
+
sign_in_form.submit
|
|
74
|
+
rescue Mechanize::ResponseCodeError => err
|
|
75
|
+
raise Error.new("Error signing into Goodreads: #{err}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
cookieless_message_el = page_after_sign_in.at("#ap_error_page_cookieless_message")
|
|
79
|
+
if cookieless_message_el
|
|
80
|
+
raise Error.new("Could not sign in to Goodreads without cookies: " \
|
|
81
|
+
"#{Util.squish(cookieless_message_el.text)}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
profile_link = T.let(page_after_sign_in.link_with(text: "Profile"), T.nilable(Mechanize::Page::Link))
|
|
85
|
+
successful_sign_in = !profile_link.nil? && !self.class.sign_in_page?(page_after_sign_in)
|
|
86
|
+
raise Error.new("Could not log in to Goodreads") unless successful_sign_in
|
|
87
|
+
|
|
88
|
+
profile_page = profile_link.click
|
|
89
|
+
profile_header = profile_page.at("h1")
|
|
90
|
+
@profile_name = if profile_header
|
|
91
|
+
profile_header.text.strip.split(/\n/).first
|
|
92
|
+
end
|
|
93
|
+
user_id_and_slug = profile_page.uri.path.split("/").last # e.g., "21047466-cheshire"
|
|
94
|
+
@user_id, @slug = user_id_and_slug.split("-")
|
|
95
|
+
puts "#{Util::SUCCESS_EMOJI} Signed in to Goodreads as #{@profile_name}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { params(path: String).returns(Mechanize::Page) }
|
|
99
|
+
def get(path)
|
|
100
|
+
@agent.get("#{BASE_URL}#{path}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { returns T::Hash[String, T.untyped] }
|
|
104
|
+
def to_h
|
|
105
|
+
{
|
|
106
|
+
"email" => @email,
|
|
107
|
+
"password" => @password,
|
|
108
|
+
"profile_name" => @profile_name,
|
|
109
|
+
"user_id" => @user_id,
|
|
110
|
+
"slug" => @slug,
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(cred_client: Database::Credentials).void }
|
|
115
|
+
def save_to_database(cred_client)
|
|
116
|
+
cred_client.upsert(key: CREDENTIALS_DB_KEY, value: to_h)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(cred_client: Database::Credentials).returns(T::Boolean) }
|
|
120
|
+
def load_from_database(cred_client)
|
|
121
|
+
goodreads_data = cred_client.find(key: CREDENTIALS_DB_KEY)
|
|
122
|
+
unless goodreads_data
|
|
123
|
+
puts "#{Util::INFO_EMOJI} No Goodreads credentials found in database"
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@email = goodreads_data["email"]
|
|
128
|
+
@password = goodreads_data["password"]
|
|
129
|
+
@profile_name = goodreads_data["profile_name"]
|
|
130
|
+
@user_id = goodreads_data["user_id"]
|
|
131
|
+
@slug = goodreads_data["slug"]
|
|
132
|
+
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module GoodAudibleStorySync
|
|
7
|
+
module Goodreads
|
|
8
|
+
class AuthFlow
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(credentials_db: Database::Credentials).returns(T.nilable(Auth)) }
|
|
12
|
+
def self.run(credentials_db:)
|
|
13
|
+
new(credentials_db: credentials_db).run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(credentials_db: Database::Credentials).void }
|
|
17
|
+
def initialize(credentials_db:)
|
|
18
|
+
@credentials_db = credentials_db
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { returns T.nilable(Auth) }
|
|
22
|
+
def run
|
|
23
|
+
load_from_database || log_in_via_website
|
|
24
|
+
rescue Auth::Error
|
|
25
|
+
puts "Failed to sign in to Goodreads."
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
sig { returns T.nilable(Auth) }
|
|
32
|
+
def load_from_database
|
|
33
|
+
auth = Auth.new
|
|
34
|
+
puts "#{Util::INFO_EMOJI} Looking for saved Goodreads credentials in database..."
|
|
35
|
+
success = auth.load_from_database(@credentials_db)
|
|
36
|
+
puts "#{Util::SUCCESS_EMOJI} Found saved Goodreads credentials." if success
|
|
37
|
+
return nil unless success
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
auth.sign_in
|
|
41
|
+
rescue Auth::Error => err
|
|
42
|
+
puts "#{Util::ERROR_EMOJI} Failed to sign in to Goodreads: #{err.message}"
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
auth
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { returns T.nilable(Auth) }
|
|
50
|
+
def log_in_via_website
|
|
51
|
+
puts "#{Util::INFO_EMOJI} Logging in to Goodreads..."
|
|
52
|
+
print "Enter Goodreads email: "
|
|
53
|
+
email = gets.chomp
|
|
54
|
+
print "Enter Goodreads password: "
|
|
55
|
+
password = T.unsafe(STDIN).noecho(&:gets).chomp
|
|
56
|
+
print "\n"
|
|
57
|
+
|
|
58
|
+
auth = begin
|
|
59
|
+
Auth.sign_in(email: email, password: password)
|
|
60
|
+
rescue Auth::Error => err
|
|
61
|
+
puts "#{Util::ERROR_EMOJI} Failed to sign in to Goodreads: #{err.message}"
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
auth.save_to_database(@credentials_db)
|
|
66
|
+
auth
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "rainbow"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
module Goodreads
|
|
9
|
+
class Book
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
class Status < T::Enum
|
|
13
|
+
enums do
|
|
14
|
+
CurrentlyReading = new("currently-reading")
|
|
15
|
+
WantToRead = new("to-read")
|
|
16
|
+
Read = new("read")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Public: From a `table#books tbody tr` element on a page like
|
|
21
|
+
# https://www.goodreads.com/review/list/21047466-cheshire?shelf=read.
|
|
22
|
+
sig { params(node: Nokogiri::XML::Element, page: Mechanize::Page).returns(Book) }
|
|
23
|
+
def self.from_book_list(node, page:)
|
|
24
|
+
name_el = node.at(".field.title .value")
|
|
25
|
+
raise "Could not find itemprop=name element for book list item on #{node.document.url}" unless name_el
|
|
26
|
+
link = name_el.at("a")
|
|
27
|
+
raise "Could not find book link for #{name_el.text} on #{node.document.url}" unless link
|
|
28
|
+
url = URI.parse(link["href"])
|
|
29
|
+
url.hostname ||= page.uri.hostname
|
|
30
|
+
url.scheme ||= page.uri.scheme
|
|
31
|
+
author_el = node.at(".field.author .value")
|
|
32
|
+
author = if author_el
|
|
33
|
+
value = author_el.text.strip
|
|
34
|
+
if value.include?(", ") # e.g., "Simmons, Dan"
|
|
35
|
+
value = value.split(", ").reverse.join(" ")
|
|
36
|
+
end
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
current_shelf_link = node.at(".field.shelves .value a")
|
|
40
|
+
isbn_el = node.at(".field.isbn .value")
|
|
41
|
+
new({
|
|
42
|
+
"title" => Util.squish(name_el.text),
|
|
43
|
+
"url" => url, # e.g., "https://www.goodreads.com/book/show/11286.Carrion_Comfort"
|
|
44
|
+
"slug" => url.path&.split("/")&.last, # e.g., "11286.Carrion_Comfort"
|
|
45
|
+
"author" => author,
|
|
46
|
+
"status" => current_shelf_link&.text, # e.g., "to-read"
|
|
47
|
+
"isbn" => isbn_el&.text&.strip,
|
|
48
|
+
})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(data: T::Hash[String, T.untyped]).void }
|
|
52
|
+
def initialize(data)
|
|
53
|
+
@data = data
|
|
54
|
+
raise "Cannot create a Goodreads book without a slug" unless @data["slug"] && !@data["slug"].empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { returns String }
|
|
58
|
+
def slug
|
|
59
|
+
@data["slug"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { returns T.nilable(String) }
|
|
63
|
+
def isbn
|
|
64
|
+
@data["isbn"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
|
|
68
|
+
def title(stylize: false)
|
|
69
|
+
value = @data["title"]
|
|
70
|
+
return value unless stylize && value
|
|
71
|
+
Rainbow(value).underline
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { returns T.nilable(String) }
|
|
75
|
+
def author
|
|
76
|
+
@data["author"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { returns T.nilable(Status) }
|
|
80
|
+
def status
|
|
81
|
+
value = @data["status"]
|
|
82
|
+
return value if value.nil? || value.is_a?(Status)
|
|
83
|
+
Status.try_deserialize(value.to_s)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { returns T::Boolean }
|
|
87
|
+
def finished?
|
|
88
|
+
status == Status::Read
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { returns T::Boolean }
|
|
92
|
+
def currently_reading?
|
|
93
|
+
status == Status::CurrentlyReading
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { returns T::Boolean }
|
|
97
|
+
def want_to_read?
|
|
98
|
+
status == Status::WantToRead
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(stylize: T::Boolean).returns(T.nilable(String)) }
|
|
102
|
+
def url(stylize: false)
|
|
103
|
+
@url_by_stylize ||= {}
|
|
104
|
+
return @url_by_stylize[stylize] if @url_by_stylize[stylize]
|
|
105
|
+
value = @data["url"] || "#{Client::BASE_URL}/book/show/#{slug}"
|
|
106
|
+
@url_by_stylize[stylize] = if stylize
|
|
107
|
+
Rainbow(value).blue
|
|
108
|
+
else
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sig { params(stylize: T::Boolean).returns(String) }
|
|
114
|
+
def title_and_author(stylize: false)
|
|
115
|
+
@title_and_author_by_stylize ||= {}
|
|
116
|
+
return @title_and_author_by_stylize[stylize] if @title_and_author_by_stylize[stylize]
|
|
117
|
+
author = self.author
|
|
118
|
+
@title_and_author_by_stylize[stylize] = if author && !author.empty?
|
|
119
|
+
"#{title(stylize: stylize)} by #{author}"
|
|
120
|
+
else
|
|
121
|
+
title(stylize: stylize) || "Unknown (slug #{slug})"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { params(indent_level: Integer, stylize: T::Boolean).returns(String) }
|
|
126
|
+
def to_s(indent_level: 0, stylize: false)
|
|
127
|
+
line1 = "#{Util::TAB * indent_level}#{title_and_author(stylize: stylize)}" \
|
|
128
|
+
"#{status_summary(stylize: stylize)}"
|
|
129
|
+
lines = [
|
|
130
|
+
line1,
|
|
131
|
+
"#{Util::TAB * (indent_level + 1)}#{Util::NEWLINE_EMOJI} #{url(stylize: stylize)}",
|
|
132
|
+
]
|
|
133
|
+
lines.join("\n")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { returns String }
|
|
137
|
+
def inspect
|
|
138
|
+
@data.inspect
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
sig { returns T::Hash[String, T.untyped] }
|
|
142
|
+
def to_h
|
|
143
|
+
@data.dup
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { params(books_db: Database::GoodreadsBooks).returns(T::Boolean) }
|
|
147
|
+
def save_to_database(books_db)
|
|
148
|
+
books_db.upsert(slug: slug, title: title, author: author,
|
|
149
|
+
isbn: isbn, status: status&.serialize)
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
sig { params(prefix: String, stylize: T::Boolean).returns(T.nilable(String)) }
|
|
154
|
+
def status_summary(prefix: " - ", stylize: false)
|
|
155
|
+
suffix = if finished?
|
|
156
|
+
"Finished"
|
|
157
|
+
elsif currently_reading?
|
|
158
|
+
"Currently reading"
|
|
159
|
+
elsif want_to_read?
|
|
160
|
+
"Want to read"
|
|
161
|
+
else
|
|
162
|
+
status&.serialize
|
|
163
|
+
end
|
|
164
|
+
if suffix
|
|
165
|
+
value = "#{prefix}#{suffix}"
|
|
166
|
+
stylize ? Rainbow(value).italic : value
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require "mechanize"
|
|
5
|
+
require "rainbow"
|
|
6
|
+
|
|
7
|
+
module GoodAudibleStorySync
|
|
8
|
+
module Goodreads
|
|
9
|
+
class Client
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
BASE_URL = Auth::BASE_URL
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class NotAuthenticatedError < StandardError; end
|
|
16
|
+
|
|
17
|
+
sig { returns Mechanize }
|
|
18
|
+
attr_reader :agent
|
|
19
|
+
|
|
20
|
+
sig { params(auth: Auth).void }
|
|
21
|
+
def initialize(auth:)
|
|
22
|
+
@agent = T.let(auth.agent, Mechanize)
|
|
23
|
+
@auth = auth
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# e.g., https://www.goodreads.com/review/list/21047466-cheshire?shelf=read
|
|
27
|
+
sig do
|
|
28
|
+
params(
|
|
29
|
+
page: Integer,
|
|
30
|
+
load_all_pages: T::Boolean,
|
|
31
|
+
process_book: T.nilable(T.proc.params(arg0: Book).void)
|
|
32
|
+
).returns(Library)
|
|
33
|
+
end
|
|
34
|
+
def get_read_books(page: 1, load_all_pages: true, process_book: nil)
|
|
35
|
+
initial_page = get("/review/list/#{@auth.user_id}-#{@auth.slug}?shelf=read&page=#{page}")
|
|
36
|
+
library = Library.new
|
|
37
|
+
books = get_read_books_on_page(page: initial_page, load_all_pages: load_all_pages,
|
|
38
|
+
process_book: process_book)
|
|
39
|
+
books.each { |book| library.add_book(book) }
|
|
40
|
+
library
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(path: String).returns(Mechanize::Page) }
|
|
44
|
+
def get(path)
|
|
45
|
+
url = "#{BASE_URL}#{path}"
|
|
46
|
+
puts "#{Util::INFO_EMOJI} GET #{Rainbow(url).blue}"
|
|
47
|
+
load_page(-> { @agent.get(url) })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig do
|
|
53
|
+
params(
|
|
54
|
+
path: T.nilable(String),
|
|
55
|
+
page: T.nilable(Mechanize::Page),
|
|
56
|
+
load_all_pages: T::Boolean,
|
|
57
|
+
process_book: T.nilable(T.proc.params(arg0: Book).void)
|
|
58
|
+
).returns(T::Array[Book])
|
|
59
|
+
end
|
|
60
|
+
def get_read_books_on_page(path: nil, page: nil, load_all_pages: true, process_book: nil)
|
|
61
|
+
if path
|
|
62
|
+
page = get(path)
|
|
63
|
+
elsif page.nil?
|
|
64
|
+
raise "Either a relative URL or a page must be provided"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
book_elements = T.let(page.search("table#books tbody tr"), Nokogiri::XML::NodeSet)
|
|
68
|
+
books = book_elements.map { |el| Book.from_book_list(el, page: page) }
|
|
69
|
+
puts "#{Util::WARNING_EMOJI} No books found on #{page.uri}" if books.empty?
|
|
70
|
+
books.each { |book| process_book.call(book) } if process_book
|
|
71
|
+
|
|
72
|
+
if load_all_pages
|
|
73
|
+
next_page_link = page.at("a.next_page")
|
|
74
|
+
if next_page_link
|
|
75
|
+
units = books.size == 1 ? "book" : "books"
|
|
76
|
+
print "#{Util::INFO_EMOJI} Found #{books.size} #{units} on page"
|
|
77
|
+
last_book = books.last
|
|
78
|
+
print ", ending with #{last_book.title_and_author}" if last_book
|
|
79
|
+
puts
|
|
80
|
+
|
|
81
|
+
books += get_read_books_on_page(path: next_page_link["href"],
|
|
82
|
+
load_all_pages: load_all_pages, process_book: process_book)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
books
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
sig { params(make_request: T.proc.returns(Mechanize::Page)).returns(Mechanize::Page) }
|
|
90
|
+
def load_page(make_request)
|
|
91
|
+
page = make_request.call
|
|
92
|
+
raise NotAuthenticatedError if Auth.sign_in_page?(page)
|
|
93
|
+
sleep 1 # don't hammer the server
|
|
94
|
+
page
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
module GoodAudibleStorySync
|
|
5
|
+
module Goodreads
|
|
6
|
+
class Library
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
SYNC_TIME_KEY = "goodreads_library"
|
|
10
|
+
|
|
11
|
+
sig { params(client: Client, db_client: Database::Client, options: Options).returns(Library) }
|
|
12
|
+
def self.load(client:, db_client:, options:)
|
|
13
|
+
library_cache_last_modified = db_client.sync_times.find(SYNC_TIME_KEY)&.to_time
|
|
14
|
+
library_is_cached = !library_cache_last_modified.nil?
|
|
15
|
+
should_refresh_library = library_cache_last_modified &&
|
|
16
|
+
library_cache_last_modified > options.refresh_cutoff_time
|
|
17
|
+
|
|
18
|
+
if library_is_cached && should_refresh_library
|
|
19
|
+
load_from_database(db_client.goodreads_books)
|
|
20
|
+
else
|
|
21
|
+
if library_is_cached
|
|
22
|
+
puts "#{Util::INFO_EMOJI} Goodreads library cache has not been updated " \
|
|
23
|
+
"since #{Util.pretty_time(library_cache_last_modified)}, updating..."
|
|
24
|
+
end
|
|
25
|
+
load_from_web(client: client, db_client: db_client)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(client: Client, db_client: Database::Client).returns(Library) }
|
|
30
|
+
def self.load_from_web(client:, db_client:)
|
|
31
|
+
books_db = db_client.goodreads_books
|
|
32
|
+
save_book = T.let(
|
|
33
|
+
->(book) { book.save_to_database(books_db) },
|
|
34
|
+
T.proc.params(arg0: GoodAudibleStorySync::Goodreads::Book).void
|
|
35
|
+
)
|
|
36
|
+
library = client.get_read_books(process_book: save_book)
|
|
37
|
+
library.update_sync_time(db_client.sync_times)
|
|
38
|
+
library
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(books_db: Database::GoodreadsBooks).returns(Library) }
|
|
42
|
+
def self.load_from_database(books_db)
|
|
43
|
+
library = new
|
|
44
|
+
library.load_from_database(books_db)
|
|
45
|
+
library
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { void }
|
|
49
|
+
def initialize
|
|
50
|
+
@books_by_slug = T.let({}, T::Hash[String, Book])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { returns T::Array[Book] }
|
|
54
|
+
def books
|
|
55
|
+
@books_by_slug.values
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(book: Book).void }
|
|
59
|
+
def add_book(book)
|
|
60
|
+
@books_by_slug[book.slug] = book
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { returns T::Array[Book] }
|
|
64
|
+
def finished_books
|
|
65
|
+
calculate_finished_unfinished_books
|
|
66
|
+
@finished_books
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { returns Integer }
|
|
70
|
+
def total_finished
|
|
71
|
+
@total_finished ||= finished_books.size
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { returns Integer }
|
|
75
|
+
def total_books
|
|
76
|
+
@books_by_slug.size
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { returns Integer }
|
|
80
|
+
def finished_percent
|
|
81
|
+
@finished_percent ||= (total_finished.to_f / total_books * 100).round
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns String }
|
|
85
|
+
def finished_book_units
|
|
86
|
+
total_finished == 1 ? "book" : "books"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
sig { returns String }
|
|
90
|
+
def book_units
|
|
91
|
+
total_books == 1 ? "book" : "books"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(books_db: Database::GoodreadsBooks).returns(T::Boolean) }
|
|
95
|
+
def load_from_database(books_db)
|
|
96
|
+
puts "#{Util::INFO_EMOJI} Loading cached Goodreads library..."
|
|
97
|
+
|
|
98
|
+
rows = books_db.find_all
|
|
99
|
+
rows.each { |row| add_book(Book.new(row)) }
|
|
100
|
+
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { params(sync_times_db: Database::SyncTimes).void }
|
|
105
|
+
def update_sync_time(sync_times_db)
|
|
106
|
+
puts "#{Util::SAVE_EMOJI} Updating time Goodreads library was last cached..."
|
|
107
|
+
sync_times_db.touch(SYNC_TIME_KEY)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
111
|
+
def to_s(limit: 5, stylize: false)
|
|
112
|
+
[
|
|
113
|
+
"📚 Found #{total_books} #{book_units} on Goodreads",
|
|
114
|
+
finished_books_summary(limit: limit, stylize: stylize),
|
|
115
|
+
].compact.join("\n")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sig { params(limit: Integer, stylize: T::Boolean).returns(String) }
|
|
119
|
+
def finished_books_summary(limit: 5, stylize: false)
|
|
120
|
+
lines = T.let([
|
|
121
|
+
"#{Util::DONE_EMOJI} #{total_finished} #{finished_book_units} " \
|
|
122
|
+
"(#{finished_percent}%) in Goodreads library have been finished:",
|
|
123
|
+
], T::Array[String])
|
|
124
|
+
lines.concat(finished_books.take(limit).map { |book| book.to_s(indent_level: 1, stylize: stylize) })
|
|
125
|
+
lines << "#{Util::TAB}..." if total_finished > limit
|
|
126
|
+
lines << ""
|
|
127
|
+
lines.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sig { returns String }
|
|
131
|
+
def to_json
|
|
132
|
+
JSON.pretty_generate(books.map(&:to_h))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(isbn: String).returns(T.nilable(Book)) }
|
|
136
|
+
def find_by_isbn(isbn)
|
|
137
|
+
books.detect { |book| book.isbn == isbn }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
sig { void }
|
|
143
|
+
def calculate_finished_unfinished_books
|
|
144
|
+
return if @finished_books && @unfinished_books
|
|
145
|
+
@finished_books, @unfinished_books = books.partition(&:finished?)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodAudibleStorySync
|
|
4
|
+
module Goodreads
|
|
5
|
+
end
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require_relative "goodreads/auth"
|
|
9
|
+
require_relative "goodreads/auth_flow"
|
|
10
|
+
require_relative "goodreads/book"
|
|
11
|
+
require_relative "goodreads/client"
|
|
12
|
+
require_relative "goodreads/library"
|