tbr-deal-finder 0.1.6__py3-none-any.whl → 0.1.8__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 +107 -47
- tbr_deal_finder/cli.py +26 -25
- tbr_deal_finder/config.py +20 -2
- tbr_deal_finder/migrations.py +16 -0
- tbr_deal_finder/owned_books.py +18 -0
- tbr_deal_finder/queries/get_active_deals.sql +1 -1
- tbr_deal_finder/queries/get_deals_found_at.sql +1 -1
- tbr_deal_finder/retailer/amazon.py +79 -0
- tbr_deal_finder/retailer/audible.py +47 -97
- tbr_deal_finder/retailer/chirp.py +69 -33
- tbr_deal_finder/retailer/librofm.py +44 -40
- tbr_deal_finder/retailer/models.py +3 -2
- tbr_deal_finder/retailer_deal.py +37 -34
- tbr_deal_finder/tracked_books.py +239 -39
- {tbr_deal_finder-0.1.6.dist-info → tbr_deal_finder-0.1.8.dist-info}/METADATA +8 -6
- tbr_deal_finder-0.1.8.dist-info/RECORD +23 -0
- tbr_deal_finder/library_exports.py +0 -205
- tbr_deal_finder-0.1.6.dist-info/RECORD +0 -22
- {tbr_deal_finder-0.1.6.dist-info → tbr_deal_finder-0.1.8.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.6.dist-info → tbr_deal_finder-0.1.8.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.6.dist-info → tbr_deal_finder-0.1.8.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@ import asyncio
|
|
2
2
|
import json
|
3
3
|
import os
|
4
4
|
from datetime import datetime, timedelta
|
5
|
+
from textwrap import dedent
|
5
6
|
|
6
7
|
import aiohttp
|
7
8
|
import click
|
@@ -9,7 +10,7 @@ import click
|
|
9
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
10
11
|
from tbr_deal_finder.config import Config
|
11
12
|
from tbr_deal_finder.retailer.models import Retailer
|
12
|
-
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
|
13
14
|
from tbr_deal_finder.utils import currency_to_float, echo_err
|
14
15
|
|
15
16
|
|
@@ -83,10 +84,9 @@ class Chirp(Retailer):
|
|
83
84
|
json.dump(response, f)
|
84
85
|
|
85
86
|
async def get_book(
|
86
|
-
self, target: Book,
|
87
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
87
88
|
) -> Book:
|
88
89
|
title = target.title
|
89
|
-
authors = target.authors
|
90
90
|
async with semaphore:
|
91
91
|
async with aiohttp.ClientSession() as http_client:
|
92
92
|
response = await http_client.request(
|
@@ -102,16 +102,8 @@ class Chirp(Retailer):
|
|
102
102
|
|
103
103
|
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
104
104
|
if not audiobooks:
|
105
|
-
|
106
|
-
|
107
|
-
title=title,
|
108
|
-
authors=authors,
|
109
|
-
list_price=0,
|
110
|
-
current_price=0,
|
111
|
-
timepoint=runtime,
|
112
|
-
format=BookFormat.AUDIOBOOK,
|
113
|
-
exists=False,
|
114
|
-
)
|
105
|
+
target.exists = False
|
106
|
+
return target
|
115
107
|
|
116
108
|
for book in audiobooks:
|
117
109
|
if not book["currentProduct"]:
|
@@ -120,28 +112,14 @@ class Chirp(Retailer):
|
|
120
112
|
normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
|
121
113
|
if (
|
122
114
|
book["displayTitle"] == title
|
123
|
-
and
|
115
|
+
and is_matching_authors(target.normalized_authors, normalized_authors)
|
124
116
|
):
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
authors=authors,
|
129
|
-
list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
|
130
|
-
current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
|
131
|
-
timepoint=runtime,
|
132
|
-
format=BookFormat.AUDIOBOOK,
|
133
|
-
)
|
117
|
+
target.list_price = currency_to_float(book["currentProduct"]["listingPrice"])
|
118
|
+
target.current_price = currency_to_float(book["currentProduct"]["discountPrice"])
|
119
|
+
return target
|
134
120
|
|
135
|
-
|
136
|
-
|
137
|
-
title=title,
|
138
|
-
authors=target.authors,
|
139
|
-
list_price=0,
|
140
|
-
current_price=0,
|
141
|
-
timepoint=runtime,
|
142
|
-
format=BookFormat.AUDIOBOOK,
|
143
|
-
exists=False,
|
144
|
-
)
|
121
|
+
target.exists = False
|
122
|
+
return target
|
145
123
|
|
146
124
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
147
125
|
wishlist_books = []
|
@@ -180,3 +158,61 @@ class Chirp(Retailer):
|
|
180
158
|
)
|
181
159
|
|
182
160
|
page += 1
|
161
|
+
|
162
|
+
async def get_library(self, config: Config) -> list[Book]:
|
163
|
+
library_books = []
|
164
|
+
page = 1
|
165
|
+
query = dedent("""
|
166
|
+
query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
|
167
|
+
currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
|
168
|
+
audiobook {
|
169
|
+
id
|
170
|
+
allAuthors{name}
|
171
|
+
displayTitle
|
172
|
+
displayAuthors
|
173
|
+
displayNarrators
|
174
|
+
durationMs
|
175
|
+
description
|
176
|
+
publisher
|
177
|
+
}
|
178
|
+
archived
|
179
|
+
playable
|
180
|
+
finishedAt
|
181
|
+
currentOverallOffsetMs
|
182
|
+
}
|
183
|
+
}
|
184
|
+
""")
|
185
|
+
|
186
|
+
while True:
|
187
|
+
response = await self.make_request(
|
188
|
+
"POST",
|
189
|
+
json={
|
190
|
+
"query": query,
|
191
|
+
"variables": {"page": page, "pageSize": 15},
|
192
|
+
"operationName": "AndroidCurrentUserAudiobooks"
|
193
|
+
}
|
194
|
+
)
|
195
|
+
|
196
|
+
audiobooks = response.get(
|
197
|
+
"data", {}
|
198
|
+
).get("currentUserAudiobooks", [])
|
199
|
+
|
200
|
+
if not audiobooks:
|
201
|
+
return library_books
|
202
|
+
|
203
|
+
for book in audiobooks:
|
204
|
+
audiobook = book["audiobook"]
|
205
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
206
|
+
library_books.append(
|
207
|
+
Book(
|
208
|
+
retailer=self.name,
|
209
|
+
title=audiobook["displayTitle"],
|
210
|
+
authors=", ".join(authors),
|
211
|
+
list_price=1,
|
212
|
+
current_price=1,
|
213
|
+
timepoint=config.run_time,
|
214
|
+
format=self.format,
|
215
|
+
)
|
216
|
+
)
|
217
|
+
|
218
|
+
page += 1
|
@@ -10,7 +10,7 @@ import click
|
|
10
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
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
|
|
@@ -76,11 +76,11 @@ class LibroFM(Retailer):
|
|
76
76
|
"password": click.prompt("Libro FM Password", hide_input=True),
|
77
77
|
}
|
78
78
|
)
|
79
|
-
self.auth_token = response
|
79
|
+
self.auth_token = response["access_token"]
|
80
80
|
with open(auth_path, "w") as f:
|
81
81
|
json.dump(response, f)
|
82
82
|
|
83
|
-
async def get_book_isbn(self, book: Book,
|
83
|
+
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
|
84
84
|
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
85
85
|
|
86
86
|
title = book.title
|
@@ -101,7 +101,7 @@ class LibroFM(Retailer):
|
|
101
101
|
|
102
102
|
if (
|
103
103
|
title == b["title"]
|
104
|
-
and
|
104
|
+
and is_matching_authors(book.normalized_authors, normalized_authors)
|
105
105
|
):
|
106
106
|
book.audiobook_isbn = b["isbn"]
|
107
107
|
break
|
@@ -109,24 +109,11 @@ class LibroFM(Retailer):
|
|
109
109
|
return book
|
110
110
|
|
111
111
|
async def get_book(
|
112
|
-
self, target: Book,
|
112
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
113
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
|
-
|
119
114
|
if not target.audiobook_isbn:
|
120
|
-
|
121
|
-
|
122
|
-
title=target.title,
|
123
|
-
authors=target.authors,
|
124
|
-
list_price=0,
|
125
|
-
current_price=0,
|
126
|
-
timepoint=runtime,
|
127
|
-
format=BookFormat.AUDIOBOOK,
|
128
|
-
exists=False,
|
129
|
-
)
|
115
|
+
target.exists = False
|
116
|
+
return target
|
130
117
|
|
131
118
|
async with semaphore:
|
132
119
|
response = await self.make_request(
|
@@ -135,26 +122,12 @@ class LibroFM(Retailer):
|
|
135
122
|
)
|
136
123
|
|
137
124
|
if response:
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
authors=target.authors,
|
142
|
-
list_price=target.audiobook_list_price,
|
143
|
-
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
144
|
-
timepoint=runtime,
|
145
|
-
format=BookFormat.AUDIOBOOK,
|
146
|
-
)
|
125
|
+
target.list_price = target.audiobook_list_price
|
126
|
+
target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
|
127
|
+
return target
|
147
128
|
|
148
|
-
|
149
|
-
|
150
|
-
title=target.title,
|
151
|
-
authors=target.authors,
|
152
|
-
list_price=0,
|
153
|
-
current_price=0,
|
154
|
-
timepoint=runtime,
|
155
|
-
format=BookFormat.AUDIOBOOK,
|
156
|
-
exists=False,
|
157
|
-
)
|
129
|
+
target.exists = False
|
130
|
+
return target
|
158
131
|
|
159
132
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
160
133
|
wishlist_books = []
|
@@ -165,7 +138,7 @@ class LibroFM(Retailer):
|
|
165
138
|
response = await self.make_request(
|
166
139
|
f"api/v10/explore/wishlist",
|
167
140
|
"GET",
|
168
|
-
params=dict(page=
|
141
|
+
params=dict(page=page)
|
169
142
|
)
|
170
143
|
wishlist = response.get("data", {}).get("wishlist", {})
|
171
144
|
if not wishlist:
|
@@ -189,3 +162,34 @@ class LibroFM(Retailer):
|
|
189
162
|
total_pages = wishlist["total_pages"]
|
190
163
|
|
191
164
|
return wishlist_books
|
165
|
+
|
166
|
+
async def get_library(self, config: Config) -> list[Book]:
|
167
|
+
library_books = []
|
168
|
+
|
169
|
+
page = 1
|
170
|
+
total_pages = 1
|
171
|
+
while page <= total_pages:
|
172
|
+
response = await self.make_request(
|
173
|
+
f"api/v10/library",
|
174
|
+
"GET",
|
175
|
+
params=dict(page=page)
|
176
|
+
)
|
177
|
+
|
178
|
+
for book in response.get("audiobooks", []):
|
179
|
+
library_books.append(
|
180
|
+
Book(
|
181
|
+
retailer=self.name,
|
182
|
+
title=book["title"],
|
183
|
+
authors=", ".join(book["authors"]),
|
184
|
+
list_price=1,
|
185
|
+
current_price=1,
|
186
|
+
timepoint=config.run_time,
|
187
|
+
format=self.format,
|
188
|
+
audiobook_isbn=book["isbn"],
|
189
|
+
)
|
190
|
+
)
|
191
|
+
|
192
|
+
page += 1
|
193
|
+
total_pages = response["total_pages"]
|
194
|
+
|
195
|
+
return library_books
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
|
-
from datetime import datetime
|
4
3
|
|
5
4
|
from tbr_deal_finder.book import Book, BookFormat
|
6
5
|
from tbr_deal_finder.config import Config
|
@@ -29,7 +28,7 @@ class Retailer(abc.ABC):
|
|
29
28
|
raise NotImplementedError
|
30
29
|
|
31
30
|
async def get_book(
|
32
|
-
self, target: Book,
|
31
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
33
32
|
) -> Book:
|
34
33
|
"""Get book information from the retailer.
|
35
34
|
|
@@ -50,4 +49,6 @@ class Retailer(abc.ABC):
|
|
50
49
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
51
50
|
raise NotImplementedError
|
52
51
|
|
52
|
+
async def get_library(self, config: Config) -> list[Book]:
|
53
|
+
raise NotImplementedError
|
53
54
|
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -21,9 +21,9 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
21
21
|
:param new_deals:
|
22
22
|
"""
|
23
23
|
|
24
|
-
# This could be done using a temp table for the new deals, but that feels like overkill
|
25
|
-
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR
|
26
|
-
# If it were any larger we'd have bigger problems.
|
24
|
+
# This could be done using a temp table for the new deals, but that feels like overkill.
|
25
|
+
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR.
|
26
|
+
# If it were any larger, we'd have bigger problems.
|
27
27
|
active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
|
28
28
|
# Dirty trick to ensure uniqueness in request
|
29
29
|
new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
|
@@ -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
|
-
echo_warning(f"{str(deal)} is no longer active")
|
44
|
+
echo_warning(f"{str(deal)} is no longer active\n")
|
45
45
|
deal.timepoint = config.run_time
|
46
46
|
deal.deleted = True
|
47
47
|
df_data.append(deal.dict())
|
@@ -55,21 +55,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
55
55
|
db_conn.unregister("_df")
|
56
56
|
|
57
57
|
|
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
58
|
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
59
|
"""Get Books with limited concurrency.
|
75
60
|
|
@@ -88,9 +73,13 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
88
73
|
semaphore = asyncio.Semaphore(10)
|
89
74
|
response = []
|
90
75
|
unresolved_books = []
|
76
|
+
books = [copy.deepcopy(book) for book in books]
|
77
|
+
for book in books:
|
78
|
+
book.retailer = retailer.name
|
79
|
+
book.format = retailer.format
|
91
80
|
|
92
81
|
tasks = [
|
93
|
-
retailer.get_book(
|
82
|
+
retailer.get_book(book, semaphore)
|
94
83
|
for book in books
|
95
84
|
]
|
96
85
|
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
@@ -100,13 +89,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
100
89
|
elif not book.exists:
|
101
90
|
unresolved_books.append(book)
|
102
91
|
|
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")
|
92
|
+
click.echo()
|
93
|
+
for book in unresolved_books:
|
94
|
+
echo_info(f"{book.title} by {book.authors} not found")
|
110
95
|
|
111
96
|
return response
|
112
97
|
|
@@ -145,6 +130,27 @@ def _apply_proper_list_prices(books: list[Book]):
|
|
145
130
|
book.list_price = max(book.current_price, list_price)
|
146
131
|
|
147
132
|
|
133
|
+
def _get_retailer_relevant_tbr_books(
|
134
|
+
retailer: Retailer,
|
135
|
+
books: list[Book],
|
136
|
+
) -> list[Book]:
|
137
|
+
"""
|
138
|
+
Don't check on deals in a specified format that does not match the format the retailer sells.
|
139
|
+
|
140
|
+
:param retailer:
|
141
|
+
:param books:
|
142
|
+
:return:
|
143
|
+
"""
|
144
|
+
|
145
|
+
response = []
|
146
|
+
|
147
|
+
for book in books:
|
148
|
+
if book.format == BookFormat.NA or book.format == retailer.format:
|
149
|
+
response.append(book)
|
150
|
+
|
151
|
+
return response
|
152
|
+
|
153
|
+
|
148
154
|
async def get_latest_deals(config: Config):
|
149
155
|
"""
|
150
156
|
Fetches the latest book deals from all tracked retailers for the user's TBR list.
|
@@ -169,13 +175,10 @@ async def get_latest_deals(config: Config):
|
|
169
175
|
retailer = RETAILER_MAP[retailer_str]()
|
170
176
|
await retailer.set_auth()
|
171
177
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
for book in tbr_books
|
177
|
-
if book.format == BookFormat.NA or book.format == retailer.format
|
178
|
-
]
|
178
|
+
relevant_tbr_books = _get_retailer_relevant_tbr_books(
|
179
|
+
retailer,
|
180
|
+
tbr_books,
|
181
|
+
)
|
179
182
|
|
180
183
|
echo_info(f"Getting deals from {retailer.name}")
|
181
184
|
click.echo("\n---------------")
|