tbr-deal-finder 0.1.7__py3-none-any.whl → 0.2.0__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 -56
- tbr_deal_finder/cli.py +5 -3
- tbr_deal_finder/config.py +10 -0
- tbr_deal_finder/migrations.py +16 -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/__init__.py +2 -0
- tbr_deal_finder/retailer/amazon.py +85 -0
- tbr_deal_finder/retailer/audible.py +13 -100
- tbr_deal_finder/retailer/chirp.py +34 -55
- tbr_deal_finder/retailer/kindle.py +141 -0
- tbr_deal_finder/retailer/librofm.py +27 -53
- tbr_deal_finder/retailer/models.py +37 -2
- tbr_deal_finder/retailer_deal.py +9 -19
- tbr_deal_finder/tracked_books.py +258 -39
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/METADATA +5 -2
- tbr_deal_finder-0.2.0.dist-info/RECORD +24 -0
- tbr_deal_finder/library_exports.py +0 -208
- tbr_deal_finder-0.1.7.dist-info/RECORD +0 -23
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,141 @@
|
|
1
|
+
import asyncio
|
2
|
+
import readline # type: ignore
|
3
|
+
|
4
|
+
from tbr_deal_finder.config import Config
|
5
|
+
from tbr_deal_finder.retailer.amazon import Amazon
|
6
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_title, get_normalized_authors, is_matching_authors
|
7
|
+
|
8
|
+
|
9
|
+
class Kindle(Amazon):
|
10
|
+
|
11
|
+
@property
|
12
|
+
def name(self) -> str:
|
13
|
+
return "Kindle"
|
14
|
+
|
15
|
+
@property
|
16
|
+
def format(self) -> BookFormat:
|
17
|
+
return BookFormat.EBOOK
|
18
|
+
|
19
|
+
def _get_base_url(self) -> str:
|
20
|
+
return f"https://www.amazon.{self._auth.locale.domain}"
|
21
|
+
|
22
|
+
async def get_book_asin(
|
23
|
+
self,
|
24
|
+
target: Book,
|
25
|
+
semaphore: asyncio.Semaphore
|
26
|
+
) -> Book:
|
27
|
+
title = target.title
|
28
|
+
async with semaphore:
|
29
|
+
match = await self._client.get(
|
30
|
+
f"{self._get_base_url()}/kindle-dbs/kws?userCode=AndroidKin&deviceType=A3VNNDO1I14V03&node=2671536011&excludedNodes=&page=1&size=20&autoSpellCheck=1&rank=r",
|
31
|
+
query=title,
|
32
|
+
)
|
33
|
+
|
34
|
+
for product in match.get("items", []):
|
35
|
+
normalized_authors = get_normalized_authors(product["authors"])
|
36
|
+
if (
|
37
|
+
get_normalized_title(product["title"]) != title
|
38
|
+
or not is_matching_authors(target.normalized_authors, normalized_authors)
|
39
|
+
):
|
40
|
+
continue
|
41
|
+
try:
|
42
|
+
target.ebook_asin = product["asin"]
|
43
|
+
break
|
44
|
+
except KeyError:
|
45
|
+
continue
|
46
|
+
|
47
|
+
return target
|
48
|
+
|
49
|
+
async def get_book(
|
50
|
+
self,
|
51
|
+
target: Book,
|
52
|
+
semaphore: asyncio.Semaphore
|
53
|
+
) -> Book:
|
54
|
+
target.exists = False
|
55
|
+
|
56
|
+
if not target.ebook_asin:
|
57
|
+
return target
|
58
|
+
|
59
|
+
asin = target.ebook_asin
|
60
|
+
async with semaphore:
|
61
|
+
match = await self._client.get(
|
62
|
+
f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
|
63
|
+
headers={"x-client-id": "kindle-android-deeplink"},
|
64
|
+
)
|
65
|
+
products = match.get("resources", [])
|
66
|
+
if not products:
|
67
|
+
return target
|
68
|
+
|
69
|
+
actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
|
70
|
+
if not actions:
|
71
|
+
return target
|
72
|
+
|
73
|
+
for action in actions:
|
74
|
+
if "printListPrice" in action["offer"]:
|
75
|
+
target.list_price = action["offer"]["printListPrice"]["value"]
|
76
|
+
target.current_price = action["offer"]["digitalPrice"]["value"]
|
77
|
+
target.exists = True
|
78
|
+
break
|
79
|
+
|
80
|
+
return target
|
81
|
+
|
82
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
83
|
+
"""Not currently supported
|
84
|
+
|
85
|
+
Getting this info is proving to be a nightmare
|
86
|
+
|
87
|
+
:param config:
|
88
|
+
:return:
|
89
|
+
"""
|
90
|
+
return []
|
91
|
+
|
92
|
+
async def get_library(self, config: Config) -> list[Book]:
|
93
|
+
"""Not currently supported
|
94
|
+
|
95
|
+
Getting this info is proving to be a nightmare
|
96
|
+
|
97
|
+
:param config:
|
98
|
+
:return:
|
99
|
+
"""
|
100
|
+
return []
|
101
|
+
|
102
|
+
async def _get_library_attempt(self, config: Config) -> list[Book]:
|
103
|
+
"""This should work, but it's returning a redirect
|
104
|
+
|
105
|
+
The user is already authenticated at this point, so I'm not sure what's happening
|
106
|
+
"""
|
107
|
+
response = []
|
108
|
+
pagination_token = 0
|
109
|
+
total_pages = 1
|
110
|
+
|
111
|
+
while pagination_token < total_pages:
|
112
|
+
optional_params = {}
|
113
|
+
if pagination_token:
|
114
|
+
optional_params["paginationToken"] = pagination_token
|
115
|
+
|
116
|
+
response = await self._client.get(
|
117
|
+
"https://read.amazon.com/kindle-library/search",
|
118
|
+
query="",
|
119
|
+
libraryType="BOOKS",
|
120
|
+
sortType="recency",
|
121
|
+
resourceType="EBOOK",
|
122
|
+
querySize=5,
|
123
|
+
**optional_params
|
124
|
+
)
|
125
|
+
|
126
|
+
if "paginationToken" in response:
|
127
|
+
total_pages = int(response["paginationToken"])
|
128
|
+
|
129
|
+
for book in response["itemsList"]:
|
130
|
+
response.append(
|
131
|
+
Book(
|
132
|
+
retailer=self.name,
|
133
|
+
title = book["title"],
|
134
|
+
authors = book["authors"][0],
|
135
|
+
format=self.format,
|
136
|
+
timepoint=config.run_time,
|
137
|
+
ebook_asin=book["asin"],
|
138
|
+
)
|
139
|
+
)
|
140
|
+
|
141
|
+
return response
|
@@ -4,17 +4,16 @@ import os
|
|
4
4
|
import urllib.parse
|
5
5
|
from datetime import datetime, timedelta
|
6
6
|
|
7
|
-
import aiohttp
|
8
7
|
import click
|
9
8
|
|
10
9
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
10
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.retailer.models import Retailer
|
13
|
-
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
11
|
+
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
|
12
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors, get_normalized_title
|
14
13
|
from tbr_deal_finder.utils import currency_to_float
|
15
14
|
|
16
15
|
|
17
|
-
class LibroFM(Retailer):
|
16
|
+
class LibroFM(AioHttpSession, Retailer):
|
18
17
|
BASE_URL = "https://libro.fm"
|
19
18
|
USER_AGENT = "okhttp/3.14.9"
|
20
19
|
USER_AGENT_DOWNLOAD = (
|
@@ -27,6 +26,8 @@ class LibroFM(Retailer):
|
|
27
26
|
)
|
28
27
|
|
29
28
|
def __init__(self):
|
29
|
+
super().__init__()
|
30
|
+
|
30
31
|
self.auth_token = None
|
31
32
|
|
32
33
|
@property
|
@@ -44,17 +45,17 @@ class LibroFM(Retailer):
|
|
44
45
|
if self.auth_token:
|
45
46
|
headers["authorization"] = f"Bearer {self.auth_token}"
|
46
47
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
48
|
+
session = await self._get_session()
|
49
|
+
response = await session.request(
|
50
|
+
request_type.upper(),
|
51
|
+
url,
|
52
|
+
headers=headers,
|
53
|
+
**kwargs
|
54
|
+
)
|
55
|
+
if response.ok:
|
56
|
+
return await response.json()
|
57
|
+
else:
|
58
|
+
return {}
|
58
59
|
|
59
60
|
async def set_auth(self):
|
60
61
|
auth_path = TBR_DEALS_PATH.joinpath("libro_fm.json")
|
@@ -76,11 +77,11 @@ class LibroFM(Retailer):
|
|
76
77
|
"password": click.prompt("Libro FM Password", hide_input=True),
|
77
78
|
}
|
78
79
|
)
|
79
|
-
self.auth_token = response
|
80
|
+
self.auth_token = response["access_token"]
|
80
81
|
with open(auth_path, "w") as f:
|
81
82
|
json.dump(response, f)
|
82
83
|
|
83
|
-
async def get_book_isbn(self, book: Book,
|
84
|
+
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
|
84
85
|
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
85
86
|
|
86
87
|
title = book.title
|
@@ -100,7 +101,7 @@ class LibroFM(Retailer):
|
|
100
101
|
normalized_authors = get_normalized_authors(b["authors"])
|
101
102
|
|
102
103
|
if (
|
103
|
-
title == b["title"]
|
104
|
+
title == get_normalized_title(b["title"])
|
104
105
|
and is_matching_authors(book.normalized_authors, normalized_authors)
|
105
106
|
):
|
106
107
|
book.audiobook_isbn = b["isbn"]
|
@@ -109,24 +110,11 @@ class LibroFM(Retailer):
|
|
109
110
|
return book
|
110
111
|
|
111
112
|
async def get_book(
|
112
|
-
self, target: Book,
|
113
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
113
114
|
) -> 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
115
|
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
|
-
)
|
116
|
+
target.exists = False
|
117
|
+
return target
|
130
118
|
|
131
119
|
async with semaphore:
|
132
120
|
response = await self.make_request(
|
@@ -135,26 +123,12 @@ class LibroFM(Retailer):
|
|
135
123
|
)
|
136
124
|
|
137
125
|
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
|
-
)
|
126
|
+
target.list_price = target.audiobook_list_price
|
127
|
+
target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
|
128
|
+
return target
|
147
129
|
|
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
|
-
)
|
130
|
+
target.exists = False
|
131
|
+
return target
|
158
132
|
|
159
133
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
160
134
|
wishlist_books = []
|
@@ -1,6 +1,7 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
|
-
|
3
|
+
|
4
|
+
import aiohttp
|
4
5
|
|
5
6
|
from tbr_deal_finder.book import Book, BookFormat
|
6
7
|
from tbr_deal_finder.config import Config
|
@@ -29,7 +30,7 @@ class Retailer(abc.ABC):
|
|
29
30
|
raise NotImplementedError
|
30
31
|
|
31
32
|
async def get_book(
|
32
|
-
self, target: Book,
|
33
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
33
34
|
) -> Book:
|
34
35
|
"""Get book information from the retailer.
|
35
36
|
|
@@ -53,3 +54,37 @@ class Retailer(abc.ABC):
|
|
53
54
|
async def get_library(self, config: Config) -> list[Book]:
|
54
55
|
raise NotImplementedError
|
55
56
|
|
57
|
+
|
58
|
+
class AioHttpSession:
|
59
|
+
|
60
|
+
def __init__(self):
|
61
|
+
self._session = None
|
62
|
+
|
63
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
64
|
+
"""Get or create the session."""
|
65
|
+
if self._session is None or self._session.closed:
|
66
|
+
self._session = aiohttp.ClientSession()
|
67
|
+
return self._session
|
68
|
+
|
69
|
+
async def close(self):
|
70
|
+
"""Close the session when done."""
|
71
|
+
if self._session and not self._session.closed:
|
72
|
+
await self._session.close()
|
73
|
+
|
74
|
+
async def __aenter__(self):
|
75
|
+
"""Support async context manager."""
|
76
|
+
return self
|
77
|
+
|
78
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
79
|
+
"""Cleanup on context manager exit."""
|
80
|
+
await self.close()
|
81
|
+
|
82
|
+
def __del__(self):
|
83
|
+
"""Attempt to close session on garbage collection."""
|
84
|
+
if self._session and not self._session.closed:
|
85
|
+
try:
|
86
|
+
asyncio.create_task(self._session.close())
|
87
|
+
except RuntimeError:
|
88
|
+
# Event loop might be closed
|
89
|
+
pass
|
90
|
+
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -8,7 +8,6 @@ from tqdm.asyncio import tqdm_asyncio
|
|
8
8
|
|
9
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.owned_books import get_owned_books
|
12
11
|
from tbr_deal_finder.tracked_books import get_tbr_books
|
13
12
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
14
13
|
from tbr_deal_finder.retailer.models import Retailer
|
@@ -22,9 +21,9 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
22
21
|
:param new_deals:
|
23
22
|
"""
|
24
23
|
|
25
|
-
# This could be done using a temp table for the new deals, but that feels like overkill
|
26
|
-
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR
|
27
|
-
# 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.
|
28
27
|
active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
|
29
28
|
# Dirty trick to ensure uniqueness in request
|
30
29
|
new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
|
@@ -74,9 +73,13 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
74
73
|
semaphore = asyncio.Semaphore(10)
|
75
74
|
response = []
|
76
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
|
77
80
|
|
78
81
|
tasks = [
|
79
|
-
retailer.get_book(
|
82
|
+
retailer.get_book(book, semaphore)
|
80
83
|
for book in books
|
81
84
|
]
|
82
85
|
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
@@ -130,26 +133,19 @@ def _apply_proper_list_prices(books: list[Book]):
|
|
130
133
|
def _get_retailer_relevant_tbr_books(
|
131
134
|
retailer: Retailer,
|
132
135
|
books: list[Book],
|
133
|
-
owned_book_title_map: dict[str, dict[BookFormat, Book]],
|
134
136
|
) -> list[Book]:
|
135
137
|
"""
|
136
138
|
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
|
|
139
140
|
:param retailer:
|
140
141
|
:param books:
|
141
|
-
:param owned_book_title_map:
|
142
142
|
:return:
|
143
143
|
"""
|
144
144
|
|
145
145
|
response = []
|
146
146
|
|
147
147
|
for book in books:
|
148
|
-
|
149
|
-
if (
|
150
|
-
(book.format == BookFormat.NA or book.format == retailer.format)
|
151
|
-
and retailer.format not in owned_versions
|
152
|
-
):
|
148
|
+
if book.format == BookFormat.NA or book.format == retailer.format:
|
153
149
|
response.append(book)
|
154
150
|
|
155
151
|
return response
|
@@ -174,11 +170,6 @@ async def get_latest_deals(config: Config):
|
|
174
170
|
|
175
171
|
books: list[Book] = []
|
176
172
|
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
173
|
|
183
174
|
for retailer_str in config.tracked_retailers:
|
184
175
|
retailer = RETAILER_MAP[retailer_str]()
|
@@ -187,7 +178,6 @@ async def get_latest_deals(config: Config):
|
|
187
178
|
relevant_tbr_books = _get_retailer_relevant_tbr_books(
|
188
179
|
retailer,
|
189
180
|
tbr_books,
|
190
|
-
owned_book_title_map,
|
191
181
|
)
|
192
182
|
|
193
183
|
echo_info(f"Getting deals from {retailer.name}")
|