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
@@ -1,21 +1,88 @@
|
|
1
1
|
import asyncio
|
2
|
-
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
from datetime import datetime, timedelta
|
5
|
+
from textwrap import dedent
|
3
6
|
|
4
7
|
import aiohttp
|
8
|
+
import click
|
5
9
|
|
10
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
11
|
+
from tbr_deal_finder.config import Config
|
6
12
|
from tbr_deal_finder.retailer.models import Retailer
|
7
|
-
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.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
|
+
from tbr_deal_finder.utils import currency_to_float, echo_err
|
9
15
|
|
10
16
|
|
11
17
|
class Chirp(Retailer):
|
12
18
|
# Static because url for other locales just redirects to .com
|
13
|
-
_url: str = "https://
|
19
|
+
_url: str = "https://api.chirpbooks.com/api/graphql"
|
20
|
+
USER_AGENT = "ChirpBooks/5.13.9 (Android)"
|
21
|
+
|
22
|
+
def __init__(self):
|
23
|
+
self.auth_token = None
|
14
24
|
|
15
25
|
@property
|
16
26
|
def name(self) -> str:
|
17
27
|
return "Chirp"
|
18
28
|
|
29
|
+
@property
|
30
|
+
def format(self) -> BookFormat:
|
31
|
+
return BookFormat.AUDIOBOOK
|
32
|
+
|
33
|
+
async def make_request(self, request_type: str, **kwargs) -> dict:
|
34
|
+
headers = kwargs.pop("headers", {})
|
35
|
+
headers["Accept"] = "application/json"
|
36
|
+
headers["Content-Type"] = "application/json"
|
37
|
+
headers["User-Agent"] = self.USER_AGENT
|
38
|
+
if self.auth_token:
|
39
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
40
|
+
|
41
|
+
async with aiohttp.ClientSession() as http_client:
|
42
|
+
response = await http_client.request(
|
43
|
+
request_type.upper(),
|
44
|
+
self._url,
|
45
|
+
headers=headers,
|
46
|
+
**kwargs
|
47
|
+
)
|
48
|
+
if response.ok:
|
49
|
+
return await response.json()
|
50
|
+
else:
|
51
|
+
return {}
|
52
|
+
|
53
|
+
async def set_auth(self):
|
54
|
+
auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
|
55
|
+
if os.path.exists(auth_path):
|
56
|
+
with open(auth_path, "r") as f:
|
57
|
+
auth_info = json.load(f)
|
58
|
+
if auth_info:
|
59
|
+
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
60
|
+
max_token_age = datetime.now() - timedelta(days=5)
|
61
|
+
if token_created_at > max_token_age:
|
62
|
+
self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
|
63
|
+
return
|
64
|
+
|
65
|
+
response = await self.make_request(
|
66
|
+
"POST",
|
67
|
+
json={
|
68
|
+
"query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
|
69
|
+
"variables": {
|
70
|
+
"email": click.prompt("Chirp account email"),
|
71
|
+
"password": click.prompt("Chirp Password", hide_input=True),
|
72
|
+
}
|
73
|
+
}
|
74
|
+
)
|
75
|
+
if not response:
|
76
|
+
echo_err("Chirp login failed, please try again.")
|
77
|
+
await self.set_auth()
|
78
|
+
|
79
|
+
# Set token for future requests during the current execution
|
80
|
+
self.auth_token = response["data"]["signIn"]["user"]["token"]
|
81
|
+
|
82
|
+
response["created_at"] = datetime.now().timestamp()
|
83
|
+
with open(auth_path, "w") as f:
|
84
|
+
json.dump(response, f)
|
85
|
+
|
19
86
|
async def get_book(
|
20
87
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
21
88
|
) -> Book:
|
@@ -51,9 +118,10 @@ class Chirp(Retailer):
|
|
51
118
|
if not book["currentProduct"]:
|
52
119
|
continue
|
53
120
|
|
121
|
+
normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
|
54
122
|
if (
|
55
123
|
book["displayTitle"] == title
|
56
|
-
and
|
124
|
+
and is_matching_authors(target.normalized_authors, normalized_authors)
|
57
125
|
):
|
58
126
|
return Book(
|
59
127
|
retailer=self.name,
|
@@ -76,5 +144,98 @@ class Chirp(Retailer):
|
|
76
144
|
exists=False,
|
77
145
|
)
|
78
146
|
|
79
|
-
async def
|
80
|
-
|
147
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
148
|
+
wishlist_books = []
|
149
|
+
page = 1
|
150
|
+
|
151
|
+
while True:
|
152
|
+
response = await self.make_request(
|
153
|
+
"POST",
|
154
|
+
json={
|
155
|
+
"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}}}}}}}",
|
156
|
+
"variables": {"page": page, "pageSize": 15},
|
157
|
+
"operationName": "FetchWishlistDealAudiobooks"
|
158
|
+
}
|
159
|
+
)
|
160
|
+
|
161
|
+
audiobooks = response.get(
|
162
|
+
"data", {}
|
163
|
+
).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
|
164
|
+
|
165
|
+
if not audiobooks:
|
166
|
+
return wishlist_books
|
167
|
+
|
168
|
+
for book in audiobooks:
|
169
|
+
audiobook = book["audiobook"]
|
170
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
171
|
+
wishlist_books.append(
|
172
|
+
Book(
|
173
|
+
retailer=self.name,
|
174
|
+
title=audiobook["displayTitle"],
|
175
|
+
authors=", ".join(authors),
|
176
|
+
list_price=1,
|
177
|
+
current_price=1,
|
178
|
+
timepoint=config.run_time,
|
179
|
+
format=self.format,
|
180
|
+
)
|
181
|
+
)
|
182
|
+
|
183
|
+
page += 1
|
184
|
+
|
185
|
+
async def get_library(self, config: Config) -> list[Book]:
|
186
|
+
library_books = []
|
187
|
+
page = 1
|
188
|
+
query = dedent("""
|
189
|
+
query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
|
190
|
+
currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
|
191
|
+
audiobook {
|
192
|
+
id
|
193
|
+
allAuthors{name}
|
194
|
+
displayTitle
|
195
|
+
displayAuthors
|
196
|
+
displayNarrators
|
197
|
+
durationMs
|
198
|
+
description
|
199
|
+
publisher
|
200
|
+
}
|
201
|
+
archived
|
202
|
+
playable
|
203
|
+
finishedAt
|
204
|
+
currentOverallOffsetMs
|
205
|
+
}
|
206
|
+
}
|
207
|
+
""")
|
208
|
+
|
209
|
+
while True:
|
210
|
+
response = await self.make_request(
|
211
|
+
"POST",
|
212
|
+
json={
|
213
|
+
"query": query,
|
214
|
+
"variables": {"page": page, "pageSize": 15},
|
215
|
+
"operationName": "AndroidCurrentUserAudiobooks"
|
216
|
+
}
|
217
|
+
)
|
218
|
+
|
219
|
+
audiobooks = response.get(
|
220
|
+
"data", {}
|
221
|
+
).get("currentUserAudiobooks", [])
|
222
|
+
|
223
|
+
if not audiobooks:
|
224
|
+
return library_books
|
225
|
+
|
226
|
+
for book in audiobooks:
|
227
|
+
audiobook = book["audiobook"]
|
228
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
229
|
+
library_books.append(
|
230
|
+
Book(
|
231
|
+
retailer=self.name,
|
232
|
+
title=audiobook["displayTitle"],
|
233
|
+
authors=", ".join(authors),
|
234
|
+
list_price=1,
|
235
|
+
current_price=1,
|
236
|
+
timepoint=config.run_time,
|
237
|
+
format=self.format,
|
238
|
+
)
|
239
|
+
)
|
240
|
+
|
241
|
+
page += 1
|
@@ -3,14 +3,14 @@ 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
|
-
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
13
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
14
|
from tbr_deal_finder.utils import currency_to_float
|
15
15
|
|
16
16
|
|
@@ -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,7 +80,9 @@ 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
87
|
|
81
88
|
async with semaphore:
|
@@ -90,15 +97,25 @@ class LibroFM(Retailer):
|
|
90
97
|
)
|
91
98
|
|
92
99
|
for b in response["audiobook_collection"]["audiobooks"]:
|
93
|
-
|
100
|
+
normalized_authors = get_normalized_authors(b["authors"])
|
101
|
+
|
102
|
+
if (
|
103
|
+
title == b["title"]
|
104
|
+
and is_matching_authors(book.normalized_authors, normalized_authors)
|
105
|
+
):
|
94
106
|
book.audiobook_isbn = b["isbn"]
|
95
|
-
|
107
|
+
break
|
96
108
|
|
97
|
-
return
|
109
|
+
return book
|
98
110
|
|
99
111
|
async def get_book(
|
100
112
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
101
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
|
+
|
102
119
|
if not target.audiobook_isbn:
|
103
120
|
return Book(
|
104
121
|
retailer=self.name,
|
@@ -122,7 +139,7 @@ class LibroFM(Retailer):
|
|
122
139
|
retailer=self.name,
|
123
140
|
title=target.title,
|
124
141
|
authors=target.authors,
|
125
|
-
list_price=
|
142
|
+
list_price=target.audiobook_list_price,
|
126
143
|
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
127
144
|
timepoint=runtime,
|
128
145
|
format=BookFormat.AUDIOBOOK,
|
@@ -138,3 +155,68 @@ class LibroFM(Retailer):
|
|
138
155
|
format=BookFormat.AUDIOBOOK,
|
139
156
|
exists=False,
|
140
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=page)
|
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
|
192
|
+
|
193
|
+
async def get_library(self, config: Config) -> list[Book]:
|
194
|
+
library_books = []
|
195
|
+
|
196
|
+
page = 1
|
197
|
+
total_pages = 1
|
198
|
+
while page <= total_pages:
|
199
|
+
response = await self.make_request(
|
200
|
+
f"api/v10/library",
|
201
|
+
"GET",
|
202
|
+
params=dict(page=page)
|
203
|
+
)
|
204
|
+
|
205
|
+
for book in response.get("audiobooks", []):
|
206
|
+
library_books.append(
|
207
|
+
Book(
|
208
|
+
retailer=self.name,
|
209
|
+
title=book["title"],
|
210
|
+
authors=", ".join(book["authors"]),
|
211
|
+
list_price=1,
|
212
|
+
current_price=1,
|
213
|
+
timepoint=config.run_time,
|
214
|
+
format=self.format,
|
215
|
+
audiobook_isbn=book["isbn"],
|
216
|
+
)
|
217
|
+
)
|
218
|
+
|
219
|
+
page += 1
|
220
|
+
total_pages = response["total_pages"]
|
221
|
+
|
222
|
+
return library_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,9 @@ 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
|
|
53
|
+
async def get_library(self, config: Config) -> list[Book]:
|
54
|
+
raise NotImplementedError
|
37
55
|
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -6,9 +6,10 @@ 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.owned_books import get_owned_books
|
12
|
+
from tbr_deal_finder.tracked_books import get_tbr_books
|
12
13
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
13
14
|
from tbr_deal_finder.retailer.models import Retailer
|
14
15
|
from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
|
@@ -41,7 +42,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
41
42
|
# Any remaining values in active_deal_map mean that
|
42
43
|
# it wasn't found and should be marked for deletion
|
43
44
|
for deal in active_deal_map.values():
|
44
|
-
echo_warning(f"{str(deal)} is no longer active")
|
45
|
+
echo_warning(f"{str(deal)} is no longer active\n")
|
45
46
|
deal.timepoint = config.run_time
|
46
47
|
deal.deleted = True
|
47
48
|
df_data.append(deal.dict())
|
@@ -55,25 +56,10 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
55
56
|
db_conn.unregister("_df")
|
56
57
|
|
57
58
|
|
58
|
-
def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
|
59
|
-
response = []
|
60
|
-
found_book_set = {f'{b.title} - {b.authors}' for b in found_books}
|
61
|
-
for book in all_books:
|
62
|
-
if ":" not in book.title:
|
63
|
-
continue
|
64
|
-
|
65
|
-
if f'{book.title} - {book.authors}' not in found_book_set:
|
66
|
-
alt_book = copy.deepcopy(book)
|
67
|
-
alt_book.title = alt_book.title.split(":")[0]
|
68
|
-
response.append(alt_book)
|
69
|
-
|
70
|
-
return response
|
71
|
-
|
72
|
-
|
73
59
|
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
60
|
"""Get Books with limited concurrency.
|
75
61
|
|
76
|
-
- Creates
|
62
|
+
- Creates semaphore to limit concurrent requests.
|
77
63
|
- Creates a list to store the response.
|
78
64
|
- Creates a list to store unresolved books.
|
79
65
|
|
@@ -100,13 +86,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
100
86
|
elif not book.exists:
|
101
87
|
unresolved_books.append(book)
|
102
88
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
elif unresolved_books:
|
107
|
-
click.echo()
|
108
|
-
for book in unresolved_books:
|
109
|
-
echo_info(f"{book.title} by {book.authors} not found")
|
89
|
+
click.echo()
|
90
|
+
for book in unresolved_books:
|
91
|
+
echo_info(f"{book.title} by {book.authors} not found")
|
110
92
|
|
111
93
|
return response
|
112
94
|
|
@@ -145,6 +127,34 @@ def _apply_proper_list_prices(books: list[Book]):
|
|
145
127
|
book.list_price = max(book.current_price, list_price)
|
146
128
|
|
147
129
|
|
130
|
+
def _get_retailer_relevant_tbr_books(
|
131
|
+
retailer: Retailer,
|
132
|
+
books: list[Book],
|
133
|
+
owned_book_title_map: dict[str, dict[BookFormat, Book]],
|
134
|
+
) -> list[Book]:
|
135
|
+
"""
|
136
|
+
Don't check on deals in a specified format that does not match the format the retailer sells.
|
137
|
+
Also, don't check on deals for a book if a copy is already owned in that same format.
|
138
|
+
|
139
|
+
:param retailer:
|
140
|
+
:param books:
|
141
|
+
:param owned_book_title_map:
|
142
|
+
:return:
|
143
|
+
"""
|
144
|
+
|
145
|
+
response = []
|
146
|
+
|
147
|
+
for book in books:
|
148
|
+
owned_versions = owned_book_title_map[book.full_title_str]
|
149
|
+
if (
|
150
|
+
(book.format == BookFormat.NA or book.format == retailer.format)
|
151
|
+
and retailer.format not in owned_versions
|
152
|
+
):
|
153
|
+
response.append(book)
|
154
|
+
|
155
|
+
return response
|
156
|
+
|
157
|
+
|
148
158
|
async def get_latest_deals(config: Config):
|
149
159
|
"""
|
150
160
|
Fetches the latest book deals from all tracked retailers for the user's TBR list.
|
@@ -163,14 +173,26 @@ async def get_latest_deals(config: Config):
|
|
163
173
|
"""
|
164
174
|
|
165
175
|
books: list[Book] = []
|
166
|
-
tbr_books = get_tbr_books(config)
|
176
|
+
tbr_books = await get_tbr_books(config)
|
177
|
+
owned_books = await get_owned_books(config)
|
178
|
+
|
179
|
+
owned_book_title_map: dict[str, dict[BookFormat, Book]] = defaultdict(dict)
|
180
|
+
for book in owned_books:
|
181
|
+
owned_book_title_map[book.full_title_str][book.format] = book
|
182
|
+
|
167
183
|
for retailer_str in config.tracked_retailers:
|
168
184
|
retailer = RETAILER_MAP[retailer_str]()
|
169
185
|
await retailer.set_auth()
|
170
186
|
|
187
|
+
relevant_tbr_books = _get_retailer_relevant_tbr_books(
|
188
|
+
retailer,
|
189
|
+
tbr_books,
|
190
|
+
owned_book_title_map,
|
191
|
+
)
|
192
|
+
|
171
193
|
echo_info(f"Getting deals from {retailer.name}")
|
172
194
|
click.echo("\n---------------")
|
173
|
-
books.extend(await _get_books(config, retailer,
|
195
|
+
books.extend(await _get_books(config, retailer, relevant_tbr_books))
|
174
196
|
click.echo("---------------\n")
|
175
197
|
|
176
198
|
_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
|
+
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.7
|
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
|
@@ -9,6 +9,7 @@ Requires-Dist: aiohttp>=3.12.14
|
|
9
9
|
Requires-Dist: audible==0.8.2
|
10
10
|
Requires-Dist: click>=8.2.1
|
11
11
|
Requires-Dist: duckdb>=1.3.2
|
12
|
+
Requires-Dist: levenshtein>=0.27.1
|
12
13
|
Requires-Dist: pandas>=2.3.1
|
13
14
|
Requires-Dist: questionary>=2.1.0
|
14
15
|
Requires-Dist: tqdm>=4.67.1
|
@@ -17,15 +18,17 @@ Description-Content-Type: text/markdown
|
|
17
18
|
|
18
19
|
# tbr-deal-finder
|
19
20
|
|
20
|
-
Track price drops and find deals on books in your TBR (To Be Read)
|
21
|
+
Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
|
21
22
|
|
22
23
|
## Features
|
23
|
-
-
|
24
|
-
- Supports multiple of the library exports above
|
24
|
+
- Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
25
|
+
- Supports multiple of the library exports above
|
26
|
+
- Tracks deals on the wishlist of all your configured retailers like audible
|
25
27
|
- Supports multiple locales and currencies
|
26
|
-
-
|
28
|
+
- Find the latest and active deals from supported sellers
|
27
29
|
- Simple CLI interface for setup and usage
|
28
30
|
- Only get notified for new deals or view all active deals
|
31
|
+
- Filters out books you already own to prevent purchasing the same book on multiple retailers
|
29
32
|
|
30
33
|
## Support
|
31
34
|
|
@@ -66,7 +69,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
66
69
|
https://docs.astral.sh/uv/getting-started/installation/
|
67
70
|
|
68
71
|
## Configuration
|
69
|
-
This tool
|
72
|
+
This tool can use the csv generated by the app you use to track your TBRs.
|
70
73
|
Here are the steps to get your export.
|
71
74
|
|
72
75
|
### StoryGraph
|