tbr-deal-finder 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl
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.
- tbr_deal_finder/book.py +18 -5
- tbr_deal_finder/cli.py +43 -19
- tbr_deal_finder/library_exports.py +121 -71
- tbr_deal_finder/retailer/audible.py +53 -26
- tbr_deal_finder/retailer/chirp.py +120 -17
- tbr_deal_finder/retailer/librofm.py +75 -20
- tbr_deal_finder/retailer/models.py +18 -2
- tbr_deal_finder/retailer_deal.py +19 -10
- tbr_deal_finder/tracked_books.py +120 -0
- tbr_deal_finder/utils.py +17 -0
- {tbr_deal_finder-0.1.4.dist-info → tbr_deal_finder-0.1.6.dist-info}/METADATA +4 -3
- tbr_deal_finder-0.1.6.dist-info/RECORD +22 -0
- tbr_deal_finder-0.1.4.dist-info/RECORD +0 -21
- {tbr_deal_finder-0.1.4.dist-info → tbr_deal_finder-0.1.6.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.4.dist-info → tbr_deal_finder-0.1.6.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.4.dist-info → tbr_deal_finder-0.1.6.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -26,7 +26,11 @@ class Book:
|
|
26
26
|
current_price: float
|
27
27
|
timepoint: datetime
|
28
28
|
format: Union[BookFormat, str]
|
29
|
+
|
30
|
+
# Metadata really only used for tracked books.
|
31
|
+
# See get_tbr_books for more context
|
29
32
|
audiobook_isbn: str = None
|
33
|
+
audiobook_list_price: float = 0
|
30
34
|
|
31
35
|
deleted: bool = False
|
32
36
|
|
@@ -35,16 +39,16 @@ class Book:
|
|
35
39
|
normalized_authors: list[str] = None
|
36
40
|
|
37
41
|
def __post_init__(self):
|
42
|
+
self.current_price = round(self.current_price, 2)
|
43
|
+
self.list_price = round(self.list_price, 2)
|
44
|
+
self.normalized_authors = get_normalized_authors(self.authors)
|
45
|
+
|
38
46
|
if not self.deal_id:
|
39
|
-
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.
|
47
|
+
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
40
48
|
|
41
49
|
if isinstance(self.format, str):
|
42
50
|
self.format = BookFormat(self.format)
|
43
51
|
|
44
|
-
self.current_price = round(self.current_price, 2)
|
45
|
-
self.list_price = round(self.list_price, 2)
|
46
|
-
self.normalized_authors = get_normalized_authors(self.authors)
|
47
|
-
|
48
52
|
def discount(self) -> int:
|
49
53
|
return int((self.list_price/self.current_price - 1) * 100)
|
50
54
|
|
@@ -56,6 +60,10 @@ class Book:
|
|
56
60
|
def title_id(self) -> str:
|
57
61
|
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
58
62
|
|
63
|
+
@property
|
64
|
+
def full_title_str(self) -> str:
|
65
|
+
return f"{self.title}__{self.normalized_authors}"
|
66
|
+
|
59
67
|
def list_price_string(self):
|
60
68
|
return self.price_to_string(self.list_price)
|
61
69
|
|
@@ -74,6 +82,7 @@ class Book:
|
|
74
82
|
response = dataclasses.asdict(self)
|
75
83
|
response["format"] = self.format.value
|
76
84
|
del response["audiobook_isbn"]
|
85
|
+
del response["audiobook_list_price"]
|
77
86
|
del response["exists"]
|
78
87
|
del response["normalized_authors"]
|
79
88
|
|
@@ -114,3 +123,7 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
|
114
123
|
authors = [i for i in authors.split(",")]
|
115
124
|
|
116
125
|
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
126
|
+
|
127
|
+
|
128
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
129
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
tbr_deal_finder/cli.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
import asyncio
|
2
2
|
import os
|
3
|
+
import sys
|
3
4
|
from datetime import timedelta
|
4
5
|
from textwrap import dedent
|
5
6
|
from typing import Union
|
@@ -8,12 +9,19 @@ import click
|
|
8
9
|
import questionary
|
9
10
|
|
10
11
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.library_exports import
|
12
|
+
from tbr_deal_finder.library_exports import maybe_enrich_library_exports
|
12
13
|
from tbr_deal_finder.migrations import make_migrations
|
13
14
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
14
15
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
15
16
|
from tbr_deal_finder.retailer_deal import get_latest_deals
|
16
|
-
from tbr_deal_finder.utils import
|
17
|
+
from tbr_deal_finder.utils import (
|
18
|
+
echo_err,
|
19
|
+
echo_info,
|
20
|
+
echo_success,
|
21
|
+
execute_query,
|
22
|
+
get_duckdb_conn,
|
23
|
+
get_query_by_name
|
24
|
+
)
|
17
25
|
|
18
26
|
|
19
27
|
@click.group()
|
@@ -31,12 +39,12 @@ def _add_path(existing_paths: list[str]) -> Union[str, None]:
|
|
31
39
|
try:
|
32
40
|
new_path = os.path.expanduser(click.prompt("What is the new path"))
|
33
41
|
if new_path in existing_paths:
|
34
|
-
|
42
|
+
echo_info(f"{new_path} is already being tracked.\n")
|
35
43
|
return None
|
36
44
|
elif os.path.exists(new_path):
|
37
45
|
return new_path
|
38
46
|
else:
|
39
|
-
|
47
|
+
echo_err(f"Could not find {new_path}. Please try again.\n")
|
40
48
|
return _add_path(existing_paths)
|
41
49
|
except (KeyError, KeyboardInterrupt, TypeError):
|
42
50
|
return None
|
@@ -116,14 +124,26 @@ def _set_locale(config: Config):
|
|
116
124
|
|
117
125
|
|
118
126
|
def _set_tracked_retailers(config: Config):
|
119
|
-
config.
|
120
|
-
|
121
|
-
"
|
122
|
-
"
|
127
|
+
if not config.tracked_retailers:
|
128
|
+
echo_info(
|
129
|
+
"If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
|
130
|
+
"Note: I don't work for Chirp and this isn't a paid plug."
|
131
|
+
)
|
132
|
+
|
133
|
+
while True:
|
134
|
+
user_response = questionary.checkbox(
|
135
|
+
"Select the retailers you want to check deals for.\n",
|
123
136
|
choices=[
|
124
137
|
questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
|
125
138
|
for retailer in RETAILER_MAP.keys()
|
126
|
-
|
139
|
+
]).ask()
|
140
|
+
if len(user_response) > 0:
|
141
|
+
break
|
142
|
+
else:
|
143
|
+
echo_err("You must track deals for at least one retailer.")
|
144
|
+
|
145
|
+
config.set_tracked_retailers(
|
146
|
+
user_response
|
127
147
|
)
|
128
148
|
|
129
149
|
|
@@ -133,10 +153,14 @@ def _set_config() -> Config:
|
|
133
153
|
except FileNotFoundError:
|
134
154
|
config = Config(library_export_paths=[], tracked_retailers=list(RETAILER_MAP.keys()))
|
135
155
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
156
|
+
try:
|
157
|
+
# Config attrs that requires a user provided value
|
158
|
+
_set_library_export_paths(config)
|
159
|
+
_set_tracked_retailers(config)
|
160
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
161
|
+
echo_err("Config setup cancelled.")
|
162
|
+
sys.exit(0)
|
163
|
+
|
140
164
|
_set_locale(config)
|
141
165
|
|
142
166
|
config.max_price = click.prompt(
|
@@ -151,7 +175,7 @@ def _set_config() -> Config:
|
|
151
175
|
)
|
152
176
|
|
153
177
|
config.save()
|
154
|
-
|
178
|
+
echo_success("Configuration saved!")
|
155
179
|
|
156
180
|
return config
|
157
181
|
|
@@ -166,7 +190,7 @@ def latest_deals():
|
|
166
190
|
"""Find book deals from your Library export."""
|
167
191
|
config = Config.load()
|
168
192
|
|
169
|
-
asyncio.run(
|
193
|
+
asyncio.run(maybe_enrich_library_exports(config))
|
170
194
|
|
171
195
|
db_conn = get_duckdb_conn()
|
172
196
|
results = execute_query(
|
@@ -182,7 +206,7 @@ def latest_deals():
|
|
182
206
|
except Exception as e:
|
183
207
|
ran_successfully = False
|
184
208
|
details = f"Error getting deals: {e}"
|
185
|
-
|
209
|
+
echo_err(details)
|
186
210
|
else:
|
187
211
|
ran_successfully = True
|
188
212
|
details = ""
|
@@ -198,7 +222,7 @@ def latest_deals():
|
|
198
222
|
return
|
199
223
|
|
200
224
|
else:
|
201
|
-
|
225
|
+
echo_info(dedent("""
|
202
226
|
To prevent abuse lastest deals can only be pulled every 8 hours.
|
203
227
|
Fetching most recent deal results.\n
|
204
228
|
"""))
|
@@ -207,7 +231,7 @@ def latest_deals():
|
|
207
231
|
if books := get_deals_found_at(config.run_time):
|
208
232
|
print_books(books)
|
209
233
|
else:
|
210
|
-
|
234
|
+
echo_info("No new deals found.")
|
211
235
|
|
212
236
|
|
213
237
|
@cli.command()
|
@@ -216,7 +240,7 @@ def active_deals():
|
|
216
240
|
if books := get_active_deals():
|
217
241
|
print_books(books)
|
218
242
|
else:
|
219
|
-
|
243
|
+
echo_info("No deals found.")
|
220
244
|
|
221
245
|
|
222
246
|
if __name__ == '__main__':
|
@@ -2,15 +2,17 @@ import asyncio
|
|
2
2
|
import csv
|
3
3
|
import shutil
|
4
4
|
import tempfile
|
5
|
+
from datetime import datetime
|
6
|
+
from typing import Callable, Awaitable, Optional
|
5
7
|
|
6
8
|
from tqdm.asyncio import tqdm_asyncio
|
7
9
|
|
8
|
-
from tbr_deal_finder.book import Book, BookFormat
|
10
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
9
11
|
from tbr_deal_finder.config import Config
|
10
|
-
from tbr_deal_finder.retailer
|
12
|
+
from tbr_deal_finder.retailer import LibroFM, Chirp
|
11
13
|
|
12
14
|
|
13
|
-
def
|
15
|
+
def get_book_authors(book: dict) -> str:
|
14
16
|
if authors := book.get('Authors'):
|
15
17
|
return authors
|
16
18
|
|
@@ -21,12 +23,12 @@ def _get_book_authors(book: dict) -> str:
|
|
21
23
|
return authors
|
22
24
|
|
23
25
|
|
24
|
-
def
|
26
|
+
def get_book_title(book: dict) -> str:
|
25
27
|
title = book['Title']
|
26
28
|
return title.split("(")[0].strip()
|
27
29
|
|
28
30
|
|
29
|
-
def
|
31
|
+
def is_tbr_book(book: dict) -> bool:
|
30
32
|
if "Read Status" in book:
|
31
33
|
return book["Read Status"] == "to-read"
|
32
34
|
elif "Bookshelves" in book:
|
@@ -35,65 +37,56 @@ def _is_tbr_book(book: dict) -> bool:
|
|
35
37
|
return True
|
36
38
|
|
37
39
|
|
38
|
-
def
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
for book_dict in csv.DictReader(file):
|
45
|
-
if not _is_tbr_book(book_dict):
|
46
|
-
continue
|
47
|
-
|
48
|
-
title = _get_book_title(book_dict)
|
49
|
-
authors = _get_book_authors(book_dict)
|
50
|
-
key = f'{title}__{authors}'
|
51
|
-
|
52
|
-
if key in tbr_book_map:
|
53
|
-
continue
|
54
|
-
|
55
|
-
tbr_book_map[key] = Book(
|
56
|
-
retailer="N/A",
|
57
|
-
title=title,
|
58
|
-
authors=authors,
|
59
|
-
list_price=0,
|
60
|
-
current_price=0,
|
61
|
-
timepoint=config.run_time,
|
62
|
-
format=BookFormat.NA,
|
63
|
-
audiobook_isbn=book_dict["audiobook_isbn"],
|
64
|
-
)
|
65
|
-
return list(tbr_book_map.values())
|
66
|
-
|
40
|
+
def requires_audiobook_list_price_default(config: Config) -> bool:
|
41
|
+
return bool(
|
42
|
+
"Libro.FM" in config.tracked_retailers
|
43
|
+
and "Audible" not in config.tracked_retailers
|
44
|
+
and "Chirp" not in config.tracked_retailers
|
45
|
+
)
|
67
46
|
|
68
|
-
async def maybe_set_library_export_audiobook_isbn(config: Config):
|
69
|
-
"""To get the price from Libro.fm for a book you need its ISBN
|
70
47
|
|
71
|
-
|
72
|
-
|
48
|
+
async def _maybe_set_column_for_library_exports(
|
49
|
+
config: Config,
|
50
|
+
attr_name: str,
|
51
|
+
get_book_callable: Callable[[Book, datetime, asyncio.Semaphore], Awaitable[Book]],
|
52
|
+
column_name: Optional[str] = None,
|
53
|
+
):
|
54
|
+
"""Adds a new column to all library exports that are missing it.
|
55
|
+
Uses get_book_callable to set the column value if a matching record couldn't be found
|
56
|
+
on that column in any other library export file.
|
57
|
+
|
58
|
+
:param config:
|
59
|
+
:param attr_name:
|
60
|
+
:param get_book_callable:
|
61
|
+
:param column_name:
|
62
|
+
:return:
|
73
63
|
"""
|
74
|
-
|
75
|
-
|
76
|
-
return
|
64
|
+
if not column_name:
|
65
|
+
column_name = attr_name
|
77
66
|
|
78
67
|
books_requiring_check_map = dict()
|
79
|
-
|
80
|
-
|
68
|
+
book_to_col_val_map = dict()
|
81
69
|
|
70
|
+
# Iterate all library export paths
|
82
71
|
for library_export_path in config.library_export_paths:
|
83
72
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
84
73
|
# Use csv.DictReader to get dictionaries with column headers
|
85
74
|
for book_dict in csv.DictReader(file):
|
86
|
-
if not
|
75
|
+
if not is_tbr_book(book_dict):
|
87
76
|
continue
|
88
77
|
|
89
|
-
title =
|
90
|
-
authors =
|
91
|
-
key = f'{title}__{authors}'
|
78
|
+
title = get_book_title(book_dict)
|
79
|
+
authors = get_book_authors(book_dict)
|
80
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
92
81
|
|
93
|
-
if
|
94
|
-
|
82
|
+
if column_name in book_dict:
|
83
|
+
# Keep state of value for this book/key
|
84
|
+
# in the event another export has the same book but the value is not set
|
85
|
+
book_to_col_val_map[key] = book_dict[column_name]
|
86
|
+
# Value has been found so a check no longer needs to be performed
|
95
87
|
books_requiring_check_map.pop(key, None)
|
96
|
-
elif key not in
|
88
|
+
elif key not in book_to_col_val_map:
|
89
|
+
# Not found, add the book to those requiring the column val to be set
|
97
90
|
books_requiring_check_map[key] = Book(
|
98
91
|
retailer="N/A",
|
99
92
|
title=title,
|
@@ -105,27 +98,33 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
105
98
|
)
|
106
99
|
|
107
100
|
if not books_requiring_check_map:
|
101
|
+
# Everything was resolved, nothing else to do
|
108
102
|
return
|
109
103
|
|
110
|
-
|
111
|
-
|
112
|
-
semaphore = asyncio.Semaphore(3)
|
104
|
+
semaphore = asyncio.Semaphore(5)
|
105
|
+
human_readable_name = attr_name.replace("_", " ").title()
|
113
106
|
|
114
|
-
#
|
115
|
-
|
107
|
+
# Get books with the appropriate transform applied
|
108
|
+
# Responsibility is on the callable here
|
109
|
+
enriched_books = await tqdm_asyncio.gather(
|
116
110
|
*[
|
117
|
-
|
111
|
+
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
118
112
|
],
|
119
|
-
desc="Getting required
|
113
|
+
desc=f"Getting required {human_readable_name} info"
|
120
114
|
)
|
115
|
+
updated_book_map = {
|
116
|
+
b.full_title_str: b
|
117
|
+
for b in enriched_books
|
118
|
+
}
|
119
|
+
|
121
120
|
|
122
|
-
# Go back and now add the
|
121
|
+
# Go back and now add the new column where it hasn't been set
|
123
122
|
for library_export_path in config.library_export_paths:
|
124
123
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
125
124
|
reader = csv.DictReader(file)
|
126
|
-
field_names = list(reader.fieldnames) + [
|
125
|
+
field_names = list(reader.fieldnames) + [column_name]
|
127
126
|
file_content = [book_dict for book_dict in reader]
|
128
|
-
if not file_content or
|
127
|
+
if not file_content or column_name in file_content[0]:
|
129
128
|
continue
|
130
129
|
|
131
130
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
@@ -134,22 +133,73 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
134
133
|
writer.writeheader()
|
135
134
|
|
136
135
|
for book_dict in file_content:
|
137
|
-
if
|
138
|
-
title =
|
139
|
-
authors =
|
140
|
-
key = f'{title}__{authors}'
|
141
|
-
|
142
|
-
if key in
|
143
|
-
|
136
|
+
if is_tbr_book(book_dict):
|
137
|
+
title = get_book_title(book_dict)
|
138
|
+
authors = get_book_authors(book_dict)
|
139
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
140
|
+
|
141
|
+
if key in book_to_col_val_map:
|
142
|
+
col_val = book_to_col_val_map[key]
|
143
|
+
elif key in updated_book_map:
|
144
|
+
book = updated_book_map[key]
|
145
|
+
col_val = getattr(book, attr_name)
|
144
146
|
else:
|
145
|
-
|
146
|
-
audiobook_isbn = book.audiobook_isbn
|
147
|
+
col_val = ""
|
147
148
|
|
148
|
-
book_dict[
|
149
|
+
book_dict[column_name] = col_val
|
149
150
|
else:
|
150
|
-
book_dict[
|
151
|
+
book_dict[column_name] = ""
|
151
152
|
|
152
153
|
writer.writerow(book_dict)
|
153
154
|
|
154
155
|
shutil.move(temp_filename, library_export_path)
|
155
156
|
|
157
|
+
|
158
|
+
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
159
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
160
|
+
|
161
|
+
As opposed to trying to get that every time latest-deals is run
|
162
|
+
we're just updating the export csv once to include the ISBN.
|
163
|
+
|
164
|
+
Unfortunately, we do have to get it at run time for wishlists.
|
165
|
+
"""
|
166
|
+
if "Libro.FM" not in config.tracked_retailers:
|
167
|
+
return
|
168
|
+
|
169
|
+
libro_fm = LibroFM()
|
170
|
+
await libro_fm.set_auth()
|
171
|
+
|
172
|
+
await _maybe_set_column_for_library_exports(
|
173
|
+
config,
|
174
|
+
"audiobook_isbn",
|
175
|
+
libro_fm.get_book_isbn,
|
176
|
+
)
|
177
|
+
|
178
|
+
|
179
|
+
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
180
|
+
"""Set a default list price for audiobooks
|
181
|
+
|
182
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
183
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
184
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
185
|
+
|
186
|
+
:param config:
|
187
|
+
:return:
|
188
|
+
"""
|
189
|
+
if not requires_audiobook_list_price_default(config):
|
190
|
+
return
|
191
|
+
|
192
|
+
chirp = Chirp()
|
193
|
+
await chirp.set_auth()
|
194
|
+
|
195
|
+
await _maybe_set_column_for_library_exports(
|
196
|
+
config,
|
197
|
+
"list_price",
|
198
|
+
chirp.get_book,
|
199
|
+
"audiobook_list_price"
|
200
|
+
)
|
201
|
+
|
202
|
+
|
203
|
+
async def maybe_enrich_library_exports(config: Config):
|
204
|
+
await _maybe_set_library_export_audiobook_isbn(config)
|
205
|
+
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import asyncio
|
2
|
+
import math
|
2
3
|
import os.path
|
3
4
|
from datetime import datetime
|
4
5
|
from textwrap import dedent
|
@@ -22,11 +23,9 @@ def login_url_callback(url: str) -> str:
|
|
22
23
|
|
23
24
|
try:
|
24
25
|
from playwright.sync_api import sync_playwright # type: ignore
|
25
|
-
use_playwright = True
|
26
26
|
except ImportError:
|
27
|
-
|
28
|
-
|
29
|
-
if use_playwright:
|
27
|
+
pass
|
28
|
+
else:
|
30
29
|
with sync_playwright() as p:
|
31
30
|
iphone = p.devices["iPhone 12 Pro"]
|
32
31
|
browser = p.webkit.launch(headless=False)
|
@@ -75,6 +74,23 @@ class Audible(Retailer):
|
|
75
74
|
def name(self) -> str:
|
76
75
|
return "Audible"
|
77
76
|
|
77
|
+
@property
|
78
|
+
def format(self) -> BookFormat:
|
79
|
+
return BookFormat.AUDIOBOOK
|
80
|
+
|
81
|
+
async def set_auth(self):
|
82
|
+
if not os.path.exists(_AUTH_PATH):
|
83
|
+
auth = audible.Authenticator.from_login_external(
|
84
|
+
locale=Config.locale,
|
85
|
+
login_url_callback=login_url_callback
|
86
|
+
)
|
87
|
+
|
88
|
+
# Save credentials to file
|
89
|
+
auth.to_file(_AUTH_PATH)
|
90
|
+
|
91
|
+
self._auth = audible.Authenticator.from_file(_AUTH_PATH)
|
92
|
+
self._client = audible.AsyncClient(auth=self._auth)
|
93
|
+
|
78
94
|
async def get_book(
|
79
95
|
self,
|
80
96
|
target: Book,
|
@@ -95,19 +111,7 @@ class Audible(Retailer):
|
|
95
111
|
]
|
96
112
|
)
|
97
113
|
|
98
|
-
|
99
|
-
return Book(
|
100
|
-
retailer=self.name,
|
101
|
-
title=title,
|
102
|
-
authors=authors,
|
103
|
-
list_price=0,
|
104
|
-
current_price=0,
|
105
|
-
timepoint=runtime,
|
106
|
-
format=BookFormat.AUDIOBOOK,
|
107
|
-
exists=False,
|
108
|
-
)
|
109
|
-
|
110
|
-
for product in match["products"]:
|
114
|
+
for product in match.get("products", []):
|
111
115
|
if product["title"] != title:
|
112
116
|
continue
|
113
117
|
|
@@ -132,15 +136,38 @@ class Audible(Retailer):
|
|
132
136
|
exists=False,
|
133
137
|
)
|
134
138
|
|
135
|
-
async def
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
139
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
140
|
+
wishlist_books = []
|
141
|
+
|
142
|
+
page = 0
|
143
|
+
total_pages = 1
|
144
|
+
page_size = 50
|
145
|
+
while page < total_pages:
|
146
|
+
response = await self._client.get(
|
147
|
+
"1.0/wishlist",
|
148
|
+
num_results=page_size,
|
149
|
+
page=page,
|
150
|
+
response_groups=[
|
151
|
+
"contributors, product_attrs, product_desc, product_extended_attrs"
|
152
|
+
]
|
140
153
|
)
|
141
154
|
|
142
|
-
|
143
|
-
|
155
|
+
for audiobook in response.get("products", []):
|
156
|
+
authors = [author["name"] for author in audiobook["authors"]]
|
157
|
+
wishlist_books.append(
|
158
|
+
Book(
|
159
|
+
retailer=self.name,
|
160
|
+
title=audiobook["title"],
|
161
|
+
authors=", ".join(authors),
|
162
|
+
list_price=1,
|
163
|
+
current_price=1,
|
164
|
+
timepoint=config.run_time,
|
165
|
+
format=self.format,
|
166
|
+
audiobook_isbn=audiobook["isbn"],
|
167
|
+
)
|
168
|
+
)
|
144
169
|
|
145
|
-
|
146
|
-
|
170
|
+
page += 1
|
171
|
+
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
172
|
+
|
173
|
+
return wishlist_books
|
@@ -1,38 +1,105 @@
|
|
1
1
|
import asyncio
|
2
|
-
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
from datetime import datetime, timedelta
|
3
5
|
|
4
6
|
import aiohttp
|
7
|
+
import click
|
5
8
|
|
9
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
10
|
+
from tbr_deal_finder.config import Config
|
6
11
|
from tbr_deal_finder.retailer.models import Retailer
|
7
12
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
8
|
-
from tbr_deal_finder.utils import currency_to_float
|
13
|
+
from tbr_deal_finder.utils import currency_to_float, echo_err
|
9
14
|
|
10
15
|
|
11
16
|
class Chirp(Retailer):
|
12
17
|
# Static because url for other locales just redirects to .com
|
13
|
-
_url: str = "https://
|
18
|
+
_url: str = "https://api.chirpbooks.com/api/graphql"
|
19
|
+
USER_AGENT = "ChirpBooks/5.13.9 (Android)"
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
self.auth_token = None
|
14
23
|
|
15
24
|
@property
|
16
25
|
def name(self) -> str:
|
17
26
|
return "Chirp"
|
18
27
|
|
28
|
+
@property
|
29
|
+
def format(self) -> BookFormat:
|
30
|
+
return BookFormat.AUDIOBOOK
|
31
|
+
|
32
|
+
async def make_request(self, request_type: str, **kwargs) -> dict:
|
33
|
+
headers = kwargs.pop("headers", {})
|
34
|
+
headers["Accept"] = "application/json"
|
35
|
+
headers["Content-Type"] = "application/json"
|
36
|
+
headers["User-Agent"] = self.USER_AGENT
|
37
|
+
if self.auth_token:
|
38
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
39
|
+
|
40
|
+
async with aiohttp.ClientSession() as http_client:
|
41
|
+
response = await http_client.request(
|
42
|
+
request_type.upper(),
|
43
|
+
self._url,
|
44
|
+
headers=headers,
|
45
|
+
**kwargs
|
46
|
+
)
|
47
|
+
if response.ok:
|
48
|
+
return await response.json()
|
49
|
+
else:
|
50
|
+
return {}
|
51
|
+
|
52
|
+
async def set_auth(self):
|
53
|
+
auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
|
54
|
+
if os.path.exists(auth_path):
|
55
|
+
with open(auth_path, "r") as f:
|
56
|
+
auth_info = json.load(f)
|
57
|
+
if auth_info:
|
58
|
+
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
59
|
+
max_token_age = datetime.now() - timedelta(days=5)
|
60
|
+
if token_created_at > max_token_age:
|
61
|
+
self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
|
62
|
+
return
|
63
|
+
|
64
|
+
response = await self.make_request(
|
65
|
+
"POST",
|
66
|
+
json={
|
67
|
+
"query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
|
68
|
+
"variables": {
|
69
|
+
"email": click.prompt("Chirp account email"),
|
70
|
+
"password": click.prompt("Chirp Password", hide_input=True),
|
71
|
+
}
|
72
|
+
}
|
73
|
+
)
|
74
|
+
if not response:
|
75
|
+
echo_err("Chirp login failed, please try again.")
|
76
|
+
await self.set_auth()
|
77
|
+
|
78
|
+
# Set token for future requests during the current execution
|
79
|
+
self.auth_token = response["data"]["signIn"]["user"]["token"]
|
80
|
+
|
81
|
+
response["created_at"] = datetime.now().timestamp()
|
82
|
+
with open(auth_path, "w") as f:
|
83
|
+
json.dump(response, f)
|
84
|
+
|
19
85
|
async def get_book(
|
20
86
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
21
87
|
) -> Book:
|
22
88
|
title = target.title
|
23
89
|
authors = target.authors
|
90
|
+
async with semaphore:
|
91
|
+
async with aiohttp.ClientSession() as http_client:
|
92
|
+
response = await http_client.request(
|
93
|
+
"POST",
|
94
|
+
self._url,
|
95
|
+
json={
|
96
|
+
"query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
|
97
|
+
"variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
|
98
|
+
"operationName": "AudiobookSearch"
|
99
|
+
}
|
100
|
+
)
|
101
|
+
response_body = await response.json()
|
24
102
|
|
25
|
-
async with aiohttp.ClientSession() as http_client:
|
26
|
-
response = await http_client.request(
|
27
|
-
"POST",
|
28
|
-
self._url,
|
29
|
-
json={
|
30
|
-
"query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
|
31
|
-
"variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
|
32
|
-
"operationName": "AudiobookSearch"
|
33
|
-
}
|
34
|
-
)
|
35
|
-
response_body = await response.json()
|
36
103
|
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
37
104
|
if not audiobooks:
|
38
105
|
return Book(
|
@@ -50,9 +117,10 @@ class Chirp(Retailer):
|
|
50
117
|
if not book["currentProduct"]:
|
51
118
|
continue
|
52
119
|
|
120
|
+
normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
|
53
121
|
if (
|
54
122
|
book["displayTitle"] == title
|
55
|
-
and
|
123
|
+
and any(author in normalized_authors for author in target.normalized_authors)
|
56
124
|
):
|
57
125
|
return Book(
|
58
126
|
retailer=self.name,
|
@@ -75,5 +143,40 @@ class Chirp(Retailer):
|
|
75
143
|
exists=False,
|
76
144
|
)
|
77
145
|
|
78
|
-
async def
|
79
|
-
|
146
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
147
|
+
wishlist_books = []
|
148
|
+
page = 1
|
149
|
+
|
150
|
+
while True:
|
151
|
+
response = await self.make_request(
|
152
|
+
"POST",
|
153
|
+
json={
|
154
|
+
"query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query FetchWishlistDealAudiobooks($page:Int,$pageSize:Int){currentUserWishlist{paginatedItems(filter:\"currently_promoted\",sort:\"promotion_end_date\",salability:current_or_future){totalCount objects(page:$page,pageSize:$pageSize){... on WishlistItem{id audiobook{...audiobookFields currentProduct{...productFields}}}}}}}",
|
155
|
+
"variables": {"page": page, "pageSize": 15},
|
156
|
+
"operationName": "FetchWishlistDealAudiobooks"
|
157
|
+
}
|
158
|
+
)
|
159
|
+
|
160
|
+
audiobooks = response.get(
|
161
|
+
"data", {}
|
162
|
+
).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
|
163
|
+
|
164
|
+
if not audiobooks:
|
165
|
+
return wishlist_books
|
166
|
+
|
167
|
+
for book in audiobooks:
|
168
|
+
audiobook = book["audiobook"]
|
169
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
170
|
+
wishlist_books.append(
|
171
|
+
Book(
|
172
|
+
retailer=self.name,
|
173
|
+
title=audiobook["displayTitle"],
|
174
|
+
authors=", ".join(authors),
|
175
|
+
list_price=1,
|
176
|
+
current_price=1,
|
177
|
+
timepoint=config.run_time,
|
178
|
+
format=self.format,
|
179
|
+
)
|
180
|
+
)
|
181
|
+
|
182
|
+
page += 1
|
@@ -3,12 +3,12 @@ import json
|
|
3
3
|
import os
|
4
4
|
import urllib.parse
|
5
5
|
from datetime import datetime, timedelta
|
6
|
-
from typing import Union
|
7
6
|
|
8
7
|
import aiohttp
|
9
8
|
import click
|
10
9
|
|
11
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
|
+
from tbr_deal_finder.config import Config
|
12
12
|
from tbr_deal_finder.retailer.models import Retailer
|
13
13
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
14
14
|
from tbr_deal_finder.utils import currency_to_float
|
@@ -33,11 +33,16 @@ class LibroFM(Retailer):
|
|
33
33
|
def name(self) -> str:
|
34
34
|
return "Libro.FM"
|
35
35
|
|
36
|
+
@property
|
37
|
+
def format(self) -> BookFormat:
|
38
|
+
return BookFormat.AUDIOBOOK
|
39
|
+
|
36
40
|
async def make_request(self, url_path: str, request_type: str, **kwargs) -> dict:
|
37
41
|
url = urllib.parse.urljoin(self.BASE_URL, url_path)
|
38
42
|
headers = kwargs.pop("headers", {})
|
39
43
|
headers["User-Agent"] = self.USER_AGENT
|
40
|
-
|
44
|
+
if self.auth_token:
|
45
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
41
46
|
|
42
47
|
async with aiohttp.ClientSession() as http_client:
|
43
48
|
response = await http_client.request(
|
@@ -75,28 +80,42 @@ class LibroFM(Retailer):
|
|
75
80
|
with open(auth_path, "w") as f:
|
76
81
|
json.dump(response, f)
|
77
82
|
|
78
|
-
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) ->
|
83
|
+
async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
|
84
|
+
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
85
|
+
|
79
86
|
title = book.title
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
"
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
87
|
+
|
88
|
+
async with semaphore:
|
89
|
+
response = await self.make_request(
|
90
|
+
f"api/v10/explore/search",
|
91
|
+
"GET",
|
92
|
+
params={
|
93
|
+
"q": title,
|
94
|
+
"searchby": "titles",
|
95
|
+
"sortby": "relevance#results",
|
96
|
+
},
|
97
|
+
)
|
89
98
|
|
90
99
|
for b in response["audiobook_collection"]["audiobooks"]:
|
91
|
-
|
100
|
+
normalized_authors = get_normalized_authors(b["authors"])
|
101
|
+
|
102
|
+
if (
|
103
|
+
title == b["title"]
|
104
|
+
and any(author in normalized_authors for author in book.normalized_authors)
|
105
|
+
):
|
92
106
|
book.audiobook_isbn = b["isbn"]
|
93
|
-
|
107
|
+
break
|
94
108
|
|
95
|
-
return
|
109
|
+
return book
|
96
110
|
|
97
111
|
async def get_book(
|
98
112
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
99
113
|
) -> Book:
|
114
|
+
if target.format == BookFormat.AUDIOBOOK and not target.audiobook_isbn:
|
115
|
+
# When "format" is AUDIOBOOK here that means the target was pulled from an audiobook retailer wishlist
|
116
|
+
# In this flow, there is no attempt to resolve the isbn ahead of time, so it's done here instead.
|
117
|
+
await self.get_book_isbn(target, runtime, semaphore)
|
118
|
+
|
100
119
|
if not target.audiobook_isbn:
|
101
120
|
return Book(
|
102
121
|
retailer=self.name,
|
@@ -108,17 +127,19 @@ class LibroFM(Retailer):
|
|
108
127
|
format=BookFormat.AUDIOBOOK,
|
109
128
|
exists=False,
|
110
129
|
)
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
130
|
+
|
131
|
+
async with semaphore:
|
132
|
+
response = await self.make_request(
|
133
|
+
f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
|
134
|
+
"GET"
|
135
|
+
)
|
115
136
|
|
116
137
|
if response:
|
117
138
|
return Book(
|
118
139
|
retailer=self.name,
|
119
140
|
title=target.title,
|
120
141
|
authors=target.authors,
|
121
|
-
list_price=
|
142
|
+
list_price=target.audiobook_list_price,
|
122
143
|
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
123
144
|
timepoint=runtime,
|
124
145
|
format=BookFormat.AUDIOBOOK,
|
@@ -134,3 +155,37 @@ class LibroFM(Retailer):
|
|
134
155
|
format=BookFormat.AUDIOBOOK,
|
135
156
|
exists=False,
|
136
157
|
)
|
158
|
+
|
159
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
160
|
+
wishlist_books = []
|
161
|
+
|
162
|
+
page = 1
|
163
|
+
total_pages = 1
|
164
|
+
while page <= total_pages:
|
165
|
+
response = await self.make_request(
|
166
|
+
f"api/v10/explore/wishlist",
|
167
|
+
"GET",
|
168
|
+
params=dict(page=2)
|
169
|
+
)
|
170
|
+
wishlist = response.get("data", {}).get("wishlist", {})
|
171
|
+
if not wishlist:
|
172
|
+
return []
|
173
|
+
|
174
|
+
for book in wishlist.get("audiobooks", []):
|
175
|
+
wishlist_books.append(
|
176
|
+
Book(
|
177
|
+
retailer=self.name,
|
178
|
+
title=book["title"],
|
179
|
+
authors=", ".join(book["authors"]),
|
180
|
+
list_price=1,
|
181
|
+
current_price=1,
|
182
|
+
timepoint=config.run_time,
|
183
|
+
format=self.format,
|
184
|
+
audiobook_isbn=book["isbn"],
|
185
|
+
)
|
186
|
+
)
|
187
|
+
|
188
|
+
page += 1
|
189
|
+
total_pages = wishlist["total_pages"]
|
190
|
+
|
191
|
+
return wishlist_books
|
@@ -2,7 +2,8 @@ import abc
|
|
2
2
|
import asyncio
|
3
3
|
from datetime import datetime
|
4
4
|
|
5
|
-
from tbr_deal_finder.book import Book
|
5
|
+
from tbr_deal_finder.book import Book, BookFormat
|
6
|
+
from tbr_deal_finder.config import Config
|
6
7
|
|
7
8
|
|
8
9
|
class Retailer(abc.ABC):
|
@@ -12,6 +13,21 @@ class Retailer(abc.ABC):
|
|
12
13
|
def name(self) -> str:
|
13
14
|
raise NotImplementedError
|
14
15
|
|
16
|
+
@property
|
17
|
+
def format(self) -> BookFormat:
|
18
|
+
"""The format of the books they sell.
|
19
|
+
|
20
|
+
For example,
|
21
|
+
Audible would be audiobooks
|
22
|
+
Kindle would be ebooks
|
23
|
+
|
24
|
+
:return:
|
25
|
+
"""
|
26
|
+
raise NotImplementedError
|
27
|
+
|
28
|
+
async def set_auth(self):
|
29
|
+
raise NotImplementedError
|
30
|
+
|
15
31
|
async def get_book(
|
16
32
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
17
33
|
) -> Book:
|
@@ -31,7 +47,7 @@ class Retailer(abc.ABC):
|
|
31
47
|
"""
|
32
48
|
raise NotImplementedError
|
33
49
|
|
34
|
-
async def
|
50
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
35
51
|
raise NotImplementedError
|
36
52
|
|
37
53
|
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -6,12 +6,12 @@ import click
|
|
6
6
|
import pandas as pd
|
7
7
|
from tqdm.asyncio import tqdm_asyncio
|
8
8
|
|
9
|
-
from tbr_deal_finder.book import Book, get_active_deals
|
9
|
+
from tbr_deal_finder.book import Book, get_active_deals, BookFormat
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.
|
11
|
+
from tbr_deal_finder.tracked_books import get_tbr_books
|
12
12
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
13
13
|
from tbr_deal_finder.retailer.models import Retailer
|
14
|
-
from tbr_deal_finder.utils import get_duckdb_conn
|
14
|
+
from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
|
15
15
|
|
16
16
|
|
17
17
|
def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
@@ -41,7 +41,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
41
41
|
# Any remaining values in active_deal_map mean that
|
42
42
|
# it wasn't found and should be marked for deletion
|
43
43
|
for deal in active_deal_map.values():
|
44
|
-
|
44
|
+
echo_warning(f"{str(deal)} is no longer active")
|
45
45
|
deal.timepoint = config.run_time
|
46
46
|
deal.deleted = True
|
47
47
|
df_data.append(deal.dict())
|
@@ -73,7 +73,7 @@ def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
|
|
73
73
|
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
74
|
"""Get Books with limited concurrency.
|
75
75
|
|
76
|
-
- Creates
|
76
|
+
- Creates semaphore to limit concurrent requests.
|
77
77
|
- Creates a list to store the response.
|
78
78
|
- Creates a list to store unresolved books.
|
79
79
|
|
@@ -101,12 +101,12 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
101
101
|
unresolved_books.append(book)
|
102
102
|
|
103
103
|
if retry_books := _retry_books(response, books):
|
104
|
-
|
104
|
+
echo_info("Attempting to find missing books with alternate title")
|
105
105
|
response.extend(await _get_books(config, retailer, retry_books))
|
106
106
|
elif unresolved_books:
|
107
107
|
click.echo()
|
108
108
|
for book in unresolved_books:
|
109
|
-
|
109
|
+
echo_info(f"{book.title} by {book.authors} not found")
|
110
110
|
|
111
111
|
return response
|
112
112
|
|
@@ -163,14 +163,23 @@ async def get_latest_deals(config: Config):
|
|
163
163
|
"""
|
164
164
|
|
165
165
|
books: list[Book] = []
|
166
|
-
tbr_books = get_tbr_books(config)
|
166
|
+
tbr_books = await get_tbr_books(config)
|
167
|
+
|
167
168
|
for retailer_str in config.tracked_retailers:
|
168
169
|
retailer = RETAILER_MAP[retailer_str]()
|
169
170
|
await retailer.set_auth()
|
170
171
|
|
171
|
-
|
172
|
+
# Don't check on deals in a specified format
|
173
|
+
# that does not match the format the retailer sells
|
174
|
+
relevant_tbr_books = [
|
175
|
+
book
|
176
|
+
for book in tbr_books
|
177
|
+
if book.format == BookFormat.NA or book.format == retailer.format
|
178
|
+
]
|
179
|
+
|
180
|
+
echo_info(f"Getting deals from {retailer.name}")
|
172
181
|
click.echo("\n---------------")
|
173
|
-
books.extend(await _get_books(config, retailer,
|
182
|
+
books.extend(await _get_books(config, retailer, relevant_tbr_books))
|
174
183
|
click.echo("---------------\n")
|
175
184
|
|
176
185
|
_apply_proper_list_prices(books)
|
@@ -0,0 +1,120 @@
|
|
1
|
+
import asyncio
|
2
|
+
import csv
|
3
|
+
|
4
|
+
from tqdm.asyncio import tqdm_asyncio
|
5
|
+
|
6
|
+
from tbr_deal_finder.book import Book, BookFormat, get_title_id
|
7
|
+
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP
|
8
|
+
from tbr_deal_finder.config import Config
|
9
|
+
from tbr_deal_finder.library_exports import (
|
10
|
+
get_book_authors,
|
11
|
+
get_book_title,
|
12
|
+
is_tbr_book,
|
13
|
+
requires_audiobook_list_price_default
|
14
|
+
)
|
15
|
+
from tbr_deal_finder.retailer.models import Retailer
|
16
|
+
from tbr_deal_finder.utils import currency_to_float
|
17
|
+
|
18
|
+
|
19
|
+
def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
20
|
+
"""Adds tbr books in the library export to the provided tbr_book_map
|
21
|
+
|
22
|
+
:param config:
|
23
|
+
:param tbr_book_map:
|
24
|
+
:return:
|
25
|
+
"""
|
26
|
+
for library_export_path in config.library_export_paths:
|
27
|
+
|
28
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
29
|
+
# Use csv.DictReader to get dictionaries with column headers
|
30
|
+
for book_dict in csv.DictReader(file):
|
31
|
+
if not is_tbr_book(book_dict):
|
32
|
+
continue
|
33
|
+
|
34
|
+
title = get_book_title(book_dict)
|
35
|
+
authors = get_book_authors(book_dict)
|
36
|
+
|
37
|
+
key = get_title_id(title, authors, BookFormat.NA)
|
38
|
+
if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
|
39
|
+
continue
|
40
|
+
|
41
|
+
tbr_book_map[key] = Book(
|
42
|
+
retailer="N/A",
|
43
|
+
title=title,
|
44
|
+
authors=authors,
|
45
|
+
list_price=0,
|
46
|
+
current_price=0,
|
47
|
+
timepoint=config.run_time,
|
48
|
+
format=BookFormat.NA,
|
49
|
+
audiobook_isbn=book_dict.get("audiobook_isbn"),
|
50
|
+
audiobook_list_price=currency_to_float(book_dict.get("audiobook_list_price") or 0),
|
51
|
+
)
|
52
|
+
|
53
|
+
|
54
|
+
async def _apply_dynamic_audiobook_list_price(config: Config, tbr_book_map: dict[str: Book]):
|
55
|
+
target_books = [
|
56
|
+
book
|
57
|
+
for book in tbr_book_map.values()
|
58
|
+
if book.format == BookFormat.AUDIOBOOK
|
59
|
+
]
|
60
|
+
if not target_books:
|
61
|
+
return
|
62
|
+
|
63
|
+
chirp = Chirp()
|
64
|
+
semaphore = asyncio.Semaphore(5)
|
65
|
+
books_with_pricing: list[Book] = await tqdm_asyncio.gather(
|
66
|
+
*[
|
67
|
+
chirp.get_book(book, config.run_time, semaphore)
|
68
|
+
for book in target_books
|
69
|
+
],
|
70
|
+
desc=f"Getting list prices for Libro.FM wishlist"
|
71
|
+
)
|
72
|
+
for book in books_with_pricing:
|
73
|
+
tbr_book_map[book.title_id].audiobook_list_price = book.list_price
|
74
|
+
|
75
|
+
|
76
|
+
async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
77
|
+
"""Adds wishlist books in the library export to the provided tbr_book_map
|
78
|
+
Books added here has the format the retailer sells (e.g. Audiobook)
|
79
|
+
so deals are only checked for retailers with that type.
|
80
|
+
|
81
|
+
For example,
|
82
|
+
I as a user have Dune on my audible wishlist.
|
83
|
+
I want to see deals for it on Libro because it's an audiobook.
|
84
|
+
I don't want to see Kindle deals.
|
85
|
+
|
86
|
+
:param config:
|
87
|
+
:param tbr_book_map:
|
88
|
+
:return:
|
89
|
+
"""
|
90
|
+
for retailer_str in config.tracked_retailers:
|
91
|
+
retailer: Retailer = RETAILER_MAP[retailer_str]()
|
92
|
+
await retailer.set_auth()
|
93
|
+
|
94
|
+
for book in (await retailer.get_wishlist(config)):
|
95
|
+
na_key = get_title_id(book.title, book.authors, BookFormat.NA)
|
96
|
+
if na_key in tbr_book_map:
|
97
|
+
continue
|
98
|
+
|
99
|
+
key = book.title_id
|
100
|
+
if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
|
101
|
+
continue
|
102
|
+
|
103
|
+
tbr_book_map[key] = book
|
104
|
+
|
105
|
+
if requires_audiobook_list_price_default(config):
|
106
|
+
await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
|
107
|
+
|
108
|
+
|
109
|
+
async def get_tbr_books(config: Config) -> list[Book]:
|
110
|
+
tbr_book_map: dict[str: Book] = {}
|
111
|
+
|
112
|
+
# Get TBRs specified in the user library (StoryGraph/GoodReads) export
|
113
|
+
_library_export_tbr_books(config, tbr_book_map)
|
114
|
+
|
115
|
+
# Pull wishlist from tracked retailers
|
116
|
+
await _retailer_wishlist(config, tbr_book_map)
|
117
|
+
|
118
|
+
return list(tbr_book_map.values())
|
119
|
+
|
120
|
+
|
tbr_deal_finder/utils.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import re
|
2
2
|
from typing import Optional
|
3
3
|
|
4
|
+
import click
|
4
5
|
import duckdb
|
5
6
|
|
6
7
|
from tbr_deal_finder import TBR_DEALS_PATH, QUERY_PATH
|
@@ -38,3 +39,19 @@ def execute_query(
|
|
38
39
|
|
39
40
|
def get_query_by_name(file_name: str) -> str:
|
40
41
|
return QUERY_PATH.joinpath(file_name).read_text()
|
42
|
+
|
43
|
+
|
44
|
+
def echo_err(message):
|
45
|
+
click.secho(f'\n❌ {message}\n', fg='red', bold=True)
|
46
|
+
|
47
|
+
|
48
|
+
def echo_success(message):
|
49
|
+
click.secho(f'\n✅ {message}', fg='green', bold=True)
|
50
|
+
|
51
|
+
|
52
|
+
def echo_warning(message):
|
53
|
+
click.secho(f'\n⚠️ {message}', fg='yellow')
|
54
|
+
|
55
|
+
|
56
|
+
def echo_info(message):
|
57
|
+
click.secho(f'{message}', fg='blue')
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.6
|
4
4
|
Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
|
5
5
|
License: MIT
|
6
6
|
License-File: LICENSE
|
@@ -21,7 +21,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
21
21
|
|
22
22
|
## Features
|
23
23
|
- Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
24
|
-
- Supports multiple of the library exports above
|
24
|
+
- Supports multiple of the library exports above
|
25
|
+
- Tracks deals on the wishlist of all your configured retailers like audible
|
25
26
|
- Supports multiple locales and currencies
|
26
27
|
- Finds the latest and active deals from supported sellers
|
27
28
|
- Simple CLI interface for setup and usage
|
@@ -32,7 +33,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
32
33
|
### Audiobooks
|
33
34
|
* Audible
|
34
35
|
* Chirp
|
35
|
-
* Libro.fm
|
36
|
+
* Libro.fm
|
36
37
|
|
37
38
|
### Locales
|
38
39
|
* US
|
@@ -0,0 +1,22 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=ZnwuIU2-rP0UC12fSC_HiTXpmgBp5XrRiFe82OLQ-E0,3786
|
3
|
+
tbr_deal_finder/cli.py,sha256=jIwzyESLGc77Wyxsv4XjEfMHZHjQI_PTmBLamc9tiV0,7284
|
4
|
+
tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
|
5
|
+
tbr_deal_finder/library_exports.py,sha256=Hupx3mJyhvqXEslR2R3ifG9ykSKxkOb-H3gGK_CRx68,7133
|
6
|
+
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=wMziFXCvrJQ_i4IdsXVPlaXnUIeGTRPt_rwbPfqX2FE,6742
|
8
|
+
tbr_deal_finder/tracked_books.py,sha256=EKecARSOMPPkmjSSWfWQw9B-TnoSd3-6AQepc2EYTz0,4031
|
9
|
+
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
10
|
+
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
11
|
+
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
12
|
+
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
13
|
+
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
14
|
+
tbr_deal_finder/retailer/audible.py,sha256=AY8jippIQe0XqCXk9iLb3CHADIYIG3orlctNQRSv2q8,5315
|
15
|
+
tbr_deal_finder/retailer/chirp.py,sha256=BVtHsrM0nsMmT2fxDnUliXVWfY2xZY8TR3FWZzyaxIA,8042
|
16
|
+
tbr_deal_finder/retailer/librofm.py,sha256=ZiowAIpDYnuH6KREdPK874t-Handlr0jZ9Mj0QMVGis,6428
|
17
|
+
tbr_deal_finder/retailer/models.py,sha256=wAGZtp0BWz9vlZCcWZqll8gwXUP6-6oFtsWv3gCXEHM,1415
|
18
|
+
tbr_deal_finder-0.1.6.dist-info/METADATA,sha256=CbRtsumWOcJ_VaabBoCqzGURzKjCOvvdk5koY11ukZE,4215
|
19
|
+
tbr_deal_finder-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
20
|
+
tbr_deal_finder-0.1.6.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
21
|
+
tbr_deal_finder-0.1.6.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
22
|
+
tbr_deal_finder-0.1.6.dist-info/RECORD,,
|
@@ -1,21 +0,0 @@
|
|
1
|
-
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
-
tbr_deal_finder/book.py,sha256=2MQirkxDIVKdQQ07U56zwOw45rC8KH-5aC932_X9dhE,3333
|
3
|
-
tbr_deal_finder/cli.py,sha256=8riqTK5QVPKyP3aUQ3hHjNbIuZ0ibF_WLiNFMBqddT4,6830
|
4
|
-
tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
|
5
|
-
tbr_deal_finder/library_exports.py,sha256=FJXBQV_9H5AI_cwNuGjvGQxgQ1RLlpldmDBwc_PsiK4,5474
|
6
|
-
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
-
tbr_deal_finder/retailer_deal.py,sha256=J3dGceB84wDQUV0FsW_0I0PLA5nU6LStzAx6sMNTo2c,6408
|
8
|
-
tbr_deal_finder/utils.py,sha256=c_AfIpfE1IAewUBiaRhPHjBM2o-fvuVVcWfM7jPEOvk,1021
|
9
|
-
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
10
|
-
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
11
|
-
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
12
|
-
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
13
|
-
tbr_deal_finder/retailer/audible.py,sha256=7z7rDQBcCwOhdATU4BJjsJ-QBP0HnxscMSGPmzN_K2k,4408
|
14
|
-
tbr_deal_finder/retailer/chirp.py,sha256=mi2uIhiXHxMnlRgtd1BZULEZbpF_K2HzxWubV4v_vvc,3540
|
15
|
-
tbr_deal_finder/retailer/librofm.py,sha256=M4WvGh3Gf3LVUE3KOCVtNKJB8koQasgybUFhKBvqBe0,4476
|
16
|
-
tbr_deal_finder/retailer/models.py,sha256=zEwyM_0ildB8p38sxfpE6p2dIvtwAjeaul-GkJlk2Fo,1012
|
17
|
-
tbr_deal_finder-0.1.4.dist-info/METADATA,sha256=oLSiX7Bb6Spb3DAAxB6cu0qMsWUfk8LnQgLtzGh76Ho,4157
|
18
|
-
tbr_deal_finder-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
19
|
-
tbr_deal_finder-0.1.4.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
20
|
-
tbr_deal_finder-0.1.4.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
21
|
-
tbr_deal_finder-0.1.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|