tbr-deal-finder 0.1.5__py3-none-any.whl → 0.1.7__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 +44 -5
- tbr_deal_finder/cli.py +31 -27
- tbr_deal_finder/config.py +10 -2
- tbr_deal_finder/library_exports.py +123 -70
- tbr_deal_finder/owned_books.py +18 -0
- tbr_deal_finder/retailer/audible.py +89 -26
- tbr_deal_finder/retailer/chirp.py +168 -7
- tbr_deal_finder/retailer/librofm.py +90 -8
- tbr_deal_finder/retailer/models.py +20 -2
- tbr_deal_finder/retailer_deal.py +50 -28
- tbr_deal_finder/tracked_books.py +120 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.7.dist-info}/METADATA +9 -6
- tbr_deal_finder-0.1.7.dist-info/RECORD +23 -0
- tbr_deal_finder-0.1.5.dist-info/RECORD +0 -21
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.7.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.7.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.7.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -5,6 +5,7 @@ from enum import Enum
|
|
5
5
|
from typing import Optional, Union
|
6
6
|
|
7
7
|
import click
|
8
|
+
from Levenshtein import ratio
|
8
9
|
from unidecode import unidecode
|
9
10
|
|
10
11
|
from tbr_deal_finder.config import Config
|
@@ -26,7 +27,11 @@ class Book:
|
|
26
27
|
current_price: float
|
27
28
|
timepoint: datetime
|
28
29
|
format: Union[BookFormat, str]
|
30
|
+
|
31
|
+
# Metadata really only used for tracked books.
|
32
|
+
# See get_tbr_books for more context
|
29
33
|
audiobook_isbn: str = None
|
34
|
+
audiobook_list_price: float = 0
|
30
35
|
|
31
36
|
deleted: bool = False
|
32
37
|
|
@@ -35,16 +40,20 @@ class Book:
|
|
35
40
|
normalized_authors: list[str] = None
|
36
41
|
|
37
42
|
def __post_init__(self):
|
43
|
+
self.current_price = round(self.current_price, 2)
|
44
|
+
self.list_price = round(self.list_price, 2)
|
45
|
+
self.normalized_authors = get_normalized_authors(self.authors)
|
46
|
+
|
47
|
+
# Strip the title down to its most basic repr
|
48
|
+
# Improves hit rate on retailers
|
49
|
+
self.title = self.title.split(":")[0].split("(")[0].strip()
|
50
|
+
|
38
51
|
if not self.deal_id:
|
39
|
-
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.
|
52
|
+
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
40
53
|
|
41
54
|
if isinstance(self.format, str):
|
42
55
|
self.format = BookFormat(self.format)
|
43
56
|
|
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
57
|
def discount(self) -> int:
|
49
58
|
return int((self.list_price/self.current_price - 1) * 100)
|
50
59
|
|
@@ -56,6 +65,10 @@ class Book:
|
|
56
65
|
def title_id(self) -> str:
|
57
66
|
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
58
67
|
|
68
|
+
@property
|
69
|
+
def full_title_str(self) -> str:
|
70
|
+
return f"{self.title}__{self.normalized_authors}"
|
71
|
+
|
59
72
|
def list_price_string(self):
|
60
73
|
return self.price_to_string(self.list_price)
|
61
74
|
|
@@ -74,6 +87,7 @@ class Book:
|
|
74
87
|
response = dataclasses.asdict(self)
|
75
88
|
response["format"] = self.format.value
|
76
89
|
del response["audiobook_isbn"]
|
90
|
+
del response["audiobook_list_price"]
|
77
91
|
del response["exists"]
|
78
92
|
del response["normalized_authors"]
|
79
93
|
|
@@ -109,8 +123,33 @@ def print_books(books: list[Book]):
|
|
109
123
|
click.echo(str(book))
|
110
124
|
|
111
125
|
|
126
|
+
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
127
|
+
return f"{title}__{get_normalized_authors(authors)}"
|
128
|
+
|
129
|
+
|
130
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
131
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
132
|
+
|
133
|
+
|
112
134
|
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
113
135
|
if isinstance(authors, str):
|
114
136
|
authors = [i for i in authors.split(",")]
|
115
137
|
|
116
138
|
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
139
|
+
|
140
|
+
|
141
|
+
def is_matching_authors(a1: list[str], a2: list[str]) -> bool:
|
142
|
+
"""Checks if two normalized authors are matching.
|
143
|
+
Matching here means that they are at least 80% similar using levenshtein distance.
|
144
|
+
|
145
|
+
Score is calculated as follows:
|
146
|
+
1 - (distance / (len1 + len2))
|
147
|
+
|
148
|
+
:param a1:
|
149
|
+
:param a2:
|
150
|
+
:return:
|
151
|
+
"""
|
152
|
+
return any(
|
153
|
+
any(ratio(author1, author2, score_cutoff=.8) for author2 in a2)
|
154
|
+
for author1 in a1
|
155
|
+
)
|
tbr_deal_finder/cli.py
CHANGED
@@ -9,7 +9,7 @@ import click
|
|
9
9
|
import questionary
|
10
10
|
|
11
11
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.library_exports import
|
12
|
+
from tbr_deal_finder.library_exports import maybe_enrich_library_exports
|
13
13
|
from tbr_deal_finder.migrations import make_migrations
|
14
14
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
15
15
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
@@ -28,12 +28,6 @@ from tbr_deal_finder.utils import (
|
|
28
28
|
def cli():
|
29
29
|
make_migrations()
|
30
30
|
|
31
|
-
# Check that the config exists for all commands ran
|
32
|
-
try:
|
33
|
-
Config.load()
|
34
|
-
except FileNotFoundError:
|
35
|
-
_set_config()
|
36
|
-
|
37
31
|
|
38
32
|
def _add_path(existing_paths: list[str]) -> Union[str, None]:
|
39
33
|
try:
|
@@ -68,24 +62,26 @@ def _set_library_export_paths(config: Config):
|
|
68
62
|
Ensures that only valid, unique paths are added. Updates the config in-place.
|
69
63
|
"""
|
70
64
|
while True:
|
71
|
-
if config.library_export_paths:
|
72
|
-
|
73
|
-
choices = ["Add new path", "Remove path", "Done"]
|
74
|
-
else:
|
75
|
-
choices = ["Add new path", "Done"]
|
76
|
-
|
77
|
-
try:
|
78
|
-
user_selection = questionary.select(
|
79
|
-
"What change would you like to make to your library export paths",
|
80
|
-
choices=choices,
|
81
|
-
).ask()
|
82
|
-
except (KeyError, KeyboardInterrupt, TypeError):
|
83
|
-
return
|
65
|
+
if len(config.library_export_paths) > 0:
|
66
|
+
choices = ["Add new path", "Remove path", "Done"]
|
84
67
|
else:
|
85
|
-
|
86
|
-
|
68
|
+
choices = ["Add new path", "Done"]
|
69
|
+
|
70
|
+
try:
|
71
|
+
user_selection = questionary.select(
|
72
|
+
"What change would you like to make to your library export paths",
|
73
|
+
choices=choices,
|
74
|
+
).ask()
|
75
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
76
|
+
return
|
87
77
|
|
88
78
|
if user_selection == "Done":
|
79
|
+
if not config.library_export_paths:
|
80
|
+
if not click.confirm(
|
81
|
+
"Don't add a GoodReads or StoryGraph export and use wishlist entirely? "
|
82
|
+
"Note: Wishlist checks will still work even if you add your StoryGraph/GoodReads export."
|
83
|
+
):
|
84
|
+
continue
|
89
85
|
return
|
90
86
|
elif user_selection == "Add new path":
|
91
87
|
if new_path := _add_path(config.library_export_paths):
|
@@ -124,15 +120,20 @@ def _set_locale(config: Config):
|
|
124
120
|
|
125
121
|
|
126
122
|
def _set_tracked_retailers(config: Config):
|
123
|
+
if not config.tracked_retailers:
|
124
|
+
echo_info(
|
125
|
+
"If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
|
126
|
+
"Note: I don't work for Chirp and this isn't a paid plug."
|
127
|
+
)
|
128
|
+
|
127
129
|
while True:
|
128
130
|
user_response = questionary.checkbox(
|
129
|
-
"Select the retailers you want to check deals for.\n"
|
130
|
-
"Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.\n",
|
131
|
+
"Select the retailers you want to check deals for.\n",
|
131
132
|
choices=[
|
132
133
|
questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
|
133
134
|
for retailer in RETAILER_MAP.keys()
|
134
135
|
]).ask()
|
135
|
-
if len(user_response) >
|
136
|
+
if len(user_response) > 0:
|
136
137
|
break
|
137
138
|
else:
|
138
139
|
echo_err("You must track deals for at least one retailer.")
|
@@ -183,9 +184,12 @@ def setup():
|
|
183
184
|
@cli.command()
|
184
185
|
def latest_deals():
|
185
186
|
"""Find book deals from your Library export."""
|
186
|
-
|
187
|
+
try:
|
188
|
+
config = Config.load()
|
189
|
+
except FileNotFoundError:
|
190
|
+
config = _set_config()
|
187
191
|
|
188
|
-
asyncio.run(
|
192
|
+
asyncio.run(maybe_enrich_library_exports(config))
|
189
193
|
|
190
194
|
db_conn = get_duckdb_conn()
|
191
195
|
results = execute_query(
|
tbr_deal_finder/config.py
CHANGED
@@ -62,10 +62,16 @@ class Config:
|
|
62
62
|
tracked_retailers_str = parser.get('DEFAULT', 'tracked_retailers')
|
63
63
|
locale = parser.get('DEFAULT', 'locale', fallback="us")
|
64
64
|
cls.set_locale(locale)
|
65
|
+
|
66
|
+
if export_paths_str:
|
67
|
+
library_export_paths = [i.strip() for i in export_paths_str.split(",")]
|
68
|
+
else:
|
69
|
+
library_export_paths = []
|
70
|
+
|
65
71
|
return cls(
|
66
72
|
max_price=parser.getfloat('DEFAULT', 'max_price', fallback=8.0),
|
67
73
|
min_discount=parser.getint('DEFAULT', 'min_discount', fallback=35),
|
68
|
-
library_export_paths=
|
74
|
+
library_export_paths=library_export_paths,
|
69
75
|
tracked_retailers=[i.strip() for i in tracked_retailers_str.split(",")]
|
70
76
|
)
|
71
77
|
|
@@ -78,7 +84,9 @@ class Config:
|
|
78
84
|
return ", ".join(self.tracked_retailers)
|
79
85
|
|
80
86
|
def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
|
81
|
-
if
|
87
|
+
if not library_export_paths:
|
88
|
+
self.library_export_paths = []
|
89
|
+
elif isinstance(library_export_paths, str):
|
82
90
|
self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
|
83
91
|
else:
|
84
92
|
self.library_export_paths = library_export_paths
|
@@ -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_full_title_str
|
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,59 @@ 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
|
-
if "Libro.FM" not in config.tracked_retailers:
|
64
|
+
if not config.library_export_paths:
|
76
65
|
return
|
77
66
|
|
78
|
-
|
79
|
-
|
67
|
+
if not column_name:
|
68
|
+
column_name = attr_name
|
80
69
|
|
70
|
+
books_requiring_check_map = dict()
|
71
|
+
book_to_col_val_map = dict()
|
81
72
|
|
73
|
+
# Iterate all library export paths
|
82
74
|
for library_export_path in config.library_export_paths:
|
83
75
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
84
76
|
# Use csv.DictReader to get dictionaries with column headers
|
85
77
|
for book_dict in csv.DictReader(file):
|
86
|
-
if not
|
78
|
+
if not is_tbr_book(book_dict):
|
87
79
|
continue
|
88
80
|
|
89
|
-
title =
|
90
|
-
authors =
|
91
|
-
key =
|
81
|
+
title = get_book_title(book_dict)
|
82
|
+
authors = get_book_authors(book_dict)
|
83
|
+
key = get_full_title_str(title, authors)
|
92
84
|
|
93
|
-
if
|
94
|
-
|
85
|
+
if column_name in book_dict:
|
86
|
+
# Keep state of value for this book/key
|
87
|
+
# in the event another export has the same book but the value is not set
|
88
|
+
book_to_col_val_map[key] = book_dict[column_name]
|
89
|
+
# Value has been found so a check no longer needs to be performed
|
95
90
|
books_requiring_check_map.pop(key, None)
|
96
|
-
elif key not in
|
91
|
+
elif key not in book_to_col_val_map:
|
92
|
+
# Not found, add the book to those requiring the column val to be set
|
97
93
|
books_requiring_check_map[key] = Book(
|
98
94
|
retailer="N/A",
|
99
95
|
title=title,
|
@@ -105,27 +101,33 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
105
101
|
)
|
106
102
|
|
107
103
|
if not books_requiring_check_map:
|
104
|
+
# Everything was resolved, nothing else to do
|
108
105
|
return
|
109
106
|
|
110
|
-
|
111
|
-
|
112
|
-
semaphore = asyncio.Semaphore(3)
|
107
|
+
semaphore = asyncio.Semaphore(5)
|
108
|
+
human_readable_name = attr_name.replace("_", " ").title()
|
113
109
|
|
114
|
-
#
|
115
|
-
|
110
|
+
# Get books with the appropriate transform applied
|
111
|
+
# Responsibility is on the callable here
|
112
|
+
enriched_books = await tqdm_asyncio.gather(
|
116
113
|
*[
|
117
|
-
|
114
|
+
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
118
115
|
],
|
119
|
-
desc="Getting required
|
116
|
+
desc=f"Getting required {human_readable_name} info"
|
120
117
|
)
|
118
|
+
updated_book_map = {
|
119
|
+
b.full_title_str: b
|
120
|
+
for b in enriched_books
|
121
|
+
}
|
122
|
+
|
121
123
|
|
122
|
-
# Go back and now add the
|
124
|
+
# Go back and now add the new column where it hasn't been set
|
123
125
|
for library_export_path in config.library_export_paths:
|
124
126
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
125
127
|
reader = csv.DictReader(file)
|
126
|
-
field_names = list(reader.fieldnames) + [
|
128
|
+
field_names = list(reader.fieldnames) + [column_name]
|
127
129
|
file_content = [book_dict for book_dict in reader]
|
128
|
-
if not file_content or
|
130
|
+
if not file_content or column_name in file_content[0]:
|
129
131
|
continue
|
130
132
|
|
131
133
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
@@ -134,22 +136,73 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
134
136
|
writer.writeheader()
|
135
137
|
|
136
138
|
for book_dict in file_content:
|
137
|
-
if
|
138
|
-
title =
|
139
|
-
authors =
|
140
|
-
key =
|
141
|
-
|
142
|
-
if key in
|
143
|
-
|
139
|
+
if is_tbr_book(book_dict):
|
140
|
+
title = get_book_title(book_dict)
|
141
|
+
authors = get_book_authors(book_dict)
|
142
|
+
key = get_full_title_str(title, authors)
|
143
|
+
|
144
|
+
if key in book_to_col_val_map:
|
145
|
+
col_val = book_to_col_val_map[key]
|
146
|
+
elif key in updated_book_map:
|
147
|
+
book = updated_book_map[key]
|
148
|
+
col_val = getattr(book, attr_name)
|
144
149
|
else:
|
145
|
-
|
146
|
-
audiobook_isbn = book.audiobook_isbn
|
150
|
+
col_val = ""
|
147
151
|
|
148
|
-
book_dict[
|
152
|
+
book_dict[column_name] = col_val
|
149
153
|
else:
|
150
|
-
book_dict[
|
154
|
+
book_dict[column_name] = ""
|
151
155
|
|
152
156
|
writer.writerow(book_dict)
|
153
157
|
|
154
158
|
shutil.move(temp_filename, library_export_path)
|
155
159
|
|
160
|
+
|
161
|
+
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
162
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
163
|
+
|
164
|
+
As opposed to trying to get that every time latest-deals is run
|
165
|
+
we're just updating the export csv once to include the ISBN.
|
166
|
+
|
167
|
+
Unfortunately, we do have to get it at run time for wishlists.
|
168
|
+
"""
|
169
|
+
if "Libro.FM" not in config.tracked_retailers:
|
170
|
+
return
|
171
|
+
|
172
|
+
libro_fm = LibroFM()
|
173
|
+
await libro_fm.set_auth()
|
174
|
+
|
175
|
+
await _maybe_set_column_for_library_exports(
|
176
|
+
config,
|
177
|
+
"audiobook_isbn",
|
178
|
+
libro_fm.get_book_isbn,
|
179
|
+
)
|
180
|
+
|
181
|
+
|
182
|
+
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
183
|
+
"""Set a default list price for audiobooks
|
184
|
+
|
185
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
186
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
187
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
188
|
+
|
189
|
+
:param config:
|
190
|
+
:return:
|
191
|
+
"""
|
192
|
+
if not requires_audiobook_list_price_default(config):
|
193
|
+
return
|
194
|
+
|
195
|
+
chirp = Chirp()
|
196
|
+
await chirp.set_auth()
|
197
|
+
|
198
|
+
await _maybe_set_column_for_library_exports(
|
199
|
+
config,
|
200
|
+
"list_price",
|
201
|
+
chirp.get_book,
|
202
|
+
"audiobook_list_price"
|
203
|
+
)
|
204
|
+
|
205
|
+
|
206
|
+
async def maybe_enrich_library_exports(config: Config):
|
207
|
+
await _maybe_set_library_export_audiobook_isbn(config)
|
208
|
+
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -0,0 +1,18 @@
|
|
1
|
+
from tbr_deal_finder.book import Book
|
2
|
+
from tbr_deal_finder.config import Config
|
3
|
+
from tbr_deal_finder.retailer import RETAILER_MAP
|
4
|
+
from tbr_deal_finder.retailer.models import Retailer
|
5
|
+
|
6
|
+
|
7
|
+
async def get_owned_books(config: Config) -> list[Book]:
|
8
|
+
owned_books = []
|
9
|
+
|
10
|
+
for retailer_str in config.tracked_retailers:
|
11
|
+
retailer: Retailer = RETAILER_MAP[retailer_str]()
|
12
|
+
await retailer.set_auth()
|
13
|
+
|
14
|
+
owned_books.extend(
|
15
|
+
await retailer.get_library(config)
|
16
|
+
)
|
17
|
+
|
18
|
+
return owned_books
|
@@ -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,74 @@ 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
|
174
|
+
|
175
|
+
async def get_library(self, config: Config) -> list[Book]:
|
176
|
+
library_books = []
|
177
|
+
|
178
|
+
page = 1
|
179
|
+
total_pages = 1
|
180
|
+
page_size = 1000
|
181
|
+
while page <= total_pages:
|
182
|
+
response = await self._client.get(
|
183
|
+
"1.0/library",
|
184
|
+
num_results=page_size,
|
185
|
+
page=page,
|
186
|
+
response_groups=[
|
187
|
+
"contributors, product_attrs, product_desc, product_extended_attrs"
|
188
|
+
]
|
189
|
+
)
|
190
|
+
|
191
|
+
for audiobook in response.get("items", []):
|
192
|
+
authors = [author["name"] for author in audiobook["authors"]]
|
193
|
+
library_books.append(
|
194
|
+
Book(
|
195
|
+
retailer=self.name,
|
196
|
+
title=audiobook["title"],
|
197
|
+
authors=", ".join(authors),
|
198
|
+
list_price=1,
|
199
|
+
current_price=1,
|
200
|
+
timepoint=config.run_time,
|
201
|
+
format=self.format,
|
202
|
+
audiobook_isbn=audiobook["isbn"],
|
203
|
+
)
|
204
|
+
)
|
205
|
+
|
206
|
+
page += 1
|
207
|
+
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
208
|
+
|
209
|
+
return library_books
|